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 Go-style duration; use a positive number with an \
64                     s/m/h suffix (e.g. 90s, 5m, 1h) — how long the restore waits for the \
65                     source snapshot to appear before applying onMissingSnapshot"
66                ),
67            });
68        };
69        // For an object-store `fromPolicy`/`identity` restore the wait is polled
70        // INSIDE the mover Job, so it must fit within an explicit
71        // `activeDeadlineSeconds` (else the Job is killed mid-wait, before
72        // onMissingSnapshot applies). When the deadline is unset the controller's
73        // generous default (hours) always dwarfs a sane waitTimeout, so this only
74        // guards an explicit, too-small deadline. `snapshotRef` waits in the
75        // controller, but the same bound is harmless and keeps the rule simple.
76        if let Some(deadline) = spec
77            .failure_policy
78            .as_ref()
79            .and_then(|f| f.active_deadline_seconds)
80            && deadline > 0
81            && wait.as_secs() as i64 >= deadline
82        {
83            return Err(ValidationError::InvalidFieldValue {
84                field: "restore.policy.waitTimeout".to_string(),
85                reason: format!(
86                    "{wt:?} ({}s) must be shorter than failurePolicy.activeDeadlineSeconds \
87                     ({deadline}s): the wait for the source snapshot is polled inside the \
88                     restore Job, so a waitTimeout at or beyond the deadline would let the \
89                     Job be killed before onMissingSnapshot applies. Lower waitTimeout or \
90                     raise activeDeadlineSeconds.",
91                    wait.as_secs()
92                ),
93            });
94        }
95    }
96    match &spec.target {
97        RestoreTarget::Pvc(t) if t.name.trim().is_empty() => {
98            return Err(ValidationError::MissingRequiredField {
99                field: "restore.target.pvc.name".to_string(),
100            });
101        }
102        // `target.pvc` makes the operator CREATE the PVC, so it must know the
103        // size — a guessed default could be smaller than the restored data.
104        RestoreTarget::Pvc(t) if t.capacity.as_deref().is_none_or(|c| c.trim().is_empty()) => {
105            return Err(ValidationError::MissingRequiredField {
106                field: "restore.target.pvc.capacity".to_string(),
107            });
108        }
109        RestoreTarget::Populator(_) => {
110            // Exhaustive over the inherit variant: the live-pod modes cannot work
111            // (no workload pod exists at provision time), but `snapshot` reads the
112            // backup's RECORDED identity in the controller before the Job — it needs
113            // no live pod, so it is deliberately ALLOWED with a populator target.
114            if let Some(m) = &spec.mover {
115                use crate::common::InheritSecurityContextFrom;
116                match &m.inherit_security_context_from {
117                    None | Some(InheritSecurityContextFrom::Snapshot(_)) => {}
118                    Some(InheritSecurityContextFrom::WorkloadSelector(_))
119                    | Some(InheritSecurityContextFrom::PvcConsumer(_)) => {
120                        return Err(ValidationError::InvalidFieldValue {
121                            field: "restore.mover.inheritSecurityContextFrom".to_string(),
122                            reason: "is not allowed with target.populator: no workload pod \
123                                     exists at provision time to inherit a security context \
124                                     from. Set mover.securityContext explicitly, use \
125                                     inheritSecurityContextFrom: { snapshot: {} } (the \
126                                     backup's recorded identity — needs no live pod), or rely \
127                                     on the repository's moverDefaults instead"
128                                .to_string(),
129                        });
130                    }
131                }
132            }
133        }
134        RestoreTarget::Pvc(_) | RestoreTarget::PvcRef(_) => {}
135    }
136    // Access modes on a create-target PVC: canonical/unique/RWOP-sole. Fail-fast on
137    // the first problem (this validator's contract); the accumulate wrapper reports
138    // the rest. A legacy stored value decodes as `PvcAccessMode::Unknown` (never a
139    // watcher-poisoning serde error) and is rejected HERE, per-CR, with the value
140    // quoted — the controller calls this defensively on every reconcile, so the
141    // rejection reaches the user as a Warning Event + structural backoff.
142    if let RestoreTarget::Pvc(t) = &spec.target
143        && let Some(e) = validate_access_modes("restore.target.pvc.accessModes", &t.access_modes)
144            .into_iter()
145            .next()
146    {
147        return Err(e);
148    }
149    // `pvcConsumer` derives the workload from a *backup source* PVC; a restore has no such
150    // source (it writes a target whose consumer may not exist yet), so it is backup-only.
151    if let Some(m) = &spec.mover {
152        forbid_pvc_consumer(
153            m,
154            "restore",
155            "Use inheritSecurityContextFrom.workloadSelector (the pod that will read the restored \
156             data), or an explicit mover.securityContext, instead.",
157        )?;
158        validate_mover(m, "Restore mover")?;
159    }
160    Ok(())
161}
162
163/// An `asOf` point-in-time selector must be a valid RFC3339 timestamp — the
164/// reconciler parses it with `chrono::DateTime::parse_from_rfc3339`, so the
165/// webhook rejects anything that parser would choke on, with a fix in the message.
166fn validate_as_of(field: &str, as_of: Option<&str>) -> ValidationResult {
167    if let Some(s) = as_of
168        && chrono::DateTime::parse_from_rfc3339(s).is_err()
169    {
170        return Err(ValidationError::InvalidFieldValue {
171            field: field.to_string(),
172            reason: format!(
173                "{s:?} is not an RFC3339 timestamp; use e.g. 2026-05-01T00:00:00Z \
174                 (the newest snapshot at or before this instant is restored)"
175            ),
176        });
177    }
178    Ok(())
179}
180
181/// Validate a `Restore` spec, accumulating all problems (wraps the fail-fast
182/// [`validate_restore`] for caller symmetry).
183pub fn validate_restore_spec(spec: &RestoreSpec) -> Vec<ValidationError> {
184    let mut errs = Vec::new();
185    if let Some(r) = &spec.repository
186        && let Err(e) = validate_repository_ref(r)
187    {
188        errs.push(e);
189    }
190    if let Err(e) = validate_restore(spec) {
191        errs.push(e);
192    }
193    if let Some(m) = &spec.mover
194        && let Err(e) = validate_mover(m, "Restore mover")
195    {
196        errs.push(e);
197    }
198    if let Some(fp) = &spec.failure_policy
199        && let Err(e) = validate_failure_policy(fp, "Restore")
200    {
201        errs.push(e);
202    }
203    if let Some(o) = &spec.options
204        && let Some(p) = o.parallel
205        && let Some(e) = require_min(
206            "Restore spec.options.parallel",
207            p.into(),
208            NumericBound::Count,
209        )
210    {
211        errs.push(e);
212    }
213    errs
214}