Skip to main content

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;