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}