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 admission;
26mod backend;
27mod identity;
28mod repository;
29mod restore;
30mod snapshot;
31mod snapshot_replication_overlap;
32
33pub use admission::*;
34pub use backend::*;
35pub use identity::*;
36pub use repository::*;
37pub use restore::*;
38pub use snapshot::*;
39pub use snapshot_replication_overlap::*;
40
41/// Validate a `pvcSelector`'s shape.
42///
43/// Two refusals, both structural rather than stylistic:
44///
45/// * **`namespaceSelector` is rejected outright.** A Pod can only mount
46/// PersistentVolumeClaims in its OWN namespace — that is a Kubernetes
47/// invariant, not a kopiur limitation. The mover Job runs in the `Snapshot`'s
48/// namespace, which is the policy's, so a PVC matched elsewhere could never be
49/// mounted. Accepting the field would mean either failing at reconcile with
50/// "source PVC not found" or, far worse, silently snapshotting a
51/// SAME-NAMED PVC in the policy's own namespace under the matched one's
52/// identity. Use one `SnapshotPolicy` per namespace.
53/// * **An unusable `matchExpressions` entry is rejected** rather than dropped.
54/// The generated schema does not enum-constrain `operator`, so a typo (`in`
55/// for `In`) would otherwise be silently discarded — WIDENING the selector, so
56/// PVCs the user meant to exclude get backed up. An `In`/`NotIn` with no
57/// values renders as `key in ()`, which the API server rejects with a 400 that
58/// would abort the whole schedule fire.
59fn validate_pvc_selector(selector: &crate::snapshot_policy::PvcSelector) -> ValidationResult {
60 if selector
61 .namespace_selector
62 .as_ref()
63 .is_some_and(|n| !n.match_names.is_empty())
64 {
65 return Err(ValidationError::InvalidFieldValue {
66 field: "spec.sources[].pvcSelector.namespaceSelector".to_string(),
67 reason: "a backup's mover Pod can only mount PersistentVolumeClaims in its own \
68 namespace, so a selector cannot reach PVCs in another one. Use one \
69 SnapshotPolicy per namespace (each may point at the same repository)"
70 .to_string(),
71 });
72 }
73 if let Some(ls) = selector.label_selector.as_ref()
74 && let Some(exprs) = ls.match_expressions.as_ref()
75 {
76 for e in exprs {
77 match e.operator.as_str() {
78 "In" | "NotIn" => {
79 if e.values.as_ref().is_none_or(|v| v.is_empty()) {
80 return Err(ValidationError::InvalidFieldValue {
81 field: format!(
82 "spec.sources[].pvcSelector.labelSelector.matchExpressions[{}]",
83 e.key
84 ),
85 reason: format!(
86 "operator `{}` requires at least one value; an empty list \
87 renders as `{} {} ()`, which the API server rejects",
88 e.operator,
89 e.key,
90 e.operator.to_lowercase()
91 ),
92 });
93 }
94 }
95 "Exists" | "DoesNotExist" => {}
96 other => {
97 return Err(ValidationError::InvalidFieldValue {
98 field: format!(
99 "spec.sources[].pvcSelector.labelSelector.matchExpressions[{}].operator",
100 e.key
101 ),
102 reason: format!(
103 "`{other}` is not a label-selector operator (expected In, NotIn, \
104 Exists or DoesNotExist). An unrecognized operator would be dropped, \
105 WIDENING the selector so PVCs you meant to exclude get backed up"
106 ),
107 });
108 }
109 }
110 }
111 }
112 Ok(())
113}
114
115/// A single backup `Source` is well-formed: **exactly one** of `pvc`,
116/// `pvcSelector`, or `nfs` is set (ADR §3.3 — modeled as sibling Options because
117/// the forms share `sourcePath*` keys, so it's a webhook check, not an enum). When
118/// the source is `nfs`, its server/path are also validated.
119pub fn validate_source(source: &Source) -> ValidationResult {
120 let set: Vec<&str> = [
121 ("pvc", source.pvc.is_some()),
122 ("pvcSelector", source.pvc_selector.is_some()),
123 ("nfs", source.nfs.is_some()),
124 ]
125 .into_iter()
126 .filter_map(|(name, present)| present.then_some(name))
127 .collect();
128
129 match set.as_slice() {
130 [] => Err(ValidationError::MissingRequiredField {
131 field: "source.pvc, source.pvcSelector, or source.nfs".to_string(),
132 }),
133 [first, second, ..] => Err(ValidationError::MutuallyExclusive {
134 a: (*first).to_string(),
135 b: (*second).to_string(),
136 context: "snapshot source".to_string(),
137 }),
138 [_only] => match &source.nfs {
139 Some(nfs) => {
140 // `readOnly: false` exists for exactly one purpose — letting the kubelet
141 // apply `fsGroup` to the source — and the kubelet does not apply `fsGroup`
142 // to in-tree NFS volumes at all. So on NFS it buys nothing and only makes
143 // the export writable to the mover. Reject it rather than ship a knob that
144 // silently does the opposite of what its user wants.
145 if !crate::snapshot_policy::source_read_only(source) {
146 return Err(ValidationError::InvalidFieldValue {
147 field: "spec.sources[].readOnly".to_string(),
148 reason: "readOnly: false is not supported on an nfs source: the kubelet \
149 does not apply fsGroup to in-tree NFS volumes, so a read-write \
150 mount grants the mover no additional readability and only \
151 exposes the export to writes. Remove readOnly (NFS is read \
152 directly), and grant access with mover.podSecurityContext \
153 supplementalGroups / mover.securityContext runAsUser matching \
154 the export's ownership, or with a server-side ID remap"
155 .to_string(),
156 });
157 }
158 validate_nfs_volume(nfs, "snapshot source")
159 }
160 None => match source.pvc_selector.as_ref() {
161 Some(selector) => validate_pvc_selector(selector),
162 None => Ok(()),
163 },
164 },
165 }
166}
167
168/// An inline [`NfsVolume`] is well-formed: a non-empty server and an absolute
169/// export path. The structural schema can't express either, so the webhook does.
170/// `context` names where it appears (e.g. `"snapshot source"`, `"filesystem repo"`)
171/// for an actionable message.
172pub fn validate_nfs_volume(nfs: &NfsVolume, context: &str) -> ValidationResult {
173 if nfs.server.trim().is_empty() {
174 return Err(ValidationError::MissingRequiredField {
175 field: format!("{context} nfs.server"),
176 });
177 }
178 if !nfs.path.starts_with('/') {
179 return Err(ValidationError::InvalidFieldValue {
180 field: format!("{context} nfs.path"),
181 reason: format!(
182 "must be an absolute export path beginning with '/' (got {:?})",
183 nfs.path
184 ),
185 });
186 }
187 Ok(())
188}
189
190/// Validate a PVC access-modes list wherever one appears (`spec.staging.accessModes`,
191/// `restore.target.pvc.accessModes`). Three rules, one place, both callers (webhook
192/// at admission, controller defensively):
193///
194/// * every entry must be canonical — an [`PvcAccessMode::Unknown`] value is either
195/// a legacy stored string from before schema enforcement or a typo, and no PVC
196/// could ever be provisioned from it;
197/// * no duplicates;
198/// * `ReadWriteOncePod` must be the **sole** mode — the apiserver rejects the
199/// combination at PVC-create time, so catching it here fails at admission with
200/// the reason instead of wedging the first run in a create-retry loop.
201///
202/// `field` names the exact path for the message. Accumulates so every bad entry is
203/// reported in one apply.
204pub fn validate_access_modes(field: &str, modes: &[PvcAccessMode]) -> Vec<ValidationError> {
205 let mut errs = Vec::new();
206 let mut seen = std::collections::BTreeSet::new();
207 for (i, mode) in modes.iter().enumerate() {
208 if let PvcAccessMode::Unknown(value) = mode {
209 errs.push(ValidationError::InvalidFieldValue {
210 field: format!("{field}[{i}]"),
211 reason: format!(
212 "{value:?} is not a Kubernetes access mode (valid: {}). No PVC can be \
213 provisioned from it — if this value was stored before kopiur enforced \
214 the schema, it was already broken then; edit the resource to one of the \
215 valid modes.",
216 PvcAccessMode::CANONICAL.join(", ")
217 ),
218 });
219 }
220 if !seen.insert(mode.mode_str().to_string()) {
221 errs.push(ValidationError::InvalidFieldValue {
222 field: format!("{field}[{i}]"),
223 reason: format!(
224 "duplicate access mode {:?}; list each mode at most once",
225 mode.mode_str()
226 ),
227 });
228 }
229 }
230 if modes.len() > 1
231 && modes
232 .iter()
233 .any(|m| matches!(m, PvcAccessMode::ReadWriteOncePod))
234 {
235 errs.push(ValidationError::InvalidFieldValue {
236 field: field.to_string(),
237 reason: "ReadWriteOncePod may not be combined with other access modes — the \
238 apiserver rejects such a PVC at create time, so the run would wedge in \
239 a retry loop instead of failing here. Use ReadWriteOncePod alone, or \
240 drop it."
241 .to_string(),
242 });
243 }
244 errs
245}
246
247/// The semantic class of a numeric lower-bound, so [`require_min`] can attach the
248/// right minimum AND a one-line *why* without every caller re-deriving it — the
249/// difference between the old terse `"must be >= 1 (got 0)"` and a message that
250/// says why 0 is refused and what to do instead. Exhaustive `min`/`because`, so a
251/// new bound cannot be added without deciding both (mirrors the type-safety
252/// thesis: a knob's meaning is not free-text).
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum NumericBound {
255 /// A worker/parallelism count (`parallel`, `fileParallelism`, …). 0 does no work.
256 Count,
257 /// A per-second cap (bytes/s or ops/s). kopia reads an ABSENT field as "no limit".
258 RatePerSecond,
259 /// A megabyte size cap (e.g. `upload.limitMb`).
260 Megabytes,
261 /// A number of seconds — a Job/pod deadline.
262 Seconds,
263 /// A retry backoff count, which legitimately allows 0.
264 BackoffCount,
265}
266
267impl NumericBound {
268 /// The inclusive minimum this bound enforces.
269 pub fn min(self) -> i64 {
270 match self {
271 NumericBound::BackoffCount => 0,
272 NumericBound::Count
273 | NumericBound::RatePerSecond
274 | NumericBound::Megabytes
275 | NumericBound::Seconds => 1,
276 }
277 }
278
279 /// Why the minimum exists — the `because` clause of the rejection message.
280 pub fn because(self) -> &'static str {
281 match self {
282 NumericBound::Count => "0 would do no work; set a positive count",
283 NumericBound::RatePerSecond => {
284 "kopia treats an ABSENT field, not 0, as \"no limit\"; 0 would stall the transfer, \
285 so omit the field to run uncapped"
286 }
287 NumericBound::Megabytes => {
288 "0 would cap the upload at nothing; omit the field to leave it uncapped"
289 }
290 NumericBound::Seconds => {
291 "a non-positive deadline is rejected by the kubelet (or fails the pod immediately)"
292 }
293 NumericBound::BackoffCount => "a negative retry budget is meaningless",
294 }
295 }
296}
297
298/// A numeric knob must meet its [`NumericBound`]'s minimum — the shared one-liner
299/// behind every `Option<u32>` count / `Option<i64>` rate field (e.g.
300/// `RepositoryReplication.spec.sync.parallel`), so the rule, its minimum, AND its
301/// rationale are written once instead of re-derived per field. `field` names the
302/// exact path for the message (e.g. `"RepositoryReplication spec.sync.parallel"`).
303/// Callers only invoke this for a `Some` value — an absent knob is always valid
304/// and never reaches this helper.
305pub fn require_min(field: &str, value: i64, bound: NumericBound) -> Option<ValidationError> {
306 let min = bound.min();
307 (value < min).then(|| ValidationError::InvalidFieldValue {
308 field: field.to_string(),
309 reason: crate::message::Diagnostic::new(format!("must be at least {min} (got {value})"))
310 .because(bound.because())
311 .to_string(),
312 })
313}
314
315/// Validate every SET knob of a [`Throttle`](crate::common::Throttle): a cap is a
316/// positive rate, so each present field must be >= 1. `field` names the block's
317/// path for the message (e.g. `"SnapshotReplication spec.migrate.throttle.source"`),
318/// and each knob's own camelCase name is appended.
319///
320/// A `0` is refused rather than read as "unlimited": kopia's own "no limit" is the
321/// ABSENT field, so accepting `0` would give one wire value two plausible meanings
322/// (uncapped vs. stalled) — and the one it actually has in kopia is a hard stop.
323/// Exhaustively destructured, so a fifth knob cannot be added without deciding its
324/// bound here.
325pub fn validate_throttle(field: &str, throttle: &crate::common::Throttle) -> Vec<ValidationError> {
326 let crate::common::Throttle {
327 upload_bytes_per_second,
328 download_bytes_per_second,
329 read_ops_per_second,
330 write_ops_per_second,
331 } = throttle;
332 [
333 ("uploadBytesPerSecond", upload_bytes_per_second),
334 ("downloadBytesPerSecond", download_bytes_per_second),
335 ("readOpsPerSecond", read_ops_per_second),
336 ("writeOpsPerSecond", write_ops_per_second),
337 ]
338 .into_iter()
339 .filter_map(|(knob, value)| {
340 value.and_then(|v| require_min(&format!("{field}.{knob}"), v, NumericBound::RatePerSecond))
341 })
342 .collect()
343}
344
345/// Validate a `MoverSpec`. `context` names the owning resource for the message (e.g.
346/// `"Restore mover"`).
347///
348/// `inheritSecurityContextFrom` and the explicit `securityContext`/`podSecurityContext` are
349/// **compatible**, not mutually exclusive: they are adjacent layers of the merge ladder
350/// (`hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit`), so the explicit context overrides the
351/// inherited one field-wise and fills whatever the workload does not pin — and stands in alone
352/// when inheritance cannot resolve a pod.
353///
354/// This pair used to be rejected here, on the rationale that "the mover's effective contexts
355/// must have a single, unambiguous source so the privileged-mover gate runs on exactly one".
356/// That rationale was never true: the gate has always evaluated the *merged* product of
357/// hardened + `moverDefaults` + recipe (see the callers of
358/// [`crate::common::requires_privilege_resolved`]), which
359/// [`crate::invariants::enforce_security_context_invariants`] normalizes first — INV-1 exists
360/// precisely to reconcile an inherited `runAsUser: 0` against the hardened `runAsNonRoot:
361/// true`. Merging one more layer in cannot smuggle an elevated mover past it.
362pub fn validate_mover(mover: &MoverSpec, context: &str) -> ValidationResult {
363 if let Some(resources) = &mover.resources {
364 validate_resources(resources, context)?;
365 }
366 Ok(())
367}
368
369/// The first resource key whose `requests` value exceeds its `limits` value (both present
370/// and parseable), as `(key, request, limit)`. Quantity comparison uses `kube_quantity`'s
371/// `ParsedQuantity` (the same `k8s-openapi` `Quantity` type the cluster uses), so
372/// `"1Gi" > "512Mi"` is compared correctly across binary/SI/milli suffixes. **Best-effort:**
373/// a key whose quantity fails to parse is skipped, never a false rejection.
374fn requests_exceeding_limits(resources: &ResourceRequirements) -> Option<(String, String, String)> {
375 let (Some(requests), Some(limits)) = (resources.requests.as_ref(), resources.limits.as_ref())
376 else {
377 return None;
378 };
379 for (key, req) in requests {
380 let Some(lim) = limits.get(key) else { continue };
381 let (Ok(req_p), Ok(lim_p)) = (ParsedQuantity::try_from(req), ParsedQuantity::try_from(lim))
382 else {
383 continue;
384 };
385 if req_p > lim_p {
386 return Some((key.clone(), req.0.clone(), lim.0.clone()));
387 }
388 }
389 None
390}
391
392/// Validate that a `ResourceRequirements` has no `requests > limits` for any key. A pod with
393/// `requests > limits` is **rejected by the API server**, so the mover Job never creates a
394/// pod and the run hangs — the same silent-wedge class as an impossible securityContext.
395/// `context` names the owner (e.g. `"SnapshotPolicy mover"`).
396pub fn validate_resources(resources: &ResourceRequirements, context: &str) -> ValidationResult {
397 if let Some((key, req, lim)) = requests_exceeding_limits(resources) {
398 return Err(ValidationError::InvalidFieldValue {
399 field: format!("{context} resources.requests.{key}"),
400 reason: format!(
401 "request `{req}` exceeds limit `{lim}`; the API server rejects a pod whose \
402 requests exceed its limits, so the mover Job would never create a pod (it hangs \
403 instead of failing). Lower the request or raise the limit."
404 ),
405 });
406 }
407 Ok(())
408}
409
410/// Validate a [`FailurePolicy`]'s numeric fields are sane: `activeDeadlineSeconds` and
411/// `podStartupDeadlineSeconds` must be positive (the kubelet rejects a non-positive Job
412/// deadline, and a non-positive grace would fail every pod on its first reconcile);
413/// `backoffLimit` must be non-negative. `context` names the owner (e.g. `"Snapshot"`).
414pub fn validate_failure_policy(fp: &FailurePolicy, context: &str) -> ValidationResult {
415 if let Some(d) = fp.active_deadline_seconds
416 && let Some(e) = require_min(
417 &format!("{context} failurePolicy.activeDeadlineSeconds"),
418 d,
419 NumericBound::Seconds,
420 )
421 {
422 return Err(e);
423 }
424 if let Some(g) = fp.pod_startup_deadline_seconds
425 && g <= 0
426 {
427 // Bespoke `because`: this deadline has a meaning worth naming beyond the
428 // generic Seconds rationale.
429 return Err(ValidationError::InvalidFieldValue {
430 field: format!("{context} failurePolicy.podStartupDeadlineSeconds"),
431 reason: crate::message::Diagnostic::new(format!("must be at least 1 second (got {g})"))
432 .because(
433 "it bounds how long a non-starting mover pod is tolerated before the run fails",
434 )
435 .to_string(),
436 });
437 }
438 if let Some(b) = fp.backoff_limit
439 && let Some(e) = require_min(
440 &format!("{context} failurePolicy.backoffLimit"),
441 b.into(),
442 NumericBound::BackoffCount,
443 )
444 {
445 return Err(e);
446 }
447 Ok(())
448}
449
450/// A cron expression parses with the same parser the controller uses at runtime, so
451/// bad expressions are rejected at apply time, not at first reconcile (ADR §4.1).
452///
453/// `croner` 2.x does not implement Jenkins-style `H`. Since kopiur resolves `H`
454/// deterministically in [`crate::jitter::substitute_h`] (not in the parser), we
455/// substitute every `H` field with the fixed placeholder `0` purely to validate the
456/// expression's *shape* here. The real `H` spread is produced at scheduling time.
457///
458/// ```
459/// use kopiur_api::validate::validate_cron;
460/// use kopiur_api::ValidationError;
461///
462/// // Valid 5-field crons pass — including Jenkins-style `H` (resolved later).
463/// assert!(validate_cron("0 2 * * *").is_ok());
464/// assert!(validate_cron("H 2 * * *").is_ok());
465///
466/// // Garbage is rejected at apply time, not at first reconcile (ADR §4.1).
467/// assert!(matches!(
468/// validate_cron("not a cron"),
469/// Err(ValidationError::InvalidCron { .. }),
470/// ));
471/// ```
472pub fn validate_cron(expr: &str) -> ValidationResult {
473 let probe = expr
474 .split_whitespace()
475 .map(|f| if f == "H" { "0" } else { f })
476 .collect::<Vec<_>>()
477 .join(" ");
478 match crate::jitter::cron_parser().parse(&probe) {
479 Ok(_) => Ok(()),
480 Err(e) => Err(ValidationError::InvalidCron {
481 expr: expr.to_string(),
482 reason: e.to_string(),
483 }),
484 }
485}
486
487/// A non-blocking admission WARNING (never a rejection) when a schedule's cron
488/// fires more often than hourly (issue #249). Every fire creates one per-run
489/// `Snapshot` CR per source, and they accumulate up to the `SnapshotPolicy`
490/// retention window — each terminal one is then re-reconciled for that whole
491/// window — so a sub-hourly cadence with a wide (or absent) retention can produce
492/// thousands of CRs. Sub-hourly is legitimate for some workloads, so this is a
493/// footgun heads-up, not a block.
494///
495/// Pure and cron-only: the schedule webhook is client-less and can't read the
496/// referenced policy's retention, so the message states the cadence and the
497/// CR-count relationship rather than an exact number. `None` for an hourly-or-slower
498/// cadence, or a cron that doesn't parse (that error is surfaced by [`validate_cron`]).
499pub fn schedule_cr_growth_warning(cron: &str) -> Option<String> {
500 let fires = schedule_fires_per_hour(cron)?;
501 if fires <= 1 {
502 return None;
503 }
504 Some(format!(
505 "schedule fires ~{fires}×/hour: each fire creates one Snapshot CR per source, and \
506 they accumulate up to the SnapshotPolicy retention window (CR count ≈ fires × \
507 retained snapshots). A sub-hourly schedule with a wide or absent retention can \
508 produce thousands of Snapshot CRs, each re-reconciled for its whole retention \
509 window. If unintended, use a coarser schedule, bound SnapshotPolicy.spec.retention, \
510 or set the Snapshot deletionPolicy to Retain/Orphan. See docs/backups.md \
511 ('How many Snapshot CRs will I have?')."
512 ))
513}
514
515/// Count how many times `cron` fires within one representative active hour. Pure and
516/// clock-free — anchored on the cron's FIRST fire from a fixed instant (not a fixed
517/// calendar hour), so a day-of-week / day-of-month constrained cron is measured
518/// during an hour it is actually active. `H` tokens are substituted to a fixed value
519/// first (they pick a minute within the window, not the cadence, so the count is
520/// H-independent). `None` if the cron doesn't parse.
521fn schedule_fires_per_hour(cron: &str) -> Option<u32> {
522 use chrono::{Duration, TimeZone, Utc};
523 let probe = cron
524 .split_whitespace()
525 .map(|f| if f == "H" { "0" } else { f })
526 .collect::<Vec<_>>()
527 .join(" ");
528 let parsed = crate::jitter::cron_parser().parse(&probe).ok()?;
529 let anchor = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single()?;
530 let first = parsed.find_next_occurrence(&anchor, true).ok()?;
531 let horizon = first + Duration::hours(1);
532 let mut cursor = first;
533 let mut count = 0u32;
534 // The cron grammar has no seconds field, so at most 60 fires/hour; cap defensively.
535 for _ in 0..61 {
536 let next = parsed.find_next_occurrence(&cursor, true).ok()?;
537 if next >= horizon {
538 break;
539 }
540 count += 1;
541 cursor = next + Duration::seconds(1);
542 }
543 Some(count)
544}
545
546/// Validate an optional Go-style `jitter` duration (`30m`, `1h`, …) against the SAME
547/// parser the controller uses at scheduling time, so a typo or an out-of-range value
548/// is rejected at apply time rather than silently degrading to *no jitter* at the
549/// next reconcile (`parse_go_duration` returns `None`, which the schedule treats as a
550/// zero offset). `None` (no jitter) is always valid. `field` names the path for the
551/// error message (e.g. `spec.schedule.jitter`).
552pub fn validate_jitter(field: &str, jitter: Option<&str>) -> ValidationResult {
553 if let Some(j) = jitter
554 && crate::duration::parse_go_duration(j).is_none()
555 {
556 return Err(ValidationError::InvalidFieldValue {
557 field: field.to_string(),
558 reason: format!(
559 "{j:?} is not a valid duration. Use a Go-style duration like 30s, 5m, or 1h"
560 ),
561 });
562 }
563 Ok(())
564}
565
566/// The largest jitter window any cron may carry: 24 hours.
567///
568/// Jitter is a deterministic spread WITHIN a cron period, not an offset that moves
569/// the schedule. Beyond a day the spread exceeds every cadence kopiur schedules
570/// (the coarsest built-in default is a daily full maintenance), so a larger value
571/// is always a misunderstanding — typically an attempt to say "run 30h after the
572/// slot", which jitter cannot express.
573pub const MAX_JITTER: std::time::Duration = std::time::Duration::from_secs(86_400);
574
575/// Validate a **present** Go-style `jitter` duration: it must parse (same parser
576/// [`validate_jitter`] and the scheduler use) AND must not exceed [`MAX_JITTER`].
577///
578/// The bounds half is a TIGHTENING of an existing rule, so it is admission-only —
579/// the controller re-runs the shared aggregates as a hard stop at reconcile, and
580/// adding this to one of them would brick a stored CR carrying an over-24h jitter
581/// rather than merely refusing the next edit. Call it from the webhook's per-kind
582/// `validate_*_admission_extras`, never from a shared aggregate.
583///
584/// Takes a bare `&str` (not `Option<&str>`) because every caller is already inside
585/// an `if let Some(j)` over the field it is checking.
586///
587/// ```
588/// use kopiur_api::validate::validate_jitter_bounds;
589///
590/// assert!(validate_jitter_bounds("spec.schedule.jitter", "10m").is_ok());
591/// // Exactly the cap is fine; a second past it is not.
592/// assert!(validate_jitter_bounds("spec.schedule.jitter", "24h").is_ok());
593/// assert!(validate_jitter_bounds("spec.schedule.jitter", "86401s").is_err());
594/// // Garbage is rejected by the parse half.
595/// assert!(validate_jitter_bounds("spec.schedule.jitter", "soon").is_err());
596/// ```
597pub fn validate_jitter_bounds(field: &str, jitter: &str) -> ValidationResult {
598 // Parse first, reusing `validate_jitter` so a typo reads identically whichever
599 // validator catches it. Its `None` arm is the only unparseable case, so a bare
600 // `let else` back into that same error keeps this panic-free rather than
601 // leaning on an `expect` that "cannot" fire.
602 let Some(parsed) = crate::duration::parse_go_duration(jitter) else {
603 validate_jitter(field, Some(jitter))?;
604 // Unreachable in practice (validate_jitter rejects exactly what fails to
605 // parse) — but expressed as a value, not a panic: this is backup software.
606 return Ok(());
607 };
608 if parsed > MAX_JITTER {
609 return Err(ValidationError::InvalidFieldValue {
610 field: field.to_string(),
611 reason: format!(
612 "jitter of {jitter} exceeds the 24h maximum — jitter is a per-slot spread \
613 within a cron period, not a schedule offset; use a window smaller than the \
614 cron period (e.g. 10m), or move the offset into the cron expression"
615 ),
616 });
617 }
618 Ok(())
619}
620
621/// Keys a user may not set in `moverDefaults.podLabels`/`podAnnotations`: anything
622/// under kopiur's own domain prefix, plus the exact `app.kubernetes.io/managed-by`
623/// key.
624///
625/// Both are keys the controller stamps itself. Extra pod metadata merges UNDER
626/// kopiur's, so a collision is silently DROPPED at render time — the manifest would
627/// claim a label the pod never carries. Reject it instead of ignoring it (the same
628/// reasoning as the replication mover's `inheritSecurityContextFrom` refusal).
629const RESERVED_POD_METADATA_KEYS: &[&str] = &["app.kubernetes.io/managed-by"];
630
631/// kopiur's own label/annotation domain prefix. Everything under it is
632/// operator-owned wire contract (see [`crate::consts`]).
633const KOPIUR_KEY_PREFIX: &str = "kopiur.home-operations.com/";
634
635/// Validate `moverDefaults.podLabels`/`podAnnotations` key sets, accumulating every
636/// reserved key so a user fixes them all in one apply.
637///
638/// New fields, so this is safe in the shared repository aggregates the controller
639/// re-runs: no stored object can carry a key this rejects.
640pub fn validate_pod_metadata(defaults: &crate::common::MoverDefaults) -> Vec<ValidationError> {
641 let mut errs = Vec::new();
642 for (field, map) in [
643 ("moverDefaults.podLabels", defaults.pod_labels.as_ref()),
644 (
645 "moverDefaults.podAnnotations",
646 defaults.pod_annotations.as_ref(),
647 ),
648 ] {
649 let Some(map) = map else { continue };
650 for key in map.keys() {
651 let reserved = key.starts_with(KOPIUR_KEY_PREFIX)
652 || RESERVED_POD_METADATA_KEYS.contains(&key.as_str());
653 if reserved {
654 errs.push(ValidationError::InvalidFieldValue {
655 field: format!("{field}[{key:?}]"),
656 reason: format!(
657 "{key:?} is reserved by kopiur — the operator stamps this key on every \
658 mover pod and its own value wins the merge, so the value here would be \
659 silently dropped. Use a key outside `{KOPIUR_KEY_PREFIX}` (and not \
660 `app.kubernetes.io/managed-by`)"
661 ),
662 });
663 }
664 }
665 }
666 errs
667}
668
669/// Validate an optional IANA timezone name against the same `chrono-tz` database the
670/// controller uses at scheduling time, so a typo (e.g. `America/Chicgo`) is rejected at
671/// apply time rather than silently resolving to UTC at the next reconcile. `None` (use
672/// the controller default) is always valid.
673///
674/// ```
675/// use kopiur_api::validate::validate_timezone;
676///
677/// assert!(validate_timezone(None).is_ok());
678/// assert!(validate_timezone(Some("America/Chicago")).is_ok());
679/// assert!(validate_timezone(Some("UTC")).is_ok());
680/// assert!(validate_timezone(Some("America/Chicgo")).is_err());
681/// ```
682pub fn validate_timezone(name: Option<&str>) -> ValidationResult {
683 match name {
684 None => Ok(()),
685 Some(tz) if tz.parse::<chrono_tz::Tz>().is_ok() => Ok(()),
686 Some(tz) => Err(ValidationError::InvalidTimezone {
687 name: tz.to_string(),
688 }),
689 }
690}
691
692/// The shared `spec.server` rules the type system can't express (server addendum):
693/// * `auth.insecure` requires `acknowledgeInsecure: true` — a no-auth server exposes
694/// full read/read of the repository, so it must be explicit.
695/// * `service.port` must be non-zero.
696/// * `readOnly: false` is contradictory on a `mode: ReadOnly` repository — a ReadOnly
697/// repo can never serve a writable UI, so the explicit denial is rejected (omitting
698/// the field is fine; the mode forces read-only).
699///
700/// `mode` is the parent repository's [`RepositoryMode`] (both callers have it). Accumulates
701/// so a user sees every server problem at once. The PVC `ReadWriteMany` requirement for
702/// filesystem-backend servers is **not** here — it needs a live PVC read and is enforced
703/// at reconcile, not admission.
704pub fn validate_server(server: &ServerSpec, mode: RepositoryMode) -> Vec<ValidationError> {
705 let mut errs = Vec::new();
706 if let Some(ServerAuth::Insecure(ack)) = &server.auth
707 && !ack.acknowledge_insecure
708 {
709 errs.push(ValidationError::InsecureServerNotAcknowledged);
710 }
711 if let Some(service) = &server.service
712 && service.port == Some(0)
713 {
714 errs.push(ValidationError::InvalidServerPort { port: 0 });
715 }
716 if server.read_only == Some(false) && !mode.allows_writes() {
717 errs.push(ValidationError::InvalidFieldValue {
718 field: "server.readOnly".to_string(),
719 reason: "a Repository with spec.mode: ReadOnly cannot serve a read-write UI; remove \
720 server.readOnly (the ReadOnly mode forces the UI read-only) or set the \
721 repository's spec.mode: ReadWrite"
722 .to_string(),
723 });
724 }
725 if let Some(res) = &server.resources
726 && let Err(e) = validate_resources(res, "server")
727 {
728 errs.push(e);
729 }
730 errs
731}
732
733/// `inheritSecurityContextFrom.pvcConsumer` derives the mover identity from a **backup
734/// source** PVC's consumer; a kind that has no backup source (Restore, Maintenance) must
735/// reject it at admission rather than fail at runtime. `field_prefix` names the owning kind
736/// (e.g. `"restore"`/`"maintenance"`), `instead` is the kind-appropriate remedy.
737pub(crate) fn forbid_pvc_consumer(
738 mover: &MoverSpec,
739 field_prefix: &str,
740 instead: &str,
741) -> ValidationResult {
742 if matches!(
743 mover.inherit_security_context_from,
744 Some(crate::common::InheritSecurityContextFrom::PvcConsumer(_))
745 ) {
746 return Err(ValidationError::InvalidFieldValue {
747 field: format!("{field_prefix}.mover.inheritSecurityContextFrom.pvcConsumer"),
748 reason: format!(
749 "is only valid for a backup source — there is no source PVC here to derive a \
750 workload from. {instead}"
751 ),
752 });
753 }
754 Ok(())
755}
756
757/// `inheritSecurityContextFrom.snapshot` reproduces the identity RECORDED on a backup
758/// (`Snapshot.status.recorded`), so it only makes sense where a snapshot is being consumed —
759/// a `Restore`. A backup's identity comes from the live workload and maintenance touches no
760/// snapshot at all, so those kinds reject the variant at admission rather than fail (or,
761/// worse, silently no-op) at runtime. `field_prefix` names the owning kind (e.g.
762/// `"snapshotPolicy"`/`"maintenance"`), `reason` is the kind-appropriate what/why/fix.
763pub(crate) fn forbid_snapshot_inherit(
764 mover: &MoverSpec,
765 field_prefix: &str,
766 reason: &str,
767) -> ValidationResult {
768 if matches!(
769 mover.inherit_security_context_from,
770 Some(crate::common::InheritSecurityContextFrom::Snapshot(_))
771 ) {
772 return Err(ValidationError::InvalidFieldValue {
773 field: format!("{field_prefix}.mover.inheritSecurityContextFrom.snapshot"),
774 reason: reason.to_string(),
775 });
776 }
777 Ok(())
778}
779
780/// Reject `inheritSecurityContextFrom` **entirely**, for a kind whose reconciler never resolves
781/// it. Stronger than [`forbid_pvc_consumer`]: that rejects one variant on a kind that *does*
782/// honor the other, this rejects the whole field on a kind that honors none of it.
783///
784/// Accepting a field and then ignoring it is the failure mode this repo exists to design out —
785/// the manifest says the mover runs as the workload, the mover runs as something else, and
786/// nothing says otherwise. If a kind cannot honor it, admission must say so.
787pub(crate) fn forbid_inherit(
788 mover: &MoverSpec,
789 field_prefix: &str,
790 reason: &str,
791) -> ValidationResult {
792 if mover.inherit_security_context_from.is_some() {
793 return Err(ValidationError::InvalidFieldValue {
794 field: format!("{field_prefix}.mover.inheritSecurityContextFrom"),
795 reason: reason.to_string(),
796 });
797 }
798 Ok(())
799}
800
801#[cfg(test)]
802mod tests;