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