1use crate::common::PolicyRef;
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
22use kube::CustomResource;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
28#[kube(
29 group = "kopiur.home-operations.com",
30 version = "v1alpha1",
31 kind = "SnapshotSchedule",
32 namespaced,
33 status = "SnapshotScheduleStatus",
34 shortname = "kopiasched",
35 category = "kopiur",
36 printcolumn = r#"{"name":"Config","type":"string","jsonPath":".spec.policyRef.name"}"#,
37 printcolumn = r#"{"name":"Schedule","type":"string","jsonPath":".spec.schedule.cron"}"#,
38 printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.schedule.suspend"}"#,
39 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
40)]
41#[schemars(extend("x-kubernetes-validations" = [{
44 "rule": "[has(self.policyRef), has(self.policySelector)].filter(x, x).size() == 1",
45 "message": "exactly one of policyRef or policySelector"
46}]))]
47#[serde(rename_all = "camelCase")]
48pub struct SnapshotScheduleSpec {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub policy_ref: Option<PolicyRef>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub policy_selector: Option<LabelSelector>,
55 pub schedule: ScheduleSpec,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 #[schemars(default = "default_failed_jobs_history_limit")]
61 pub failed_jobs_history_limit: Option<u32>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub deletion: Option<ScheduleDeletionSpec>,
65}
66
67#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct ScheduleDeletionSpec {
73 #[serde(default = "default_on_schedule_delete")]
77 #[schemars(default = "default_on_schedule_delete")]
78 pub on_schedule_delete: crate::common::ScheduleDeletePolicy,
79}
80
81fn default_on_schedule_delete() -> crate::common::ScheduleDeletePolicy {
82 crate::common::ScheduleDeletePolicy::Retain
83}
84
85pub fn effective_on_schedule_delete(
90 deletion: Option<&ScheduleDeletionSpec>,
91) -> crate::common::ScheduleDeletePolicy {
92 deletion.map(|d| d.on_schedule_delete).unwrap_or_default()
93}
94
95fn default_failed_jobs_history_limit() -> Option<u32> {
101 Some(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
102}
103
104fn default_run_on_create() -> bool {
109 false
110}
111
112fn default_concurrency_policy() -> ConcurrencyPolicy {
115 ConcurrencyPolicy::Forbid
116}
117
118#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ScheduleSpec {
122 pub cron: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub jitter: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub timezone: Option<String>,
130 #[serde(default = "default_run_on_create")]
132 #[schemars(default = "default_run_on_create")]
133 pub run_on_create: bool,
134 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
136 pub suspend: bool,
137 #[serde(default = "default_concurrency_policy")]
139 #[schemars(default = "default_concurrency_policy")]
140 pub concurrency_policy: ConcurrencyPolicy,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub starting_deadline_seconds: Option<i64>,
144}
145
146#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
160pub enum ConcurrencyPolicy {
161 #[default]
165 Forbid,
166 Allow,
168 Replace,
171}
172
173#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
175#[serde(rename_all = "camelCase")]
176pub struct SnapshotScheduleStatus {
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub observed_generation: Option<i64>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub last_schedule: Option<ScheduleRef>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub next_schedule: Option<ScheduleRef>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub last_successful_schedule: Option<ScheduleRef>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub consecutive_failures: Option<i64>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub conditions: Vec<Condition>,
195}
196
197#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
199#[serde(rename_all = "camelCase")]
200pub struct ScheduleRef {
201 #[serde(
203 default,
204 alias = "scheduledAt",
205 skip_serializing_if = "Option::is_none"
206 )]
207 pub at: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub snapshot_ref: Option<SnapshotReference>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub timezone: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub jitter: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub cron: Option<String>,
247}
248
249#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
251#[serde(rename_all = "camelCase")]
252pub struct SnapshotReference {
253 pub name: String,
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::testutil::from_yaml;
261 use kube::core::CustomResourceExt;
262
263 #[test]
264 fn backup_schedule_crd_metadata_is_correct() {
265 let crd = SnapshotSchedule::crd();
266 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
267 assert_eq!(crd.spec.names.kind, "SnapshotSchedule");
268 assert_eq!(crd.spec.scope, "Namespaced");
269 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
270 }
271
272 #[test]
273 fn failed_jobs_history_limit_schema_default_matches_the_constant() {
274 let crd = SnapshotSchedule::crd();
277 let json = serde_json::to_value(&crd).unwrap();
278 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
279 assert_eq!(
280 spec["properties"]["failedJobsHistoryLimit"]["default"],
281 serde_json::json!(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
282 );
283 assert_eq!(
284 crate::consts::effective_failed_jobs_history_limit(None),
285 crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT
286 );
287 }
288
289 #[test]
290 fn schedule_deletion_on_schedule_delete_schema_default_is_retain() {
291 let crd = SnapshotSchedule::crd();
295 let json = serde_json::to_value(&crd).unwrap();
296 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
297 assert_eq!(
298 spec["properties"]["deletion"]["properties"]["onScheduleDelete"]["default"],
299 serde_json::json!("Retain")
300 );
301 assert_eq!(
302 effective_on_schedule_delete(None),
303 crate::common::ScheduleDeletePolicy::Retain
304 );
305 }
306
307 #[test]
308 fn schedule_deletion_round_trips_and_absent_stays_none() {
309 use crate::common::ScheduleDeletePolicy;
310
311 let spec: SnapshotScheduleSpec = from_yaml(
312 "policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\ndeletion: { onScheduleDelete: Delete }\n",
313 );
314 assert_eq!(
315 spec.deletion.as_ref().map(|d| d.on_schedule_delete),
316 Some(ScheduleDeletePolicy::Delete)
317 );
318 assert_eq!(
319 effective_on_schedule_delete(spec.deletion.as_ref()),
320 ScheduleDeletePolicy::Delete
321 );
322 let json = serde_json::to_value(&spec).unwrap();
323 assert_eq!(json["deletion"]["onScheduleDelete"], "Delete");
324 let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).unwrap();
325 assert_eq!(spec, reparsed);
326
327 let bare: SnapshotScheduleSpec =
329 from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
330 assert!(bare.deletion.is_none());
331 assert!(
332 serde_json::to_value(&bare)
333 .unwrap()
334 .get("deletion")
335 .is_none(),
336 "absent deletion must be elided"
337 );
338 assert_eq!(
339 effective_on_schedule_delete(bare.deletion.as_ref()),
340 ScheduleDeletePolicy::Retain
341 );
342 }
343
344 #[test]
345 fn schedule_delete_policy_serializes_to_expected_strings() {
346 use crate::common::ScheduleDeletePolicy;
347
348 assert_eq!(
349 serde_json::to_value(ScheduleDeletePolicy::Retain).unwrap(),
350 "Retain"
351 );
352 assert_eq!(
353 serde_json::to_value(ScheduleDeletePolicy::Delete).unwrap(),
354 "Delete"
355 );
356 assert_eq!(
357 ScheduleDeletePolicy::default(),
358 ScheduleDeletePolicy::Retain
359 );
360 }
361
362 #[test]
363 fn schedule_crd_carries_policy_target_xor_validation() {
364 let crd = SnapshotSchedule::crd();
366 let json = serde_json::to_value(&crd).expect("serialize CRD");
367 let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
368 ["x-kubernetes-validations"]
369 .as_array()
370 .expect("spec.x-kubernetes-validations present");
371 assert!(rules.iter().any(|r| {
372 r["rule"]
373 .as_str()
374 .is_some_and(|s| s.contains("policySelector"))
375 }));
376 }
377
378 #[test]
379 fn schedule_defaults_carry_static_openapi_defaults_in_crd() {
380 let crd = SnapshotSchedule::crd();
384 let json = serde_json::to_value(&crd).expect("serialize CRD");
385 let schedule = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
386 ["properties"]["schedule"]["properties"];
387 assert_eq!(
388 schedule["runOnCreate"]["default"], false,
389 "runOnCreate must emit `default: false`"
390 );
391 assert_eq!(
392 schedule["concurrencyPolicy"]["default"], "Forbid",
393 "concurrencyPolicy must emit `default: Forbid`"
394 );
395 }
396
397 #[test]
398 fn schedule_static_defaults_materialize_and_round_trip() {
399 let spec: SnapshotScheduleSpec =
402 from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
403 assert!(!spec.schedule.run_on_create);
404 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
405 let json = serde_json::to_value(&spec).unwrap();
406 assert_eq!(json["schedule"]["runOnCreate"], false);
407 assert_eq!(json["schedule"]["concurrencyPolicy"], "Forbid");
408 }
409
410 #[test]
411 fn backup_schedule_roundtrip_matches_adr_shape() {
412 let yaml = r#"
414policyRef:
415 name: postgres-data
416schedule:
417 cron: "H 2 * * *"
418 jitter: 30m
419 timezone: "America/Los_Angeles"
420 runOnCreate: false
421 suspend: false
422 concurrencyPolicy: Forbid
423 startingDeadlineSeconds: 600
424failedJobsHistoryLimit: 3
425"#;
426 let spec: SnapshotScheduleSpec = from_yaml(yaml);
427 assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
428 assert_eq!(spec.schedule.cron, "H 2 * * *");
429 assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
430 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
431 assert!(!spec.schedule.run_on_create);
432 assert_eq!(spec.failed_jobs_history_limit, Some(3));
433
434 let json = serde_json::to_value(&spec).expect("serialize");
435 let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).expect("reparse");
436 assert_eq!(spec, reparsed);
437 }
438
439 #[test]
440 fn schedule_defaults_are_gitops_friendly() {
441 let spec: SnapshotScheduleSpec = from_yaml(
443 "policyRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
444 );
445 assert!(!spec.schedule.run_on_create);
447 assert!(!spec.schedule.suspend);
448 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
449 }
451
452 #[test]
453 fn concurrency_policy_serializes_to_expected_strings() {
454 assert_eq!(
455 serde_json::to_value(ConcurrencyPolicy::Forbid).unwrap(),
456 "Forbid"
457 );
458 assert_eq!(
459 serde_json::to_value(ConcurrencyPolicy::Allow).unwrap(),
460 "Allow"
461 );
462 assert_eq!(
463 serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
464 "Replace"
465 );
466 assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
467 }
468
469 #[test]
470 fn schedule_status_accepts_both_at_and_scheduled_at() {
471 let status: SnapshotScheduleStatus = from_yaml(
473 r#"
474lastSchedule:
475 scheduledAt: 2026-05-24T02:13:00Z
476 snapshotRef: { name: postgres-data-20260524-021300 }
477nextSchedule:
478 at: 2026-05-25T02:21:00Z
479lastSuccessfulSchedule:
480 at: 2026-05-24T02:13:00Z
481 snapshotRef: { name: postgres-data-20260524-021300 }
482consecutiveFailures: 0
483"#,
484 );
485 assert_eq!(
486 status.last_schedule.as_ref().unwrap().at.as_deref(),
487 Some("2026-05-24T02:13:00Z")
488 );
489 assert_eq!(
490 status.next_schedule.as_ref().unwrap().at.as_deref(),
491 Some("2026-05-25T02:21:00Z")
492 );
493 let json = serde_json::to_value(&status).unwrap();
495 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
496 assert_eq!(status, reparsed);
497 }
498
499 #[test]
500 fn next_schedule_timezone_round_trips() {
501 let status: SnapshotScheduleStatus = from_yaml(
504 r#"
505nextSchedule:
506 at: 2026-05-25T09:00:00Z
507 timezone: America/Chicago
508"#,
509 );
510 assert_eq!(
511 status.next_schedule.as_ref().unwrap().timezone.as_deref(),
512 Some("America/Chicago")
513 );
514 let json = serde_json::to_value(&status).unwrap();
515 assert_eq!(json["nextSchedule"]["timezone"], "America/Chicago");
516 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
517 assert_eq!(status, reparsed);
518
519 let bare: SnapshotScheduleStatus =
521 from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
522 assert!(bare.next_schedule.as_ref().unwrap().timezone.is_none());
523 let bare_json = serde_json::to_value(&bare).unwrap();
524 assert!(bare_json["nextSchedule"].get("timezone").is_none());
525 }
526
527 #[test]
528 fn next_schedule_jitter_round_trips() {
529 let status: SnapshotScheduleStatus = from_yaml(
534 r#"
535nextSchedule:
536 at: 2026-05-25T09:00:00Z
537 timezone: America/Chicago
538 jitter: 30m
539"#,
540 );
541 let pin = status.next_schedule.as_ref().unwrap();
542 assert_eq!(pin.jitter.as_deref(), Some("30m"));
543 assert_eq!(pin.timezone.as_deref(), Some("America/Chicago"));
544 let json = serde_json::to_value(&status).unwrap();
545 assert_eq!(json["nextSchedule"]["jitter"], "30m");
546 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
547 assert_eq!(status, reparsed);
548 }
549
550 #[test]
551 fn next_schedule_absent_jitter_decodes_and_stays_absent() {
552 let legacy: SnapshotScheduleStatus = from_yaml(
558 r#"
559nextSchedule:
560 at: 2026-05-25T09:00:00Z
561 timezone: America/Chicago
562"#,
563 );
564 let pin = legacy.next_schedule.as_ref().unwrap();
565 assert!(pin.jitter.is_none());
566 let json = serde_json::to_value(&legacy).unwrap();
567 assert!(json["nextSchedule"].get("jitter").is_none());
568
569 let oldest: SnapshotScheduleStatus =
571 from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
572 let pin = oldest.next_schedule.as_ref().unwrap();
573 assert!(pin.jitter.is_none() && pin.timezone.is_none());
574 assert_eq!(
575 serde_json::to_value(&oldest).unwrap()["nextSchedule"],
576 serde_json::json!({ "at": "2026-05-25T09:00:00Z" }),
577 "a legacy pin must round-trip byte-identically"
578 );
579 }
580}