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/src/content/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`, `stream`
312/// (webhook-enforced).
313// The exactly-one-of rule is written as an integer sum of `has()` ternaries rather
314// than `[...].filter(x,x).size()==1`: the apiserver estimates per-item CEL cost ×
315// `maxItems`, and a list-construction + lambda `filter` blows the budget on the
316// repeating `sources` list. The sum form is a cheap constant per item.
317// `Default` is derived purely for construction ergonomics: `Source` is built as an
318// exhaustive struct literal in ~20 places, and every added field would otherwise have
319// to be spelled out at each one. An all-`None` `Source` is not a valid spec (the CEL
320// rule above demands exactly one of pvc/pvcSelector/nfs/stream) and admission rejects it.
321//
322// The forms stay sibling `Option`s rather than an externally-tagged enum because they
323// SHARE the `sourcePath*`/`readOnly` keys (see `crate::validate::validate_source`), which
324// an enum could only express with a `#[serde(flatten)]` kube's structural-schema rewriter
325// cannot represent. The type-safety thesis is upheld one layer up instead: [`source_shape`]
326// resolves this struct into the [`SourceShape`] enum, and every reconcile path matches on
327// THAT exhaustively — so a fifth form cannot compile until each handler accounts for it.
328#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, JsonSchema)]
329#[schemars(extend("x-kubernetes-validations" = [{
330    "rule": "(has(self.pvc) ? 1 : 0) + (has(self.pvcSelector) ? 1 : 0) + (has(self.nfs) ? 1 : 0) + (has(self.stream) ? 1 : 0) == 1",
331    "message": "exactly one of pvc, pvcSelector, nfs, stream"
332}]))]
333#[serde(rename_all = "camelCase")]
334pub struct Source {
335    /// Single PVC by name. Mutually exclusive with `pvcSelector`/`nfs`.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub pvc: Option<PvcSource>,
338    /// Label/namespace selector matching many PVCs. Mutually exclusive with `pvc`/`nfs`.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub pvc_selector: Option<PvcSelector>,
341    /// An inline NFS export to back up directly. Mutually exclusive with `pvc`/`pvcSelector`/`stream`.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub nfs: Option<NfsVolume>,
344    /// Capture a command's standard output as one virtual file — a logical backup
345    /// (`pg_dumpall`, `mysqldump`, …) rather than a volume copy. Mutually exclusive
346    /// with `pvc`/`pvcSelector`/`nfs`. No volume is mounted; the mover execs the
347    /// command in a running workload Pod and streams its stdout straight into kopia.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub stream: Option<StreamSource>,
350    /// Mount the source read-only (default `true`; kopia only ever reads it).
351    ///
352    /// Set `false` **only** to make `fsGroup` work on the source. The kubelet applies
353    /// `fsGroup` by recursively `chgrp`-ing the volume and adding group-write — and it
354    /// skips that walk entirely on a read-only mount, which is why a mover
355    /// `fsGroup`/`fsGroupChangePolicy` otherwise has no effect here. Under
356    /// `copyMethod: Snapshot`/`Clone` the walk rewrites the throwaway staged PVC and
357    /// never touches your data. Under `copyMethod: Direct` it rewrites the LIVE volume,
358    /// which requires `acknowledgeLiveMutation`.
359    ///
360    /// Not supported on an `nfs` source: the kubelet does not apply `fsGroup` to
361    /// in-tree NFS volumes at all, so a read-write mount would grant nothing.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    #[schemars(default = "default_source_read_only")]
364    pub read_only: Option<bool>,
365    /// Acknowledges that `copyMethod: Direct` + `readOnly: false` lets the kubelet
366    /// recursively `chgrp` the **live** volume to the mover's `fsGroup` and make it
367    /// group-writable — permanently, while the workload is running. Required for that
368    /// combination alone.
369    ///
370    /// Ignored (not rejected) otherwise: it is an acknowledgement, never harmful to
371    /// carry, and rejecting a stale one would make switching `copyMethod` between
372    /// `Direct` and `Snapshot`/`Clone` a two-step edit in both directions.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub acknowledge_live_mutation: Option<bool>,
375    /// What kopia records as the source path (default `/pvc/<name>`, or the NFS export `path`).
376    ///
377    /// On a `pvcSelector` source this is a footgun. It is ONE literal path, so
378    /// it works only while the selector matches exactly one PVC; the moment a
379    /// second PVC matches, both would land on that same kopia source, merging
380    /// their histories into one stream where they also prune each other, so the
381    /// BACKUP is refused at run time and no further snapshots are minted for
382    /// that source.
383    ///
384    /// Watch the failed schedule fire, not verification: the earlier snapshots
385    /// remain at that path, so verification keeps passing while backups have
386    /// stopped. (Verification only fails on a path that never received a
387    /// backup.) Prefer `sourcePathStrategy`, which derives a distinct path per
388    /// PVC, or put the override on its own `pvc:` source.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    #[schemars(length(max = 4096))]
391    pub source_path_override: Option<String>,
392    /// How a `pvcSelector`-matched PVC's source path is derived (`pvcName` vs `pvcNamespacedName`).
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    #[schemars(default = "default_source_path_strategy")]
395    pub source_path_strategy: Option<SourcePathStrategy>,
396}
397
398/// The virtual directory a stream source's single file lives under, so a streamed
399/// artifact can never collide with a PVC source's `/pvc/<name>` namespace.
400pub const STREAM_SOURCE_ROOT: &str = "/stream";
401
402/// Capture a command's standard output as ONE virtual file inside a normal kopia
403/// snapshot — the logical-backup source (`pg_dumpall`, `mysqldump`, …).
404///
405/// Nothing is mounted: the mover execs `workloadExec.command` in a running workload
406/// Pod and pipes its stdout directly into `kopia snapshot create --stdin-file`. The
407/// snapshot root is a virtual directory at `/stream/<fileName>` (override with
408/// `sourcePathOverride`) containing exactly that one file.
409///
410/// The command runs in the WORKLOAD's container, so it uses the database credentials
411/// already present there; the mover never hands it repository credentials.
412#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
413#[serde(rename_all = "camelCase")]
414pub struct StreamSource {
415    /// Name of the single virtual file stored in the snapshot (e.g. `postgres.sql`).
416    ///
417    /// Must be ONE file name: no `/`, and not `.` or `..`. kopia stores this string
418    /// verbatim as the entry name and does not sanitize it, so a path-shaped value
419    /// would make a later `kopia restore` write OUTSIDE its destination directory.
420    #[schemars(length(min = 1, max = 255))]
421    pub file_name: String,
422    /// The producer: what to run, and where. Its stdout IS the backup data.
423    pub workload_exec: StreamExec,
424}
425
426/// Exec a command in exactly one running workload Pod, streaming one of its
427/// standard streams. Used as the producer of a `stream` backup source and as the
428/// consumer of a `streamExec` restore target.
429///
430/// Exactly one RUNNING Pod must match `podSelector`: zero, several, or only
431/// not-running matches are named failures, never an arbitrary pick — a backup that
432/// silently dumped a different replica than intended is worse than one that stops.
433#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
434#[serde(rename_all = "camelCase")]
435pub struct StreamExec {
436    /// Standard label selector identifying the workload Pod, resolved in the
437    /// `SnapshotPolicy`'s (or `Restore`'s) own namespace. Must not be empty — an
438    /// empty selector matches every Pod in the namespace.
439    pub pod_selector: LabelSelector,
440    /// Container to exec in; absent uses the Pod's default container.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub container: Option<String>,
443    /// The argv to execute. NOT a shell line: element 0 is the program, so use
444    /// `["sh", "-ec", "..."]` explicitly if you want shell semantics.
445    ///
446    /// Reference credentials through the container's existing environment or mounted
447    /// Secrets — never inline them here. This argv is copied into the mover Pod's
448    /// spec, so anyone with `pods:get` in the namespace can read it.
449    #[schemars(length(min = 1))]
450    pub command: Vec<String>,
451    /// Go duration bounding the command (e.g. `2h`; default `1h`). On expiry the
452    /// command is abandoned and the run fails, leaving no snapshot behind.
453    ///
454    /// The same bound applies whether this exec is a backup's producer or a
455    /// `streamExec` restore's consumer.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    #[schemars(default = "default_stream_timeout")]
458    pub timeout: Option<String>,
459}
460
461/// schemars default for [`StreamExec::timeout`] —
462/// [`DEFAULT_STREAM_TIMEOUT_SECS`](crate::consts::DEFAULT_STREAM_TIMEOUT_SECS)
463/// (`1h`) rendered as the Go duration string the field takes.
464///
465/// Context-free per api-conventions §4a: BOTH resolution sites (the backup Job
466/// build in `snapshot::build` and the restore Job build in `restore`) map an
467/// absent — or unparseable — `timeout` to exactly that constant, and the field
468/// means the same thing as a producer and as a consumer, so materializing `1h`
469/// server-side changes no behavior. A unit test pins `"1h"` to the constant.
470fn default_stream_timeout() -> Option<String> {
471    Some("1h".to_string())
472}
473
474/// The four mutually-exclusive forms a [`Source`] can take, resolved from its
475/// sibling `Option`s.
476///
477/// THE point of this type: `Source` must stay a struct of `Option`s on the wire (the
478/// forms share `sourcePath*`/`readOnly` keys), but every reconcile path matches on
479/// this enum EXHAUSTIVELY — so a fifth source form cannot compile until backup Job
480/// construction, validation, and identity resolution each account for it. Borrowed,
481/// so callers match without cloning.
482///
483/// Not to be confused with the private `expand::SourceShape`, a narrower classifier
484/// used only by [`restore_source_path`](crate::expand::restore_source_path): that one
485/// answers "can this source's kopia path be derived from the restore's target PVC?",
486/// keeps only the fields that decision needs, and tolerates a malformed stored object
487/// with an `Invalid` arm where this resolver returns a
488/// [`ValidationError`](crate::error::ValidationError). Adding a form here means adding
489/// an arm there.
490#[derive(Debug, Clone, Copy, PartialEq)]
491pub enum SourceShape<'a> {
492    /// One PVC named directly.
493    Pvc(&'a PvcSource),
494    /// A label/namespace selector matching many PVCs (fans out into child Snapshots).
495    PvcSelector(&'a PvcSelector),
496    /// An inline NFS export read directly.
497    Nfs(&'a NfsVolume),
498    /// A command whose stdout is captured as one virtual file.
499    Stream(&'a StreamSource),
500}
501
502impl SourceShape<'_> {
503    /// Stable discriminant string for status, metrics, and messages.
504    pub fn kind_str(&self) -> &'static str {
505        match self {
506            SourceShape::Pvc(_) => "pvc",
507            SourceShape::PvcSelector(_) => "pvcSelector",
508            SourceShape::Nfs(_) => "nfs",
509            SourceShape::Stream(_) => "stream",
510        }
511    }
512}
513
514/// THE exactly-one-of resolver for a [`Source`]'s form.
515///
516/// Never panics: a stored CR can carry an invalid shape (none set, or several — e.g. a
517/// write that raced an old CRD schema), so those come back as a
518/// [`ValidationError`](crate::error::ValidationError) for the caller's error path rather
519/// than as an `unwrap` in a reconciler. Mirrors
520/// [`policy_repositories`](crate::snapshot_policy::policy_repositories).
521///
522/// ```
523/// use kopiur_api::snapshot_policy::{Source, SourceShape, PvcSource, source_shape};
524///
525/// let s = Source { pvc: Some(PvcSource { name: "data".into() }), ..Default::default() };
526/// assert!(matches!(source_shape(&s), Ok(SourceShape::Pvc(p)) if p.name == "data"));
527///
528/// // Nothing set is a named error, never a silent default.
529/// assert!(source_shape(&Source::default()).is_err());
530/// ```
531pub fn source_shape(source: &Source) -> Result<SourceShape<'_>, crate::error::ValidationError> {
532    let set: Vec<&'static str> = [
533        ("pvc", source.pvc.is_some()),
534        ("pvcSelector", source.pvc_selector.is_some()),
535        ("nfs", source.nfs.is_some()),
536        ("stream", source.stream.is_some()),
537    ]
538    .into_iter()
539    .filter_map(|(name, present)| present.then_some(name))
540    .collect();
541
542    match set.as_slice() {
543        [] => Err(crate::error::ValidationError::MissingRequiredField {
544            field: "source.pvc, source.pvcSelector, source.nfs, or source.stream".to_string(),
545        }),
546        [first, second, ..] => Err(crate::error::ValidationError::MutuallyExclusive {
547            a: (*first).to_string(),
548            b: (*second).to_string(),
549            context: "snapshot source".to_string(),
550        }),
551        // Exactly one is set; return the one that is. The `expect`s cannot fire —
552        // each arm is reached only when its own `is_some()` put the name in `set`.
553        ["pvc"] => Ok(SourceShape::Pvc(source.pvc.as_ref().expect("pvc is set"))),
554        ["pvcSelector"] => Ok(SourceShape::PvcSelector(
555            source.pvc_selector.as_ref().expect("pvcSelector is set"),
556        )),
557        ["nfs"] => Ok(SourceShape::Nfs(source.nfs.as_ref().expect("nfs is set"))),
558        ["stream"] => Ok(SourceShape::Stream(
559            source.stream.as_ref().expect("stream is set"),
560        )),
561        // Unreachable: `set` is built from exactly the four names above.
562        [other] => Err(crate::error::ValidationError::InvalidFieldValue {
563            field: "spec.sources[]".to_string(),
564            reason: format!("unknown source form `{other}`"),
565        }),
566    }
567}
568
569/// The kopia source path a stream source records by default: `/stream/<fileName>`.
570/// Deliberately distinct from a PVC source's `/pvc/<name>` so a streamed artifact and
571/// a volume backup can never share a kopia identity.
572pub fn stream_source_path(stream: &StreamSource) -> String {
573    format!("{STREAM_SOURCE_ROOT}/{}", stream.file_name)
574}
575
576/// A single backup source addressed by PVC name.
577#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
578#[serde(rename_all = "camelCase")]
579pub struct PvcSource {
580    /// Name of the `PersistentVolumeClaim` to back up (in the `SnapshotPolicy`'s namespace).
581    pub name: String,
582}
583
584/// Selects PVCs across namespaces by label.
585#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
586#[serde(rename_all = "camelCase")]
587pub struct PvcSelector {
588    /// Restricts the search to specific namespaces; absent means the policy's own namespace.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub namespace_selector: Option<NamespaceSelector>,
591    /// Standard Kubernetes label selector matching the PVCs to include.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub label_selector: Option<LabelSelector>,
594}
595
596/// Restricts a `PvcSelector` to an explicit set of namespaces.
597#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
598#[serde(rename_all = "camelCase")]
599pub struct NamespaceSelector {
600    /// Exact namespace names to search; empty means the policy's own namespace.
601    #[serde(default, skip_serializing_if = "Vec::is_empty")]
602    pub match_names: Vec<String>,
603}
604
605/// serde/schemars `default` for [`SnapshotPolicySpec::copy_method`] — **`Snapshot`**.
606///
607/// `Snapshot` (point-in-time CSI `VolumeSnapshot` staging) is the default because it
608/// is **crash-consistent**: kopia reads a frozen point-in-time capture instead of a
609/// live, possibly-mid-write PVC, which matters most for databases and other stateful
610/// apps. It requires the CSI external-snapshotter stack plus a `VolumeSnapshotClass`
611/// for the source's driver. `Direct` (read the live PVC) remains available and is the
612/// right choice for non-CSI/static sources (e.g. hostPath, some NFS setups) or when the
613/// snapshot stack isn't installed — set `copyMethod: Direct` explicitly to opt in. If
614/// the CSI stack is missing under the `Snapshot` default, the operator fails loud: the
615/// `Snapshot`/`SnapshotPolicy` status condition and Warning Event spell out exactly
616/// what to install or which field to set (see `crates/controller/src/io/staging.rs`).
617///
618/// A named fn so it backs BOTH `#[serde(default = ...)]` and `#[schemars(default = ...)]`,
619/// which is what makes schemars 1 emit a real OpenAPI `default:` in the generated CRD.
620fn default_copy_method() -> CopyMethod {
621    CopyMethod::Snapshot
622}
623
624/// The default OS-artifact exclude set for `Files.ignore_rules` — filesystem/NAS
625/// junk that is never intentional user data, so excluding it by default is
626/// additive-safe. Per-entry rationale:
627///
628/// - `/lost+found` — root-anchored ext4/fsck recovery dir. Anchored (leading
629///   `/`) so a *nested* user directory named `lost+found` is left alone; only
630///   the source root's own fsck dir is excluded.
631/// - `System Volume Information`, `$RECYCLE.BIN` — Windows/SMB-client
632///   artifacts that show up on samba-share-backed PVCs.
633/// - `@eaDir` — Synology NAS extended-attribute/thumbnail metadata junk.
634/// - `.snapshot` — NAS-exposed snapshot pseudo-directories (NetApp-style).
635///   Deliberately **unanchored** (no leading `/`): these appear at *every*
636///   level of a NetApp-backed export, not just the root, and backing one up
637///   recursively would multiply the backup size by re-capturing older
638///   snapshot generations as regular file data. Flip side: a legitimate
639///   directory named `.snapshot` at any depth is also excluded — set
640///   `ignoreRules` explicitly if you have one (your list replaces the default).
641///
642/// A named fn so it backs BOTH `#[serde(default = ...)]` (the common case: an
643/// absent `files:` block, handled by the controller glue in
644/// `kopiur_mover::workspec` since the apiserver only server-side-defaults
645/// NESTED fields when the parent object is present) AND
646/// `#[schemars(default = ...)]` (so the default is visible in the generated
647/// CRD schema / `kubectl explain`, and applies when `files: {}` is present
648/// without `ignoreRules`). ONE source of truth for both layers.
649pub fn default_ignore_rules() -> Vec<String> {
650    vec![
651        "/lost+found".to_string(),
652        "System Volume Information".to_string(),
653        "$RECYCLE.BIN".to_string(),
654        "@eaDir".to_string(),
655        ".snapshot".to_string(),
656    ]
657}
658
659/// Volume snapshot copy method. Closed enum. ADR §3.3.
660///
661/// ```
662/// use kopiur_api::CopyMethod;
663///
664/// // Defaults to crash-consistent CSI VolumeSnapshot staging.
665/// assert_eq!(CopyMethod::default(), CopyMethod::Snapshot);
666/// // Serializes as a bare PascalCase string (no external tagging — it has no payload).
667/// assert_eq!(serde_json::to_value(CopyMethod::Snapshot).unwrap(), "Snapshot");
668/// assert_eq!(serde_json::to_value(CopyMethod::Direct).unwrap(), "Direct");
669/// ```
670#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
671pub enum CopyMethod {
672    /// Point-in-time CSI volume snapshot (the default; requires the CSI snapshot stack + a `VolumeSnapshotClass`).
673    #[default]
674    Snapshot,
675    /// CSI volume clone of the source (opt-in; requires a cloning-capable CSI driver). Mounted per `sources[].readOnly` — read-only by default.
676    Clone,
677    /// Read the live PVC directly with no intermediate snapshot/clone (opt-in; works on any storage, no CSI required).
678    Direct,
679}
680
681/// `SnapshotPolicy.spec.staging` — knobs for the CSI capture (`copyMethod:
682/// Snapshot`/`Clone`) that runs before the mover. A sub-object so future staging
683/// fields slot in without API breakage.
684#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
685#[serde(rename_all = "camelCase")]
686pub struct StagingSpec {
687    /// How long each staging phase may take before the backup is failed (Go-style
688    /// duration like `10m` or `1h`; default `10m`): first the staged
689    /// `VolumeSnapshot` becoming `readyToUse` (measured from its creation), then —
690    /// on an `Immediate`-binding StorageClass — the staged PVC binding (a fresh
691    /// budget measured from the PVC's creation, covering the CSI restore/clone).
692    /// A transient CSI/snapshot-controller error during either wait is retried,
693    /// never fatal on its own — only this deadline fails staging. A zero duration
694    /// (`0`/`0s`) waits indefinitely. Raise this for backends whose snapshots or
695    /// clones take long (e.g. cloud snapshots of large volumes, CephFS full clones
696    /// of small-file-heavy volumes).
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub timeout: Option<String>,
699    /// StorageClass for the **staged PVC** — the temporary PVC restored from the
700    /// CSI `VolumeSnapshot` (`copyMethod: Snapshot`) or cloned from the source
701    /// (`copyMethod: Clone`). Absent ⇒ the staged PVC copies the source PVC's
702    /// class. Must belong to the **same CSI driver** as the source (staging fails
703    /// fast on a mismatch). Flagship use: a rook-ceph CephFS class with
704    /// `backingSnapshot: "true"`, which mounts the snapshot shallowly
705    /// (metadata-only, near-instant, read-only) instead of running a full
706    /// subvolume clone that can take many minutes on small-file-heavy volumes.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub storage_class_name: Option<String>,
709    /// Access modes for the staged PVC. Empty ⇒ copy the source PVC's modes.
710    /// `[ReadOnlyMany]` pairs with snapshot-backed read-only classes (e.g. CephFS
711    /// `backingSnapshot`); the mover mounts the staged PVC read-only to match, and
712    /// rejects it at admission if a source sets `readOnly: false` (a read-only stage
713    /// cannot be mounted read-write).
714    #[serde(default, skip_serializing_if = "Vec::is_empty")]
715    pub access_modes: Vec<PvcAccessMode>,
716}
717
718/// Multi-PVC grouping strategy. Defaults to a consistent group snapshot across
719/// all PVCs; set `None` *explicitly* to accept independent per-PVC snapshots,
720/// because a silent per-PVC fallback would produce inconsistent backups.
721#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
722pub enum GroupBy {
723    /// Consistent group snapshot across all PVCs (default for multi-PVC).
724    #[default]
725    VolumeGroupSnapshot,
726    /// Opt into independent per-PVC snapshots.
727    None,
728}
729
730/// schemars default for `PvcSnapshotPolicy::group_by` — the consistent group
731/// snapshot. Returns the field's `Option` type so schemars emits the schema
732/// `default:` (`VolumeGroupSnapshot`) for `kubectl explain`.
733fn default_group_by() -> Option<GroupBy> {
734    Some(GroupBy::VolumeGroupSnapshot)
735}
736
737/// How a selector-matched PVC's source path is derived. Only relevant for
738/// `pvcSelector` sources, where one recipe expands to many PVCs and each needs a
739/// distinct kopia source path. Defaults to `PvcName`.
740#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
741pub enum SourcePathStrategy {
742    /// Path derived from the PVC name alone (default).
743    #[default]
744    PvcName,
745    /// Path derived from `<namespace>/<name>` to disambiguate same-named PVCs across namespaces.
746    PvcNamespacedName,
747}
748
749/// schemars default for `PvcSnapshotPolicy::source_path_strategy` — `PvcName`.
750/// Returns the field's `Option` type so schemars emits the schema `default:`.
751fn default_source_path_strategy() -> Option<SourcePathStrategy> {
752    Some(SourcePathStrategy::PvcName)
753}
754
755/// schemars default for `Source::read_only` — a backup source is read-only unless the
756/// user asks otherwise. Returns the field's `Option` type so schemars emits the schema
757/// `default: true` for `kubectl explain`. Paired with `source_read_only()`, which
758/// resolves absent to exactly this value at the mount site — the pairing is what makes
759/// a schema default safe to advertise (see `Repository`'s health defaults).
760fn default_source_read_only() -> Option<bool> {
761    Some(true)
762}
763
764/// Whether a source is mounted read-only. THE resolver for `Source::read_only`'s
765/// absent case, so the CRD's advertised `default: true` and the mount agree by
766/// construction rather than by coincidence.
767pub fn source_read_only(source: &Source) -> bool {
768    source.read_only.unwrap_or(true)
769}
770
771/// Whether this source's mount lets the kubelet rewrite the **live** workload volume:
772/// a writable mount with no staging in front of it.
773///
774/// `copyMethod: Snapshot`/`Clone` interpose a throwaway staged PVC, so the kubelet's
775/// recursive `fsGroup` chgrp lands on a copy that is deleted when the run ends. Only
776/// `Direct` mounts the workload's own PVC, where that same walk permanently rewrites
777/// group ownership on production data. Pure, and the single definition of the hazard.
778///
779/// An `nfs` source is excluded, and not merely because [`validate_source`] rejects a
780/// writable one anyway: the kubelet does not apply `fsGroup` to in-tree NFS volumes at
781/// all, so no walk ever happens and this predicate's premise is simply false there.
782/// Answering `true` would also make admission emit two errors for one mistake, the
783/// second of them advice — "set `acknowledgeLiveMutation`" — that could never make the
784/// configuration valid.
785///
786/// [`validate_source`]: crate::validate::validate_source
787pub fn source_mutates_live_volume(copy_method: CopyMethod, source: &Source) -> bool {
788    matches!(copy_method, CopyMethod::Direct)
789        && !source_read_only(source)
790        && (source.pvc.is_some() || source.pvc_selector.is_some())
791}
792
793/// schemars default for `PvcSnapshotPolicy::default_deletion_policy` — `Delete`,
794/// the deletion policy produced `Snapshot` CRs inherit. Returns the field's
795/// `Option` type so schemars emits the schema `default:`.
796fn recipe_default_deletion_policy() -> Option<crate::common::DeletionPolicy> {
797    Some(crate::common::DeletionPolicy::Delete)
798}
799
800/// Compression policy.
801#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
802#[serde(rename_all = "camelCase")]
803pub struct Compression {
804    /// kopia compressor name (e.g. `zstd`); absent leaves kopia's default.
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub compressor: Option<String>,
807    /// Filename globs to leave uncompressed (e.g. already-compressed media).
808    #[serde(default, skip_serializing_if = "Vec::is_empty")]
809    pub never_compress: Vec<String>,
810}
811
812/// File-ignore policy.
813#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
814#[serde(rename_all = "camelCase")]
815pub struct Files {
816    /// Filename/path globs to exclude from the snapshot (e.g. `*.tmp`, `*/cache/*`).
817    /// Absent ⇒ [`default_ignore_rules`] (OS-artifact junk: `/lost+found`,
818    /// `System Volume Information`, `$RECYCLE.BIN`, `@eaDir`, `.snapshot`). An
819    /// explicit list REPLACES the default wholesale (re-add any entries you
820    /// still want); explicit `ignoreRules: []` opts fully out. NOT
821    /// `skip_serializing_if` — an explicit empty list must round-trip as `[]`,
822    /// not vanish back to "absent" (which would silently resurrect the
823    /// default on the next parse).
824    #[serde(default = "default_ignore_rules")]
825    #[schemars(default = "default_ignore_rules")]
826    pub ignore_rules: Vec<String>,
827    /// Honor `CACHEDIR.TAG`.
828    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
829    pub ignore_cache_dirs: bool,
830    /// Skip taking a new snapshot when the source is identical to the previous one.
831    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
832    pub ignore_identical_snapshots: bool,
833}
834
835/// Backup-side error-handling policy: let kopia complete a snapshot with errors rather than aborting.
836#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
837#[serde(rename_all = "camelCase")]
838pub struct ErrorHandling {
839    /// Continue the snapshot when a file cannot be read (`--ignore-file-errors`).
840    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
841    pub ignore_file_errors: bool,
842    /// Continue the snapshot when a directory cannot be read (`--ignore-dir-errors`).
843    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
844    pub ignore_dir_errors: bool,
845    /// Continue past entries of unknown type (`--ignore-unknown-types`).
846    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
847    pub ignore_unknown_types: bool,
848    /// Abort the snapshot at the first error instead of collecting and
849    /// continuing (`snapshot create --fail-fast`; kopia default: false). This
850    /// is a `snapshot create` argv flag, not a `policy set` knob, but lives
851    /// beside its semantic opposites (`ignore*Errors`) for discoverability.
852    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
853    pub fail_fast: bool,
854}
855
856/// Upload parallelism (kopia's upload policy); absent knobs leave kopia's default.
857#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
858#[serde(rename_all = "camelCase")]
859pub struct Upload {
860    /// `--max-parallel-snapshots`: how many sources snapshot concurrently.
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub max_parallel_snapshots: Option<i64>,
863    /// `--max-parallel-file-reads`: file-read concurrency within a snapshot.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub max_parallel_file_reads: Option<i64>,
866    /// `snapshot create --upload-limit-mb`: abort the snapshot once this many
867    /// MB have been uploaded (kopia default: 0 — unlimited). Named `limitMb`
868    /// rather than `uploadLimitMb` to avoid the `upload.uploadLimitMb` stutter;
869    /// like `failFast`, this is a `snapshot create` argv flag, not a `policy
870    /// set` knob, but lives here beside its parallelism siblings.
871    #[serde(default, skip_serializing_if = "Option::is_none")]
872    pub limit_mb: Option<i64>,
873}
874
875/// First-class backup verification proving snapshots are restorable; opt-in, with quick and deep tiers.
876#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
877#[serde(rename_all = "camelCase")]
878pub struct Verification {
879    /// Quick (blob-level) verification tier; absent ⇒ no quick verification. Its cron
880    /// lives under `quick.schedule` (matching `deep.schedule`), see [`QuickVerification`].
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub quick: Option<QuickVerification>,
883    /// Schedule + knobs for the rarer scratch-restore test; absent ⇒ no deep verification.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub deep: Option<DeepVerification>,
886    /// CEL pass/fail predicate over the verify result; applies to both tiers.
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub success_expr: Option<String>,
889    /// How many files `quick` verifies fully (`--verify-files-percent`); absent leaves kopia's default.
890    #[serde(default, skip_serializing_if = "Option::is_none")]
891    pub verify_files_percent: Option<u8>,
892}
893
894/// Quick (blob-level) verification tier: schedule for the frequent `kopia snapshot verify`.
895///
896/// A wrapper so this tier's shape matches `deep` — the cron lives at
897/// `quick.schedule.cron` (GitHub #174). `schedule` is deliberately `Option` for
898/// decode-tolerance: an already-persisted old-shape `quick: { cron: ... }` object
899/// still decodes (serde ignores the unknown `cron` key) as `schedule: None` rather
900/// than failing typed serde — a hard decode failure would wedge the SnapshotPolicy
901/// reflector and poison SnapshotPolicy admission cluster-wide. New writes with the
902/// old shape are rejected at admission by the shared validator, which points at the
903/// move. A persisted `schedule: None` means the quick tier is disabled until updated.
904#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
905#[serde(rename_all = "camelCase")]
906pub struct QuickVerification {
907    /// Cron + jitter + timezone for the frequent blob-level verify; absent ⇒ quick tier disabled.
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub schedule: Option<CronSpec>,
910    /// `--parallel`: verification parallelism (kopia default: 8).
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub parallel: Option<u32>,
913    /// `--file-parallelism`: parallelism for file verification (kopia default: unset).
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub file_parallelism: Option<u32>,
916    /// `--file-queue-length`: queue length for file verification (kopia default: 20000).
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub file_queue_length: Option<u32>,
919    /// `--max-errors`: stop after this many errors (kopia default: 0, meaning stop
920    /// at the first error).
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub max_errors: Option<u32>,
923}
924
925/// Deep (scratch-restore) verification: restore the latest snapshot into an ephemeral volume, then discard.
926#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
927#[serde(rename_all = "camelCase")]
928pub struct DeepVerification {
929    /// Cron + jitter for the deep restore-test (e.g. weekly).
930    pub schedule: CronSpec,
931    /// StorageClass for the ephemeral scratch PVC; absent uses the cluster default (only with `capacity`).
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub storage_class_name: Option<String>,
934    /// Size of the ephemeral scratch PVC (e.g. `10Gi`); absent falls back to a node-ephemeral `emptyDir`.
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub capacity: Option<String>,
937    /// `restore --parallel`: restore parallelism for the scratch-restore (deep verify
938    /// IS a restore under the hood); absent leaves kopia's default.
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub parallel: Option<u32>,
941}
942
943/// Pre/post snapshot hook lists.
944#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
945#[serde(rename_all = "camelCase")]
946pub struct Hooks {
947    /// Hooks run (in order) before the snapshot is taken — e.g. quiescing a database.
948    #[serde(default, skip_serializing_if = "Vec::is_empty")]
949    pub before_snapshot: Vec<Hook>,
950    /// Hooks run (in order) after the snapshot completes — e.g. resuming the workload.
951    #[serde(default, skip_serializing_if = "Vec::is_empty")]
952    pub after_snapshot: Vec<Hook>,
953}
954
955/// One of three hook forms. Externally-tagged: the wire shape is
956/// `{ workloadExec: {...} }`, `{ runJob: {...} }`, or `{ httpRequest: {...} }`,
957/// and exactly one form is present.
958#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
959#[serde(rename_all = "camelCase")]
960pub enum Hook {
961    /// `kubectl exec`-style into a matched workload pod/container (the default form).
962    WorkloadExec(WorkloadExecHook),
963    /// Full `JobSpec` run as a one-shot Job (k8up `PreBackupPod` analog).
964    RunJob(Box<RunJobHook>),
965    /// Typed POST to a URL for cross-system orchestration.
966    HttpRequest(HttpRequestHook),
967}
968
969impl Hook {
970    /// Stable discriminant string for status/metrics — one of `"WorkloadExec"`,
971    /// `"RunJob"`, or `"HttpRequest"`.
972    ///
973    /// ```
974    /// use kopiur_api::snapshot_policy::{Hook, HttpRequestHook};
975    ///
976    /// let hook = Hook::HttpRequest(HttpRequestHook {
977    ///     url: "https://example/notify".into(),
978    ///     method: None,
979    ///     body: None,
980    ///     headers: Vec::new(),
981    ///     timeout: None,
982    ///     continue_on_failure: false,
983    /// });
984    /// assert_eq!(hook.kind_str(), "HttpRequest");
985    /// ```
986    pub fn kind_str(&self) -> &'static str {
987        match self {
988            Hook::WorkloadExec(_) => "WorkloadExec",
989            Hook::RunJob(_) => "RunJob",
990            Hook::HttpRequest(_) => "HttpRequest",
991        }
992    }
993}
994
995/// `kubectl exec`-style hook into a matched workload pod/container.
996#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
997#[serde(rename_all = "camelCase")]
998pub struct WorkloadExecHook {
999    /// Selects the workload pod/container to exec into (flattened onto the hook).
1000    #[serde(flatten)]
1001    pub selector: PodSelector,
1002    /// Command + args to run inside the selected container.
1003    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1004    pub command: Vec<String>,
1005    /// Max time to wait for the command (Go duration string, e.g. `2m`).
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub timeout: Option<String>,
1008    /// If `true`, a failed hook does not abort the backup (default: abort).
1009    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1010    pub continue_on_failure: bool,
1011}
1012
1013/// A hook that materializes a full one-shot Job (k8up `PreBackupPod` analog).
1014#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
1015#[serde(rename_all = "camelCase")]
1016pub struct RunJobHook {
1017    /// The full Kubernetes `JobSpec` to run.
1018    #[schemars(schema_with = "crate::schema::preserve_unknown_object")]
1019    pub job_spec: JobSpec,
1020    /// Max time to wait for the Job to complete (Go duration string).
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub timeout: Option<String>,
1023    /// If `true`, a failed Job does not abort the backup (default: abort).
1024    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1025    pub continue_on_failure: bool,
1026}
1027
1028/// One HTTP header sent with an `httpRequest` hook.
1029#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
1030#[serde(rename_all = "camelCase")]
1031pub struct HttpHeader {
1032    /// Header name (case-insensitive; RFC 7230 token, e.g. `Content-Type`).
1033    pub name: String,
1034    /// Header value.
1035    pub value: String,
1036}
1037
1038/// A hook that issues an HTTP request for cross-system orchestration.
1039#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
1040#[serde(rename_all = "camelCase")]
1041pub struct HttpRequestHook {
1042    /// Target URL to call.
1043    pub url: String,
1044    /// HTTP method (default `POST`).
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub method: Option<String>,
1047    /// Optional request body.
1048    #[serde(default, skip_serializing_if = "Option::is_none")]
1049    pub body: Option<String>,
1050    /// Additional request headers (e.g. `Content-Type: application/json`).
1051    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1052    pub headers: Vec<HttpHeader>,
1053    /// Max time to wait for the response (Go duration string).
1054    #[serde(default, skip_serializing_if = "Option::is_none")]
1055    pub timeout: Option<String>,
1056    /// If `true`, a failed request does not abort the backup (default: abort).
1057    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1058    pub continue_on_failure: bool,
1059}
1060
1061/// Observed state of a `SnapshotPolicy`.
1062#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
1063#[serde(rename_all = "camelCase")]
1064pub struct SnapshotPolicyStatus {
1065    /// `metadata.generation` last reconciled, for staleness detection.
1066    #[serde(default, skip_serializing_if = "Option::is_none")]
1067    pub observed_generation: Option<i64>,
1068    /// What would be passed to kopia — pinned at admission.
1069    #[serde(default, skip_serializing_if = "Option::is_none")]
1070    pub resolved: Option<ResolvedPolicy>,
1071    /// Summary of GFS retention pruning against this config's `Snapshot` CRs.
1072    #[serde(default, skip_serializing_if = "Option::is_none")]
1073    pub retention: Option<RetentionSummary>,
1074    /// Summary of automatic adoption of discovered snapshots into this recipe.
1075    #[serde(default, skip_serializing_if = "Option::is_none")]
1076    pub adoption: Option<AdoptionSummary>,
1077    /// RFC3339 timestamp of the most recent successful child `Snapshot` from this recipe.
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub last_successful_snapshot: Option<String>,
1080    /// RFC3339 timestamp of the most recent successful verification (any tier).
1081    ///
1082    /// Verification runs once per (repository x source) cell: a
1083    /// `spec.repositories` fan-out adds the repository dimension, and a
1084    /// `pvcSelector` source adds one cell per matched PVC (each has its own
1085    /// kopia source path, so each needs its own verification).
1086    ///
1087    /// * one repository AND a non-selector source (`pvc`/`nfs`): stamped
1088    ///   directly by the verify mover, exactly as before;
1089    /// * otherwise — a `spec.repositories` fan-out, or ANY `pvcSelector` source
1090    ///   (including one currently matching a single PVC): computed by the
1091    ///   controller as the MINIMUM across the CURRENT cells ("everything is
1092    ///   verified as of T"), and absent until EVERY current cell has verified at
1093    ///   least once — a partially verified policy must not display a reassuring
1094    ///   timestamp.
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub last_verified: Option<String>,
1097    /// Per-repository verification records for a multi-repository policy
1098    /// (#368): one entry per CURRENT `spec.repositories` member, maintained by
1099    /// the controller (single writer — entries for repositories no longer in
1100    /// the spec are pruned). Empty (elided) for the single-repo shape, whose
1101    /// wire stays byte-identical.
1102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1103    pub verification: Vec<RepoVerification>,
1104    /// Internal write channel for per-cell verification: RFC3339 markers keyed
1105    /// by the (repository x source) cell a verify run covered.
1106    ///
1107    /// Key shapes, where the repository segment is the normalized repository
1108    /// key ([`repo_key`](crate::common::repo_key)) and the member segment is a
1109    /// stable 12-hex tag over the matched PVC's derived kopia source path:
1110    ///
1111    /// * `<repository>` — a `spec.repositories` fan-out with a single source;
1112    /// * `<repository>#<member>` — a fan-out over both dimensions;
1113    /// * `#<member>` — one repository, a `pvcSelector` source (the repository
1114    ///   segment is empty).
1115    ///
1116    /// `#` is the separator because a repository key cannot contain one.
1117    ///
1118    /// Each verify mover merge-patches ONLY its own key — a JSON merge patch
1119    /// merges map keys, so concurrent cells can never clobber one another (a
1120    /// Vec would be replaced wholesale). The controller folds these into
1121    /// `verification` and `lastVerified` on its next pass and prunes keys whose
1122    /// repository or matched PVC is gone. Not written when the policy targets
1123    /// one repository with a non-selector source, whose wire stays
1124    /// byte-identical; a `pvcSelector` source always uses this map, even while
1125    /// it matches a single PVC.
1126    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1127    pub verification_stamps: std::collections::BTreeMap<String, String>,
1128    /// Human-readable summary of the policy's repository target(s) for the
1129    /// `Repositories` print column: the comma-joined repository names (the one
1130    /// name for the single-repo shape), capped near a kubectl column width
1131    /// with a `+N` overflow marker. Written by the controller.
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub repository_summary: Option<String>,
1134    /// Standard Kubernetes conditions (e.g. `RepositoryReachable`, `GroupSnapshotSupported`).
1135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1136    pub conditions: Vec<Condition>,
1137}
1138
1139/// One repository's verification record on a multi-repository policy
1140/// (`status.verification`). Entry-keyed by the (normalized) repository ref so
1141/// per-repo verify results never collapse into one flat timestamp.
1142#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
1143#[serde(rename_all = "camelCase")]
1144pub struct RepoVerification {
1145    /// The repository this record covers, normalized
1146    /// ([`normalized_repository_ref`](crate::common::normalized_repository_ref))
1147    /// so it re-resolves from anywhere.
1148    pub repository: RepositoryRef,
1149    /// RFC3339 timestamp of the most recent successful verification (any tier)
1150    /// against THIS repository; absent until its first successful verify.
1151    ///
1152    /// When the policy's source is a `pvcSelector`, this is the MINIMUM across
1153    /// that repository's matched PVCs, and absent until every one of them has
1154    /// verified — a repository whose volumes are only partly verified must not
1155    /// look fully verified.
1156    #[serde(default, skip_serializing_if = "Option::is_none")]
1157    pub last_verified: Option<String>,
1158}
1159
1160/// The recipe as kopia would see it, pinned at admission and never re-rendered.
1161#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1162#[serde(rename_all = "camelCase")]
1163pub struct ResolvedPolicy {
1164    /// The resolved `username@hostname` identity.
1165    #[serde(default, skip_serializing_if = "Option::is_none")]
1166    pub identity: Option<ResolvedIdentity>,
1167    /// The concrete PVCs + source paths after selector expansion.
1168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1169    pub sources: Vec<ResolvedPolicySource>,
1170    /// Per-repository resolution for a MULTI-repository policy
1171    /// (`spec.repositories`): one entry per member, each carrying the identity
1172    /// resolved under THAT repository's `identityDefaults` (the unit of
1173    /// identity is the `(repository, identity)` pair — N members means N
1174    /// independent kopia lineages). Empty — and elided from the wire — for the
1175    /// classic single-repo shape, whose resolution stays in the top-level
1176    /// `identity`/`sources` fields exactly as before this field existed.
1177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1178    pub repositories: Vec<ResolvedPolicyRepository>,
1179}
1180
1181/// One member repository's resolution within a multi-repository policy: which
1182/// repository, and what kopia identity this policy resolves to **under that
1183/// repository's `identityDefaults`**. Consumed by the admission fork guard as
1184/// the per-repo baseline (`repo_key` → previously-resolved identity), so an
1185/// edit that would re-identify ONE member's lineage is caught even though the
1186/// other members are unaffected.
1187#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
1188#[serde(rename_all = "camelCase")]
1189pub struct ResolvedPolicyRepository {
1190    /// The member repository this entry resolves for (by value, as listed in
1191    /// `spec.repositories`).
1192    pub repository: RepositoryRef,
1193    /// The `username@hostname` identity resolved under this repository's
1194    /// `identityDefaults`; absent when it could not be resolved (the guard
1195    /// treats an absent baseline as "no baseline" and degrades to allow for
1196    /// that member only).
1197    #[serde(default, skip_serializing_if = "Option::is_none")]
1198    pub identity: Option<ResolvedIdentity>,
1199}
1200
1201/// One resolved source — a concrete PVC and the path kopia records for it.
1202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1203#[serde(rename_all = "camelCase")]
1204pub struct ResolvedPolicySource {
1205    /// `namespace/name` of the PVC, as kopia sees it.
1206    #[serde(default, skip_serializing_if = "Option::is_none")]
1207    pub pvc: Option<String>,
1208    /// The source path kopia records for this PVC.
1209    #[serde(default, skip_serializing_if = "Option::is_none")]
1210    pub source_path: Option<String>,
1211}
1212
1213/// Summary of the most recent GFS retention prune for a `SnapshotPolicy`.
1214#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1215#[serde(rename_all = "camelCase")]
1216pub struct RetentionSummary {
1217    /// CRs currently inside the GFS window.
1218    #[serde(default, skip_serializing_if = "Option::is_none")]
1219    pub active_snapshot_count: Option<i64>,
1220    /// RFC3339 timestamp of the last prune pass.
1221    #[serde(default, skip_serializing_if = "Option::is_none")]
1222    pub last_prune_at: Option<String>,
1223    /// Number of `Snapshot` CRs deleted by the last prune pass.
1224    #[serde(default, skip_serializing_if = "Option::is_none")]
1225    pub last_prune_deleted: Option<i64>,
1226}
1227
1228/// Summary of the most recent automatic adoption pass for a `SnapshotPolicy` —
1229/// discovered snapshots whose resolved identity matched this recipe and were
1230/// re-attached (see `Origin::Adopted`), plus an on-demand re-scan request/ack
1231/// pair mirroring the repository-level `catalog-scan-requested-at` annotation
1232/// contract.
1233#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1234#[serde(rename_all = "camelCase")]
1235pub struct AdoptionSummary {
1236    /// RFC3339 timestamp of the last adoption pass that adopted at least one snapshot.
1237    #[serde(default, skip_serializing_if = "Option::is_none")]
1238    pub last_adoption_at: Option<String>,
1239    /// Number of discovered `Snapshot` CRs adopted by the last adoption pass.
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub last_adopted_count: Option<u32>,
1242    /// Running total of `Snapshot` CRs ever adopted into this recipe.
1243    #[serde(default, skip_serializing_if = "Option::is_none")]
1244    pub total_adopted: Option<u64>,
1245    /// Identity-matching discovered snapshots the last adoption pass left
1246    /// discovered because `spec.retention` would prune them immediately under
1247    /// the effective `deletionPolicy` (`Retain`/`Orphan` — a CR-only prune that
1248    /// would re-discover and re-adopt forever). `0`/absent when nothing was
1249    /// withheld. See the `AdoptionSkippedByRetention` event for the levers.
1250    #[serde(default, skip_serializing_if = "Option::is_none")]
1251    pub skipped_by_retention: Option<u32>,
1252    /// Discovered snapshots whose kopia identity matched this policy at the most
1253    /// recent adoption pass, counted BEFORE the own-id and retention filters —
1254    /// so it reads "history relevant to me exists in the catalog", independent
1255    /// of whether that pass adopted anything. A multi-repository policy SUMS
1256    /// this across the ready repositories of the pass. `0` together with
1257    /// `lastScanUnmatched: 0` means the catalog was empty at that pass; absent
1258    /// means no adoption pass has run yet. Neutral inventory, not a verdict:
1259    /// `0` matched beside a non-zero `lastScanUnmatched` is the ordinary shape
1260    /// of a new policy on a shared repository, and is also what a
1261    /// post-disaster-recovery identity mismatch looks like — compare
1262    /// `status.resolved.identity` with the pre-disaster configuration to tell
1263    /// them apart.
1264    #[serde(default, skip_serializing_if = "Option::is_none")]
1265    pub last_scan_matched: Option<u32>,
1266    /// Discovered snapshots the most recent adoption pass saw that did NOT match
1267    /// this policy's identity (other policies' or other clusters' history in a
1268    /// shared repository). Same counting rules as `lastScanMatched`: pre-filter,
1269    /// summed across a multi-repository policy's ready repositories, `0`/`0`
1270    /// for an empty catalog, absent when no pass has run.
1271    #[serde(default, skip_serializing_if = "Option::is_none")]
1272    pub last_scan_unmatched: Option<u32>,
1273    /// RFC3339 token echoing an in-flight on-demand adoption scan request for
1274    /// this policy's identity; cleared once honored.
1275    #[serde(default, skip_serializing_if = "Option::is_none")]
1276    pub scan_requested_at: Option<String>,
1277    /// The resolved kopia identity the requested scan was scoped to, pinned at
1278    /// request time so a later identity-changing edit can't retarget an
1279    /// in-flight scan.
1280    #[serde(default, skip_serializing_if = "Option::is_none")]
1281    pub scan_requested_identity: Option<String>,
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287    use crate::common::RepositoryKind;
1288    use crate::testutil::from_yaml;
1289    use kube::core::CustomResourceExt;
1290
1291    #[test]
1292    fn resolved_policy_single_repo_status_wire_is_byte_identical() {
1293        // Golden: a single-repo policy's ResolvedPolicy serializes EXACTLY as
1294        // it did before `repositories` existed — the empty vec is elided — so
1295        // stored single-repo statuses round-trip byte-identically.
1296        let resolved = ResolvedPolicy {
1297            identity: Some(crate::common::ResolvedIdentity {
1298                username: "pg".into(),
1299                hostname: "billing".into(),
1300                source_path: Some("/pvc/data".into()),
1301            }),
1302            sources: vec![ResolvedPolicySource {
1303                pvc: Some("billing/data".into()),
1304                source_path: Some("/pvc/data".into()),
1305            }],
1306            repositories: vec![],
1307        };
1308        let wire = serde_json::to_value(&resolved).expect("serializes");
1309        assert_eq!(
1310            wire,
1311            serde_json::json!({
1312                "identity": {
1313                    "username": "pg",
1314                    "hostname": "billing",
1315                    "sourcePath": "/pvc/data",
1316                },
1317                "sources": [ { "pvc": "billing/data", "sourcePath": "/pvc/data" } ],
1318            })
1319        );
1320
1321        // …and a pre-feature stored status (no `repositories` key) decodes to
1322        // the empty vec, not an error.
1323        let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1324        assert!(decoded.repositories.is_empty());
1325    }
1326
1327    #[test]
1328    fn resolved_policy_per_repo_entries_round_trip() {
1329        let resolved = ResolvedPolicy {
1330            identity: None,
1331            sources: vec![],
1332            repositories: vec![
1333                ResolvedPolicyRepository {
1334                    repository: RepositoryRef {
1335                        kind: RepositoryKind::Repository,
1336                        name: "nas".into(),
1337                        namespace: None,
1338                    },
1339                    identity: Some(crate::common::ResolvedIdentity {
1340                        username: "pg".into(),
1341                        hostname: "billing".into(),
1342                        source_path: None,
1343                    }),
1344                },
1345                ResolvedPolicyRepository {
1346                    repository: RepositoryRef {
1347                        kind: RepositoryKind::ClusterRepository,
1348                        name: "offsite".into(),
1349                        namespace: None,
1350                    },
1351                    // Unresolvable member: entry present, identity elided.
1352                    identity: None,
1353                },
1354            ],
1355        };
1356        let wire = serde_json::to_value(&resolved).expect("serializes");
1357        assert_eq!(
1358            wire,
1359            serde_json::json!({
1360                "repositories": [
1361                    {
1362                        "repository": { "kind": "Repository", "name": "nas" },
1363                        "identity": { "username": "pg", "hostname": "billing" },
1364                    },
1365                    { "repository": { "kind": "ClusterRepository", "name": "offsite" } },
1366                ],
1367            })
1368        );
1369        let decoded: ResolvedPolicy = serde_json::from_value(wire).expect("decodes");
1370        assert_eq!(decoded, resolved);
1371    }
1372
1373    #[test]
1374    fn snapshot_policy_crd_metadata_is_correct() {
1375        let crd = SnapshotPolicy::crd();
1376        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
1377        assert_eq!(crd.spec.names.kind, "SnapshotPolicy");
1378        assert_eq!(crd.spec.names.plural, "snapshotpolicies");
1379        assert_eq!(
1380            crd.spec.names.short_names.as_deref(),
1381            Some(&["kopiasp".to_string()][..])
1382        );
1383        assert_eq!(crd.spec.scope, "Namespaced");
1384        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
1385    }
1386
1387    #[test]
1388    fn copy_method_carries_static_openapi_default_in_crd() {
1389        // copyMethod must carry a real schema `default: Snapshot` so it appears in
1390        // `kubectl explain` / the stored object and GitOps stops thrashing. `Snapshot`
1391        // (crash-consistent CSI staging) is the community-preferred default; `Direct` /
1392        // `Clone` are opt-in.
1393        let crd = SnapshotPolicy::crd();
1394        let json = serde_json::to_value(&crd).expect("serialize CRD");
1395        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1396            ["properties"]["copyMethod"]["default"];
1397        assert_eq!(
1398            default, "Snapshot",
1399            "copyMethod must emit `default: Snapshot` in the CRD schema; got {default:?}"
1400        );
1401    }
1402
1403    #[test]
1404    fn staging_timeout_round_trips_and_defaults_to_absent() {
1405        // Absent staging parses to None (runtime default 10m applies in the
1406        // controller) and is skip-elided on the wire.
1407        let spec: SnapshotPolicySpec = from_yaml(
1408            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1409        );
1410        assert_eq!(spec.staging, None);
1411        let json = serde_json::to_value(&spec).unwrap();
1412        assert!(
1413            json.get("staging").is_none(),
1414            "absent staging must be elided"
1415        );
1416
1417        // A set timeout round-trips through the cluster's parse path.
1418        let spec: SnapshotPolicySpec = from_yaml(
1419            "repository: { kind: Repository, name: r }\n\
1420             sources: [ { pvc: { name: d } } ]\n\
1421             staging: { timeout: 30m }\n",
1422        );
1423        assert_eq!(
1424            spec.staging,
1425            Some(StagingSpec {
1426                timeout: Some("30m".to_string()),
1427                ..Default::default()
1428            })
1429        );
1430        let json = serde_json::to_value(&spec).unwrap();
1431        assert_eq!(json["staging"]["timeout"], "30m");
1432    }
1433
1434    #[test]
1435    fn staging_overrides_round_trip_and_are_elided_when_absent() {
1436        // The staged-PVC override pair (storageClassName + accessModes) round-trips
1437        // through the cluster's parse path; absent fields are skip-elided so
1438        // "inherit from the source PVC" stays representable as absence.
1439        let spec: SnapshotPolicySpec = from_yaml(
1440            "repository: { kind: Repository, name: r }\n\
1441             sources: [ { pvc: { name: d } } ]\n\
1442             staging: { timeout: 30m, storageClassName: cephfs-shallow, accessModes: [ReadOnlyMany] }\n",
1443        );
1444        let st = spec.staging.as_ref().unwrap();
1445        assert_eq!(st.storage_class_name.as_deref(), Some("cephfs-shallow"));
1446        assert_eq!(st.access_modes, vec![PvcAccessMode::ReadOnlyMany]);
1447        let json = serde_json::to_value(&spec).unwrap();
1448        assert_eq!(json["staging"]["storageClassName"], "cephfs-shallow");
1449        assert_eq!(
1450            json["staging"]["accessModes"],
1451            serde_json::json!(["ReadOnlyMany"])
1452        );
1453
1454        let spec: SnapshotPolicySpec = from_yaml(
1455            "repository: { kind: Repository, name: r }\n\
1456             sources: [ { pvc: { name: d } } ]\n\
1457             staging: { timeout: 30m }\n",
1458        );
1459        let json = serde_json::to_value(&spec).unwrap();
1460        assert!(json["staging"].get("storageClassName").is_none());
1461        assert!(json["staging"].get("accessModes").is_none());
1462    }
1463
1464    #[test]
1465    fn staging_access_modes_legacy_value_decodes_to_unknown_not_an_error() {
1466        // Graceful-decode contract: a non-canonical stored mode deserializes into
1467        // `Unknown` (rejected later by the shared validator, per-CR) instead of a
1468        // serde error that would wedge the typed watcher for every SnapshotPolicy.
1469        let spec: SnapshotPolicySpec = from_yaml(
1470            "repository: { kind: Repository, name: r }\n\
1471             sources: [ { pvc: { name: d } } ]\n\
1472             staging: { accessModes: [ReadWriteOnze] }\n",
1473        );
1474        assert_eq!(
1475            spec.staging.unwrap().access_modes,
1476            vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
1477        );
1478    }
1479
1480    #[test]
1481    fn staging_access_modes_render_a_closed_enum_in_the_crd_schema() {
1482        // First `Vec<unit-enum>` in the API crate: pin that the generated CRD
1483        // schema is `items: {type: string, enum: [...]}` with exactly the four
1484        // canonical modes — and does NOT leak the legacy-decode `Unknown` variant.
1485        let crd = SnapshotPolicy::crd();
1486        let json = serde_json::to_value(&crd).expect("serialize CRD");
1487        let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1488            ["properties"]["staging"]["properties"]["accessModes"]["items"];
1489        assert_eq!(
1490            items["type"], "string",
1491            "items must be strings; got {items}"
1492        );
1493        assert_eq!(
1494            items["enum"],
1495            serde_json::json!([
1496                "ReadWriteOnce",
1497                "ReadOnlyMany",
1498                "ReadWriteMany",
1499                "ReadWriteOncePod"
1500            ]),
1501            "items enum must be exactly the canonical modes; got {items}"
1502        );
1503    }
1504
1505    #[test]
1506    fn copy_method_defaults_to_snapshot_when_absent() {
1507        // A bare value with a serde default: an omitted copyMethod parses to Snapshot (the
1508        // crash-consistent CSI-staged behavior).
1509        let spec: SnapshotPolicySpec = from_yaml(
1510            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1511        );
1512        assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1513        // And it serializes (not skip-elided), so the materialized value round-trips.
1514        let json = serde_json::to_value(&spec).unwrap();
1515        assert_eq!(json["copyMethod"], "Snapshot");
1516    }
1517
1518    /// The 5-entry OS-artifact default set, in the fixed order `default_ignore_rules`
1519    /// returns it — shared by every assertion below so the list itself has one
1520    /// source of truth in the test file too.
1521    fn expected_default_ignore_rules() -> Vec<String> {
1522        vec![
1523            "/lost+found".to_string(),
1524            "System Volume Information".to_string(),
1525            "$RECYCLE.BIN".to_string(),
1526            "@eaDir".to_string(),
1527            ".snapshot".to_string(),
1528        ]
1529    }
1530
1531    #[test]
1532    fn files_ignore_rules_carries_static_openapi_default_in_crd() {
1533        // `files.ignoreRules` must carry a real schema `default:` (the 5-entry
1534        // OS-artifact set) so it appears in `kubectl explain`. Mirrors
1535        // `copy_method_carries_static_openapi_default_in_crd`.
1536        let crd = SnapshotPolicy::crd();
1537        let json = serde_json::to_value(&crd).expect("serialize CRD");
1538        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1539            ["properties"]["files"]["properties"]["ignoreRules"]["default"];
1540        let want: Vec<serde_json::Value> = expected_default_ignore_rules()
1541            .into_iter()
1542            .map(serde_json::Value::String)
1543            .collect();
1544        assert_eq!(
1545            default,
1546            &serde_json::Value::Array(want),
1547            "files.ignoreRules must emit the 5-entry OS-artifact `default:` in the CRD schema; got {default:?}"
1548        );
1549    }
1550
1551    #[test]
1552    fn ignore_rules_defaults_when_files_block_absent_entirely() {
1553        // The load-bearing case: apiserver server-side-defaulting only fires for
1554        // NESTED fields when the parent object is present, so a spec that omits
1555        // `files:` altogether never gets `Files.ignoreRules`'s schema default
1556        // applied by the apiserver. The *serde* default on `Files::ignore_rules`
1557        // only helps once `files: {}` exists — it can't fire on a wholly-`None`
1558        // `spec.files`. This asserts the glue tier's contract: the mover work-spec
1559        // seam (`kopiur_mover::workspec::PolicyArgsSpec::from_policy`) is the layer
1560        // that must apply `default_ignore_rules()` for THIS shape; see the mover
1561        // crate's `workspec` tests for that half.
1562        let spec: SnapshotPolicySpec = from_yaml(
1563            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1564        );
1565        assert!(
1566            spec.files.is_none(),
1567            "a spec omitting `files:` entirely must parse to `None`, not a defaulted `Files`"
1568        );
1569    }
1570
1571    #[test]
1572    fn ignore_rules_defaults_when_files_block_present_but_empty() {
1573        // `files: {}` (parent present, `ignoreRules` absent): the serde default
1574        // DOES fire here, and this is also what the schemars `default:` covers for
1575        // apiserver server-side-defaulting.
1576        let spec: SnapshotPolicySpec = from_yaml(
1577            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: {}\n",
1578        );
1579        let files = spec.files.expect("files: {} must parse to Some(Files)");
1580        assert_eq!(files.ignore_rules, expected_default_ignore_rules());
1581    }
1582
1583    #[test]
1584    fn ignore_rules_explicit_empty_list_opts_out_and_round_trips() {
1585        // Regression test for the opt-out subtlety: an explicit `ignoreRules: []`
1586        // must deserialize as present-empty (serde defaults only fire when the KEY
1587        // is ABSENT, not when it's present-and-empty) and — critically — must
1588        // round-trip back through serialize/deserialize as `[]`, not vanish to
1589        // "absent" and silently resurrect the default. This is why `ignore_rules`
1590        // does NOT carry `skip_serializing_if`.
1591        let spec: SnapshotPolicySpec = from_yaml(
1592            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [] }\n",
1593        );
1594        let files = spec
1595            .files
1596            .as_ref()
1597            .expect("files: {...} must parse to Some(Files)");
1598        assert!(
1599            files.ignore_rules.is_empty(),
1600            "explicit `ignoreRules: []` must opt fully out, got {:?}",
1601            files.ignore_rules
1602        );
1603
1604        // The round-trip: serialize back to JSON, the `ignoreRules` key must still
1605        // be PRESENT (as `[]`), not omitted.
1606        let json = serde_json::to_value(&spec).expect("serialize");
1607        assert_eq!(
1608            json["files"]["ignoreRules"],
1609            serde_json::json!([]),
1610            "an explicit empty ignoreRules must serialize as `[]`, not be omitted \
1611             (omission would deserialize back to the 5-entry default)"
1612        );
1613
1614        // And re-parsing that JSON must still yield the empty, opted-out list —
1615        // not the default reappearing.
1616        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1617        assert_eq!(spec, reparsed);
1618        assert!(reparsed.files.expect("files").ignore_rules.is_empty());
1619    }
1620
1621    #[test]
1622    fn ignore_rules_explicit_custom_list_replaces_default_wholesale() {
1623        // An explicit non-empty list REPLACES the default outright — it is not
1624        // merged/appended. Re-adding a default entry you still want is on the
1625        // user (documented in docs/src/content/docs/backups.mdx).
1626        let spec: SnapshotPolicySpec = from_yaml(
1627            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [\"*.tmp\", \"lost+found\"] }\n",
1628        );
1629        let files = spec.files.expect("files");
1630        assert_eq!(
1631            files.ignore_rules,
1632            vec!["*.tmp".to_string(), "lost+found".to_string()]
1633        );
1634    }
1635
1636    #[test]
1637    fn backup_config_roundtrip_matches_adr_shape() {
1638        // Mirrors ADR-0001 §3.3.
1639        let yaml = r#"
1640repository:
1641  kind: Repository
1642  name: nas-primary
1643  namespace: backups
1644identity:
1645  username: "postgres-data"
1646  hostname: "billing"
1647sources:
1648  - pvc: { name: postgres-data }
1649    sourcePathOverride: /data
1650copyMethod: Snapshot
1651volumeSnapshotClassName: csi-snap-class
1652groupBy: VolumeGroupSnapshot
1653retention:
1654  keepLatest: 10
1655  keepDaily: 14
1656defaultDeletionPolicy: Delete
1657compression:
1658  compressor: zstd
1659  neverCompress: ["*.zip", "*.gz", "*.mp4"]
1660files:
1661  ignoreRules: ["*.tmp", "*/cache/*", "lost+found"]
1662  ignoreCacheDirs: true
1663  ignoreIdenticalSnapshots: true
1664extraArgs: []
1665hooks:
1666  beforeSnapshot:
1667    - workloadExec:
1668        podSelector: { matchLabels: { app: postgres } }
1669        container: postgres
1670        command: ["pg_start_backup", "snap"]
1671        timeout: 2m
1672  afterSnapshot:
1673    - workloadExec:
1674        podSelector: { matchLabels: { app: postgres } }
1675        container: postgres
1676        command: ["pg_stop_backup"]
1677        timeout: 2m
1678mover:
1679  resources:
1680    requests: { cpu: 250m, memory: 512Mi }
1681    limits: { cpu: "2", memory: 4Gi }
1682  cache:
1683    capacity: 16Gi
1684    storageClassName: fast-ssd
1685  securityContext:
1686    runAsUser: 1000
1687    runAsGroup: 1000
1688    runAsNonRoot: true
1689    allowPrivilegeEscalation: false
1690    capabilities: { drop: ["ALL"] }
1691    seccompProfile: { type: RuntimeDefault }
1692  podSecurityContext:
1693    fsGroup: 1000
1694    fsGroupChangePolicy: OnRootMismatch
1695"#;
1696        let spec: SnapshotPolicySpec = from_yaml(yaml);
1697        let repo = spec.repository.as_ref().expect("repository");
1698        assert_eq!(repo.kind, RepositoryKind::Repository);
1699        assert_eq!(repo.name, "nas-primary");
1700        assert_eq!(spec.sources.len(), 1);
1701        assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
1702        assert_eq!(
1703            spec.sources[0].source_path_override.as_deref(),
1704            Some("/data")
1705        );
1706        assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1707        assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
1708        assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
1709        let comp = spec.compression.as_ref().unwrap();
1710        assert_eq!(comp.compressor.as_deref(), Some("zstd"));
1711        let files = spec.files.as_ref().unwrap();
1712        assert_eq!(files.ignore_rules.len(), 3);
1713        assert!(files.ignore_cache_dirs);
1714        assert!(spec.extra_args.is_empty());
1715        let hooks = spec.hooks.as_ref().unwrap();
1716        assert_eq!(hooks.before_snapshot.len(), 1);
1717        assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
1718        // Both the container- and pod-level security contexts round-trip on the mover.
1719        let mover = spec.mover.as_ref().expect("mover");
1720        assert_eq!(
1721            mover.security_context.as_ref().and_then(|s| s.run_as_user),
1722            Some(1000)
1723        );
1724        assert_eq!(
1725            mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1726            Some(1000)
1727        );
1728        // Container UID/GID match + fsGroup is unprivileged (no namespace opt-in).
1729        assert!(!mover.requires_privilege());
1730
1731        let json = serde_json::to_value(&spec).expect("serialize");
1732        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1733        assert_eq!(spec, reparsed);
1734    }
1735
1736    #[test]
1737    fn credential_projection_roundtrip() {
1738        // Opt-in projection now lives on the recipe (SnapshotPolicy), parses the
1739        // cluster's way, and round-trips.
1740        let yaml = r#"
1741repository: { kind: ClusterRepository, name: shared }
1742sources:
1743  - pvc: { name: data }
1744retention: { keepLatest: 5 }
1745credentialProjection:
1746  enabled: true
1747"#;
1748        let spec: SnapshotPolicySpec = from_yaml(yaml);
1749        assert_eq!(
1750            spec.credential_projection.as_ref().map(|p| p.enabled),
1751            Some(true)
1752        );
1753        let json = serde_json::to_value(&spec).expect("serialize");
1754        assert_eq!(json["credentialProjection"]["enabled"], true);
1755        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1756        assert_eq!(spec, reparsed);
1757
1758        // Absent ⇒ None (self-managed default); not serialized.
1759        let bare: SnapshotPolicySpec = from_yaml(
1760            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1761        );
1762        assert!(bare.credential_projection.is_none());
1763        assert!(
1764            serde_json::to_value(&bare)
1765                .unwrap()
1766                .get("credentialProjection")
1767                .is_none()
1768        );
1769        // Empty `{}` defaults enabled=false (opt-in).
1770        let empty: SnapshotPolicySpec = from_yaml(
1771            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\ncredentialProjection: {}\n",
1772        );
1773        assert_eq!(empty.credential_projection.map(|p| p.enabled), Some(false));
1774    }
1775
1776    #[test]
1777    fn backup_config_minimal_selector_source() {
1778        // Mirrors ADR-0001 §5.4 (multi-PVC selector).
1779        let yaml = r#"
1780repository: { kind: Repository, name: nas-primary, namespace: backups }
1781identity: { username: app-bundle, hostname: billing }
1782sources:
1783  - pvcSelector:
1784      labelSelector: { matchLabels: { backup: include } }
1785    sourcePathStrategy: PvcName
1786groupBy: VolumeGroupSnapshot
1787retention: { keepDaily: 14 }
1788"#;
1789        let spec: SnapshotPolicySpec = from_yaml(yaml);
1790        let src = &spec.sources[0];
1791        assert!(src.pvc.is_none());
1792        assert!(src.pvc_selector.is_some());
1793        assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
1794
1795        let json = serde_json::to_value(&spec).unwrap();
1796        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1797        assert_eq!(spec, reparsed);
1798    }
1799
1800    #[test]
1801    fn hook_run_job_variant_with_job_spec() {
1802        // RunJob embeds a full k8s-openapi JobSpec (so the struct is not Eq).
1803        let yaml = r#"
1804runJob:
1805  jobSpec:
1806    template:
1807      spec:
1808        restartPolicy: Never
1809        containers:
1810          - name: pre
1811            image: busybox
1812            command: ["sh", "-c", "echo hi"]
1813  timeout: 5m
1814  continueOnFailure: true
1815"#;
1816        let hook: Hook = from_yaml(yaml);
1817        assert_eq!(hook.kind_str(), "RunJob");
1818        match &hook {
1819            Hook::RunJob(j) => {
1820                assert!(j.continue_on_failure);
1821                assert_eq!(j.timeout.as_deref(), Some("5m"));
1822                assert_eq!(
1823                    j.job_spec
1824                        .template
1825                        .spec
1826                        .as_ref()
1827                        .unwrap()
1828                        .restart_policy
1829                        .as_deref(),
1830                    Some("Never")
1831                );
1832            }
1833            other => panic!("expected RunJob, got {}", other.kind_str()),
1834        }
1835        let json = serde_json::to_value(&hook).unwrap();
1836        assert!(json.get("runJob").is_some());
1837    }
1838
1839    #[test]
1840    fn hook_http_request_variant() {
1841        let hook: Hook = from_yaml(
1842            "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",
1843        );
1844        assert_eq!(hook.kind_str(), "HttpRequest");
1845        let v = serde_json::to_value(&hook).unwrap();
1846        assert_eq!(v["httpRequest"]["url"], "https://example/notify");
1847        assert_eq!(
1848            v.pointer("/httpRequest/headers/0/name")
1849                .and_then(|x| x.as_str()),
1850            Some("Content-Type")
1851        );
1852        assert_eq!(
1853            v.pointer("/httpRequest/headers/1/value")
1854                .and_then(|x| x.as_str()),
1855            Some("sekrit")
1856        );
1857        // Omitted headers stay off the wire (skip_serializing_if).
1858        let bare: Hook = from_yaml("httpRequest:\n  url: https://example/notify\n");
1859        let v = serde_json::to_value(&bare).unwrap();
1860        assert!(v.pointer("/httpRequest/headers").is_none());
1861    }
1862
1863    #[test]
1864    fn hook_unknown_variant_is_rejected() {
1865        let value: serde_json::Value = serde_saphyr::from_str("teleport:\n  url: x\n").unwrap();
1866        assert!(serde_json::from_value::<Hook>(value).is_err());
1867    }
1868
1869    #[test]
1870    fn error_handling_upload_and_suspend_roundtrip() {
1871        // ADR-0005 §13(b)/§13(f)/§14(e): the new policy knobs parse the cluster's
1872        // way, default sanely when absent, and round-trip.
1873        let yaml = r#"
1874repository: { kind: Repository, name: r }
1875sources: [ { pvc: { name: d } } ]
1876errorHandling:
1877  ignoreFileErrors: true
1878  ignoreDirErrors: false
1879  ignoreUnknownTypes: true
1880  failFast: true
1881upload:
1882  maxParallelSnapshots: 4
1883  maxParallelFileReads: 8
1884  limitMb: 100
1885suspend: true
1886"#;
1887        let spec: SnapshotPolicySpec = from_yaml(yaml);
1888        let eh = spec.error_handling.as_ref().expect("errorHandling");
1889        assert!(eh.ignore_file_errors);
1890        assert!(!eh.ignore_dir_errors);
1891        assert!(eh.ignore_unknown_types);
1892        assert!(eh.fail_fast);
1893        let up = spec.upload.as_ref().expect("upload");
1894        assert_eq!(up.max_parallel_snapshots, Some(4));
1895        assert_eq!(up.max_parallel_file_reads, Some(8));
1896        assert_eq!(up.limit_mb, Some(100));
1897        assert!(spec.suspend);
1898
1899        let json = serde_json::to_value(&spec).expect("serialize");
1900        assert_eq!(json["suspend"], true);
1901        assert_eq!(json["errorHandling"]["ignoreFileErrors"], true);
1902        assert_eq!(json["errorHandling"]["failFast"], true);
1903        assert_eq!(json["upload"]["maxParallelSnapshots"], 4);
1904        assert_eq!(json["upload"]["limitMb"], 100);
1905        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1906        assert_eq!(spec, reparsed);
1907
1908        // Absent ⇒ None / false (not serialized).
1909        let bare: SnapshotPolicySpec = from_yaml(
1910            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1911        );
1912        assert!(bare.error_handling.is_none());
1913        assert!(bare.upload.is_none());
1914        assert!(!bare.suspend);
1915        let bare_json = serde_json::to_value(&bare).unwrap();
1916        assert!(bare_json.get("suspend").is_none());
1917        assert!(bare_json.get("errorHandling").is_none());
1918    }
1919
1920    /// The `sources[]` item schema's operator-authored CEL rule, as the API server
1921    /// will see it. Shared by both guards below.
1922    fn source_cel_rule() -> String {
1923        let crd = SnapshotPolicy::crd();
1924        let json = serde_json::to_value(&crd).expect("serialize CRD");
1925        let source = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1926            ["properties"]["sources"]["items"];
1927        let rules = source["x-kubernetes-validations"]
1928            .as_array()
1929            .expect("sources.items.x-kubernetes-validations present")
1930            .clone();
1931        rules
1932            .iter()
1933            .find_map(|r| r["rule"].as_str().map(str::to_string))
1934            .expect("a CEL rule is present on sources.items")
1935    }
1936
1937    #[test]
1938    fn source_schema_carries_exactly_one_of_validation() {
1939        // §15: the Source sub-object schema carries the exactly-one-of(pvc/
1940        // pvcSelector/nfs/stream) rule, surviving kube's structural-schema rewriter even
1941        // as a list-item sub-object.
1942        let rule = source_cel_rule();
1943        assert!(rule.contains("pvcSelector") && rule.contains("nfs"));
1944        assert!(
1945            rule.contains("== 1"),
1946            "it must be an exactly-one-of: {rule}"
1947        );
1948    }
1949
1950    /// The `sources[]` CEL rule must name EVERY [`SourceShape`] variant.
1951    ///
1952    /// The mirror of `restore::cel_guard_tests::restore_target_cel_rule_lists_every_variant`,
1953    /// and it exists for the same reason that one does. `source_shape` being an exhaustive
1954    /// enum does NOT keep this rule in step: the rule lives in the generated schema and is
1955    /// enforced by the API SERVER, before the webhook or any reconciler runs. A form added
1956    /// to [`Source`] (hence to [`SourceShape`]) but missing from the rule compiles, passes
1957    /// clippy, passes every other test, and `cargo xtask gen-all` regenerates the CRD with
1958    /// the stale rule intact — `gen-all --check` only detects DRIFT, which a developer
1959    /// clears by regenerating. The first evidence is `kubectl apply` failing in a real
1960    /// cluster with a message naming only the old forms. That is exactly how `streamExec`
1961    /// failed on the Restore side, and this side had no guard at all.
1962    ///
1963    /// It also catches the reverse edit: dropping `(has(self.stream) ? 1 : 0)` from a rule
1964    /// this branch TOUCHED — a plausible merge-conflict resolution — makes every stream
1965    /// policy unapplyable cluster-wide, and nothing else here would notice.
1966    ///
1967    /// Driving the expectation off `kind_str()` over a one-value-per-variant array means a
1968    /// new variant fails to COMPILE here, with an instruction, rather than in a cluster.
1969    #[test]
1970    fn source_cel_rule_lists_every_variant() {
1971        let stream = StreamSource {
1972            file_name: "dump.sql".into(),
1973            workload_exec: StreamExec {
1974                pod_selector: Default::default(),
1975                container: None,
1976                command: vec!["true".into()],
1977                timeout: None,
1978            },
1979        };
1980        let pvc = PvcSource {
1981            name: "data".into(),
1982        };
1983        let selector = PvcSelector {
1984            namespace_selector: None,
1985            label_selector: None,
1986        };
1987        let nfs = NfsVolume {
1988            server: "nfs.example".into(),
1989            path: "/export/data".into(),
1990        };
1991        // One value per variant, so adding a variant makes this array fail to compile
1992        // and forces the author to extend it.
1993        let all = [
1994            SourceShape::Pvc(&pvc),
1995            SourceShape::PvcSelector(&selector),
1996            SourceShape::Nfs(&nfs),
1997            SourceShape::Stream(&stream),
1998        ];
1999
2000        let rule = source_cel_rule();
2001        for shape in &all {
2002            // `kind_str` is already the camelCase wire key for this struct.
2003            let wire = shape.kind_str();
2004            assert!(
2005                rule.contains(&format!("has(self.{wire})")),
2006                "the Source CEL rule does not mention `{wire}`, so the API SERVER will \
2007                 REJECT that source form before the webhook ever sees it. Add \
2008                 `(has(self.{wire}) ? 1 : 0)` to the rule (and its message) in \
2009                 snapshot_policy.rs, then re-run `cargo xtask gen-all`.\nrule: {rule}"
2010            );
2011        }
2012    }
2013
2014    #[test]
2015    fn snapshot_policy_has_last_snapshot_and_suspended_columns() {
2016        // ADR-0005 §3: the LAST-SNAPSHOT (status.lastSuccessfulSnapshot) and
2017        // §14(e) SUSPENDED columns are present in the CRD with the right jsonPaths.
2018        let crd = SnapshotPolicy::crd();
2019        let json = serde_json::to_value(&crd).expect("serialize CRD");
2020        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2021            .as_array()
2022            .expect("printer columns");
2023        let by_name = |name: &str| {
2024            cols.iter()
2025                .find(|c| c["name"] == name)
2026                .unwrap_or_else(|| panic!("missing column {name}"))
2027        };
2028        assert_eq!(
2029            by_name("Last-Snapshot")["jsonPath"],
2030            ".status.lastSuccessfulSnapshot"
2031        );
2032        assert_eq!(by_name("Suspended")["jsonPath"], ".spec.suspend");
2033    }
2034
2035    #[test]
2036    fn verification_roundtrip_and_opt_in() {
2037        // ADR-0005 §4: verification parses the cluster's way, round-trips, and is
2038        // opt-in (absent ⇒ None, no behavior change).
2039        let yaml = r#"
2040repository: { kind: Repository, name: r }
2041sources: [ { pvc: { name: d } } ]
2042verification:
2043  quick:
2044    schedule: { cron: "0 4 * * *", jitter: 30m }
2045  deep:
2046    schedule: { cron: "0 5 * * 0", jitter: 1h }
2047    capacity: 10Gi
2048    storageClassName: fast-ssd
2049  successExpr: "stats.files > 0 && stats.errors == 0"
2050  verifyFilesPercent: 10
2051"#;
2052        let spec: SnapshotPolicySpec = from_yaml(yaml);
2053        let v = spec.verification.as_ref().expect("verification");
2054        let quick = v.quick.as_ref().expect("quick");
2055        assert_eq!(quick.schedule.as_ref().unwrap().cron, "0 4 * * *");
2056        let deep = v.deep.as_ref().expect("deep");
2057        assert_eq!(deep.schedule.cron, "0 5 * * 0");
2058        assert_eq!(deep.capacity.as_deref(), Some("10Gi"));
2059        assert_eq!(
2060            v.success_expr.as_deref(),
2061            Some("stats.files > 0 && stats.errors == 0")
2062        );
2063        assert_eq!(v.verify_files_percent, Some(10));
2064
2065        let json = serde_json::to_value(&spec).expect("serialize");
2066        assert_eq!(
2067            json["verification"]["quick"]["schedule"]["cron"],
2068            "0 4 * * *"
2069        );
2070        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
2071        assert_eq!(spec, reparsed);
2072
2073        // Absent ⇒ None (no behavior change).
2074        let bare: SnapshotPolicySpec = from_yaml(
2075            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2076        );
2077        assert!(bare.verification.is_none());
2078        assert!(
2079            serde_json::to_value(&bare)
2080                .unwrap()
2081                .get("verification")
2082                .is_none()
2083        );
2084    }
2085
2086    #[test]
2087    fn verification_quick_old_shape_still_decodes() {
2088        // GitHub #174: `verification.quick` gained a nested `schedule`. An object
2089        // persisted in etcd BEFORE this change carries the flat shape
2090        // (`quick: { cron: ... }`). It MUST still decode (serde ignores the unknown
2091        // `cron`/`jitter` keys) as `schedule: None` — a hard decode failure would
2092        // wedge the SnapshotPolicy reflector and poison admission cluster-wide. The
2093        // quick tier is then treated as disabled; the webhook rejects NEW old-shape
2094        // writes with a pointer to the move.
2095        let old = from_yaml::<SnapshotPolicySpec>(
2096            "repository: { kind: Repository, name: r }\n\
2097             sources: [ { pvc: { name: d } } ]\n\
2098             verification:\n  quick: { cron: \"0 4 * * *\", jitter: 30m }\n",
2099        );
2100        let v = old.verification.as_ref().expect("verification");
2101        let quick = v.quick.as_ref().expect("quick present");
2102        assert!(
2103            quick.schedule.is_none(),
2104            "old flat `quick: {{cron: ...}}` must decode with schedule: None (quick disabled)"
2105        );
2106    }
2107
2108    #[test]
2109    fn verification_quick_and_deep_tuning_knobs_roundtrip() {
2110        // M3 (issue #216 category sweep): quick gains `--parallel`/`--file-parallelism`/
2111        // `--file-queue-length`/`--max-errors`; deep gains `--parallel` (it restores
2112        // under the hood). All optional, absent ⇒ kopia's own default.
2113        let yaml = r#"
2114repository: { kind: Repository, name: r }
2115sources: [ { pvc: { name: d } } ]
2116verification:
2117  quick:
2118    schedule: { cron: "0 4 * * *" }
2119    parallel: 2
2120    fileParallelism: 4
2121    fileQueueLength: 100
2122    maxErrors: 1
2123  deep:
2124    schedule: { cron: "0 5 * * 0" }
2125    parallel: 2
2126"#;
2127        let spec: SnapshotPolicySpec = from_yaml(yaml);
2128        let v = spec.verification.as_ref().expect("verification");
2129        let quick = v.quick.as_ref().expect("quick");
2130        assert_eq!(quick.parallel, Some(2));
2131        assert_eq!(quick.file_parallelism, Some(4));
2132        assert_eq!(quick.file_queue_length, Some(100));
2133        assert_eq!(quick.max_errors, Some(1));
2134        let deep = v.deep.as_ref().expect("deep");
2135        assert_eq!(deep.parallel, Some(2));
2136
2137        let json = serde_json::to_value(&spec).expect("serialize");
2138        assert_eq!(json["verification"]["quick"]["parallel"], 2);
2139        assert_eq!(json["verification"]["quick"]["fileParallelism"], 4);
2140        assert_eq!(json["verification"]["quick"]["fileQueueLength"], 100);
2141        assert_eq!(json["verification"]["quick"]["maxErrors"], 1);
2142        assert_eq!(json["verification"]["deep"]["parallel"], 2);
2143        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
2144        assert_eq!(spec, reparsed);
2145
2146        // Absent ⇒ None, and the keys are omitted entirely (no dormant defaults).
2147        let bare_yaml = r#"
2148repository: { kind: Repository, name: r }
2149sources: [ { pvc: { name: d } } ]
2150verification:
2151  quick:
2152    schedule: { cron: "0 4 * * *" }
2153  deep:
2154    schedule: { cron: "0 5 * * 0" }
2155"#;
2156        let bare: SnapshotPolicySpec = from_yaml(bare_yaml);
2157        let bv = bare.verification.as_ref().expect("verification");
2158        assert!(bv.quick.as_ref().unwrap().parallel.is_none());
2159        assert!(bv.deep.as_ref().unwrap().parallel.is_none());
2160        let bare_json = serde_json::to_value(&bare).expect("serialize");
2161        assert!(bare_json["verification"]["quick"].get("parallel").is_none());
2162        assert!(bare_json["verification"]["deep"].get("parallel").is_none());
2163    }
2164
2165    #[test]
2166    fn preflight_roundtrip_and_opt_in() {
2167        // Preflight parses the cluster's way, round-trips, and is opt-in.
2168        let yaml = r#"
2169repository: { kind: Repository, name: r }
2170sources: [ { pvc: { name: d } } ]
2171preflight:
2172  timeout: 10m
2173  checks:
2174    - name: maintenance-fresh
2175      expr: "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
2176      message: "maintenance must have run within 7d"
2177    - name: backend-up
2178      expr: "repository.backendReachable"
2179"#;
2180        let spec: SnapshotPolicySpec = from_yaml(yaml);
2181        let pf = spec.preflight.as_ref().expect("preflight");
2182        assert_eq!(pf.timeout.as_deref(), Some("10m"));
2183        assert_eq!(pf.checks.len(), 2);
2184        assert_eq!(pf.checks[0].name, "maintenance-fresh");
2185        assert_eq!(pf.checks[1].expr, "repository.backendReachable");
2186        assert!(pf.checks[1].message.is_none());
2187
2188        let json = serde_json::to_value(&spec).expect("serialize");
2189        assert_eq!(json["preflight"]["checks"][0]["name"], "maintenance-fresh");
2190        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
2191        assert_eq!(spec, reparsed);
2192
2193        // Absent ⇒ None (no behavior change).
2194        let bare: SnapshotPolicySpec = from_yaml(
2195            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2196        );
2197        assert!(bare.preflight.is_none());
2198        assert!(
2199            serde_json::to_value(&bare)
2200                .unwrap()
2201                .get("preflight")
2202                .is_none()
2203        );
2204    }
2205
2206    #[test]
2207    fn snapshot_policy_has_last_verified_column() {
2208        // ADR-0005 §4: the LAST-VERIFIED (status.lastVerified) column is present.
2209        let crd = SnapshotPolicy::crd();
2210        let json = serde_json::to_value(&crd).expect("serialize CRD");
2211        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2212            .as_array()
2213            .expect("printer columns");
2214        let col = cols
2215            .iter()
2216            .find(|c| c["name"] == "Last-Verified")
2217            .expect("Last-Verified column");
2218        assert_eq!(col["jsonPath"], ".status.lastVerified");
2219    }
2220
2221    #[test]
2222    fn snapshot_policy_has_repositories_summary_column() {
2223        // #368 B1: the REPOSITORIES column renders status.repositorySummary so a
2224        // multi-repo policy (whose `.spec.repository.name` column is empty) still
2225        // names its targets in `kubectl get`.
2226        let crd = SnapshotPolicy::crd();
2227        let json = serde_json::to_value(&crd).expect("serialize CRD");
2228        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2229            .as_array()
2230            .expect("printer columns");
2231        let col = cols
2232            .iter()
2233            .find(|c| c["name"] == "Repositories")
2234            .expect("Repositories column");
2235        assert_eq!(col["jsonPath"], ".status.repositorySummary");
2236    }
2237
2238    #[test]
2239    fn status_verification_fields_elide_when_empty_and_roundtrip() {
2240        // The golden-byte contract for every new Vec/map status field (#368):
2241        // an empty `verification` / `verificationStamps` / absent
2242        // `repositorySummary` must emit NOTHING, so a single-repo policy's
2243        // status wire is byte-identical to pre-feature operators.
2244        let bare = SnapshotPolicyStatus::default();
2245        let json = serde_json::to_value(&bare).expect("serialize");
2246        assert!(json.get("verification").is_none(), "empty vec must elide");
2247        assert!(
2248            json.get("verificationStamps").is_none(),
2249            "empty map must elide"
2250        );
2251        assert!(
2252            json.get("repositorySummary").is_none(),
2253            "absent summary must elide"
2254        );
2255
2256        // Populated: parse the cluster's way and round-trip.
2257        let status: SnapshotPolicyStatus = serde_json::from_value(serde_json::json!({
2258            "lastVerified": "2026-08-01T00:00:00Z",
2259            "repositorySummary": "nas, offsite",
2260            "verification": [
2261                {
2262                    "repository": { "kind": "Repository", "name": "nas", "namespace": "backups" },
2263                    "lastVerified": "2026-08-01T00:00:00Z"
2264                },
2265                { "repository": { "kind": "ClusterRepository", "name": "offsite" } }
2266            ],
2267            "verificationStamps": {
2268                "Repository/backups/nas": "2026-08-01T00:00:00Z"
2269            }
2270        }))
2271        .expect("status parses");
2272        assert_eq!(status.verification.len(), 2);
2273        assert_eq!(
2274            status.verification[0].last_verified.as_deref(),
2275            Some("2026-08-01T00:00:00Z")
2276        );
2277        assert!(status.verification[1].last_verified.is_none());
2278        assert_eq!(
2279            status
2280                .verification_stamps
2281                .get("Repository/backups/nas")
2282                .map(String::as_str),
2283            Some("2026-08-01T00:00:00Z")
2284        );
2285        let out = serde_json::to_value(&status).expect("serialize");
2286        assert_eq!(
2287            out["verification"][0]["repository"]["name"], "nas",
2288            "camelCase wire shape"
2289        );
2290        assert_eq!(out["repositorySummary"], "nas, offsite");
2291    }
2292
2293    #[test]
2294    fn status_last_successful_snapshot_roundtrips() {
2295        let status: SnapshotPolicyStatus =
2296            from_yaml("lastSuccessfulSnapshot: 2026-06-09T02:00:00Z\n");
2297        assert_eq!(
2298            status.last_successful_snapshot.as_deref(),
2299            Some("2026-06-09T02:00:00Z")
2300        );
2301        let json = serde_json::to_value(&status).unwrap();
2302        assert_eq!(json["lastSuccessfulSnapshot"], "2026-06-09T02:00:00Z");
2303    }
2304
2305    // --- stream source (spec.sources[].stream) -------------------------------
2306
2307    #[test]
2308    fn stream_timeout_schema_default_is_the_shared_constant() {
2309        // api-conventions §4a: a context-free default, safe to server-side
2310        // materialize because BOTH resolution sites (the backup Job build and the
2311        // restore Job build) map an absent/unparseable `timeout` to exactly
2312        // DEFAULT_STREAM_TIMEOUT_SECS. Asserted on the REAL CRD, and on both CRDs
2313        // that embed `StreamExec` — a producer and a consumer must not disagree
2314        // about how long they are allowed to run.
2315        let policy = serde_json::to_value(SnapshotPolicy::crd()).unwrap();
2316        let src = &policy["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2317            ["properties"]["sources"]["items"]["properties"]["stream"]["properties"]["workloadExec"]
2318            ["properties"]["timeout"]["default"];
2319        assert_eq!(src, &serde_json::json!("1h"));
2320
2321        let restore = serde_json::to_value(crate::Restore::crd()).unwrap();
2322        let tgt = &restore["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2323            ["properties"]["target"]["properties"]["streamExec"]["properties"]["workloadExec"]["properties"]
2324            ["timeout"]["default"];
2325        assert_eq!(
2326            tgt, src,
2327            "the producer and consumer timeout defaults come from one constant"
2328        );
2329    }
2330
2331    #[test]
2332    fn stream_timeout_schema_default_matches_the_duration_constant() {
2333        // The schema default is a Go duration STRING but the controller resolves an
2334        // absent value to a seconds constant. If someone edits one and not the
2335        // other, the materialized value stops matching the resolver and the
2336        // default is no longer behavior-preserving — which §4a forbids.
2337        let rendered = default_stream_timeout().expect("a default is emitted");
2338        assert_eq!(
2339            crate::duration::parse_go_duration(&rendered)
2340                .expect("the emitted default must be a parseable Go duration")
2341                .as_secs(),
2342            crate::consts::DEFAULT_STREAM_TIMEOUT_SECS
2343        );
2344    }
2345
2346    // --- policy-deletion cascade (spec.deletion.onPolicyDelete) --------------
2347
2348    #[test]
2349    fn policy_deletion_on_policy_delete_schema_default_is_retain() {
2350        // Mirrors snapshot_schedule's schedule_deletion_on_schedule_delete_schema_default_is_retain:
2351        // context-free default, safe to server-side-materialize because
2352        // effective_on_policy_delete maps an absent sub-object to the same value.
2353        let crd = SnapshotPolicy::crd();
2354        let json = serde_json::to_value(&crd).unwrap();
2355        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2356        assert_eq!(
2357            spec["properties"]["deletion"]["properties"]["onPolicyDelete"]["default"],
2358            serde_json::json!("Retain")
2359        );
2360        assert_eq!(
2361            effective_on_policy_delete(None),
2362            crate::common::PolicyDeletePolicy::Retain
2363        );
2364    }
2365
2366    #[test]
2367    fn policy_deletion_round_trips_and_absent_stays_none() {
2368        use crate::common::PolicyDeletePolicy;
2369
2370        let spec: SnapshotPolicySpec = from_yaml(
2371            "repository: { kind: Repository, name: r }\n\
2372             sources: [ { pvc: { name: d } } ]\n\
2373             deletion: { onPolicyDelete: Delete }\n",
2374        );
2375        assert_eq!(
2376            spec.deletion.as_ref().map(|d| d.on_policy_delete),
2377            Some(PolicyDeletePolicy::Delete)
2378        );
2379        assert_eq!(
2380            effective_on_policy_delete(spec.deletion.as_ref()),
2381            PolicyDeletePolicy::Delete
2382        );
2383        let json = serde_json::to_value(&spec).unwrap();
2384        assert_eq!(json["deletion"]["onPolicyDelete"], "Delete");
2385        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2386        assert_eq!(spec, reparsed);
2387
2388        // Absent sub-object stays None (not materialized to Retain client-side).
2389        let bare: SnapshotPolicySpec = from_yaml(
2390            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2391        );
2392        assert!(bare.deletion.is_none());
2393        assert!(
2394            serde_json::to_value(&bare)
2395                .unwrap()
2396                .get("deletion")
2397                .is_none(),
2398            "absent deletion must be elided"
2399        );
2400        assert_eq!(
2401            effective_on_policy_delete(bare.deletion.as_ref()),
2402            PolicyDeletePolicy::Retain
2403        );
2404    }
2405
2406    #[test]
2407    fn policy_deletion_unknown_on_policy_delete_value_is_rejected() {
2408        let value: serde_json::Value =
2409            serde_saphyr::from_str("deletion:\n  onPolicyDelete: Orphan\n").unwrap();
2410        assert!(serde_json::from_value::<SnapshotPolicySpec>(value).is_err());
2411    }
2412
2413    #[test]
2414    fn policy_delete_policy_serializes_to_expected_strings() {
2415        use crate::common::PolicyDeletePolicy;
2416
2417        assert_eq!(
2418            serde_json::to_value(PolicyDeletePolicy::Retain).unwrap(),
2419            "Retain"
2420        );
2421        assert_eq!(
2422            serde_json::to_value(PolicyDeletePolicy::Delete).unwrap(),
2423            "Delete"
2424        );
2425        assert_eq!(PolicyDeletePolicy::default(), PolicyDeletePolicy::Retain);
2426    }
2427
2428    // --- adoption (spec.adoption + status.adoption) --------------------------
2429
2430    #[test]
2431    fn policy_adoption_round_trips_and_absent_stays_none() {
2432        use crate::common::SnapshotAdoption;
2433
2434        let spec: SnapshotPolicySpec = from_yaml(
2435            "repository: { kind: Repository, name: r }\n\
2436             sources: [ { pvc: { name: d } } ]\n\
2437             adoption: Ignore\n",
2438        );
2439        assert_eq!(spec.adoption, Some(SnapshotAdoption::Ignore));
2440        let json = serde_json::to_value(&spec).unwrap();
2441        assert_eq!(json["adoption"], "Ignore");
2442        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2443        assert_eq!(spec, reparsed);
2444
2445        let bare: SnapshotPolicySpec = from_yaml(
2446            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2447        );
2448        assert!(bare.adoption.is_none());
2449        assert!(
2450            serde_json::to_value(&bare)
2451                .unwrap()
2452                .get("adoption")
2453                .is_none(),
2454            "absent adoption must be elided"
2455        );
2456    }
2457
2458    #[test]
2459    fn policy_adoption_schema_carries_no_default() {
2460        // §4a: the effective default (`Adopt`) is context-dependent (a policy ->
2461        // repo -> constant inheritance chain), so no schemars `default` is
2462        // emitted for THIS field — the reference stays `—`.
2463        let crd = SnapshotPolicy::crd();
2464        let json = serde_json::to_value(&crd).unwrap();
2465        let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
2466            ["properties"]["adoption"];
2467        assert!(
2468            prop.get("default").is_none(),
2469            "spec.adoption must NOT carry a schema default: {prop}"
2470        );
2471        assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
2472    }
2473
2474    // --- M7 multi-repository fan-out: golden byte-compat + accessors ---------
2475
2476    /// THE golden byte-compat proof for the `repository: Option` flip: a legacy
2477    /// single-repo spec's wire encoding is pinned as a COMMITTED fixture string
2478    /// (the pre-change encoding), not constructed by round-tripping — so any
2479    /// serialization drift (a leaked `repositories` key above all) fails here.
2480    #[test]
2481    fn legacy_single_repo_spec_wire_is_byte_identical() {
2482        const LEGACY_WIRE: &str = r#"{"repository":{"kind":"Repository","name":"nas-primary","namespace":"backups"},"sources":[{"pvc":{"name":"pgdata"}}],"copyMethod":"Snapshot","retention":{"keepLatest":3}}"#;
2483        assert!(
2484            !LEGACY_WIRE.contains("repositories"),
2485            "fixture precondition"
2486        );
2487
2488        // A legacy YAML parses to the same struct shape it always did…
2489        let spec: SnapshotPolicySpec = from_yaml(
2490            "repository: { kind: Repository, name: nas-primary, namespace: backups }\n\
2491             sources: [ { pvc: { name: pgdata } } ]\n\
2492             retention: { keepLatest: 3 }\n",
2493        );
2494        assert_eq!(spec.repository.as_ref().unwrap().name, "nas-primary");
2495        assert!(spec.repositories.is_empty());
2496
2497        // …and re-serializes to the exact pre-change bytes: no `repositories`
2498        // key (Vec::is_empty skip), `repository` unwrapped exactly as before.
2499        assert_eq!(serde_json::to_string(&spec).unwrap(), LEGACY_WIRE);
2500
2501        // The committed wire decodes back to the identical struct.
2502        let reparsed: SnapshotPolicySpec = serde_json::from_str(LEGACY_WIRE).unwrap();
2503        assert_eq!(spec, reparsed);
2504    }
2505
2506    #[test]
2507    fn repositories_roundtrip_and_absent_stays_off_the_wire() {
2508        let spec: SnapshotPolicySpec = from_yaml(
2509            "repositories:\n\
2510             - { kind: Repository, name: a }\n\
2511             - { kind: ClusterRepository, name: b }\n\
2512             sources: [ { pvc: { name: d } } ]\n",
2513        );
2514        assert!(spec.repository.is_none());
2515        assert_eq!(spec.repositories.len(), 2);
2516        let json = serde_json::to_value(&spec).unwrap();
2517        assert!(json.get("repository").is_none());
2518        assert_eq!(json["repositories"][1]["kind"], "ClusterRepository");
2519        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
2520        assert_eq!(spec, reparsed);
2521    }
2522
2523    #[test]
2524    fn policy_repositories_accessors_cover_all_four_shapes() {
2525        use crate::error::ValidationError;
2526
2527        let single: SnapshotPolicySpec = from_yaml(
2528            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2529        );
2530        assert!(matches!(
2531            policy_repositories(&single),
2532            Ok(PolicyRepositories::Single(r)) if r.name == "r"
2533        ));
2534        assert!(!is_multi_repo(&single));
2535        assert_eq!(repository_refs(&single).count(), 1);
2536        assert_eq!(single_repository_ref(&single).unwrap().name, "r");
2537
2538        let multi: SnapshotPolicySpec = from_yaml(
2539            "repositories: [ { name: a }, { name: b } ]\nsources: [ { pvc: { name: d } } ]\n",
2540        );
2541        assert!(matches!(
2542            policy_repositories(&multi),
2543            Ok(PolicyRepositories::Multi(refs)) if refs.len() == 2
2544        ));
2545        assert!(is_multi_repo(&multi));
2546        assert_eq!(
2547            repository_refs(&multi)
2548                .map(|r| r.name.as_str())
2549                .collect::<Vec<_>>(),
2550            vec!["a", "b"]
2551        );
2552        // The single-repo-only accessor refuses multi loudly — never a
2553        // silent repository #1.
2554        assert_eq!(
2555            single_repository_ref(&multi).unwrap_err(),
2556            ValidationError::PolicySingleRepositoryRequired
2557        );
2558
2559        // Neither / both: named errors, never a panic — a stored CR can be
2560        // garbage relative to the current CRD schema.
2561        let neither: SnapshotPolicySpec = from_yaml("sources: [ { pvc: { name: d } } ]\n");
2562        assert_eq!(
2563            policy_repositories(&neither).unwrap_err(),
2564            ValidationError::PolicyRepositoryExactlyOne { got: "neither" }
2565        );
2566        assert_eq!(repository_refs(&neither).count(), 0);
2567
2568        let both: SnapshotPolicySpec = from_yaml(
2569            "repository: { name: r }\nrepositories: [ { name: a } ]\n\
2570             sources: [ { pvc: { name: d } } ]\n",
2571        );
2572        assert_eq!(
2573            policy_repositories(&both).unwrap_err(),
2574            ValidationError::PolicyRepositoryExactlyOne { got: "both" }
2575        );
2576        // Tolerant iterator yields whatever exists, in repository-then-list order.
2577        assert_eq!(
2578            repository_refs(&both)
2579                .map(|r| r.name.as_str())
2580                .collect::<Vec<_>>(),
2581            vec!["r", "a"]
2582        );
2583    }
2584
2585    #[test]
2586    fn select_restore_repository_covers_every_selection_shape() {
2587        use super::select_restore_repository;
2588        use crate::common::{RepositoryKind, RepositoryRef};
2589        use crate::error::ValidationError;
2590        let single: SnapshotPolicySpec = from_yaml(
2591            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
2592        );
2593        let multi: SnapshotPolicySpec = from_yaml(
2594            "repositories:\n  - { kind: Repository, name: a }\n  - { kind: ClusterRepository, name: b }\n\
2595             sources: [ { pvc: { name: d } } ]\n",
2596        );
2597        let rref = |kind, name: &str, ns: Option<&str>| RepositoryRef {
2598            kind,
2599            name: name.into(),
2600            namespace: ns.map(str::to_string),
2601        };
2602
2603        // No selection, single-repo → the policy's one ref, verbatim.
2604        let r = select_restore_repository(&single, "pol", "backups", None, "apps").unwrap();
2605        assert_eq!(r.name, "r");
2606
2607        // No selection, multi-repo → refusal naming every valid choice.
2608        match select_restore_repository(&multi, "pol", "backups", None, "apps").unwrap_err() {
2609            ValidationError::RestoreRepositorySelectionRequired { policy, valid } => {
2610                assert_eq!(policy, "pol");
2611                assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2612            }
2613            other => panic!("expected RestoreRepositorySelectionRequired, got {other:?}"),
2614        }
2615
2616        // Explicit member (namespace-qualified to the policy's namespace) → honored.
2617        let explicit = rref(RepositoryKind::Repository, "a", Some("backups"));
2618        let r =
2619            select_restore_repository(&multi, "pol", "backups", Some(&explicit), "apps").unwrap();
2620        assert_eq!(r.name, "a");
2621
2622        // Explicit cluster-scoped member: namespace-free key matches from any
2623        // restore namespace.
2624        let cluster = rref(RepositoryKind::ClusterRepository, "b", None);
2625        assert!(
2626            select_restore_repository(&multi, "pol", "backups", Some(&cluster), "apps").is_ok()
2627        );
2628
2629        // Explicit NON-member → typed refusal naming what was given and what's
2630        // valid. (An unqualified Repository ref resolves in the RESTORE's
2631        // namespace — `apps/a` is not the policy's `backups/a`.)
2632        let typo = rref(RepositoryKind::Repository, "a", None);
2633        match select_restore_repository(&multi, "pol", "backups", Some(&typo), "apps").unwrap_err()
2634        {
2635            ValidationError::RestoreRepositoryNotInPolicy {
2636                given,
2637                policy,
2638                valid,
2639            } => {
2640                assert_eq!(given, "Repository/apps/a");
2641                assert_eq!(policy, "pol");
2642                assert_eq!(valid, "Repository/backups/a, ClusterRepository/b");
2643            }
2644            other => panic!("expected RestoreRepositoryNotInPolicy, got {other:?}"),
2645        }
2646
2647        // Explicit non-member against a SINGLE-repo policy is refused too (the
2648        // audit-m4 backstop: a typo must not silently read the wrong repo).
2649        let elsewhere = rref(RepositoryKind::ClusterRepository, "offsite", None);
2650        assert!(matches!(
2651            select_restore_repository(&single, "pol", "backups", Some(&elsewhere), "apps")
2652                .unwrap_err(),
2653            ValidationError::RestoreRepositoryNotInPolicy { .. }
2654        ));
2655    }
2656
2657    #[test]
2658    fn snapshot_policy_spec_carries_exactly_one_of_repository_cel_rule() {
2659        // The spec-level CEL rule must survive kube's structural-schema
2660        // rewriter (mirrors restore.rs / snapshot_schedule.rs precedents).
2661        let crd = SnapshotPolicy::crd();
2662        let json = serde_json::to_value(&crd).expect("serialize CRD");
2663        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
2664        let rules = spec["x-kubernetes-validations"]
2665            .as_array()
2666            .expect("spec-level x-kubernetes-validations present");
2667        assert!(
2668            rules.iter().any(|r| {
2669                r["rule"]
2670                    == "(has(self.repository) ? 1 : 0) + (has(self.repositories) ? 1 : 0) == 1"
2671                    && r["message"] == "exactly one of repository, repositories"
2672            }),
2673            "missing the exactly-one-of CEL rule; got {rules:?}"
2674        );
2675        // `repository` is no longer structurally required…
2676        let required = spec["required"].as_array().cloned().unwrap_or_default();
2677        assert!(
2678            !required.iter().any(|f| f == "repository"),
2679            "repository must not be schema-required anymore; got {required:?}"
2680        );
2681        // …and `repositories` carries the 1..=8 bounds (minItems 1 makes an
2682        // explicit `repositories: []` a structural rejection, so the CEL rule
2683        // never has to reason about a present-but-empty list).
2684        let repos = &spec["properties"]["repositories"];
2685        assert_eq!(repos["minItems"], 1, "{repos}");
2686        assert_eq!(repos["maxItems"], 8, "{repos}");
2687    }
2688
2689    #[test]
2690    fn snapshot_policy_repository_print_column_is_unchanged() {
2691        // The `Repository` column stays `.spec.repository.name` (renders
2692        // empty for a multi-repo policy — the `Repositories` summary column
2693        // covers that shape; documented in docs/src/content/docs/backups.mdx).
2694        let crd = SnapshotPolicy::crd();
2695        let json = serde_json::to_value(&crd).expect("serialize CRD");
2696        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
2697            .as_array()
2698            .expect("printer columns");
2699        let col = cols
2700            .iter()
2701            .find(|c| c["name"] == "Repository")
2702            .expect("Repository column");
2703        assert_eq!(col["jsonPath"], ".spec.repository.name");
2704    }
2705
2706    #[test]
2707    fn adoption_summary_status_roundtrips() {
2708        let status: SnapshotPolicyStatus = from_yaml(
2709            "adoption:\n  \
2710             lastAdoptionAt: 2026-06-09T02:00:00Z\n  \
2711             lastAdoptedCount: 3\n  \
2712             totalAdopted: 42\n  \
2713             lastScanMatched: 3\n  \
2714             lastScanUnmatched: 7\n  \
2715             scanRequestedAt: 2026-06-10T00:00:00Z\n  \
2716             scanRequestedIdentity: postgres@billing\n",
2717        );
2718        let a = status.adoption.as_ref().expect("adoption");
2719        assert_eq!(a.last_adoption_at.as_deref(), Some("2026-06-09T02:00:00Z"));
2720        assert_eq!(a.last_adopted_count, Some(3));
2721        assert_eq!(a.total_adopted, Some(42));
2722        assert_eq!(a.last_scan_matched, Some(3));
2723        assert_eq!(a.last_scan_unmatched, Some(7));
2724        assert_eq!(a.scan_requested_at.as_deref(), Some("2026-06-10T00:00:00Z"));
2725        assert_eq!(
2726            a.scan_requested_identity.as_deref(),
2727            Some("postgres@billing")
2728        );
2729
2730        let json = serde_json::to_value(&status).unwrap();
2731        assert_eq!(json["adoption"]["lastAdoptedCount"], 3);
2732        assert_eq!(json["adoption"]["totalAdopted"], 42);
2733        assert_eq!(json["adoption"]["lastScanMatched"], 3);
2734        assert_eq!(json["adoption"]["lastScanUnmatched"], 7);
2735        let reparsed: SnapshotPolicyStatus = serde_json::from_value(json).unwrap();
2736        assert_eq!(status, reparsed);
2737
2738        // A pass that saw an EMPTY catalog stamps an explicit 0/0 — it must
2739        // survive the round-trip as `Some(0)`, distinct from "no pass yet".
2740        let empty_scan: SnapshotPolicyStatus =
2741            from_yaml("adoption:\n  lastScanMatched: 0\n  lastScanUnmatched: 0\n");
2742        let a = empty_scan.adoption.as_ref().expect("adoption");
2743        assert_eq!(
2744            (a.last_scan_matched, a.last_scan_unmatched),
2745            (Some(0), Some(0))
2746        );
2747        let wire = serde_json::to_value(&empty_scan).unwrap();
2748        assert_eq!(wire["adoption"]["lastScanMatched"], 0);
2749        assert_eq!(wire["adoption"]["lastScanUnmatched"], 0);
2750
2751        // Absent ⇒ None, elided.
2752        let bare: SnapshotPolicyStatus = from_yaml("{}\n");
2753        assert!(bare.adoption.is_none());
2754        assert!(
2755            serde_json::to_value(&bare)
2756                .unwrap()
2757                .get("adoption")
2758                .is_none(),
2759            "absent adoption summary must be elided"
2760        );
2761    }
2762}