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]
163 Forbid,
164 Allow,
166 Replace,
168}
169
170#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct SnapshotScheduleStatus {
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub observed_generation: Option<i64>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub last_schedule: Option<ScheduleRef>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub next_schedule: Option<ScheduleRef>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub last_successful_schedule: Option<ScheduleRef>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub consecutive_failures: Option<i64>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub conditions: Vec<Condition>,
192}
193
194#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
196#[serde(rename_all = "camelCase")]
197pub struct ScheduleRef {
198 #[serde(
200 default,
201 alias = "scheduledAt",
202 skip_serializing_if = "Option::is_none"
203 )]
204 pub at: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub snapshot_ref: Option<SnapshotReference>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub timezone: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub jitter: Option<String>,
235}
236
237#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct SnapshotReference {
241 pub name: String,
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::testutil::from_yaml;
249 use kube::core::CustomResourceExt;
250
251 #[test]
252 fn backup_schedule_crd_metadata_is_correct() {
253 let crd = SnapshotSchedule::crd();
254 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
255 assert_eq!(crd.spec.names.kind, "SnapshotSchedule");
256 assert_eq!(crd.spec.scope, "Namespaced");
257 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
258 }
259
260 #[test]
261 fn failed_jobs_history_limit_schema_default_matches_the_constant() {
262 let crd = SnapshotSchedule::crd();
265 let json = serde_json::to_value(&crd).unwrap();
266 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
267 assert_eq!(
268 spec["properties"]["failedJobsHistoryLimit"]["default"],
269 serde_json::json!(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
270 );
271 assert_eq!(
272 crate::consts::effective_failed_jobs_history_limit(None),
273 crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT
274 );
275 }
276
277 #[test]
278 fn schedule_deletion_on_schedule_delete_schema_default_is_retain() {
279 let crd = SnapshotSchedule::crd();
283 let json = serde_json::to_value(&crd).unwrap();
284 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
285 assert_eq!(
286 spec["properties"]["deletion"]["properties"]["onScheduleDelete"]["default"],
287 serde_json::json!("Retain")
288 );
289 assert_eq!(
290 effective_on_schedule_delete(None),
291 crate::common::ScheduleDeletePolicy::Retain
292 );
293 }
294
295 #[test]
296 fn schedule_deletion_round_trips_and_absent_stays_none() {
297 use crate::common::ScheduleDeletePolicy;
298
299 let spec: SnapshotScheduleSpec = from_yaml(
300 "policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\ndeletion: { onScheduleDelete: Delete }\n",
301 );
302 assert_eq!(
303 spec.deletion.as_ref().map(|d| d.on_schedule_delete),
304 Some(ScheduleDeletePolicy::Delete)
305 );
306 assert_eq!(
307 effective_on_schedule_delete(spec.deletion.as_ref()),
308 ScheduleDeletePolicy::Delete
309 );
310 let json = serde_json::to_value(&spec).unwrap();
311 assert_eq!(json["deletion"]["onScheduleDelete"], "Delete");
312 let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).unwrap();
313 assert_eq!(spec, reparsed);
314
315 let bare: SnapshotScheduleSpec =
317 from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
318 assert!(bare.deletion.is_none());
319 assert!(
320 serde_json::to_value(&bare)
321 .unwrap()
322 .get("deletion")
323 .is_none(),
324 "absent deletion must be elided"
325 );
326 assert_eq!(
327 effective_on_schedule_delete(bare.deletion.as_ref()),
328 ScheduleDeletePolicy::Retain
329 );
330 }
331
332 #[test]
333 fn schedule_delete_policy_serializes_to_expected_strings() {
334 use crate::common::ScheduleDeletePolicy;
335
336 assert_eq!(
337 serde_json::to_value(ScheduleDeletePolicy::Retain).unwrap(),
338 "Retain"
339 );
340 assert_eq!(
341 serde_json::to_value(ScheduleDeletePolicy::Delete).unwrap(),
342 "Delete"
343 );
344 assert_eq!(
345 ScheduleDeletePolicy::default(),
346 ScheduleDeletePolicy::Retain
347 );
348 }
349
350 #[test]
351 fn schedule_crd_carries_policy_target_xor_validation() {
352 let crd = SnapshotSchedule::crd();
354 let json = serde_json::to_value(&crd).expect("serialize CRD");
355 let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
356 ["x-kubernetes-validations"]
357 .as_array()
358 .expect("spec.x-kubernetes-validations present");
359 assert!(rules.iter().any(|r| {
360 r["rule"]
361 .as_str()
362 .is_some_and(|s| s.contains("policySelector"))
363 }));
364 }
365
366 #[test]
367 fn schedule_defaults_carry_static_openapi_defaults_in_crd() {
368 let crd = SnapshotSchedule::crd();
372 let json = serde_json::to_value(&crd).expect("serialize CRD");
373 let schedule = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
374 ["properties"]["schedule"]["properties"];
375 assert_eq!(
376 schedule["runOnCreate"]["default"], false,
377 "runOnCreate must emit `default: false`"
378 );
379 assert_eq!(
380 schedule["concurrencyPolicy"]["default"], "Forbid",
381 "concurrencyPolicy must emit `default: Forbid`"
382 );
383 }
384
385 #[test]
386 fn schedule_static_defaults_materialize_and_round_trip() {
387 let spec: SnapshotScheduleSpec =
390 from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
391 assert!(!spec.schedule.run_on_create);
392 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
393 let json = serde_json::to_value(&spec).unwrap();
394 assert_eq!(json["schedule"]["runOnCreate"], false);
395 assert_eq!(json["schedule"]["concurrencyPolicy"], "Forbid");
396 }
397
398 #[test]
399 fn backup_schedule_roundtrip_matches_adr_shape() {
400 let yaml = r#"
402policyRef:
403 name: postgres-data
404schedule:
405 cron: "H 2 * * *"
406 jitter: 30m
407 timezone: "America/Los_Angeles"
408 runOnCreate: false
409 suspend: false
410 concurrencyPolicy: Forbid
411 startingDeadlineSeconds: 600
412failedJobsHistoryLimit: 3
413"#;
414 let spec: SnapshotScheduleSpec = from_yaml(yaml);
415 assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
416 assert_eq!(spec.schedule.cron, "H 2 * * *");
417 assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
418 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
419 assert!(!spec.schedule.run_on_create);
420 assert_eq!(spec.failed_jobs_history_limit, Some(3));
421
422 let json = serde_json::to_value(&spec).expect("serialize");
423 let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).expect("reparse");
424 assert_eq!(spec, reparsed);
425 }
426
427 #[test]
428 fn schedule_defaults_are_gitops_friendly() {
429 let spec: SnapshotScheduleSpec = from_yaml(
431 "policyRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
432 );
433 assert!(!spec.schedule.run_on_create);
435 assert!(!spec.schedule.suspend);
436 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
437 }
439
440 #[test]
441 fn concurrency_policy_serializes_to_expected_strings() {
442 assert_eq!(
443 serde_json::to_value(ConcurrencyPolicy::Forbid).unwrap(),
444 "Forbid"
445 );
446 assert_eq!(
447 serde_json::to_value(ConcurrencyPolicy::Allow).unwrap(),
448 "Allow"
449 );
450 assert_eq!(
451 serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
452 "Replace"
453 );
454 assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
455 }
456
457 #[test]
458 fn schedule_status_accepts_both_at_and_scheduled_at() {
459 let status: SnapshotScheduleStatus = from_yaml(
461 r#"
462lastSchedule:
463 scheduledAt: 2026-05-24T02:13:00Z
464 snapshotRef: { name: postgres-data-20260524-021300 }
465nextSchedule:
466 at: 2026-05-25T02:21:00Z
467lastSuccessfulSchedule:
468 at: 2026-05-24T02:13:00Z
469 snapshotRef: { name: postgres-data-20260524-021300 }
470consecutiveFailures: 0
471"#,
472 );
473 assert_eq!(
474 status.last_schedule.as_ref().unwrap().at.as_deref(),
475 Some("2026-05-24T02:13:00Z")
476 );
477 assert_eq!(
478 status.next_schedule.as_ref().unwrap().at.as_deref(),
479 Some("2026-05-25T02:21:00Z")
480 );
481 let json = serde_json::to_value(&status).unwrap();
483 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
484 assert_eq!(status, reparsed);
485 }
486
487 #[test]
488 fn next_schedule_timezone_round_trips() {
489 let status: SnapshotScheduleStatus = from_yaml(
492 r#"
493nextSchedule:
494 at: 2026-05-25T09:00:00Z
495 timezone: America/Chicago
496"#,
497 );
498 assert_eq!(
499 status.next_schedule.as_ref().unwrap().timezone.as_deref(),
500 Some("America/Chicago")
501 );
502 let json = serde_json::to_value(&status).unwrap();
503 assert_eq!(json["nextSchedule"]["timezone"], "America/Chicago");
504 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
505 assert_eq!(status, reparsed);
506
507 let bare: SnapshotScheduleStatus =
509 from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
510 assert!(bare.next_schedule.as_ref().unwrap().timezone.is_none());
511 let bare_json = serde_json::to_value(&bare).unwrap();
512 assert!(bare_json["nextSchedule"].get("timezone").is_none());
513 }
514
515 #[test]
516 fn next_schedule_jitter_round_trips() {
517 let status: SnapshotScheduleStatus = from_yaml(
522 r#"
523nextSchedule:
524 at: 2026-05-25T09:00:00Z
525 timezone: America/Chicago
526 jitter: 30m
527"#,
528 );
529 let pin = status.next_schedule.as_ref().unwrap();
530 assert_eq!(pin.jitter.as_deref(), Some("30m"));
531 assert_eq!(pin.timezone.as_deref(), Some("America/Chicago"));
532 let json = serde_json::to_value(&status).unwrap();
533 assert_eq!(json["nextSchedule"]["jitter"], "30m");
534 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
535 assert_eq!(status, reparsed);
536 }
537
538 #[test]
539 fn next_schedule_absent_jitter_decodes_and_stays_absent() {
540 let legacy: SnapshotScheduleStatus = from_yaml(
546 r#"
547nextSchedule:
548 at: 2026-05-25T09:00:00Z
549 timezone: America/Chicago
550"#,
551 );
552 let pin = legacy.next_schedule.as_ref().unwrap();
553 assert!(pin.jitter.is_none());
554 let json = serde_json::to_value(&legacy).unwrap();
555 assert!(json["nextSchedule"].get("jitter").is_none());
556
557 let oldest: SnapshotScheduleStatus =
559 from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
560 let pin = oldest.next_schedule.as_ref().unwrap();
561 assert!(pin.jitter.is_none() && pin.timezone.is_none());
562 assert_eq!(
563 serde_json::to_value(&oldest).unwrap()["nextSchedule"],
564 serde_json::json!({ "at": "2026-05-25T09:00:00Z" }),
565 "a legacy pin must round-trip byte-identically"
566 );
567 }
568}