1use crate::common::{
10 CredentialProjection, DeletionPolicy, FailurePolicy, PolicyRef, RepositoryRef,
11 ResolvedIdentity, ScheduleDeletePolicy,
12};
13use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
14use kube::CustomResource;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18
19#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
21#[kube(
22 group = "kopiur.home-operations.com",
23 version = "v1alpha1",
24 kind = "Snapshot",
25 namespaced,
26 status = "SnapshotStatus",
27 shortname = "kopiasnap",
28 category = "kopiur",
29 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
30 printcolumn = r#"{"name":"Origin","type":"string","jsonPath":".status.origin"}"#,
31 printcolumn = r#"{"name":"Snapshot","type":"string","jsonPath":".status.snapshot.kopiaSnapshotID"}"#,
32 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
33)]
34#[serde(rename_all = "camelCase")]
35pub struct SnapshotSpec {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub policy_ref: Option<PolicyRef>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub tags: Option<BTreeMap<String, String>>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub failure_policy: Option<FailurePolicy>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub deletion_policy: Option<DeletionPolicy>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub on_schedule_delete: Option<ScheduleDeletePolicy>,
62 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
64 pub pin: bool,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
70 #[schemars(length(max = 1024))]
71 pub description: Option<String>,
72}
73
74#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
79#[serde(rename_all = "camelCase")]
80pub enum Origin {
81 #[default]
83 Scheduled,
84 Manual,
86 Discovered,
88 Adopted,
94}
95
96#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
98pub enum SnapshotPhase {
99 #[default]
101 Pending,
102 Running,
104 Succeeded,
106 Failed,
108 Deleting,
110 Discovered,
112}
113
114impl Origin {
115 pub fn label_value(self) -> &'static str {
119 match self {
120 Self::Scheduled => "scheduled",
121 Self::Manual => "manual",
122 Self::Discovered => "discovered",
123 Self::Adopted => "adopted",
124 }
125 }
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum PrunedBy {
131 Retention,
133 FailedHistory,
135 PolicyCascade,
138}
139
140impl PrunedBy {
141 pub fn annotation_value(self) -> &'static str {
144 match self {
145 Self::Retention => "retention",
146 Self::FailedHistory => "failed-history",
147 Self::PolicyCascade => "policy-cascade",
148 }
149 }
150
151 pub fn parse(v: &str) -> Option<Self> {
154 match v {
155 "retention" => Some(Self::Retention),
156 "failed-history" => Some(Self::FailedHistory),
157 "policy-cascade" => Some(Self::PolicyCascade),
158 _ => None,
159 }
160 }
161}
162
163impl crate::common::PhaseLabel for SnapshotPhase {
164 const ALL: &'static [Self] = &[
165 Self::Pending,
166 Self::Running,
167 Self::Succeeded,
168 Self::Failed,
169 Self::Deleting,
170 Self::Discovered,
171 ];
172 fn label(&self) -> &'static str {
173 match self {
174 Self::Pending => "Pending",
175 Self::Running => "Running",
176 Self::Succeeded => "Succeeded",
177 Self::Failed => "Failed",
178 Self::Deleting => "Deleting",
179 Self::Discovered => "Discovered",
180 }
181 }
182}
183
184#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
186#[serde(rename_all = "camelCase")]
187pub struct SnapshotStatus {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub phase: Option<SnapshotPhase>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub origin: Option<Origin>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub observed_generation: Option<i64>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub snapshot: Option<SnapshotInfo>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub timing: Option<SnapshotTiming>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub stats: Option<SnapshotStats>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub job: Option<JobStatus>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub resolved: Option<ResolvedSnapshot>,
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub conditions: Vec<Condition>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub log_tail: Option<String>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub failure: Option<crate::common::FailureBlock>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub pinned: Option<bool>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub hooks: Option<HookExecutionStatus>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub staged: Option<StagedSources>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub preflight_since: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub cleanup: Option<CleanupStatus>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub recorded: Option<crate::recorded::RecordedSnapshotMeta>,
248}
249
250#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
261#[serde(rename_all = "camelCase")]
262pub struct CleanupStatus {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub creds_reaped_at: Option<String>,
271}
272
273#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
275#[serde(rename_all = "camelCase")]
276pub struct StagedSources {
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub copy_method: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub volume_snapshot_name: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub pvc_name: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub ready: Option<bool>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub storage_class_name: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub staging_timeout_seconds: Option<i64>,
300}
301
302#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
304#[serde(rename_all = "camelCase")]
305pub struct HookExecutionStatus {
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub pre_completed_at: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub post_completed_at: Option<String>,
312}
313
314#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
316#[serde(rename_all = "camelCase")]
317pub struct SnapshotInfo {
318 #[serde(rename = "kopiaSnapshotID")]
320 pub kopia_snapshot_id: String,
321 pub identity: ResolvedIdentity,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub description: Option<String>,
329}
330
331#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
333#[serde(rename_all = "camelCase")]
334pub struct SnapshotTiming {
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub start_time: Option<String>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub end_time: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub duration_seconds: Option<i64>,
344}
345
346#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
348#[serde(rename_all = "camelCase")]
349pub struct SnapshotStats {
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub size_bytes: Option<i64>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub bytes_new: Option<i64>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub files_new: Option<i64>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub files_modified: Option<i64>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub files_unchanged: Option<i64>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub files_failed: Option<i64>,
368}
369
370#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
372#[serde(rename_all = "camelCase")]
373pub struct JobStatus {
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub name: Option<String>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub attempts: Option<i32>,
380}
381
382#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
384#[serde(rename_all = "camelCase")]
385pub struct ResolvedSnapshot {
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub repository: Option<RepositoryRef>,
389 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub sources: Vec<ResolvedSource>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub credential_projection: Option<CredentialProjection>,
402}
403
404#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
406#[serde(rename_all = "camelCase")]
407pub struct ResolvedSource {
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub pvc: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub source_path: Option<String>,
414}
415
416pub fn repository_ref_for(snap: &Snapshot) -> Option<RepositoryRef> {
423 use crate::common::RepositoryKind;
424 if let Some(rref) = snap
425 .status
426 .as_ref()
427 .and_then(|s| s.resolved.as_ref())
428 .and_then(|r| r.repository.clone())
429 {
430 return Some(rref);
431 }
432 let owners = snap
433 .metadata
434 .owner_references
435 .as_deref()
436 .unwrap_or_default();
437 owners.iter().find_map(|o| {
438 if o.api_version != crate::consts::API_VERSION {
439 return None;
440 }
441 let kind = match o.kind.as_str() {
442 "Repository" => RepositoryKind::Repository,
443 "ClusterRepository" => RepositoryKind::ClusterRepository,
444 _ => return None,
445 };
446 Some(RepositoryRef {
447 kind,
448 name: o.name.clone(),
449 namespace: None,
451 })
452 })
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::common::PhaseLabel;
459 use crate::testutil::from_yaml;
460 use kube::core::CustomResourceExt;
461
462 #[test]
463 fn origin_label_value_matches_the_serde_encoding() {
464 for origin in [
465 Origin::Scheduled,
466 Origin::Manual,
467 Origin::Discovered,
468 Origin::Adopted,
469 ] {
470 assert_eq!(
471 serde_json::to_value(origin).unwrap(),
472 origin.label_value(),
473 "{origin:?}"
474 );
475 }
476 }
477
478 #[test]
479 fn backup_phase_all_covers_every_variant_uniquely() {
480 let labels: Vec<&str> = SnapshotPhase::ALL.iter().map(|p| p.label()).collect();
484 assert_eq!(SnapshotPhase::ALL.len(), 6);
485 assert!(labels.iter().all(|l| !l.is_empty()));
486 let mut sorted = labels.clone();
487 sorted.sort_unstable();
488 sorted.dedup();
489 assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
490 assert!(SnapshotPhase::ALL.contains(&SnapshotPhase::default()));
492 }
493
494 mod repository_derivation {
501 use super::super::repository_ref_for;
502 use crate::Snapshot;
503 use crate::common::RepositoryKind;
504
505 fn snap(v: serde_json::Value) -> Snapshot {
506 serde_json::from_value(v).expect("snapshot fixture")
507 }
508
509 #[test]
510 fn produced_snapshot_uses_the_pinned_resolved_repository() {
511 let s = snap(serde_json::json!({
512 "apiVersion": "kopiur.home-operations.com/v1alpha1",
513 "kind": "Snapshot",
514 "metadata": { "name": "s", "namespace": "media" },
515 "spec": { "policyRef": { "name": "pol" } },
516 "status": { "resolved": { "repository": { "kind": "ClusterRepository", "name": "nas" } } }
517 }));
518 let rref = repository_ref_for(&s).expect("derived");
519 assert_eq!(rref.kind, RepositoryKind::ClusterRepository);
520 assert_eq!(rref.name, "nas");
521 }
522
523 #[test]
524 fn discovered_snapshot_uses_the_owning_repository() {
525 for (kind_str, kind) in [
526 ("Repository", RepositoryKind::Repository),
527 ("ClusterRepository", RepositoryKind::ClusterRepository),
528 ] {
529 let s = snap(serde_json::json!({
530 "apiVersion": "kopiur.home-operations.com/v1alpha1",
531 "kind": "Snapshot",
532 "metadata": {
533 "name": "repo-disc-abc", "namespace": "media",
534 "ownerReferences": [{
535 "apiVersion": "kopiur.home-operations.com/v1alpha1",
536 "kind": kind_str, "name": "nas", "uid": "u1", "controller": true
537 }]
538 },
539 "spec": {},
540 "status": { "phase": "Discovered", "origin": "discovered" }
541 }));
542 let rref = repository_ref_for(&s).expect(kind_str);
543 assert_eq!(rref.kind, kind, "{kind_str}");
544 assert_eq!(rref.name, "nas");
545 assert_eq!(rref.namespace, None, "resolved relative to the snapshot ns");
546 }
547 }
548
549 #[test]
550 fn foreign_owners_and_bare_snapshots_derive_nothing() {
551 let s = snap(serde_json::json!({
553 "apiVersion": "kopiur.home-operations.com/v1alpha1",
554 "kind": "Snapshot",
555 "metadata": {
556 "name": "s", "namespace": "media",
557 "ownerReferences": [{
558 "apiVersion": "batch/v1", "kind": "Job", "name": "j", "uid": "u2"
559 }]
560 },
561 "spec": {}
562 }));
563 assert!(repository_ref_for(&s).is_none());
564 }
565 }
566
567 #[test]
568 fn backup_crd_metadata_is_correct() {
569 let crd = Snapshot::crd();
570 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
571 assert_eq!(crd.spec.names.kind, "Snapshot");
572 assert_eq!(crd.spec.scope, "Namespaced");
573 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
574 }
575
576 #[test]
577 fn backup_manual_roundtrip_matches_adr_shape() {
578 let yaml = r#"
580policyRef: { name: postgres-data }
581tags:
582 reason: "scheduled-nightly"
583failurePolicy:
584 backoffLimit: 2
585 activeDeadlineSeconds: 7200
586deletionPolicy: Delete
587"#;
588 let spec: SnapshotSpec = from_yaml(yaml);
589 assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
590 assert_eq!(spec.tags.as_ref().unwrap()["reason"], "scheduled-nightly");
591 assert_eq!(spec.failure_policy.as_ref().unwrap().backoff_limit, Some(2));
592 assert_eq!(spec.deletion_policy, Some(DeletionPolicy::Delete));
593
594 let json = serde_json::to_value(&spec).expect("serialize");
595 let reparsed: SnapshotSpec = serde_json::from_value(json).expect("reparse");
596 assert_eq!(spec, reparsed);
597 }
598
599 #[test]
600 fn backup_discovered_spec_is_empty() {
601 let spec: SnapshotSpec = from_yaml("{}\n");
603 assert!(spec.policy_ref.is_none());
604 assert!(spec.deletion_policy.is_none());
605 assert_eq!(serde_json::to_value(&spec).unwrap(), serde_json::json!({}));
607 }
608
609 #[test]
610 fn deletion_policy_serializes_to_expected_strings() {
611 assert_eq!(
612 serde_json::to_value(DeletionPolicy::Delete).unwrap(),
613 "Delete"
614 );
615 assert_eq!(
616 serde_json::to_value(DeletionPolicy::Retain).unwrap(),
617 "Retain"
618 );
619 assert_eq!(
620 serde_json::to_value(DeletionPolicy::Orphan).unwrap(),
621 "Orphan"
622 );
623 let p = DeletionPolicy::Retain;
625 let _copy = p;
626 assert_eq!(p, DeletionPolicy::Retain);
627 }
628
629 #[test]
630 fn on_schedule_delete_round_trips_and_absent_stays_absent() {
631 let yaml = r#"
632policyRef: { name: postgres-data }
633deletionPolicy: Delete
634onScheduleDelete: Delete
635"#;
636 let spec: SnapshotSpec = from_yaml(yaml);
637 assert_eq!(spec.on_schedule_delete, Some(ScheduleDeletePolicy::Delete));
638 let json = serde_json::to_value(&spec).expect("serialize");
639 assert_eq!(json["onScheduleDelete"], "Delete");
640 let reparsed: SnapshotSpec = serde_json::from_value(json).expect("reparse");
641 assert_eq!(spec, reparsed);
642
643 let bare: SnapshotSpec = from_yaml("policyRef: { name: postgres-data }\n");
646 assert!(bare.on_schedule_delete.is_none());
647 assert!(
648 serde_json::to_value(&bare)
649 .unwrap()
650 .get("onScheduleDelete")
651 .is_none(),
652 "absent onScheduleDelete must be elided"
653 );
654 }
655
656 #[test]
657 fn pruned_by_parse_is_the_exact_inverse_of_annotation_value() {
658 for variant in [
659 PrunedBy::Retention,
660 PrunedBy::FailedHistory,
661 PrunedBy::PolicyCascade,
662 ] {
663 assert_eq!(
664 PrunedBy::parse(variant.annotation_value()),
665 Some(variant),
666 "{variant:?}"
667 );
668 }
669 assert_eq!(PrunedBy::parse("garbage"), None);
670 }
671
672 #[test]
673 fn origin_and_phase_serialize_to_expected_strings() {
674 assert_eq!(
675 serde_json::to_value(Origin::Scheduled).unwrap(),
676 "scheduled"
677 );
678 assert_eq!(serde_json::to_value(Origin::Manual).unwrap(), "manual");
679 assert_eq!(
680 serde_json::to_value(Origin::Discovered).unwrap(),
681 "discovered"
682 );
683 assert_eq!(serde_json::to_value(Origin::Adopted).unwrap(), "adopted");
684 assert_eq!(
685 serde_json::to_value(SnapshotPhase::Succeeded).unwrap(),
686 "Succeeded"
687 );
688 assert_eq!(
689 serde_json::to_value(SnapshotPhase::Deleting).unwrap(),
690 "Deleting"
691 );
692 }
693
694 #[test]
695 fn backup_status_roundtrips() {
696 let yaml = r#"
698phase: Succeeded
699origin: scheduled
700snapshot:
701 kopiaSnapshotID: k1f1ec0a8
702 identity:
703 username: postgres-data
704 hostname: billing
705 sourcePath: /data
706timing:
707 startTime: 2026-05-24T02:13:00Z
708 endTime: 2026-05-24T02:18:42Z
709 durationSeconds: 342
710stats:
711 sizeBytes: 4321098765
712 bytesNew: 12345678
713 filesNew: 1233
714resolved:
715 repository: { kind: Repository, name: nas-primary, namespace: backups }
716 sources:
717 - pvc: billing/postgres-data
718 sourcePath: /data
719logTail: "Snapshot created: k1f1ec0a8"
720"#;
721 let status: SnapshotStatus = from_yaml(yaml);
722 assert_eq!(status.phase, Some(SnapshotPhase::Succeeded));
723 assert_eq!(status.origin, Some(Origin::Scheduled));
724 assert_eq!(
725 status.snapshot.as_ref().unwrap().kopia_snapshot_id,
726 "k1f1ec0a8"
727 );
728 assert_eq!(status.stats.as_ref().unwrap().size_bytes, Some(4321098765));
729
730 let json = serde_json::to_value(&status).unwrap();
731 let reparsed: SnapshotStatus = serde_json::from_value(json).unwrap();
732 assert_eq!(status, reparsed);
733 }
734
735 #[test]
736 fn backup_status_recorded_and_description_roundtrip() {
737 use crate::recorded::{RecordedSnapshotMeta, RecordedSrc};
738 let yaml = r#"
739phase: Succeeded
740snapshot:
741 kopiaSnapshotID: k1
742 identity:
743 username: u
744 hostname: h
745 sourcePath: /data
746 description: "pre-upgrade snapshot"
747recorded:
748 schema: 1
749 src: inherited
750 uid: 3001
751 gid: 3001
752 fsGroup: 65532
753"#;
754 let status: SnapshotStatus = from_yaml(yaml);
755 assert_eq!(
756 status.recorded,
757 Some(RecordedSnapshotMeta {
758 schema: 1,
759 src: RecordedSrc::Inherited,
760 uid: Some(3001),
761 gid: Some(3001),
762 fs_group: Some(65532),
763 })
764 );
765 assert_eq!(
766 status.snapshot.as_ref().unwrap().description.as_deref(),
767 Some("pre-upgrade snapshot")
768 );
769 let json = serde_json::to_value(&status).unwrap();
770 assert_eq!(json["recorded"]["fsGroup"], 65532, "camelCase wire key");
771 let reparsed: SnapshotStatus = serde_json::from_value(json).unwrap();
772 assert_eq!(status, reparsed);
773
774 let bare: SnapshotStatus = from_yaml("phase: Succeeded\n");
776 assert!(bare.recorded.is_none());
777 let wire = serde_json::to_value(&bare).unwrap();
778 assert!(wire.get("recorded").is_none());
779 }
780
781 #[test]
782 fn stored_recorded_with_future_src_decodes_gracefully() {
783 use crate::recorded::RecordedSrc;
786 let status: SnapshotStatus =
787 from_yaml("recorded:\n schema: 1\n src: workload\n uid: 7\n");
788 let rec = status.recorded.expect("decoded");
789 assert_eq!(rec.src, RecordedSrc::Unknown);
790 assert_eq!(rec.uid, Some(7));
791 }
792}