kopiur_api/validate/
restore.rs1use super::*;
2use crate::error::{ValidationError, ValidationResult};
3use crate::restore::{RestoreSource, RestoreSpec, RestoreTarget};
4
5pub fn validate_restore(spec: &RestoreSpec) -> ValidationResult {
17 if matches!(spec.source, RestoreSource::Identity(_)) && spec.repository.is_none() {
20 return Err(ValidationError::RestoreSourceRepositoryRequired);
21 }
22 match &spec.source {
26 RestoreSource::SnapshotRef(_) => {}
27 RestoreSource::FromPolicy(c) => {
28 validate_as_of("restore.source.fromPolicy.asOf", c.as_of.as_deref())?;
29 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 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 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 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 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 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 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
163fn 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
181pub 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}