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 }
30 RestoreSource::Identity(i) => {
31 validate_as_of("restore.source.identity.asOf", i.as_of.as_deref())?;
32 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 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 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 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 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 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
156fn 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
174pub 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}