Skip to main content

kopiur_api/
backup_schedule.rs

1//! The `BackupSchedule` CRD — *when* a backup runs. Creates `Backup` CRs on a
2//! cron schedule in the `BackupConfig`'s namespace. ADR-0001 §3.5, ADR-0003 §4.4.
3//!
4//! ```
5//! use kopiur_api::{BackupScheduleSpec, ConcurrencyPolicy};
6//!
7//! // The cluster path: YAML -> JSON value -> typed (never serde_yaml -> typed).
8//! let spec: BackupScheduleSpec = serde_json::from_value(serde_json::json!({
9//!     "configRef": { "name": "postgres-data" },
10//!     "schedule": { "cron": "H 2 * * *", "jitter": "30m" },
11//! }))
12//! .unwrap();
13//! assert_eq!(spec.config_ref.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::ConfigRef;
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
22use kube::CustomResource;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26/// Cron + `configRef`. One source of `Backup` CRs; pausing it doesn't affect
27/// in-flight or completed runs. ADR §3.5.
28#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
29#[kube(
30    group = "kopiur.home-operations.com",
31    version = "v1alpha1",
32    kind = "BackupSchedule",
33    namespaced,
34    status = "BackupScheduleStatus",
35    shortname = "kopiasched",
36    category = "kopiur",
37    printcolumn = r#"{"name":"Config","type":"string","jsonPath":".spec.configRef.name"}"#,
38    printcolumn = r#"{"name":"Schedule","type":"string","jsonPath":".spec.schedule.cron"}"#,
39    printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.schedule.suspend"}"#,
40    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
41)]
42#[serde(rename_all = "camelCase")]
43pub struct BackupScheduleSpec {
44    /// The `BackupConfig` (the recipe) this schedule invokes; resolved in the
45    /// schedule's own namespace. ADR §3.5 separates recipe from schedule.
46    pub config_ref: ConfigRef,
47    /// Cron, jitter, timezone, and concurrency for the firing cadence. ADR §3.5.
48    pub schedule: ScheduleSpec,
49    /// Bounds *failed* `Backup` CRs from this schedule. Successful retention is
50    /// GFS-driven on `BackupConfig.spec.retention` — there is deliberately NO
51    /// `successfulJobsHistoryLimit` (ADR-0003 §4.4, ADR-0001 §4.4).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub failed_jobs_history_limit: Option<u32>,
54}
55
56/// Cron schedule with deterministic jitter, timezone, and concurrency controls. ADR §3.5/§4.1.
57#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
58#[serde(rename_all = "camelCase")]
59pub struct ScheduleSpec {
60    /// Cron expression with Jenkins-style `H` substitution. ADR §4.1 (G4).
61    pub cron: String,
62    /// Deterministic jitter (Go-style duration), derived from `(scheduleUID, slot)`. ADR §4.1.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub jitter: Option<String>,
65    /// IANA timezone the cron is evaluated in (e.g. `America/Los_Angeles`).
66    /// Absent means the controller's configured default. ADR §4.1.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub timezone: Option<String>,
69    /// GitOps-friendly default: do NOT fire immediately on create. ADR §4.1 (G3).
70    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
71    pub run_on_create: bool,
72    /// Skip future firings while true. ADR §5.9.
73    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
74    pub suspend: bool,
75    /// How to handle a firing while a prior run is still in flight. ADR §4.1.
76    #[serde(default)]
77    pub concurrency_policy: ConcurrencyPolicy,
78    /// If a slot is missed by more than this many seconds (e.g. operator was
79    /// down), skip it instead of firing late. ADR §4.1.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub starting_deadline_seconds: Option<i64>,
82}
83
84/// What to do when a previous run is still in flight. Closed enum, default `Forbid`. ADR §4.1 (G5/G18).
85///
86/// ```
87/// use kopiur_api::ConcurrencyPolicy;
88///
89/// // The safe default: never let runs pile up.
90/// assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
91/// // Serializes as the bare PascalCase string the CRD schema expects.
92/// assert_eq!(
93///     serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
94///     serde_json::json!("Replace"),
95/// );
96/// ```
97#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
98pub enum ConcurrencyPolicy {
99    /// Skip the new run; surface a condition rather than pile up (default).
100    #[default]
101    Forbid,
102    /// Allow the new run to start alongside the in-flight one.
103    Allow,
104    /// Cancel the in-flight run and start the new one in its place.
105    Replace,
106}
107
108/// Observed state of a `BackupSchedule`: pinned firing slots and failure run. ADR §3.5.
109#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
110#[serde(rename_all = "camelCase")]
111pub struct BackupScheduleStatus {
112    /// The `metadata.generation` this status reflects, for staleness detection.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub observed_generation: Option<i64>,
115    /// Most recent firing (cron + jitter, pinned). ADR §3.5.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub last_schedule: Option<ScheduleRef>,
118    /// The next firing slot the controller has computed (cron + jitter, pinned).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub next_schedule: Option<ScheduleRef>,
121    /// The most recent firing whose `Backup` succeeded. ADR §3.5.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub last_successful_schedule: Option<ScheduleRef>,
124    /// Count of back-to-back failed runs; resets on success. Drives alerting.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub consecutive_failures: Option<i64>,
127    /// Standard Kubernetes conditions surfacing schedule health. ADR §5 status conventions.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub conditions: Vec<Condition>,
130}
131
132/// A pinned schedule slot and (optionally) the `Backup` it created. ADR §3.5.
133///
134/// `at`/`scheduledAt` are both accepted on the wire: ADR uses `scheduledAt` for
135/// `lastSchedule` and `at` for `next`/`lastSuccessful`. We model both as the single
136/// `at` field with a serde alias so either spelling round-trips.
137#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
138#[serde(rename_all = "camelCase")]
139pub struct ScheduleRef {
140    /// The RFC3339 instant this slot fired (or is scheduled to). Accepts the
141    /// `scheduledAt` alias on the wire (see the struct docs) but always
142    /// serializes back as `at`.
143    #[serde(
144        default,
145        alias = "scheduledAt",
146        skip_serializing_if = "Option::is_none"
147    )]
148    pub at: Option<String>,
149    /// The `Backup` CR this slot produced, when one was created.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub backup_ref: Option<BackupReference>,
152}
153
154/// A by-name reference to a `Backup` CR created by a schedule slot. ADR §3.5.
155#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
156#[serde(rename_all = "camelCase")]
157pub struct BackupReference {
158    /// The `Backup`'s `metadata.name` (same namespace as the schedule).
159    pub name: String,
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::testutil::from_yaml;
166    use kube::core::CustomResourceExt;
167
168    #[test]
169    fn backup_schedule_crd_metadata_is_correct() {
170        let crd = BackupSchedule::crd();
171        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
172        assert_eq!(crd.spec.names.kind, "BackupSchedule");
173        assert_eq!(crd.spec.scope, "Namespaced");
174        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
175    }
176
177    #[test]
178    fn backup_schedule_roundtrip_matches_adr_shape() {
179        // Mirrors ADR-0001 §3.5.
180        let yaml = r#"
181configRef:
182  name: postgres-data
183schedule:
184  cron: "H 2 * * *"
185  jitter: 30m
186  timezone: "America/Los_Angeles"
187  runOnCreate: false
188  suspend: false
189  concurrencyPolicy: Forbid
190  startingDeadlineSeconds: 600
191failedJobsHistoryLimit: 3
192"#;
193        let spec: BackupScheduleSpec = from_yaml(yaml);
194        assert_eq!(spec.config_ref.name, "postgres-data");
195        assert_eq!(spec.schedule.cron, "H 2 * * *");
196        assert_eq!(spec.schedule.jitter.as_deref(), Some("30m"));
197        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
198        assert!(!spec.schedule.run_on_create);
199        assert_eq!(spec.failed_jobs_history_limit, Some(3));
200
201        let json = serde_json::to_value(&spec).expect("serialize");
202        let reparsed: BackupScheduleSpec = serde_json::from_value(json).expect("reparse");
203        assert_eq!(spec, reparsed);
204    }
205
206    #[test]
207    fn schedule_defaults_are_gitops_friendly() {
208        // Mirrors ADR-0001 §5.1: minimal schedule.
209        let spec: BackupScheduleSpec = from_yaml(
210            "configRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
211        );
212        // runOnCreate and suspend default false; concurrency defaults Forbid.
213        assert!(!spec.schedule.run_on_create);
214        assert!(!spec.schedule.suspend);
215        assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
216        // No successfulJobsHistoryLimit exists on the type at all (ADR-0003 §4.4).
217    }
218
219    #[test]
220    fn concurrency_policy_serializes_to_expected_strings() {
221        assert_eq!(
222            serde_json::to_value(ConcurrencyPolicy::Forbid).unwrap(),
223            "Forbid"
224        );
225        assert_eq!(
226            serde_json::to_value(ConcurrencyPolicy::Allow).unwrap(),
227            "Allow"
228        );
229        assert_eq!(
230            serde_json::to_value(ConcurrencyPolicy::Replace).unwrap(),
231            "Replace"
232        );
233        assert_eq!(ConcurrencyPolicy::default(), ConcurrencyPolicy::Forbid);
234    }
235
236    #[test]
237    fn schedule_status_accepts_both_at_and_scheduled_at() {
238        // ADR §3.5 uses `scheduledAt` on lastSchedule and `at` on next/lastSuccessful.
239        let status: BackupScheduleStatus = from_yaml(
240            r#"
241lastSchedule:
242  scheduledAt: 2026-05-24T02:13:00Z
243  backupRef: { name: postgres-data-20260524-021300 }
244nextSchedule:
245  at: 2026-05-25T02:21:00Z
246lastSuccessfulSchedule:
247  at: 2026-05-24T02:13:00Z
248  backupRef: { name: postgres-data-20260524-021300 }
249consecutiveFailures: 0
250"#,
251        );
252        assert_eq!(
253            status.last_schedule.as_ref().unwrap().at.as_deref(),
254            Some("2026-05-24T02:13:00Z")
255        );
256        assert_eq!(
257            status.next_schedule.as_ref().unwrap().at.as_deref(),
258            Some("2026-05-25T02:21:00Z")
259        );
260        // Round-trips (serializes back as `at`).
261        let json = serde_json::to_value(&status).unwrap();
262        let reparsed: BackupScheduleStatus = serde_json::from_value(json).unwrap();
263        assert_eq!(status, reparsed);
264    }
265}