1use crate::common::{
5 DeletionPolicy, Identity, MoverSpec, PodSelector, RepositoryRef, ResolvedIdentity, Retention,
6};
7use k8s_openapi::api::batch::v1::JobSpec;
8use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
9use kube::CustomResource;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
17#[kube(
18 group = "kopiur.home-operations.com",
19 version = "v1alpha1",
20 kind = "BackupConfig",
21 namespaced,
22 status = "BackupConfigStatus",
23 shortname = "kopiabc",
24 category = "kopiur",
25 printcolumn = r#"{"name":"Repository","type":"string","jsonPath":".spec.repository.name"}"#,
26 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
27)]
28#[serde(rename_all = "camelCase")]
29pub struct BackupConfigSpec {
30 pub repository: RepositoryRef,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub identity: Option<Identity>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub sources: Vec<Source>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub copy_method: Option<CopyMethod>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub volume_snapshot_class_name: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub group_by: Option<GroupBy>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub retention: Option<Retention>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub default_deletion_policy: Option<DeletionPolicy>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub policy: Option<Policy>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub hooks: Option<Hooks>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub mover: Option<MoverSpec>,
64}
65
66#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
70#[serde(rename_all = "camelCase")]
71pub struct Source {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub pvc: Option<PvcSource>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub pvc_selector: Option<PvcSelector>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub source_path_override: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub source_path_strategy: Option<SourcePathStrategy>,
86}
87
88#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
90#[serde(rename_all = "camelCase")]
91pub struct PvcSource {
92 pub name: String,
95}
96
97#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
101#[serde(rename_all = "camelCase")]
102pub struct PvcSelector {
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub namespace_selector: Option<NamespaceSelector>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub label_selector: Option<LabelSelector>,
110}
111
112#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
114#[serde(rename_all = "camelCase")]
115pub struct NamespaceSelector {
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub match_names: Vec<String>,
120}
121
122#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
134pub enum CopyMethod {
135 #[default]
137 Snapshot,
138 Clone,
140 Direct,
143}
144
145#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
158pub enum GroupBy {
159 #[default]
161 VolumeGroupSnapshot,
162 None,
164}
165
166#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
181pub enum SourcePathStrategy {
182 #[default]
184 PvcName,
185 PvcNamespacedName,
188}
189
190#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
192#[serde(rename_all = "camelCase")]
193pub struct Policy {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub compression: Option<Compression>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub splitter: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub ignore: Option<IgnorePolicy>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub extra_args: Vec<String>,
206}
207
208#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
210#[serde(rename_all = "camelCase")]
211pub struct Compression {
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub compressor: Option<String>,
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub never_compress: Vec<String>,
218}
219
220#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
222#[serde(rename_all = "camelCase")]
223pub struct IgnorePolicy {
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub paths: Vec<String>,
228 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
230 pub cache_dirs: bool,
231 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
233 pub ignore_identical_snapshots: bool,
234}
235
236#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
240#[serde(rename_all = "camelCase")]
241pub struct Hooks {
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub before_snapshot: Vec<Hook>,
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub after_snapshot: Vec<Hook>,
250}
251
252#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
277#[serde(rename_all = "camelCase")]
278pub enum Hook {
279 WorkloadExec(WorkloadExecHook),
282 RunJob(Box<RunJobHook>),
288 HttpRequest(HttpRequestHook),
290}
291
292impl Hook {
293 pub fn kind_str(&self) -> &'static str {
309 match self {
310 Hook::WorkloadExec(_) => "WorkloadExec",
311 Hook::RunJob(_) => "RunJob",
312 Hook::HttpRequest(_) => "HttpRequest",
313 }
314 }
315}
316
317#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
321#[serde(rename_all = "camelCase")]
322pub struct WorkloadExecHook {
323 #[serde(flatten)]
326 pub selector: PodSelector,
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
329 pub command: Vec<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub timeout: Option<String>,
333 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
335 pub continue_on_failure: bool,
336}
337
338#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
342#[serde(rename_all = "camelCase")]
343pub struct RunJobHook {
344 pub job_spec: JobSpec,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub timeout: Option<String>,
349 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
351 pub continue_on_failure: bool,
352}
353
354#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
356#[serde(rename_all = "camelCase")]
357pub struct HttpRequestHook {
358 pub url: String,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub method: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub body: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub timeout: Option<String>,
369 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
371 pub continue_on_failure: bool,
372}
373
374#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
376#[serde(rename_all = "camelCase")]
377pub struct BackupConfigStatus {
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub observed_generation: Option<i64>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub resolved: Option<ResolvedConfig>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub retention: Option<RetentionSummary>,
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub conditions: Vec<Condition>,
392}
393
394#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
397#[serde(rename_all = "camelCase")]
398pub struct ResolvedConfig {
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub identity: Option<ResolvedIdentity>,
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
404 pub sources: Vec<ResolvedConfigSource>,
405}
406
407#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
409#[serde(rename_all = "camelCase")]
410pub struct ResolvedConfigSource {
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub pvc: Option<String>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub source_path: Option<String>,
417}
418
419#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
421#[serde(rename_all = "camelCase")]
422pub struct RetentionSummary {
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub active_backup_count: Option<i64>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub last_prune_at: Option<String>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub last_prune_deleted: Option<i64>,
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::common::RepositoryKind;
438 use crate::testutil::from_yaml;
439 use kube::core::CustomResourceExt;
440
441 #[test]
442 fn backup_config_crd_metadata_is_correct() {
443 let crd = BackupConfig::crd();
444 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
445 assert_eq!(crd.spec.names.kind, "BackupConfig");
446 assert_eq!(crd.spec.scope, "Namespaced");
447 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
448 }
449
450 #[test]
451 fn backup_config_roundtrip_matches_adr_shape() {
452 let yaml = r#"
454repository:
455 kind: Repository
456 name: nas-primary
457 namespace: backups
458identity:
459 username: "postgres-data"
460 hostname: "billing"
461sources:
462 - pvc: { name: postgres-data }
463 sourcePathOverride: /data
464copyMethod: Snapshot
465volumeSnapshotClassName: csi-snap-class
466groupBy: VolumeGroupSnapshot
467retention:
468 keepLatest: 10
469 keepDaily: 14
470defaultDeletionPolicy: Delete
471policy:
472 compression:
473 compressor: zstd
474 neverCompress: ["*.zip", "*.gz", "*.mp4"]
475 splitter: DYNAMIC-4M-BUZHASH
476 ignore:
477 paths: ["*.tmp", "*/cache/*", "lost+found"]
478 cacheDirs: true
479 ignoreIdenticalSnapshots: true
480 extraArgs: []
481hooks:
482 beforeSnapshot:
483 - workloadExec:
484 podSelector: { matchLabels: { app: postgres } }
485 container: postgres
486 command: ["pg_start_backup", "snap"]
487 timeout: 2m
488 afterSnapshot:
489 - workloadExec:
490 podSelector: { matchLabels: { app: postgres } }
491 container: postgres
492 command: ["pg_stop_backup"]
493 timeout: 2m
494mover:
495 resources:
496 requests: { cpu: 250m, memory: 512Mi }
497 limits: { cpu: "2", memory: 4Gi }
498 cache:
499 capacity: 16Gi
500 storageClassName: fast-ssd
501"#;
502 let spec: BackupConfigSpec = from_yaml(yaml);
503 assert_eq!(spec.repository.kind, RepositoryKind::Repository);
504 assert_eq!(spec.repository.name, "nas-primary");
505 assert_eq!(spec.sources.len(), 1);
506 assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
507 assert_eq!(
508 spec.sources[0].source_path_override.as_deref(),
509 Some("/data")
510 );
511 assert_eq!(spec.copy_method, Some(CopyMethod::Snapshot));
512 assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
513 assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
514 let comp = spec.policy.as_ref().unwrap().compression.as_ref().unwrap();
515 assert_eq!(comp.compressor.as_deref(), Some("zstd"));
516 let hooks = spec.hooks.as_ref().unwrap();
517 assert_eq!(hooks.before_snapshot.len(), 1);
518 assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
519
520 let json = serde_json::to_value(&spec).expect("serialize");
521 let reparsed: BackupConfigSpec = serde_json::from_value(json).expect("reparse");
522 assert_eq!(spec, reparsed);
523 }
524
525 #[test]
526 fn backup_config_minimal_selector_source() {
527 let yaml = r#"
529repository: { kind: Repository, name: nas-primary, namespace: backups }
530identity: { username: app-bundle, hostname: billing }
531sources:
532 - pvcSelector:
533 labelSelector: { matchLabels: { backup: include } }
534 sourcePathStrategy: PvcName
535groupBy: VolumeGroupSnapshot
536retention: { keepDaily: 14 }
537"#;
538 let spec: BackupConfigSpec = from_yaml(yaml);
539 let src = &spec.sources[0];
540 assert!(src.pvc.is_none());
541 assert!(src.pvc_selector.is_some());
542 assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
543
544 let json = serde_json::to_value(&spec).unwrap();
545 let reparsed: BackupConfigSpec = serde_json::from_value(json).unwrap();
546 assert_eq!(spec, reparsed);
547 }
548
549 #[test]
550 fn hook_run_job_variant_with_job_spec() {
551 let yaml = r#"
553runJob:
554 jobSpec:
555 template:
556 spec:
557 restartPolicy: Never
558 containers:
559 - name: pre
560 image: busybox
561 command: ["sh", "-c", "echo hi"]
562 timeout: 5m
563 continueOnFailure: true
564"#;
565 let hook: Hook = from_yaml(yaml);
566 assert_eq!(hook.kind_str(), "RunJob");
567 match &hook {
568 Hook::RunJob(j) => {
569 assert!(j.continue_on_failure);
570 assert_eq!(j.timeout.as_deref(), Some("5m"));
571 assert_eq!(
572 j.job_spec
573 .template
574 .spec
575 .as_ref()
576 .unwrap()
577 .restart_policy
578 .as_deref(),
579 Some("Never")
580 );
581 }
582 other => panic!("expected RunJob, got {}", other.kind_str()),
583 }
584 let json = serde_json::to_value(&hook).unwrap();
585 assert!(json.get("runJob").is_some());
586 }
587
588 #[test]
589 fn hook_http_request_variant() {
590 let hook: Hook = from_yaml("httpRequest:\n url: https://example/notify\n method: POST\n");
591 assert_eq!(hook.kind_str(), "HttpRequest");
592 let json = serde_json::to_value(&hook).unwrap();
593 assert_eq!(json["httpRequest"]["url"], "https://example/notify");
594 }
595
596 #[test]
597 fn hook_unknown_variant_is_rejected() {
598 let value: serde_json::Value = serde_yaml::from_str("teleport:\n url: x\n").unwrap();
599 assert!(serde_json::from_value::<Hook>(value).is_err());
600 }
601}