Skip to main content

kopiur_api/
snapshot.rs

1//! The `Snapshot` CRD — a single kopia snapshot as a Kubernetes object.
2//! ADR-0001 §3.4, ADR-0003 §4.5.
3//!
4//! Three origins (canonical value lives in `status.origin`):
5//! - `scheduled` — created by a `SnapshotSchedule`; spec carries `policyRef`.
6//! - `manual`    — created by `kubectl create` / external automation; spec carries `policyRef`.
7//! - `discovered`— materialized by the catalog scan; spec is empty/absent.
8
9use 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/// A single kopia snapshot represented as a Kubernetes object.
20#[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    /// The `SnapshotPolicy` recipe to run; absent for `discovered` backups.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub policy_ref: Option<PolicyRef>,
39    /// Free-form tags attached to the kopia snapshot manifest itself
40    /// (`snapshot create --tags`), e.g. `reason: pre-upgrade` — durable in the
41    /// repository, independent of this CR. Keys must be non-empty, colon-free
42    /// (kopia splits on the first colon), and must not start with the reserved
43    /// `kopiur` prefix; at most 10 tags, keys ≤ 63 bytes, values ≤ 256 bytes
44    /// (webhook-enforced).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub tags: Option<BTreeMap<String, String>>,
47    /// Mover Job retry and deadline limits for this run.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub failure_policy: Option<FailurePolicy>,
50    /// What happens to the kopia snapshot when this CR is deleted.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub deletion_policy: Option<DeletionPolicy>,
53    /// What the schedule-deletion cascade does to this Snapshot: consulted by the
54    /// finalizer ONLY when the deletion is external (not an operator prune) and the
55    /// owning `SnapshotSchedule` is gone or replaced (ownerRef UID mismatch).
56    /// `Retain` downgrades an effective `Delete` so the kopia snapshot survives;
57    /// `Delete` lets the Snapshot's own `deletionPolicy` cascade. Stamped at
58    /// creation from the schedule's `spec.deletion.onScheduleDelete`; absent
59    /// (pre-upgrade Snapshots, manual/discovered Snapshots) resolves to `Retain`.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub on_schedule_delete: Option<ScheduleDeletePolicy>,
62    /// Exempt this snapshot from GFS retention.
63    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
64    pub pin: bool,
65    /// Free-form text recorded on the kopia snapshot manifest
66    /// (`snapshot create --description`). Per-invocation by nature —
67    /// scheduled/discovered `Snapshot`s never set this (no templated
68    /// descriptions).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    #[schemars(length(max = 1024))]
71    pub description: Option<String>,
72}
73
74/// How a `Snapshot` came to exist. Canonical value mirrored from the
75/// `kopiur.home-operations.com/origin` label. Origin drives the deletion-policy
76/// default: `discovered` backups are forced to `Retain` because the operator did
77/// not create those snapshots.
78#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
79#[serde(rename_all = "camelCase")]
80pub enum Origin {
81    /// Created by a `SnapshotSchedule`; spec carries `policyRef`.
82    #[default]
83    Scheduled,
84    /// Created by `kubectl create` / external automation; spec carries `policyRef`.
85    Manual,
86    /// Materialized by the catalog scan for a snapshot kopiur didn't produce.
87    Discovered,
88    /// A `discovered` snapshot whose resolved identity matched a live
89    /// `SnapshotPolicy` and was automatically (or explicitly) re-attached to
90    /// it: it now carries that policy's config label and is retention-governed
91    /// like any produced row, even though the operator did not create the
92    /// underlying kopia snapshot.
93    Adopted,
94}
95
96/// Lifecycle phase of a `Snapshot`.
97#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
98pub enum SnapshotPhase {
99    /// Admitted, not yet started (also the default).
100    #[default]
101    Pending,
102    /// Mover Job is in flight.
103    Running,
104    /// Snapshot created successfully.
105    Succeeded,
106    /// Mover Job exhausted its retries.
107    Failed,
108    /// CR is being deleted; finalizer is reclaiming the snapshot.
109    Deleting,
110    /// Catalog-materialized backup kopiur didn't produce.
111    Discovered,
112}
113
114impl Origin {
115    /// The stable wire/label value (the serde camelCase encoding), for the
116    /// `kopiur.home-operations.com/origin` label and `status.origin` — single
117    /// definition so producers (controller, kubectl plugin) cannot drift.
118    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/// Which operator lifecycle removed a Snapshot (the `pruned-by` annotation).
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum PrunedBy {
131    /// GFS retention prune (`SnapshotPolicy.spec.retention`).
132    Retention,
133    /// `SnapshotSchedule.spec.failedJobsHistoryLimit` prune.
134    FailedHistory,
135    /// Policy-deletion cascade under `onPolicyDelete: Retain` — release the
136    /// CR, never contact the repository.
137    PolicyCascade,
138}
139
140impl PrunedBy {
141    /// The stable annotation value stamped by the operator before it deletes a
142    /// `Snapshot` as part of its own lifecycle (see [`crate::consts::PRUNED_BY_ANNOTATION`]).
143    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    /// Strict parse: `None` for anything unrecognized (the finalizer must treat
152    /// that as an EXTERNAL deletion — never guess "operator").
153    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/// Observed state of a [`Snapshot`].
185#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
186#[serde(rename_all = "camelCase")]
187pub struct SnapshotStatus {
188    /// Current lifecycle phase.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub phase: Option<SnapshotPhase>,
191    /// Canonical origin (also mirrored to the `origin` label).
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub origin: Option<Origin>,
194    /// `metadata.generation` last reconciled, for staleness detection.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub observed_generation: Option<i64>,
197    /// The kopia artifact this CR represents.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub snapshot: Option<SnapshotInfo>,
200    /// Start/end/duration of the snapshot run.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub timing: Option<SnapshotTiming>,
203    /// Byte/file counts parsed from kopia's JSON output.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub stats: Option<SnapshotStats>,
206    /// The mover Job backing this run; absent for discovered.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub job: Option<JobStatus>,
209    /// Frozen recipe values at run time (scheduled/manual).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub resolved: Option<ResolvedSnapshot>,
212    /// Standard Kubernetes conditions (e.g. `SourcesQuiesced`, `SnapshotCreated`).
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub conditions: Vec<Condition>,
215    /// The last lines of the run's output, written by the mover at the terminal transition.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub log_tail: Option<String>,
218    /// Structured terminal-failure detail (kopia error class, stderr tail, retry hint).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub failure: Option<crate::common::FailureBlock>,
221    /// The observed kopia-side pin state: `Some(true)` if pinned, `Some(false)` if unpinned, `None` before any pin reconcile.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub pinned: Option<bool>,
224    /// Hook-execution bookkeeping so each hook list runs exactly once per Snapshot.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub hooks: Option<HookExecutionStatus>,
227    /// The CSI staging objects the run created for `copyMethod: Snapshot`/`Clone`.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub staged: Option<StagedSources>,
230    /// RFC 3339 timestamp of the first reconcile where the repository was `Ready`
231    /// but a `spec.preflight` check was failing. The one-shot anchor for the
232    /// preflight `timeout` deadline (so the budget covers preflight only, not the
233    /// earlier repository-not-Ready wait). Cleared once every preflight check passes,
234    /// so a later failing episode gets a fresh budget rather than a stale anchor.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub preflight_since: Option<String>,
237    /// Post-run cleanup bookkeeping, so each cleanup runs at most once per Snapshot.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub cleanup: Option<CleanupStatus>,
240    /// The mover identity recorded on the kopia snapshot itself (the
241    /// `kopiur-meta` tag): the resolved effective uid/gid/fsGroup the backup ran
242    /// as, plus its provenance. Produced runs stamp this at launch (from the
243    /// same value written into the tag); discovered rows decode it from the tag
244    /// during the catalog scan. Absent for pre-feature snapshots, foreign
245    /// backups without the tag, or a tag this operator version cannot decode.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub recorded: Option<crate::recorded::RecordedSnapshotMeta>,
248}
249
250/// One-shot markers for the cleanups a terminal `Snapshot` performs, mirroring
251/// [`HookExecutionStatus`]: the stamp IS the idempotence, so a stamped Snapshot's
252/// steady-state reconcile is a no-op forever.
253///
254/// This is not just tidiness. A terminal `Snapshot` is re-reconciled every 10
255/// minutes for the whole retention window (the steady-state requeue), and it is
256/// retained as long as the kopia snapshot it owns — months. An ungated cleanup
257/// probe would therefore re-issue its GETs against the apiserver, per Snapshot,
258/// forever, to re-discover that there is nothing left to clean. `pin_job_may_exist`
259/// exists in the same reconciler for exactly this reason.
260#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
261#[serde(rename_all = "camelCase")]
262pub struct CleanupStatus {
263    /// When the run's projected credential Secrets were reclaimed (RFC 3339);
264    /// absent until the reap has run. A projected copy is only needed while a mover
265    /// Job can still load it via `envFrom`, but it is owner-ref'd to this CR, which
266    /// long outlives that Job — so without an explicit reap it would sit in the
267    /// workload namespace holding live repository credentials until the CR is pruned
268    /// (#240).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub creds_reaped_at: Option<String>,
271}
272
273/// The CSI staging objects a backup created so kopia reads a point-in-time copy of the source PVC.
274#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
275#[serde(rename_all = "camelCase")]
276pub struct StagedSources {
277    /// The resolved capture method (`Snapshot` or `Clone`) that produced this stage.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub copy_method: Option<String>,
280    /// Name of the `VolumeSnapshot` created from the source PVC (`copyMethod: Snapshot` only).
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub volume_snapshot_name: Option<String>,
283    /// Name of the staged `PersistentVolumeClaim` the mover mounts in place of the live source PVC.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub pvc_name: Option<String>,
286    /// `true` once the stage is ready for the mover.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub ready: Option<bool>,
289    /// StorageClass of the staged PVC — `spec.staging.storageClassName` when set,
290    /// else the source PVC's class. Pinned for observability (e.g. confirming a
291    /// CephFS shallow-clone class actually took effect).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub storage_class_name: Option<String>,
294    /// The resolved `spec.staging.timeout` (seconds) pinned when the stage was
295    /// stamped, so the running-Job staged-PVC bind watchdog never re-resolves a
296    /// policy that may have been edited or deleted mid-run. `0` = wait
297    /// indefinitely.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub staging_timeout_seconds: Option<i64>,
300}
301
302/// When each hook list completed.
303#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
304#[serde(rename_all = "camelCase")]
305pub struct HookExecutionStatus {
306    /// When the `beforeSnapshot` list completed (RFC3339); absent until it has.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub pre_completed_at: Option<String>,
309    /// When the `afterSnapshot` list completed (RFC3339); absent until it has.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub post_completed_at: Option<String>,
312}
313
314/// Identifies the kopia snapshot a [`Snapshot`] CR owns.
315#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
316#[serde(rename_all = "camelCase")]
317pub struct SnapshotInfo {
318    /// kopia's snapshot ID — the handle the finalizer uses to delete content.
319    #[serde(rename = "kopiaSnapshotID")]
320    pub kopia_snapshot_id: String,
321    /// The `username@hostname:path` identity recorded for this snapshot.
322    pub identity: ResolvedIdentity,
323    /// The kopia snapshot description (`snapshot create --description`), when
324    /// one is recorded and non-empty. For discovered rows this is copied from
325    /// the repository listing TRUNCATED to 1024 bytes (char-boundary-safe) —
326    /// the value is foreign-writer-controlled and must never fail the CR write.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub description: Option<String>,
329}
330
331/// Timing of a snapshot run.
332#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
333#[serde(rename_all = "camelCase")]
334pub struct SnapshotTiming {
335    /// RFC3339 start time of the run.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub start_time: Option<String>,
338    /// RFC3339 end time of the run.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub end_time: Option<String>,
341    /// Wall-clock duration in seconds.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub duration_seconds: Option<i64>,
344}
345
346/// Stats populated from kopia's JSON output.
347#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
348#[serde(rename_all = "camelCase")]
349pub struct SnapshotStats {
350    /// Total logical size of the snapshot in bytes.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub size_bytes: Option<i64>,
353    /// Bytes newly uploaded this run (after dedup/compression).
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub bytes_new: Option<i64>,
356    /// Count of files new since the previous snapshot.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub files_new: Option<i64>,
359    /// Count of files changed since the previous snapshot.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub files_modified: Option<i64>,
362    /// Count of files unchanged since the previous snapshot.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub files_unchanged: Option<i64>,
365    /// Count of source entries kopia could not read and excluded, making the snapshot incomplete.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub files_failed: Option<i64>,
368}
369
370/// The mover Job backing a scheduled/manual `Snapshot`; absent for discovered.
371#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
372#[serde(rename_all = "camelCase")]
373pub struct JobStatus {
374    /// Name of the mover `Job`.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub name: Option<String>,
377    /// Number of attempts so far (bounded by `failurePolicy.backoffLimit`).
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub attempts: Option<i32>,
380}
381
382/// Frozen recipe values pinned at run time.
383#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
384#[serde(rename_all = "camelCase")]
385pub struct ResolvedSnapshot {
386    /// The repository this run targeted, frozen at run time.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub repository: Option<RepositoryRef>,
389    /// The concrete PVCs + source paths backed up this run.
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub sources: Vec<ResolvedSource>,
392    /// The recipe's `spec.credentialProjection` as it stood for this run.
393    ///
394    /// The deletion path re-projects the mover's credentials, but the opt-in lives on the
395    /// `SnapshotPolicy` — which a user may delete first. Pinning it here lets the finalizer
396    /// honor the opt-in that was actually in force, instead of reading an absent recipe as
397    /// "projection off" and blocking on a Secret that was never meant to be namespace-local
398    /// (#255). Absent only on a `Snapshot` that predates the pin or never ran; a run always
399    /// writes it, including `enabled: false`, so absent stays distinguishable from off.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub credential_projection: Option<CredentialProjection>,
402}
403
404/// One resolved source backed up by a run — a concrete PVC and its kopia path.
405#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
406#[serde(rename_all = "camelCase")]
407pub struct ResolvedSource {
408    /// `namespace/name` of the PVC, as kopia sees it.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub pvc: Option<String>,
411    /// The source path kopia recorded for this PVC.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub source_path: Option<String>,
414}
415
416/// Derive the repository a `Snapshot` belongs to: a *produced* snapshot pins it
417/// in `status.resolved.repository`; a *discovered* snapshot carries its
418/// `Repository`/`ClusterRepository` as the controller `ownerReference` (it has
419/// no `resolved` block). Pure. Shared by the `Restore` reconciler
420/// (`spec.repository` derivation for `snapshotRef`) and the `kubectl kopiur`
421/// browse data-plane, so the derivation rule cannot fork.
422pub 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            // Absent = resolved relative to the Snapshot's own namespace.
450            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        // Guards the enumerate-and-reset contract: every variant is in ALL with
481        // a unique, non-empty label. A new variant added without updating ALL
482        // makes this fail (and `label`'s exhaustive match won't compile at all).
483        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        // Default is reachable through ALL.
491        assert!(SnapshotPhase::ALL.contains(&SnapshotPhase::default()));
492    }
493
494    /// Regression for the inert "derived from source" contract (found by the
495    /// kubectl-plugin e2e): a snapshotRef Restore with no spec.repository was
496    /// refused with "restore requires spec.repository" even though the CRD
497    /// documents derivation. The pure derivation must cover both snapshot
498    /// origins. (Moved here from the controller when the browse data-plane
499    /// started sharing it.)
500    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            // A non-kopiur owner (e.g. a Job) must not be mistaken for a repository.
552            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        // Mirrors ADR-0001 §3.4 spec block + §5.6.
579        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        // Discovered backups carry no spec fields.
602        let spec: SnapshotSpec = from_yaml("{}\n");
603        assert!(spec.policy_ref.is_none());
604        assert!(spec.deletion_policy.is_none());
605        // Empty spec serializes to an empty object (all fields skip).
606        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        // DeletionPolicy is Copy (ADR-0003 §4.5).
624        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        // Absent stays absent (no schema default — the safety default lives in
644        // the controller resolver, not here).
645        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        // Mirrors ADR-0001 §3.4 status block.
697        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        // Absent stays absent — no null/{} noise on old rows.
775        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        // A newer operator wrote `src: workload` onto status; this version's
784        // typed watcher must decode it (graceful-decode convention), not error.
785        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}