1use crate::common::{CronSpec, FailurePolicy, MoverSpec, RepositoryRef};
5use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
6use kube::CustomResource;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10pub fn default_maintenance_schedule() -> MaintenanceSchedule {
27 MaintenanceSchedule {
28 quick: CronSpec {
29 cron: "0 */6 * * *".to_string(),
30 jitter: Some("30m".to_string()),
31 },
32 full: CronSpec {
33 cron: "0 3 * * *".to_string(),
34 jitter: Some("1h".to_string()),
35 },
36 timezone: None,
37 }
38}
39
40#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
44#[kube(
45 group = "kopiur.home-operations.com",
46 version = "v1alpha1",
47 kind = "Maintenance",
48 namespaced,
49 status = "MaintenanceStatus",
50 shortname = "kopiamaint",
51 category = "kopiur",
52 printcolumn = r#"{"name":"Repository","type":"string","jsonPath":".spec.repository.name"}"#,
53 printcolumn = r#"{"name":"Owner","type":"string","jsonPath":".status.ownership.owner"}"#,
54 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
55)]
56#[serde(rename_all = "camelCase")]
57pub struct MaintenanceSpec {
58 pub repository: RepositoryRef,
60 pub schedule: MaintenanceSchedule,
63 pub ownership: Ownership,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub mover: Option<MoverSpec>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub failure_policy: Option<FailurePolicy>,
73}
74
75#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
77#[serde(rename_all = "camelCase")]
78pub struct MaintenanceSchedule {
79 pub quick: CronSpec,
81 pub full: CronSpec,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub timezone: Option<String>,
86}
87
88#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
90#[serde(rename_all = "camelCase")]
91pub struct Ownership {
92 pub owner: String,
95 #[serde(default)]
97 pub takeover_policy: TakeoverPolicy,
98}
99
100#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
113pub enum TakeoverPolicy {
114 #[default]
116 Never,
117 PromptCondition,
119 Force,
121}
122
123#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
136#[serde(rename_all = "camelCase")]
137pub struct RepositoryMaintenanceSpec {
138 #[serde(default = "crate::common::default_true")]
143 pub enabled: bool,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub schedule: Option<MaintenanceSchedule>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub mover: Option<MoverSpec>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub failure_policy: Option<FailurePolicy>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub takeover_policy: Option<TakeoverPolicy>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub namespace: Option<String>,
164}
165
166impl Default for RepositoryMaintenanceSpec {
167 fn default() -> Self {
170 Self {
171 enabled: true,
172 schedule: None,
173 mover: None,
174 failure_policy: None,
175 takeover_policy: None,
176 namespace: None,
177 }
178 }
179}
180
181#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
183#[serde(rename_all = "camelCase")]
184pub struct MaintenanceStatus {
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub observed_generation: Option<i64>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub ownership: Option<OwnershipStatus>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub quick: Option<RunStatus>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub full: Option<RunStatus>,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub conditions: Vec<Condition>,
200}
201
202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub struct OwnershipStatus {
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub owner: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub claimed_at: Option<String>,
212}
213
214#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
216#[serde(rename_all = "camelCase")]
217pub struct RunStatus {
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub last_run_at: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub next_scheduled_at: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub consecutive_failures: Option<i64>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub last_content_reclaimed_bytes: Option<i64>,
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::common::RepositoryKind;
236 use crate::testutil::from_yaml;
237 use kube::core::CustomResourceExt;
238
239 #[test]
240 fn maintenance_crd_metadata_is_correct() {
241 let crd = Maintenance::crd();
242 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
243 assert_eq!(crd.spec.names.kind, "Maintenance");
244 assert_eq!(crd.spec.scope, "Namespaced");
245 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
246 }
247
248 #[test]
249 fn maintenance_roundtrip_matches_adr_shape() {
250 let yaml = r#"
252repository:
253 kind: Repository
254 name: nas-primary
255schedule:
256 quick: { cron: "0 */6 * * *", jitter: 30m }
257 full: { cron: "0 3 * * 0", jitter: 1h }
258 timezone: UTC
259ownership:
260 owner: "kopia-operator/nas-primary"
261 takeoverPolicy: PromptCondition
262mover:
263 resources: { requests: { cpu: 250m, memory: 1Gi }, limits: { cpu: "2", memory: 4Gi } }
264failurePolicy:
265 backoffLimit: 1
266 activeDeadlineSeconds: 14400
267"#;
268 let spec: MaintenanceSpec = from_yaml(yaml);
269 assert_eq!(spec.repository.kind, RepositoryKind::Repository);
270 assert_eq!(spec.schedule.quick.cron, "0 */6 * * *");
271 assert_eq!(spec.schedule.quick.jitter.as_deref(), Some("30m"));
272 assert_eq!(spec.schedule.full.cron, "0 3 * * 0");
273 assert_eq!(spec.schedule.timezone.as_deref(), Some("UTC"));
274 assert_eq!(spec.ownership.owner, "kopia-operator/nas-primary");
275 assert_eq!(
276 spec.ownership.takeover_policy,
277 TakeoverPolicy::PromptCondition
278 );
279 assert_eq!(
280 spec.failure_policy
281 .as_ref()
282 .unwrap()
283 .active_deadline_seconds,
284 Some(14400)
285 );
286
287 let json = serde_json::to_value(&spec).expect("serialize");
288 let reparsed: MaintenanceSpec = serde_json::from_value(json).expect("reparse");
289 assert_eq!(spec, reparsed);
290 }
291
292 #[test]
293 fn maintenance_status_roundtrips() {
294 let yaml = r#"
296ownership:
297 owner: "kopia-operator/nas-primary"
298 claimedAt: 2026-05-12T08:14:02Z
299quick:
300 lastRunAt: 2026-05-24T12:00:11Z
301 nextScheduledAt: 2026-05-24T18:00:00Z
302 consecutiveFailures: 0
303 lastContentReclaimedBytes: 1234567
304full:
305 lastRunAt: 2026-05-19T03:01:42Z
306 nextScheduledAt: 2026-05-26T03:00:00Z
307 consecutiveFailures: 0
308 lastContentReclaimedBytes: 89456789012
309"#;
310 let status: MaintenanceStatus = from_yaml(yaml);
311 assert_eq!(
312 status.ownership.as_ref().unwrap().owner.as_deref(),
313 Some("kopia-operator/nas-primary")
314 );
315 assert_eq!(
316 status.quick.as_ref().unwrap().last_content_reclaimed_bytes,
317 Some(1234567)
318 );
319 assert_eq!(
320 status.full.as_ref().unwrap().last_content_reclaimed_bytes,
321 Some(89456789012)
322 );
323
324 let json = serde_json::to_value(&status).unwrap();
325 let reparsed: MaintenanceStatus = serde_json::from_value(json).unwrap();
326 assert_eq!(status, reparsed);
327 }
328
329 #[test]
330 fn repository_maintenance_defaults_to_enabled() {
331 let m: RepositoryMaintenanceSpec = from_yaml("{}\n");
333 assert!(
334 m.enabled,
335 "absent `enabled` must default to true (default-on)"
336 );
337 assert!(m.schedule.is_none());
338 assert!(m.namespace.is_none());
339 assert!(m.takeover_policy.is_none());
340 assert_eq!(m, RepositoryMaintenanceSpec::default());
342 }
343
344 #[test]
345 fn repository_maintenance_roundtrip_with_overrides() {
346 let yaml = r#"
347enabled: false
348schedule:
349 quick: { cron: "0 */4 * * *", jitter: 20m }
350 full: { cron: "30 2 * * *", jitter: 45m }
351 timezone: America/Chicago
352takeoverPolicy: Force
353namespace: kopia-system
354failurePolicy:
355 backoffLimit: 2
356"#;
357 let m: RepositoryMaintenanceSpec = from_yaml(yaml);
358 assert!(!m.enabled);
359 let s = m.schedule.as_ref().expect("schedule");
360 assert_eq!(s.quick.cron, "0 */4 * * *");
361 assert_eq!(s.full.jitter.as_deref(), Some("45m"));
362 assert_eq!(s.timezone.as_deref(), Some("America/Chicago"));
363 assert_eq!(m.takeover_policy, Some(TakeoverPolicy::Force));
364 assert_eq!(m.namespace.as_deref(), Some("kopia-system"));
365 assert_eq!(m.failure_policy.as_ref().unwrap().backoff_limit, Some(2));
366
367 let json = serde_json::to_value(&m).expect("serialize");
368 let reparsed: RepositoryMaintenanceSpec = serde_json::from_value(json).expect("reparse");
369 assert_eq!(m, reparsed);
370 }
371
372 #[test]
373 fn default_maintenance_schedule_is_quick_6h_full_daily() {
374 let s = default_maintenance_schedule();
375 assert_eq!(s.quick.cron, "0 */6 * * *");
376 assert_eq!(s.quick.jitter.as_deref(), Some("30m"));
377 assert_eq!(s.full.cron, "0 3 * * *");
378 assert_eq!(s.full.jitter.as_deref(), Some("1h"));
379 assert!(s.timezone.is_none());
380 }
381
382 #[test]
383 fn takeover_policy_serializes_to_expected_strings() {
384 assert_eq!(
385 serde_json::to_value(TakeoverPolicy::Never).unwrap(),
386 "Never"
387 );
388 assert_eq!(
389 serde_json::to_value(TakeoverPolicy::PromptCondition).unwrap(),
390 "PromptCondition"
391 );
392 assert_eq!(
393 serde_json::to_value(TakeoverPolicy::Force).unwrap(),
394 "Force"
395 );
396 assert_eq!(TakeoverPolicy::default(), TakeoverPolicy::Never);
397 }
398}