Skip to main content

kopiur_api/validate/
restore.rs

1use super::*;
2use crate::error::{ValidationError, ValidationResult};
3use crate::restore::{RestoreSource, RestoreSpec, RestoreTarget};
4
5/// A `Restore` spec is internally consistent (ADR §3.6/§4.6 / ADR-0005 §9).
6///
7/// The externally-tagged `RestoreSource`/`RestoreTarget` enums already guarantee
8/// **exactly one** variant — that is a compile-time/serde invariant, not re-checked
9/// here (a `Restore` with no `target` now fails to deserialize entirely, ADR-0005 §9).
10/// We validate the cross-field rules the enums can't express:
11/// - `source.identity` requires `spec.repository` (nothing else can derive it).
12/// - if `target: pvc`, the template must name the PVC (`name` non-empty).
13/// - `target: populator` forbids `mover.inheritSecurityContextFrom`: no workload pod
14///   exists at provision time to inherit from (ADR-0005 §9 / ADR §4.7) — point the
15///   user at `moverDefaults` / an explicit `securityContext` instead.
16pub fn validate_restore(spec: &RestoreSpec) -> ValidationResult {
17    // Exactly-one-variant on `source`/`target` is guaranteed by the enums; see
18    // RestoreSource / RestoreTarget (both required, externally tagged).
19    if matches!(spec.source, RestoreSource::Identity(_)) && spec.repository.is_none() {
20        return Err(ValidationError::RestoreSourceRepositoryRequired);
21    }
22    // `asOf` / `waitTimeout` are parsed at reconcile time with the SAME parsers
23    // used here, so a value the webhook admits can never fail to parse later.
24    // Exhaustive over the source so a new variant must declare its rules.
25    match &spec.source {
26        RestoreSource::SnapshotRef(_) => {}
27        RestoreSource::FromPolicy(c) => {
28            validate_as_of("restore.source.fromPolicy.asOf", c.as_of.as_deref())?;
29            // The per-PVC source-path override (#443). Same shape check as
30            // `IdentitySource::sourcePath` and as the resolved path the identity
31            // kernel emits, so a value the webhook admits can never be rejected
32            // later by `resolve_identity`'s own `validate_source_path`.
33            if let Some(p) = c.source_path.as_deref() {
34                crate::validate::validate_source_path("restore.source.fromPolicy.sourcePath", p)?;
35            }
36        }
37        RestoreSource::Identity(i) => {
38            validate_as_of("restore.source.identity.asOf", i.as_of.as_deref())?;
39            // `snapshotID` pins an exact snapshot — combining it with the
40            // relative selectors would silently ignore one of them.
41            if i.snapshot_id.is_some() && i.as_of.is_some() {
42                return Err(ValidationError::MutuallyExclusive {
43                    a: "source.identity.snapshotID".to_string(),
44                    b: "source.identity.asOf".to_string(),
45                    context: "snapshotID pins an exact snapshot; asOf selects by time".to_string(),
46                });
47            }
48            if i.snapshot_id.is_some() && i.offset.is_some_and(|o| o != 0) {
49                return Err(ValidationError::MutuallyExclusive {
50                    a: "source.identity.snapshotID".to_string(),
51                    b: "source.identity.offset".to_string(),
52                    context: "snapshotID pins an exact snapshot; offset selects by position"
53                        .to_string(),
54                });
55            }
56        }
57    }
58    if let Some(wt) = spec.policy.as_ref().and_then(|p| p.wait_timeout.as_deref()) {
59        let Some(wait) = crate::duration::parse_go_duration(wt) else {
60            return Err(ValidationError::InvalidFieldValue {
61                field: "restore.policy.waitTimeout".to_string(),
62                reason: format!(
63                    "{wt:?} is not a valid duration. Fix: use a Go-style duration like 90s, 5m, \
64                     or 1h"
65                ),
66            });
67        };
68        // For an object-store `fromPolicy`/`identity` restore the wait is polled
69        // INSIDE the mover Job, so it must fit within an explicit
70        // `activeDeadlineSeconds` (else the Job is killed mid-wait, before
71        // onMissingSnapshot applies). When the deadline is unset the controller's
72        // generous default (hours) always dwarfs a sane waitTimeout, so this only
73        // guards an explicit, too-small deadline. `snapshotRef` waits in the
74        // controller, but the same bound is harmless and keeps the rule simple.
75        if let Some(deadline) = spec
76            .failure_policy
77            .as_ref()
78            .and_then(|f| f.active_deadline_seconds)
79            && deadline > 0
80            && wait.as_secs() as i64 >= deadline
81        {
82            return Err(ValidationError::InvalidFieldValue {
83                field: "restore.policy.waitTimeout".to_string(),
84                reason: format!(
85                    "{wt:?} ({}s) must be shorter than failurePolicy.activeDeadlineSeconds \
86                     ({deadline}s), or the restore Job is killed before onMissingSnapshot \
87                     applies. Fix: lower waitTimeout or raise activeDeadlineSeconds",
88                    wait.as_secs()
89                ),
90            });
91        }
92    }
93    match &spec.target {
94        RestoreTarget::Pvc(t) if t.name.trim().is_empty() => {
95            return Err(ValidationError::MissingRequiredField {
96                field: "restore.target.pvc.name".to_string(),
97            });
98        }
99        // `target.pvc` makes the operator CREATE the PVC, so it must know the
100        // size — a guessed default could be smaller than the restored data.
101        RestoreTarget::Pvc(t) if t.capacity.as_deref().is_none_or(|c| c.trim().is_empty()) => {
102            return Err(ValidationError::MissingRequiredField {
103                field: "restore.target.pvc.capacity".to_string(),
104            });
105        }
106        RestoreTarget::Populator(_) => {
107            // Exhaustive over the inherit variant: the live-pod modes cannot work
108            // (no workload pod exists at provision time), but `snapshot` reads the
109            // backup's RECORDED identity in the controller before the Job — it needs
110            // no live pod, so it is deliberately ALLOWED with a populator target.
111            if let Some(m) = &spec.mover {
112                use crate::common::InheritSecurityContextFrom;
113                match &m.inherit_security_context_from {
114                    None | Some(InheritSecurityContextFrom::Snapshot(_)) => {}
115                    Some(InheritSecurityContextFrom::WorkloadSelector(_))
116                    | Some(InheritSecurityContextFrom::PvcConsumer(_)) => {
117                        return Err(ValidationError::InvalidFieldValue {
118                            field: "restore.mover.inheritSecurityContextFrom".to_string(),
119                            reason: "is not allowed with target.populator (no workload pod \
120                                     exists yet). Fix: use inheritSecurityContextFrom: \
121                                     { snapshot: {} }, or set mover.securityContext explicitly"
122                                .to_string(),
123                        });
124                    }
125                }
126            }
127        }
128        // A stream target writes no PVC: it pipes ONE virtual file out of the
129        // snapshot into a command's stdin. Validate the same things the backup-side
130        // stream source validates, with the same words.
131        RestoreTarget::StreamExec(t) => {
132            crate::validate::validate_stream_file_name(
133                "restore.target.streamExec.fileName",
134                &t.file_name,
135            )?;
136            crate::validate::validate_stream_exec(
137                "restore.target.streamExec.workloadExec",
138                &t.workload_exec,
139            )?;
140        }
141        RestoreTarget::Pvc(_) | RestoreTarget::PvcRef(_) => {}
142    }
143    // Access modes on a create-target PVC: canonical/unique/RWOP-sole. Fail-fast on
144    // the first problem (this validator's contract); the accumulate wrapper reports
145    // the rest. A legacy stored value decodes as `PvcAccessMode::Unknown` (never a
146    // watcher-poisoning serde error) and is rejected HERE, per-CR, with the value
147    // quoted — the controller calls this defensively on every reconcile, so the
148    // rejection reaches the user as a Warning Event + structural backoff.
149    if let RestoreTarget::Pvc(t) = &spec.target
150        && let Some(e) = validate_access_modes("restore.target.pvc.accessModes", &t.access_modes)
151            .into_iter()
152            .next()
153    {
154        return Err(e);
155    }
156    // `pvcConsumer` derives the workload from a *backup source* PVC; a restore has no such
157    // source (it writes a target whose consumer may not exist yet), so it is backup-only.
158    if let Some(m) = &spec.mover {
159        forbid_pvc_consumer(
160            m,
161            "restore",
162            "Fix: use inheritSecurityContextFrom.workloadSelector, or set \
163             mover.securityContext explicitly",
164        )?;
165        validate_mover(m, "Restore mover")?;
166    }
167    Ok(())
168}
169
170/// An `asOf` point-in-time selector must be a valid RFC3339 timestamp — the
171/// reconciler parses it with `chrono::DateTime::parse_from_rfc3339`, so the
172/// webhook rejects anything that parser would choke on, with a fix in the message.
173fn validate_as_of(field: &str, as_of: Option<&str>) -> ValidationResult {
174    if let Some(s) = as_of
175        && chrono::DateTime::parse_from_rfc3339(s).is_err()
176    {
177        return Err(ValidationError::InvalidFieldValue {
178            field: field.to_string(),
179            reason: format!(
180                "{s:?} is not an RFC3339 timestamp. Fix: use e.g. 2026-05-01T00:00:00Z"
181            ),
182        });
183    }
184    Ok(())
185}
186
187/// Validate a `Restore` spec, accumulating all problems (wraps the fail-fast
188/// [`validate_restore`] for caller symmetry).
189pub fn validate_restore_spec(spec: &RestoreSpec) -> Vec<ValidationError> {
190    let mut errs = Vec::new();
191    if let Some(r) = &spec.repository
192        && let Err(e) = validate_repository_ref(r)
193    {
194        errs.push(e);
195    }
196    if let Err(e) = validate_restore(spec) {
197        errs.push(e);
198    }
199    if let Some(m) = &spec.mover
200        && let Err(e) = validate_mover(m, "Restore mover")
201    {
202        errs.push(e);
203    }
204    if let Some(fp) = &spec.failure_policy
205        && let Err(e) = validate_failure_policy(fp, "Restore")
206    {
207        errs.push(e);
208    }
209    if let Some(o) = &spec.options
210        && let Some(p) = o.parallel
211        && let Some(e) = require_min(
212            "Restore spec.options.parallel",
213            p.into(),
214            NumericBound::Count,
215        )
216    {
217        errs.push(e);
218    }
219    errs
220}