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":"Last-Snapshot","type":"date","jsonPath":".status.lastSuccessfulSnapshot"}"#,
28    printcolumn = r#"{"name":"Last-Verified","type":"date","jsonPath":".status.lastVerified"}"#,
29    printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.suspend"}"#,
30    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
31)]
32#[serde(rename_all = "camelCase")]
33pub struct SnapshotPolicySpec {
34    /// Discriminated reference to a `Repository` or `ClusterRepository`.
35    pub repository: RepositoryRef,
36    /// Identity overrides — what kopia records as `username@hostname:path`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub identity: Option<Identity>,
39    /// What to back up (at least one source; webhook-enforced).
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    #[schemars(length(max = 100))]
42    pub sources: Vec<Source>,
43    /// How the source volume is captured before kopia reads it: `Snapshot` (default), `Direct`, or `Clone`.
44    #[serde(default = "default_copy_method")]
45    #[schemars(default = "default_copy_method")]
46    pub copy_method: CopyMethod,
47    /// `VolumeSnapshotClass` used when `copyMethod` snapshots/clones the source.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub volume_snapshot_class_name: Option<String>,
50    /// Staging knobs for `copyMethod: Snapshot`/`Clone` (e.g. how long to wait for
51    /// the CSI capture to become ready before failing the backup).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub staging: Option<StagingSpec>,
54    /// Multi-PVC consistency grouping; `None` opts into independent per-PVC snapshots.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    #[schemars(default = "default_group_by")]
57    pub group_by: Option<GroupBy>,
58    /// GFS retention, enforced by the operator pruning `Snapshot` CRs.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub retention: Option<Retention>,
61    /// Default `deletionPolicy` for `Snapshot` CRs created against this config.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    #[schemars(default = "recipe_default_deletion_policy")]
64    pub default_deletion_policy: Option<DeletionPolicy>,
65    /// Compression algorithm + per-extension opt-outs.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub compression: Option<Compression>,
68    /// Paths/patterns kopia should skip while snapshotting.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub files: Option<Files>,
71    /// Escape hatch for kopia flags not yet modeled.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub extra_args: Vec<String>,
74    /// Backup-side error handling: let a snapshot complete-with-errors instead of failing outright.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub error_handling: Option<ErrorHandling>,
77    /// Upload parallelism (kopia's `--max-parallel-snapshots` / `--max-parallel-file-reads`).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub upload: Option<Upload>,
80    /// First-class backup verification; opt-in (absent ⇒ no verification runs).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub verification: Option<Verification>,
83    /// Named CEL preconditions evaluated before each backup run; opt-in (absent ⇒
84    /// no preflight). A failing check holds the `Snapshot` in `Pending`
85    /// (`PreflightFailed`) and, after `timeout`, fails it.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub preflight: Option<crate::preflight::PreflightSpec>,
88    /// Pause this recipe declaratively (schedules and reconcile skip a suspended policy).
89    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
90    pub suspend: bool,
91    /// Pre/post snapshot hooks that run in the workload, not the mover.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub hooks: Option<Hooks>,
94    /// Per-recipe mover overrides (resources, cache, security context).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub mover: Option<MoverSpec>,
97    /// Opt-in credential-Secret projection into each backup mover's namespace (default off).
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub credential_projection: Option<CredentialProjection>,
100    /// Deletion semantics for the `Snapshot` CRs carrying this recipe's config label.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub deletion: Option<PolicyDeletionSpec>,
103    /// Per-policy override of automatic adoption for discovered snapshots whose
104    /// resolved identity matches this recipe; absent inherits the repository's
105    /// `catalog.adoption` (see [`effective_adoption`](crate::common::effective_adoption)).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub adoption: Option<crate::common::SnapshotAdoption>,
108}
109
110/// Deletion semantics for the `Snapshot`s carrying a `SnapshotPolicy`'s config
111/// label (sub-object per docs/dev/api-conventions.md §4 so future deletion
112/// knobs slot in without API breakage). Mirrors `SnapshotSchedule`'s
113/// [`ScheduleDeletionSpec`](crate::snapshot_schedule::ScheduleDeletionSpec).
114#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
115#[serde(rename_all = "camelCase")]
116pub struct PolicyDeletionSpec {
117    /// Consulted by the Snapshot finalizer when the deletion is external and the
118    /// owning `SnapshotPolicy` is gone. Absent resolves to `Retain`.
119    #[serde(default = "default_on_policy_delete")]
120    #[schemars(default = "default_on_policy_delete")]
121    pub on_policy_delete: crate::common::PolicyDeletePolicy,
122}
123
124fn default_on_policy_delete() -> crate::common::PolicyDeletePolicy {
125    crate::common::PolicyDeletePolicy::Retain
126}
127
128/// The effective cascade policy for a `SnapshotPolicy`: `spec.deletion.onPolicyDelete`
129/// when the sub-object is present, else `Retain`. (A default nested under an
130/// ABSENT optional sub-object does not materialize server-side — every read
131/// goes through this resolver.)
132pub fn effective_on_policy_delete(
133    deletion: Option<&PolicyDeletionSpec>,
134) -> crate::common::PolicyDeletePolicy {
135    deletion.map(|d| d.on_policy_delete).unwrap_or_default()
136}
137
138/// A single backup source; exactly one of `pvc`, `pvcSelector`, `nfs` (webhook-enforced).
139// The exactly-one-of rule is written as an integer sum of `has()` ternaries rather
140// than `[...].filter(x,x).size()==1`: the apiserver estimates per-item CEL cost ×
141// `maxItems`, and a list-construction + lambda `filter` blows the budget on the
142// repeating `sources` list. The sum form is a cheap constant per item.
143// `Default` is derived purely for construction ergonomics: `Source` is built as an
144// exhaustive struct literal in ~20 places, and every added field would otherwise have
145// to be spelled out at each one. An all-`None` `Source` is not a valid spec (the CEL
146// rule above demands exactly one of pvc/pvcSelector/nfs) and admission rejects it.
147#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, JsonSchema)]
148#[schemars(extend("x-kubernetes-validations" = [{
149    "rule": "(has(self.pvc) ? 1 : 0) + (has(self.pvcSelector) ? 1 : 0) + (has(self.nfs) ? 1 : 0) == 1",
150    "message": "exactly one of pvc, pvcSelector, nfs"
151}]))]
152#[serde(rename_all = "camelCase")]
153pub struct Source {
154    /// Single PVC by name. Mutually exclusive with `pvcSelector`/`nfs`.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub pvc: Option<PvcSource>,
157    /// Label/namespace selector matching many PVCs. Mutually exclusive with `pvc`/`nfs`.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub pvc_selector: Option<PvcSelector>,
160    /// An inline NFS export to back up directly. Mutually exclusive with `pvc`/`pvcSelector`.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub nfs: Option<NfsVolume>,
163    /// Mount the source read-only (default `true`; kopia only ever reads it).
164    ///
165    /// Set `false` **only** to make `fsGroup` work on the source. The kubelet applies
166    /// `fsGroup` by recursively `chgrp`-ing the volume and adding group-write — and it
167    /// skips that walk entirely on a read-only mount, which is why a mover
168    /// `fsGroup`/`fsGroupChangePolicy` otherwise has no effect here. Under
169    /// `copyMethod: Snapshot`/`Clone` the walk rewrites the throwaway staged PVC and
170    /// never touches your data. Under `copyMethod: Direct` it rewrites the LIVE volume,
171    /// which requires `acknowledgeLiveMutation`.
172    ///
173    /// Not supported on an `nfs` source: the kubelet does not apply `fsGroup` to
174    /// in-tree NFS volumes at all, so a read-write mount would grant nothing.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    #[schemars(default = "default_source_read_only")]
177    pub read_only: Option<bool>,
178    /// Acknowledges that `copyMethod: Direct` + `readOnly: false` lets the kubelet
179    /// recursively `chgrp` the **live** volume to the mover's `fsGroup` and make it
180    /// group-writable — permanently, while the workload is running. Required for that
181    /// combination alone.
182    ///
183    /// Ignored (not rejected) otherwise: it is an acknowledgement, never harmful to
184    /// carry, and rejecting a stale one would make switching `copyMethod` between
185    /// `Direct` and `Snapshot`/`Clone` a two-step edit in both directions.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub acknowledge_live_mutation: Option<bool>,
188    /// What kopia records as the source path (default `/pvc/<name>`, or the NFS export `path`).
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    #[schemars(length(max = 4096))]
191    pub source_path_override: Option<String>,
192    /// How a `pvcSelector`-matched PVC's source path is derived (`pvcName` vs `pvcNamespacedName`).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    #[schemars(default = "default_source_path_strategy")]
195    pub source_path_strategy: Option<SourcePathStrategy>,
196}
197
198/// A single backup source addressed by PVC name.
199#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
200#[serde(rename_all = "camelCase")]
201pub struct PvcSource {
202    /// Name of the `PersistentVolumeClaim` to back up (in the `SnapshotPolicy`'s namespace).
203    pub name: String,
204}
205
206/// Selects PVCs across namespaces by label.
207#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
208#[serde(rename_all = "camelCase")]
209pub struct PvcSelector {
210    /// Restricts the search to specific namespaces; absent means the policy's own namespace.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub namespace_selector: Option<NamespaceSelector>,
213    /// Standard Kubernetes label selector matching the PVCs to include.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub label_selector: Option<LabelSelector>,
216}
217
218/// Restricts a `PvcSelector` to an explicit set of namespaces.
219#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
220#[serde(rename_all = "camelCase")]
221pub struct NamespaceSelector {
222    /// Exact namespace names to search; empty means the policy's own namespace.
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub match_names: Vec<String>,
225}
226
227/// serde/schemars `default` for [`SnapshotPolicySpec::copy_method`] — **`Snapshot`**.
228///
229/// `Snapshot` (point-in-time CSI `VolumeSnapshot` staging) is the default because it
230/// is **crash-consistent**: kopia reads a frozen point-in-time capture instead of a
231/// live, possibly-mid-write PVC, which matters most for databases and other stateful
232/// apps. It requires the CSI external-snapshotter stack plus a `VolumeSnapshotClass`
233/// for the source's driver. `Direct` (read the live PVC) remains available and is the
234/// right choice for non-CSI/static sources (e.g. hostPath, some NFS setups) or when the
235/// snapshot stack isn't installed — set `copyMethod: Direct` explicitly to opt in. If
236/// the CSI stack is missing under the `Snapshot` default, the operator fails loud: the
237/// `Snapshot`/`SnapshotPolicy` status condition and Warning Event spell out exactly
238/// what to install or which field to set (see `crates/controller/src/io/staging.rs`).
239///
240/// A named fn so it backs BOTH `#[serde(default = ...)]` and `#[schemars(default = ...)]`,
241/// which is what makes schemars 1 emit a real OpenAPI `default:` in the generated CRD.
242fn default_copy_method() -> CopyMethod {
243    CopyMethod::Snapshot
244}
245
246/// The default OS-artifact exclude set for `Files.ignore_rules` — filesystem/NAS
247/// junk that is never intentional user data, so excluding it by default is
248/// additive-safe. Per-entry rationale:
249///
250/// - `/lost+found` — root-anchored ext4/fsck recovery dir. Anchored (leading
251///   `/`) so a *nested* user directory named `lost+found` is left alone; only
252///   the source root's own fsck dir is excluded.
253/// - `System Volume Information`, `$RECYCLE.BIN` — Windows/SMB-client
254///   artifacts that show up on samba-share-backed PVCs.
255/// - `@eaDir` — Synology NAS extended-attribute/thumbnail metadata junk.
256/// - `.snapshot` — NAS-exposed snapshot pseudo-directories (NetApp-style).
257///   Deliberately **unanchored** (no leading `/`): these appear at *every*
258///   level of a NetApp-backed export, not just the root, and backing one up
259///   recursively would multiply the backup size by re-capturing older
260///   snapshot generations as regular file data. Flip side: a legitimate
261///   directory named `.snapshot` at any depth is also excluded — set
262///   `ignoreRules` explicitly if you have one (your list replaces the default).
263///
264/// A named fn so it backs BOTH `#[serde(default = ...)]` (the common case: an
265/// absent `files:` block, handled by the controller glue in
266/// `kopiur_mover::workspec` since the apiserver only server-side-defaults
267/// NESTED fields when the parent object is present) AND
268/// `#[schemars(default = ...)]` (so the default is visible in the generated
269/// CRD schema / `kubectl explain`, and applies when `files: {}` is present
270/// without `ignoreRules`). ONE source of truth for both layers.
271pub fn default_ignore_rules() -> Vec<String> {
272    vec![
273        "/lost+found".to_string(),
274        "System Volume Information".to_string(),
275        "$RECYCLE.BIN".to_string(),
276        "@eaDir".to_string(),
277        ".snapshot".to_string(),
278    ]
279}
280
281/// Volume snapshot copy method. Closed enum. ADR §3.3.
282///
283/// ```
284/// use kopiur_api::CopyMethod;
285///
286/// // Defaults to crash-consistent CSI VolumeSnapshot staging.
287/// assert_eq!(CopyMethod::default(), CopyMethod::Snapshot);
288/// // Serializes as a bare PascalCase string (no external tagging — it has no payload).
289/// assert_eq!(serde_json::to_value(CopyMethod::Snapshot).unwrap(), "Snapshot");
290/// assert_eq!(serde_json::to_value(CopyMethod::Direct).unwrap(), "Direct");
291/// ```
292#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
293pub enum CopyMethod {
294    /// Point-in-time CSI volume snapshot (the default; requires the CSI snapshot stack + a `VolumeSnapshotClass`).
295    #[default]
296    Snapshot,
297    /// CSI volume clone of the source (opt-in; requires a cloning-capable CSI driver). Mounted per `sources[].readOnly` — read-only by default.
298    Clone,
299    /// Read the live PVC directly with no intermediate snapshot/clone (opt-in; works on any storage, no CSI required).
300    Direct,
301}
302
303/// `SnapshotPolicy.spec.staging` — knobs for the CSI capture (`copyMethod:
304/// Snapshot`/`Clone`) that runs before the mover. A sub-object so future staging
305/// fields slot in without API breakage.
306#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
307#[serde(rename_all = "camelCase")]
308pub struct StagingSpec {
309    /// How long each staging phase may take before the backup is failed (Go-style
310    /// duration like `10m` or `1h`; default `10m`): first the staged
311    /// `VolumeSnapshot` becoming `readyToUse` (measured from its creation), then —
312    /// on an `Immediate`-binding StorageClass — the staged PVC binding (a fresh
313    /// budget measured from the PVC's creation, covering the CSI restore/clone).
314    /// A transient CSI/snapshot-controller error during either wait is retried,
315    /// never fatal on its own — only this deadline fails staging. A zero duration
316    /// (`0`/`0s`) waits indefinitely. Raise this for backends whose snapshots or
317    /// clones take long (e.g. cloud snapshots of large volumes, CephFS full clones
318    /// of small-file-heavy volumes).
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub timeout: Option<String>,
321    /// StorageClass for the **staged PVC** — the temporary PVC restored from the
322    /// CSI `VolumeSnapshot` (`copyMethod: Snapshot`) or cloned from the source
323    /// (`copyMethod: Clone`). Absent ⇒ the staged PVC copies the source PVC's
324    /// class. Must belong to the **same CSI driver** as the source (staging fails
325    /// fast on a mismatch). Flagship use: a rook-ceph CephFS class with
326    /// `backingSnapshot: "true"`, which mounts the snapshot shallowly
327    /// (metadata-only, near-instant, read-only) instead of running a full
328    /// subvolume clone that can take many minutes on small-file-heavy volumes.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub storage_class_name: Option<String>,
331    /// Access modes for the staged PVC. Empty ⇒ copy the source PVC's modes.
332    /// `[ReadOnlyMany]` pairs with snapshot-backed read-only classes (e.g. CephFS
333    /// `backingSnapshot`); the mover mounts the staged PVC read-only to match, and
334    /// rejects it at admission if a source sets `readOnly: false` (a read-only stage
335    /// cannot be mounted read-write).
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub access_modes: Vec<PvcAccessMode>,
338}
339
340/// Multi-PVC grouping strategy. Defaults to a consistent group snapshot across
341/// all PVCs; set `None` *explicitly* to accept independent per-PVC snapshots,
342/// because a silent per-PVC fallback would produce inconsistent backups.
343#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
344pub enum GroupBy {
345    /// Consistent group snapshot across all PVCs (default for multi-PVC).
346    #[default]
347    VolumeGroupSnapshot,
348    /// Opt into independent per-PVC snapshots.
349    None,
350}
351
352/// schemars default for `PvcSnapshotPolicy::group_by` — the consistent group
353/// snapshot. Returns the field's `Option` type so schemars emits the schema
354/// `default:` (`VolumeGroupSnapshot`) for `kubectl explain`.
355fn default_group_by() -> Option<GroupBy> {
356    Some(GroupBy::VolumeGroupSnapshot)
357}
358
359/// How a selector-matched PVC's source path is derived. Only relevant for
360/// `pvcSelector` sources, where one recipe expands to many PVCs and each needs a
361/// distinct kopia source path. Defaults to `PvcName`.
362#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
363pub enum SourcePathStrategy {
364    /// Path derived from the PVC name alone (default).
365    #[default]
366    PvcName,
367    /// Path derived from `<namespace>/<name>` to disambiguate same-named PVCs across namespaces.
368    PvcNamespacedName,
369}
370
371/// schemars default for `PvcSnapshotPolicy::source_path_strategy` — `PvcName`.
372/// Returns the field's `Option` type so schemars emits the schema `default:`.
373fn default_source_path_strategy() -> Option<SourcePathStrategy> {
374    Some(SourcePathStrategy::PvcName)
375}
376
377/// schemars default for `Source::read_only` — a backup source is read-only unless the
378/// user asks otherwise. Returns the field's `Option` type so schemars emits the schema
379/// `default: true` for `kubectl explain`. Paired with `source_read_only()`, which
380/// resolves absent to exactly this value at the mount site — the pairing is what makes
381/// a schema default safe to advertise (see `Repository`'s health defaults).
382fn default_source_read_only() -> Option<bool> {
383    Some(true)
384}
385
386/// Whether a source is mounted read-only. THE resolver for `Source::read_only`'s
387/// absent case, so the CRD's advertised `default: true` and the mount agree by
388/// construction rather than by coincidence.
389pub fn source_read_only(source: &Source) -> bool {
390    source.read_only.unwrap_or(true)
391}
392
393/// Whether this source's mount lets the kubelet rewrite the **live** workload volume:
394/// a writable mount with no staging in front of it.
395///
396/// `copyMethod: Snapshot`/`Clone` interpose a throwaway staged PVC, so the kubelet's
397/// recursive `fsGroup` chgrp lands on a copy that is deleted when the run ends. Only
398/// `Direct` mounts the workload's own PVC, where that same walk permanently rewrites
399/// group ownership on production data. Pure, and the single definition of the hazard.
400///
401/// An `nfs` source is excluded, and not merely because [`validate_source`] rejects a
402/// writable one anyway: the kubelet does not apply `fsGroup` to in-tree NFS volumes at
403/// all, so no walk ever happens and this predicate's premise is simply false there.
404/// Answering `true` would also make admission emit two errors for one mistake, the
405/// second of them advice — "set `acknowledgeLiveMutation`" — that could never make the
406/// configuration valid.
407///
408/// [`validate_source`]: crate::validate::validate_source
409pub fn source_mutates_live_volume(copy_method: CopyMethod, source: &Source) -> bool {
410    matches!(copy_method, CopyMethod::Direct)
411        && !source_read_only(source)
412        && (source.pvc.is_some() || source.pvc_selector.is_some())
413}
414
415/// schemars default for `PvcSnapshotPolicy::default_deletion_policy` — `Delete`,
416/// the deletion policy produced `Snapshot` CRs inherit. Returns the field's
417/// `Option` type so schemars emits the schema `default:`.
418fn recipe_default_deletion_policy() -> Option<crate::common::DeletionPolicy> {
419    Some(crate::common::DeletionPolicy::Delete)
420}
421
422/// Compression policy.
423#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
424#[serde(rename_all = "camelCase")]
425pub struct Compression {
426    /// kopia compressor name (e.g. `zstd`); absent leaves kopia's default.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub compressor: Option<String>,
429    /// Filename globs to leave uncompressed (e.g. already-compressed media).
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub never_compress: Vec<String>,
432}
433
434/// File-ignore policy.
435#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
436#[serde(rename_all = "camelCase")]
437pub struct Files {
438    /// Filename/path globs to exclude from the snapshot (e.g. `*.tmp`, `*/cache/*`).
439    /// Absent ⇒ [`default_ignore_rules`] (OS-artifact junk: `/lost+found`,
440    /// `System Volume Information`, `$RECYCLE.BIN`, `@eaDir`, `.snapshot`). An
441    /// explicit list REPLACES the default wholesale (re-add any entries you
442    /// still want); explicit `ignoreRules: []` opts fully out. NOT
443    /// `skip_serializing_if` — an explicit empty list must round-trip as `[]`,
444    /// not vanish back to "absent" (which would silently resurrect the
445    /// default on the next parse).
446    #[serde(default = "default_ignore_rules")]
447    #[schemars(default = "default_ignore_rules")]
448    pub ignore_rules: Vec<String>,
449    /// Honor `CACHEDIR.TAG`.
450    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
451    pub ignore_cache_dirs: bool,
452    /// Skip taking a new snapshot when the source is identical to the previous one.
453    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
454    pub ignore_identical_snapshots: bool,
455}
456
457/// Backup-side error-handling policy: let kopia complete a snapshot with errors rather than aborting.
458#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
459#[serde(rename_all = "camelCase")]
460pub struct ErrorHandling {
461    /// Continue the snapshot when a file cannot be read (`--ignore-file-errors`).
462    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
463    pub ignore_file_errors: bool,
464    /// Continue the snapshot when a directory cannot be read (`--ignore-dir-errors`).
465    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
466    pub ignore_dir_errors: bool,
467    /// Continue past entries of unknown type (`--ignore-unknown-types`).
468    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
469    pub ignore_unknown_types: bool,
470    /// Abort the snapshot at the first error instead of collecting and
471    /// continuing (`snapshot create --fail-fast`; kopia default: false). This
472    /// is a `snapshot create` argv flag, not a `policy set` knob, but lives
473    /// beside its semantic opposites (`ignore*Errors`) for discoverability.
474    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
475    pub fail_fast: bool,
476}
477
478/// Upload parallelism (kopia's upload policy); absent knobs leave kopia's default.
479#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
480#[serde(rename_all = "camelCase")]
481pub struct Upload {
482    /// `--max-parallel-snapshots`: how many sources snapshot concurrently.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub max_parallel_snapshots: Option<i64>,
485    /// `--max-parallel-file-reads`: file-read concurrency within a snapshot.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub max_parallel_file_reads: Option<i64>,
488    /// `snapshot create --upload-limit-mb`: abort the snapshot once this many
489    /// MB have been uploaded (kopia default: 0 — unlimited). Named `limitMb`
490    /// rather than `uploadLimitMb` to avoid the `upload.uploadLimitMb` stutter;
491    /// like `failFast`, this is a `snapshot create` argv flag, not a `policy
492    /// set` knob, but lives here beside its parallelism siblings.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub limit_mb: Option<i64>,
495}
496
497/// First-class backup verification proving snapshots are restorable; opt-in, with quick and deep tiers.
498#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
499#[serde(rename_all = "camelCase")]
500pub struct Verification {
501    /// Quick (blob-level) verification tier; absent ⇒ no quick verification. Its cron
502    /// lives under `quick.schedule` (matching `deep.schedule`), see [`QuickVerification`].
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub quick: Option<QuickVerification>,
505    /// Schedule + knobs for the rarer scratch-restore test; absent ⇒ no deep verification.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub deep: Option<DeepVerification>,
508    /// CEL pass/fail predicate over the verify result; applies to both tiers.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub success_expr: Option<String>,
511    /// How many files `quick` verifies fully (`--verify-files-percent`); absent leaves kopia's default.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub verify_files_percent: Option<u8>,
514}
515
516/// Quick (blob-level) verification tier: schedule for the frequent `kopia snapshot verify`.
517///
518/// A wrapper so this tier's shape matches `deep` — the cron lives at
519/// `quick.schedule.cron` (GitHub #174). `schedule` is deliberately `Option` for
520/// decode-tolerance: an already-persisted old-shape `quick: { cron: ... }` object
521/// still decodes (serde ignores the unknown `cron` key) as `schedule: None` rather
522/// than failing typed serde — a hard decode failure would wedge the SnapshotPolicy
523/// reflector and poison SnapshotPolicy admission cluster-wide. New writes with the
524/// old shape are rejected at admission by the shared validator, which points at the
525/// move. A persisted `schedule: None` means the quick tier is disabled until updated.
526#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
527#[serde(rename_all = "camelCase")]
528pub struct QuickVerification {
529    /// Cron + jitter + timezone for the frequent blob-level verify; absent ⇒ quick tier disabled.
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub schedule: Option<CronSpec>,
532    /// `--parallel`: verification parallelism (kopia default: 8).
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub parallel: Option<u32>,
535    /// `--file-parallelism`: parallelism for file verification (kopia default: unset).
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub file_parallelism: Option<u32>,
538    /// `--file-queue-length`: queue length for file verification (kopia default: 20000).
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub file_queue_length: Option<u32>,
541    /// `--max-errors`: stop after this many errors (kopia default: 0, meaning stop
542    /// at the first error).
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub max_errors: Option<u32>,
545}
546
547/// Deep (scratch-restore) verification: restore the latest snapshot into an ephemeral volume, then discard.
548#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
549#[serde(rename_all = "camelCase")]
550pub struct DeepVerification {
551    /// Cron + jitter for the deep restore-test (e.g. weekly).
552    pub schedule: CronSpec,
553    /// StorageClass for the ephemeral scratch PVC; absent uses the cluster default (only with `capacity`).
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub storage_class_name: Option<String>,
556    /// Size of the ephemeral scratch PVC (e.g. `10Gi`); absent falls back to a node-ephemeral `emptyDir`.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub capacity: Option<String>,
559    /// `restore --parallel`: restore parallelism for the scratch-restore (deep verify
560    /// IS a restore under the hood); absent leaves kopia's default.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub parallel: Option<u32>,
563}
564
565/// Pre/post snapshot hook lists.
566#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
567#[serde(rename_all = "camelCase")]
568pub struct Hooks {
569    /// Hooks run (in order) before the snapshot is taken — e.g. quiescing a database.
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub before_snapshot: Vec<Hook>,
572    /// Hooks run (in order) after the snapshot completes — e.g. resuming the workload.
573    #[serde(default, skip_serializing_if = "Vec::is_empty")]
574    pub after_snapshot: Vec<Hook>,
575}
576
577/// One of three hook forms. Externally-tagged: the wire shape is
578/// `{ workloadExec: {...} }`, `{ runJob: {...} }`, or `{ httpRequest: {...} }`,
579/// and exactly one form is present.
580#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
581#[serde(rename_all = "camelCase")]
582pub enum Hook {
583    /// `kubectl exec`-style into a matched workload pod/container (the default form).
584    WorkloadExec(WorkloadExecHook),
585    /// Full `JobSpec` run as a one-shot Job (k8up `PreBackupPod` analog).
586    RunJob(Box<RunJobHook>),
587    /// Typed POST to a URL for cross-system orchestration.
588    HttpRequest(HttpRequestHook),
589}
590
591impl Hook {
592    /// Stable discriminant string for status/metrics — one of `"WorkloadExec"`,
593    /// `"RunJob"`, or `"HttpRequest"`.
594    ///
595    /// ```
596    /// use kopiur_api::snapshot_policy::{Hook, HttpRequestHook};
597    ///
598    /// let hook = Hook::HttpRequest(HttpRequestHook {
599    ///     url: "https://example/notify".into(),
600    ///     method: None,
601    ///     body: None,
602    ///     headers: Vec::new(),
603    ///     timeout: None,
604    ///     continue_on_failure: false,
605    /// });
606    /// assert_eq!(hook.kind_str(), "HttpRequest");
607    /// ```
608    pub fn kind_str(&self) -> &'static str {
609        match self {
610            Hook::WorkloadExec(_) => "WorkloadExec",
611            Hook::RunJob(_) => "RunJob",
612            Hook::HttpRequest(_) => "HttpRequest",
613        }
614    }
615}
616
617/// `kubectl exec`-style hook into a matched workload pod/container.
618#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
619#[serde(rename_all = "camelCase")]
620pub struct WorkloadExecHook {
621    /// Selects the workload pod/container to exec into (flattened onto the hook).
622    #[serde(flatten)]
623    pub selector: PodSelector,
624    /// Command + args to run inside the selected container.
625    #[serde(default, skip_serializing_if = "Vec::is_empty")]
626    pub command: Vec<String>,
627    /// Max time to wait for the command (Go duration string, e.g. `2m`).
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub timeout: Option<String>,
630    /// If `true`, a failed hook does not abort the backup (default: abort).
631    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
632    pub continue_on_failure: bool,
633}
634
635/// A hook that materializes a full one-shot Job (k8up `PreBackupPod` analog).
636#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
637#[serde(rename_all = "camelCase")]
638pub struct RunJobHook {
639    /// The full Kubernetes `JobSpec` to run.
640    #[schemars(schema_with = "crate::schema::preserve_unknown_object")]
641    pub job_spec: JobSpec,
642    /// Max time to wait for the Job to complete (Go duration string).
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub timeout: Option<String>,
645    /// If `true`, a failed Job does not abort the backup (default: abort).
646    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
647    pub continue_on_failure: bool,
648}
649
650/// One HTTP header sent with an `httpRequest` hook.
651#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
652#[serde(rename_all = "camelCase")]
653pub struct HttpHeader {
654    /// Header name (case-insensitive; RFC 7230 token, e.g. `Content-Type`).
655    pub name: String,
656    /// Header value.
657    pub value: String,
658}
659
660/// A hook that issues an HTTP request for cross-system orchestration.
661#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
662#[serde(rename_all = "camelCase")]
663pub struct HttpRequestHook {
664    /// Target URL to call.
665    pub url: String,
666    /// HTTP method (default `POST`).
667    #[serde(default, skip_serializing_if = "Option::is_none")]
668    pub method: Option<String>,
669    /// Optional request body.
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub body: Option<String>,
672    /// Additional request headers (e.g. `Content-Type: application/json`).
673    #[serde(default, skip_serializing_if = "Vec::is_empty")]
674    pub headers: Vec<HttpHeader>,
675    /// Max time to wait for the response (Go duration string).
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub timeout: Option<String>,
678    /// If `true`, a failed request does not abort the backup (default: abort).
679    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
680    pub continue_on_failure: bool,
681}
682
683/// Observed state of a `SnapshotPolicy`.
684#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
685#[serde(rename_all = "camelCase")]
686pub struct SnapshotPolicyStatus {
687    /// `metadata.generation` last reconciled, for staleness detection.
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub observed_generation: Option<i64>,
690    /// What would be passed to kopia — pinned at admission.
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub resolved: Option<ResolvedPolicy>,
693    /// Summary of GFS retention pruning against this config's `Snapshot` CRs.
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub retention: Option<RetentionSummary>,
696    /// Summary of automatic adoption of discovered snapshots into this recipe.
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub adoption: Option<AdoptionSummary>,
699    /// RFC3339 timestamp of the most recent successful child `Snapshot` from this recipe.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub last_successful_snapshot: Option<String>,
702    /// RFC3339 timestamp of the most recent successful verification (any tier).
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub last_verified: Option<String>,
705    /// Standard Kubernetes conditions (e.g. `RepositoryReachable`, `GroupSnapshotSupported`).
706    #[serde(default, skip_serializing_if = "Vec::is_empty")]
707    pub conditions: Vec<Condition>,
708}
709
710/// The recipe as kopia would see it, pinned at admission and never re-rendered.
711#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
712#[serde(rename_all = "camelCase")]
713pub struct ResolvedPolicy {
714    /// The resolved `username@hostname` identity.
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub identity: Option<ResolvedIdentity>,
717    /// The concrete PVCs + source paths after selector expansion.
718    #[serde(default, skip_serializing_if = "Vec::is_empty")]
719    pub sources: Vec<ResolvedPolicySource>,
720}
721
722/// One resolved source — a concrete PVC and the path kopia records for it.
723#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
724#[serde(rename_all = "camelCase")]
725pub struct ResolvedPolicySource {
726    /// `namespace/name` of the PVC, as kopia sees it.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub pvc: Option<String>,
729    /// The source path kopia records for this PVC.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub source_path: Option<String>,
732}
733
734/// Summary of the most recent GFS retention prune for a `SnapshotPolicy`.
735#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
736#[serde(rename_all = "camelCase")]
737pub struct RetentionSummary {
738    /// CRs currently inside the GFS window.
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub active_snapshot_count: Option<i64>,
741    /// RFC3339 timestamp of the last prune pass.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub last_prune_at: Option<String>,
744    /// Number of `Snapshot` CRs deleted by the last prune pass.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub last_prune_deleted: Option<i64>,
747}
748
749/// Summary of the most recent automatic adoption pass for a `SnapshotPolicy` —
750/// discovered snapshots whose resolved identity matched this recipe and were
751/// re-attached (see `Origin::Adopted`), plus an on-demand re-scan request/ack
752/// pair mirroring the repository-level `catalog-scan-requested-at` annotation
753/// contract.
754#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
755#[serde(rename_all = "camelCase")]
756pub struct AdoptionSummary {
757    /// RFC3339 timestamp of the last adoption pass that adopted at least one snapshot.
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub last_adoption_at: Option<String>,
760    /// Number of discovered `Snapshot` CRs adopted by the last adoption pass.
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub last_adopted_count: Option<u32>,
763    /// Running total of `Snapshot` CRs ever adopted into this recipe.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub total_adopted: Option<u64>,
766    /// Identity-matching discovered snapshots the last adoption pass left
767    /// discovered because `spec.retention` would prune them immediately under
768    /// the effective `deletionPolicy` (`Retain`/`Orphan` — a CR-only prune that
769    /// would re-discover and re-adopt forever). `0`/absent when nothing was
770    /// withheld. See the `AdoptionSkippedByRetention` event for the levers.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub skipped_by_retention: Option<u32>,
773    /// RFC3339 token echoing an in-flight on-demand adoption scan request for
774    /// this policy's identity; cleared once honored.
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub scan_requested_at: Option<String>,
777    /// The resolved kopia identity the requested scan was scoped to, pinned at
778    /// request time so a later identity-changing edit can't retarget an
779    /// in-flight scan.
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub scan_requested_identity: Option<String>,
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::common::RepositoryKind;
788    use crate::testutil::from_yaml;
789    use kube::core::CustomResourceExt;
790
791    #[test]
792    fn snapshot_policy_crd_metadata_is_correct() {
793        let crd = SnapshotPolicy::crd();
794        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
795        assert_eq!(crd.spec.names.kind, "SnapshotPolicy");
796        assert_eq!(crd.spec.names.plural, "snapshotpolicies");
797        assert_eq!(
798            crd.spec.names.short_names.as_deref(),
799            Some(&["kopiasp".to_string()][..])
800        );
801        assert_eq!(crd.spec.scope, "Namespaced");
802        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
803    }
804
805    #[test]
806    fn copy_method_carries_static_openapi_default_in_crd() {
807        // copyMethod must carry a real schema `default: Snapshot` so it appears in
808        // `kubectl explain` / the stored object and GitOps stops thrashing. `Snapshot`
809        // (crash-consistent CSI staging) is the community-preferred default; `Direct` /
810        // `Clone` are opt-in.
811        let crd = SnapshotPolicy::crd();
812        let json = serde_json::to_value(&crd).expect("serialize CRD");
813        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
814            ["properties"]["copyMethod"]["default"];
815        assert_eq!(
816            default, "Snapshot",
817            "copyMethod must emit `default: Snapshot` in the CRD schema; got {default:?}"
818        );
819    }
820
821    #[test]
822    fn staging_timeout_round_trips_and_defaults_to_absent() {
823        // Absent staging parses to None (runtime default 10m applies in the
824        // controller) and is skip-elided on the wire.
825        let spec: SnapshotPolicySpec = from_yaml(
826            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
827        );
828        assert_eq!(spec.staging, None);
829        let json = serde_json::to_value(&spec).unwrap();
830        assert!(
831            json.get("staging").is_none(),
832            "absent staging must be elided"
833        );
834
835        // A set timeout round-trips through the cluster's parse path.
836        let spec: SnapshotPolicySpec = from_yaml(
837            "repository: { kind: Repository, name: r }\n\
838             sources: [ { pvc: { name: d } } ]\n\
839             staging: { timeout: 30m }\n",
840        );
841        assert_eq!(
842            spec.staging,
843            Some(StagingSpec {
844                timeout: Some("30m".to_string()),
845                ..Default::default()
846            })
847        );
848        let json = serde_json::to_value(&spec).unwrap();
849        assert_eq!(json["staging"]["timeout"], "30m");
850    }
851
852    #[test]
853    fn staging_overrides_round_trip_and_are_elided_when_absent() {
854        // The staged-PVC override pair (storageClassName + accessModes) round-trips
855        // through the cluster's parse path; absent fields are skip-elided so
856        // "inherit from the source PVC" stays representable as absence.
857        let spec: SnapshotPolicySpec = from_yaml(
858            "repository: { kind: Repository, name: r }\n\
859             sources: [ { pvc: { name: d } } ]\n\
860             staging: { timeout: 30m, storageClassName: cephfs-shallow, accessModes: [ReadOnlyMany] }\n",
861        );
862        let st = spec.staging.as_ref().unwrap();
863        assert_eq!(st.storage_class_name.as_deref(), Some("cephfs-shallow"));
864        assert_eq!(st.access_modes, vec![PvcAccessMode::ReadOnlyMany]);
865        let json = serde_json::to_value(&spec).unwrap();
866        assert_eq!(json["staging"]["storageClassName"], "cephfs-shallow");
867        assert_eq!(
868            json["staging"]["accessModes"],
869            serde_json::json!(["ReadOnlyMany"])
870        );
871
872        let spec: SnapshotPolicySpec = from_yaml(
873            "repository: { kind: Repository, name: r }\n\
874             sources: [ { pvc: { name: d } } ]\n\
875             staging: { timeout: 30m }\n",
876        );
877        let json = serde_json::to_value(&spec).unwrap();
878        assert!(json["staging"].get("storageClassName").is_none());
879        assert!(json["staging"].get("accessModes").is_none());
880    }
881
882    #[test]
883    fn staging_access_modes_legacy_value_decodes_to_unknown_not_an_error() {
884        // Graceful-decode contract: a non-canonical stored mode deserializes into
885        // `Unknown` (rejected later by the shared validator, per-CR) instead of a
886        // serde error that would wedge the typed watcher for every SnapshotPolicy.
887        let spec: SnapshotPolicySpec = from_yaml(
888            "repository: { kind: Repository, name: r }\n\
889             sources: [ { pvc: { name: d } } ]\n\
890             staging: { accessModes: [ReadWriteOnze] }\n",
891        );
892        assert_eq!(
893            spec.staging.unwrap().access_modes,
894            vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
895        );
896    }
897
898    #[test]
899    fn staging_access_modes_render_a_closed_enum_in_the_crd_schema() {
900        // First `Vec<unit-enum>` in the API crate: pin that the generated CRD
901        // schema is `items: {type: string, enum: [...]}` with exactly the four
902        // canonical modes — and does NOT leak the legacy-decode `Unknown` variant.
903        let crd = SnapshotPolicy::crd();
904        let json = serde_json::to_value(&crd).expect("serialize CRD");
905        let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
906            ["properties"]["staging"]["properties"]["accessModes"]["items"];
907        assert_eq!(
908            items["type"], "string",
909            "items must be strings; got {items}"
910        );
911        assert_eq!(
912            items["enum"],
913            serde_json::json!([
914                "ReadWriteOnce",
915                "ReadOnlyMany",
916                "ReadWriteMany",
917                "ReadWriteOncePod"
918            ]),
919            "items enum must be exactly the canonical modes; got {items}"
920        );
921    }
922
923    #[test]
924    fn copy_method_defaults_to_snapshot_when_absent() {
925        // A bare value with a serde default: an omitted copyMethod parses to Snapshot (the
926        // crash-consistent CSI-staged behavior).
927        let spec: SnapshotPolicySpec = from_yaml(
928            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
929        );
930        assert_eq!(spec.copy_method, CopyMethod::Snapshot);
931        // And it serializes (not skip-elided), so the materialized value round-trips.
932        let json = serde_json::to_value(&spec).unwrap();
933        assert_eq!(json["copyMethod"], "Snapshot");
934    }
935
936    /// The 5-entry OS-artifact default set, in the fixed order `default_ignore_rules`
937    /// returns it — shared by every assertion below so the list itself has one
938    /// source of truth in the test file too.
939    fn expected_default_ignore_rules() -> Vec<String> {
940        vec![
941            "/lost+found".to_string(),
942            "System Volume Information".to_string(),
943            "$RECYCLE.BIN".to_string(),
944            "@eaDir".to_string(),
945            ".snapshot".to_string(),
946        ]
947    }
948
949    #[test]
950    fn files_ignore_rules_carries_static_openapi_default_in_crd() {
951        // `files.ignoreRules` must carry a real schema `default:` (the 5-entry
952        // OS-artifact set) so it appears in `kubectl explain`. Mirrors
953        // `copy_method_carries_static_openapi_default_in_crd`.
954        let crd = SnapshotPolicy::crd();
955        let json = serde_json::to_value(&crd).expect("serialize CRD");
956        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
957            ["properties"]["files"]["properties"]["ignoreRules"]["default"];
958        let want: Vec<serde_json::Value> = expected_default_ignore_rules()
959            .into_iter()
960            .map(serde_json::Value::String)
961            .collect();
962        assert_eq!(
963            default,
964            &serde_json::Value::Array(want),
965            "files.ignoreRules must emit the 5-entry OS-artifact `default:` in the CRD schema; got {default:?}"
966        );
967    }
968
969    #[test]
970    fn ignore_rules_defaults_when_files_block_absent_entirely() {
971        // The load-bearing case: apiserver server-side-defaulting only fires for
972        // NESTED fields when the parent object is present, so a spec that omits
973        // `files:` altogether never gets `Files.ignoreRules`'s schema default
974        // applied by the apiserver. The *serde* default on `Files::ignore_rules`
975        // only helps once `files: {}` exists — it can't fire on a wholly-`None`
976        // `spec.files`. This asserts the glue tier's contract: the mover work-spec
977        // seam (`kopiur_mover::workspec::PolicyArgsSpec::from_policy`) is the layer
978        // that must apply `default_ignore_rules()` for THIS shape; see the mover
979        // crate's `workspec` tests for that half.
980        let spec: SnapshotPolicySpec = from_yaml(
981            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
982        );
983        assert!(
984            spec.files.is_none(),
985            "a spec omitting `files:` entirely must parse to `None`, not a defaulted `Files`"
986        );
987    }
988
989    #[test]
990    fn ignore_rules_defaults_when_files_block_present_but_empty() {
991        // `files: {}` (parent present, `ignoreRules` absent): the serde default
992        // DOES fire here, and this is also what the schemars `default:` covers for
993        // apiserver server-side-defaulting.
994        let spec: SnapshotPolicySpec = from_yaml(
995            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: {}\n",
996        );
997        let files = spec.files.expect("files: {} must parse to Some(Files)");
998        assert_eq!(files.ignore_rules, expected_default_ignore_rules());
999    }
1000
1001    #[test]
1002    fn ignore_rules_explicit_empty_list_opts_out_and_round_trips() {
1003        // Regression test for the opt-out subtlety: an explicit `ignoreRules: []`
1004        // must deserialize as present-empty (serde defaults only fire when the KEY
1005        // is ABSENT, not when it's present-and-empty) and — critically — must
1006        // round-trip back through serialize/deserialize as `[]`, not vanish to
1007        // "absent" and silently resurrect the default. This is why `ignore_rules`
1008        // does NOT carry `skip_serializing_if`.
1009        let spec: SnapshotPolicySpec = from_yaml(
1010            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [] }\n",
1011        );
1012        let files = spec
1013            .files
1014            .as_ref()
1015            .expect("files: {...} must parse to Some(Files)");
1016        assert!(
1017            files.ignore_rules.is_empty(),
1018            "explicit `ignoreRules: []` must opt fully out, got {:?}",
1019            files.ignore_rules
1020        );
1021
1022        // The round-trip: serialize back to JSON, the `ignoreRules` key must still
1023        // be PRESENT (as `[]`), not omitted.
1024        let json = serde_json::to_value(&spec).expect("serialize");
1025        assert_eq!(
1026            json["files"]["ignoreRules"],
1027            serde_json::json!([]),
1028            "an explicit empty ignoreRules must serialize as `[]`, not be omitted \
1029             (omission would deserialize back to the 5-entry default)"
1030        );
1031
1032        // And re-parsing that JSON must still yield the empty, opted-out list —
1033        // not the default reappearing.
1034        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1035        assert_eq!(spec, reparsed);
1036        assert!(reparsed.files.expect("files").ignore_rules.is_empty());
1037    }
1038
1039    #[test]
1040    fn ignore_rules_explicit_custom_list_replaces_default_wholesale() {
1041        // An explicit non-empty list REPLACES the default outright — it is not
1042        // merged/appended. Re-adding a default entry you still want is on the
1043        // user (documented in docs/backups.md).
1044        let spec: SnapshotPolicySpec = from_yaml(
1045            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\nfiles: { ignoreRules: [\"*.tmp\", \"lost+found\"] }\n",
1046        );
1047        let files = spec.files.expect("files");
1048        assert_eq!(
1049            files.ignore_rules,
1050            vec!["*.tmp".to_string(), "lost+found".to_string()]
1051        );
1052    }
1053
1054    #[test]
1055    fn backup_config_roundtrip_matches_adr_shape() {
1056        // Mirrors ADR-0001 §3.3.
1057        let yaml = r#"
1058repository:
1059  kind: Repository
1060  name: nas-primary
1061  namespace: backups
1062identity:
1063  username: "postgres-data"
1064  hostname: "billing"
1065sources:
1066  - pvc: { name: postgres-data }
1067    sourcePathOverride: /data
1068copyMethod: Snapshot
1069volumeSnapshotClassName: csi-snap-class
1070groupBy: VolumeGroupSnapshot
1071retention:
1072  keepLatest: 10
1073  keepDaily: 14
1074defaultDeletionPolicy: Delete
1075compression:
1076  compressor: zstd
1077  neverCompress: ["*.zip", "*.gz", "*.mp4"]
1078files:
1079  ignoreRules: ["*.tmp", "*/cache/*", "lost+found"]
1080  ignoreCacheDirs: true
1081  ignoreIdenticalSnapshots: true
1082extraArgs: []
1083hooks:
1084  beforeSnapshot:
1085    - workloadExec:
1086        podSelector: { matchLabels: { app: postgres } }
1087        container: postgres
1088        command: ["pg_start_backup", "snap"]
1089        timeout: 2m
1090  afterSnapshot:
1091    - workloadExec:
1092        podSelector: { matchLabels: { app: postgres } }
1093        container: postgres
1094        command: ["pg_stop_backup"]
1095        timeout: 2m
1096mover:
1097  resources:
1098    requests: { cpu: 250m, memory: 512Mi }
1099    limits: { cpu: "2", memory: 4Gi }
1100  cache:
1101    capacity: 16Gi
1102    storageClassName: fast-ssd
1103  securityContext:
1104    runAsUser: 1000
1105    runAsGroup: 1000
1106    runAsNonRoot: true
1107    allowPrivilegeEscalation: false
1108    capabilities: { drop: ["ALL"] }
1109    seccompProfile: { type: RuntimeDefault }
1110  podSecurityContext:
1111    fsGroup: 1000
1112    fsGroupChangePolicy: OnRootMismatch
1113"#;
1114        let spec: SnapshotPolicySpec = from_yaml(yaml);
1115        assert_eq!(spec.repository.kind, RepositoryKind::Repository);
1116        assert_eq!(spec.repository.name, "nas-primary");
1117        assert_eq!(spec.sources.len(), 1);
1118        assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
1119        assert_eq!(
1120            spec.sources[0].source_path_override.as_deref(),
1121            Some("/data")
1122        );
1123        assert_eq!(spec.copy_method, CopyMethod::Snapshot);
1124        assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
1125        assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
1126        let comp = spec.compression.as_ref().unwrap();
1127        assert_eq!(comp.compressor.as_deref(), Some("zstd"));
1128        let files = spec.files.as_ref().unwrap();
1129        assert_eq!(files.ignore_rules.len(), 3);
1130        assert!(files.ignore_cache_dirs);
1131        assert!(spec.extra_args.is_empty());
1132        let hooks = spec.hooks.as_ref().unwrap();
1133        assert_eq!(hooks.before_snapshot.len(), 1);
1134        assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
1135        // Both the container- and pod-level security contexts round-trip on the mover.
1136        let mover = spec.mover.as_ref().expect("mover");
1137        assert_eq!(
1138            mover.security_context.as_ref().and_then(|s| s.run_as_user),
1139            Some(1000)
1140        );
1141        assert_eq!(
1142            mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1143            Some(1000)
1144        );
1145        // Container UID/GID match + fsGroup is unprivileged (no namespace opt-in).
1146        assert!(!mover.requires_privilege());
1147
1148        let json = serde_json::to_value(&spec).expect("serialize");
1149        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1150        assert_eq!(spec, reparsed);
1151    }
1152
1153    #[test]
1154    fn credential_projection_roundtrip() {
1155        // Opt-in projection now lives on the recipe (SnapshotPolicy), parses the
1156        // cluster's way, and round-trips.
1157        let yaml = r#"
1158repository: { kind: ClusterRepository, name: shared }
1159sources:
1160  - pvc: { name: data }
1161retention: { keepLatest: 5 }
1162credentialProjection:
1163  enabled: true
1164"#;
1165        let spec: SnapshotPolicySpec = from_yaml(yaml);
1166        assert_eq!(
1167            spec.credential_projection.as_ref().map(|p| p.enabled),
1168            Some(true)
1169        );
1170        let json = serde_json::to_value(&spec).expect("serialize");
1171        assert_eq!(json["credentialProjection"]["enabled"], true);
1172        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1173        assert_eq!(spec, reparsed);
1174
1175        // Absent ⇒ None (self-managed default); not serialized.
1176        let bare: SnapshotPolicySpec = from_yaml(
1177            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1178        );
1179        assert!(bare.credential_projection.is_none());
1180        assert!(
1181            serde_json::to_value(&bare)
1182                .unwrap()
1183                .get("credentialProjection")
1184                .is_none()
1185        );
1186        // Empty `{}` defaults enabled=false (opt-in).
1187        let empty: SnapshotPolicySpec = from_yaml(
1188            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\ncredentialProjection: {}\n",
1189        );
1190        assert_eq!(empty.credential_projection.map(|p| p.enabled), Some(false));
1191    }
1192
1193    #[test]
1194    fn backup_config_minimal_selector_source() {
1195        // Mirrors ADR-0001 §5.4 (multi-PVC selector).
1196        let yaml = r#"
1197repository: { kind: Repository, name: nas-primary, namespace: backups }
1198identity: { username: app-bundle, hostname: billing }
1199sources:
1200  - pvcSelector:
1201      labelSelector: { matchLabels: { backup: include } }
1202    sourcePathStrategy: PvcName
1203groupBy: VolumeGroupSnapshot
1204retention: { keepDaily: 14 }
1205"#;
1206        let spec: SnapshotPolicySpec = from_yaml(yaml);
1207        let src = &spec.sources[0];
1208        assert!(src.pvc.is_none());
1209        assert!(src.pvc_selector.is_some());
1210        assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
1211
1212        let json = serde_json::to_value(&spec).unwrap();
1213        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1214        assert_eq!(spec, reparsed);
1215    }
1216
1217    #[test]
1218    fn hook_run_job_variant_with_job_spec() {
1219        // RunJob embeds a full k8s-openapi JobSpec (so the struct is not Eq).
1220        let yaml = r#"
1221runJob:
1222  jobSpec:
1223    template:
1224      spec:
1225        restartPolicy: Never
1226        containers:
1227          - name: pre
1228            image: busybox
1229            command: ["sh", "-c", "echo hi"]
1230  timeout: 5m
1231  continueOnFailure: true
1232"#;
1233        let hook: Hook = from_yaml(yaml);
1234        assert_eq!(hook.kind_str(), "RunJob");
1235        match &hook {
1236            Hook::RunJob(j) => {
1237                assert!(j.continue_on_failure);
1238                assert_eq!(j.timeout.as_deref(), Some("5m"));
1239                assert_eq!(
1240                    j.job_spec
1241                        .template
1242                        .spec
1243                        .as_ref()
1244                        .unwrap()
1245                        .restart_policy
1246                        .as_deref(),
1247                    Some("Never")
1248                );
1249            }
1250            other => panic!("expected RunJob, got {}", other.kind_str()),
1251        }
1252        let json = serde_json::to_value(&hook).unwrap();
1253        assert!(json.get("runJob").is_some());
1254    }
1255
1256    #[test]
1257    fn hook_http_request_variant() {
1258        let hook: Hook = from_yaml(
1259            "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",
1260        );
1261        assert_eq!(hook.kind_str(), "HttpRequest");
1262        let v = serde_json::to_value(&hook).unwrap();
1263        assert_eq!(v["httpRequest"]["url"], "https://example/notify");
1264        assert_eq!(
1265            v.pointer("/httpRequest/headers/0/name")
1266                .and_then(|x| x.as_str()),
1267            Some("Content-Type")
1268        );
1269        assert_eq!(
1270            v.pointer("/httpRequest/headers/1/value")
1271                .and_then(|x| x.as_str()),
1272            Some("sekrit")
1273        );
1274        // Omitted headers stay off the wire (skip_serializing_if).
1275        let bare: Hook = from_yaml("httpRequest:\n  url: https://example/notify\n");
1276        let v = serde_json::to_value(&bare).unwrap();
1277        assert!(v.pointer("/httpRequest/headers").is_none());
1278    }
1279
1280    #[test]
1281    fn hook_unknown_variant_is_rejected() {
1282        let value: serde_json::Value = serde_yaml::from_str("teleport:\n  url: x\n").unwrap();
1283        assert!(serde_json::from_value::<Hook>(value).is_err());
1284    }
1285
1286    #[test]
1287    fn error_handling_upload_and_suspend_roundtrip() {
1288        // ADR-0005 §13(b)/§13(f)/§14(e): the new policy knobs parse the cluster's
1289        // way, default sanely when absent, and round-trip.
1290        let yaml = r#"
1291repository: { kind: Repository, name: r }
1292sources: [ { pvc: { name: d } } ]
1293errorHandling:
1294  ignoreFileErrors: true
1295  ignoreDirErrors: false
1296  ignoreUnknownTypes: true
1297  failFast: true
1298upload:
1299  maxParallelSnapshots: 4
1300  maxParallelFileReads: 8
1301  limitMb: 100
1302suspend: true
1303"#;
1304        let spec: SnapshotPolicySpec = from_yaml(yaml);
1305        let eh = spec.error_handling.as_ref().expect("errorHandling");
1306        assert!(eh.ignore_file_errors);
1307        assert!(!eh.ignore_dir_errors);
1308        assert!(eh.ignore_unknown_types);
1309        assert!(eh.fail_fast);
1310        let up = spec.upload.as_ref().expect("upload");
1311        assert_eq!(up.max_parallel_snapshots, Some(4));
1312        assert_eq!(up.max_parallel_file_reads, Some(8));
1313        assert_eq!(up.limit_mb, Some(100));
1314        assert!(spec.suspend);
1315
1316        let json = serde_json::to_value(&spec).expect("serialize");
1317        assert_eq!(json["suspend"], true);
1318        assert_eq!(json["errorHandling"]["ignoreFileErrors"], true);
1319        assert_eq!(json["errorHandling"]["failFast"], true);
1320        assert_eq!(json["upload"]["maxParallelSnapshots"], 4);
1321        assert_eq!(json["upload"]["limitMb"], 100);
1322        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1323        assert_eq!(spec, reparsed);
1324
1325        // Absent ⇒ None / false (not serialized).
1326        let bare: SnapshotPolicySpec = from_yaml(
1327            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1328        );
1329        assert!(bare.error_handling.is_none());
1330        assert!(bare.upload.is_none());
1331        assert!(!bare.suspend);
1332        let bare_json = serde_json::to_value(&bare).unwrap();
1333        assert!(bare_json.get("suspend").is_none());
1334        assert!(bare_json.get("errorHandling").is_none());
1335    }
1336
1337    #[test]
1338    fn source_schema_carries_exactly_one_of_validation() {
1339        // §15: the Source sub-object schema carries the exactly-one-of(pvc/
1340        // pvcSelector/nfs) rule, surviving kube's structural-schema rewriter even as a
1341        // list-item sub-object.
1342        let crd = SnapshotPolicy::crd();
1343        let json = serde_json::to_value(&crd).expect("serialize CRD");
1344        let source = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1345            ["properties"]["sources"]["items"];
1346        let rules = source["x-kubernetes-validations"]
1347            .as_array()
1348            .expect("sources.items.x-kubernetes-validations present");
1349        assert!(rules.iter().any(|r| {
1350            r["rule"]
1351                .as_str()
1352                .is_some_and(|s| s.contains("pvcSelector") && s.contains("nfs"))
1353        }));
1354    }
1355
1356    #[test]
1357    fn snapshot_policy_has_last_snapshot_and_suspended_columns() {
1358        // ADR-0005 §3: the LAST-SNAPSHOT (status.lastSuccessfulSnapshot) and
1359        // §14(e) SUSPENDED columns are present in the CRD with the right jsonPaths.
1360        let crd = SnapshotPolicy::crd();
1361        let json = serde_json::to_value(&crd).expect("serialize CRD");
1362        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1363            .as_array()
1364            .expect("printer columns");
1365        let by_name = |name: &str| {
1366            cols.iter()
1367                .find(|c| c["name"] == name)
1368                .unwrap_or_else(|| panic!("missing column {name}"))
1369        };
1370        assert_eq!(
1371            by_name("Last-Snapshot")["jsonPath"],
1372            ".status.lastSuccessfulSnapshot"
1373        );
1374        assert_eq!(by_name("Suspended")["jsonPath"], ".spec.suspend");
1375    }
1376
1377    #[test]
1378    fn verification_roundtrip_and_opt_in() {
1379        // ADR-0005 §4: verification parses the cluster's way, round-trips, and is
1380        // opt-in (absent ⇒ None, no behavior change).
1381        let yaml = r#"
1382repository: { kind: Repository, name: r }
1383sources: [ { pvc: { name: d } } ]
1384verification:
1385  quick:
1386    schedule: { cron: "0 4 * * *", jitter: 30m }
1387  deep:
1388    schedule: { cron: "0 5 * * 0", jitter: 1h }
1389    capacity: 10Gi
1390    storageClassName: fast-ssd
1391  successExpr: "stats.files > 0 && stats.errors == 0"
1392  verifyFilesPercent: 10
1393"#;
1394        let spec: SnapshotPolicySpec = from_yaml(yaml);
1395        let v = spec.verification.as_ref().expect("verification");
1396        let quick = v.quick.as_ref().expect("quick");
1397        assert_eq!(quick.schedule.as_ref().unwrap().cron, "0 4 * * *");
1398        let deep = v.deep.as_ref().expect("deep");
1399        assert_eq!(deep.schedule.cron, "0 5 * * 0");
1400        assert_eq!(deep.capacity.as_deref(), Some("10Gi"));
1401        assert_eq!(
1402            v.success_expr.as_deref(),
1403            Some("stats.files > 0 && stats.errors == 0")
1404        );
1405        assert_eq!(v.verify_files_percent, Some(10));
1406
1407        let json = serde_json::to_value(&spec).expect("serialize");
1408        assert_eq!(
1409            json["verification"]["quick"]["schedule"]["cron"],
1410            "0 4 * * *"
1411        );
1412        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1413        assert_eq!(spec, reparsed);
1414
1415        // Absent ⇒ None (no behavior change).
1416        let bare: SnapshotPolicySpec = from_yaml(
1417            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1418        );
1419        assert!(bare.verification.is_none());
1420        assert!(
1421            serde_json::to_value(&bare)
1422                .unwrap()
1423                .get("verification")
1424                .is_none()
1425        );
1426    }
1427
1428    #[test]
1429    fn verification_quick_old_shape_still_decodes() {
1430        // GitHub #174: `verification.quick` gained a nested `schedule`. An object
1431        // persisted in etcd BEFORE this change carries the flat shape
1432        // (`quick: { cron: ... }`). It MUST still decode (serde ignores the unknown
1433        // `cron`/`jitter` keys) as `schedule: None` — a hard decode failure would
1434        // wedge the SnapshotPolicy reflector and poison admission cluster-wide. The
1435        // quick tier is then treated as disabled; the webhook rejects NEW old-shape
1436        // writes with a pointer to the move.
1437        let old = from_yaml::<SnapshotPolicySpec>(
1438            "repository: { kind: Repository, name: r }\n\
1439             sources: [ { pvc: { name: d } } ]\n\
1440             verification:\n  quick: { cron: \"0 4 * * *\", jitter: 30m }\n",
1441        );
1442        let v = old.verification.as_ref().expect("verification");
1443        let quick = v.quick.as_ref().expect("quick present");
1444        assert!(
1445            quick.schedule.is_none(),
1446            "old flat `quick: {{cron: ...}}` must decode with schedule: None (quick disabled)"
1447        );
1448    }
1449
1450    #[test]
1451    fn verification_quick_and_deep_tuning_knobs_roundtrip() {
1452        // M3 (issue #216 category sweep): quick gains `--parallel`/`--file-parallelism`/
1453        // `--file-queue-length`/`--max-errors`; deep gains `--parallel` (it restores
1454        // under the hood). All optional, absent ⇒ kopia's own default.
1455        let yaml = r#"
1456repository: { kind: Repository, name: r }
1457sources: [ { pvc: { name: d } } ]
1458verification:
1459  quick:
1460    schedule: { cron: "0 4 * * *" }
1461    parallel: 2
1462    fileParallelism: 4
1463    fileQueueLength: 100
1464    maxErrors: 1
1465  deep:
1466    schedule: { cron: "0 5 * * 0" }
1467    parallel: 2
1468"#;
1469        let spec: SnapshotPolicySpec = from_yaml(yaml);
1470        let v = spec.verification.as_ref().expect("verification");
1471        let quick = v.quick.as_ref().expect("quick");
1472        assert_eq!(quick.parallel, Some(2));
1473        assert_eq!(quick.file_parallelism, Some(4));
1474        assert_eq!(quick.file_queue_length, Some(100));
1475        assert_eq!(quick.max_errors, Some(1));
1476        let deep = v.deep.as_ref().expect("deep");
1477        assert_eq!(deep.parallel, Some(2));
1478
1479        let json = serde_json::to_value(&spec).expect("serialize");
1480        assert_eq!(json["verification"]["quick"]["parallel"], 2);
1481        assert_eq!(json["verification"]["quick"]["fileParallelism"], 4);
1482        assert_eq!(json["verification"]["quick"]["fileQueueLength"], 100);
1483        assert_eq!(json["verification"]["quick"]["maxErrors"], 1);
1484        assert_eq!(json["verification"]["deep"]["parallel"], 2);
1485        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1486        assert_eq!(spec, reparsed);
1487
1488        // Absent ⇒ None, and the keys are omitted entirely (no dormant defaults).
1489        let bare_yaml = r#"
1490repository: { kind: Repository, name: r }
1491sources: [ { pvc: { name: d } } ]
1492verification:
1493  quick:
1494    schedule: { cron: "0 4 * * *" }
1495  deep:
1496    schedule: { cron: "0 5 * * 0" }
1497"#;
1498        let bare: SnapshotPolicySpec = from_yaml(bare_yaml);
1499        let bv = bare.verification.as_ref().expect("verification");
1500        assert!(bv.quick.as_ref().unwrap().parallel.is_none());
1501        assert!(bv.deep.as_ref().unwrap().parallel.is_none());
1502        let bare_json = serde_json::to_value(&bare).expect("serialize");
1503        assert!(bare_json["verification"]["quick"].get("parallel").is_none());
1504        assert!(bare_json["verification"]["deep"].get("parallel").is_none());
1505    }
1506
1507    #[test]
1508    fn preflight_roundtrip_and_opt_in() {
1509        // Preflight parses the cluster's way, round-trips, and is opt-in.
1510        let yaml = r#"
1511repository: { kind: Repository, name: r }
1512sources: [ { pvc: { name: d } } ]
1513preflight:
1514  timeout: 10m
1515  checks:
1516    - name: maintenance-fresh
1517      expr: "maintenance.hasRun && maintenance.lastSuccessAgeSeconds < 604800"
1518      message: "maintenance must have run within 7d"
1519    - name: backend-up
1520      expr: "repository.backendReachable"
1521"#;
1522        let spec: SnapshotPolicySpec = from_yaml(yaml);
1523        let pf = spec.preflight.as_ref().expect("preflight");
1524        assert_eq!(pf.timeout.as_deref(), Some("10m"));
1525        assert_eq!(pf.checks.len(), 2);
1526        assert_eq!(pf.checks[0].name, "maintenance-fresh");
1527        assert_eq!(pf.checks[1].expr, "repository.backendReachable");
1528        assert!(pf.checks[1].message.is_none());
1529
1530        let json = serde_json::to_value(&spec).expect("serialize");
1531        assert_eq!(json["preflight"]["checks"][0]["name"], "maintenance-fresh");
1532        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).expect("reparse");
1533        assert_eq!(spec, reparsed);
1534
1535        // Absent ⇒ None (no behavior change).
1536        let bare: SnapshotPolicySpec = from_yaml(
1537            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1538        );
1539        assert!(bare.preflight.is_none());
1540        assert!(
1541            serde_json::to_value(&bare)
1542                .unwrap()
1543                .get("preflight")
1544                .is_none()
1545        );
1546    }
1547
1548    #[test]
1549    fn snapshot_policy_has_last_verified_column() {
1550        // ADR-0005 §4: the LAST-VERIFIED (status.lastVerified) column is present.
1551        let crd = SnapshotPolicy::crd();
1552        let json = serde_json::to_value(&crd).expect("serialize CRD");
1553        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1554            .as_array()
1555            .expect("printer columns");
1556        let col = cols
1557            .iter()
1558            .find(|c| c["name"] == "Last-Verified")
1559            .expect("Last-Verified column");
1560        assert_eq!(col["jsonPath"], ".status.lastVerified");
1561    }
1562
1563    #[test]
1564    fn status_last_successful_snapshot_roundtrips() {
1565        let status: SnapshotPolicyStatus =
1566            from_yaml("lastSuccessfulSnapshot: 2026-06-09T02:00:00Z\n");
1567        assert_eq!(
1568            status.last_successful_snapshot.as_deref(),
1569            Some("2026-06-09T02:00:00Z")
1570        );
1571        let json = serde_json::to_value(&status).unwrap();
1572        assert_eq!(json["lastSuccessfulSnapshot"], "2026-06-09T02:00:00Z");
1573    }
1574
1575    // --- policy-deletion cascade (spec.deletion.onPolicyDelete) --------------
1576
1577    #[test]
1578    fn policy_deletion_on_policy_delete_schema_default_is_retain() {
1579        // Mirrors snapshot_schedule's schedule_deletion_on_schedule_delete_schema_default_is_retain:
1580        // context-free default, safe to server-side-materialize because
1581        // effective_on_policy_delete maps an absent sub-object to the same value.
1582        let crd = SnapshotPolicy::crd();
1583        let json = serde_json::to_value(&crd).unwrap();
1584        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1585        assert_eq!(
1586            spec["properties"]["deletion"]["properties"]["onPolicyDelete"]["default"],
1587            serde_json::json!("Retain")
1588        );
1589        assert_eq!(
1590            effective_on_policy_delete(None),
1591            crate::common::PolicyDeletePolicy::Retain
1592        );
1593    }
1594
1595    #[test]
1596    fn policy_deletion_round_trips_and_absent_stays_none() {
1597        use crate::common::PolicyDeletePolicy;
1598
1599        let spec: SnapshotPolicySpec = from_yaml(
1600            "repository: { kind: Repository, name: r }\n\
1601             sources: [ { pvc: { name: d } } ]\n\
1602             deletion: { onPolicyDelete: Delete }\n",
1603        );
1604        assert_eq!(
1605            spec.deletion.as_ref().map(|d| d.on_policy_delete),
1606            Some(PolicyDeletePolicy::Delete)
1607        );
1608        assert_eq!(
1609            effective_on_policy_delete(spec.deletion.as_ref()),
1610            PolicyDeletePolicy::Delete
1611        );
1612        let json = serde_json::to_value(&spec).unwrap();
1613        assert_eq!(json["deletion"]["onPolicyDelete"], "Delete");
1614        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1615        assert_eq!(spec, reparsed);
1616
1617        // Absent sub-object stays None (not materialized to Retain client-side).
1618        let bare: SnapshotPolicySpec = from_yaml(
1619            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1620        );
1621        assert!(bare.deletion.is_none());
1622        assert!(
1623            serde_json::to_value(&bare)
1624                .unwrap()
1625                .get("deletion")
1626                .is_none(),
1627            "absent deletion must be elided"
1628        );
1629        assert_eq!(
1630            effective_on_policy_delete(bare.deletion.as_ref()),
1631            PolicyDeletePolicy::Retain
1632        );
1633    }
1634
1635    #[test]
1636    fn policy_deletion_unknown_on_policy_delete_value_is_rejected() {
1637        let value: serde_json::Value =
1638            serde_yaml::from_str("deletion:\n  onPolicyDelete: Orphan\n").unwrap();
1639        assert!(serde_json::from_value::<SnapshotPolicySpec>(value).is_err());
1640    }
1641
1642    #[test]
1643    fn policy_delete_policy_serializes_to_expected_strings() {
1644        use crate::common::PolicyDeletePolicy;
1645
1646        assert_eq!(
1647            serde_json::to_value(PolicyDeletePolicy::Retain).unwrap(),
1648            "Retain"
1649        );
1650        assert_eq!(
1651            serde_json::to_value(PolicyDeletePolicy::Delete).unwrap(),
1652            "Delete"
1653        );
1654        assert_eq!(PolicyDeletePolicy::default(), PolicyDeletePolicy::Retain);
1655    }
1656
1657    // --- adoption (spec.adoption + status.adoption) --------------------------
1658
1659    #[test]
1660    fn policy_adoption_round_trips_and_absent_stays_none() {
1661        use crate::common::SnapshotAdoption;
1662
1663        let spec: SnapshotPolicySpec = from_yaml(
1664            "repository: { kind: Repository, name: r }\n\
1665             sources: [ { pvc: { name: d } } ]\n\
1666             adoption: Ignore\n",
1667        );
1668        assert_eq!(spec.adoption, Some(SnapshotAdoption::Ignore));
1669        let json = serde_json::to_value(&spec).unwrap();
1670        assert_eq!(json["adoption"], "Ignore");
1671        let reparsed: SnapshotPolicySpec = serde_json::from_value(json).unwrap();
1672        assert_eq!(spec, reparsed);
1673
1674        let bare: SnapshotPolicySpec = from_yaml(
1675            "repository: { kind: Repository, name: r }\nsources: [ { pvc: { name: d } } ]\n",
1676        );
1677        assert!(bare.adoption.is_none());
1678        assert!(
1679            serde_json::to_value(&bare)
1680                .unwrap()
1681                .get("adoption")
1682                .is_none(),
1683            "absent adoption must be elided"
1684        );
1685    }
1686
1687    #[test]
1688    fn policy_adoption_schema_carries_no_default() {
1689        // §4a: the effective default (`Adopt`) is context-dependent (a policy ->
1690        // repo -> constant inheritance chain), so no schemars `default` is
1691        // emitted for THIS field — the reference stays `—`.
1692        let crd = SnapshotPolicy::crd();
1693        let json = serde_json::to_value(&crd).unwrap();
1694        let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1695            ["properties"]["adoption"];
1696        assert!(
1697            prop.get("default").is_none(),
1698            "spec.adoption must NOT carry a schema default: {prop}"
1699        );
1700        assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
1701    }
1702
1703    #[test]
1704    fn adoption_summary_status_roundtrips() {
1705        let status: SnapshotPolicyStatus = from_yaml(
1706            "adoption:\n  \
1707             lastAdoptionAt: 2026-06-09T02:00:00Z\n  \
1708             lastAdoptedCount: 3\n  \
1709             totalAdopted: 42\n  \
1710             scanRequestedAt: 2026-06-10T00:00:00Z\n  \
1711             scanRequestedIdentity: postgres@billing\n",
1712        );
1713        let a = status.adoption.as_ref().expect("adoption");
1714        assert_eq!(a.last_adoption_at.as_deref(), Some("2026-06-09T02:00:00Z"));
1715        assert_eq!(a.last_adopted_count, Some(3));
1716        assert_eq!(a.total_adopted, Some(42));
1717        assert_eq!(a.scan_requested_at.as_deref(), Some("2026-06-10T00:00:00Z"));
1718        assert_eq!(
1719            a.scan_requested_identity.as_deref(),
1720            Some("postgres@billing")
1721        );
1722
1723        let json = serde_json::to_value(&status).unwrap();
1724        assert_eq!(json["adoption"]["lastAdoptedCount"], 3);
1725        assert_eq!(json["adoption"]["totalAdopted"], 42);
1726        let reparsed: SnapshotPolicyStatus = serde_json::from_value(json).unwrap();
1727        assert_eq!(status, reparsed);
1728
1729        // Absent ⇒ None, elided.
1730        let bare: SnapshotPolicyStatus = from_yaml("{}\n");
1731        assert!(bare.adoption.is_none());
1732        assert!(
1733            serde_json::to_value(&bare)
1734                .unwrap()
1735                .get("adoption")
1736                .is_none(),
1737            "absent adoption summary must be elided"
1738        );
1739    }
1740}