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