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