kopiur_api/validate/mod.rs
1//! Cross-field validation the type system can't express (ADR §2.2 principle 8).
2//!
3//! These are the rules a single struct's types can't enforce: "field X is
4//! forbidden only when sibling Y has a particular variant," "this string must
5//! parse as a cron," "a discovered backup may only Retain." They live here as pure
6//! functions so the **webhook calls them at admission and the controller calls them
7//! defensively** — one validator, two callers (SKILL hard-rule 4). No `kube::Client`,
8//! no `tokio`.
9//!
10//! ## Fail-fast vs. accumulate (see [`crate::error`])
11//!
12//! Single-rule helpers return [`ValidationResult`] (fail-fast — first problem).
13//! The per-CRD aggregate validators (`validate_backup_config`, …) return
14//! `Vec<ValidationError>` so a user sees every independent problem in one apply.
15//! An empty vec means valid.
16
17use crate::backend::NfsVolume;
18use crate::common::{FailurePolicy, MoverSpec, PvcAccessMode, RepositoryMode};
19use crate::error::{ValidationError, ValidationResult};
20use crate::server::{ServerAuth, ServerSpec};
21use crate::snapshot_policy::Source;
22use k8s_openapi::api::core::v1::ResourceRequirements;
23use kube_quantity::ParsedQuantity;
24
25mod backend;
26mod identity;
27mod repository;
28mod restore;
29mod snapshot;
30
31pub use backend::*;
32pub use identity::*;
33pub use repository::*;
34pub use restore::*;
35pub use snapshot::*;
36
37/// A single backup `Source` is well-formed: **exactly one** of `pvc`,
38/// `pvcSelector`, or `nfs` is set (ADR §3.3 — modeled as sibling Options because
39/// the forms share `sourcePath*` keys, so it's a webhook check, not an enum). When
40/// the source is `nfs`, its server/path are also validated.
41pub fn validate_source(source: &Source) -> ValidationResult {
42 let set: Vec<&str> = [
43 ("pvc", source.pvc.is_some()),
44 ("pvcSelector", source.pvc_selector.is_some()),
45 ("nfs", source.nfs.is_some()),
46 ]
47 .into_iter()
48 .filter_map(|(name, present)| present.then_some(name))
49 .collect();
50
51 match set.as_slice() {
52 [] => Err(ValidationError::MissingRequiredField {
53 field: "source.pvc, source.pvcSelector, or source.nfs".to_string(),
54 }),
55 [first, second, ..] => Err(ValidationError::MutuallyExclusive {
56 a: (*first).to_string(),
57 b: (*second).to_string(),
58 context: "snapshot source".to_string(),
59 }),
60 [_only] => match &source.nfs {
61 Some(nfs) => {
62 // `readOnly: false` exists for exactly one purpose — letting the kubelet
63 // apply `fsGroup` to the source — and the kubelet does not apply `fsGroup`
64 // to in-tree NFS volumes at all. So on NFS it buys nothing and only makes
65 // the export writable to the mover. Reject it rather than ship a knob that
66 // silently does the opposite of what its user wants.
67 if !crate::snapshot_policy::source_read_only(source) {
68 return Err(ValidationError::InvalidFieldValue {
69 field: "spec.sources[].readOnly".to_string(),
70 reason: "readOnly: false is not supported on an nfs source: the kubelet \
71 does not apply fsGroup to in-tree NFS volumes, so a read-write \
72 mount grants the mover no additional readability and only \
73 exposes the export to writes. Remove readOnly (NFS is read \
74 directly), and grant access with mover.podSecurityContext \
75 supplementalGroups / mover.securityContext runAsUser matching \
76 the export's ownership, or with a server-side ID remap"
77 .to_string(),
78 });
79 }
80 validate_nfs_volume(nfs, "snapshot source")
81 }
82 None => Ok(()),
83 },
84 }
85}
86
87/// An inline [`NfsVolume`] is well-formed: a non-empty server and an absolute
88/// export path. The structural schema can't express either, so the webhook does.
89/// `context` names where it appears (e.g. `"snapshot source"`, `"filesystem repo"`)
90/// for an actionable message.
91pub fn validate_nfs_volume(nfs: &NfsVolume, context: &str) -> ValidationResult {
92 if nfs.server.trim().is_empty() {
93 return Err(ValidationError::MissingRequiredField {
94 field: format!("{context} nfs.server"),
95 });
96 }
97 if !nfs.path.starts_with('/') {
98 return Err(ValidationError::InvalidFieldValue {
99 field: format!("{context} nfs.path"),
100 reason: format!(
101 "must be an absolute export path beginning with '/' (got {:?})",
102 nfs.path
103 ),
104 });
105 }
106 Ok(())
107}
108
109/// Validate a PVC access-modes list wherever one appears (`spec.staging.accessModes`,
110/// `restore.target.pvc.accessModes`). Three rules, one place, both callers (webhook
111/// at admission, controller defensively):
112///
113/// * every entry must be canonical — an [`PvcAccessMode::Unknown`] value is either
114/// a legacy stored string from before schema enforcement or a typo, and no PVC
115/// could ever be provisioned from it;
116/// * no duplicates;
117/// * `ReadWriteOncePod` must be the **sole** mode — the apiserver rejects the
118/// combination at PVC-create time, so catching it here fails at admission with
119/// the reason instead of wedging the first run in a create-retry loop.
120///
121/// `field` names the exact path for the message. Accumulates so every bad entry is
122/// reported in one apply.
123pub fn validate_access_modes(field: &str, modes: &[PvcAccessMode]) -> Vec<ValidationError> {
124 let mut errs = Vec::new();
125 let mut seen = std::collections::BTreeSet::new();
126 for (i, mode) in modes.iter().enumerate() {
127 if let PvcAccessMode::Unknown(value) = mode {
128 errs.push(ValidationError::InvalidFieldValue {
129 field: format!("{field}[{i}]"),
130 reason: format!(
131 "{value:?} is not a Kubernetes access mode (valid: {}). No PVC can be \
132 provisioned from it — if this value was stored before kopiur enforced \
133 the schema, it was already broken then; edit the resource to one of the \
134 valid modes.",
135 PvcAccessMode::CANONICAL.join(", ")
136 ),
137 });
138 }
139 if !seen.insert(mode.mode_str().to_string()) {
140 errs.push(ValidationError::InvalidFieldValue {
141 field: format!("{field}[{i}]"),
142 reason: format!(
143 "duplicate access mode {:?}; list each mode at most once",
144 mode.mode_str()
145 ),
146 });
147 }
148 }
149 if modes.len() > 1
150 && modes
151 .iter()
152 .any(|m| matches!(m, PvcAccessMode::ReadWriteOncePod))
153 {
154 errs.push(ValidationError::InvalidFieldValue {
155 field: field.to_string(),
156 reason: "ReadWriteOncePod may not be combined with other access modes — the \
157 apiserver rejects such a PVC at create time, so the run would wedge in \
158 a retry loop instead of failing here. Use ReadWriteOncePod alone, or \
159 drop it."
160 .to_string(),
161 });
162 }
163 errs
164}
165
166/// A numeric knob must be at least `min` — the shared one-liner behind every
167/// `Option<u32>` count / `Option<i64>` bytes-per-second field (e.g.
168/// `RepositoryReplication.spec.sync.parallel`), so the rule and its message
169/// shape are written once instead of re-derived per field. `field` names the
170/// exact path for the message (e.g. `"RepositoryReplication spec.sync.parallel"`).
171/// Callers only invoke this for a `Some` value — an absent knob is always valid
172/// and never reaches this helper.
173pub fn require_min(field: &str, value: i64, min: i64) -> Option<ValidationError> {
174 (value < min).then(|| ValidationError::InvalidFieldValue {
175 field: field.to_string(),
176 reason: format!("must be >= {min} (got {value})"),
177 })
178}
179
180/// Validate a `MoverSpec`. `context` names the owning resource for the message (e.g.
181/// `"Restore mover"`).
182///
183/// `inheritSecurityContextFrom` and the explicit `securityContext`/`podSecurityContext` are
184/// **compatible**, not mutually exclusive: they are adjacent layers of the merge ladder
185/// (`hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit`), so the explicit context overrides the
186/// inherited one field-wise and fills whatever the workload does not pin — and stands in alone
187/// when inheritance cannot resolve a pod.
188///
189/// This pair used to be rejected here, on the rationale that "the mover's effective contexts
190/// must have a single, unambiguous source so the privileged-mover gate runs on exactly one".
191/// That rationale was never true: the gate has always evaluated the *merged* product of
192/// hardened + `moverDefaults` + recipe (see the callers of
193/// [`crate::common::requires_privilege_resolved`]), which
194/// [`crate::invariants::enforce_security_context_invariants`] normalizes first — INV-1 exists
195/// precisely to reconcile an inherited `runAsUser: 0` against the hardened `runAsNonRoot:
196/// true`. Merging one more layer in cannot smuggle an elevated mover past it.
197pub fn validate_mover(mover: &MoverSpec, context: &str) -> ValidationResult {
198 if let Some(resources) = &mover.resources {
199 validate_resources(resources, context)?;
200 }
201 Ok(())
202}
203
204/// The first resource key whose `requests` value exceeds its `limits` value (both present
205/// and parseable), as `(key, request, limit)`. Quantity comparison uses `kube_quantity`'s
206/// `ParsedQuantity` (the same `k8s-openapi` `Quantity` type the cluster uses), so
207/// `"1Gi" > "512Mi"` is compared correctly across binary/SI/milli suffixes. **Best-effort:**
208/// a key whose quantity fails to parse is skipped, never a false rejection.
209fn requests_exceeding_limits(resources: &ResourceRequirements) -> Option<(String, String, String)> {
210 let (Some(requests), Some(limits)) = (resources.requests.as_ref(), resources.limits.as_ref())
211 else {
212 return None;
213 };
214 for (key, req) in requests {
215 let Some(lim) = limits.get(key) else { continue };
216 let (Ok(req_p), Ok(lim_p)) = (ParsedQuantity::try_from(req), ParsedQuantity::try_from(lim))
217 else {
218 continue;
219 };
220 if req_p > lim_p {
221 return Some((key.clone(), req.0.clone(), lim.0.clone()));
222 }
223 }
224 None
225}
226
227/// Validate that a `ResourceRequirements` has no `requests > limits` for any key. A pod with
228/// `requests > limits` is **rejected by the API server**, so the mover Job never creates a
229/// pod and the run hangs — the same silent-wedge class as an impossible securityContext.
230/// `context` names the owner (e.g. `"SnapshotPolicy mover"`).
231pub fn validate_resources(resources: &ResourceRequirements, context: &str) -> ValidationResult {
232 if let Some((key, req, lim)) = requests_exceeding_limits(resources) {
233 return Err(ValidationError::InvalidFieldValue {
234 field: format!("{context} resources.requests.{key}"),
235 reason: format!(
236 "request `{req}` exceeds limit `{lim}`; the API server rejects a pod whose \
237 requests exceed its limits, so the mover Job would never create a pod (it hangs \
238 instead of failing). Lower the request or raise the limit."
239 ),
240 });
241 }
242 Ok(())
243}
244
245/// Validate a [`FailurePolicy`]'s numeric fields are sane: `activeDeadlineSeconds` and
246/// `podStartupDeadlineSeconds` must be positive (the kubelet rejects a non-positive Job
247/// deadline, and a non-positive grace would fail every pod on its first reconcile);
248/// `backoffLimit` must be non-negative. `context` names the owner (e.g. `"Snapshot"`).
249pub fn validate_failure_policy(fp: &FailurePolicy, context: &str) -> ValidationResult {
250 if let Some(d) = fp.active_deadline_seconds
251 && d <= 0
252 {
253 return Err(ValidationError::InvalidFieldValue {
254 field: format!("{context} failurePolicy.activeDeadlineSeconds"),
255 reason: format!("must be a positive number of seconds (got {d})"),
256 });
257 }
258 if let Some(g) = fp.pod_startup_deadline_seconds
259 && g <= 0
260 {
261 return Err(ValidationError::InvalidFieldValue {
262 field: format!("{context} failurePolicy.podStartupDeadlineSeconds"),
263 reason: format!(
264 "must be a positive number of seconds (got {g}); it bounds how long a \
265 non-starting mover pod is tolerated before the run fails"
266 ),
267 });
268 }
269 if let Some(b) = fp.backoff_limit
270 && b < 0
271 {
272 return Err(ValidationError::InvalidFieldValue {
273 field: format!("{context} failurePolicy.backoffLimit"),
274 reason: format!("must be >= 0 (got {b})"),
275 });
276 }
277 Ok(())
278}
279
280/// A cron expression parses with the same parser the controller uses at runtime, so
281/// bad expressions are rejected at apply time, not at first reconcile (ADR §4.1).
282///
283/// `croner` 2.x does not implement Jenkins-style `H`. Since kopiur resolves `H`
284/// deterministically in [`crate::jitter::substitute_h`] (not in the parser), we
285/// substitute every `H` field with the fixed placeholder `0` purely to validate the
286/// expression's *shape* here. The real `H` spread is produced at scheduling time.
287///
288/// ```
289/// use kopiur_api::validate::validate_cron;
290/// use kopiur_api::ValidationError;
291///
292/// // Valid 5-field crons pass — including Jenkins-style `H` (resolved later).
293/// assert!(validate_cron("0 2 * * *").is_ok());
294/// assert!(validate_cron("H 2 * * *").is_ok());
295///
296/// // Garbage is rejected at apply time, not at first reconcile (ADR §4.1).
297/// assert!(matches!(
298/// validate_cron("not a cron"),
299/// Err(ValidationError::InvalidCron { .. }),
300/// ));
301/// ```
302pub fn validate_cron(expr: &str) -> ValidationResult {
303 let probe = expr
304 .split_whitespace()
305 .map(|f| if f == "H" { "0" } else { f })
306 .collect::<Vec<_>>()
307 .join(" ");
308 match crate::jitter::cron_parser().parse(&probe) {
309 Ok(_) => Ok(()),
310 Err(e) => Err(ValidationError::InvalidCron {
311 expr: expr.to_string(),
312 reason: e.to_string(),
313 }),
314 }
315}
316
317/// A non-blocking admission WARNING (never a rejection) when a schedule's cron
318/// fires more often than hourly (issue #249). Every fire creates one per-run
319/// `Snapshot` CR per source, and they accumulate up to the `SnapshotPolicy`
320/// retention window — each terminal one is then re-reconciled for that whole
321/// window — so a sub-hourly cadence with a wide (or absent) retention can produce
322/// thousands of CRs. Sub-hourly is legitimate for some workloads, so this is a
323/// footgun heads-up, not a block.
324///
325/// Pure and cron-only: the schedule webhook is client-less and can't read the
326/// referenced policy's retention, so the message states the cadence and the
327/// CR-count relationship rather than an exact number. `None` for an hourly-or-slower
328/// cadence, or a cron that doesn't parse (that error is surfaced by [`validate_cron`]).
329pub fn schedule_cr_growth_warning(cron: &str) -> Option<String> {
330 let fires = schedule_fires_per_hour(cron)?;
331 if fires <= 1 {
332 return None;
333 }
334 Some(format!(
335 "schedule fires ~{fires}×/hour: each fire creates one Snapshot CR per source, and \
336 they accumulate up to the SnapshotPolicy retention window (CR count ≈ fires × \
337 retained snapshots). A sub-hourly schedule with a wide or absent retention can \
338 produce thousands of Snapshot CRs, each re-reconciled for its whole retention \
339 window. If unintended, use a coarser schedule, bound SnapshotPolicy.spec.retention, \
340 or set the Snapshot deletionPolicy to Retain/Orphan. See docs/backups.md \
341 ('How many Snapshot CRs will I have?')."
342 ))
343}
344
345/// Count how many times `cron` fires within one representative active hour. Pure and
346/// clock-free — anchored on the cron's FIRST fire from a fixed instant (not a fixed
347/// calendar hour), so a day-of-week / day-of-month constrained cron is measured
348/// during an hour it is actually active. `H` tokens are substituted to a fixed value
349/// first (they pick a minute within the window, not the cadence, so the count is
350/// H-independent). `None` if the cron doesn't parse.
351fn schedule_fires_per_hour(cron: &str) -> Option<u32> {
352 use chrono::{Duration, TimeZone, Utc};
353 let probe = cron
354 .split_whitespace()
355 .map(|f| if f == "H" { "0" } else { f })
356 .collect::<Vec<_>>()
357 .join(" ");
358 let parsed = crate::jitter::cron_parser().parse(&probe).ok()?;
359 let anchor = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single()?;
360 let first = parsed.find_next_occurrence(&anchor, true).ok()?;
361 let horizon = first + Duration::hours(1);
362 let mut cursor = first;
363 let mut count = 0u32;
364 // The cron grammar has no seconds field, so at most 60 fires/hour; cap defensively.
365 for _ in 0..61 {
366 let next = parsed.find_next_occurrence(&cursor, true).ok()?;
367 if next >= horizon {
368 break;
369 }
370 count += 1;
371 cursor = next + Duration::seconds(1);
372 }
373 Some(count)
374}
375
376/// Validate an optional Go-style `jitter` duration (`30m`, `1h`, …) against the SAME
377/// parser the controller uses at scheduling time, so a typo or an out-of-range value
378/// is rejected at apply time rather than silently degrading to *no jitter* at the
379/// next reconcile (`parse_go_duration` returns `None`, which the schedule treats as a
380/// zero offset). `None` (no jitter) is always valid. `field` names the path for the
381/// error message (e.g. `spec.schedule.jitter`).
382pub fn validate_jitter(field: &str, jitter: Option<&str>) -> ValidationResult {
383 if let Some(j) = jitter
384 && crate::duration::parse_go_duration(j).is_none()
385 {
386 return Err(ValidationError::InvalidFieldValue {
387 field: field.to_string(),
388 reason: format!(
389 "{j:?} is not a valid duration. Use a Go-style duration like 30s, 5m, or 1h"
390 ),
391 });
392 }
393 Ok(())
394}
395
396/// Validate an optional IANA timezone name against the same `chrono-tz` database the
397/// controller uses at scheduling time, so a typo (e.g. `America/Chicgo`) is rejected at
398/// apply time rather than silently resolving to UTC at the next reconcile. `None` (use
399/// the controller default) is always valid.
400///
401/// ```
402/// use kopiur_api::validate::validate_timezone;
403///
404/// assert!(validate_timezone(None).is_ok());
405/// assert!(validate_timezone(Some("America/Chicago")).is_ok());
406/// assert!(validate_timezone(Some("UTC")).is_ok());
407/// assert!(validate_timezone(Some("America/Chicgo")).is_err());
408/// ```
409pub fn validate_timezone(name: Option<&str>) -> ValidationResult {
410 match name {
411 None => Ok(()),
412 Some(tz) if tz.parse::<chrono_tz::Tz>().is_ok() => Ok(()),
413 Some(tz) => Err(ValidationError::InvalidTimezone {
414 name: tz.to_string(),
415 }),
416 }
417}
418
419/// The shared `spec.server` rules the type system can't express (server addendum):
420/// * `auth.insecure` requires `acknowledgeInsecure: true` — a no-auth server exposes
421/// full read/read of the repository, so it must be explicit.
422/// * `service.port` must be non-zero.
423/// * `readOnly: false` is contradictory on a `mode: ReadOnly` repository — a ReadOnly
424/// repo can never serve a writable UI, so the explicit denial is rejected (omitting
425/// the field is fine; the mode forces read-only).
426///
427/// `mode` is the parent repository's [`RepositoryMode`] (both callers have it). Accumulates
428/// so a user sees every server problem at once. The PVC `ReadWriteMany` requirement for
429/// filesystem-backend servers is **not** here — it needs a live PVC read and is enforced
430/// at reconcile, not admission.
431pub fn validate_server(server: &ServerSpec, mode: RepositoryMode) -> Vec<ValidationError> {
432 let mut errs = Vec::new();
433 if let Some(ServerAuth::Insecure(ack)) = &server.auth
434 && !ack.acknowledge_insecure
435 {
436 errs.push(ValidationError::InsecureServerNotAcknowledged);
437 }
438 if let Some(service) = &server.service
439 && service.port == Some(0)
440 {
441 errs.push(ValidationError::InvalidServerPort { port: 0 });
442 }
443 if server.read_only == Some(false) && !mode.allows_writes() {
444 errs.push(ValidationError::InvalidFieldValue {
445 field: "server.readOnly".to_string(),
446 reason: "a Repository with spec.mode: ReadOnly cannot serve a read-write UI; remove \
447 server.readOnly (the ReadOnly mode forces the UI read-only) or set the \
448 repository's spec.mode: ReadWrite"
449 .to_string(),
450 });
451 }
452 if let Some(res) = &server.resources
453 && let Err(e) = validate_resources(res, "server")
454 {
455 errs.push(e);
456 }
457 errs
458}
459
460/// `inheritSecurityContextFrom.pvcConsumer` derives the mover identity from a **backup
461/// source** PVC's consumer; a kind that has no backup source (Restore, Maintenance) must
462/// reject it at admission rather than fail at runtime. `field_prefix` names the owning kind
463/// (e.g. `"restore"`/`"maintenance"`), `instead` is the kind-appropriate remedy.
464pub(crate) fn forbid_pvc_consumer(
465 mover: &MoverSpec,
466 field_prefix: &str,
467 instead: &str,
468) -> ValidationResult {
469 if matches!(
470 mover.inherit_security_context_from,
471 Some(crate::common::InheritSecurityContextFrom::PvcConsumer(_))
472 ) {
473 return Err(ValidationError::InvalidFieldValue {
474 field: format!("{field_prefix}.mover.inheritSecurityContextFrom.pvcConsumer"),
475 reason: format!(
476 "is only valid for a backup source — there is no source PVC here to derive a \
477 workload from. {instead}"
478 ),
479 });
480 }
481 Ok(())
482}
483
484/// `inheritSecurityContextFrom.snapshot` reproduces the identity RECORDED on a backup
485/// (`Snapshot.status.recorded`), so it only makes sense where a snapshot is being consumed —
486/// a `Restore`. A backup's identity comes from the live workload and maintenance touches no
487/// snapshot at all, so those kinds reject the variant at admission rather than fail (or,
488/// worse, silently no-op) at runtime. `field_prefix` names the owning kind (e.g.
489/// `"snapshotPolicy"`/`"maintenance"`), `reason` is the kind-appropriate what/why/fix.
490pub(crate) fn forbid_snapshot_inherit(
491 mover: &MoverSpec,
492 field_prefix: &str,
493 reason: &str,
494) -> ValidationResult {
495 if matches!(
496 mover.inherit_security_context_from,
497 Some(crate::common::InheritSecurityContextFrom::Snapshot(_))
498 ) {
499 return Err(ValidationError::InvalidFieldValue {
500 field: format!("{field_prefix}.mover.inheritSecurityContextFrom.snapshot"),
501 reason: reason.to_string(),
502 });
503 }
504 Ok(())
505}
506
507/// Reject `inheritSecurityContextFrom` **entirely**, for a kind whose reconciler never resolves
508/// it. Stronger than [`forbid_pvc_consumer`]: that rejects one variant on a kind that *does*
509/// honor the other, this rejects the whole field on a kind that honors none of it.
510///
511/// Accepting a field and then ignoring it is the failure mode this repo exists to design out —
512/// the manifest says the mover runs as the workload, the mover runs as something else, and
513/// nothing says otherwise. If a kind cannot honor it, admission must say so.
514pub(crate) fn forbid_inherit(
515 mover: &MoverSpec,
516 field_prefix: &str,
517 reason: &str,
518) -> ValidationResult {
519 if mover.inherit_security_context_from.is_some() {
520 return Err(ValidationError::InvalidFieldValue {
521 field: format!("{field_prefix}.mover.inheritSecurityContextFrom"),
522 reason: reason.to_string(),
523 });
524 }
525 Ok(())
526}
527
528#[cfg(test)]
529mod tests;