Skip to main content

kopiur_api/
snapshot_schedule.rs

1//! The `SnapshotSchedule` CRD — *when* a backup runs. Creates `Snapshot` CRs on a
2//! cron schedule in the `SnapshotPolicy`'s namespace. ADR-0001 §3.5, ADR-0003 §4.4.
3//!
4//! ```
5//! use kopiur_api::{SnapshotScheduleSpec, ConcurrencyPolicy};
6//!
7//! // The cluster path: YAML -> JSON value -> typed (never serde_yaml -> typed).
8//! let spec: SnapshotScheduleSpec = serde_json::from_value(serde_json::json!({
9//!     "policyRef": { "name": "postgres-data" },
10//!     "schedule": { "cron": "H 2 * * *", "jitter": "30m" },
11//! }))
12//! .unwrap();
13//! assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
14//! // GitOps-friendly defaults: no immediate fire, not suspended, Forbid overlap.
15//! assert!(!spec.schedule.run_on_create);
16//! assert!(!spec.schedule.suspend);
17//! assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
18//! ```
19
20use 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/// Cron schedule that fires `Snapshot` CRs from a `SnapshotPolicy`.
27#[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// §10/§15: exactly one of policyRef / policySelector (apiserver + CI validation,
42// complementing the webhook validator). Both optional at the type level.
43#[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    /// The single `SnapshotPolicy` this schedule invokes; mutually exclusive with `policySelector`.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub policy_ref: Option<PolicyRef>,
52    /// Label selector fanning out over `SnapshotPolicy` objects; mutually exclusive with `policyRef`.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub policy_selector: Option<LabelSelector>,
55    /// Cron, jitter, timezone, and concurrency for the firing cadence.
56    pub schedule: ScheduleSpec,
57    /// Maximum number of failed `Snapshot` CRs from this schedule to retain
58    /// (default `10`; `0` keeps none).
59    #[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    /// Deletion semantics for the Snapshots this schedule produced.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub deletion: Option<ScheduleDeletionSpec>,
65}
66
67/// Deletion semantics for a schedule's produced `Snapshot`s (sub-object per
68/// docs/dev/api-conventions.md §4 so future deletion knobs slot in without
69/// API breakage).
70#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct ScheduleDeletionSpec {
73    /// Stamped onto every produced Snapshot at creation (`spec.onScheduleDelete`)
74    /// and consulted by the Snapshot finalizer when the owning schedule is gone
75    /// or replaced. Absent resolves to `Retain`.
76    #[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
85/// The effective cascade policy for a schedule: `spec.deletion.onScheduleDelete`
86/// when the sub-object is present, else `Retain`. (A default nested under an
87/// ABSENT optional sub-object does not materialize server-side — every read
88/// goes through this resolver.)
89pub 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
95/// schemars default for [`SnapshotScheduleSpec::failed_jobs_history_limit`] —
96/// [`DEFAULT_FAILED_JOBS_HISTORY_LIMIT`](crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
97/// (`10`), matching `effective_failed_jobs_history_limit`'s absent→CONST
98/// resolution. Returns the field's `Option` type so schemars 1 emits the
99/// schema `default:`.
100fn default_failed_jobs_history_limit() -> Option<u32> {
101    Some(crate::consts::DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
102}
103
104/// serde/schemars `default` for [`ScheduleSpec::run_on_create`] — `false`
105/// (ADR-0005 §1). A named fn so it backs BOTH `#[serde(default = ...)]` and
106/// `#[schemars(default = ...)]`, which is what makes schemars 1 emit the OpenAPI
107/// `default:` in the generated CRD schema.
108fn default_run_on_create() -> bool {
109    false
110}
111
112/// serde/schemars `default` for [`ScheduleSpec::concurrency_policy`] — `Forbid`
113/// (ADR-0005 §1). Same dual-attribute pattern as [`default_run_on_create`].
114fn default_concurrency_policy() -> ConcurrencyPolicy {
115    ConcurrencyPolicy::Forbid
116}
117
118/// Cron schedule with deterministic jitter, timezone, and concurrency controls.
119#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ScheduleSpec {
122    /// Cron expression with Jenkins-style `H` substitution.
123    pub cron: String,
124    /// Deterministic jitter (Go-style duration), derived from `(scheduleUID, slot)`.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub jitter: Option<String>,
127    /// IANA timezone the cron is evaluated in; absent uses the controller's default.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub timezone: Option<String>,
130    /// Whether to fire immediately on create (default `false`).
131    #[serde(default = "default_run_on_create")]
132    #[schemars(default = "default_run_on_create")]
133    pub run_on_create: bool,
134    /// Skip future firings while true.
135    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
136    pub suspend: bool,
137    /// How to handle a firing while a prior run is still in flight (default `Forbid`).
138    #[serde(default = "default_concurrency_policy")]
139    #[schemars(default = "default_concurrency_policy")]
140    pub concurrency_policy: ConcurrencyPolicy,
141    /// If a slot is missed by more than this many seconds, skip it instead of firing late.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub starting_deadline_seconds: Option<i64>,
144}
145
146/// What to do when a previous run is still in flight. Closed enum, default `Forbid`. ADR §4.1 (G5/G18).
147///
148/// ```
149/// use kopiur_api::ConcurrencyPolicy;
150///
151/// // The safe default: never let runs pile up.
152/// assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
153/// // Serializes as the bare PascalCase string the CRD schema expects.
154/// assert_eq!(
155///     serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
156///     serde_json::json!("Replace"),
157/// );
158/// ```
159#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
160pub enum ConcurrencyPolicy {
161    /// Skip the new run rather than let runs pile up (default).
162    #[default]
163    Forbid,
164    /// Allow the new run to start alongside the in-flight one.
165    Allow,
166    /// Cancel the in-flight run and start the new one in its place.
167    Replace,
168}
169
170/// Observed state of a `SnapshotSchedule`: pinned firing slots and failure run.
171#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct SnapshotScheduleStatus {
174    /// The `metadata.generation` this status reflects, for staleness detection.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub observed_generation: Option<i64>,
177    /// Most recent firing (cron + jitter, pinned).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub last_schedule: Option<ScheduleRef>,
180    /// The next firing slot the controller has computed (cron + jitter, pinned).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub next_schedule: Option<ScheduleRef>,
183    /// The most recent firing whose `Snapshot` succeeded.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub last_successful_schedule: Option<ScheduleRef>,
186    /// Count of back-to-back failed runs; resets on success.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub consecutive_failures: Option<i64>,
189    /// Standard Kubernetes conditions surfacing schedule health.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub conditions: Vec<Condition>,
192}
193
194/// A pinned schedule slot and (optionally) the `Snapshot` it created.
195#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
196#[serde(rename_all = "camelCase")]
197pub struct ScheduleRef {
198    /// The RFC3339 instant this slot fired (or is scheduled to); also accepts the `scheduledAt` alias.
199    #[serde(
200        default,
201        alias = "scheduledAt",
202        skip_serializing_if = "Option::is_none"
203    )]
204    pub at: Option<String>,
205    /// The `Snapshot` CR this slot produced, when one was created.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub snapshot_ref: Option<SnapshotReference>,
208    /// The IANA timezone the cron was evaluated in when this slot was pinned
209    /// (`nextSchedule` only). Recorded so the controller can detect an
210    /// effective-timezone change — a `spec.schedule.timezone` edit or a change to
211    /// the target repository's `scheduleDefaults.timezone` — and invalidate the
212    /// pinned wall-clock slot, recomputing it in the new zone. Absent on legacy
213    /// pins written before this field existed (treated as "unchanged").
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub timezone: Option<String>,
216}
217
218/// A by-name reference to a `Snapshot` CR created by a schedule slot.
219#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
220#[serde(rename_all = "camelCase")]
221pub struct SnapshotReference {
222    /// The `Snapshot`'s `metadata.name` (same namespace as the schedule).
223    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        // Context-free default surfaced in the schema (server-side-materialized);
244        // safe because effective_failed_jobs_history_limit maps absent → this value.
245        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        // Mirrors failed_jobs_history_limit_schema_default_matches_the_constant:
261        // a context-free default is safe to server-side-materialize because
262        // effective_on_schedule_delete maps an absent sub-object to the same value.
263        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        // Absent sub-object stays None (not materialized to Retain client-side).
297        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        // §10/§15: the spec schema carries the policyRef-XOR-policySelector rule.
334        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        // ADR-0005 §1: schedule.runOnCreate (false) and schedule.concurrencyPolicy
350        // (Forbid) must carry real schema defaults so they materialize into the
351        // stored object / `kubectl explain` and GitOps stops diff-thrashing.
352        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        // Both fields parse to their defaults when absent AND serialize (not elided),
369        // so the materialized value round-trips.
370        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        // Mirrors ADR-0001 §3.5.
382        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        // Mirrors ADR-0001 §5.1: minimal schedule.
411        let spec: SnapshotScheduleSpec = from_yaml(
412            "policyRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
413        );
414        // runOnCreate and suspend default false; concurrency defaults Forbid.
415        assert!(!spec.schedule.run_on_create);
416        assert!(!spec.schedule.suspend);
417        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
418        // No successfulJobsHistoryLimit exists on the type at all (ADR-0003 §4.4).
419    }
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        // ADR §3.5 uses `scheduledAt` on lastSchedule and `at` on next/lastSuccessful.
441        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        // Round-trips (serializes back as `at`).
463        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        // The pinned-slot timezone (recorded so an effective-timezone change can
471        // invalidate the pin) parses from YAML and serializes back unchanged.
472        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        // Absent timezone (legacy pins) stays absent, not `null`.
489        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}