Skip to main content

kopiur_api/
snapshot_policy.rs

1//! The `SnapshotPolicy` CRD — the *recipe*. Idempotent; runs nothing on its own.
2//! ADR-0001 §3.3, ADR-0003 §4.8.
3
4use crate::backend::NfsVolume;
5use crate::common::{
6    CredentialProjection, CronSpec, DeletionPolicy, Identity, MoverSpec, PodSelector,
7    PvcAccessMode, RepositoryRef, ResolvedIdentity, Retention,
8};
9use k8s_openapi::api::batch::v1::JobSpec;
10use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
11use kube::CustomResource;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15/// What to back up: sources, identity, retention, policy, hooks.
16#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
17#[kube(
18    group = "kopiur.home-operations.com",
19    version = "v1alpha1",
20    kind = "SnapshotPolicy",
21    plural = "snapshotpolicies",
22    namespaced,
23    status = "SnapshotPolicyStatus",
24    shortname = "kopiasp",
25    category = "kopiur",
26    printcolumn = r#"{"name":"Repository","type":"string","jsonPath":".spec.repository.name"}"#,
27    printcolumn = r#"{"name":"Repositories","type":"string","jsonPath":".status.repositorySummary"}"#,
28    printcolumn = r#"{"name":"Last-Snapshot","type":"date","jsonPath":".status.lastSuccessfulSnapshot"}"#,
29    printcolumn = r#"{"name":"Last-Verified","type":"date","jsonPath":".status.lastVerified"}"#,
30    printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.suspend"}"#,
31    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
32)]
33// §15: operator-authored spec-level CEL — exactly one of `repository` /
34// `repositories` (apiserver + CI validation, complementing the shared
35// validator's [`crate::validate::validate_backup_config`] check). Both are
36// optional at the type level so old single-repo objects keep decoding; the
37// integer-sum form matches the per-item `Source` rule's cheap-constant style.
38#[schemars(extend("x-kubernetes-validations" = [{
39    "rule": "(has(self.repository) ? 1 : 0) + (has(self.repositories) ? 1 : 0) == 1",
40    "message": "exactly one of repository, repositories"
41}]))]
42#[serde(rename_all = "camelCase")]
43pub struct SnapshotPolicySpec {
44    /// Discriminated reference to a `Repository` or `ClusterRepository`.
45    /// Mutually exclusive with `repositories` (exactly one of the two is set).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub repository: Option<RepositoryRef>,
48    /// Multi-repository fan-out: every listed `Repository`/`ClusterRepository`
49    /// receives its own independent backup of each source — one `Snapshot` CR +
50    /// one mover Job per (source, repository) pair, so the captures are separate
51    /// kopia snapshots, not copies of one another. Identity resolves per-repo
52    /// under that repository's `identityDefaults`. Mutually exclusive with
53    /// `repository` (exactly one of the two is set) AND with `hooks`: with N
54    /// concurrent children the first finisher would run the thaw hook while the
55    /// other N-1 movers still read — use a single-repo policy plus a
56    /// `SnapshotReplication` when hooks are needed for a second target.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    #[schemars(length(min = 1, max = 8))]
59    pub repositories: Vec<RepositoryRef>,
60    /// Identity overrides — what kopia records as `username@hostname:path`.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub identity: Option<Identity>,
63    /// What to back up (at least one source; webhook-enforced).
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    #[schemars(length(max = 100))]
66    pub sources: Vec<Source>,
67    /// How the source volume is captured before kopia reads it: `Snapshot` (default), `Direct`, or `Clone`.
68    #[serde(default = "default_copy_method")]
69    #[schemars(default = "default_copy_method")]
70    pub copy_method: CopyMethod,
71    /// `VolumeSnapshotClass` used when `copyMethod` snapshots/clones the source. Absent or
72    /// empty both mean auto-select the default class for the source PVC's CSI driver, so a
73    /// GitOps-templated value (Flux/Kustomize `${VAR}`) is safe when the variable is unset.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub volume_snapshot_class_name: Option<String>,
76    /// Staging knobs for `copyMethod: Snapshot`/`Clone` (e.g. how long to wait for
77    /// the CSI capture to become ready before failing the backup).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub staging: Option<StagingSpec>,
80    /// Multi-PVC consistency grouping; `None` opts into independent per-PVC snapshots.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    #[schemars(default = "default_group_by")]
83    pub group_by: Option<GroupBy>,
84    /// GFS retention, enforced by the operator pruning `Snapshot` CRs.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub retention: Option<Retention>,
87    /// Default `deletionPolicy` for `Snapshot` CRs created against this config.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    #[schemars(default = "recipe_default_deletion_policy")]
90    pub default_deletion_policy: Option<DeletionPolicy>,
91    /// Compression algorithm + per-extension opt-outs.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub compression: Option<Compression>,
94    /// Paths/patterns kopia should skip while snapshotting.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub files: Option<Files>,
97    /// Escape hatch for kopia flags not yet modeled.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub extra_args: Vec<String>,
100    /// Backup-side error handling: let a snapshot complete-with-errors instead of failing outright.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub error_handling: Option<ErrorHandling>,
103    /// Upload parallelism (kopia's `--max-parallel-snapshots` / `--max-parallel-file-reads`).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub upload: Option<Upload>,
106    /// First-class backup verification; opt-in (absent ⇒ no verification runs).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub verification: Option<Verification>,
109    /// Named CEL preconditions evaluated before each backup run; opt-in (absent ⇒
110    /// no preflight). A failing check holds the `Snapshot` in `Pending`
111    /// (`PreflightFailed`) and, after `timeout`, fails it.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub preflight: Option<crate::preflight::PreflightSpec>,
114    /// Pause this recipe declaratively (schedules and reconcile skip a suspended policy).
115    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116    pub suspend: bool,
117    /// Pre/post snapshot hooks that run in the workload, not the mover.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub hooks: Option<Hooks>,
120    /// Per-recipe mover overrides (resources, cache, security context).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub mover: Option<MoverSpec>,
123    /// Opt-in credential-Secret projection into each backup mover's namespace (default off).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub credential_projection: Option<CredentialProjection>,
126    /// Deletion semantics for the `Snapshot` CRs carrying this recipe's config label.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub deletion: Option<PolicyDeletionSpec>,
129    /// Per-policy override of automatic adoption for discovered snapshots whose
130    /// resolved identity matches this recipe; absent inherits the repository's
131    /// `catalog.adoption` (see [`effective_adoption`](crate::common::effective_adoption)).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub adoption: Option<crate::common::SnapshotAdoption>,
134}
135
136/// The repository target(s) of a `SnapshotPolicy`, as an exactly-one-of view
137/// over `spec.repository` / `spec.repositories`. Borrowed so consumers match
138/// without cloning; produced by [`policy_repositories`].
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum PolicyRepositories<'a> {
141    /// The classic single-repo shape (`spec.repository`).
142    Single(&'a RepositoryRef),
143    /// The multi-repository fan-out shape (`spec.repositories`, 1–8 entries).
144    Multi(&'a [RepositoryRef]),
145}
146
147/// THE exactly-one-of resolver for a policy's repository target(s).
148///
149/// Never panics: a stored CR can carry garbage (both set, or neither — e.g. a
150/// write that raced an old CRD schema), so the invalid shapes come back as
151/// [`ValidationError::PolicyRepositoryExactlyOne`] for the caller's defensive
152/// error path rather than as an `unwrap` in a reconciler.
153///
154/// ```
155/// use kopiur_api::snapshot_policy::{PolicyRepositories, policy_repositories};
156///
157/// let spec: kopiur_api::SnapshotPolicySpec = serde_json::from_value(serde_json::json!({
158///     "repository": { "kind": "Repository", "name": "r" },
159///     "sources": [ { "pvc": { "name": "d" } } ],
160/// }))
161/// .unwrap();
162/// assert!(matches!(
163///     policy_repositories(&spec),
164///     Ok(PolicyRepositories::Single(r)) if r.name == "r"
165/// ));
166/// ```
167pub fn policy_repositories(
168    spec: &SnapshotPolicySpec,
169) -> Result<PolicyRepositories<'_>, crate::error::ValidationError> {
170    match (&spec.repository, spec.repositories.is_empty()) {
171        (Some(single), true) => Ok(PolicyRepositories::Single(single)),
172        (None, false) => Ok(PolicyRepositories::Multi(&spec.repositories)),
173        (Some(_), false) => {
174            Err(crate::error::ValidationError::PolicyRepositoryExactlyOne { got: "both" })
175        }
176        (None, true) => {
177            Err(crate::error::ValidationError::PolicyRepositoryExactlyOne { got: "neither" })
178        }
179    }
180}
181
182/// The single repository a policy targets, for code paths that genuinely
183/// cannot take a multi-repository policy.
184///
185/// The multi-repo arm returns
186/// [`ValidationError::PolicySingleRepositoryRequired`](crate::error::ValidationError::PolicySingleRepositoryRequired)
187/// so a consumer that needs "the one repository" fails LOUDLY instead of silently picking
188/// repository #1. Multi-repo policies address per-repository work through
189/// each child `Snapshot`'s `spec.repository` pin
190/// ([`crate::snapshot::effective_repository_ref`]); callers that can handle
191/// both shapes should match [`policy_repositories`] instead. Callers that
192/// treat multi as "no single answer" (e.g. the restore readiness gate's
193/// non-fatal repository lookup) `.ok()` this deliberately.
194pub fn single_repository_ref(
195    spec: &SnapshotPolicySpec,
196) -> Result<&RepositoryRef, crate::error::ValidationError> {
197    match policy_repositories(spec)? {
198        PolicyRepositories::Single(r) => Ok(r),
199        PolicyRepositories::Multi(_) => {
200            Err(crate::error::ValidationError::PolicySingleRepositoryRequired)
201        }
202    }
203}
204
205/// Tolerant iterator over every repository ref a policy names — whatever
206/// exists, in `repository`-then-`repositories` order, no validity judgment.
207/// For any-of predicates (watch mappers, repo-edit guards, tenancy loops)
208/// that must not error on a malformed stored CR.
209pub fn repository_refs(spec: &SnapshotPolicySpec) -> impl Iterator<Item = &RepositoryRef> {
210    spec.repository.iter().chain(spec.repositories.iter())
211}
212
213/// Whether this policy uses the multi-repository fan-out shape.
214pub fn is_multi_repo(spec: &SnapshotPolicySpec) -> bool {
215    !spec.repositories.is_empty()
216}
217
218/// The repository a `fromPolicy` restore reads, resolved from the policy's
219/// repository set + the restore's optional explicit `spec.repository`.
220/// **Pure** — the resolver backstop shared with the M9 webhook mirror, so the
221/// two can never disagree.
222///
223/// - **Explicit selection** — it must be a MEMBER of the policy's repository
224///   set (compared by normalized [`repo_key`](crate::common::repo_key); the
225///   explicit ref resolves relative to `restore_ns`, the members relative to
226///   `policy_ns`), else
227///   [`ValidationError`](crate::error::ValidationError::RestoreRepositoryNotInPolicy):
228///   a typo'd ref must not silently read a repository the recipe never wrote
229///   to. A member ref is returned verbatim (the caller resolves it in the
230///   restore's namespace, exactly as an explicit ref always has been).
231/// - **No selection, single-repo** — the policy's one repository, verbatim.
232/// - **No selection, multi-repo** —
233///   [`ValidationError::RestoreRepositorySelectionRequired`](crate::error::ValidationError::RestoreRepositorySelectionRequired),
234///   naming every valid choice; repository #1 is never guessed (the N
235///   repositories are independent captures that can diverge).
236pub fn select_restore_repository(
237    policy: &SnapshotPolicySpec,
238    policy_name: &str,
239    policy_ns: &str,
240    explicit: Option<&RepositoryRef>,
241    restore_ns: &str,
242) -> Result<RepositoryRef, crate::error::ValidationError> {
243    use crate::common::repo_key;
244    let repos = policy_repositories(policy)?;
245    let members: Vec<&RepositoryRef> = match repos {
246        PolicyRepositories::Single(r) => vec![r],
247        PolicyRepositories::Multi(rs) => rs.iter().collect(),
248    };
249    let valid = || {
250        members
251            .iter()
252            .map(|m| repo_key(m, policy_ns))
253            .collect::<Vec<_>>()
254            .join(", ")
255    };
256    match explicit {
257        Some(given) => {
258            let given_key = repo_key(given, restore_ns);
259            if members.iter().any(|m| repo_key(m, policy_ns) == given_key) {
260                Ok(given.clone())
261            } else {
262                Err(
263                    crate::error::ValidationError::RestoreRepositoryNotInPolicy {
264                        given: given_key,
265                        policy: policy_name.to_string(),
266                        valid: valid(),
267                    },
268                )
269            }
270        }
271        None => match repos {
272            PolicyRepositories::Single(r) => Ok(r.clone()),
273            PolicyRepositories::Multi(_) => Err(
274                crate::error::ValidationError::RestoreRepositorySelectionRequired {
275                    policy: policy_name.to_string(),
276                    valid: valid(),
277                },
278            ),
279        },
280    }
281}
282
283/// Deletion semantics for the `Snapshot`s carrying a `SnapshotPolicy`'s config
284/// label (sub-object per docs/dev/api-conventions.md §4 so future deletion
285/// knobs slot in without API breakage). Mirrors `SnapshotSchedule`'s
286/// [`ScheduleDeletionSpec`](crate::snapshot_schedule::ScheduleDeletionSpec).
287#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
288#[serde(rename_all = "camelCase")]
289pub struct PolicyDeletionSpec {
290    /// Consulted by the Snapshot finalizer when the deletion is external and the
291    /// owning `SnapshotPolicy` is gone. Absent resolves to `Retain`.
292    #[serde(default = "default_on_policy_delete")]
293    #[schemars(default = "default_on_policy_delete")]
294    pub on_policy_delete: crate::common::PolicyDeletePolicy,
295}
296
297fn default_on_policy_delete() -> crate::common::PolicyDeletePolicy {
298    crate::common::PolicyDeletePolicy::Retain
299}
300
301/// The effective cascade policy for a `SnapshotPolicy`: `spec.deletion.onPolicyDelete`
302/// when the sub-object is present, else `Retain`. (A default nested under an
303/// ABSENT optional sub-object does not materialize server-side — every read
304/// goes through this resolver.)
305pub fn effective_on_policy_delete(
306    deletion: Option<&PolicyDeletionSpec>,
307) -> crate::common::PolicyDeletePolicy {
308    deletion.map(|d| d.on_policy_delete).unwrap_or_default()
309}
310
311/// A single backup source; exactly one of `pvc`, `pvcSelector`, `nfs` (webhook-enforced).
312// The exactly-one-of rule is written as an integer sum of `has()` ternaries rather
313// than `[...].filter(x,x).size()==1`: the apiserver estimates per-item CEL cost ×
314// `maxItems`, and a list-construction + lambda `filter` blows the budget on the
315// repeating `sources` list. The sum form is a cheap constant per item.
316// `Default` is derived purely for construction ergonomics: `Source` is built as an
317// exhaustive struct literal in ~20 places, and every added field would otherwise have
318// to be spelled out at each one. An all-`None` `Source` is not a valid spec (the CEL
319// rule above demands exactly one of pvc/pvcSelector/nfs) and admission rejects it.
320#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, JsonSchema)]
321#[schemars(extend("x-kubernetes-validations" = [{
322    "rule": "(has(self.pvc) ? 1 : 0) + (has(self.pvcSelector) ? 1 : 0) + (has(self.nfs) ? 1 : 0) == 1",
323    "message": "exactly one of pvc, pvcSelector, nfs"
324}]))]
325#[serde(rename_all = "camelCase")]
326pub struct Source {
327    /// Single PVC by name. Mutually exclusive with `pvcSelector`/`nfs`.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub pvc: Option<PvcSource>,
330    /// Label/namespace selector matching many PVCs. Mutually exclusive with `pvc`/`nfs`.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub pvc_selector: Option<PvcSelector>,
333    /// An inline NFS export to back up directly. Mutually exclusive with `pvc`/`pvcSelector`.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub nfs: Option<NfsVolume>,
336    /// Mount the source read-only (default `true`; kopia only ever reads it).
337    ///
338    /// Set `false` **only** to make `fsGroup` work on the source. The kubelet applies
339    /// `fsGroup` by recursively `chgrp`-ing the volume and adding group-write — and it
340    /// skips that walk entirely on a read-only mount, which is why a mover
341    /// `fsGroup`/`fsGroupChangePolicy` otherwise has no effect here. Under
342    /// `copyMethod: Snapshot`/`Clone` the walk rewrites the throwaway staged PVC and
343    /// never touches your data. Under `copyMethod: Direct` it rewrites the LIVE volume,
344    /// which requires `acknowledgeLiveMutation`.
345    ///
346    /// Not supported on an `nfs` source: the kubelet does not apply `fsGroup` to
347    /// in-tree NFS volumes at all, so a read-write mount would grant nothing.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    #[schemars(default = "default_source_read_only")]
350    pub read_only: Option<bool>,
351    /// Acknowledges that `copyMethod: Direct` + `readOnly: false` lets the kubelet
352    /// recursively `chgrp` the **live** volume to the mover's `fsGroup` and make it
353    /// group-writable — permanently, while the workload is running. Required for that
354    /// combination alone.
355    ///
356    /// Ignored (not rejected) otherwise: it is an acknowledgement, never harmful to
357    /// carry, and rejecting a stale one would make switching `copyMethod` between
358    /// `Direct` and `Snapshot`/`Clone` a two-step edit in both directions.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub acknowledge_live_mutation: Option<bool>,
361    /// What kopia records as the source path (default `/pvc/<name>`, or the NFS export `path`).
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    #[schemars(length(max = 4096))]
364    pub source_path_override: Option<String>,
365    /// How a `pvcSelector`-matched PVC's source path is derived (`pvcName` vs `pvcNamespacedName`).
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    #[schemars(default = "default_source_path_strategy")]
368    pub source_path_strategy: Option<SourcePathStrategy>,
369}
370
371/// A single backup source addressed by PVC name.
372#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
373#[serde(rename_all = "camelCase")]
374pub struct PvcSource {
375    /// Name of the `PersistentVolumeClaim` to back up (in the `SnapshotPolicy`'s namespace).
376    pub name: String,
377}
378
379/// Selects PVCs across namespaces by label.
380#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
381#[serde(rename_all = "camelCase")]
382pub struct PvcSelector {
383    /// Restricts the search to specific namespaces; absent means the policy's own namespace.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub namespace_selector: Option<NamespaceSelector>,
386    /// Standard Kubernetes label selector matching the PVCs to include.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub label_selector: Option<LabelSelector>,
389}
390
391/// Restricts a `PvcSelector` to an explicit set of namespaces.
392#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
393#[serde(rename_all = "camelCase")]
394pub struct NamespaceSelector {
395    /// Exact namespace names to search; empty means the policy's own namespace.
396    #[serde(default, skip_serializing_if = "Vec::is_empty")]
397    pub match_names: Vec<String>,
398}
399
400/// serde/schemars `default` for [`SnapshotPolicySpec::copy_method`] — **`Snapshot`**.
401///
402/// `Snapshot` (point-in-time CSI `VolumeSnapshot` staging) is the default because it
403/// is **crash-consistent**: kopia reads a frozen point-in-time capture instead of a
404/// live, possibly-mid-write PVC, which matters most for databases and other stateful
405/// apps. It requires the CSI external-snapshotter stack plus a `VolumeSnapshotClass`
406/// for the source's driver. `Direct` (read the live PVC) remains available and is the
407/// right choice for non-CSI/static sources (e.g. hostPath, some NFS setups) or when the
408/// snapshot stack isn't installed — set `copyMethod: Direct` explicitly to opt in. If
409/// the CSI stack is missing under the `Snapshot` default, the operator fails loud: the
410/// `Snapshot`/`SnapshotPolicy` status condition and Warning Event spell out exactly
411/// what to install or which field to set (see `crates/controller/src/io/staging.rs`).
412///
413/// A named fn so it backs BOTH `#[serde(default = ...)]` and `#[schemars(default = ...)]`,
414/// which is what makes schemars 1 emit a real OpenAPI `default:` in the generated CRD.
415fn default_copy_method() -> CopyMethod {
416    CopyMethod::Snapshot
417}
418
419/// The default OS-artifact exclude set for `Files.ignore_rules` — filesystem/NAS
420/// junk that is never intentional user data, so excluding it by default is
421/// additive-safe. Per-entry rationale:
422///
423/// - `/lost+found` — root-anchored ext4/fsck recovery dir. Anchored (leading
424///   `/`) so a *nested* user directory named `lost+found` is left alone; only
425///   the source root's own fsck dir is excluded.
426/// - `System Volume Information`, `$RECYCLE.BIN` — Windows/SMB-client
427///   artifacts that show up on samba-share-backed PVCs.
428/// - `@eaDir` — Synology NAS extended-attribute/thumbnail metadata junk.
429/// - `.snapshot` — NAS-exposed snapshot pseudo-directories (NetApp-style).
430///   Deliberately **unanchored** (no leading `/`): these appear at *every*
431///   level of a NetApp-backed export, not just the root, and backing one up
432///   recursively would multiply the backup size by re-capturing older
433///   snapshot generations as regular file data. Flip side: a legitimate
434///   directory named `.snapshot` at any depth is also excluded — set
435///   `ignoreRules` explicitly if you have one (your list replaces the default).
436///
437/// A named fn so it backs BOTH `#[serde(default = ...)]` (the common case: an
438/// absent `files:` block, handled by the controller glue in
439/// `kopiur_mover::workspec` since the apiserver only server-side-defaults
440/// NESTED fields when the parent object is present) AND
441/// `#[schemars(default = ...)]` (so the default is visible in the generated
442/// CRD schema / `kubectl explain`, and applies when `files: {}` is present
443/// without `ignoreRules`). ONE source of truth for both layers.
444pub fn default_ignore_rules() -> Vec<String> {
445    vec![
446        "/lost+found".to_string(),
447        "System Volume Information".to_string(),
448        "$RECYCLE.BIN".to_string(),
449        "@eaDir".to_string(),
450        ".snapshot".to_string(),
451    ]
452}
453
454/// Volume snapshot copy method. Closed enum. ADR §3.3.
455///
456/// ```
457/// use kopiur_api::CopyMethod;
458///
459/// // Defaults to crash-consistent CSI VolumeSnapshot staging.
460/// assert_eq!(CopyMethod::default(), CopyMethod::Snapshot);
461/// // Serializes as a bare PascalCase string (no external tagging — it has no payload).
462/// assert_eq!(serde_json::to_value(CopyMethod::Snapshot).unwrap(), "Snapshot");
463/// assert_eq!(serde_json::to_value(CopyMethod::Direct).unwrap(), "Direct");
464/// ```
465#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
466pub enum CopyMethod {
467    /// Point-in-time CSI volume snapshot (the default; requires the CSI snapshot stack + a `VolumeSnapshotClass`).
468    #[default]
469    Snapshot,
470    /// CSI volume clone of the source (opt-in; requires a cloning-capable CSI driver). Mounted per `sources[].readOnly` — read-only by default.
471    Clone,
472    /// Read the live PVC directly with no intermediate snapshot/clone (opt-in; works on any storage, no CSI required).
473    Direct,
474}
475
476/// `SnapshotPolicy.spec.staging` — knobs for the CSI capture (`copyMethod:
477/// Snapshot`/`Clone`) that runs before the mover. A sub-object so future staging
478/// fields slot in without API breakage.
479#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
480#[serde(rename_all = "camelCase")]
481pub struct StagingSpec {
482    /// How long each staging phase may take before the backup is failed (Go-style
483    /// duration like `10m` or `1h`; default `10m`): first the staged
484    /// `VolumeSnapshot` becoming `readyToUse` (measured from its creation), then —
485    /// on an `Immediate`-binding StorageClass — the staged PVC binding (a fresh
486    /// budget measured from the PVC's creation, covering the CSI restore/clone).
487    /// A transient CSI/snapshot-controller error during either wait is retried,
488    /// never fatal on its own — only this deadline fails staging. A zero duration
489    /// (`0`/`0s`) waits indefinitely. Raise this for backends whose snapshots or
490    /// clones take long (e.g. cloud snapshots of large volumes, CephFS full clones
491    /// of small-file-heavy volumes).
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub timeout: Option<String>,
494    /// StorageClass for the **staged PVC** — the temporary PVC restored from the
495    /// CSI `VolumeSnapshot` (`copyMethod: Snapshot`) or cloned from the source
496    /// (`copyMethod: Clone`). Absent ⇒ the staged PVC copies the source PVC's
497    /// class. Must belong to the **same CSI driver** as the source (staging fails
498    /// fast on a mismatch). Flagship use: a rook-ceph CephFS class with
499    /// `backingSnapshot: "true"`, which mounts the snapshot shallowly
500    /// (metadata-only, near-instant, read-only) instead of running a full
501    /// subvolume clone that can take many minutes on small-file-heavy volumes.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub storage_class_name: Option<String>,
504    /// Access modes for the staged PVC. Empty ⇒ copy the source PVC's modes.
505    /// `[ReadOnlyMany]` pairs with snapshot-backed read-only classes (e.g. CephFS
506    /// `backingSnapshot`); the mover mounts the staged PVC read-only to match, and
507    /// rejects it at admission if a source sets `readOnly: false` (a read-only stage
508    /// cannot be mounted read-write).
509    #[serde(default, skip_serializing_if = "Vec::is_empty")]
510    pub access_modes: Vec<PvcAccessMode>,
511}
512
513/// Multi-PVC grouping strategy. Defaults to a consistent group snapshot across
514/// all PVCs; set `None` *explicitly* to accept independent per-PVC snapshots,
515/// because a silent per-PVC fallback would produce inconsistent backups.
516#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
517pub enum GroupBy {
518    /// Consistent group snapshot across all PVCs (default for multi-PVC).
519    #[default]
520    VolumeGroupSnapshot,
521    /// Opt into independent per-PVC snapshots.
522    None,
523}
524
525/// schemars default for `PvcSnapshotPolicy::group_by` — the consistent group
526/// snapshot. Returns the field's `Option` type so schemars emits the schema
527/// `default:` (`VolumeGroupSnapshot`) for `kubectl explain`.
528fn default_group_by() -> Option<GroupBy> {
529    Some(GroupBy::VolumeGroupSnapshot)
530}
531
532/// How a selector-matched PVC's source path is derived. Only relevant for
533/// `pvcSelector` sources, where one recipe expands to many PVCs and each needs a
534/// distinct kopia source path. Defaults to `PvcName`.
535#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
536pub enum SourcePathStrategy {
537    /// Path derived from the PVC name alone (default).
538    #[default]
539    PvcName,
540    /// Path derived from `<namespace>/<name>` to disambiguate same-named PVCs across namespaces.
541    PvcNamespacedName,
542}
543
544/// schemars default for `PvcSnapshotPolicy::source_path_strategy` — `PvcName`.
545/// Returns the field's `Option` type so schemars emits the schema `default:`.
546fn default_source_path_strategy() -> Option<SourcePathStrategy> {
547    Some(SourcePathStrategy::PvcName)
548}
549
550/// schemars default for `Source::read_only` — a backup source is read-only unless the
551/// user asks otherwise. Returns the field's `Option` type so schemars emits the schema
552/// `default: true` for `kubectl explain`. Paired with `source_read_only()`, which
553/// resolves absent to exactly this value at the mount site — the pairing is what makes
554/// a schema default safe to advertise (see `Repository`'s health defaults).
555fn default_source_read_only() -> Option<bool> {
556    Some(true)
557}
558
559/// Whether a source is mounted read-only. THE resolver for `Source::read_only`'s
560/// absent case, so the CRD's advertised `default: true` and the mount agree by
561/// construction rather than by coincidence.
562pub fn source_read_only(source: &Source) -> bool {
563    source.read_only.unwrap_or(true)
564}
565
566/// Whether this source's mount lets the kubelet rewrite the **live** workload volume:
567/// a writable mount with no staging in front of it.
568///
569/// `copyMethod: Snapshot`/`Clone` interpose a throwaway staged PVC, so the kubelet's
570/// recursive `fsGroup` chgrp lands on a copy that is deleted when the run ends. Only
571/// `Direct` mounts the workload's own PVC, where that same walk permanently rewrites
572/// group ownership on production data. Pure, and the single definition of the hazard.
573///
574/// An `nfs` source is excluded, and not merely because [`validate_source`] rejects a
575/// writable one anyway: the kubelet does not apply `fsGroup` to in-tree NFS volumes at
576/// all, so no walk ever happens and this predicate's premise is simply false there.
577/// Answering `true` would also make admission emit two errors for one mistake, the
578/// second of them advice — "set `acknowledgeLiveMutation`" — that could never make the
579/// configuration valid.
580///
581/// [`validate_source`]: crate::validate::validate_source
582pub fn source_mutates_live_volume(copy_method: CopyMethod, source: &Source) -> bool {
583    matches!(copy_method, CopyMethod::Direct)
584        && !source_read_only(source)
585        && (source.pvc.is_some() || source.pvc_selector.is_some())
586}
587
588/// schemars default for `PvcSnapshotPolicy::default_deletion_policy` — `Delete`,
589/// the deletion policy produced `Snapshot` CRs inherit. Returns the field's
590/// `Option` type so schemars emits the schema `default:`.
591fn recipe_default_deletion_policy() -> Option<crate::common::DeletionPolicy> {
592    Some(crate::common::DeletionPolicy::Delete)
593}
594
595/// Compression policy.
596#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
597#[serde(rename_all = "camelCase")]
598pub struct Compression {
599    /// kopia compressor name (e.g. `zstd`); absent leaves kopia's default.
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub compressor: Option<String>,
602    /// Filename globs to leave uncompressed (e.g. already-compressed media).
603    #[serde(default, skip_serializing_if = "Vec::is_empty")]
604    pub never_compress: Vec<String>,
605}
606
607/// File-ignore policy.
608#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
609#[serde(rename_all = "camelCase")]
610pub struct Files {
611    /// Filename/path globs to exclude from the snapshot (e.g. `*.tmp`, `*/cache/*`).
612    /// Absent ⇒ [`default_ignore_rules`] (OS-artifact junk: `/lost+found`,
613    /// `System Volume Information`, `$RECYCLE.BIN`, `@eaDir`, `.snapshot`). An
614    /// explicit list REPLACES the default wholesale (re-add any entries you
615    /// still want); explicit `ignoreRules: []` opts fully out. NOT
616    /// `skip_serializing_if` — an explicit empty list must round-trip as `[]`,
617    /// not vanish back to "absent" (which would silently resurrect the
618    /// default on the next parse).
619    #[serde(default = "default_ignore_rules")]
620    #[schemars(default = "default_ignore_rules")]
621    pub ignore_rules: Vec<String>,
622    /// Honor `CACHEDIR.TAG`.
623    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
624    pub ignore_cache_dirs: bool,
625    /// Skip taking a new snapshot when the source is identical to the previous one.
626    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
627    pub ignore_identical_snapshots: bool,
628}
629
630/// Backup-side error-handling policy: let kopia complete a snapshot with errors rather than aborting.
631#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
632#[serde(rename_all = "camelCase")]
633pub struct ErrorHandling {
634    /// Continue the snapshot when a file cannot be read (`--ignore-file-errors`).
635    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
636    pub ignore_file_errors: bool,
637    /// Continue the snapshot when a directory cannot be read (`--ignore-dir-errors`).
638    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
639    pub ignore_dir_errors: bool,
640    /// Continue past entries of unknown type (`--ignore-unknown-types`).
641    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
642    pub ignore_unknown_types: bool,
643    /// Abort the snapshot at the first error instead of collecting and
644    /// continuing (`snapshot create --fail-fast`; kopia default: false). This
645    /// is a `snapshot create` argv flag, not a `policy set` knob, but lives
646    /// beside its semantic opposites (`ignore*Errors`) for discoverability.
647    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
648    pub fail_fast: bool,
649}
650
651/// Upload parallelism (kopia's upload policy); absent knobs leave kopia's default.
652#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
653#[serde(rename_all = "camelCase")]
654pub struct Upload {
655    /// `--max-parallel-snapshots`: how many sources snapshot concurrently.
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub max_parallel_snapshots: Option<i64>,
658    /// `--max-parallel-file-reads`: file-read concurrency within a snapshot.
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub max_parallel_file_reads: Option<i64>,
661    /// `snapshot create --upload-limit-mb`: abort the snapshot once this many
662    /// MB have been uploaded (kopia default: 0 — unlimited). Named `limitMb`
663    /// rather than `uploadLimitMb` to avoid the `upload.uploadLimitMb` stutter;
664    /// like `failFast`, this is a `snapshot create` argv flag, not a `policy
665    /// set` knob, but lives here beside its parallelism siblings.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub limit_mb: Option<i64>,
668}
669
670/// First-class backup verification proving snapshots are restorable; opt-in, with quick and deep tiers.
671#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
672#[serde(rename_all = "camelCase")]
673pub struct Verification {
674    /// Quick (blob-level) verification tier; absent ⇒ no quick verification. Its cron
675    /// lives under `quick.schedule` (matching `deep.schedule`), see [`QuickVerification`].
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub quick: Option<QuickVerification>,
678    /// Schedule + knobs for the rarer scratch-restore test; absent ⇒ no deep verification.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub deep: Option<DeepVerification>,
681    /// CEL pass/fail predicate over the verify result; applies to both tiers.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub success_expr: Option<String>,
684    /// How many files `quick` verifies fully (`--verify-files-percent`); absent leaves kopia's default.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub verify_files_percent: Option<u8>,
687}
688
689/// Quick (blob-level) verification tier: schedule for the frequent `kopia snapshot verify`.
690///
691/// A wrapper so this tier's shape matches `deep` — the cron lives at
692/// `quick.schedule.cron` (GitHub #174). `schedule` is deliberately `Option` for
693/// decode-tolerance: an already-persisted old-shape `quick: { cron: ... }` object
694/// still decodes (serde ignores the unknown `cron` key) as `schedule: None` rather
695/// than failing typed serde — a hard decode failure would wedge the SnapshotPolicy
696/// reflector and poison SnapshotPolicy admission cluster-wide. New writes with the
697/// old shape are rejected at admission by the shared validator, which points at the
698/// move. A persisted `schedule: None` means the quick tier is disabled until updated.
699#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
700#[serde(rename_all = "camelCase")]
701pub struct QuickVerification {
702    /// Cron + jitter + timezone for the frequent blob-level verify; absent ⇒ quick tier disabled.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub schedule: Option<CronSpec>,
705    /// `--parallel`: verification parallelism (kopia default: 8).
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub parallel: Option<u32>,
708    /// `--file-parallelism`: parallelism for file verification (kopia default: unset).
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub file_parallelism: Option<u32>,
711    /// `--file-queue-length`: queue length for file verification (kopia default: 20000).
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub file_queue_length: Option<u32>,
714    /// `--max-errors`: stop after this many errors (kopia default: 0, meaning stop
715    /// at the first error).
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub max_errors: Option<u32>,
718}
719
720/// Deep (scratch-restore) verification: restore the latest snapshot into an ephemeral volume, then discard.
721#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
722#[serde(rename_all = "camelCase")]
723pub struct DeepVerification {
724    /// Cron + jitter for the deep restore-test (e.g. weekly).
725    pub schedule: CronSpec,
726    /// StorageClass for the ephemeral scratch PVC; absent uses the cluster default (only with `capacity`).
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub storage_class_name: Option<String>,
729    /// Size of the ephemeral scratch PVC (e.g. `10Gi`); absent falls back to a node-ephemeral `emptyDir`.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub capacity: Option<String>,
732    /// `restore --parallel`: restore parallelism for the scratch-restore (deep verify
733    /// IS a restore under the hood); absent leaves kopia's default.
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub parallel: Option<u32>,
736}
737
738/// Pre/post snapshot hook lists.
739#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
740#[serde(rename_all = "camelCase")]
741pub struct Hooks {
742    /// Hooks run (in order) before the snapshot is taken — e.g. quiescing a database.
743    #[serde(default, skip_serializing_if = "Vec::is_empty")]
744    pub before_snapshot: Vec<Hook>,
745    /// Hooks run (in order) after the snapshot completes — e.g. resuming the workload.
746    #[serde(default, skip_serializing_if = "Vec::is_empty")]
747    pub after_snapshot: Vec<Hook>,
748}
749
750/// One of three hook forms. Externally-tagged: the wire shape is
751/// `{ workloadExec: {...} }`, `{ runJob: {...} }`, or `{ httpRequest: {...} }`,
752/// and exactly one form is present.
753#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
754#[serde(rename_all = "camelCase")]
755pub enum Hook {
756    /// `kubectl exec`-style into a matched workload pod/container (the default form).
757    WorkloadExec(WorkloadExecHook),
758    /// Full `JobSpec` run as a one-shot Job (k8up `PreBackupPod` analog).
759    RunJob(Box<RunJobHook>),
760    /// Typed POST to a URL for cross-system orchestration.
761    HttpRequest(HttpRequestHook),
762}
763
764impl Hook {
765    /// Stable discriminant string for status/metrics — one of `"WorkloadExec"`,
766    /// `"RunJob"`, or `"HttpRequest"`.
767    ///
768    /// ```
769    /// use kopiur_api::snapshot_policy::{Hook, HttpRequestHook};
770    ///
771    /// let hook = Hook::HttpRequest(HttpRequestHook {
772    ///     url: "https://example/notify".into(),
773    ///     method: None,
774    ///     body: None,
775    ///     headers: Vec::new(),
776    ///     timeout: None,
777    ///     continue_on_failure: false,
778    /// });
779    /// assert_eq!(hook.kind_str(), "HttpRequest");
780    /// ```
781    pub fn kind_str(&self) -> &'static str {
782        match self {
783            Hook::WorkloadExec(_) => "WorkloadExec",
784            Hook::RunJob(_) => "RunJob",
785            Hook::HttpRequest(_) => "HttpRequest",
786        }
787    }
788}
789
790/// `kubectl exec`-style hook into a matched workload pod/container.
791#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
792#[serde(rename_all = "camelCase")]
793pub struct WorkloadExecHook {
794    /// Selects the workload pod/container to exec into (flattened onto the hook).
795    #[serde(flatten)]
796    pub selector: PodSelector,
797    /// Command + args to run inside the selected container.
798    #[serde(default, skip_serializing_if = "Vec::is_empty")]
799    pub command: Vec<String>,
800    /// Max time to wait for the command (Go duration string, e.g. `2m`).
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub timeout: Option<String>,
803    /// If `true`, a failed hook does not abort the backup (default: abort).
804    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
805    pub continue_on_failure: bool,
806}
807
808/// A hook that materializes a full one-shot Job (k8up `PreBackupPod` analog).
809#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
810#[serde(rename_all = "camelCase")]
811pub struct RunJobHook {
812    /// The full Kubernetes `JobSpec` to run.
813    #[schemars(schema_with = "crate::schema::preserve_unknown_object")]
814    pub job_spec: JobSpec,
815    /// Max time to wait for the Job to complete (Go duration string).
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub timeout: Option<String>,
818    /// If `true`, a failed Job does not abort the backup (default: abort).
819    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
820    pub continue_on_failure: bool,
821}
822
823/// One HTTP header sent with an `httpRequest` hook.
824#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
825#[serde(rename_all = "camelCase")]
826pub struct HttpHeader {
827    /// Header name (case-insensitive; RFC 7230 token, e.g. `Content-Type`).
828    pub name: String,
829    /// Header value.
830    pub value: String,
831}
832
833/// A hook that issues an HTTP request for cross-system orchestration.
834#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
835#[serde(rename_all = "camelCase")]
836pub struct HttpRequestHook {
837    /// Target URL to call.
838    pub url: String,
839    /// HTTP method (default `POST`).
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub method: Option<String>,
842    /// Optional request body.
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub body: Option<String>,
845    /// Additional request headers (e.g. `Content-Type: application/json`).
846    #[serde(default, skip_serializing_if = "Vec::is_empty")]
847    pub headers: Vec<HttpHeader>,
848    /// Max time to wait for the response (Go duration string).
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub timeout: Option<String>,
851    /// If `true`, a failed request does not abort the backup (default: abort).
852    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
853    pub continue_on_failure: bool,
854}
855
856/// Observed state of a `SnapshotPolicy`.
857#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
858#[serde(rename_all = "camelCase")]
859pub struct SnapshotPolicyStatus {
860    /// `metadata.generation` last reconciled, for staleness detection.
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub observed_generation: Option<i64>,
863    /// What would be passed to kopia — pinned at admission.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub resolved: Option<ResolvedPolicy>,
866    /// Summary of GFS retention pruning against this config's `Snapshot` CRs.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub retention: Option<RetentionSummary>,
869    /// Summary of automatic adoption of discovered snapshots into this recipe.
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub adoption: Option<AdoptionSummary>,
872    /// RFC3339 timestamp of the most recent successful child `Snapshot` from this recipe.
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub last_successful_snapshot: Option<String>,
875    /// RFC3339 timestamp of the most recent successful verification (any tier).
876    /// Single-repo: stamped directly by the verify mover. Multi-repo: computed
877    /// by the controller as the MINIMUM `lastVerified` across the CURRENT
878    /// repositories ("everything is verified as of T"), absent until every
879    /// current repository has verified at least once.
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub last_verified: Option<String>,
882    /// Per-repository verification records for a multi-repository policy
883    /// (#368): one entry per CURRENT `spec.repositories` member, maintained by
884    /// the controller (single writer — entries for repositories no longer in
885    /// the spec are pruned). Empty (elided) for the single-repo shape, whose
886    /// wire stays byte-identical.
887    #[serde(default, skip_serializing_if = "Vec::is_empty")]
888    pub verification: Vec<RepoVerification>,
889    /// Internal write channel for per-repository verification (#368): RFC3339
890    /// markers keyed by the normalized repository key
891    /// ([`repo_key`](crate::common::repo_key)). Each verify mover merge-patches
892    /// ONLY its own key — a JSON merge patch merges map keys, so two concurrent
893    /// per-repo verifies can never clobber one another (a Vec would be replaced
894    /// wholesale). The controller folds these into `verification` on its next
895    /// pass and prunes keys for repositories no longer in the spec. Never
896    /// written for the single-repo shape.
897    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
898    pub verification_stamps: std::collections::BTreeMap<String, String>,
899    /// Human-readable summary of the policy's repository target(s) for the
900    /// `Repositories` print column: the comma-joined repository names (the one
901    /// name for the single-repo shape), capped near a kubectl column width
902    /// with a `+N` overflow marker. Written by the controller.
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub repository_summary: Option<String>,
905    /// Standard Kubernetes conditions (e.g. `RepositoryReachable`, `GroupSnapshotSupported`).
906    #[serde(default, skip_serializing_if = "Vec::is_empty")]
907    pub conditions: Vec<Condition>,
908}
909
910/// One repository's verification record on a multi-repository policy
911/// (`status.verification`). Entry-keyed by the (normalized) repository ref so
912/// per-repo verify results never collapse into one flat timestamp.
913#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
914#[serde(rename_all = "camelCase")]
915pub struct RepoVerification {
916    /// The repository this record covers, normalized
917    /// ([`normalized_repository_ref`](crate::common::normalized_repository_ref))
918    /// so it re-resolves from anywhere.
919    pub repository: RepositoryRef,
920    /// RFC3339 timestamp of the most recent successful verification (any tier)
921    /// against THIS repository; absent until its first successful verify.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub last_verified: Option<String>,
924}
925
926/// The recipe as kopia would see it, pinned at admission and never re-rendered.
927#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
928#[serde(rename_all = "camelCase")]
929pub struct ResolvedPolicy {
930    /// The resolved `username@hostname` identity.
931    #[serde(default, skip_serializing_if = "Option::is_none")]
932    pub identity: Option<ResolvedIdentity>,
933    /// The concrete PVCs + source paths after selector expansion.
934    #[serde(default, skip_serializing_if = "Vec::is_empty")]
935    pub sources: Vec<ResolvedPolicySource>,
936    /// Per-repository resolution for a MULTI-repository policy
937    /// (`spec.repositories`): one entry per member, each carrying the identity
938    /// resolved under THAT repository's `identityDefaults` (the unit of
939    /// identity is the `(repository, identity)` pair — N members means N
940    /// independent kopia lineages). Empty — and elided from the wire — for the
941    /// classic single-repo shape, whose resolution stays in the top-level
942    /// `identity`/`sources` fields exactly as before this field existed.
943    #[serde(default, skip_serializing_if = "Vec::is_empty")]
944    pub repositories: Vec<ResolvedPolicyRepository>,
945}
946
947/// One member repository's resolution within a multi-repository policy: which
948/// repository, and what kopia identity this policy resolves to **under that
949/// repository's `identityDefaults`**. Consumed by the admission fork guard as
950/// the per-repo baseline (`repo_key` → previously-resolved identity), so an
951/// edit that would re-identify ONE member's lineage is caught even though the
952/// other members are unaffected.
953#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
954#[serde(rename_all = "camelCase")]
955pub struct ResolvedPolicyRepository {
956    /// The member repository this entry resolves for (by value, as listed in
957    /// `spec.repositories`).
958    pub repository: RepositoryRef,
959    /// The `username@hostname` identity resolved under this repository's
960    /// `identityDefaults`; absent when it could not be resolved (the guard
961    /// treats an absent baseline as "no baseline" and degrades to allow for
962    /// that member only).
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub identity: Option<ResolvedIdentity>,
965}
966
967/// One resolved source — a concrete PVC and the path kopia records for it.
968#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
969#[serde(rename_all = "camelCase")]
970pub struct ResolvedPolicySource {
971    /// `namespace/name` of the PVC, as kopia sees it.
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub pvc: Option<String>,
974    /// The source path kopia records for this PVC.
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub source_path: Option<String>,
977}
978
979/// Summary of the most recent GFS retention prune for a `SnapshotPolicy`.
980#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
981#[serde(rename_all = "camelCase")]
982pub struct RetentionSummary {
983    /// CRs currently inside the GFS window.
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub active_snapshot_count: Option<i64>,
986    /// RFC3339 timestamp of the last prune pass.
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub last_prune_at: Option<String>,
989    /// Number of `Snapshot` CRs deleted by the last prune pass.
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub last_prune_deleted: Option<i64>,
992}
993
994/// Summary of the most recent automatic adoption pass for a `SnapshotPolicy` —
995/// discovered snapshots whose resolved identity matched this recipe and were
996/// re-attached (see `Origin::Adopted`), plus an on-demand re-scan request/ack
997/// pair mirroring the repository-level `catalog-scan-requested-at` annotation
998/// contract.
999#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1000#[serde(rename_all = "camelCase")]
1001pub struct AdoptionSummary {
1002    /// RFC3339 timestamp of the last adoption pass that adopted at least one snapshot.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub last_adoption_at: Option<String>,
1005    /// Number of discovered `Snapshot` CRs adopted by the last adoption pass.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub last_adopted_count: Option<u32>,
1008    /// Running total of `Snapshot` CRs ever adopted into this recipe.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub total_adopted: Option<u64>,
1011    /// Identity-matching discovered snapshots the last adoption pass left
1012    /// discovered because `spec.retention` would prune them immediately under
1013    /// the effective `deletionPolicy` (`Retain`/`Orphan` — a CR-only prune that
1014    /// would re-discover and re-adopt forever). `0`/absent when nothing was
1015    /// withheld. See the `AdoptionSkippedByRetention` event for the levers.
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub skipped_by_retention: Option<u32>,
1018    /// Discovered snapshots whose kopia identity matched this policy at the most
1019    /// recent adoption pass, counted BEFORE the own-id and retention filters —
1020    /// so it reads "history relevant to me exists in the catalog", independent
1021    /// of whether that pass adopted anything. A multi-repository policy SUMS
1022    /// this across the ready repositories of the pass. `0` together with
1023    /// `lastScanUnmatched: 0` means the catalog was empty at that pass; absent
1024    /// means no adoption pass has run yet. Neutral inventory, not a verdict:
1025    /// `0` matched beside a non-zero `lastScanUnmatched` is the ordinary shape
1026    /// of a new policy on a shared repository, and is also what a
1027    /// post-disaster-recovery identity mismatch looks like — compare
1028    /// `status.resolved.identity` with the pre-disaster configuration to tell
1029    /// them apart.
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub last_scan_matched: Option<u32>,
1032    /// Discovered snapshots the most recent adoption pass saw that did NOT match
1033    /// this policy's identity (other policies' or other clusters' history in a
1034    /// shared repository). Same counting rules as `lastScanMatched`: pre-filter,
1035    /// summed across a multi-repository policy's ready repositories, `0`/`0`
1036    /// for an empty catalog, absent when no pass has run.
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub last_scan_unmatched: Option<u32>,
1039    /// RFC3339 token echoing an in-flight on-demand adoption scan request for
1040    /// this policy's identity; cleared once honored.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub scan_requested_at: Option<String>,
1043    /// The resolved kopia identity the requested scan was scoped to, pinned at
1044    /// request time so a later identity-changing edit can't retarget an
1045    /// in-flight scan.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub scan_requested_identity: Option<String>,
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use crate::common::RepositoryKind;
1054    use crate::testutil::from_yaml;
1055    use kube::core::CustomResourceExt;
1056
1057    #[test]
1058    fn resolved_policy_single_repo_status_wire_is_byte_identical() {
1059        // Golden: a single-repo policy's ResolvedPolicy serializes EXACTLY as
1060        // it did before `repositories` existed — the empty vec is elided — so
1061        // stored single-repo statuses round-trip byte-identically.
1062        let resolved = ResolvedPolicy {
1063            identity: Some(crate::common::ResolvedIdentity {
1064                username: "pg".into(),
1065                hostname: "billing".into(),
1066                source_path: Some("/pvc/data".into()),
1067            }),
1068            sources: vec![ResolvedPolicySource {
1069                pvc: Some("billing/data".into()),
1070                source_path: Some("/pvc/data".into()),
1071            }],
1072            repositories: vec![],
1073        };
1074        let wire = serde_json::to_value(&resolved).expect("serializes");
1075        assert_eq!(
1076            wire,
1077            serde_json::json!({
1078                "identity": {
1079                    "username": "pg",
1080                    "hostname": "billing",
1081                    "sourcePath": "/pvc/data",
1082                },
1083                "sources": [ { "pvc": "billing/data", "sourcePath": "/pvc/data" } ],
1084            })
1085        );
1086
1087        // …and a pre-feature stored status (no `repositories` key) decodes to
1088        // the empty vec, not an error.
1089        let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1090        assert!(decoded.repositories.is_empty());
1091    }
1092
1093    #[test]
1094    fn resolved_policy_per_repo_entries_round_trip() {
1095        let resolved = ResolvedPolicy {
1096            identity: None,
1097            sources: vec![],
1098            repositories: vec![
1099                ResolvedPolicyRepository {
1100                    repository: RepositoryRef {
1101                        kind: RepositoryKind::Repository,
1102                        name: "nas".into(),
1103                        namespace: None,
1104                    },
1105                    identity: Some(crate::common::ResolvedIdentity {
1106                        username: "pg".into(),
1107                        hostname: "billing".into(),
1108                        source_path: None,
1109                    }),
1110                },
1111                ResolvedPolicyRepository {
1112                    repository: RepositoryRef {
1113                        kind: RepositoryKind::ClusterRepository,
1114                        name: "offsite".into(),
1115                        namespace: None,
1116                    },
1117                    // Unresolvable member: entry present, identity elided.
1118                    identity: None,
1119                },
1120            ],
1121        };
1122        let wire = serde_json::to_value(&resolved).expect("serializes");
1123        assert_eq!(
1124            wire,
1125            serde_json::json!({
1126                "repositories": [
1127                    {
1128                        "repository": { "kind": "Repository", "name": "nas" },
1129                        "identity": { "username": "pg", "hostname": "billing" },
1130                    },
1131                    { "repository": { "kind": "ClusterRepository", "name": "offsite" } },
1132                ],
1133            })
1134        );
1135        let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1136        assert_eq!(decoded, resolved);
1137    }
1138
1139    #[test]
1140    fn snapshot_policy_crd_metadata_is_correct() {
1141        let crd = SnapshotPolicy::crd();
1142        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
1143        assert_eq!(crd.spec.names.kind, "SnapshotPolicy");
1144        assert_eq!(crd.spec.names.plural, "snapshotpolicies");
1145        assert_eq!(
1146            crd.spec.names.short_names.as_deref(),
1147            Some(&["kopiasp".to_string()][..])
1148        );
1149        assert_eq!(crd.spec.scope, "Namespaced");
1150        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
1151    }
1152
1153    #[test]
1154    fn copy_method_carries_static_openapi_default_in_crd() {
1155        // copyMethod must carry a real schema `default: Snapshot` so it appears in
1156        // `kubectl explain` / the stored object and GitOps stops thrashing. `Snapshot`
1157        // (crash-consistent CSI staging) is the community-preferred default; `Direct` /
1158        // `Clone` are opt-in.
1159        let crd = SnapshotPolicy::crd();
1160        let json = serde_json::to_value(&crd).expect("serialize CRD");
1161        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1162            ["properties"]["copyMethod"]["default"];
1163        assert_eq!(
1164            default, "Snapshot",
1165            "copyMethod must emit `default: Snapshot` in the CRD schema; got {default:?}"
1166        );
1167    }
1168
1169    #[test]
1170    fn staging_timeout_round_trips_and_defaults_to_absent() {
1171        // Absent staging parses to None (runtime default 10m applies in the
1172        // controller) and is skip-elided on the wire.
1173        let spec: SnapshotPolicySpec = from_yaml(
1174            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1175        );
1176        assert_eq!(spec.staging, None);
1177        let json = serde_json::to_value(&spec).unwrap();
1178        assert!(
1179            json.get("staging").is_none(),
1180            "absent staging must be elided"
1181        );
1182
1183        // A set timeout round-trips through the cluster's parse path.
1184        let spec: SnapshotPolicySpec = from_yaml(
1185            "repository: { kind: Repository, name: r }\n\
1186             sources: [ { pvc: { name: d } } ]\n\
1187             staging: { timeout: 30m }\n",
1188        );
1189        assert_eq!(
1190            spec.staging,
1191            Some(StagingSpec {
1192                timeout: Some("30m".to_string()),
1193                ..Default::default()
1194            })
1195        );
1196        let json = serde_json::to_value(&spec).unwrap();
1197        assert_eq!(json["staging"]["timeout"], "30m");
1198    }
1199
1200    #[test]
1201    fn staging_overrides_round_trip_and_are_elided_when_absent() {
1202        // The staged-PVC override pair (storageClassName + accessModes) round-trips
1203        // through the cluster's parse path; absent fields are skip-elided so
1204        // "inherit from the source PVC" stays representable as absence.
1205        let spec: SnapshotPolicySpec = from_yaml(
1206            "repository: { kind: Repository, name: r }\n\
1207             sources: [ { pvc: { name: d } } ]\n\
1208             staging: { timeout: 30m, storageClassName: cephfs-shallow, accessModes: [ReadOnlyMany] }\n",
1209        );
1210        let st = spec.staging.as_ref().unwrap();
1211        assert_eq!(st.storage_class_name.as_deref(), Some("cephfs-shallow"));
1212        assert_eq!(st.access_modes, vec![PvcAccessMode::ReadOnlyMany]);
1213        let json = serde_json::to_value(&spec).unwrap();
1214        assert_eq!(json["staging"]["storageClassName"], "cephfs-shallow");
1215        assert_eq!(
1216            json["staging"]["accessModes"],
1217            serde_json::json!(["ReadOnlyMany"])
1218        );
1219
1220        let spec: SnapshotPolicySpec = from_yaml(
1221            "repository: { kind: Repository, name: r }\n\
1222             sources: [ { pvc: { name: d } } ]\n\
1223             staging: { timeout: 30m }\n",
1224        );
1225        let json = serde_json::to_value(&spec).unwrap();
1226        assert!(json["staging"].get("storageClassName").is_none());
1227        assert!(json["staging"].get("accessModes").is_none());
1228    }
1229
1230    #[test]
1231    fn staging_access_modes_legacy_value_decodes_to_unknown_not_an_error() {
1232        // Graceful-decode contract: a non-canonical stored mode deserializes into
1233        // `Unknown` (rejected later by the shared validator, per-CR) instead of a
1234        // serde error that would wedge the typed watcher for every SnapshotPolicy.
1235        let spec: SnapshotPolicySpec = from_yaml(
1236            "repository: { kind: Repository, name: r }\n\
1237             sources: [ { pvc: { name: d } } ]\n\
1238             staging: { accessModes: [ReadWriteOnze] }\n",
1239        );
1240        assert_eq!(
1241            spec.staging.unwrap().access_modes,
1242            vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
1243        );
1244    }
1245
1246    #[test]
1247    fn staging_access_modes_render_a_closed_enum_in_the_crd_schema() {
1248        // First `Vec<unit-enum>` in the API crate: pin that the generated CRD
1249        // schema is `items: {type: string, enum: [...]}` with exactly the four
1250        // canonical modes — and does NOT leak the legacy-decode `Unknown` variant.
1251        let crd = SnapshotPolicy::crd();
1252        let json = serde_json::to_value(&crd).expect("serialize CRD");
1253        let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1254            ["properties"]["staging"]["properties"]["accessModes"]["items"];
1255        assert_eq!(
1256            items["type"], "string",
1257            "items must be strings; got {items}"
1258        );
1259        assert_eq!(
1260            items["enum"],
1261            serde_json::json!([
1262                "ReadWriteOnce",
1263                "ReadOnlyMany",
1264                "ReadWriteMany",
1265                "ReadWriteOncePod"
1266            ]),
1267            "items enum must be exactly the canonical modes; got {items}"
1268        );
1269    }
1270
1271    #[test]
1272    fn copy_method_defaults_to_snapshot_when_absent() {
1273        // A bare value with a serde default: an omitted copyMethod parses to Snapshot (the
1274        // crash-consistent CSI-staged behavior).
1275        let spec: SnapshotPolicySpec = from_yaml(
1276            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1277        );
1278        assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1279        // And it serializes (not skip-elided), so the materialized value round-trips.
1280        let json = serde_json::to_value(&spec).unwrap();
1281        assert_eq!(json["copyMethod"], "Snapshot");
1282    }
1283
1284    /// The 5-entry OS-artifact default set, in the fixed order `default_ignore_rules`
1285    /// returns it — shared by every assertion below so the list itself has one
1286    /// source of truth in the test file too.
1287    fn expected_default_ignore_rules() -> Vec<String> {
1288        vec![
1289            "/lost+found".to_string(),
1290            "System Volume Information".to_string(),
1291            "$RECYCLE.BIN".to_string(),
1292            "@eaDir".to_string(),
1293            ".snapshot".to_string(),
1294        ]
1295    }
1296
1297    #[test]
1298    fn files_ignore_rules_carries_static_openapi_default_in_crd() {
1299        // `files.ignoreRules` must carry a real schema `default:` (the 5-entry
1300        // OS-artifact set) so it appears in `kubectl explain`. Mirrors
1301        // `copy_method_carries_static_openapi_default_in_crd`.
1302        let crd = SnapshotPolicy::crd();
1303        let json = serde_json::to_value(&crd).expect("serialize CRD");
1304        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1305            ["properties"]["files"]["properties"]["ignoreRules"]["default"];
1306        let want: Vec<serde_json::Value> = expected_default_ignore_rules()
1307            .into_iter()
1308            .map(serde_json::Value::String)
1309            .collect();
1310        assert_eq!(
1311            default,
1312            &serde_json::Value::Array(want),
1313            "files.ignoreRules must emit the 5-entry OS-artifact `default:` in the CRD schema; got {default:?}"
1314        );
1315    }
1316
1317    #[test]
1318    fn ignore_rules_defaults_when_files_block_absent_entirely() {
1319        // The load-bearing case: apiserver server-side-defaulting only fires for
1320        // NESTED fields when the parent object is present, so a spec that omits
1321        // `files:` altogether never gets `Files.ignoreRules`'s schema default
1322        // applied by the apiserver. The *serde* default on `Files::ignore_rules`
1323        // only helps once `files: {}` exists — it can't fire on a wholly-`None`
1324        // `spec.files`. This asserts the glue tier's contract: the mover work-spec
1325        // seam (`kopiur_mover::workspec::PolicyArgsSpec::from_policy`) is the layer
1326        // that must apply `default_ignore_rules()` for THIS shape; see the mover
1327        // crate's `workspec` tests for that half.
1328        let spec: SnapshotPolicySpec = from_yaml(
1329            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1330        );
1331        assert!(
1332            spec.files.is_none(),
1333            "a spec omitting `files:` entirely must parse to `None`, not a defaulted `Files`"
1334        );
1335    }
1336
1337    #[test]
1338    fn ignore_rules_defaults_when_files_block_present_but_empty() {
1339        // `files: {}` (parent present, `ignoreRules` absent): the serde default
1340        // DOES fire here, and this is also what the schemars `default:` covers for
1341        // apiserver server-side-defaulting.
1342        let spec: SnapshotPolicySpec = from_yaml(
1343            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: {}\n",
1344        );
1345        let files = spec.files.expect("files: {} must parse to Some(Files)");
1346        assert_eq!(files.ignore_rules, expected_default_ignore_rules());
1347    }
1348
1349    #[test]
1350    fn ignore_rules_explicit_empty_list_opts_out_and_round_trips() {
1351        // Regression test for the opt-out subtlety: an explicit `ignoreRules: []`
1352        // must deserialize as present-empty (serde defaults only fire when the KEY
1353        // is ABSENT, not when it's present-and-empty) and — critically — must
1354        // round-trip back through serialize/deserialize as `[]`, not vanish to
1355        // "absent" and silently resurrect the default. This is why `ignore_rules`
1356        // does NOT carry `skip_serializing_if`.
1357        let spec: SnapshotPolicySpec = from_yaml(
1358            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [] }\n",
1359        );
1360        let files = spec
1361            .files
1362            .as_ref()
1363            .expect("files: {...} must parse to Some(Files)");
1364        assert!(
1365            files.ignore_rules.is_empty(),
1366            "explicit `ignoreRules: []` must opt fully out, got {:?}",
1367            files.ignore_rules
1368        );
1369
1370        // The round-trip: serialize back to JSON, the `ignoreRules` key must still
1371        // be PRESENT (as `[]`), not omitted.
1372        let json = serde_json::to_value(&spec).expect("serialize");
1373        assert_eq!(
1374            json["files"]["ignoreRules"],
1375            serde_json::json!([]),
1376            "an explicit empty ignoreRules must serialize as `[]`, not be omitted \
1377             (omission would deserialize back to the 5-entry default)"
1378        );
1379
1380        // And re-parsing that JSON must still yield the empty, opted-out list —
1381        // not the default reappearing.
1382        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1383        assert_eq!(spec, reparsed);
1384        assert!(reparsed.files.expect("files").ignore_rules.is_empty());
1385    }
1386
1387    #[test]
1388    fn ignore_rules_explicit_custom_list_replaces_default_wholesale() {
1389        // An explicit non-empty list REPLACES the default outright — it is not
1390        // merged/appended. Re-adding a default entry you still want is on the
1391        // user (documented in docs/backups.md).
1392        let spec: SnapshotPolicySpec = from_yaml(
1393            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [\"*.tmp\", \"lost+found\"] }\n",
1394        );
1395        let files = spec.files.expect("files");
1396        assert_eq!(
1397            files.ignore_rules,
1398            vec!["*.tmp".to_string(), "lost+found".to_string()]
1399        );
1400    }
1401
1402    #[test]
1403    fn backup_config_roundtrip_matches_adr_shape() {
1404        // Mirrors ADR-0001 §3.3.
1405        let yaml = r#"
1406repository:
1407  kind: Repository
1408  name: nas-primary
1409  namespace: backups
1410identity:
1411  username: "postgres-data"
1412  hostname: "billing"
1413sources:
1414  - pvc: { name: postgres-data }
1415    sourcePathOverride: /data
1416copyMethod: Snapshot
1417volumeSnapshotClassName: csi-snap-class
1418groupBy: VolumeGroupSnapshot
1419retention:
1420  keepLatest: 10
1421  keepDaily: 14
1422defaultDeletionPolicy: Delete
1423compression:
1424  compressor: zstd
1425  neverCompress: ["*.zip", "*.gz", "*.mp4"]
1426files:
1427  ignoreRules: ["*.tmp", "*/cache/*", "lost+found"]
1428  ignoreCacheDirs: true
1429  ignoreIdenticalSnapshots: true
1430extraArgs: []
1431hooks:
1432  beforeSnapshot:
1433    - workloadExec:
1434        podSelector: { matchLabels: { app: postgres } }
1435        container: postgres
1436        command: ["pg_start_backup", "snap"]
1437        timeout: 2m
1438  afterSnapshot:
1439    - workloadExec:
1440        podSelector: { matchLabels: { app: postgres } }
1441        container: postgres
1442        command: ["pg_stop_backup"]
1443        timeout: 2m
1444mover:
1445  resources:
1446    requests: { cpu: 250m, memory: 512Mi }
1447    limits: { cpu: "2", memory: 4Gi }
1448  cache:
1449    capacity: 16Gi
1450    storageClassName: fast-ssd
1451  securityContext:
1452    runAsUser: 1000
1453    runAsGroup: 1000
1454    runAsNonRoot: true
1455    allowPrivilegeEscalation: false
1456    capabilities: { drop: ["ALL"] }
1457    seccompProfile: { type: RuntimeDefault }
1458  podSecurityContext:
1459    fsGroup: 1000
1460    fsGroupChangePolicy: OnRootMismatch
1461"#;
1462        let spec: SnapshotPolicySpec = from_yaml(yaml);
1463        let repo = spec.repository.as_ref().expect("repository");
1464        assert_eq!(repo.kind, RepositoryKind::Repository);
1465        assert_eq!(repo.name, "nas-primary");
1466        assert_eq!(spec.sources.len(), 1);
1467        assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
1468        assert_eq!(
1469            spec.sources[0].source_path_override.as_deref(),
1470            Some("/data")
1471        );
1472        assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1473        assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
1474        assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
1475        let comp = spec.compression.as_ref().unwrap();
1476        assert_eq!(comp.compressor.as_deref(), Some("zstd"));
1477        let files = spec.files.as_ref().unwrap();
1478        assert_eq!(files.ignore_rules.len(), 3);
1479        assert!(files.ignore_cache_dirs);
1480        assert!(spec.extra_args.is_empty());
1481        let hooks = spec.hooks.as_ref().unwrap();
1482        assert_eq!(hooks.before_snapshot.len(), 1);
1483        assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
1484        // Both the container- and pod-level security contexts round-trip on the mover.
1485        let mover = spec.mover.as_ref().expect("mover");
1486        assert_eq!(
1487            mover.security_context.as_ref().and_then(|s| s.run_as_user),
1488            Some(1000)
1489        );
1490        assert_eq!(
1491            mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1492            Some(1000)
1493        );
1494        // Container UID/GID match + fsGroup is unprivileged (no namespace opt-in).
1495        assert!(!mover.requires_privilege());
1496
1497        let json = serde_json::to_value(&spec).expect("serialize");
1498        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1499        assert_eq!(spec, reparsed);
1500    }
1501
1502    #[test]
1503    fn credential_projection_roundtrip() {
1504        // Opt-in projection now lives on the recipe (SnapshotPolicy), parses the
1505        // cluster's way, and round-trips.
1506        let yaml = r#"
1507repository: { kind: ClusterRepository, name: shared }
1508sources:
1509  - pvc: { name: data }
1510retention: { keepLatest: 5 }
1511credentialProjection:
1512  enabled: true
1513"#;
1514        let spec: SnapshotPolicySpec = from_yaml(yaml);
1515        assert_eq!(
1516            spec.credential_projection.as_ref().map(|p| p.enabled),
1517            Some(true)
1518        );
1519        let json = serde_json::to_value(&spec).expect("serialize");
1520        assert_eq!(json["credentialProjection"]["enabled"], true);
1521        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1522        assert_eq!(spec, reparsed);
1523
1524        // Absent ⇒ None (self-managed default); not serialized.
1525        let bare: SnapshotPolicySpec = from_yaml(
1526            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1527        );
1528        assert!(bare.credential_projection.is_none());
1529        assert!(
1530            serde_json::to_value(&bare)
1531                .unwrap()
1532                .get("credentialProjection")
1533                .is_none()
1534        );
1535        // Empty `{}` defaults enabled=false (opt-in).
1536        let empty: SnapshotPolicySpec = from_yaml(
1537            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\ncredentialProjection: {}\n",
1538        );
1539        assert_eq!(empty.credential_projection.map(|p| p.enabled), Some(false));
1540    }
1541
1542    #[test]
1543    fn backup_config_minimal_selector_source() {
1544        // Mirrors ADR-0001 §5.4 (multi-PVC selector).
1545        let yaml = r#"
1546repository: { kind: Repository, name: nas-primary, namespace: backups }
1547identity: { username: app-bundle, hostname: billing }
1548sources:
1549  - pvcSelector:
1550      labelSelector: { matchLabels: { backup: include } }
1551    sourcePathStrategy: PvcName
1552groupBy: VolumeGroupSnapshot
1553retention: { keepDaily: 14 }
1554"#;
1555        let spec: SnapshotPolicySpec = from_yaml(yaml);
1556        let src = &spec.sources[0];
1557        assert!(src.pvc.is_none());
1558        assert!(src.pvc_selector.is_some());
1559        assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
1560
1561        let json = serde_json::to_value(&spec).unwrap();
1562        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1563        assert_eq!(spec, reparsed);
1564    }
1565
1566    #[test]
1567    fn hook_run_job_variant_with_job_spec() {
1568        // RunJob embeds a full k8s-openapi JobSpec (so the struct is not Eq).
1569        let yaml = r#"
1570runJob:
1571  jobSpec:
1572    template:
1573      spec:
1574        restartPolicy: Never
1575        containers:
1576          - name: pre
1577            image: busybox
1578            command: ["sh", "-c", "echo hi"]
1579  timeout: 5m
1580  continueOnFailure: true
1581"#;
1582        let hook: Hook = from_yaml(yaml);
1583        assert_eq!(hook.kind_str(), "RunJob");
1584        match &hook {
1585            Hook::RunJob(j) => {
1586                assert!(j.continue_on_failure);
1587                assert_eq!(j.timeout.as_deref(), Some("5m"));
1588                assert_eq!(
1589                    j.job_spec
1590                        .template
1591                        .spec
1592                        .as_ref()
1593                        .unwrap()
1594                        .restart_policy
1595                        .as_deref(),
1596                    Some("Never")
1597                );
1598            }
1599            other => panic!("expected RunJob, got {}", other.kind_str()),
1600        }
1601        let json = serde_json::to_value(&hook).unwrap();
1602        assert!(json.get("runJob").is_some());
1603    }
1604
1605    #[test]
1606    fn hook_http_request_variant() {
1607        let hook: Hook = from_yaml(
1608            "httpRequest:\n  url: https://example/notify\n  method: POST\n  headers:\n    - name: Content-Type\n      value: application/json\n    - name: X-Api-Key\n      value: sekrit\n",
1609        );
1610        assert_eq!(hook.kind_str(), "HttpRequest");
1611        let v = serde_json::to_value(&hook).unwrap();
1612        assert_eq!(v["httpRequest"]["url"], "https://example/notify");
1613        assert_eq!(
1614            v.pointer("/httpRequest/headers/0/name")
1615                .and_then(|x| x.as_str()),
1616            Some("Content-Type")
1617        );
1618        assert_eq!(
1619            v.pointer("/httpRequest/headers/1/value")
1620                .and_then(|x| x.as_str()),
1621            Some("sekrit")
1622        );
1623        // Omitted headers stay off the wire (skip_serializing_if).
1624        let bare: Hook = from_yaml("httpRequest:\n  url: https://example/notify\n");
1625        let v = serde_json::to_value(&bare).unwrap();
1626        assert!(v.pointer("/httpRequest/headers").is_none());
1627    }
1628
1629    #[test]
1630    fn hook_unknown_variant_is_rejected() {
1631        let value: serde_json::Value = serde_yaml::from_str("teleport:\n  url: x\n").unwrap();
1632        assert!(serde_json::from_value::<Hook>(value).is_err());
1633    }
1634
1635    #[test]
1636    fn error_handling_upload_and_suspend_roundtrip() {
1637        // ADR-0005 §13(b)/§13(f)/§14(e): the new policy knobs parse the cluster's
1638        // way, default sanely when absent, and round-trip.
1639        let yaml = r#"
1640repository: { kind: Repository, name: r }
1641sources: [ { pvc: { name: d } } ]
1642errorHandling:
1643  ignoreFileErrors: true
1644  ignoreDirErrors: false
1645  ignoreUnknownTypes: true
1646  failFast: true
1647upload:
1648  maxParallelSnapshots: 4
1649  maxParallelFileReads: 8
1650  limitMb: 100
1651suspend: true
1652"#;
1653        let spec: SnapshotPolicySpec = from_yaml(yaml);
1654        let eh = spec.error_handling.as_ref().expect("errorHandling");
1655        assert!(eh.ignore_file_errors);
1656        assert!(!eh.ignore_dir_errors);
1657        assert!(eh.ignore_unknown_types);
1658        assert!(eh.fail_fast);
1659        let up = spec.upload.as_ref().expect("upload");
1660        assert_eq!(up.max_parallel_snapshots, Some(4));
1661        assert_eq!(up.max_parallel_file_reads, Some(8));
1662        assert_eq!(up.limit_mb, Some(100));
1663        assert!(spec.suspend);
1664
1665        let json = serde_json::to_value(&spec).expect("serialize");
1666        assert_eq!(json["suspend"], true);
1667        assert_eq!(json["errorHandling"]["ignoreFileErrors"], true);
1668        assert_eq!(json["errorHandling"]["failFast"], true);
1669        assert_eq!(json["upload"]["maxParallelSnapshots"], 4);
1670        assert_eq!(json["upload"]["limitMb"], 100);
1671        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1672        assert_eq!(spec, reparsed);
1673
1674        // Absent ⇒ None / false (not serialized).
1675        let bare: SnapshotPolicySpec = from_yaml(
1676            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1677        );
1678        assert!(bare.error_handling.is_none());
1679        assert!(bare.upload.is_none());
1680        assert!(!bare.suspend);
1681        let bare_json = serde_json::to_value(&bare).unwrap();
1682        assert!(bare_json.get("suspend").is_none());
1683        assert!(bare_json.get("errorHandling").is_none());
1684    }
1685
1686    #[test]
1687    fn source_schema_carries_exactly_one_of_validation() {
1688        // §15: the Source sub-object schema carries the exactly-one-of(pvc/
1689        // pvcSelector/nfs) rule, surviving kube's structural-schema rewriter even as a
1690        // list-item sub-object.
1691        let crd = SnapshotPolicy::crd();
1692        let json = serde_json::to_value(&crd).expect("serialize CRD");
1693        let source = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1694            ["properties"]["sources"]["items"];
1695        let rules = source["x-kubernetes-validations"]
1696            .as_array()
1697            .expect("sources.items.x-kubernetes-validations present");
1698        assert!(rules.iter().any(|r| {
1699            r["rule"]
1700                .as_str()
1701                .is_some_and(|s| s.contains("pvcSelector") && s.contains("nfs"))
1702        }));
1703    }
1704
1705    #[test]
1706    fn snapshot_policy_has_last_snapshot_and_suspended_columns() {
1707        // ADR-0005 §3: the LAST-SNAPSHOT (status.lastSuccessfulSnapshot) and
1708        // §14(e) SUSPENDED columns are present in the CRD with the right jsonPaths.
1709        let crd = SnapshotPolicy::crd();
1710        let json = serde_json::to_value(&crd).expect("serialize CRD");
1711        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1712            .as_array()
1713            .expect("printer columns");
1714        let by_name = |name: &str| {
1715            cols.iter()
1716                .find(|c| c["name"] == name)
1717                .unwrap_or_else(|| panic!("missing column {name}"))
1718        };
1719        assert_eq!(
1720            by_name("Last-Snapshot")["jsonPath"],
1721            ".status.lastSuccessfulSnapshot"
1722        );
1723        assert_eq!(by_name("Suspended")["jsonPath"], ".spec.suspend");
1724    }
1725
1726    #[test]
1727    fn verification_roundtrip_and_opt_in() {
1728        // ADR-0005 §4: verification parses the cluster's way, round-trips, and is
1729        // opt-in (absent ⇒ None, no behavior change).
1730        let yaml = r#"
1731repository: { kind: Repository, name: r }
1732sources: [ { pvc: { name: d } } ]
1733verification:
1734  quick:
1735    schedule: { cron: "0 4 * * *", jitter: 30m }
1736  deep:
1737    schedule: { cron: "0 5 * * 0", jitter: 1h }
1738    capacity: 10Gi
1739    storageClassName: fast-ssd
1740  successExpr: "stats.files > 0 && stats.errors == 0"
1741  verifyFilesPercent: 10
1742"#;
1743        let spec: SnapshotPolicySpec = from_yaml(yaml);
1744        let v = spec.verification.as_ref().expect("verification");
1745        let quick = v.quick.as_ref().expect("quick");
1746        assert_eq!(quick.schedule.as_ref().unwrap().cron, "0 4 * * *");
1747        let deep = v.deep.as_ref().expect("deep");
1748        assert_eq!(deep.schedule.cron, "0 5 * * 0");
1749        assert_eq!(deep.capacity.as_deref(), Some("10Gi"));
1750        assert_eq!(
1751            v.success_expr.as_deref(),
1752            Some("stats.files > 0 && stats.errors == 0")
1753        );
1754        assert_eq!(v.verify_files_percent, Some(10));
1755
1756        let json = serde_json::to_value(&spec).expect("serialize");
1757        assert_eq!(
1758            json["verification"]["quick"]["schedule"]["cron"],
1759            "0 4 * * *"
1760        );
1761        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1762        assert_eq!(spec, reparsed);
1763
1764        // Absent ⇒ None (no behavior change).
1765        let bare: SnapshotPolicySpec = from_yaml(
1766            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1767        );
1768        assert!(bare.verification.is_none());
1769        assert!(
1770            serde_json::to_value(&bare)
1771                .unwrap()
1772                .get("verification")
1773                .is_none()
1774        );
1775    }
1776
1777    #[test]
1778    fn verification_quick_old_shape_still_decodes() {
1779        // GitHub #174: `verification.quick` gained a nested `schedule`. An object
1780        // persisted in etcd BEFORE this change carries the flat shape
1781        // (`quick: { cron: ... }`). It MUST still decode (serde ignores the unknown
1782        // `cron`/`jitter` keys) as `schedule: None` — a hard decode failure would
1783        // wedge the SnapshotPolicy reflector and poison admission cluster-wide. The
1784        // quick tier is then treated as disabled; the webhook rejects NEW old-shape
1785        // writes with a pointer to the move.
1786        let old = from_yaml::<SnapshotPolicySpec>(
1787            "repository: { kind: Repository, name: r }\n\
1788             sources: [ { pvc: { name: d } } ]\n\
1789             verification:\n  quick: { cron: \"0 4 * * *\", jitter: 30m }\n",
1790        );
1791        let v = old.verification.as_ref().expect("verification");
1792        let quick = v.quick.as_ref().expect("quick present");
1793        assert!(
1794            quick.schedule.is_none(),
1795            "old flat `quick: {{cron: ...}}` must decode with schedule: None (quick disabled)"
1796        );
1797    }
1798
1799    #[test]
1800    fn verification_quick_and_deep_tuning_knobs_roundtrip() {
1801        // M3 (issue #216 category sweep): quick gains `--parallel`/`--file-parallelism`/
1802        // `--file-queue-length`/`--max-errors`; deep gains `--parallel` (it restores
1803        // under the hood). All optional, absent ⇒ kopia's own default.
1804        let yaml = r#"
1805repository: { kind: Repository, name: r }
1806sources: [ { pvc: { name: d } } ]
1807verification:
1808  quick:
1809    schedule: { cron: "0 4 * * *" }
1810    parallel: 2
1811    fileParallelism: 4
1812    fileQueueLength: 100
1813    maxErrors: 1
1814  deep:
1815    schedule: { cron: "0 5 * * 0" }
1816    parallel: 2
1817"#;
1818        let spec: SnapshotPolicySpec = from_yaml(yaml);
1819        let v = spec.verification.as_ref().expect("verification");
1820        let quick = v.quick.as_ref().expect("quick");
1821        assert_eq!(quick.parallel, Some(2));
1822        assert_eq!(quick.file_parallelism, Some(4));
1823        assert_eq!(quick.file_queue_length, Some(100));
1824        assert_eq!(quick.max_errors, Some(1));
1825        let deep = v.deep.as_ref().expect("deep");
1826        assert_eq!(deep.parallel, Some(2));
1827
1828        let json = serde_json::to_value(&spec).expect("serialize");
1829        assert_eq!(json["verification"]["quick"]["parallel"], 2);
1830        assert_eq!(json["verification"]["quick"]["fileParallelism"], 4);
1831        assert_eq!(json["verification"]["quick"]["fileQueueLength"], 100);
1832        assert_eq!(json["verification"]["quick"]["maxErrors"], 1);
1833        assert_eq!(json["verification"]["deep"]["parallel"], 2);
1834        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1835        assert_eq!(spec, reparsed);
1836
1837        // Absent ⇒ None, and the keys are omitted entirely (no dormant defaults).
1838        let bare_yaml = r#"
1839repository: { kind: Repository, name: r }
1840sources: [ { pvc: { name: d } } ]
1841verification:
1842  quick:
1843    schedule: { cron: "0 4 * * *" }
1844  deep:
1845    schedule: { cron: "0 5 * * 0" }
1846"#;
1847        let bare: SnapshotPolicySpec = from_yaml(bare_yaml);
1848        let bv = bare.verification.as_ref().expect("verification");
1849        assert!(bv.quick.as_ref().unwrap().parallel.is_none());
1850        assert!(bv.deep.as_ref().unwrap().parallel.is_none());
1851        let bare_json = serde_json::to_value(&bare).expect("serialize");
1852        assert!(bare_json["verification"]["quick"].get("parallel").is_none());
1853        assert!(bare_json["verification"]["deep"].get("parallel").is_none());
1854    }
1855
1856    #[test]
1857    fn preflight_roundtrip_and_opt_in() {
1858        // Preflight parses the cluster's way, round-trips, and is opt-in.
1859        let yaml = r#"
1860repository: { kind: Repository, name: r }
1861sources: [ { pvc: { name: d } } ]
1862preflight:
1863  timeout: 10m
1864  checks:
1865    - name: maintenance-fresh
1866      expr: "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
1867      message: "maintenance must have run within 7d"
1868    - name: backend-up
1869      expr: "repository.backendReachable"
1870"#;
1871        let spec: SnapshotPolicySpec = from_yaml(yaml);
1872        let pf = spec.preflight.as_ref().expect("preflight");
1873        assert_eq!(pf.timeout.as_deref(), Some("10m"));
1874        assert_eq!(pf.checks.len(), 2);
1875        assert_eq!(pf.checks[0].name, "maintenance-fresh");
1876        assert_eq!(pf.checks[1].expr, "repository.backendReachable");
1877        assert!(pf.checks[1].message.is_none());
1878
1879        let json = serde_json::to_value(&spec).expect("serialize");
1880        assert_eq!(json["preflight"]["checks"][0]["name"], "maintenance-fresh");
1881        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1882        assert_eq!(spec, reparsed);
1883
1884        // Absent ⇒ None (no behavior change).
1885        let bare: SnapshotPolicySpec = from_yaml(
1886            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1887        );
1888        assert!(bare.preflight.is_none());
1889        assert!(
1890            serde_json::to_value(&bare)
1891                .unwrap()
1892                .get("preflight")
1893                .is_none()
1894        );
1895    }
1896
1897    #[test]
1898    fn snapshot_policy_has_last_verified_column() {
1899        // ADR-0005 §4: the LAST-VERIFIED (status.lastVerified) column is present.
1900        let crd = SnapshotPolicy::crd();
1901        let json = serde_json::to_value(&crd).expect("serialize CRD");
1902        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1903            .as_array()
1904            .expect("printer columns");
1905        let col = cols
1906            .iter()
1907            .find(|c| c["name"] == "Last-Verified")
1908            .expect("Last-Verified column");
1909        assert_eq!(col["jsonPath"], ".status.lastVerified");
1910    }
1911
1912    #[test]
1913    fn snapshot_policy_has_repositories_summary_column() {
1914        // #368 B1: the REPOSITORIES column renders status.repositorySummary so a
1915        // multi-repo policy (whose `.spec.repository.name` column is empty) still
1916        // names its targets in `kubectl get`.
1917        let crd = SnapshotPolicy::crd();
1918        let json = serde_json::to_value(&crd).expect("serialize CRD");
1919        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1920            .as_array()
1921            .expect("printer columns");
1922        let col = cols
1923            .iter()
1924            .find(|c| c["name"] == "Repositories")
1925            .expect("Repositories column");
1926        assert_eq!(col["jsonPath"], ".status.repositorySummary");
1927    }
1928
1929    #[test]
1930    fn status_verification_fields_elide_when_empty_and_roundtrip() {
1931        // The golden-byte contract for every new Vec/map status field (#368):
1932        // an empty `verification` / `verificationStamps` / absent
1933        // `repositorySummary` must emit NOTHING, so a single-repo policy's
1934        // status wire is byte-identical to pre-feature operators.
1935        let bare = SnapshotPolicyStatus::default();
1936        let json = serde_json::to_value(&bare).expect("serialize");
1937        assert!(json.get("verification").is_none(), "empty vec must elide");
1938        assert!(
1939            json.get("verificationStamps").is_none(),
1940            "empty map must elide"
1941        );
1942        assert!(
1943            json.get("repositorySummary").is_none(),
1944            "absent summary must elide"
1945        );
1946
1947        // Populated: parse the cluster's way and round-trip.
1948        let status: SnapshotPolicyStatus = serde_json::from_value(serde_json::json!({
1949            "lastVerified": "2026-08-01T00:00:00Z",
1950            "repositorySummary": "nas, offsite",
1951            "verification": [
1952                {
1953                    "repository": { "kind": "Repository", "name": "nas", "namespace": "backups" },
1954                    "lastVerified": "2026-08-01T00:00:00Z"
1955                },
1956                { "repository": { "kind": "ClusterRepository", "name": "offsite" } }
1957            ],
1958            "verificationStamps": {
1959                "Repository/backups/nas": "2026-08-01T00:00:00Z"
1960            }
1961        }))
1962        .expect("status parses");
1963        assert_eq!(status.verification.len(), 2);
1964        assert_eq!(
1965            status.verification[0].last_verified.as_deref(),
1966            Some("2026-08-01T00:00:00Z")
1967        );
1968        assert!(status.verification[1].last_verified.is_none());
1969        assert_eq!(
1970            status
1971                .verification_stamps
1972                .get("Repository/backups/nas")
1973                .map(String::as_str),
1974            Some("2026-08-01T00:00:00Z")
1975        );
1976        let out = serde_json::to_value(&status).expect("serialize");
1977        assert_eq!(
1978            out["verification"][0]["repository"]["name"], "nas",
1979            "camelCase wire shape"
1980        );
1981        assert_eq!(out["repositorySummary"], "nas, offsite");
1982    }
1983
1984    #[test]
1985    fn status_last_successful_snapshot_roundtrips() {
1986        let status: SnapshotPolicyStatus =
1987            from_yaml("lastSuccessfulSnapshot: 2026-06-09T02:00:00Z\n");
1988        assert_eq!(
1989            status.last_successful_snapshot.as_deref(),
1990            Some("2026-06-09T02:00:00Z")
1991        );
1992        let json = serde_json::to_value(&status).unwrap();
1993        assert_eq!(json["lastSuccessfulSnapshot"], "2026-06-09T02:00:00Z");
1994    }
1995
1996    // --- policy-deletion cascade (spec.deletion.onPolicyDelete) --------------
1997
1998    #[test]
1999    fn policy_deletion_on_policy_delete_schema_default_is_retain() {
2000        // Mirrors snapshot_schedule's schedule_deletion_on_schedule_delete_schema_default_is_retain:
2001        // context-free default, safe to server-side-materialize because
2002        // effective_on_policy_delete maps an absent sub-object to the same value.
2003        let crd = SnapshotPolicy::crd();
2004        let json = serde_json::to_value(&crd).unwrap();
2005        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2006        assert_eq!(
2007            spec["properties"]["deletion"]["properties"]["onPolicyDelete"]["default"],
2008            serde_json::json!("Retain")
2009        );
2010        assert_eq!(
2011            effective_on_policy_delete(None),
2012            crate::common::PolicyDeletePolicy::Retain
2013        );
2014    }
2015
2016    #[test]
2017    fn policy_deletion_round_trips_and_absent_stays_none() {
2018        use crate::common::PolicyDeletePolicy;
2019
2020        let spec: SnapshotPolicySpec = from_yaml(
2021            "repository: { kind: Repository, name: r }\n\
2022             sources: [ { pvc: { name: d } } ]\n\
2023             deletion: { onPolicyDelete: Delete }\n",
2024        );
2025        assert_eq!(
2026            spec.deletion.as_ref().map(|d| d.on_policy_delete),
2027            Some(PolicyDeletePolicy::Delete)
2028        );
2029        assert_eq!(
2030            effective_on_policy_delete(spec.deletion.as_ref()),
2031            PolicyDeletePolicy::Delete
2032        );
2033        let json = serde_json::to_value(&spec).unwrap();
2034        assert_eq!(json["deletion"]["onPolicyDelete"], "Delete");
2035        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2036        assert_eq!(spec, reparsed);
2037
2038        // Absent sub-object stays None (not materialized to Retain client-side).
2039        let bare: SnapshotPolicySpec = from_yaml(
2040            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2041        );
2042        assert!(bare.deletion.is_none());
2043        assert!(
2044            serde_json::to_value(&bare)
2045                .unwrap()
2046                .get("deletion")
2047                .is_none(),
2048            "absent deletion must be elided"
2049        );
2050        assert_eq!(
2051            effective_on_policy_delete(bare.deletion.as_ref()),
2052            PolicyDeletePolicy::Retain
2053        );
2054    }
2055
2056    #[test]
2057    fn policy_deletion_unknown_on_policy_delete_value_is_rejected() {
2058        let value: serde_json::Value =
2059            serde_yaml::from_str("deletion:\n  onPolicyDelete: Orphan\n").unwrap();
2060        assert!(serde_json::from_value::<SnapshotPolicySpec>(value).is_err());
2061    }
2062
2063    #[test]
2064    fn policy_delete_policy_serializes_to_expected_strings() {
2065        use crate::common::PolicyDeletePolicy;
2066
2067        assert_eq!(
2068            serde_json::to_value(PolicyDeletePolicy::Retain).unwrap(),
2069            "Retain"
2070        );
2071        assert_eq!(
2072            serde_json::to_value(PolicyDeletePolicy::Delete).unwrap(),
2073            "Delete"
2074        );
2075        assert_eq!(PolicyDeletePolicy::default(), PolicyDeletePolicy::Retain);
2076    }
2077
2078    // --- adoption (spec.adoption + status.adoption) --------------------------
2079
2080    #[test]
2081    fn policy_adoption_round_trips_and_absent_stays_none() {
2082        use crate::common::SnapshotAdoption;
2083
2084        let spec: SnapshotPolicySpec = from_yaml(
2085            "repository: { kind: Repository, name: r }\n\
2086             sources: [ { pvc: { name: d } } ]\n\
2087             adoption: Ignore\n",
2088        );
2089        assert_eq!(spec.adoption, Some(SnapshotAdoption::Ignore));
2090        let json = serde_json::to_value(&spec).unwrap();
2091        assert_eq!(json["adoption"], "Ignore");
2092        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2093        assert_eq!(spec, reparsed);
2094
2095        let bare: SnapshotPolicySpec = from_yaml(
2096            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2097        );
2098        assert!(bare.adoption.is_none());
2099        assert!(
2100            serde_json::to_value(&bare)
2101                .unwrap()
2102                .get("adoption")
2103                .is_none(),
2104            "absent adoption must be elided"
2105        );
2106    }
2107
2108    #[test]
2109    fn policy_adoption_schema_carries_no_default() {
2110        // §4a: the effective default (`Adopt`) is context-dependent (a policy ->
2111        // repo -> constant inheritance chain), so no schemars `default` is
2112        // emitted for THIS field — the reference stays `—`.
2113        let crd = SnapshotPolicy::crd();
2114        let json = serde_json::to_value(&crd).unwrap();
2115        let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2116            ["properties"]["adoption"];
2117        assert!(
2118            prop.get("default").is_none(),
2119            "spec.adoption must NOT carry a schema default: {prop}"
2120        );
2121        assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
2122    }
2123
2124    // --- M7 multi-repository fan-out: golden byte-compat + accessors ---------
2125
2126    /// THE golden byte-compat proof for the `repository: Option` flip: a legacy
2127    /// single-repo spec's wire encoding is pinned as a COMMITTED fixture string
2128    /// (the pre-change encoding), not constructed by round-tripping — so any
2129    /// serialization drift (a leaked `repositories` key above all) fails here.
2130    #[test]
2131    fn legacy_single_repo_spec_wire_is_byte_identical() {
2132        const LEGACY_WIRE: &str = r#"{"repository":{"kind":"Repository","name":"nas-primary","namespace":"backups"},"sources":[{"pvc":{"name":"pgdata"}}],"copyMethod":"Snapshot","retention":{"keepLatest":3}}"#;
2133        assert!(
2134            !LEGACY_WIRE.contains("repositories"),
2135            "fixture precondition"
2136        );
2137
2138        // A legacy YAML parses to the same struct shape it always did…
2139        let spec: SnapshotPolicySpec = from_yaml(
2140            "repository: { kind: Repository, name: nas-primary, namespace: backups }\n\
2141             sources: [ { pvc: { name: pgdata } } ]\n\
2142             retention: { keepLatest: 3 }\n",
2143        );
2144        assert_eq!(spec.repository.as_ref().unwrap().name, "nas-primary");
2145        assert!(spec.repositories.is_empty());
2146
2147        // …and re-serializes to the exact pre-change bytes: no `repositories`
2148        // key (Vec::is_empty skip), `repository` unwrapped exactly as before.
2149        assert_eq!(serde_json::to_string(&spec).unwrap(), LEGACY_WIRE);
2150
2151        // The committed wire decodes back to the identical struct.
2152        let reparsed: SnapshotPolicySpec = serde_json::from_str(LEGACY_WIRE).unwrap();
2153        assert_eq!(spec, reparsed);
2154    }
2155
2156    #[test]
2157    fn repositories_roundtrip_and_absent_stays_off_the_wire() {
2158        let spec: SnapshotPolicySpec = from_yaml(
2159            "repositories:\n\
2160             - { kind: Repository, name: a }\n\
2161             - { kind: ClusterRepository, name: b }\n\
2162             sources: [ { pvc: { name: d } } ]\n",
2163        );
2164        assert!(spec.repository.is_none());
2165        assert_eq!(spec.repositories.len(), 2);
2166        let json = serde_json::to_value(&spec).unwrap();
2167        assert!(json.get("repository").is_none());
2168        assert_eq!(json["repositories"][1]["kind"], "ClusterRepository");
2169        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2170        assert_eq!(spec, reparsed);
2171    }
2172
2173    #[test]
2174    fn policy_repositories_accessors_cover_all_four_shapes() {
2175        use crate::error::ValidationError;
2176
2177        let single: SnapshotPolicySpec = from_yaml(
2178            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2179        );
2180        assert!(matches!(
2181            policy_repositories(&single),
2182            Ok(PolicyRepositories::Single(r)) if r.name == "r"
2183        ));
2184        assert!(!is_multi_repo(&single));
2185        assert_eq!(repository_refs(&single).count(), 1);
2186        assert_eq!(single_repository_ref(&single).unwrap().name, "r");
2187
2188        let multi: SnapshotPolicySpec = from_yaml(
2189            "repositories: [ { name: a }, { name: b } ]\nsources: [ { pvc: { name: d } } ]\n",
2190        );
2191        assert!(matches!(
2192            policy_repositories(&multi),
2193            Ok(PolicyRepositories::Multi(refs)) if refs.len() == 2
2194        ));
2195        assert!(is_multi_repo(&multi));
2196        assert_eq!(
2197            repository_refs(&multi)
2198                .map(|r| r.name.as_str())
2199                .collect::<Vec<_>>(),
2200            vec!["a", "b"]
2201        );
2202        // The single-repo-only accessor refuses multi loudly — never a
2203        // silent repository #1.
2204        assert_eq!(
2205            single_repository_ref(&multi).unwrap_err(),
2206            ValidationError::PolicySingleRepositoryRequired
2207        );
2208
2209        // Neither / both: named errors, never a panic — a stored CR can be
2210        // garbage relative to the current CRD schema.
2211        let neither: SnapshotPolicySpec = from_yaml("sources: [ { pvc: { name: d } } ]\n");
2212        assert_eq!(
2213            policy_repositories(&neither).unwrap_err(),
2214            ValidationError::PolicyRepositoryExactlyOne { got: "neither" }
2215        );
2216        assert_eq!(repository_refs(&neither).count(), 0);
2217
2218        let both: SnapshotPolicySpec = from_yaml(
2219            "repository: { name: r }\nrepositories: [ { name: a } ]\n\
2220             sources: [ { pvc: { name: d } } ]\n",
2221        );
2222        assert_eq!(
2223            policy_repositories(&both).unwrap_err(),
2224            ValidationError::PolicyRepositoryExactlyOne { got: "both" }
2225        );
2226        // Tolerant iterator yields whatever exists, in repository-then-list order.
2227        assert_eq!(
2228            repository_refs(&both)
2229                .map(|r| r.name.as_str())
2230                .collect::<Vec<_>>(),
2231            vec!["r", "a"]
2232        );
2233    }
2234
2235    #[test]
2236    fn select_restore_repository_covers_every_selection_shape() {
2237        use super::select_restore_repository;
2238        use crate::common::{RepositoryKind, RepositoryRef};
2239        use crate::error::ValidationError;
2240        let single: SnapshotPolicySpec = from_yaml(
2241            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2242        );
2243        let multi: SnapshotPolicySpec = from_yaml(
2244            "repositories:\n  - { kind: Repository, name: a }\n  - { kind: ClusterRepository, name: b }\n\
2245             sources: [ { pvc: { name: d } } ]\n",
2246        );
2247        let rref = |kind, name: &str, ns: Option<&str>| RepositoryRef {
2248            kind,
2249            name: name.into(),
2250            namespace: ns.map(str::to_string),
2251        };
2252
2253        // No selection, single-repo → the policy's one ref, verbatim.
2254        let r = select_restore_repository(&single, "pol", "backups", None, "apps").unwrap();
2255        assert_eq!(r.name, "r");
2256
2257        // No selection, multi-repo → refusal naming every valid choice.
2258        match select_restore_repository(&multi, "pol", "backups", None, "apps").unwrap_err() {
2259            ValidationError::RestoreRepositorySelectionRequired { policy, valid } => {
2260                assert_eq!(policy, "pol");
2261                assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2262            }
2263            other => panic!("expected RestoreRepositorySelectionRequired, got {other:?}"),
2264        }
2265
2266        // Explicit member (namespace-qualified to the policy's namespace) → honored.
2267        let explicit = rref(RepositoryKind::Repository, "a", Some("backups"));
2268        let r =
2269            select_restore_repository(&multi, "pol", "backups", Some(&explicit), "apps").unwrap();
2270        assert_eq!(r.name, "a");
2271
2272        // Explicit cluster-scoped member: namespace-free key matches from any
2273        // restore namespace.
2274        let cluster = rref(RepositoryKind::ClusterRepository, "b", None);
2275        assert!(
2276            select_restore_repository(&multi, "pol", "backups", Some(&cluster), "apps").is_ok()
2277        );
2278
2279        // Explicit NON-member → typed refusal naming what was given and what's
2280        // valid. (An unqualified Repository ref resolves in the RESTORE's
2281        // namespace — `apps/a` is not the policy's `backups/a`.)
2282        let typo = rref(RepositoryKind::Repository, "a", None);
2283        match select_restore_repository(&multi, "pol", "backups", Some(&typo), "apps").unwrap_err()
2284        {
2285            ValidationError::RestoreRepositoryNotInPolicy {
2286                given,
2287                policy,
2288                valid,
2289            } => {
2290                assert_eq!(given, "Repository/apps/a");
2291                assert_eq!(policy, "pol");
2292                assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2293            }
2294            other => panic!("expected RestoreRepositoryNotInPolicy, got {other:?}"),
2295        }
2296
2297        // Explicit non-member against a SINGLE-repo policy is refused too (the
2298        // audit-m4 backstop: a typo must not silently read the wrong repo).
2299        let elsewhere = rref(RepositoryKind::ClusterRepository, "offsite", None);
2300        assert!(matches!(
2301            select_restore_repository(&single, "pol", "backups", Some(&elsewhere), "apps")
2302                .unwrap_err(),
2303            ValidationError::RestoreRepositoryNotInPolicy { .. }
2304        ));
2305    }
2306
2307    #[test]
2308    fn snapshot_policy_spec_carries_exactly_one_of_repository_cel_rule() {
2309        // The spec-level CEL rule must survive kube's structural-schema
2310        // rewriter (mirrors restore.rs / snapshot_schedule.rs precedents).
2311        let crd = SnapshotPolicy::crd();
2312        let json = serde_json::to_value(&crd).expect("serialize CRD");
2313        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2314        let rules = spec["x-kubernetes-validations"]
2315            .as_array()
2316            .expect("spec-level x-kubernetes-validations present");
2317        assert!(
2318            rules.iter().any(|r| {
2319                r["rule"]
2320                    == "(has(self.repository) ? 1 : 0) + (has(self.repositories) ? 1 : 0) == 1"
2321                    && r["message"] == "exactly one of repository, repositories"
2322            }),
2323            "missing the exactly-one-of CEL rule; got {rules:?}"
2324        );
2325        // `repository` is no longer structurally required…
2326        let required = spec["required"].as_array().cloned().unwrap_or_default();
2327        assert!(
2328            !required.iter().any(|f| f == "repository"),
2329            "repository must not be schema-required anymore; got {required:?}"
2330        );
2331        // …and `repositories` carries the 1..=8 bounds (minItems 1 makes an
2332        // explicit `repositories: []` a structural rejection, so the CEL rule
2333        // never has to reason about a present-but-empty list).
2334        let repos = &spec["properties"]["repositories"];
2335        assert_eq!(repos["minItems"], 1, "{repos}");
2336        assert_eq!(repos["maxItems"], 8, "{repos}");
2337    }
2338
2339    #[test]
2340    fn snapshot_policy_repository_print_column_is_unchanged() {
2341        // The `Repository` column stays `.spec.repository.name` (renders
2342        // empty for a multi-repo policy — the `Repositories` summary column
2343        // covers that shape; documented in docs/backups.md).
2344        let crd = SnapshotPolicy::crd();
2345        let json = serde_json::to_value(&crd).expect("serialize CRD");
2346        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2347            .as_array()
2348            .expect("printer columns");
2349        let col = cols
2350            .iter()
2351            .find(|c| c["name"] == "Repository")
2352            .expect("Repository column");
2353        assert_eq!(col["jsonPath"], ".spec.repository.name");
2354    }
2355
2356    #[test]
2357    fn adoption_summary_status_roundtrips() {
2358        let status: SnapshotPolicyStatus = from_yaml(
2359            "adoption:\n  \
2360             lastAdoptionAt: 2026-06-09T02:00:00Z\n  \
2361             lastAdoptedCount: 3\n  \
2362             totalAdopted: 42\n  \
2363             lastScanMatched: 3\n  \
2364             lastScanUnmatched: 7\n  \
2365             scanRequestedAt: 2026-06-10T00:00:00Z\n  \
2366             scanRequestedIdentity: postgres@billing\n",
2367        );
2368        let a = status.adoption.as_ref().expect("adoption");
2369        assert_eq!(a.last_adoption_at.as_deref(), Some("2026-06-09T02:00:00Z"));
2370        assert_eq!(a.last_adopted_count, Some(3));
2371        assert_eq!(a.total_adopted, Some(42));
2372        assert_eq!(a.last_scan_matched, Some(3));
2373        assert_eq!(a.last_scan_unmatched, Some(7));
2374        assert_eq!(a.scan_requested_at.as_deref(), Some("2026-06-10T00:00:00Z"));
2375        assert_eq!(
2376            a.scan_requested_identity.as_deref(),
2377            Some("postgres@billing")
2378        );
2379
2380        let json = serde_json::to_value(&status).unwrap();
2381        assert_eq!(json["adoption"]["lastAdoptedCount"], 3);
2382        assert_eq!(json["adoption"]["totalAdopted"], 42);
2383        assert_eq!(json["adoption"]["lastScanMatched"], 3);
2384        assert_eq!(json["adoption"]["lastScanUnmatched"], 7);
2385        let reparsed: SnapshotPolicyStatus = serde_json::from_value(json).unwrap();
2386        assert_eq!(status, reparsed);
2387
2388        // A pass that saw an EMPTY catalog stamps an explicit 0/0 — it must
2389        // survive the round-trip as `Some(0)`, distinct from "no pass yet".
2390        let empty_scan: SnapshotPolicyStatus =
2391            from_yaml("adoption:\n  lastScanMatched: 0\n  lastScanUnmatched: 0\n");
2392        let a = empty_scan.adoption.as_ref().expect("adoption");
2393        assert_eq!(
2394            (a.last_scan_matched, a.last_scan_unmatched),
2395            (Some(0), Some(0))
2396        );
2397        let wire = serde_json::to_value(&empty_scan).unwrap();
2398        assert_eq!(wire["adoption"]["lastScanMatched"], 0);
2399        assert_eq!(wire["adoption"]["lastScanUnmatched"], 0);
2400
2401        // Absent ⇒ None, elided.
2402        let bare: SnapshotPolicyStatus = from_yaml("{}\n");
2403        assert!(bare.adoption.is_none());
2404        assert!(
2405            serde_json::to_value(&bare)
2406                .unwrap()
2407                .get("adoption")
2408                .is_none(),
2409            "absent adoption summary must be elided"
2410        );
2411    }
2412}