1use crate::common::ConfigRef;
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
22use kube::CustomResource;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26#[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 pub config_ref: ConfigRef,
47 pub schedule: ScheduleSpec,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub failed_jobs_history_limit: Option<u32>,
54}
55
56#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
58#[serde(rename_all = "camelCase")]
59pub struct ScheduleSpec {
60 pub cron: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub jitter: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub timezone: Option<String>,
69 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
71 pub run_on_create: bool,
72 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
74 pub suspend: bool,
75 #[serde(default)]
77 pub concurrency_policy: ConcurrencyPolicy,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub starting_deadline_seconds: Option<i64>,
82}
83
84#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
98pub enum ConcurrencyPolicy {
99 #[default]
101 Forbid,
102 Allow,
104 Replace,
106}
107
108#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
110#[serde(rename_all = "camelCase")]
111pub struct BackupScheduleStatus {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub observed_generation: Option<i64>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub last_schedule: Option<ScheduleRef>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub next_schedule: Option<ScheduleRef>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub last_successful_schedule: Option<ScheduleRef>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub consecutive_failures: Option<i64>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub conditions: Vec<Condition>,
130}
131
132#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
138#[serde(rename_all = "camelCase")]
139pub struct ScheduleRef {
140 #[serde(
144 default,
145 alias = "scheduledAt",
146 skip_serializing_if = "Option::is_none"
147 )]
148 pub at: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub backup_ref: Option<BackupReference>,
152}
153
154#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
156#[serde(rename_all = "camelCase")]
157pub struct BackupReference {
158 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 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 let spec: BackupScheduleSpec = from_yaml(
210 "configRef: { name: postgres-data }\nschedule: { cron: \"H 2 * * *\", jitter: 30m }\n",
211 );
212 assert!(!spec.schedule.run_on_create);
214 assert!(!spec.schedule.suspend);
215 assert_eq!(spec.schedule.concurrency_policy, ConcurrencyPolicy::Forbid);
216 }
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 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 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}