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