Skip to main content

kopiur_api/
restore.rs

1//! The `Restore` CRD — a restore from a snapshot/identity to a PVC, or a passive
2//! populator source. ADR-0001 §3.6, ADR-0003 §4.6.
3
4use crate::common::{
5    CredentialProjection, FailurePolicy, MoverSpec, ObjectRef, PvcAccessMode, RepositoryRef,
6    ResolvedIdentity,
7};
8use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
9use kube::CustomResource;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// A restore operation from a snapshot/identity into a PVC, or a passive populator source.
14#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
15#[kube(
16    group = "kopiur.home-operations.com",
17    version = "v1alpha1",
18    kind = "Restore",
19    namespaced,
20    status = "RestoreStatus",
21    shortname = "kopiarestore",
22    category = "kopiur",
23    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
24    printcolumn = r#"{"name":"Source","type":"string","jsonPath":".status.sourceKind"}"#,
25    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
26)]
27// §15: operator-authored CEL in the CRD schema — exactly one of
28// target.pvc/target.pvcRef/target.populator. Validates in the apiserver + CI
29// (`kubeconform`), complementing the webhook. `target` is required so `has(self.target)`
30// is always true; the rule counts the present sub-keys.
31#[schemars(extend("x-kubernetes-validations" = [{
32    "rule": "[has(self.target.pvc), has(self.target.pvcRef), has(self.target.populator)].filter(x, x).size() == 1",
33    "message": "exactly one of target.pvc, target.pvcRef, target.populator"
34}]))]
35#[serde(rename_all = "camelCase")]
36/// Desired state of a Restore: where to read from, where to write to, and how to behave when the snapshot is missing.
37pub struct RestoreSpec {
38    /// The repository to read from; derived from `source` when omitted, required only with `source.identity`.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub repository: Option<RepositoryRef>,
41    /// Where to read data from (snapshotRef, fromPolicy, or identity).
42    pub source: RestoreSource,
43    /// Where to write the restored data (pvc, pvcRef, or populator).
44    pub target: RestoreTarget,
45    /// kopia restore behavior (file deletion, permission/atomicity handling).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub options: Option<RestoreOptions>,
48    /// What to do when the referenced snapshot doesn't exist yet, and how long to wait.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub policy: Option<RestorePolicy>,
51    /// Opt-in copying of the repository's credential Secret(s) into the mover's namespace (default off).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub credential_projection: Option<CredentialProjection>,
54    /// Per-run mover overrides for this restore's Job (resources, cache, `securityContext`).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub mover: Option<MoverSpec>,
57    /// Mover `Job` retry/deadline limits (`backoffLimit`, `activeDeadlineSeconds`).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub failure_policy: Option<FailurePolicy>,
60}
61
62/// Where to restore from; exactly one variant.
63#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
64#[serde(rename_all = "camelCase")]
65pub enum RestoreSource {
66    /// A `Snapshot` CR (scheduled, manual, or discovered).
67    SnapshotRef(ObjectRef),
68    /// A `SnapshotPolicy` CR, resolved via identity even with no `Snapshot` CR present (deploy-or-restore).
69    FromPolicy(FromPolicy),
70    /// A raw kopia identity (foreign writers / aged-out catalog); requires `spec.repository`.
71    Identity(IdentitySource),
72}
73
74impl RestoreSource {
75    /// Stable discriminant string for status/metrics.
76    ///
77    /// ```
78    /// use kopiur_api::common::ObjectRef;
79    /// use kopiur_api::restore::RestoreSource;
80    ///
81    /// let src = RestoreSource::SnapshotRef(ObjectRef { name: "pg-20260524".into(), namespace: None });
82    /// assert_eq!(src.kind_str(), "SnapshotRef");
83    ///
84    /// // Externally tagged: each variant deserializes under its own camelCase key.
85    /// let from_cfg: RestoreSource =
86    ///     serde_json::from_value(serde_json::json!({ "fromPolicy": { "name": "pg" } })).unwrap();
87    /// assert_eq!(from_cfg.kind_str(), "FromPolicy");
88    /// ```
89    pub fn kind_str(&self) -> &'static str {
90        match self {
91            RestoreSource::SnapshotRef(_) => "SnapshotRef",
92            RestoreSource::FromPolicy(_) => "FromPolicy",
93            RestoreSource::Identity(_) => "Identity",
94        }
95    }
96}
97
98/// The `fromPolicy` source: resolve a snapshot via a `SnapshotPolicy`'s identity, even when no `Snapshot` CR exists yet.
99#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
100#[serde(rename_all = "camelCase")]
101pub struct FromPolicy {
102    /// Name of the `SnapshotPolicy` whose identity selects the snapshot.
103    pub name: String,
104    /// Namespace of the `SnapshotPolicy`; absent = the `Restore`'s own namespace.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub namespace: Option<String>,
107    /// Restore the newest snapshot at or before this RFC3339 timestamp (point-in-time).
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub as_of: Option<String>,
110    /// Which snapshot to pick: 0 = latest, 1 = previous, and so on.
111    #[serde(default = "default_offset")]
112    #[schemars(default = "default_offset")]
113    pub offset: i64,
114}
115
116/// serde/schemars `default` for [`FromPolicy::offset`] — `0`, the latest snapshot
117/// (ADR-0005 §1). A named fn so it backs BOTH `#[serde(default = ...)]` and
118/// `#[schemars(default = ...)]`, which is what makes schemars 1 emit the OpenAPI
119/// `default:` in the generated CRD schema.
120fn default_offset() -> i64 {
121    0
122}
123
124/// The `identity` source: a raw kopia `username@hostname:path` identity; requires `spec.repository`.
125#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
126#[serde(rename_all = "camelCase")]
127pub struct IdentitySource {
128    /// The kopia `username` to match.
129    pub username: String,
130    /// The kopia `hostname` to match.
131    pub hostname: String,
132    /// The kopia source path to match; absent matches any path for the identity.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub source_path: Option<String>,
135    /// Pin an exact kopia snapshot by ID.
136    #[serde(
137        default,
138        rename = "snapshotID",
139        skip_serializing_if = "Option::is_none"
140    )]
141    pub snapshot_id: Option<String>,
142    /// Restore the newest snapshot at or before this RFC3339 timestamp (point-in-time).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub as_of: Option<String>,
145    /// Which snapshot to pick: 0 = latest, 1 = previous, and so on.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub offset: Option<i64>,
148}
149
150/// Where to restore to; exactly one variant.
151#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
152#[serde(rename_all = "camelCase")]
153pub enum RestoreTarget {
154    /// Operator creates the PVC.
155    Pvc(PvcTemplate),
156    /// Write into an existing PVC.
157    PvcRef(ObjectRef),
158    /// Passive populator mode: the restore is claimed by a PVC's `spec.dataSourceRef`.
159    Populator(PopulatorTarget),
160}
161
162impl RestoreTarget {
163    /// Stable discriminant string for status/metrics.
164    ///
165    /// ```
166    /// use kopiur_api::common::ObjectRef;
167    /// use kopiur_api::restore::RestoreTarget;
168    ///
169    /// let into_existing = RestoreTarget::PvcRef(ObjectRef { name: "data".into(), namespace: None });
170    /// assert_eq!(into_existing.kind_str(), "PvcRef");
171    ///
172    /// // Externally tagged: `{ pvc: {...} }` selects the create-PVC variant.
173    /// let created: RestoreTarget =
174    ///     serde_json::from_value(serde_json::json!({ "pvc": { "name": "restored" } })).unwrap();
175    /// assert_eq!(created.kind_str(), "Pvc");
176    /// ```
177    pub fn kind_str(&self) -> &'static str {
178        match self {
179            RestoreTarget::Pvc(_) => "Pvc",
180            RestoreTarget::PvcRef(_) => "PvcRef",
181            RestoreTarget::Populator(_) => "Populator",
182        }
183    }
184}
185
186/// Passive-populator target marker; its presence selects populator mode.
187#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
188#[serde(rename_all = "camelCase")]
189pub struct PopulatorTarget {}
190
191/// Template for a PVC the operator creates as the restore target.
192#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
193#[serde(rename_all = "camelCase")]
194pub struct PvcTemplate {
195    /// Name of the PVC to create.
196    pub name: String,
197    /// StorageClass for the new PVC; absent uses the cluster default.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub storage_class_name: Option<String>,
200    /// Requested size of the new PVC (e.g. `100Gi`).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub capacity: Option<String>,
203    /// Access modes for the new PVC; empty defaults to `[ReadWriteOnce]`. Closed
204    /// enum in the schema; a non-canonical value persisted before enforcement
205    /// decodes as [`PvcAccessMode::Unknown`] and is rejected per-CR with the
206    /// value quoted (webhook + reconciler, via `validate_access_modes`) instead
207    /// of poisoning the typed watcher.
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub access_modes: Vec<PvcAccessMode>,
210}
211
212/// kopia restore behavior knobs (M2 flag sweep). Every `Option` field's `None`
213/// reproduces kopia's own default — an all-`None`, `enableFileDeletion: false`
214/// instance yields the exact same `restore_args` argv produced before these
215/// fields existed. The tri-state booleans map to kopia's `--[no-]flag` grammar
216/// (`Some(true)` → `--flag`, `Some(false)` → `--no-flag`, `None` → omit).
217#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
218#[serde(rename_all = "camelCase")]
219pub struct RestoreOptions {
220    /// Delete files in the target that are not present in the snapshot (exact mirror); off by default.
221    /// Wired to kopia's `--[no-]delete-extra` (previously a silent no-op — see issue #216 gap sweep).
222    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
223    pub enable_file_deletion: bool,
224    /// Continue past permission errors during restore (default true).
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub ignore_permission_errors: Option<bool>,
227    /// Write files atomically via a temp file + rename (default true).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub write_files_atomically: Option<bool>,
230    /// `--parallel`: restore parallelism (kopia default `8`; `1` disables parallelism).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub parallel: Option<u32>,
233    /// `--[no-]write-sparse-files`: attempt to write files sparsely, allocating the
234    /// minimum disk space needed (kopia default `false`).
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub write_sparse_files: Option<bool>,
237    /// `--[no-]skip-owners`: skip restoring file owners (kopia default `false`).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub skip_owners: Option<bool>,
240    /// `--[no-]skip-permissions`: skip restoring file permissions (kopia default `false`).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub skip_permissions: Option<bool>,
243    /// `--[no-]skip-times`: skip restoring file modification times (kopia default `false`).
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub skip_times: Option<bool>,
246    /// `--[no-]overwrite-files`: overwrite existing files in the target (kopia default `true`).
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub overwrite_files: Option<bool>,
249    /// `--[no-]overwrite-directories`: overwrite existing directories in the target
250    /// (kopia default `true`).
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub overwrite_directories: Option<bool>,
253    /// `--[no-]overwrite-symlinks`: overwrite existing symlinks in the target
254    /// (kopia default `true`).
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub overwrite_symlinks: Option<bool>,
257    /// `--[no-]ignore-errors`: ignore all restore errors and continue (kopia default `false`).
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub ignore_errors: Option<bool>,
260    /// `--[no-]skip-existing`: skip files/symlinks that already exist in the target
261    /// (kopia default `false`).
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub skip_existing: Option<bool>,
264}
265
266/// How the restore reacts to a missing snapshot and how long it waits.
267#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
268#[serde(rename_all = "camelCase")]
269pub struct RestorePolicy {
270    /// What to do when the resolved source matches no snapshot (`Fail`/`Continue`).
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub on_missing_snapshot: Option<OnMissingSnapshot>,
273    /// How long to wait for the source snapshot to appear before giving up (e.g. `5m`).
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub wait_timeout: Option<String>,
276}
277
278/// What to do when the resolved source matches no snapshot. Defaults to `Fail`
279/// (fail-closed) so an explicit restore can never silently no-op; choose
280/// `Continue` to provision an empty volume instead (deploy-or-restore).
281#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
282pub enum OnMissingSnapshot {
283    /// Fail-closed; the default for explicit `snapshotRef`/`identity` sources.
284    #[default]
285    Fail,
286    /// Proceed with an empty volume (deploy-or-restore); the default for `fromPolicy`.
287    Continue,
288}
289
290/// Lifecycle phase of a restore.
291#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
292pub enum RestorePhase {
293    /// Admitted but not yet acted on; the default initial phase.
294    #[default]
295    Pending,
296    /// Resolving the source to a concrete snapshot and pinning it to status.
297    Resolving,
298    /// The mover `Job` is actively writing data into the target.
299    Restoring,
300    /// The restore finished successfully.
301    Completed,
302    /// The restore terminally failed; see `conditions` for the reason.
303    Failed,
304}
305
306impl crate::common::PhaseLabel for RestorePhase {
307    const ALL: &'static [Self] = &[
308        Self::Pending,
309        Self::Resolving,
310        Self::Restoring,
311        Self::Completed,
312        Self::Failed,
313    ];
314    fn label(&self) -> &'static str {
315        match self {
316            Self::Pending => "Pending",
317            Self::Resolving => "Resolving",
318            Self::Restoring => "Restoring",
319            Self::Completed => "Completed",
320            Self::Failed => "Failed",
321        }
322    }
323}
324
325/// Observed state of a Restore, written by the controller/mover.
326#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
327#[serde(rename_all = "camelCase")]
328pub struct RestoreStatus {
329    /// Current lifecycle phase.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub phase: Option<RestorePhase>,
332    /// The pinned source kind (`SnapshotRef`/`FromPolicy`/`Identity`); backs the `SOURCE` printer column.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub source_kind: Option<String>,
335    /// `metadata.generation` last reconciled, so stale status is detectable.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub observed_generation: Option<i64>,
338    /// The source resolved and pinned at admission; never re-resolved.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub resolved: Option<ResolvedRestore>,
341    /// Resolved target details (the PVC written to / populator handshake).
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub target: Option<RestoreTargetStatus>,
344    /// Start/end timestamps for the restore run.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub timing: Option<RestoreTiming>,
347    /// Bytes/files restored so far, patched periodically by the mover.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub progress: Option<RestoreProgress>,
350    /// Standard Kubernetes conditions carrying the human-readable status/reason.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub conditions: Vec<Condition>,
353    /// The last lines of the run's output, written by the mover at the terminal transition.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub log_tail: Option<String>,
356    /// Structured terminal-failure detail (kopia error class, stderr tail, retry hint).
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub failure: Option<crate::common::FailureBlock>,
359}
360
361/// Which outcome the source resolution pinned, once and never re-resolved.
362#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
363pub enum ResolutionOutcome {
364    /// The source resolved to a concrete kopia snapshot (see `kopiaSnapshotID`).
365    Snapshot,
366    /// The source matched no snapshot; `Continue` chose an empty (deploy-or-restore) volume.
367    NoSnapshot,
368}
369
370/// The source resolved and pinned at admission, so a restore never silently retargets.
371#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
372#[serde(rename_all = "camelCase")]
373pub struct ResolvedRestore {
374    /// Which outcome the resolution pinned (`Snapshot`/`NoSnapshot`).
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub resolution: Option<ResolutionOutcome>,
377    /// The exact kopia snapshot manifest id the source resolved to; pinned once.
378    #[serde(
379        default,
380        rename = "kopiaSnapshotID",
381        skip_serializing_if = "Option::is_none"
382    )]
383    pub kopia_snapshot_id: Option<String>,
384    /// The concrete `Snapshot` CR the source resolved to, when applicable.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub snapshot_ref: Option<ObjectRef>,
387    /// The repository the snapshot lives in, resolved from the source.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub repository: Option<RepositoryRef>,
390    /// Timestamp at which the source was pinned (RFC3339).
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub pinned_at: Option<String>,
393    /// The resolved kopia identity (`username@hostname:path`) of the snapshot.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub identity: Option<ResolvedIdentity>,
396}
397
398/// Resolved restore target details written to status.
399#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
400#[serde(rename_all = "camelCase")]
401pub struct RestoreTargetStatus {
402    /// Populator handshake (passive / pvc-create modes).
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub pvc_prime: Option<String>,
405    /// The PVC actually written to (created or pre-existing).
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub pvc_ref: Option<ObjectRef>,
408}
409
410/// Start/end timestamps of a restore run.
411#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
412#[serde(rename_all = "camelCase")]
413pub struct RestoreTiming {
414    /// When the mover began restoring (RFC3339).
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub start_time: Option<String>,
417    /// When the restore reached a terminal phase (RFC3339).
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub end_time: Option<String>,
420}
421
422/// Live progress counters patched by the mover during a restore.
423#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
424#[serde(rename_all = "camelCase")]
425pub struct RestoreProgress {
426    /// Total bytes restored so far.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub bytes_restored: Option<i64>,
429    /// Total files restored so far.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub files_restored: Option<i64>,
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::testutil::from_yaml;
438    use kube::core::CustomResourceExt;
439
440    #[test]
441    fn restore_crd_metadata_is_correct() {
442        let crd = Restore::crd();
443        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
444        assert_eq!(crd.spec.names.kind, "Restore");
445        assert_eq!(crd.spec.scope, "Namespaced");
446        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
447    }
448
449    #[test]
450    fn restore_crd_carries_target_xor_x_kubernetes_validation() {
451        // §15: the generated CRD spec schema must carry the operator-authored
452        // x-kubernetes-validations rule (exactly-one-of target.*) at the spec level,
453        // surviving kube's structural-schema rewriter.
454        let crd = Restore::crd();
455        let json = serde_json::to_value(&crd).expect("serialize CRD");
456        let spec_schema =
457            &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
458        let rules = spec_schema["x-kubernetes-validations"]
459            .as_array()
460            .expect("spec.x-kubernetes-validations present");
461        assert!(
462            rules.iter().any(|r| r["rule"]
463                .as_str()
464                .is_some_and(|s| s.contains("target.populator"))),
465            "expected the target XOR rule; got {rules:?}"
466        );
467    }
468
469    #[test]
470    fn from_policy_offset_carries_static_openapi_default_in_crd() {
471        // ADR-0005 §1: source.fromPolicy.offset must carry a real schema `default: 0`.
472        let crd = Restore::crd();
473        let json = serde_json::to_value(&crd).expect("serialize CRD");
474        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
475            ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["offset"]["default"];
476        assert_eq!(
477            default, 0,
478            "fromPolicy.offset must emit `default: 0` in the CRD schema; got {default:?}"
479        );
480    }
481
482    #[test]
483    fn from_policy_offset_defaults_to_zero_when_absent() {
484        let spec: RestoreSpec =
485            from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
486        match &spec.source {
487            RestoreSource::FromPolicy(c) => assert_eq!(c.offset, 0),
488            other => panic!("expected FromPolicy, got {}", other.kind_str()),
489        }
490    }
491
492    #[test]
493    fn restore_backup_ref_roundtrip_matches_adr_shape() {
494        // Mirrors ADR-0001 §3.6 / §5.3.
495        let yaml = r#"
496source:
497  snapshotRef: { name: postgres-data-20260524-021300, namespace: billing }
498target:
499  pvc:
500    name: postgres-data-restored
501    storageClassName: fast-ssd
502    capacity: 100Gi
503    accessModes: [ReadWriteOnce]
504options:
505  enableFileDeletion: false
506  ignorePermissionErrors: true
507  writeFilesAtomically: true
508policy:
509  onMissingSnapshot: Fail
510  waitTimeout: 5m
511"#;
512        let spec: RestoreSpec = from_yaml(yaml);
513        assert_eq!(spec.source.kind_str(), "SnapshotRef");
514        match &spec.source {
515            RestoreSource::SnapshotRef(r) => {
516                assert_eq!(r.name, "postgres-data-20260524-021300");
517                assert_eq!(r.namespace.as_deref(), Some("billing"));
518            }
519            other => panic!("expected SnapshotRef, got {}", other.kind_str()),
520        }
521        let target = &spec.target;
522        assert_eq!(target.kind_str(), "Pvc");
523        match target {
524            RestoreTarget::Pvc(t) => {
525                assert_eq!(t.name, "postgres-data-restored");
526                assert_eq!(t.access_modes, vec![PvcAccessMode::ReadWriteOnce]);
527            }
528            other => panic!("expected Pvc, got {}", other.kind_str()),
529        }
530        assert_eq!(
531            spec.policy.as_ref().unwrap().on_missing_snapshot,
532            Some(OnMissingSnapshot::Fail)
533        );
534
535        let json = serde_json::to_value(&spec).expect("serialize");
536        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
537        assert_eq!(spec, reparsed);
538    }
539
540    #[test]
541    fn restore_pvc_access_modes_render_a_closed_enum_in_the_crd_schema() {
542        // The Vec<String> → Vec<PvcAccessMode> migration must surface in the CRD:
543        // items are a closed string enum (apiserver rejects typos on new writes),
544        // and the legacy-decode `Unknown` variant never appears.
545        let crd = Restore::crd();
546        let json = serde_json::to_value(&crd).expect("serialize CRD");
547        let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
548            ["properties"]["target"]["properties"]["pvc"]["properties"]["accessModes"]["items"];
549        assert_eq!(
550            items["type"], "string",
551            "items must be strings; got {items}"
552        );
553        assert_eq!(
554            items["enum"],
555            serde_json::json!([
556                "ReadWriteOnce",
557                "ReadOnlyMany",
558                "ReadWriteMany",
559                "ReadWriteOncePod"
560            ]),
561            "items enum must be exactly the canonical modes; got {items}"
562        );
563    }
564
565    #[test]
566    fn restore_legacy_access_mode_decodes_to_unknown_not_an_error() {
567        // A pre-enforcement stored Restore with a bogus mode must still
568        // deserialize (a serde error would poison the typed watch stream for the
569        // whole Kind) — the value lands in `Unknown`, verbatim, and the shared
570        // validator rejects it per-CR with the value quoted.
571        let spec: RestoreSpec = from_yaml(
572            "source: { snapshotRef: { name: b } }\n\
573             target: { pvc: { name: restored, capacity: 10Gi, accessModes: [ReadWriteOnze] } }\n",
574        );
575        match &spec.target {
576            RestoreTarget::Pvc(t) => assert_eq!(
577                t.access_modes,
578                vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
579            ),
580            other => panic!("expected Pvc, got {}", other.kind_str()),
581        }
582        // And it re-serializes unchanged (read-modify-write never mutates it).
583        let json = serde_json::to_value(&spec).unwrap();
584        assert_eq!(
585            json["target"]["pvc"]["accessModes"],
586            serde_json::json!(["ReadWriteOnze"])
587        );
588    }
589
590    #[test]
591    fn restore_options_full_flag_sweep_roundtrip() {
592        // M2 flag sweep: every new `options` knob round-trips through the cluster's
593        // YAML → serde_json::Value → typed path.
594        let yaml = r#"
595source: { snapshotRef: { name: b } }
596target: { pvcRef: { name: d } }
597options:
598  enableFileDeletion: true
599  ignorePermissionErrors: false
600  writeFilesAtomically: true
601  parallel: 4
602  writeSparseFiles: true
603  skipOwners: true
604  skipPermissions: false
605  skipTimes: true
606  overwriteFiles: false
607  overwriteDirectories: false
608  overwriteSymlinks: true
609  ignoreErrors: false
610  skipExisting: true
611"#;
612        let spec: RestoreSpec = from_yaml(yaml);
613        let o = spec.options.as_ref().expect("options set");
614        assert!(o.enable_file_deletion);
615        assert_eq!(o.ignore_permission_errors, Some(false));
616        assert_eq!(o.write_files_atomically, Some(true));
617        assert_eq!(o.parallel, Some(4));
618        assert_eq!(o.write_sparse_files, Some(true));
619        assert_eq!(o.skip_owners, Some(true));
620        assert_eq!(o.skip_permissions, Some(false));
621        assert_eq!(o.skip_times, Some(true));
622        assert_eq!(o.overwrite_files, Some(false));
623        assert_eq!(o.overwrite_directories, Some(false));
624        assert_eq!(o.overwrite_symlinks, Some(true));
625        assert_eq!(o.ignore_errors, Some(false));
626        assert_eq!(o.skip_existing, Some(true));
627
628        let json = serde_json::to_value(&spec).expect("serialize");
629        assert_eq!(json["options"]["parallel"], 4);
630        assert_eq!(json["options"]["skipExisting"], true);
631        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
632        assert_eq!(spec, reparsed);
633    }
634
635    #[test]
636    fn restore_options_omits_unset_leaf_fields() {
637        // A minimal `options` block that only sets `enableFileDeletion` must not
638        // serialize the other (unset) knobs.
639        let yaml = r#"
640source: { snapshotRef: { name: b } }
641target: { pvcRef: { name: d } }
642options:
643  enableFileDeletion: true
644"#;
645        let spec: RestoreSpec = from_yaml(yaml);
646        let json = serde_json::to_value(&spec).unwrap();
647        let opts_json = &json["options"];
648        assert_eq!(opts_json["enableFileDeletion"], true);
649        for key in [
650            "ignorePermissionErrors",
651            "writeFilesAtomically",
652            "parallel",
653            "writeSparseFiles",
654            "skipOwners",
655            "skipPermissions",
656            "skipTimes",
657            "overwriteFiles",
658            "overwriteDirectories",
659            "overwriteSymlinks",
660            "ignoreErrors",
661            "skipExisting",
662        ] {
663            assert!(opts_json.get(key).is_none(), "{key} should be absent");
664        }
665    }
666
667    #[test]
668    fn restore_passive_populator_mode_uses_explicit_populator_target() {
669        // ADR-0005 §9: passive populator mode is now an EXPLICIT `target.populator: {}`
670        // (the empty-`target` form is removed). Mirrors ADR-0001 §5.5
671        // deploy-or-restore: fromPolicy + Continue + populator.
672        let yaml = r#"
673source: { fromPolicy: { name: postgres-data, offset: 0 } }
674target: { populator: {} }
675policy: { onMissingSnapshot: Continue }
676"#;
677        let spec: RestoreSpec = from_yaml(yaml);
678        assert_eq!(spec.source.kind_str(), "FromPolicy");
679        assert_eq!(spec.target.kind_str(), "Populator");
680        assert!(matches!(spec.target, RestoreTarget::Populator(_)));
681        match &spec.source {
682            RestoreSource::FromPolicy(c) => {
683                assert_eq!(c.name, "postgres-data");
684                assert_eq!(c.offset, 0);
685            }
686            other => panic!("expected FromPolicy, got {}", other.kind_str()),
687        }
688
689        // Externally tagged: `{ populator: {} }`.
690        let json = serde_json::to_value(&spec).unwrap();
691        assert!(json["target"]["populator"].is_object());
692        let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
693        assert_eq!(spec, reparsed);
694    }
695
696    #[test]
697    fn restore_without_target_fails_to_deserialize() {
698        // ADR-0005 §9 breaking change: a Restore with no `target` is invalid.
699        let value: serde_json::Value =
700            serde_yaml::from_str("source: { snapshotRef: { name: b } }\n").unwrap();
701        assert!(
702            serde_json::from_value::<RestoreSpec>(value).is_err(),
703            "an absent target must be rejected (no empty-target form, ADR-0005 §9)"
704        );
705    }
706
707    #[test]
708    fn restore_populator_rejects_inherit_security_context() {
709        // ADR-0005 §9: inheritSecurityContextFrom is meaningless with a populator
710        // target (no workload pod exists at provision time) — the validator rejects it.
711        use crate::common::{InheritSecurityContextFrom, MoverSpec, PodSelector};
712        use crate::validate::validate_restore;
713        use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
714        let spec = RestoreSpec {
715            repository: None,
716            source: RestoreSource::FromPolicy(FromPolicy {
717                name: "pg".into(),
718                namespace: None,
719                as_of: None,
720                offset: 0,
721            }),
722            target: RestoreTarget::Populator(PopulatorTarget {}),
723            options: None,
724            policy: None,
725            credential_projection: None,
726            mover: Some(MoverSpec {
727                inherit_security_context_from: Some(InheritSecurityContextFrom::WorkloadSelector(
728                    PodSelector {
729                        pod_selector: LabelSelector::default(),
730                        container: None,
731                    },
732                )),
733                ..Default::default()
734            }),
735            failure_policy: None,
736        };
737        assert!(matches!(
738            validate_restore(&spec),
739            Err(crate::error::ValidationError::InvalidFieldValue { .. })
740        ));
741
742        // The same populator target WITHOUT inherit is fine.
743        let ok = RestoreSpec {
744            mover: None,
745            ..spec
746        };
747        assert!(validate_restore(&ok).is_ok());
748    }
749
750    #[test]
751    fn restore_identity_source_requires_repository_in_practice() {
752        // The `identity` source variant; spec.repository is webhook-required (not type-required).
753        let yaml = r#"
754repository: { kind: Repository, name: nas-primary, namespace: backups }
755source:
756  identity:
757    username: postgres-data
758    hostname: billing
759    sourcePath: /data
760    snapshotID: k1f1ec0a8
761target:
762  pvcRef: { name: postgres-data-restored }
763"#;
764        let spec: RestoreSpec = from_yaml(yaml);
765        assert_eq!(spec.source.kind_str(), "Identity");
766        assert!(spec.repository.is_some());
767        match &spec.source {
768            RestoreSource::Identity(i) => {
769                assert_eq!(i.username, "postgres-data");
770                assert_eq!(i.snapshot_id.as_deref(), Some("k1f1ec0a8"));
771            }
772            other => panic!("expected Identity, got {}", other.kind_str()),
773        }
774        assert_eq!(spec.target.kind_str(), "PvcRef");
775
776        let json = serde_json::to_value(&spec).unwrap();
777        let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
778        assert_eq!(spec, reparsed);
779    }
780
781    #[test]
782    fn restore_mover_and_failure_policy_roundtrip() {
783        // Restore carries the same mover surface a backup gets (resources +
784        // securityContext for UID/GID match + cache) plus a failurePolicy.
785        let yaml = r#"
786source: { snapshotRef: { name: app-data-backup } }
787target: { pvcRef: { name: app-data-restored } }
788mover:
789  resources:
790    requests: { cpu: 250m, memory: 512Mi }
791    limits: { cpu: "2", memory: 4Gi }
792  cache:
793    capacity: 16Gi
794    storageClassName: fast-ssd
795  securityContext:
796    runAsUser: 1000
797    runAsGroup: 1000
798    runAsNonRoot: true
799    allowPrivilegeEscalation: false
800    capabilities: { drop: ["ALL"] }
801    seccompProfile: { type: RuntimeDefault }
802  podSecurityContext:
803    fsGroup: 1000
804    fsGroupChangePolicy: OnRootMismatch
805failurePolicy:
806  backoffLimit: 4
807  activeDeadlineSeconds: 3600
808"#;
809        let spec: RestoreSpec = from_yaml(yaml);
810        let mover = spec.mover.as_ref().expect("mover");
811        assert!(mover.resources.is_some());
812        assert_eq!(
813            mover.cache.as_ref().and_then(|c| c.capacity.as_deref()),
814            Some("16Gi")
815        );
816        assert_eq!(
817            mover.security_context.as_ref().and_then(|s| s.run_as_user),
818            Some(1000)
819        );
820        // fsGroup is carried on the pod-level securityContext (makes a fresh restore
821        // volume group-writable for an unprivileged mover).
822        assert_eq!(
823            mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
824            Some(1000)
825        );
826        // A hardened non-root container + fsGroup is NOT privileged: the gate lets it run.
827        assert!(!mover.requires_privilege());
828        let fp = spec.failure_policy.as_ref().expect("failurePolicy");
829        assert_eq!(fp.backoff_limit, Some(4));
830        assert_eq!(fp.active_deadline_seconds, Some(3600));
831
832        let json = serde_json::to_value(&spec).expect("serialize");
833        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
834        assert_eq!(spec, reparsed);
835    }
836
837    #[test]
838    fn restore_mover_root_context_is_privileged() {
839        // `runAsUser: 0` on a restore mover trips the same privileged-mover gate as a
840        // backup — the controller refuses it unless the namespace opts in.
841        let yaml = r#"
842source: { snapshotRef: { name: app-data-backup } }
843target: { pvcRef: { name: app-data-restored } }
844mover:
845  securityContext:
846    runAsUser: 0
847    runAsNonRoot: false
848"#;
849        let spec: RestoreSpec = from_yaml(yaml);
850        assert!(spec.mover.as_ref().unwrap().requires_privilege());
851    }
852
853    #[test]
854    fn restore_crd_schema_carries_the_snapshot_inherit_variant() {
855        // `Restore::crd()`/`SnapshotPolicy::crd()` smoke for the new externally-tagged
856        // variant: schema generation must not panic, and the Restore schema must
857        // surface `inheritSecurityContextFrom.snapshot` as an object property (the
858        // SnapshotPolicy schema carries it too — the restriction is webhook-level,
859        // not structural).
860        let crd = Restore::crd();
861        let json = serde_json::to_value(&crd).expect("serialize CRD");
862        let inherit = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
863            ["properties"]["mover"]["properties"]["inheritSecurityContextFrom"]["properties"];
864        assert!(
865            inherit["snapshot"].is_object(),
866            "inheritSecurityContextFrom must carry the `snapshot` variant; got {inherit}"
867        );
868        assert_eq!(inherit["snapshot"]["type"], "object");
869        // The other variants are untouched.
870        assert!(inherit["workloadSelector"].is_object());
871        assert!(inherit["pvcConsumer"].is_object());
872        let _ = crate::SnapshotPolicy::crd();
873    }
874
875    #[test]
876    fn restore_snapshot_inherit_mover_roundtrip() {
877        // The restore-only recorded-identity inherit mode, parsed the cluster's way.
878        use crate::common::{InheritSecurityContextFrom, SnapshotInherit};
879        let yaml = r#"
880source: { snapshotRef: { name: app-data-backup } }
881target: { pvcRef: { name: app-data-restored } }
882mover:
883  inheritSecurityContextFrom:
884    snapshot: {}
885"#;
886        let spec: RestoreSpec = from_yaml(yaml);
887        assert!(matches!(
888            spec.mover
889                .as_ref()
890                .and_then(|m| m.inherit_security_context_from.as_ref()),
891            Some(InheritSecurityContextFrom::Snapshot(SnapshotInherit {})),
892        ));
893        let json = serde_json::to_value(&spec).expect("serialize");
894        assert!(json["mover"]["inheritSecurityContextFrom"]["snapshot"].is_object());
895        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
896        assert_eq!(spec, reparsed);
897    }
898
899    #[test]
900    fn restore_source_unknown_variant_is_rejected() {
901        let value: serde_json::Value = serde_yaml::from_str("snapshotUrl:\n  url: x\n").unwrap();
902        assert!(serde_json::from_value::<RestoreSource>(value).is_err());
903    }
904
905    #[test]
906    fn on_missing_snapshot_and_phase_serialize_to_expected_strings() {
907        assert_eq!(
908            serde_json::to_value(OnMissingSnapshot::Fail).unwrap(),
909            "Fail"
910        );
911        assert_eq!(
912            serde_json::to_value(OnMissingSnapshot::Continue).unwrap(),
913            "Continue"
914        );
915        assert_eq!(
916            serde_json::to_value(RestorePhase::Restoring).unwrap(),
917            "Restoring"
918        );
919        assert_eq!(
920            serde_json::to_value(RestorePhase::Completed).unwrap(),
921            "Completed"
922        );
923    }
924}