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")]
215 pub timezone: Option<String>,
216}
217
218#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
220#[serde(rename_all = "camelCase")]
221pub struct SnapshotReference {
222 pub name: String,
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::testutil::from_yaml;
230 use kube::core::CustomResourceExt;
231
232 #[test]
233 fn backup_schedule_crd_metadata_is_correct() {
234 let crd = SnapshotSchedule::crd();
235 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
236 assert_eq!(crd.spec.names.kind, "SnapshotSchedule");
237 assert_eq!(crd.spec.scope, "Namespaced");
238 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
239 }
240
241 #[test]
242 fn failed_jobs_history_limit_schema_default_matches_the_constant() {
243 let crd = SnapshotSchedule::crd();
246 let json = serde_json::to_value(&crd).unwrap();
247 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
248 assert_eq!(
249 spec["properties"]["failedJobsHistoryLimit"]["default"],
250 serde_json::json!(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
251 );
252 assert_eq!(
253 crate::consts::effective_failed_jobs_history_limit(None),
254 crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT
255 );
256 }
257
258 #[test]
259 fn schedule_deletion_on_schedule_delete_schema_default_is_retain() {
260 let crd = SnapshotSchedule::crd();
264 let json = serde_json::to_value(&crd).unwrap();
265 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
266 assert_eq!(
267 spec["properties"]["deletion"]["properties"]["onScheduleDelete"]["default"],
268 serde_json::json!("Retain")
269 );
270 assert_eq!(
271 effective_on_schedule_delete(None),
272 crate::common::ScheduleDeletePolicy::Retain
273 );
274 }
275
276 #[test]
277 fn schedule_deletion_round_trips_and_absent_stays_none() {
278 use crate::common::ScheduleDeletePolicy;
279
280 let spec: SnapshotScheduleSpec = from_yaml(
281 "policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\ndeletion: { onScheduleDelete: Delete }\n",
282 );
283 assert_eq!(
284 spec.deletion.as_ref().map(|d| d.on_schedule_delete),
285 Some(ScheduleDeletePolicy::Delete)
286 );
287 assert_eq!(
288 effective_on_schedule_delete(spec.deletion.as_ref()),
289 ScheduleDeletePolicy::Delete
290 );
291 let json = serde_json::to_value(&spec).unwrap();
292 assert_eq!(json["deletion"]["onScheduleDelete"], "Delete");
293 let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).unwrap();
294 assert_eq!(spec, reparsed);
295
296 let bare: SnapshotScheduleSpec =
298 from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
299 assert!(bare.deletion.is_none());
300 assert!(
301 serde_json::to_value(&bare)
302 .unwrap()
303 .get("deletion")
304 .is_none(),
305 "absent deletion must be elided"
306 );
307 assert_eq!(
308 effective_on_schedule_delete(bare.deletion.as_ref()),
309 ScheduleDeletePolicy::Retain
310 );
311 }
312
313 #[test]
314 fn schedule_delete_policy_serializes_to_expected_strings() {
315 use crate::common::ScheduleDeletePolicy;
316
317 assert_eq!(
318 serde_json::to_value(ScheduleDeletePolicy::Retain).unwrap(),
319 "Retain"
320 );
321 assert_eq!(
322 serde_json::to_value(ScheduleDeletePolicy::Delete).unwrap(),
323 "Delete"
324 );
325 assert_eq!(
326 ScheduleDeletePolicy::default(),
327 ScheduleDeletePolicy::Retain
328 );
329 }
330
331 #[test]
332 fn schedule_crd_carries_policy_target_xor_validation() {
333 let crd = SnapshotSchedule::crd();
335 let json = serde_json::to_value(&crd).expect("serialize CRD");
336 let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
337 ["x-kubernetes-validations"]
338 .as_array()
339 .expect("spec.x-kubernetes-validations present");
340 assert!(rules.iter().any(|r| {
341 r["rule"]
342 .as_str()
343 .is_some_and(|s| s.contains("policySelector"))
344 }));
345 }
346
347 #[test]
348 fn schedule_defaults_carry_static_openapi_defaults_in_crd() {
349 let crd = SnapshotSchedule::crd();
353 let json = serde_json::to_value(&crd).expect("serialize CRD");
354 let schedule = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
355 ["properties"]["schedule"]["properties"];
356 assert_eq!(
357 schedule["runOnCreate"]["default"], false,
358 "runOnCreate must emit `default: false`"
359 );
360 assert_eq!(
361 schedule["concurrencyPolicy"]["default"], "Forbid",
362 "concurrencyPolicy must emit `default: Forbid`"
363 );
364 }
365
366 #[test]
367 fn schedule_static_defaults_materialize_and_round_trip() {
368 let spec: SnapshotScheduleSpec =
371 from_yaml("policyRef: { name: pg }\nschedule: { cron: \"H 2 * * *\" }\n");
372 assert!(!spec.schedule.run_on_create);
373 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
374 let json = serde_json::to_value(&spec).unwrap();
375 assert_eq!(json["schedule"]["runOnCreate"], false);
376 assert_eq!(json["schedule"]["concurrencyPolicy"], "Forbid");
377 }
378
379 #[test]
380 fn backup_schedule_roundtrip_matches_adr_shape() {
381 let yaml = r#"
383policyRef:
384 name: postgres-data
385schedule:
386 cron: "H 2 * * *"
387 jitter: 30m
388 timezone: "America/Los_Angeles"
389 runOnCreate: false
390 suspend: false
391 concurrencyPolicy: Forbid
392 startingDeadlineSeconds: 600
393failedJobsHistoryLimit: 3
394"#;
395 let spec: SnapshotScheduleSpec = from_yaml(yaml);
396 assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
397 assert_eq!(spec.schedule.cron, "H 2 * * *");
398 assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
399 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
400 assert!(!spec.schedule.run_on_create);
401 assert_eq!(spec.failed_jobs_history_limit, Some(3));
402
403 let json = serde_json::to_value(&spec).expect("serialize");
404 let reparsed: SnapshotScheduleSpec = serde_json::from_value(json).expect("reparse");
405 assert_eq!(spec, reparsed);
406 }
407
408 #[test]
409 fn schedule_defaults_are_gitops_friendly() {
410 let spec: SnapshotScheduleSpec = from_yaml(
412 "policyRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
413 );
414 assert!(!spec.schedule.run_on_create);
416 assert!(!spec.schedule.suspend);
417 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
418 }
420
421 #[test]
422 fn concurrency_policy_serializes_to_expected_strings() {
423 assert_eq!(
424 serde_json::to_value(ConcurrencyPolicy::Forbid).unwrap(),
425 "Forbid"
426 );
427 assert_eq!(
428 serde_json::to_value(ConcurrencyPolicy::Allow).unwrap(),
429 "Allow"
430 );
431 assert_eq!(
432 serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
433 "Replace"
434 );
435 assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
436 }
437
438 #[test]
439 fn schedule_status_accepts_both_at_and_scheduled_at() {
440 let status: SnapshotScheduleStatus = from_yaml(
442 r#"
443lastSchedule:
444 scheduledAt: 2026-05-24T02:13:00Z
445 snapshotRef: { name: postgres-data-20260524-021300 }
446nextSchedule:
447 at: 2026-05-25T02:21:00Z
448lastSuccessfulSchedule:
449 at: 2026-05-24T02:13:00Z
450 snapshotRef: { name: postgres-data-20260524-021300 }
451consecutiveFailures: 0
452"#,
453 );
454 assert_eq!(
455 status.last_schedule.as_ref().unwrap().at.as_deref(),
456 Some("2026-05-24T02:13:00Z")
457 );
458 assert_eq!(
459 status.next_schedule.as_ref().unwrap().at.as_deref(),
460 Some("2026-05-25T02:21:00Z")
461 );
462 let json = serde_json::to_value(&status).unwrap();
464 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
465 assert_eq!(status, reparsed);
466 }
467
468 #[test]
469 fn next_schedule_timezone_round_trips() {
470 let status: SnapshotScheduleStatus = from_yaml(
473 r#"
474nextSchedule:
475 at: 2026-05-25T09:00:00Z
476 timezone: America/Chicago
477"#,
478 );
479 assert_eq!(
480 status.next_schedule.as_ref().unwrap().timezone.as_deref(),
481 Some("America/Chicago")
482 );
483 let json = serde_json::to_value(&status).unwrap();
484 assert_eq!(json["nextSchedule"]["timezone"], "America/Chicago");
485 let reparsed: SnapshotScheduleStatus = serde_json::from_value(json).unwrap();
486 assert_eq!(status, reparsed);
487
488 let bare: SnapshotScheduleStatus =
490 from_yaml("nextSchedule: { at: 2026-05-25T09:00:00Z }\n");
491 assert!(bare.next_schedule.as_ref().unwrap().timezone.is_none());
492 let bare_json = serde_json::to_value(&bare).unwrap();
493 assert!(bare_json["nextSchedule"].get("timezone").is_none());
494 }
495}