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}