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;
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;