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, SourceShape, StreamExec, StreamSource};
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 can only read PVCs in its own namespace. Fix: use one \
68 SnapshotPolicy per namespace (they can share a repository)"
69 .to_string(),
70 });
71 }
72 if let Some(ls) = selector.label_selector.as_ref()
73 && let Some(exprs) = ls.match_expressions.as_ref()
74 {
75 for e in exprs {
76 match e.operator.as_str() {
77 "In" | "NotIn" => {
78 if e.values.as_ref().is_none_or(|v| v.is_empty()) {
79 return Err(ValidationError::InvalidFieldValue {
80 field: format!(
81 "spec.sources[].pvcSelector.labelSelector.matchExpressions[{}]",
82 e.key
83 ),
84 reason: format!(
85 "operator `{}` requires at least one value; an empty list \
86 renders as `{} {} ()`, which the API server rejects",
87 e.operator,
88 e.key,
89 e.operator.to_lowercase()
90 ),
91 });
92 }
93 }
94 "Exists" | "DoesNotExist" => {}
95 other => {
96 return Err(ValidationError::InvalidFieldValue {
97 field: format!(
98 "spec.sources[].pvcSelector.labelSelector.matchExpressions[{}].operator",
99 e.key
100 ),
101 reason: format!(
102 "`{other}` is not a label-selector operator. Fix: use In, NotIn, \
103 Exists or DoesNotExist"
104 ),
105 });
106 }
107 }
108 }
109 }
110 Ok(())
111}
112
113/// A single backup `Source` is well-formed: **exactly one** of `pvc`,
114/// `pvcSelector`, `nfs`, or `stream` is set (ADR §3.3 — modeled as sibling Options
115/// because the forms share `sourcePath*` keys, so it's a webhook check, not an
116/// enum), and that form's own content is valid.
117///
118/// The dispatch is an EXHAUSTIVE match over [`SourceShape`] rather than a chain of
119/// `if let`s: a fifth source form then cannot compile until it is given content
120/// validation here, instead of silently falling through with none.
121pub fn validate_source(source: &Source) -> ValidationResult {
122 match crate::snapshot_policy::source_shape(source)? {
123 SourceShape::Pvc(_) => Ok(()),
124 // `pvcSelector` + `sourcePathOverride` is deliberately NOT refused here. It
125 // is a footgun, but a pure validator cannot see how many PVCs the selector
126 // currently matches, and the two cases differ:
127 //
128 // * ONE matched PVC — a working single-volume policy with a custom path.
129 // The override wins at `kopia_source_path`'s first branch, there is no
130 // path COLLISION for `expand_sources` to refuse, the backup is minted
131 // and real snapshots exist there. Refusing it would park the WHOLE
132 // policy on upgrade (`reconcile_inner` validates first and returns on
133 // the first error), stopping retention pruning, adoption, status and the
134 // repository summary along with verification.
135 // * TWO OR MORE — `expand_sources` refuses the RUN via its path-collision
136 // check (N members on one path would merge their histories into one
137 // stream and prune each other), so no further snapshot is minted for
138 // that source.
139 //
140 // So the broken case IS caught at run time, at the moment it becomes
141 // broken, and the working one keeps working. The loud signal there is the
142 // refused backup — the failed schedule fire — NOT verification: on a policy
143 // that was working, the earlier snapshots still sit at that path, so
144 // verification keeps passing. (It fails only where the path never received
145 // a backup, which the mover now treats as terminal rather than a false
146 // pass, #456.) The `sourcePathOverride` field documentation says so too.
147 SourceShape::PvcSelector(selector) => validate_pvc_selector(selector),
148 SourceShape::Nfs(nfs) => {
149 // `readOnly: false` exists for exactly one purpose — letting the kubelet
150 // apply `fsGroup` to the source — and the kubelet does not apply `fsGroup`
151 // to in-tree NFS volumes at all. So on NFS it buys nothing and only makes
152 // the export writable to the mover. Reject it rather than ship a knob that
153 // silently does the opposite of what its user wants.
154 if !crate::snapshot_policy::source_read_only(source) {
155 return Err(ValidationError::InvalidFieldValue {
156 field: "spec.sources[].readOnly".to_string(),
157 reason: "readOnly: false is not supported on an nfs source (fsGroup does \
158 not apply to NFS). Fix: remove readOnly, and grant access with \
159 mover.podSecurityContext supplementalGroups or \
160 mover.securityContext runAsUser matching the export's owner"
161 .to_string(),
162 });
163 }
164 validate_nfs_volume(nfs, "snapshot source")
165 }
166 SourceShape::Stream(stream) => validate_stream_source(source, stream),
167 }
168}
169
170/// A `stream` source is well-formed: a safe single-segment `fileName`, a runnable
171/// `command`, a non-empty `podSelector`, a parseable `timeout`, and none of the
172/// PVC-only knobs that cannot mean anything without a mounted volume.
173fn validate_stream_source(source: &Source, stream: &StreamSource) -> ValidationResult {
174 validate_stream_file_name("spec.sources[].stream.fileName", &stream.file_name)?;
175 validate_stream_exec("spec.sources[].stream.workloadExec", &stream.workload_exec)?;
176
177 // PVC-only knobs. A stream source mounts nothing, so `readOnly` has no volume to
178 // apply to, `acknowledgeLiveMutation` acknowledges a mutation that cannot happen,
179 // and `sourcePathStrategy` derives a path from a PVC that does not exist.
180 //
181 // CRITICALLY, two of these carry a SCHEMA DEFAULT (`readOnly: true`,
182 // `sourcePathStrategy: PvcName`), which the API server materializes onto every
183 // source before admission ever sees it. So `is_some()` is NOT a signal that the
184 // user wrote the field — on a stream source it is always true, and checking it
185 // rejects every valid policy. Reject only the values that would actually MEAN
186 // something; a materialized default is inert and indistinguishable from absence.
187 if source.read_only == Some(false) {
188 return Err(ValidationError::InvalidFieldValue {
189 field: "spec.sources[].readOnly".to_string(),
190 reason: "readOnly: false does not apply to a stream source, which mounts no \
191 volume. Fix: remove readOnly"
192 .to_string(),
193 });
194 }
195 if source.acknowledge_live_mutation.is_some() {
196 return Err(ValidationError::InvalidFieldValue {
197 field: "spec.sources[].acknowledgeLiveMutation".to_string(),
198 reason: "acknowledgeLiveMutation does not apply to a stream source, which mounts \
199 no volume. Fix: remove acknowledgeLiveMutation"
200 .to_string(),
201 });
202 }
203 // Same materialized-default reasoning: only a NON-default strategy is a real
204 // request. `PvcName` is what the API server stamps on everything.
205 if source
206 .source_path_strategy
207 .is_some_and(|s| s != crate::snapshot_policy::SourcePathStrategy::PvcName)
208 {
209 return Err(ValidationError::InvalidFieldValue {
210 field: "spec.sources[].sourcePathStrategy".to_string(),
211 reason: "sourcePathStrategy only applies to a pvcSelector source. Fix: remove it; \
212 to change a stream source's path (/stream/<fileName>), set \
213 sourcePathOverride"
214 .to_string(),
215 });
216 }
217 Ok(())
218}
219
220/// `fileName` is ONE safe path segment.
221///
222/// Security-relevant, not cosmetic: kopia stores the `--stdin-file` value verbatim as
223/// the snapshot entry's name and does not sanitize it, so an entry named `../x` makes
224/// a later `kopia restore <id> <dir>` write OUTSIDE `<dir>` (verified against kopia
225/// 0.23.1). An empty value is worse still — kopia then ignores stdin entirely and
226/// tries to snapshot the source path from the mover's own filesystem.
227pub fn validate_stream_file_name(field: &str, name: &str) -> ValidationResult {
228 let bad = name.is_empty()
229 || name.contains('/')
230 || name == "."
231 || name == ".."
232 || name.contains('\0')
233 || name.chars().any(|c| c.is_control());
234 if bad {
235 return Err(ValidationError::InvalidFieldValue {
236 field: field.to_string(),
237 reason: format!(
238 "`{name}` is not a usable file name. Fix: give a single file name (e.g. \
239 `postgres.sql`): not empty, no `/`, not `.` or `..`, no control characters"
240 ),
241 });
242 }
243 Ok(())
244}
245
246/// A [`StreamExec`] is runnable: a non-empty argv, a selector that identifies
247/// something, and a parseable timeout. Shared by the backup source and the restore
248/// target so both reject the same mistakes with the same words.
249pub fn validate_stream_exec(field: &str, exec: &StreamExec) -> ValidationResult {
250 if exec.command.is_empty() {
251 return Err(ValidationError::InvalidFieldValue {
252 field: format!("{field}.command"),
253 reason: "is empty. Fix: give the command to run, e.g. [\"sh\", \"-ec\", \
254 \"pg_dumpall -U postgres\"] (it runs without a shell)"
255 .to_string(),
256 });
257 }
258 if exec.command.iter().all(|a| a.trim().is_empty()) {
259 return Err(ValidationError::InvalidFieldValue {
260 field: format!("{field}.command"),
261 reason: "the command is entirely blank; element 0 must name the program to run"
262 .to_string(),
263 });
264 }
265 let selector_empty = exec
266 .pod_selector
267 .match_labels
268 .as_ref()
269 .is_none_or(|m| m.is_empty())
270 && exec
271 .pod_selector
272 .match_expressions
273 .as_ref()
274 .is_none_or(|e| e.is_empty());
275 if selector_empty {
276 return Err(ValidationError::InvalidFieldValue {
277 field: format!("{field}.podSelector"),
278 reason: "is empty, so it matches every pod in the namespace. Fix: set \
279 matchLabels/matchExpressions for the one pod to run the command in"
280 .to_string(),
281 });
282 }
283 if let Some(t) = &exec.timeout
284 && crate::duration::parse_go_duration(t).is_none()
285 {
286 return Err(ValidationError::InvalidFieldValue {
287 field: format!("{field}.timeout"),
288 reason: format!("`{t}` is not a Go duration. Use forms like `30m`, `2h`, `90s`"),
289 });
290 }
291 Ok(())
292}
293
294/// An inline [`NfsVolume`] is well-formed: a non-empty server and an absolute
295/// export path. The structural schema can't express either, so the webhook does.
296/// `context` names where it appears (e.g. `"snapshot source"`, `"filesystem repo"`)
297/// for an actionable message.
298pub fn validate_nfs_volume(nfs: &NfsVolume, context: &str) -> ValidationResult {
299 if nfs.server.trim().is_empty() {
300 return Err(ValidationError::MissingRequiredField {
301 field: format!("{context} nfs.server"),
302 });
303 }
304 if !nfs.path.starts_with('/') {
305 return Err(ValidationError::InvalidFieldValue {
306 field: format!("{context} nfs.path"),
307 reason: format!(
308 "must be an absolute export path beginning with '/' (got {:?})",
309 nfs.path
310 ),
311 });
312 }
313 Ok(())
314}
315
316/// Validate a PVC access-modes list wherever one appears (`spec.staging.accessModes`,
317/// `restore.target.pvc.accessModes`). Three rules, one place, both callers (webhook
318/// at admission, controller defensively):
319///
320/// * every entry must be canonical — an [`PvcAccessMode::Unknown`] value is either
321/// a legacy stored string from before schema enforcement or a typo, and no PVC
322/// could ever be provisioned from it;
323/// * no duplicates;
324/// * `ReadWriteOncePod` must be the **sole** mode — the apiserver rejects the
325/// combination at PVC-create time, so catching it here fails at admission with
326/// the reason instead of wedging the first run in a create-retry loop.
327///
328/// `field` names the exact path for the message. Accumulates so every bad entry is
329/// reported in one apply.
330pub fn validate_access_modes(field: &str, modes: &[PvcAccessMode]) -> Vec<ValidationError> {
331 let mut errs = Vec::new();
332 let mut seen = std::collections::BTreeSet::new();
333 for (i, mode) in modes.iter().enumerate() {
334 if let PvcAccessMode::Unknown(value) = mode {
335 errs.push(ValidationError::InvalidFieldValue {
336 field: format!("{field}[{i}]"),
337 reason: format!(
338 "{value:?} is not a Kubernetes access mode. Fix: use one of: {}",
339 PvcAccessMode::CANONICAL.join(", ")
340 ),
341 });
342 }
343 if !seen.insert(mode.mode_str().to_string()) {
344 errs.push(ValidationError::InvalidFieldValue {
345 field: format!("{field}[{i}]"),
346 reason: format!(
347 "duplicate access mode {:?}; list each mode at most once",
348 mode.mode_str()
349 ),
350 });
351 }
352 }
353 if modes.len() > 1
354 && modes
355 .iter()
356 .any(|m| matches!(m, PvcAccessMode::ReadWriteOncePod))
357 {
358 errs.push(ValidationError::InvalidFieldValue {
359 field: field.to_string(),
360 reason: "ReadWriteOncePod cannot be combined with other access modes. Fix: use \
361 ReadWriteOncePod alone, or drop it"
362 .to_string(),
363 });
364 }
365 errs
366}
367
368/// The semantic class of a numeric lower-bound, so [`require_min`] can attach the
369/// right minimum AND a one-line *why* without every caller re-deriving it — the
370/// difference between the old terse `"must be >= 1 (got 0)"` and a message that
371/// says why 0 is refused and what to do instead. Exhaustive `min`/`because`, so a
372/// new bound cannot be added without deciding both (mirrors the type-safety
373/// thesis: a knob's meaning is not free-text).
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum NumericBound {
376 /// A worker/parallelism count (`parallel`, `fileParallelism`, …). 0 does no work.
377 Count,
378 /// A per-second cap (bytes/s or ops/s). kopia reads an ABSENT field as "no limit".
379 RatePerSecond,
380 /// A megabyte size cap (e.g. `upload.limitMb`).
381 Megabytes,
382 /// A number of seconds — a Job/pod deadline.
383 Seconds,
384 /// A retry backoff count, which legitimately allows 0.
385 BackoffCount,
386}
387
388impl NumericBound {
389 /// The inclusive minimum this bound enforces.
390 pub fn min(self) -> i64 {
391 match self {
392 NumericBound::BackoffCount => 0,
393 NumericBound::Count
394 | NumericBound::RatePerSecond
395 | NumericBound::Megabytes
396 | NumericBound::Seconds => 1,
397 }
398 }
399
400 /// Why the minimum exists — the `because` clause of the rejection message.
401 pub fn because(self) -> &'static str {
402 match self {
403 NumericBound::Count => "0 would do no work; set a positive count",
404 NumericBound::RatePerSecond => {
405 "0 would stall the transfer; omit the field for no limit"
406 }
407 NumericBound::Megabytes => {
408 "0 would cap the upload at nothing; omit the field to leave it uncapped"
409 }
410 NumericBound::Seconds => {
411 "a non-positive deadline is rejected by the kubelet (or fails the pod immediately)"
412 }
413 NumericBound::BackoffCount => "a negative retry budget is meaningless",
414 }
415 }
416}
417
418/// A numeric knob must meet its [`NumericBound`]'s minimum — the shared one-liner
419/// behind every `Option<u32>` count / `Option<i64>` rate field (e.g.
420/// `RepositoryReplication.spec.sync.parallel`), so the rule, its minimum, AND its
421/// rationale are written once instead of re-derived per field. `field` names the
422/// exact path for the message (e.g. `"RepositoryReplication spec.sync.parallel"`).
423/// Callers only invoke this for a `Some` value — an absent knob is always valid
424/// and never reaches this helper.
425pub fn require_min(field: &str, value: i64, bound: NumericBound) -> Option<ValidationError> {
426 let min = bound.min();
427 (value < min).then(|| ValidationError::InvalidFieldValue {
428 field: field.to_string(),
429 reason: crate::message::Diagnostic::new(format!("must be at least {min} (got {value})"))
430 .because(bound.because())
431 .to_string(),
432 })
433}
434
435/// Validate every SET knob of a [`Throttle`](crate::common::Throttle): a cap is a
436/// positive rate, so each present field must be >= 1. `field` names the block's
437/// path for the message (e.g. `"SnapshotReplication spec.migrate.throttle.source"`),
438/// and each knob's own camelCase name is appended.
439///
440/// A `0` is refused rather than read as "unlimited": kopia's own "no limit" is the
441/// ABSENT field, so accepting `0` would give one wire value two plausible meanings
442/// (uncapped vs. stalled) — and the one it actually has in kopia is a hard stop.
443/// Exhaustively destructured, so a fifth knob cannot be added without deciding its
444/// bound here.
445pub fn validate_throttle(field: &str, throttle: &crate::common::Throttle) -> Vec<ValidationError> {
446 let crate::common::Throttle {
447 upload_bytes_per_second,
448 download_bytes_per_second,
449 read_ops_per_second,
450 write_ops_per_second,
451 } = throttle;
452 [
453 ("uploadBytesPerSecond", upload_bytes_per_second),
454 ("downloadBytesPerSecond", download_bytes_per_second),
455 ("readOpsPerSecond", read_ops_per_second),
456 ("writeOpsPerSecond", write_ops_per_second),
457 ]
458 .into_iter()
459 .filter_map(|(knob, value)| {
460 value.and_then(|v| require_min(&format!("{field}.{knob}"), v, NumericBound::RatePerSecond))
461 })
462 .collect()
463}
464
465/// Validate a `MoverSpec`. `context` names the owning resource for the message (e.g.
466/// `"Restore mover"`).
467///
468/// `inheritSecurityContextFrom` and the explicit `securityContext`/`podSecurityContext` are
469/// **compatible**, not mutually exclusive: they are adjacent layers of the merge ladder
470/// (`hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit`), so the explicit context overrides the
471/// inherited one field-wise and fills whatever the workload does not pin — and stands in alone
472/// when inheritance cannot resolve a pod.
473///
474/// This pair used to be rejected here, on the rationale that "the mover's effective contexts
475/// must have a single, unambiguous source so the privileged-mover gate runs on exactly one".
476/// That rationale was never true: the gate has always evaluated the *merged* product of
477/// hardened + `moverDefaults` + recipe (see the callers of
478/// [`crate::common::requires_privilege_resolved`]), which
479/// [`crate::invariants::enforce_security_context_invariants`] normalizes first — INV-1 exists
480/// precisely to reconcile an inherited `runAsUser: 0` against the hardened `runAsNonRoot:
481/// true`. Merging one more layer in cannot smuggle an elevated mover past it.
482pub fn validate_mover(mover: &MoverSpec, context: &str) -> ValidationResult {
483 if let Some(resources) = &mover.resources {
484 validate_resources(resources, context)?;
485 }
486 Ok(())
487}
488
489/// The first resource key whose `requests` value exceeds its `limits` value (both present
490/// and parseable), as `(key, request, limit)`. Quantity comparison uses `kube_quantity`'s
491/// `ParsedQuantity` (the same `k8s-openapi` `Quantity` type the cluster uses), so
492/// `"1Gi" > "512Mi"` is compared correctly across binary/SI/milli suffixes. **Best-effort:**
493/// a key whose quantity fails to parse is skipped, never a false rejection.
494fn requests_exceeding_limits(resources: &ResourceRequirements) -> Option<(String, String, String)> {
495 let (Some(requests), Some(limits)) = (resources.requests.as_ref(), resources.limits.as_ref())
496 else {
497 return None;
498 };
499 for (key, req) in requests {
500 let Some(lim) = limits.get(key) else { continue };
501 let (Ok(req_p), Ok(lim_p)) = (ParsedQuantity::try_from(req), ParsedQuantity::try_from(lim))
502 else {
503 continue;
504 };
505 if req_p > lim_p {
506 return Some((key.clone(), req.0.clone(), lim.0.clone()));
507 }
508 }
509 None
510}
511
512/// Validate that a `ResourceRequirements` has no `requests > limits` for any key. A pod with
513/// `requests > limits` is **rejected by the API server**, so the mover Job never creates a
514/// pod and the run hangs — the same silent-wedge class as an impossible securityContext.
515/// `context` names the owner (e.g. `"SnapshotPolicy mover"`).
516pub fn validate_resources(resources: &ResourceRequirements, context: &str) -> ValidationResult {
517 if let Some((key, req, lim)) = requests_exceeding_limits(resources) {
518 return Err(ValidationError::InvalidFieldValue {
519 field: format!("{context} resources.requests.{key}"),
520 reason: format!(
521 "request `{req}` exceeds limit `{lim}`, so the mover pod can never start. Fix: \
522 lower the request or raise the limit"
523 ),
524 });
525 }
526 Ok(())
527}
528
529/// Validate a [`FailurePolicy`]'s numeric fields are sane: `activeDeadlineSeconds` and
530/// `podStartupDeadlineSeconds` must be positive (the kubelet rejects a non-positive Job
531/// deadline, and a non-positive grace would fail every pod on its first reconcile);
532/// `backoffLimit` must be non-negative. `context` names the owner (e.g. `"Snapshot"`).
533pub fn validate_failure_policy(fp: &FailurePolicy, context: &str) -> ValidationResult {
534 if let Some(d) = fp.active_deadline_seconds
535 && let Some(e) = require_min(
536 &format!("{context} failurePolicy.activeDeadlineSeconds"),
537 d,
538 NumericBound::Seconds,
539 )
540 {
541 return Err(e);
542 }
543 if let Some(g) = fp.pod_startup_deadline_seconds
544 && g <= 0
545 {
546 // Bespoke `because`: this deadline has a meaning worth naming beyond the
547 // generic Seconds rationale.
548 return Err(ValidationError::InvalidFieldValue {
549 field: format!("{context} failurePolicy.podStartupDeadlineSeconds"),
550 reason: crate::message::Diagnostic::new(format!("must be at least 1 second (got {g})"))
551 .because("it is how long a mover pod may take to start before the run fails")
552 .to_string(),
553 });
554 }
555 if let Some(b) = fp.backoff_limit
556 && let Some(e) = require_min(
557 &format!("{context} failurePolicy.backoffLimit"),
558 b.into(),
559 NumericBound::BackoffCount,
560 )
561 {
562 return Err(e);
563 }
564 Ok(())
565}
566
567/// A cron expression parses with the same parser the controller uses at runtime, so
568/// bad expressions are rejected at apply time, not at first reconcile (ADR §4.1).
569///
570/// `croner` 2.x does not implement Jenkins-style `H`. Since kopiur resolves `H`
571/// deterministically in [`crate::jitter::substitute_h`] (not in the parser), we
572/// substitute every `H` field with the fixed placeholder `0` purely to validate the
573/// expression's *shape* here. The real `H` spread is produced at scheduling time.
574///
575/// ```
576/// use kopiur_api::validate::validate_cron;
577/// use kopiur_api::ValidationError;
578///
579/// // Valid 5-field crons pass — including Jenkins-style `H` (resolved later).
580/// assert!(validate_cron("0 2 * * *").is_ok());
581/// assert!(validate_cron("H 2 * * *").is_ok());
582///
583/// // Garbage is rejected at apply time, not at first reconcile (ADR §4.1).
584/// assert!(matches!(
585/// validate_cron("not a cron"),
586/// Err(ValidationError::InvalidCron { .. }),
587/// ));
588/// ```
589pub fn validate_cron(expr: &str) -> ValidationResult {
590 let probe = expr
591 .split_whitespace()
592 .map(|f| if f == "H" { "0" } else { f })
593 .collect::<Vec<_>>()
594 .join(" ");
595 match crate::jitter::cron_parser().parse(&probe) {
596 Ok(_) => Ok(()),
597 Err(e) => Err(ValidationError::InvalidCron {
598 expr: expr.to_string(),
599 reason: e.to_string(),
600 }),
601 }
602}
603
604/// A non-blocking admission WARNING (never a rejection) when a schedule's cron
605/// fires more often than hourly (issue #249). Every fire creates one per-run
606/// `Snapshot` CR per source, and they accumulate up to the `SnapshotPolicy`
607/// retention window — each terminal one is then re-reconciled for that whole
608/// window — so a sub-hourly cadence with a wide (or absent) retention can produce
609/// thousands of CRs. Sub-hourly is legitimate for some workloads, so this is a
610/// footgun heads-up, not a block.
611///
612/// Pure and cron-only: the schedule webhook is client-less and can't read the
613/// referenced policy's retention, so the message states the cadence and the
614/// CR-count relationship rather than an exact number. `None` for an hourly-or-slower
615/// cadence, or a cron that doesn't parse (that error is surfaced by [`validate_cron`]).
616pub fn schedule_cr_growth_warning(cron: &str) -> Option<String> {
617 let fires = schedule_fires_per_hour(cron)?;
618 if fires <= 1 {
619 return None;
620 }
621 Some(format!(
622 "schedule fires ~{fires}×/hour and each fire creates a Snapshot CR per source, kept \
623 for the SnapshotPolicy retention window — this can add up to thousands of CRs. If \
624 unintended, use a coarser schedule or bound SnapshotPolicy.spec.retention. See \
625 https://kopiur.home-operations.com/backups/#how-many-snapshot-crs-will-i-have"
626 ))
627}
628
629/// A non-blocking admission WARNING (#547) when a CR's name is longer than
630/// [`crate::consts::JOB_NAME_MAX`] (57) and its mover `Job` takes that exact name
631/// (a `Snapshot`'s backup Job, a `Restore`'s Job). Kubernetes 1.37's
632/// `WorkloadWithJob` derives a `<job>-pgt-0` name that must fit 63 characters, so
633/// such a Job breaks there. The Job cannot simply be renamed (its name is the
634/// lookup key tying it to the CR), so admission says so up front. `None` when the
635/// name fits.
636pub fn job_name_length_warning(kind: &str, name: &str) -> Option<String> {
637 use crate::consts::JOB_NAME_MAX;
638 (name.len() > JOB_NAME_MAX).then(|| {
639 format!(
640 "{kind} name `{name}` is {} characters. Its mover Job uses the same name, and \
641 Kubernetes 1.37+ (WorkloadWithJob) needs Job names of at most {JOB_NAME_MAX} \
642 characters. Fix: use a {kind} name of {JOB_NAME_MAX} characters or fewer",
643 name.len()
644 )
645 })
646}
647
648/// The scheduled-run sibling of [`job_name_length_warning`] (#547): every fire of
649/// a `SnapshotSchedule` creates a `Snapshot` named `<schedule>-<YYYYMMDDHHMMSS>`
650/// (15 more characters), whose backup Job takes that name. `None` when those
651/// names fit [`crate::consts::JOB_NAME_MAX`].
652pub fn scheduled_job_name_length_warning(schedule_name: &str) -> Option<String> {
653 use crate::consts::JOB_NAME_MAX;
654 // `-` + the 14-digit `%Y%m%d%H%M%S` slot stamp.
655 const SLOT_SUFFIX: usize = 15;
656 let max = JOB_NAME_MAX - SLOT_SUFFIX;
657 (schedule_name.len() > max).then(|| {
658 format!(
659 "SnapshotSchedule name `{schedule_name}` is {} characters, so each run's Snapshot \
660 and backup Job get a {}-character name. Kubernetes 1.37+ (WorkloadWithJob) needs \
661 Job names of at most {JOB_NAME_MAX} characters. Fix: use a schedule name of {max} \
662 characters or fewer",
663 schedule_name.len(),
664 schedule_name.len() + SLOT_SUFFIX
665 )
666 })
667}
668
669/// Count how many times `cron` fires within one representative active hour. Pure and
670/// clock-free — anchored on the cron's FIRST fire from a fixed instant (not a fixed
671/// calendar hour), so a day-of-week / day-of-month constrained cron is measured
672/// during an hour it is actually active. `H` tokens are substituted to a fixed value
673/// first (they pick a minute within the window, not the cadence, so the count is
674/// H-independent). `None` if the cron doesn't parse.
675fn schedule_fires_per_hour(cron: &str) -> Option<u32> {
676 use chrono::{Duration, TimeZone, Utc};
677 let probe = cron
678 .split_whitespace()
679 .map(|f| if f == "H" { "0" } else { f })
680 .collect::<Vec<_>>()
681 .join(" ");
682 let parsed = crate::jitter::cron_parser().parse(&probe).ok()?;
683 let anchor = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single()?;
684 let first = parsed.find_next_occurrence(&anchor, true).ok()?;
685 let horizon = first + Duration::hours(1);
686 let mut cursor = first;
687 let mut count = 0u32;
688 // The cron grammar has no seconds field, so at most 60 fires/hour; cap defensively.
689 for _ in 0..61 {
690 let next = parsed.find_next_occurrence(&cursor, true).ok()?;
691 if next >= horizon {
692 break;
693 }
694 count += 1;
695 cursor = next + Duration::seconds(1);
696 }
697 Some(count)
698}
699
700/// Validate an optional Go-style `jitter` duration (`30m`, `1h`, …) against the SAME
701/// parser the controller uses at scheduling time, so a typo or an out-of-range value
702/// is rejected at apply time rather than silently degrading to *no jitter* at the
703/// next reconcile (`parse_go_duration` returns `None`, which the schedule treats as a
704/// zero offset). `None` (no jitter) is always valid. `field` names the path for the
705/// error message (e.g. `spec.schedule.jitter`).
706pub fn validate_jitter(field: &str, jitter: Option<&str>) -> ValidationResult {
707 if let Some(j) = jitter
708 && crate::duration::parse_go_duration(j).is_none()
709 {
710 return Err(ValidationError::InvalidFieldValue {
711 field: field.to_string(),
712 reason: format!(
713 "{j:?} is not a valid duration. Fix: use a Go-style duration like 30s, 5m, or 1h"
714 ),
715 });
716 }
717 Ok(())
718}
719
720/// The largest jitter window any cron may carry: 24 hours.
721///
722/// Jitter is a deterministic spread WITHIN a cron period, not an offset that moves
723/// the schedule. Beyond a day the spread exceeds every cadence kopiur schedules
724/// (the coarsest built-in default is a daily full maintenance), so a larger value
725/// is always a misunderstanding — typically an attempt to say "run 30h after the
726/// slot", which jitter cannot express.
727pub const MAX_JITTER: std::time::Duration = std::time::Duration::from_secs(86_400);
728
729/// Validate a **present** Go-style `jitter` duration: it must parse (same parser
730/// [`validate_jitter`] and the scheduler use) AND must not exceed [`MAX_JITTER`].
731///
732/// The bounds half is a TIGHTENING of an existing rule, so it is admission-only —
733/// the controller re-runs the shared aggregates as a hard stop at reconcile, and
734/// adding this to one of them would brick a stored CR carrying an over-24h jitter
735/// rather than merely refusing the next edit. Call it from the webhook's per-kind
736/// `validate_*_admission_extras`, never from a shared aggregate.
737///
738/// Takes a bare `&str` (not `Option<&str>`) because every caller is already inside
739/// an `if let Some(j)` over the field it is checking.
740///
741/// ```
742/// use kopiur_api::validate::validate_jitter_bounds;
743///
744/// assert!(validate_jitter_bounds("spec.schedule.jitter", "10m").is_ok());
745/// // Exactly the cap is fine; a second past it is not.
746/// assert!(validate_jitter_bounds("spec.schedule.jitter", "24h").is_ok());
747/// assert!(validate_jitter_bounds("spec.schedule.jitter", "86401s").is_err());
748/// // Garbage is rejected by the parse half.
749/// assert!(validate_jitter_bounds("spec.schedule.jitter", "soon").is_err());
750/// ```
751pub fn validate_jitter_bounds(field: &str, jitter: &str) -> ValidationResult {
752 // Parse first, reusing `validate_jitter` so a typo reads identically whichever
753 // validator catches it. Its `None` arm is the only unparseable case, so a bare
754 // `let else` back into that same error keeps this panic-free rather than
755 // leaning on an `expect` that "cannot" fire.
756 let Some(parsed) = crate::duration::parse_go_duration(jitter) else {
757 validate_jitter(field, Some(jitter))?;
758 // Unreachable in practice (validate_jitter rejects exactly what fails to
759 // parse) — but expressed as a value, not a panic: this is backup software.
760 return Ok(());
761 };
762 if parsed > MAX_JITTER {
763 return Err(ValidationError::InvalidFieldValue {
764 field: field.to_string(),
765 reason: format!(
766 "jitter of {jitter} exceeds the 24h maximum. Fix: use a window smaller than \
767 the cron period (e.g. 10m); put a fixed offset in the cron expression instead"
768 ),
769 });
770 }
771 Ok(())
772}
773
774/// Keys a user may not set in `moverDefaults.podLabels`/`podAnnotations`: anything
775/// under kopiur's own domain prefix, plus the exact `app.kubernetes.io/managed-by`
776/// key.
777///
778/// Both are keys the controller stamps itself. Extra pod metadata merges UNDER
779/// kopiur's, so a collision is silently DROPPED at render time — the manifest would
780/// claim a label the pod never carries. Reject it instead of ignoring it (the same
781/// reasoning as the replication mover's `inheritSecurityContextFrom` refusal).
782const RESERVED_POD_METADATA_KEYS: &[&str] = &["app.kubernetes.io/managed-by"];
783
784/// kopiur's own label/annotation domain prefix. Everything under it is
785/// operator-owned wire contract (see [`crate::consts`]).
786const KOPIUR_KEY_PREFIX: &str = "kopiur.home-operations.com/";
787
788/// Validate `moverDefaults.podLabels`/`podAnnotations` key sets, accumulating every
789/// reserved key so a user fixes them all in one apply.
790///
791/// New fields, so this is safe in the shared repository aggregates the controller
792/// re-runs: no stored object can carry a key this rejects.
793pub fn validate_pod_metadata(defaults: &crate::common::MoverDefaults) -> Vec<ValidationError> {
794 let mut errs = Vec::new();
795 for (field, map) in [
796 ("moverDefaults.podLabels", defaults.pod_labels.as_ref()),
797 (
798 "moverDefaults.podAnnotations",
799 defaults.pod_annotations.as_ref(),
800 ),
801 ] {
802 let Some(map) = map else { continue };
803 for key in map.keys() {
804 let reserved = key.starts_with(KOPIUR_KEY_PREFIX)
805 || RESERVED_POD_METADATA_KEYS.contains(&key.as_str());
806 if reserved {
807 errs.push(ValidationError::InvalidFieldValue {
808 field: format!("{field}[{key:?}]"),
809 reason: format!(
810 "{key:?} is reserved by kopiur and would be overwritten. Fix: use a key \
811 outside `{KOPIUR_KEY_PREFIX}` (and not `app.kubernetes.io/managed-by`)"
812 ),
813 });
814 }
815 }
816 }
817 errs
818}
819
820/// Validate an optional IANA timezone name against the same `chrono-tz` database the
821/// controller uses at scheduling time, so a typo (e.g. `America/Chicgo`) is rejected at
822/// apply time rather than silently resolving to UTC at the next reconcile. `None` (use
823/// the controller default) is always valid.
824///
825/// ```
826/// use kopiur_api::validate::validate_timezone;
827///
828/// assert!(validate_timezone(None).is_ok());
829/// assert!(validate_timezone(Some("America/Chicago")).is_ok());
830/// assert!(validate_timezone(Some("UTC")).is_ok());
831/// assert!(validate_timezone(Some("America/Chicgo")).is_err());
832/// ```
833pub fn validate_timezone(name: Option<&str>) -> ValidationResult {
834 match name {
835 None => Ok(()),
836 Some(tz) if tz.parse::<chrono_tz::Tz>().is_ok() => Ok(()),
837 Some(tz) => Err(ValidationError::InvalidTimezone {
838 name: tz.to_string(),
839 }),
840 }
841}
842
843/// The shared `spec.server` rules the type system can't express (server addendum):
844/// * `auth.insecure` requires `acknowledgeInsecure: true` — a no-auth server exposes
845/// full read/read of the repository, so it must be explicit.
846/// * `service.port` must be non-zero.
847/// * `readOnly: false` is contradictory on a `mode: ReadOnly` repository — a ReadOnly
848/// repo can never serve a writable UI, so the explicit denial is rejected (omitting
849/// the field is fine; the mode forces read-only).
850///
851/// `mode` is the parent repository's [`RepositoryMode`] (both callers have it). Accumulates
852/// so a user sees every server problem at once. The PVC `ReadWriteMany` requirement for
853/// filesystem-backend servers is **not** here — it needs a live PVC read and is enforced
854/// at reconcile, not admission.
855pub fn validate_server(server: &ServerSpec, mode: RepositoryMode) -> Vec<ValidationError> {
856 let mut errs = Vec::new();
857 if let Some(ServerAuth::Insecure(ack)) = &server.auth
858 && !ack.acknowledge_insecure
859 {
860 errs.push(ValidationError::InsecureServerNotAcknowledged);
861 }
862 if let Some(service) = &server.service
863 && service.port == Some(0)
864 {
865 errs.push(ValidationError::InvalidServerPort { port: 0 });
866 }
867 if server.read_only == Some(false) && !mode.allows_writes() {
868 errs.push(ValidationError::InvalidFieldValue {
869 field: "server.readOnly".to_string(),
870 reason: "a Repository with spec.mode: ReadOnly cannot serve a read-write UI. Fix: \
871 remove server.readOnly, or set spec.mode: ReadWrite"
872 .to_string(),
873 });
874 }
875 if let Some(res) = &server.resources
876 && let Err(e) = validate_resources(res, "server")
877 {
878 errs.push(e);
879 }
880 errs
881}
882
883/// `inheritSecurityContextFrom.pvcConsumer` derives the mover identity from a **backup
884/// source** PVC's consumer; a kind that has no backup source (Restore, Maintenance) must
885/// reject it at admission rather than fail at runtime. `field_prefix` names the owning kind
886/// (e.g. `"restore"`/`"maintenance"`), `instead` is the kind-appropriate remedy.
887pub(crate) fn forbid_pvc_consumer(
888 mover: &MoverSpec,
889 field_prefix: &str,
890 instead: &str,
891) -> ValidationResult {
892 if matches!(
893 mover.inherit_security_context_from,
894 Some(crate::common::InheritSecurityContextFrom::PvcConsumer(_))
895 ) {
896 return Err(ValidationError::InvalidFieldValue {
897 field: format!("{field_prefix}.mover.inheritSecurityContextFrom.pvcConsumer"),
898 reason: format!(
899 "is only valid for a backup source (there is no source PVC here). {instead}"
900 ),
901 });
902 }
903 Ok(())
904}
905
906/// `inheritSecurityContextFrom.snapshot` reproduces the identity RECORDED on a backup
907/// (`Snapshot.status.recorded`), so it only makes sense where a snapshot is being consumed —
908/// a `Restore`. A backup's identity comes from the live workload and maintenance touches no
909/// snapshot at all, so those kinds reject the variant at admission rather than fail (or,
910/// worse, silently no-op) at runtime. `field_prefix` names the owning kind (e.g.
911/// `"snapshotPolicy"`/`"maintenance"`), `reason` is the kind-appropriate what/why/fix.
912pub(crate) fn forbid_snapshot_inherit(
913 mover: &MoverSpec,
914 field_prefix: &str,
915 reason: &str,
916) -> ValidationResult {
917 if matches!(
918 mover.inherit_security_context_from,
919 Some(crate::common::InheritSecurityContextFrom::Snapshot(_))
920 ) {
921 return Err(ValidationError::InvalidFieldValue {
922 field: format!("{field_prefix}.mover.inheritSecurityContextFrom.snapshot"),
923 reason: reason.to_string(),
924 });
925 }
926 Ok(())
927}
928
929/// Reject `inheritSecurityContextFrom` **entirely**, for a kind whose reconciler never resolves
930/// it. Stronger than [`forbid_pvc_consumer`]: that rejects one variant on a kind that *does*
931/// honor the other, this rejects the whole field on a kind that honors none of it.
932///
933/// Accepting a field and then ignoring it is the failure mode this repo exists to design out —
934/// the manifest says the mover runs as the workload, the mover runs as something else, and
935/// nothing says otherwise. If a kind cannot honor it, admission must say so.
936pub(crate) fn forbid_inherit(
937 mover: &MoverSpec,
938 field_prefix: &str,
939 reason: &str,
940) -> ValidationResult {
941 if mover.inherit_security_context_from.is_some() {
942 return Err(ValidationError::InvalidFieldValue {
943 field: format!("{field_prefix}.mover.inheritSecurityContextFrom"),
944 reason: reason.to_string(),
945 });
946 }
947 Ok(())
948}
949
950#[cfg(test)]
951mod tests;