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//! 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//! - `adopted`   — a discovered row re-attached to a live `SnapshotPolicy`.
9//! - `replicated`— a dest-side copy CR minted by a `SnapshotReplication` run;
10//!   spec carries `repository` (the destination pin) and no `policyRef`.
11
12use crate::common::{
13    CredentialProjection, DeletionPolicy, FailurePolicy, MoverSpec, PolicyRef, RepositoryRef,
14    ResolvedIdentity, ScheduleDeletePolicy,
15};
16use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
17use kube::CustomResource;
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::collections::BTreeMap;
21
22/// A single kopia snapshot represented as a Kubernetes object.
23#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
24#[kube(
25    group = "kopiur.home-operations.com",
26    version = "v1alpha1",
27    kind = "Snapshot",
28    namespaced,
29    status = "SnapshotStatus",
30    shortname = "kopiasnap",
31    category = "kopiur",
32    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
33    printcolumn = r#"{"name":"Origin","type":"string","jsonPath":".status.origin"}"#,
34    printcolumn = r#"{"name":"Snapshot","type":"string","jsonPath":".status.snapshot.kopiaSnapshotID"}"#,
35    // The PVC this run covers. Blank for a single-source policy (the recipe's
36    // one source IS the answer); populated for every child of a `pvcSelector`
37    // expansion, where a bare `kubectl get snapshots` would otherwise show N
38    // rows with the same policy and no way to tell them apart.
39    printcolumn = r#"{"name":"Source","type":"string","jsonPath":".spec.source.target.pvc.name"}"#,
40    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
41)]
42#[serde(rename_all = "camelCase")]
43pub struct SnapshotSpec {
44    /// The `SnapshotPolicy` recipe to run; absent for `discovered` backups.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub policy_ref: Option<PolicyRef>,
47    /// The ONE repository this `Snapshot` targets, pinned by value at mint
48    /// time. Stamped by a multi-repository `SnapshotPolicy` fan-out (each child
49    /// covers exactly one member of the policy's repository set) and by
50    /// `SnapshotReplication` copy CRs (the destination repository). Absent for
51    /// the legacy single-repository case, where the policy's own
52    /// `spec.repository` (or, for catalog rows, the owning repository CR) is
53    /// the answer — an absent pin resolves exactly as before this field
54    /// existed, so pre-feature `Snapshot`s are untouched.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub repository: Option<RepositoryRef>,
57    /// The ONE concrete source this `Snapshot` covers, when `policyRef` names a
58    /// recipe whose `sources[]` expands to many — i.e. a
59    /// [`pvcSelector`](crate::snapshot_policy::PvcSelector).
60    ///
61    /// Stamped by whoever minted the CR: a `SnapshotSchedule` fire, or
62    /// `kubectl kopiur snapshot now`. Absent for the ordinary single-source
63    /// case, where the policy's own `sources[0]` is the target.
64    ///
65    /// Absent against a *selector* policy is refused rather than guessed. The
66    /// operator must never pick a PVC on the user's behalf: silently backing up
67    /// one arbitrary volume out of N looks exactly like success.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub source: Option<SnapshotSourceRef>,
70    /// Free-form tags attached to the kopia snapshot manifest itself
71    /// (`snapshot create --tags`), e.g. `reason: pre-upgrade` — durable in the
72    /// repository, independent of this CR. Keys must be non-empty, colon-free
73    /// (kopia splits on the first colon), and must not start with the reserved
74    /// `kopiur` prefix; at most 10 tags, keys ≤ 63 bytes, values ≤ 256 bytes
75    /// (webhook-enforced).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub tags: Option<BTreeMap<String, String>>,
78    /// Mover Job retry and deadline limits for this run.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub failure_policy: Option<FailurePolicy>,
81    /// What happens to the kopia snapshot when this CR is deleted.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub deletion_policy: Option<DeletionPolicy>,
84    /// What the schedule-deletion cascade does to this Snapshot: consulted by the
85    /// finalizer ONLY when the deletion is external (not an operator prune) and the
86    /// owning `SnapshotSchedule` is gone or replaced (ownerRef UID mismatch).
87    /// `Retain` downgrades an effective `Delete` so the kopia snapshot survives;
88    /// `Delete` lets the Snapshot's own `deletionPolicy` cascade. Stamped at
89    /// creation from the schedule's `spec.deletion.onScheduleDelete`; absent
90    /// (pre-upgrade Snapshots, manual/discovered Snapshots) resolves to `Retain`.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub on_schedule_delete: Option<ScheduleDeletePolicy>,
93    /// Exempt this snapshot from GFS retention.
94    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95    pub pin: bool,
96    /// Free-form text recorded on the kopia snapshot manifest
97    /// (`snapshot create --description`). Per-invocation by nature —
98    /// scheduled/discovered `Snapshot`s never set this (no templated
99    /// descriptions).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    #[schemars(length(max = 1024))]
102    pub description: Option<String>,
103    /// Per-run mover overrides for THIS snapshot's Job (resources, cache budgets,
104    /// `securityContext`, `privilegedMode`, `ttlSecondsAfterFinished`).
105    ///
106    /// Layered field-wise as `repository.moverDefaults < policyRef's mover <
107    /// this mover` — the highest layer that sets a field wins, and a field you
108    /// omit falls through, so a partial override here can only adjust what it
109    /// names. This exists so an ad-hoc one-shot (`kubectl create` before a risky
110    /// change, a debug run that needs more memory or a different UID) does not
111    /// require editing the shared, GitOps-managed `SnapshotPolicy` that every
112    /// scheduled run also uses.
113    ///
114    /// Two deliberate exclusions:
115    ///
116    /// * `mover.cache` here is **run-scoped only**. It sets this run's kopia cache
117    ///   budgets (`--content-cache-size-mb`/`--metadata-cache-size-mb`) and, when
118    ///   the cache is `Ephemeral`, that Job's cache-volume `capacity` and
119    ///   `storageClassName` — a generic ephemeral volume bound to this pod and
120    ///   GC'd with it. What it cannot touch is anything **shared**: `cache.mode`
121    ///   is policy-owned (a per-run `mode: Persistent` never mints the shared
122    ///   PVC), and under `mode: Persistent` the PVC's own `capacity`/
123    ///   `storageClassName` come from the policy alone, because that claim is
124    ///   named per-POLICY and every sibling `Snapshot` depends on it.
125    ///
126    /// * `inheritSecurityContextFrom.snapshot` is restore-only and rejected here,
127    ///   exactly as on `SnapshotPolicy`: a backup's identity comes from the live
128    ///   workload; it is the run that *records* an identity.
129    ///
130    /// An elevated mover assembled here is gated by the namespace's
131    /// `privileged-movers` opt-in just like a policy-level one — the gate runs on
132    /// the merged result.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub mover: Option<MoverSpec>,
135}
136
137/// Which source of the referenced `SnapshotPolicy` this `Snapshot` covers, and
138/// what that source resolved to at expansion time.
139#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
140#[serde(rename_all = "camelCase")]
141pub struct SnapshotSourceRef {
142    /// Zero-based index into `policyRef`'s `spec.sources` this child expanded
143    /// from.
144    ///
145    /// Pins WHICH source's knobs (`readOnly`, `sourcePathOverride`,
146    /// `sourcePathStrategy`, `acknowledgeLiveMutation`) govern this run, so a
147    /// policy carrying several sources stays unambiguous. An index that is out
148    /// of range at reconcile time — the policy shrank mid-run — is a named
149    /// terminal failure, never a silent fallback to `sources[0]`.
150    pub source_index: u32,
151    /// What the expansion resolved to.
152    pub target: SnapshotSourceTarget,
153    /// The consistency group this child belongs to, present only when the
154    /// policy asked for one (`groupBy: VolumeGroupSnapshot`) AND the expansion
155    /// produced more than one member in this namespace.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub group: Option<SnapshotSourceGroup>,
158}
159
160/// The resolved target of one expanded source.
161///
162/// Externally tagged (`target: { pvc: {...} }`) per the repo's
163/// discriminated-union rule: internally-tagged enums break Kubernetes
164/// structural-schema generation. A future expandable source kind cannot compile
165/// until every handler accounts for it.
166#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
167#[serde(rename_all = "camelCase")]
168pub enum SnapshotSourceTarget {
169    /// One `PersistentVolumeClaim`, fully qualified.
170    Pvc(PvcTargetRef),
171}
172
173/// A fully-qualified `PersistentVolumeClaim` reference.
174#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
175#[serde(rename_all = "camelCase")]
176pub struct PvcTargetRef {
177    /// Namespace of the matched `PersistentVolumeClaim`.
178    ///
179    /// Explicit rather than inferred from the `Snapshot`'s own namespace: a
180    /// `pvcSelector` under a `ClusterRepository` may match across namespaces.
181    pub namespace: String,
182    /// Name of the matched `PersistentVolumeClaim`.
183    pub name: String,
184}
185
186/// The shared CSI `VolumeGroupSnapshot` every member of one expansion stages
187/// from.
188///
189/// Pinned to the SPEC, not derived per reconcile, for the same reason
190/// `status.staged.stagingTimeoutSeconds` is pinned: the group is an
191/// *invocation*-time decision, and a policy edited or deleted mid-run must
192/// never move the object a live member is waiting on — or reaping.
193#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
194#[serde(rename_all = "camelCase")]
195pub struct SnapshotSourceGroup {
196    /// Namespace the `VolumeGroupSnapshot` lives in.
197    ///
198    /// A `VolumeGroupSnapshot` is namespaced and its `source.selector` is
199    /// namespace-local, so a selector spanning namespaces yields ONE GROUP PER
200    /// NAMESPACE, not one group. The consistency guarantee is per-namespace and
201    /// this field is where that shows.
202    pub namespace: String,
203    /// Name of the shared `VolumeGroupSnapshot`.
204    pub volume_group_snapshot_name: String,
205}
206
207/// How a `Snapshot` came to exist. Canonical value mirrored from the
208/// `kopiur.home-operations.com/origin` label. Origin drives the deletion-policy
209/// default: `discovered` backups are forced to `Retain` because the operator did
210/// not create those snapshots.
211#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
212#[serde(rename_all = "camelCase")]
213pub enum Origin {
214    /// Created by a `SnapshotSchedule`; spec carries `policyRef`.
215    #[default]
216    Scheduled,
217    /// Created by `kubectl create` / external automation; spec carries `policyRef`.
218    Manual,
219    /// Materialized by the catalog scan for a snapshot kopiur didn't produce.
220    Discovered,
221    /// A `discovered` snapshot whose resolved identity matched a live
222    /// `SnapshotPolicy` and was automatically (or explicitly) re-attached to
223    /// it: it now carries that policy's config label and is retention-governed
224    /// like any produced row, even though the operator did not create the
225    /// underlying kopia snapshot.
226    Adopted,
227    /// A destination-side copy CR minted by a `SnapshotReplication` run: the
228    /// kopia snapshot it represents was `snapshot migrate`d from another
229    /// repository, not produced by a backup run here. Like `discovered`/
230    /// `adopted` it is catalog history — it must never enter the backup-run
231    /// machinery — but it is pruned only by its `SnapshotReplication`'s own
232    /// pruning mode, never by any policy's GFS retention.
233    Replicated,
234}
235
236impl Origin {
237    /// Every variant, for the label-value ↔ [`parse`](Self::parse) round-trip
238    /// tests and consumer-side variant-count guards (e.g. the CLI's
239    /// `OriginFilter`). A new variant added without extending this array fails
240    /// the round-trip test (and `label_value`/`parse`'s exhaustive matches
241    /// won't compile until it is classified).
242    pub const ALL: &'static [Self] = &[
243        Self::Scheduled,
244        Self::Manual,
245        Self::Discovered,
246        Self::Adopted,
247        Self::Replicated,
248    ];
249}
250
251/// Lifecycle phase of a `Snapshot`.
252///
253/// ```
254/// use kopiur_api::SnapshotPhase;
255/// use kopiur_api::common::PhaseLabel;
256///
257/// // Canonical values round-trip as bare strings.
258/// assert_eq!(serde_json::to_value(SnapshotPhase::Succeeded).unwrap(), "Succeeded");
259/// let p: SnapshotPhase = serde_json::from_value(serde_json::json!("Running")).unwrap();
260/// assert_eq!(p, SnapshotPhase::Running);
261///
262/// // A phase written by a NEWER operator decodes into `Unknown` (never a
263/// // watcher-poisoning serde error) and re-serializes verbatim.
264/// let p: SnapshotPhase = serde_json::from_value(serde_json::json!("Quiescing")).unwrap();
265/// assert_eq!(p, SnapshotPhase::Unknown("Quiescing".into()));
266/// assert_eq!(serde_json::to_value(&p).unwrap(), "Quiescing");
267/// assert_eq!(p.label(), "Quiescing");
268/// // Never terminal: an unrecognized phase is held and surfaced, not finished.
269/// assert!(!p.is_terminal());
270/// ```
271#[derive(Clone, Debug, PartialEq, Eq, Default)]
272pub enum SnapshotPhase {
273    /// Admitted, not yet started (also the default).
274    #[default]
275    Pending,
276    /// Mover Job is in flight.
277    Running,
278    /// Snapshot created successfully.
279    Succeeded,
280    /// Mover Job exhausted its retries.
281    Failed,
282    /// CR is being deleted; finalizer is reclaiming the snapshot.
283    Deleting,
284    /// Catalog-materialized backup kopiur didn't produce.
285    Discovered,
286    /// The backup ran to completion but kopia wrote **no new manifest**: the
287    /// source was byte-identical to the previous snapshot, and this policy has
288    /// [`files.ignoreIdenticalSnapshots`](crate::snapshot_policy::Files::ignore_identical_snapshots)
289    /// enabled.
290    ///
291    /// Terminal, and a **success**: the source was read and hashed, and it is
292    /// protected — by the *previous* snapshot, which remains the live restore
293    /// point. So an `Unchanged` run advances every liveness signal (last-backup
294    /// timestamp, policy health, failure-streak reset) exactly like `Succeeded`.
295    ///
296    /// What it does NOT do is own a kopia manifest. `status.snapshot` is absent,
297    /// the finalizer has nothing to reclaim, and it takes no GFS retention slot
298    /// — a restore point that does not exist must not displace one that does.
299    /// Recording it as `Succeeded` instead would make the controller resolve
300    /// "its" snapshot and find its predecessor's, leaving two CRs claiming one
301    /// manifest and the first prune deleting it out from under the second.
302    ///
303    /// Unreachable unless the policy opts in: the mover pins
304    /// `--ignore-identical-snapshots=false` at the identity scope on every run.
305    /// See #351.
306    Unchanged,
307    /// A phase string this build does not recognize — written by a newer
308    /// operator during a rolling upgrade, or persisted before this variant set
309    /// existed. Decode-compat only: hidden from the CRD schema (the apiserver
310    /// rejects it on every new write) and never produced by this build.
311    ///
312    /// Never terminal, never schedulable, never reapable, never a success —
313    /// every consumer holds and surfaces it rather than acting on a phase whose
314    /// meaning it does not know.
315    Unknown(String),
316}
317
318crate::common::phase_serde!(SnapshotPhase, "Lifecycle phase of a `Snapshot`.");
319
320impl Origin {
321    /// The stable wire/label value (the serde camelCase encoding), for the
322    /// `kopiur.home-operations.com/origin` label and `status.origin` — single
323    /// definition so producers (controller, kubectl plugin) cannot drift.
324    pub fn label_value(self) -> &'static str {
325        match self {
326            Self::Scheduled => "scheduled",
327            Self::Manual => "manual",
328            Self::Discovered => "discovered",
329            Self::Adopted => "adopted",
330            Self::Replicated => "replicated",
331        }
332    }
333
334    /// Strict, TOTAL parse of an origin marker (the `origin` label /
335    /// `status.origin` wire value): `None` for anything unrecognized.
336    ///
337    /// The single inverse of [`label_value`](Self::label_value), so string
338    /// matchers (the controller's `resolve_origin`, the webhook's
339    /// `backup_origin`) can never silently classify an unknown origin as a
340    /// known one. The pre-parse versions of both defaulted unknown strings to
341    /// `Manual` — which would have routed a row written by a NEWER operator
342    /// (e.g. `replicated` before this variant existed) into the backup-run
343    /// machinery and minted a mover Job for a snapshot this build does not
344    /// understand. Callers must treat `None` conservatively: warn + inert
345    /// handling, never `Manual`.
346    pub fn parse(v: &str) -> Option<Self> {
347        // One arm per variant (each also pinned by the ALL round-trip test);
348        // the trailing arm is the UNKNOWN-string case, not a variant catch-all.
349        match v {
350            "scheduled" => Some(Self::Scheduled),
351            "manual" => Some(Self::Manual),
352            "discovered" => Some(Self::Discovered),
353            "adopted" => Some(Self::Adopted),
354            "replicated" => Some(Self::Replicated),
355            _ => None,
356        }
357    }
358}
359
360/// Which operator lifecycle removed a Snapshot (the `pruned-by` annotation).
361#[derive(Clone, Copy, Debug, PartialEq, Eq)]
362pub enum PrunedBy {
363    /// GFS retention prune (`SnapshotPolicy.spec.retention`).
364    Retention,
365    /// `SnapshotSchedule.spec.failedJobsHistoryLimit` prune.
366    FailedHistory,
367    /// Policy-deletion cascade under `onPolicyDelete: Retain` — release the
368    /// CR, never contact the repository.
369    PolicyCascade,
370    /// A `SnapshotReplication`'s own retention prune of its dest-side copy CRs
371    /// (`pruning: retention`). An OPERATOR prune exactly like `Retention`:
372    /// bounded, deliberate, breaker-exempt. (`pruning: mirrorSource` deletes
373    /// deliberately carry NO stamp, so a mass source-vanish classifies EXTERNAL
374    /// and the dest repository's breaker holds it.)
375    ReplicationRetention,
376    /// `SnapshotSchedule.spec.schedule.concurrencyPolicy: Replace` cancelled
377    /// this still-unfinished run so the newly-due slot could take its place.
378    /// An OPERATOR prune: bounded by construction (at most this schedule's own
379    /// unfinished children, at most one slot's worth per fire) and deliberate —
380    /// the user asked for cancel-the-old — so it is breaker-EXEMPT. Without the
381    /// stamp every `Replace` fire would classify EXTERNAL and a busy schedule
382    /// would trip its repository's mass-deletion breaker.
383    ReplacedRun,
384}
385
386impl PrunedBy {
387    /// Every variant, for the annotation-value ↔ [`parse`](Self::parse)
388    /// round-trip test. A new variant added without extending this array fails
389    /// that test (and the exhaustive matches here and in the controller's
390    /// deletion planner won't compile until it is classified).
391    pub const ALL: &'static [Self] = &[
392        Self::Retention,
393        Self::FailedHistory,
394        Self::PolicyCascade,
395        Self::ReplicationRetention,
396        Self::ReplacedRun,
397    ];
398
399    /// The stable annotation value stamped by the operator before it deletes a
400    /// `Snapshot` as part of its own lifecycle (see [`crate::consts::PRUNED_BY_ANNOTATION`]).
401    pub fn annotation_value(self) -> &'static str {
402        match self {
403            Self::Retention => "retention",
404            Self::FailedHistory => "failed-history",
405            Self::PolicyCascade => "policy-cascade",
406            Self::ReplicationRetention => "replication-retention",
407            Self::ReplacedRun => "replaced-run",
408        }
409    }
410
411    /// Strict parse: `None` for anything unrecognized (the finalizer must treat
412    /// that as an EXTERNAL deletion — never guess "operator").
413    pub fn parse(v: &str) -> Option<Self> {
414        match v {
415            "retention" => Some(Self::Retention),
416            "failed-history" => Some(Self::FailedHistory),
417            "policy-cascade" => Some(Self::PolicyCascade),
418            "replication-retention" => Some(Self::ReplicationRetention),
419            "replaced-run" => Some(Self::ReplacedRun),
420            _ => None,
421        }
422    }
423}
424
425impl SnapshotPhase {
426    /// Whether this phase is **terminal**: the operator will do no further work
427    /// on the object of its own accord, so a diagnostic must not report it as
428    /// in-flight (nor as stuck).
429    ///
430    /// `Deleting` is deliberately **not** terminal. A CR sitting in `Deleting`
431    /// has a finalizer that is still trying to reclaim its kopia snapshot — a
432    /// wedged finalizer (an unreachable backend, a held mass-deletion breaker)
433    /// is in-flight work that never completes, which is exactly the state worth
434    /// surfacing. Classifying it terminal is how a stuck deletion becomes
435    /// invisible.
436    ///
437    /// `Discovered` and `Unchanged` ARE terminal: a discovered CR mirrors a
438    /// kopia snapshot the operator did not produce and never advances on its
439    /// own, and an `Unchanged` run already finished (successfully, owning no
440    /// manifest). A `Discovered` CR that is later deleted moves to `Deleting`
441    /// like any other, so nothing is lost by treating the phase itself as done.
442    ///
443    /// Pure + exhaustive so the single definition lives in one tested place —
444    /// the CLI and the controller must never disagree about what "still
445    /// working" means.
446    ///
447    /// ```
448    /// use kopiur_api::SnapshotPhase;
449    ///
450    /// assert!(SnapshotPhase::Succeeded.is_terminal());
451    /// assert!(SnapshotPhase::Failed.is_terminal());
452    /// assert!(SnapshotPhase::Discovered.is_terminal());
453    /// assert!(SnapshotPhase::Unchanged.is_terminal());
454    /// assert!(!SnapshotPhase::Pending.is_terminal());
455    /// assert!(!SnapshotPhase::Running.is_terminal());
456    /// // A wedged finalizer is in-flight work, not a finished object.
457    /// assert!(!SnapshotPhase::Deleting.is_terminal());
458    /// // An unrecognized phase is never terminal — hold and surface it.
459    /// assert!(!SnapshotPhase::Unknown("Quiescing".into()).is_terminal());
460    /// ```
461    pub fn is_terminal(&self) -> bool {
462        match self {
463            Self::Succeeded | Self::Failed | Self::Discovered | Self::Unchanged => true,
464            Self::Pending | Self::Running | Self::Deleting => false,
465            // Conservative surface-it policy: a phase this build cannot
466            // interpret must never be reported as finished work, or a newer
467            // operator's in-flight (or wedged) object goes invisible to an
468            // older CLI/reconciler. Not-terminal keeps it in every "still
469            // working / worth looking at" set.
470            Self::Unknown(_) => false,
471        }
472    }
473
474    /// Whether this phase is the **decode sentinel** — a value the running build
475    /// cannot interpret, kept verbatim by [`Unknown`](Self::Unknown) instead of
476    /// failing the whole typed `list()`/watch (#359, defect 3).
477    ///
478    /// The contract is narrow on purpose, and it is the reason this is a method
479    /// rather than an inline `matches!` at each caller: `true` means *only*
480    /// "this string is not a phase this binary knows", never "unusual" or
481    /// "not one I handle". A canonical variant added to this enum later is by
482    /// definition **not** the sentinel, so `false` is the right answer for it —
483    /// which is exactly why the exhaustive `match` below is written out. Callers
484    /// asking a set-shaped question ("is this finished?", "is this a failure?")
485    /// want [`is_terminal`](Self::is_terminal) or their own exhaustive match,
486    /// not this.
487    ///
488    /// ```
489    /// use kopiur_api::SnapshotPhase;
490    ///
491    /// assert!(SnapshotPhase::Unknown("Quiescing".into()).is_unknown());
492    /// assert!(!SnapshotPhase::Succeeded.is_unknown());
493    /// assert!(!SnapshotPhase::Failed.is_unknown());
494    /// assert!(!SnapshotPhase::Deleting.is_unknown());
495    /// ```
496    pub fn is_unknown(&self) -> bool {
497        match self {
498            Self::Unknown(_) => true,
499            Self::Pending
500            | Self::Running
501            | Self::Succeeded
502            | Self::Failed
503            | Self::Deleting
504            | Self::Discovered
505            | Self::Unchanged => false,
506        }
507    }
508}
509
510impl crate::common::PhaseLabel for SnapshotPhase {
511    const ALL: &'static [Self] = &[
512        Self::Pending,
513        Self::Running,
514        Self::Succeeded,
515        Self::Failed,
516        Self::Deleting,
517        Self::Discovered,
518        Self::Unchanged,
519    ];
520    fn label(&self) -> &str {
521        match self {
522            Self::Pending => "Pending",
523            Self::Running => "Running",
524            Self::Succeeded => "Succeeded",
525            Self::Failed => "Failed",
526            Self::Deleting => "Deleting",
527            Self::Discovered => "Discovered",
528            Self::Unchanged => "Unchanged",
529            Self::Unknown(s) => s,
530        }
531    }
532    fn unknown(raw: String) -> Self {
533        Self::Unknown(raw)
534    }
535}
536
537/// Observed state of a [`Snapshot`].
538#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
539#[serde(rename_all = "camelCase")]
540pub struct SnapshotStatus {
541    /// Current lifecycle phase.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub phase: Option<SnapshotPhase>,
544    /// Canonical origin (also mirrored to the `origin` label).
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub origin: Option<Origin>,
547    /// `metadata.generation` last reconciled, for staleness detection.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub observed_generation: Option<i64>,
550    /// The kopia artifact this CR represents.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub snapshot: Option<SnapshotInfo>,
553    /// Start/end/duration of the snapshot run.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub timing: Option<SnapshotTiming>,
556    /// Byte/file counts parsed from kopia's JSON output.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub stats: Option<SnapshotStats>,
559    /// The mover Job backing this run; absent for discovered.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub job: Option<JobStatus>,
562    /// Frozen recipe values at run time (scheduled/manual).
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub resolved: Option<ResolvedSnapshot>,
565    /// Standard Kubernetes conditions (e.g. `SourcesQuiesced`, `SnapshotCreated`).
566    #[serde(default, skip_serializing_if = "Vec::is_empty")]
567    pub conditions: Vec<Condition>,
568    /// The last lines of the run's output, written by the mover at the terminal transition.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub log_tail: Option<String>,
571    /// Structured terminal-failure detail (kopia error class, stderr tail, retry hint).
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub failure: Option<crate::common::FailureBlock>,
574    /// The observed kopia-side pin state: `Some(true)` if pinned, `Some(false)` if unpinned, `None` before any pin reconcile.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub pinned: Option<bool>,
577    /// Hook-execution bookkeeping so each hook list runs exactly once per Snapshot.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub hooks: Option<HookExecutionStatus>,
580    /// The CSI staging objects the run created for `copyMethod: Snapshot`/`Clone`.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub staged: Option<StagedSources>,
583    /// RFC 3339 timestamp of the first reconcile where the repository was `Ready`
584    /// but a `spec.preflight` check was failing. The one-shot anchor for the
585    /// preflight `timeout` deadline (so the budget covers preflight only, not the
586    /// earlier repository-not-Ready wait). Cleared once every preflight check passes,
587    /// so a later failing episode gets a fresh budget rather than a stale anchor.
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub preflight_since: Option<String>,
590    /// Post-run cleanup bookkeeping, so each cleanup runs at most once per Snapshot.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub cleanup: Option<CleanupStatus>,
593    /// The mover identity recorded on the kopia snapshot itself (the
594    /// `kopiur-meta` tag): the resolved effective uid/gid/fsGroup the backup ran
595    /// as, plus its provenance. Produced runs stamp this at launch (from the
596    /// same value written into the tag); discovered rows decode it from the tag
597    /// during the catalog scan. Absent for pre-feature snapshots, foreign
598    /// backups without the tag, or a tag this operator version cannot decode.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub recorded: Option<crate::recorded::RecordedSnapshotMeta>,
601    /// Lineage for `origin: replicated` rows: the source repository, source
602    /// manifest id, and `startTime` the copy was migrated from. Written by the
603    /// `SnapshotReplication` mover in the same atomic PATCH as
604    /// `status.snapshot`; absent on every other origin. See [`CopiedFrom`] for
605    /// why this lives on the CR rather than as kopia tags.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub copied_from: Option<CopiedFrom>,
608}
609
610/// One-shot markers for the cleanups a terminal `Snapshot` performs, mirroring
611/// [`HookExecutionStatus`]: the stamp IS the idempotence, so a stamped Snapshot's
612/// steady-state reconcile is a no-op forever.
613///
614/// This is not just tidiness. A terminal `Snapshot` is re-reconciled every 10
615/// minutes for the whole retention window (the steady-state requeue), and it is
616/// retained as long as the kopia snapshot it owns — months. An ungated cleanup
617/// probe would therefore re-issue its GETs against the apiserver, per Snapshot,
618/// forever, to re-discover that there is nothing left to clean. `pin_job_may_exist`
619/// exists in the same reconciler for exactly this reason.
620#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
621#[serde(rename_all = "camelCase")]
622pub struct CleanupStatus {
623    /// When the run's projected credential Secrets were reclaimed (RFC 3339);
624    /// absent until the reap has run. A projected copy is only needed while a mover
625    /// Job can still load it via `envFrom`, but it is owner-ref'd to this CR, which
626    /// long outlives that Job — so without an explicit reap it would sit in the
627    /// workload namespace holding live repository credentials until the CR is pruned
628    /// (#240).
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub creds_reaped_at: Option<String>,
631}
632
633/// The CSI staging objects a backup created so kopia reads a point-in-time copy of the source PVC.
634#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
635#[serde(rename_all = "camelCase")]
636pub struct StagedSources {
637    /// Name of the shared CSI `VolumeGroupSnapshot` this member staged from,
638    /// when the recipe asked for a consistency group
639    /// ([`groupBy: VolumeGroupSnapshot`](crate::snapshot_policy::GroupBy)).
640    ///
641    /// Recorded because the group is otherwise invisible: it deliberately
642    /// carries no ownerReferences (see `io::group_staging`), so this is how an
643    /// operator tells which capture a backup came from — and how `kubectl
644    /// kopiur doctor` finds one that outlived its members.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub volume_group_snapshot_name: Option<String>,
647    /// The resolved capture method (`Snapshot` or `Clone`) that produced this stage.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub copy_method: Option<String>,
650    /// Name of the `VolumeSnapshot` created from the source PVC (`copyMethod: Snapshot` only).
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub volume_snapshot_name: Option<String>,
653    /// Name of the staged `PersistentVolumeClaim` the mover mounts in place of the live source PVC.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub pvc_name: Option<String>,
656    /// `true` once the stage is ready for the mover.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub ready: Option<bool>,
659    /// StorageClass of the staged PVC — `spec.staging.storageClassName` when set,
660    /// else the source PVC's class. Pinned for observability (e.g. confirming a
661    /// CephFS shallow-clone class actually took effect).
662    #[serde(default, skip_serializing_if = "Option::is_none")]
663    pub storage_class_name: Option<String>,
664    /// The resolved `spec.staging.timeout` (seconds) pinned when the stage was
665    /// stamped, so the running-Job staged-PVC bind watchdog never re-resolves a
666    /// policy that may have been edited or deleted mid-run. `0` = wait
667    /// indefinitely.
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub staging_timeout_seconds: Option<i64>,
670}
671
672/// When each hook list completed.
673#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
674#[serde(rename_all = "camelCase")]
675pub struct HookExecutionStatus {
676    /// When the `beforeSnapshot` list completed (RFC3339); absent until it has.
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub pre_completed_at: Option<String>,
679    /// When the `afterSnapshot` list completed (RFC3339); absent until it has.
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub post_completed_at: Option<String>,
682}
683
684/// Identifies the kopia snapshot a [`Snapshot`] CR owns.
685#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
686#[serde(rename_all = "camelCase")]
687pub struct SnapshotInfo {
688    /// kopia's snapshot ID — the handle the finalizer uses to delete content.
689    #[serde(rename = "kopiaSnapshotID")]
690    pub kopia_snapshot_id: String,
691    /// The `username@hostname:path` identity recorded for this snapshot.
692    pub identity: ResolvedIdentity,
693    /// The kopia snapshot description (`snapshot create --description`), when
694    /// one is recorded and non-empty. For discovered rows this is copied from
695    /// the repository listing TRUNCATED to 1024 bytes (char-boundary-safe) —
696    /// the value is foreign-writer-controlled and must never fail the CR write.
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub description: Option<String>,
699}
700
701/// Timing of a snapshot run.
702#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
703#[serde(rename_all = "camelCase")]
704pub struct SnapshotTiming {
705    /// RFC3339 start time of the run.
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub start_time: Option<String>,
708    /// RFC3339 end time of the run.
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub end_time: Option<String>,
711    /// Wall-clock duration in seconds.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub duration_seconds: Option<i64>,
714}
715
716/// Stats populated from kopia's JSON output.
717#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
718#[serde(rename_all = "camelCase")]
719pub struct SnapshotStats {
720    /// Total logical size of the snapshot in bytes.
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub size_bytes: Option<i64>,
723    /// Bytes newly uploaded this run (after dedup/compression).
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub bytes_new: Option<i64>,
726    /// Count of files new since the previous snapshot.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub files_new: Option<i64>,
729    /// Count of files changed since the previous snapshot.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub files_modified: Option<i64>,
732    /// Count of files unchanged since the previous snapshot.
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub files_unchanged: Option<i64>,
735    /// Count of source entries kopia could not read and excluded, making the snapshot incomplete.
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub files_failed: Option<i64>,
738}
739
740/// The mover Job backing a scheduled/manual `Snapshot`; absent for discovered.
741#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
742#[serde(rename_all = "camelCase")]
743pub struct JobStatus {
744    /// Name of the mover `Job`.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub name: Option<String>,
747    /// Number of attempts so far (bounded by `failurePolicy.backoffLimit`).
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub attempts: Option<i32>,
750}
751
752/// Frozen recipe values pinned at run time.
753#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
754#[serde(rename_all = "camelCase")]
755pub struct ResolvedSnapshot {
756    /// The repository this run targeted, frozen at run time.
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub repository: Option<RepositoryRef>,
759    /// The concrete PVCs + source paths backed up this run.
760    #[serde(default, skip_serializing_if = "Vec::is_empty")]
761    pub sources: Vec<ResolvedSource>,
762    /// The recipe's `spec.credentialProjection` as it stood for this run.
763    ///
764    /// The deletion path re-projects the mover's credentials, but the opt-in lives on the
765    /// `SnapshotPolicy` — which a user may delete first. Pinning it here lets the finalizer
766    /// honor the opt-in that was actually in force, instead of reading an absent recipe as
767    /// "projection off" and blocking on a Secret that was never meant to be namespace-local
768    /// (#255). Absent only on a `Snapshot` that predates the pin or never ran; a run always
769    /// writes it, including `enabled: false`, so absent stays distinguishable from off.
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub credential_projection: Option<CredentialProjection>,
772}
773
774/// Lineage of an `origin: replicated` row: where the copy came from.
775///
776/// `kopia snapshot migrate` cannot stamp tags onto the migrated manifest (it
777/// preserves the source manifest verbatim apart from assigning a new manifest
778/// id), so the provenance a dest-side copy CR needs — which repository it was
779/// copied FROM, which source manifest it corresponds to, and the `startTime`
780/// migrate keys idempotency on — cannot live in the kopia repository. It lives
781/// here, written by the replication mover in the same atomic status PATCH that
782/// records the destination manifest (`status.snapshot.kopiaSnapshotID`).
783#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
784#[serde(rename_all = "camelCase")]
785pub struct CopiedFrom {
786    /// The SOURCE repository the snapshot was migrated from (the
787    /// `SnapshotReplication`'s `sourceRef`, resolved at run time).
788    pub repository: RepositoryRef,
789    /// The kopia manifest id the snapshot had in the SOURCE repository.
790    /// Migrate assigns a NEW manifest id on the destination
791    /// (`status.snapshot.kopiaSnapshotID`); this is the old one, kept for
792    /// cross-repository correlation.
793    pub source_manifest_id: String,
794    /// The snapshot's RFC3339 `startTime` — preserved verbatim by migrate and
795    /// the key (together with the identity triple) both idempotent re-migration
796    /// and `pruning: mirrorSource` correlate source and destination rows on.
797    pub start_time: String,
798}
799
800/// One resolved source backed up by a run — a concrete PVC and its kopia path.
801#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
802#[serde(rename_all = "camelCase")]
803pub struct ResolvedSource {
804    /// `namespace/name` of the PVC, as kopia sees it.
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub pvc: Option<String>,
807    /// The source path kopia recorded for this PVC.
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub source_path: Option<String>,
810}
811
812/// Derive the repository a `Snapshot` belongs to, in fixed precedence order:
813///
814/// 1. `status.resolved.repository` — the run-time pin every *produced*
815///    snapshot records (controller-written, authoritative);
816/// 2. `spec.repository` — the mint-time pin a multi-repo policy fan-out child
817///    or a `SnapshotReplication` copy CR carries (present before any status
818///    has been written, and the only pin such a CR is guaranteed to have);
819/// 3. the `Repository`/`ClusterRepository` controller `ownerReference` a
820///    *discovered* snapshot carries (it has neither block).
821///
822/// Pure. Shared by the `Restore` reconciler (`spec.repository` derivation for
823/// `snapshotRef`) and the `kubectl kopiur` browse data-plane, so the
824/// derivation rule cannot fork.
825pub fn repository_ref_for(snap: &Snapshot) -> Option<RepositoryRef> {
826    use crate::common::RepositoryKind;
827    if let Some(rref) = snap
828        .status
829        .as_ref()
830        .and_then(|s| s.resolved.as_ref())
831        .and_then(|r| r.repository.clone())
832    {
833        return Some(rref);
834    }
835    if let Some(rref) = snap.spec.repository.clone() {
836        return Some(rref);
837    }
838    let owners = snap
839        .metadata
840        .owner_references
841        .as_deref()
842        .unwrap_or_default();
843    owners.iter().find_map(|o| {
844        if o.api_version != crate::consts::API_VERSION {
845            return None;
846        }
847        let kind = match o.kind.as_str() {
848            "Repository" => RepositoryKind::Repository,
849            "ClusterRepository" => RepositoryKind::ClusterRepository,
850            _ => return None,
851        };
852        Some(RepositoryRef {
853            kind,
854            name: o.name.clone(),
855            // Absent = resolved relative to the Snapshot's own namespace.
856            namespace: None,
857        })
858    })
859}
860
861/// The mover configuration a backup run actually uses: `Snapshot.spec.mover`
862/// merged over the recipe's `SnapshotPolicy.spec.mover` (#464).
863///
864/// **Pure**, so the controller, the admission webhook and the CLI all read one
865/// answer — the layer order can never fork between them. The repository's
866/// `moverDefaults` is NOT folded in here: it enters underneath this result in
867/// [`resolve_mover`](crate::common::resolve_mover), which every consumer already
868/// calls, giving the full ladder
869/// `hardened ⊂ moverDefaults ⊂ inherited ⊂ policy.mover ⊂ snapshot.mover`.
870/// [`MoverSpec::merge_over`]'s context merge is associative, so pre-folding the
871/// top two layers here is identical to a flat merge of all five.
872///
873/// `None` only when neither layer sets a mover at all, so a Snapshot that carries
874/// none resolves byte-identically to the pre-feature behavior.
875pub fn effective_backup_mover(
876    snapshot: &SnapshotSpec,
877    policy: &crate::snapshot_policy::SnapshotPolicySpec,
878) -> Option<MoverSpec> {
879    match (snapshot.mover.as_ref(), policy.mover.as_ref()) {
880        (None, base) => base.cloned(),
881        (Some(over), base) => Some(over.merge_over(base)),
882    }
883}
884
885/// THE repository a policy-child `Snapshot` runs against, resolved from the
886/// mint-time pin + the referenced policy's repository set. **Pure.** This is
887/// the single decision every launch/deletion/pin/preflight path shares, so
888/// multi-repo pin semantics cannot fork between consumers.
889///
890/// The rule, in order:
891///
892/// 1. **No `policyRef`** — this is NOT a policy child (a `SnapshotReplication`
893///    copy CR, or a discovered row): the `policy` argument is not this row's
894///    recipe and is ignored; the row's own derivation
895///    ([`repository_ref_for`]: status pin → spec pin → owner ref) answers, or
896///    [`ValidationError::SnapshotRepositoryUnresolvable`] when it has none.
897/// 2. **Pin present** (`spec.repository`) — the pin wins, but it must still be
898///    a member of the policy's CURRENT repository set (compared by normalized
899///    [`repo_key`](crate::common::repo_key) against `policy_ns`); a pin the
900///    recipe no longer lists is the terminal
901///    [`ValidationError::SnapshotPinNotInPolicy`] ("the recipe was edited out
902///    from under this Snapshot's pin"), never a silent re-target.
903/// 3. **No pin, single-repo policy** — the policy's one `spec.repository`,
904///    verbatim (byte-identical to the pre-multi-repo behavior).
905/// 4. **No pin, multi-repo policy** —
906///    [`ValidationError::MultiRepoSnapshotUnpinned`]: the controller-side
907///    backstop of the admission rule; repository #1 is never guessed.
908///
909/// A malformed policy (neither/both repository shapes) surfaces as its
910/// [`ValidationError::PolicyRepositoryExactlyOne`].
911pub fn effective_repository_ref(
912    snap: &Snapshot,
913    policy: &crate::snapshot_policy::SnapshotPolicySpec,
914    policy_ns: &str,
915) -> Result<RepositoryRef, crate::error::ValidationError> {
916    use crate::common::repo_key;
917    use crate::snapshot_policy::{PolicyRepositories, policy_repositories};
918    use kube::ResourceExt;
919
920    if snap.spec.policy_ref.is_none() {
921        return repository_ref_for(snap).ok_or_else(|| {
922            crate::error::ValidationError::SnapshotRepositoryUnresolvable {
923                snapshot: snap.name_any(),
924            }
925        });
926    }
927    let repos = policy_repositories(policy)?;
928    if let Some(pin) = snap.spec.repository.as_ref() {
929        // The pin was stamped NORMALIZED at mint, so keying it against the
930        // policy's namespace is stable; the members normalize against the same
931        // namespace the policy resolves them in.
932        let pin_key = repo_key(pin, policy_ns);
933        let members: Vec<&RepositoryRef> = match repos {
934            PolicyRepositories::Single(r) => vec![r],
935            PolicyRepositories::Multi(rs) => rs.iter().collect(),
936        };
937        return match members.iter().find(|m| repo_key(m, policy_ns) == pin_key) {
938            Some(_) => Ok(pin.clone()),
939            None => Err(crate::error::ValidationError::SnapshotPinNotInPolicy {
940                pin: pin_key,
941                policy: snap
942                    .spec
943                    .policy_ref
944                    .as_ref()
945                    .map(|p| p.name.clone())
946                    .unwrap_or_default(),
947                valid: members
948                    .iter()
949                    .map(|m| repo_key(m, policy_ns))
950                    .collect::<Vec<_>>()
951                    .join(", "),
952            }),
953        };
954    }
955    match repos {
956        PolicyRepositories::Single(r) => Ok(r.clone()),
957        PolicyRepositories::Multi(_) => {
958            Err(crate::error::ValidationError::MultiRepoSnapshotUnpinned {
959                policy: snap
960                    .spec
961                    .policy_ref
962                    .as_ref()
963                    .map(|p| p.name.clone())
964                    .unwrap_or_default(),
965            })
966        }
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use crate::common::PhaseLabel;
974    use crate::testutil::from_yaml;
975    use kube::core::CustomResourceExt;
976
977    #[test]
978    fn origin_label_value_matches_the_serde_encoding() {
979        // Hard-coded ALL-variants array: adding a variant without classifying
980        // it here fails the length check, and the serde/label/parse trio must
981        // agree byte-for-byte for every variant.
982        let all = [
983            Origin::Scheduled,
984            Origin::Manual,
985            Origin::Discovered,
986            Origin::Adopted,
987            Origin::Replicated,
988        ];
989        assert_eq!(Origin::ALL, all, "Origin::ALL must list every variant");
990        for origin in all {
991            assert_eq!(
992                serde_json::to_value(origin).unwrap(),
993                origin.label_value(),
994                "{origin:?}"
995            );
996        }
997    }
998
999    #[test]
1000    fn origin_parse_is_the_exact_inverse_of_label_value() {
1001        for origin in [
1002            Origin::Scheduled,
1003            Origin::Manual,
1004            Origin::Discovered,
1005            Origin::Adopted,
1006            Origin::Replicated,
1007        ] {
1008            assert_eq!(
1009                Origin::parse(origin.label_value()),
1010                Some(origin),
1011                "{origin:?}"
1012            );
1013        }
1014        // Unknown strings never resolve — in particular never to Manual, the
1015        // pre-parse default that would mint a backup Job for a foreign row.
1016        for garbage in ["", "Manual", "SCHEDULED", "replicated ", "garbage"] {
1017            assert_eq!(Origin::parse(garbage), None, "{garbage:?}");
1018        }
1019    }
1020
1021    #[test]
1022    fn backup_phase_all_covers_every_variant_uniquely() {
1023        // Guards the enumerate-and-reset contract: every variant is in ALL with
1024        // a unique, non-empty label. A new variant added without updating ALL
1025        // makes this fail (and `label`'s exhaustive match won't compile at all).
1026        let labels: Vec<&str> = SnapshotPhase::ALL.iter().map(|p| p.label()).collect();
1027        assert_eq!(SnapshotPhase::ALL.len(), 7);
1028        assert!(labels.iter().all(|l| !l.is_empty()));
1029        let mut sorted = labels.clone();
1030        sorted.sort_unstable();
1031        sorted.dedup();
1032        assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
1033        // Default is reachable through ALL.
1034        assert!(SnapshotPhase::ALL.contains(&SnapshotPhase::default()));
1035    }
1036
1037    #[test]
1038    fn snapshot_terminal_set_is_pinned() {
1039        // Tripwire for the classifier every consumer (doctor, metrics, the
1040        // schedule's concurrency accounting) must agree on. Driven off ALL so a
1041        // NEW variant cannot join without a deliberate decision here — the
1042        // `is_terminal` match won't compile until it is classified, and this
1043        // assertion won't pass until the expected set is updated.
1044        let terminal: Vec<&str> = SnapshotPhase::ALL
1045            .iter()
1046            .filter(|p| p.is_terminal())
1047            .map(|p| p.label())
1048            .collect();
1049        assert_eq!(terminal, ["Succeeded", "Failed", "Discovered", "Unchanged"]);
1050        let in_flight: Vec<&str> = SnapshotPhase::ALL
1051            .iter()
1052            .filter(|p| !p.is_terminal())
1053            .map(|p| p.label())
1054            .collect();
1055        // `Deleting` stays here on purpose: a wedged finalizer is in-flight work.
1056        assert_eq!(in_flight, ["Pending", "Running", "Deleting"]);
1057    }
1058
1059    /// The 5-way contract of [`effective_repository_ref`] — the single
1060    /// launch/deletion/pin/preflight repository decision (multi-repo fan-out,
1061    /// #368). Specs are constructed directly, the same shapes admission
1062    /// accepts now that the M7 feature gate is lifted.
1063    mod effective_repository {
1064        use super::super::effective_repository_ref;
1065        use crate::error::ValidationError;
1066        use crate::{Snapshot, SnapshotPolicySpec};
1067
1068        fn snap(v: serde_json::Value) -> Snapshot {
1069            serde_json::from_value(v).expect("snapshot fixture")
1070        }
1071
1072        fn policy(v: serde_json::Value) -> SnapshotPolicySpec {
1073            serde_json::from_value(v).expect("policy fixture")
1074        }
1075
1076        fn multi_policy() -> SnapshotPolicySpec {
1077            policy(serde_json::json!({
1078                "repositories": [
1079                    { "kind": "Repository", "name": "a" },
1080                    { "kind": "ClusterRepository", "name": "b" },
1081                ],
1082                "sources": [ { "pvc": { "name": "d" } } ],
1083            }))
1084        }
1085
1086        #[test]
1087        fn no_policy_ref_short_circuits_to_the_rows_own_derivation() {
1088            // A replication copy CR: no policyRef, spec pin present. The policy
1089            // argument (a multi-repo recipe that does NOT list the pin) is
1090            // ignored — it is not this row's recipe.
1091            let s = snap(serde_json::json!({
1092                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1093                "kind": "Snapshot",
1094                "metadata": { "name": "copy", "namespace": "media" },
1095                "spec": { "repository": { "kind": "ClusterRepository", "name": "offsite" } }
1096            }));
1097            let r = effective_repository_ref(&s, &multi_policy(), "media").unwrap();
1098            assert_eq!(r.name, "offsite");
1099
1100            // …and a bare row with nothing to derive from is a NAMED error,
1101            // never a guess.
1102            let bare = snap(serde_json::json!({
1103                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1104                "kind": "Snapshot",
1105                "metadata": { "name": "bare", "namespace": "media" },
1106                "spec": {}
1107            }));
1108            assert!(matches!(
1109                effective_repository_ref(&bare, &multi_policy(), "media").unwrap_err(),
1110                ValidationError::SnapshotRepositoryUnresolvable { snapshot } if snapshot == "bare"
1111            ));
1112        }
1113
1114        #[test]
1115        fn pin_that_is_a_member_wins() {
1116            let s = snap(serde_json::json!({
1117                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1118                "kind": "Snapshot",
1119                "metadata": { "name": "s", "namespace": "media" },
1120                "spec": {
1121                    "policyRef": { "name": "pol" },
1122                    // Normalized pin: the Repository member resolves to
1123                    // media/a, and so does this explicit-namespace pin.
1124                    "repository": { "kind": "Repository", "name": "a", "namespace": "media" }
1125                }
1126            }));
1127            let r = effective_repository_ref(&s, &multi_policy(), "media").unwrap();
1128            assert_eq!(r.name, "a");
1129            assert_eq!(r.namespace.as_deref(), Some("media"));
1130        }
1131
1132        #[test]
1133        fn pin_edited_out_of_the_policy_is_terminal() {
1134            let s = snap(serde_json::json!({
1135                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1136                "kind": "Snapshot",
1137                "metadata": { "name": "s", "namespace": "media" },
1138                "spec": {
1139                    "policyRef": { "name": "pol" },
1140                    "repository": { "kind": "Repository", "name": "gone", "namespace": "media" }
1141                }
1142            }));
1143            let err = effective_repository_ref(&s, &multi_policy(), "media").unwrap_err();
1144            match err {
1145                ValidationError::SnapshotPinNotInPolicy { pin, policy, valid } => {
1146                    assert_eq!(pin, "Repository/media/gone");
1147                    assert_eq!(policy, "pol");
1148                    assert_eq!(valid, "Repository/media/a, ClusterRepository/b");
1149                }
1150                other => panic!("expected SnapshotPinNotInPolicy, got {other:?}"),
1151            }
1152        }
1153
1154        #[test]
1155        fn unpinned_single_repo_child_uses_the_single_ref() {
1156            let single = policy(serde_json::json!({
1157                "repository": { "kind": "Repository", "name": "r" },
1158                "sources": [ { "pvc": { "name": "d" } } ],
1159            }));
1160            let s = snap(serde_json::json!({
1161                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1162                "kind": "Snapshot",
1163                "metadata": { "name": "s", "namespace": "media" },
1164                "spec": { "policyRef": { "name": "pol" } }
1165            }));
1166            // Verbatim — byte-identical to the pre-multi-repo behavior (no
1167            // namespace materialized that the spec didn't carry).
1168            let r = effective_repository_ref(&s, &single, "media").unwrap();
1169            assert_eq!(r.name, "r");
1170            assert_eq!(r.namespace, None);
1171        }
1172
1173        #[test]
1174        fn unpinned_multi_repo_child_is_refused_never_guessed() {
1175            let s = snap(serde_json::json!({
1176                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1177                "kind": "Snapshot",
1178                "metadata": { "name": "s", "namespace": "media" },
1179                "spec": { "policyRef": { "name": "pol" } }
1180            }));
1181            assert!(matches!(
1182                effective_repository_ref(&s, &multi_policy(), "media").unwrap_err(),
1183                ValidationError::MultiRepoSnapshotUnpinned { policy } if policy == "pol"
1184            ));
1185        }
1186
1187        #[test]
1188        fn pin_against_a_single_repo_policy_still_checks_membership() {
1189            // A multi→single edit that removed the pinned repo is the same
1190            // "edited out from under the pin" terminal error, not a silent
1191            // re-target onto the surviving repository.
1192            let single = policy(serde_json::json!({
1193                "repository": { "kind": "Repository", "name": "kept" },
1194                "sources": [ { "pvc": { "name": "d" } } ],
1195            }));
1196            let s = snap(serde_json::json!({
1197                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1198                "kind": "Snapshot",
1199                "metadata": { "name": "s", "namespace": "media" },
1200                "spec": {
1201                    "policyRef": { "name": "pol" },
1202                    "repository": { "kind": "Repository", "name": "removed", "namespace": "media" }
1203                }
1204            }));
1205            assert!(matches!(
1206                effective_repository_ref(&s, &single, "media").unwrap_err(),
1207                ValidationError::SnapshotPinNotInPolicy { .. }
1208            ));
1209            // …while a pin that matches the single ref proceeds.
1210            let matching = snap(serde_json::json!({
1211                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1212                "kind": "Snapshot",
1213                "metadata": { "name": "s", "namespace": "media" },
1214                "spec": {
1215                    "policyRef": { "name": "pol" },
1216                    "repository": { "kind": "Repository", "name": "kept", "namespace": "media" }
1217                }
1218            }));
1219            assert_eq!(
1220                effective_repository_ref(&matching, &single, "media")
1221                    .unwrap()
1222                    .name,
1223                "kept"
1224            );
1225        }
1226    }
1227
1228    /// Regression for the inert "derived from source" contract (found by the
1229    /// kubectl-plugin e2e): a snapshotRef Restore with no spec.repository was
1230    /// refused with "restore requires spec.repository" even though the CRD
1231    /// documents derivation. The pure derivation must cover both snapshot
1232    /// origins. (Moved here from the controller when the browse data-plane
1233    /// started sharing it.)
1234    mod repository_derivation {
1235        use super::super::repository_ref_for;
1236        use crate::Snapshot;
1237        use crate::common::RepositoryKind;
1238
1239        fn snap(v: serde_json::Value) -> Snapshot {
1240            serde_json::from_value(v).expect("snapshot fixture")
1241        }
1242
1243        #[test]
1244        fn produced_snapshot_uses_the_pinned_resolved_repository() {
1245            let s = snap(serde_json::json!({
1246                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1247                "kind": "Snapshot",
1248                "metadata": { "name": "s", "namespace": "media" },
1249                "spec": { "policyRef": { "name": "pol" } },
1250                "status": { "resolved": { "repository": { "kind": "ClusterRepository", "name": "nas" } } }
1251            }));
1252            let rref = repository_ref_for(&s).expect("derived");
1253            assert_eq!(rref.kind, RepositoryKind::ClusterRepository);
1254            assert_eq!(rref.name, "nas");
1255        }
1256
1257        #[test]
1258        fn discovered_snapshot_uses_the_owning_repository() {
1259            for (kind_str, kind) in [
1260                ("Repository", RepositoryKind::Repository),
1261                ("ClusterRepository", RepositoryKind::ClusterRepository),
1262            ] {
1263                let s = snap(serde_json::json!({
1264                    "apiVersion": "kopiur.home-operations.com/v1alpha1",
1265                    "kind": "Snapshot",
1266                    "metadata": {
1267                        "name": "repo-disc-abc", "namespace": "media",
1268                        "ownerReferences": [{
1269                            "apiVersion": "kopiur.home-operations.com/v1alpha1",
1270                            "kind": kind_str, "name": "nas", "uid": "u1", "controller": true
1271                        }]
1272                    },
1273                    "spec": {},
1274                    "status": { "phase": "Discovered", "origin": "discovered" }
1275                }));
1276                let rref = repository_ref_for(&s).expect(kind_str);
1277                assert_eq!(rref.kind, kind, "{kind_str}");
1278                assert_eq!(rref.name, "nas");
1279                assert_eq!(rref.namespace, None, "resolved relative to the snapshot ns");
1280            }
1281        }
1282
1283        #[test]
1284        fn spec_pin_wins_over_owner_ref_but_loses_to_status() {
1285            // Precedence: status.resolved.repository → spec.repository → ownerRef.
1286            // A replication copy CR (or multi-repo fan-out child) carries the
1287            // spec pin from CREATE, before any status exists…
1288            let spec_only = snap(serde_json::json!({
1289                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1290                "kind": "Snapshot",
1291                "metadata": {
1292                    "name": "s", "namespace": "media",
1293                    // A repository ownerRef that must NOT win over the spec pin.
1294                    "ownerReferences": [{
1295                        "apiVersion": "kopiur.home-operations.com/v1alpha1",
1296                        "kind": "Repository", "name": "owner-repo", "uid": "u1"
1297                    }]
1298                },
1299                "spec": { "repository": { "kind": "ClusterRepository", "name": "offsite" } }
1300            }));
1301            let rref = repository_ref_for(&spec_only).expect("derived from spec");
1302            assert_eq!(rref.kind, RepositoryKind::ClusterRepository);
1303            assert_eq!(rref.name, "offsite");
1304
1305            // …and once the run-time status pin lands, it stays authoritative.
1306            let with_status = snap(serde_json::json!({
1307                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1308                "kind": "Snapshot",
1309                "metadata": { "name": "s", "namespace": "media" },
1310                "spec": { "repository": { "kind": "ClusterRepository", "name": "offsite" } },
1311                "status": { "resolved": { "repository": { "kind": "Repository", "name": "nas" } } }
1312            }));
1313            let rref = repository_ref_for(&with_status).expect("derived from status");
1314            assert_eq!(rref.kind, RepositoryKind::Repository);
1315            assert_eq!(rref.name, "nas");
1316        }
1317
1318        #[test]
1319        fn foreign_owners_and_bare_snapshots_derive_nothing() {
1320            // A non-kopiur owner (e.g. a Job) must not be mistaken for a repository.
1321            let s = snap(serde_json::json!({
1322                "apiVersion": "kopiur.home-operations.com/v1alpha1",
1323                "kind": "Snapshot",
1324                "metadata": {
1325                    "name": "s", "namespace": "media",
1326                    "ownerReferences": [{
1327                        "apiVersion": "batch/v1", "kind": "Job", "name": "j", "uid": "u2"
1328                    }]
1329                },
1330                "spec": {}
1331            }));
1332            assert!(repository_ref_for(&s).is_none());
1333        }
1334    }
1335
1336    #[test]
1337    fn backup_crd_metadata_is_correct() {
1338        let crd = Snapshot::crd();
1339        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
1340        assert_eq!(crd.spec.names.kind, "Snapshot");
1341        assert_eq!(crd.spec.scope, "Namespaced");
1342        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
1343    }
1344
1345    #[test]
1346    fn backup_manual_roundtrip_matches_adr_shape() {
1347        // Mirrors ADR-0001 §3.4 spec block + §5.6.
1348        let yaml = r#"
1349policyRef: { name: postgres-data }
1350tags:
1351  reason: "scheduled-nightly"
1352failurePolicy:
1353  backoffLimit: 2
1354  activeDeadlineSeconds: 7200
1355deletionPolicy: Delete
1356"#;
1357        let spec: SnapshotSpec = from_yaml(yaml);
1358        assert_eq!(spec.policy_ref.as_ref().unwrap().name, "postgres-data");
1359        assert_eq!(spec.tags.as_ref().unwrap()["reason"], "scheduled-nightly");
1360        assert_eq!(spec.failure_policy.as_ref().unwrap().backoff_limit, Some(2));
1361        assert_eq!(spec.deletion_policy, Some(DeletionPolicy::Delete));
1362
1363        let json = serde_json::to_value(&spec).expect("serialize");
1364        let reparsed: SnapshotSpec = serde_json::from_value(json).expect("reparse");
1365        assert_eq!(spec, reparsed);
1366    }
1367
1368    #[test]
1369    fn backup_discovered_spec_is_empty() {
1370        // Discovered backups carry no spec fields.
1371        let spec: SnapshotSpec = from_yaml("{}\n");
1372        assert!(spec.policy_ref.is_none());
1373        assert!(spec.deletion_policy.is_none());
1374        assert!(spec.repository.is_none());
1375        // Empty spec serializes to an empty object (all fields skip).
1376        assert_eq!(serde_json::to_value(&spec).unwrap(), serde_json::json!({}));
1377    }
1378
1379    #[test]
1380    fn backup_spec_repository_pin_round_trips_and_absent_stays_absent() {
1381        use crate::common::RepositoryKind;
1382        // The mint-time pin a multi-repo fan-out child / replication copy CR
1383        // carries (nothing stamps it yet — the field is decode/encode-ready).
1384        let spec: SnapshotSpec =
1385            from_yaml("repository: { kind: ClusterRepository, name: offsite }\n");
1386        let pin = spec.repository.as_ref().expect("pin decoded");
1387        assert_eq!(pin.kind, RepositoryKind::ClusterRepository);
1388        assert_eq!(pin.name, "offsite");
1389        let json = serde_json::to_value(&spec).expect("serialize");
1390        assert_eq!(json["repository"]["name"], "offsite");
1391        let reparsed: SnapshotSpec = serde_json::from_value(json).expect("reparse");
1392        assert_eq!(spec, reparsed);
1393
1394        // Legacy single-repo children stay byte-identical: absent is elided.
1395        let bare: SnapshotSpec = from_yaml("policyRef: { name: postgres-data }\n");
1396        assert!(bare.repository.is_none());
1397        assert!(
1398            serde_json::to_value(&bare)
1399                .unwrap()
1400                .get("repository")
1401                .is_none(),
1402            "absent repository pin must be elided"
1403        );
1404    }
1405
1406    #[test]
1407    fn deletion_policy_serializes_to_expected_strings() {
1408        assert_eq!(
1409            serde_json::to_value(DeletionPolicy::Delete).unwrap(),
1410            "Delete"
1411        );
1412        assert_eq!(
1413            serde_json::to_value(DeletionPolicy::Retain).unwrap(),
1414            "Retain"
1415        );
1416        assert_eq!(
1417            serde_json::to_value(DeletionPolicy::Orphan).unwrap(),
1418            "Orphan"
1419        );
1420        // DeletionPolicy is Copy (ADR-0003 §4.5).
1421        let p = DeletionPolicy::Retain;
1422        let _copy = p;
1423        assert_eq!(p, DeletionPolicy::Retain);
1424    }
1425
1426    #[test]
1427    fn on_schedule_delete_round_trips_and_absent_stays_absent() {
1428        let yaml = r#"
1429policyRef: { name: postgres-data }
1430deletionPolicy: Delete
1431onScheduleDelete: Delete
1432"#;
1433        let spec: SnapshotSpec = from_yaml(yaml);
1434        assert_eq!(spec.on_schedule_delete, Some(ScheduleDeletePolicy::Delete));
1435        let json = serde_json::to_value(&spec).expect("serialize");
1436        assert_eq!(json["onScheduleDelete"], "Delete");
1437        let reparsed: SnapshotSpec = serde_json::from_value(json).expect("reparse");
1438        assert_eq!(spec, reparsed);
1439
1440        // Absent stays absent (no schema default — the safety default lives in
1441        // the controller resolver, not here).
1442        let bare: SnapshotSpec = from_yaml("policyRef: { name: postgres-data }\n");
1443        assert!(bare.on_schedule_delete.is_none());
1444        assert!(
1445            serde_json::to_value(&bare)
1446                .unwrap()
1447                .get("onScheduleDelete")
1448                .is_none(),
1449            "absent onScheduleDelete must be elided"
1450        );
1451    }
1452
1453    #[test]
1454    fn pruned_by_parse_is_the_exact_inverse_of_annotation_value() {
1455        let all = [
1456            PrunedBy::Retention,
1457            PrunedBy::FailedHistory,
1458            PrunedBy::PolicyCascade,
1459            PrunedBy::ReplicationRetention,
1460            PrunedBy::ReplacedRun,
1461        ];
1462        assert_eq!(PrunedBy::ALL, all, "PrunedBy::ALL must list every variant");
1463        // The `concurrencyPolicy: Replace` stamp, pinned as a literal: it is a
1464        // wire value the finalizer parses, so a rename would silently reclassify
1465        // every replaced run as an EXTERNAL deletion and push the repository's
1466        // mass-deletion breaker toward tripping on every busy schedule.
1467        assert_eq!(PrunedBy::ReplacedRun.annotation_value(), "replaced-run");
1468        assert_eq!(PrunedBy::parse("replaced-run"), Some(PrunedBy::ReplacedRun));
1469        assert_eq!(PrunedBy::parse("replaced_run"), None);
1470        for variant in all {
1471            assert_eq!(
1472                PrunedBy::parse(variant.annotation_value()),
1473                Some(variant),
1474                "{variant:?}"
1475            );
1476        }
1477        // Unrecognized ⇒ None ⇒ the finalizer classifies the deletion EXTERNAL
1478        // (breaker-relevant) — a missed parse arm here would silently invert a
1479        // new operator prune into a breaker-held external wave.
1480        assert_eq!(PrunedBy::parse("garbage"), None);
1481        assert_eq!(PrunedBy::parse("replication_retention"), None);
1482    }
1483
1484    #[test]
1485    fn origin_and_phase_serialize_to_expected_strings() {
1486        assert_eq!(
1487            serde_json::to_value(Origin::Scheduled).unwrap(),
1488            "scheduled"
1489        );
1490        assert_eq!(serde_json::to_value(Origin::Manual).unwrap(), "manual");
1491        assert_eq!(
1492            serde_json::to_value(Origin::Discovered).unwrap(),
1493            "discovered"
1494        );
1495        assert_eq!(serde_json::to_value(Origin::Adopted).unwrap(), "adopted");
1496        assert_eq!(
1497            serde_json::to_value(Origin::Replicated).unwrap(),
1498            "replicated"
1499        );
1500        assert_eq!(
1501            serde_json::to_value(SnapshotPhase::Succeeded).unwrap(),
1502            "Succeeded"
1503        );
1504        assert_eq!(
1505            serde_json::to_value(SnapshotPhase::Deleting).unwrap(),
1506            "Deleting"
1507        );
1508    }
1509
1510    #[test]
1511    fn backup_status_roundtrips() {
1512        // Mirrors ADR-0001 §3.4 status block.
1513        let yaml = r#"
1514phase: Succeeded
1515origin: scheduled
1516snapshot:
1517  kopiaSnapshotID: k1f1ec0a8
1518  identity:
1519    username: postgres-data
1520    hostname: billing
1521    sourcePath: /data
1522timing:
1523  startTime: 2026-05-24T02:13:00Z
1524  endTime: 2026-05-24T02:18:42Z
1525  durationSeconds: 342
1526stats:
1527  sizeBytes: 4321098765
1528  bytesNew: 12345678
1529  filesNew: 1233
1530resolved:
1531  repository: { kind: Repository, name: nas-primary, namespace: backups }
1532  sources:
1533    - pvc: billing/postgres-data
1534      sourcePath: /data
1535logTail: "Snapshot created: k1f1ec0a8"
1536"#;
1537        let status: SnapshotStatus = from_yaml(yaml);
1538        assert_eq!(status.phase, Some(SnapshotPhase::Succeeded));
1539        assert_eq!(status.origin, Some(Origin::Scheduled));
1540        assert_eq!(
1541            status.snapshot.as_ref().unwrap().kopia_snapshot_id,
1542            "k1f1ec0a8"
1543        );
1544        assert_eq!(status.stats.as_ref().unwrap().size_bytes, Some(4321098765));
1545
1546        let json = serde_json::to_value(&status).unwrap();
1547        let reparsed: SnapshotStatus = serde_json::from_value(json).unwrap();
1548        assert_eq!(status, reparsed);
1549    }
1550
1551    #[test]
1552    fn backup_status_recorded_and_description_roundtrip() {
1553        use crate::recorded::{RecordedSnapshotMeta, RecordedSrc};
1554        let yaml = r#"
1555phase: Succeeded
1556snapshot:
1557  kopiaSnapshotID: k1
1558  identity:
1559    username: u
1560    hostname: h
1561    sourcePath: /data
1562  description: "pre-upgrade snapshot"
1563recorded:
1564  schema: 1
1565  src: inherited
1566  uid: 3001
1567  gid: 3001
1568  fsGroup: 65532
1569"#;
1570        let status: SnapshotStatus = from_yaml(yaml);
1571        assert_eq!(
1572            status.recorded,
1573            Some(RecordedSnapshotMeta {
1574                schema: 1,
1575                src: RecordedSrc::Inherited,
1576                uid: Some(3001),
1577                gid: Some(3001),
1578                fs_group: Some(65532),
1579            })
1580        );
1581        assert_eq!(
1582            status.snapshot.as_ref().unwrap().description.as_deref(),
1583            Some("pre-upgrade snapshot")
1584        );
1585        let json = serde_json::to_value(&status).unwrap();
1586        assert_eq!(json["recorded"]["fsGroup"], 65532, "camelCase wire key");
1587        let reparsed: SnapshotStatus = serde_json::from_value(json).unwrap();
1588        assert_eq!(status, reparsed);
1589
1590        // Absent stays absent — no null/{} noise on old rows.
1591        let bare: SnapshotStatus = from_yaml("phase: Succeeded\n");
1592        assert!(bare.recorded.is_none());
1593        let wire = serde_json::to_value(&bare).unwrap();
1594        assert!(wire.get("recorded").is_none());
1595    }
1596
1597    #[test]
1598    fn replicated_status_copied_from_roundtrips_and_absent_stays_absent() {
1599        use crate::common::RepositoryKind;
1600        // The lineage block a SnapshotReplication copy CR carries (migrate
1601        // cannot stamp kopia tags, so provenance lives on the CR status).
1602        let yaml = r#"
1603phase: Succeeded
1604origin: replicated
1605snapshot:
1606  kopiaSnapshotID: destid123
1607  identity:
1608    username: mydb
1609    hostname: prod
1610    sourcePath: /pvc/mydb
1611copiedFrom:
1612  repository: { kind: Repository, name: nas-primary, namespace: backups }
1613  sourceManifestId: srcid456
1614  startTime: 2026-08-01T02:00:00Z
1615"#;
1616        let status: SnapshotStatus = from_yaml(yaml);
1617        let cf = status.copied_from.as_ref().expect("copiedFrom decoded");
1618        assert_eq!(cf.repository.kind, RepositoryKind::Repository);
1619        assert_eq!(cf.repository.name, "nas-primary");
1620        assert_eq!(cf.source_manifest_id, "srcid456");
1621        assert_eq!(cf.start_time, "2026-08-01T02:00:00Z");
1622
1623        // The exact camelCase wire keys — a drifting name is silently pruned
1624        // by the apiserver's structural schema.
1625        let json = serde_json::to_value(&status).unwrap();
1626        assert_eq!(json["copiedFrom"]["sourceManifestId"], "srcid456");
1627        assert_eq!(json["copiedFrom"]["startTime"], "2026-08-01T02:00:00Z");
1628        assert_eq!(json["copiedFrom"]["repository"]["name"], "nas-primary");
1629        let reparsed: SnapshotStatus = serde_json::from_value(json).unwrap();
1630        assert_eq!(status, reparsed);
1631
1632        // Every other origin: absent stays absent (no null/{} noise).
1633        let bare: SnapshotStatus = from_yaml("phase: Succeeded\n");
1634        assert!(bare.copied_from.is_none());
1635        let wire = serde_json::to_value(&bare).unwrap();
1636        assert!(wire.get("copiedFrom").is_none());
1637    }
1638
1639    #[test]
1640    fn stored_recorded_with_future_src_decodes_gracefully() {
1641        // A newer operator wrote `src: workload` onto status; this version's
1642        // typed watcher must decode it (graceful-decode convention), not error.
1643        use crate::recorded::RecordedSrc;
1644        let status: SnapshotStatus =
1645            from_yaml("recorded:\n  schema: 1\n  src: workload\n  uid: 7\n");
1646        let rec = status.recorded.expect("decoded");
1647        assert_eq!(rec.src, RecordedSrc::Unknown);
1648        assert_eq!(rec.uid, Some(7));
1649    }
1650}