Skip to main content

kopiur_api/validate/
admission.rs

1//! **Admission-only** validators — the rules the WEBHOOK enforces and the
2//! controller deliberately does not.
3//!
4//! Every shared aggregate in this module's siblings (`validate_backup_schedule`,
5//! `validate_maintenance`, `validate_repository_replication`, …) is re-run as a
6//! HARD STOP at the top of the corresponding reconciler. That makes those
7//! aggregates a ratchet in one direction only: adding a rule to one does not just
8//! refuse the next bad edit, it **bricks every already-stored CR** that happens to
9//! violate it — the object stops reconciling, and backups stop running, with no
10//! user action having taken place.
11//!
12//! So a rule that TIGHTENS an existing field lives here instead, called only from
13//! `kopiur-webhook`'s per-kind handlers. A stored object keeps reconciling under
14//! whatever it was admitted with; the next `kubectl apply` is what has to satisfy
15//! the tighter rule. (A rule about a brand-new field needs none of this: no stored
16//! object can carry a field that did not exist, so it goes in the shared aggregate
17//! where the controller re-checks it too.)
18//!
19//! Everything here is pure and spec-only — same crate, same unit tests, no
20//! `kube::Client` — so the split is about *where it is called from*, not about a
21//! second dialect of validation living in the webhook crate.
22
23use crate::error::{ValidationError, ValidationResult};
24use crate::maintenance::MaintenanceSpec;
25use crate::repository_replication::RepositoryReplicationSpec;
26use crate::snapshot_policy::SnapshotPolicySpec;
27use crate::snapshot_replication::SnapshotReplicationSpec;
28use crate::snapshot_schedule::SnapshotScheduleSpec;
29
30use super::validate_jitter_bounds;
31
32/// Run [`validate_jitter_bounds`] over an optional jitter field, pushing any
33/// problem onto `errs`. Absent jitter is always fine.
34fn push_jitter_bounds(errs: &mut Vec<ValidationError>, field: &str, jitter: Option<&str>) {
35    if let Some(j) = jitter
36        && let Err(e) = validate_jitter_bounds(field, j)
37    {
38        errs.push(e);
39    }
40}
41
42/// `startingDeadlineSeconds` must not be negative.
43///
44/// A negative deadline is not "no deadline" — the miss check is
45/// `now - slot > deadline`, so a negative value marks EVERY slot expired the
46/// instant it fires. The schedule then skips every run forever while reporting
47/// itself perfectly healthy: the silent-wedge shape. Omit the field for no
48/// deadline; `0` (fire only exactly on time) is legitimate and accepted.
49///
50/// Admission-only: `SnapshotSchedule`'s reconciler re-runs
51/// `validate_backup_schedule` as a hard stop, and a stored schedule carrying a
52/// negative value must keep reconciling (badly, but visibly) rather than stop dead.
53fn validate_starting_deadline_seconds(seconds: Option<i64>) -> ValidationResult {
54    if let Some(s) = seconds
55        && s < 0
56    {
57        return Err(ValidationError::InvalidFieldValue {
58            field: "spec.schedule.startingDeadlineSeconds".to_string(),
59            reason: format!(
60                "{s} must be >= 0, or the schedule never runs. Fix: omit the field for no \
61                 deadline, or use 0 to fire only exactly on time"
62            ),
63        });
64    }
65    Ok(())
66}
67
68/// Admission-only extras for a `SnapshotSchedule`: the jitter 24h cap and the
69/// non-negative `startingDeadlineSeconds` rule. Both TIGHTEN fields that already
70/// exist, so neither may join `validate_backup_schedule`.
71pub fn validate_backup_schedule_admission_extras(
72    spec: &SnapshotScheduleSpec,
73) -> Vec<ValidationError> {
74    let mut errs = Vec::new();
75    push_jitter_bounds(
76        &mut errs,
77        "spec.schedule.jitter",
78        spec.schedule.jitter.as_deref(),
79    );
80    if let Err(e) = validate_starting_deadline_seconds(spec.schedule.starting_deadline_seconds) {
81        errs.push(e);
82    }
83    errs
84}
85
86/// Admission-only extras for a `SnapshotPolicy`: the jitter 24h cap on both
87/// verification tiers. The PARSE half already lives in `validate_backup_config`
88/// (and has since these fields shipped); only the bound is new, so only the bound
89/// is admission-only.
90pub fn validate_backup_config_admission_extras(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
91    let mut errs = Vec::new();
92    let Some(v) = &spec.verification else {
93        return errs;
94    };
95    push_jitter_bounds(
96        &mut errs,
97        "spec.verification.quick.schedule.jitter",
98        v.quick
99            .as_ref()
100            .and_then(|q| q.schedule.as_ref())
101            .and_then(|s| s.jitter.as_deref()),
102    );
103    push_jitter_bounds(
104        &mut errs,
105        "spec.verification.deep.schedule.jitter",
106        v.deep.as_ref().and_then(|d| d.schedule.jitter.as_deref()),
107    );
108    errs
109}
110
111/// Admission-only extras for a `Maintenance`: parse AND bound both jitter windows.
112///
113/// Unlike the other kinds, `validate_maintenance` never validated these fields at
114/// all — it covers the crons and the timezone only — so a garbage window has always
115/// been accepted and silently degraded to *no jitter* at reconcile. That means
116/// stored objects carrying garbage exist, and the parse half is a tightening too:
117/// both halves are admission-only, so those objects keep maintaining themselves
118/// (unspread) until someone edits them.
119pub fn validate_maintenance_admission_extras(spec: &MaintenanceSpec) -> Vec<ValidationError> {
120    let mut errs = Vec::new();
121    push_jitter_bounds(
122        &mut errs,
123        "spec.schedule.quick.jitter",
124        spec.schedule.quick.jitter.as_deref(),
125    );
126    push_jitter_bounds(
127        &mut errs,
128        "spec.schedule.full.jitter",
129        spec.schedule.full.jitter.as_deref(),
130    );
131    errs
132}
133
134/// Admission-only extras for a `RepositoryReplication`: parse AND bound the
135/// schedule jitter. Same history as `Maintenance` — `validate_repository_replication`
136/// checks the cron and timezone but never the jitter, so both halves are new
137/// rejections over a field stored objects already carry.
138pub fn validate_repository_replication_admission_extras(
139    spec: &RepositoryReplicationSpec,
140) -> Vec<ValidationError> {
141    let mut errs = Vec::new();
142    push_jitter_bounds(
143        &mut errs,
144        "spec.schedule.jitter",
145        spec.schedule.jitter.as_deref(),
146    );
147    errs
148}
149
150/// Admission-only extras for a `SnapshotReplication`: the jitter 24h cap. The parse
151/// half already lives in `validate_snapshot_replication`.
152pub fn validate_snapshot_replication_admission_extras(
153    spec: &SnapshotReplicationSpec,
154) -> Vec<ValidationError> {
155    let mut errs = Vec::new();
156    push_jitter_bounds(
157        &mut errs,
158        "spec.schedule.jitter",
159        spec.schedule.jitter.as_deref(),
160    );
161    errs
162}
163
164// `Repository`/`ClusterRepository` deliberately have NO entry here. Everything
165// this change adds to them — `scheduleDefaults.jitter` (parse + bounds) and
166// `moverDefaults.podLabels`/`podAnnotations` (reserved keys, via
167// `super::validate_pod_metadata`) — concerns BRAND-NEW fields, so no stored object
168// can carry a value the rules reject and the rules are safe in
169// `validate_repository`/`validate_cluster_repository`, where the controller
170// re-checks them too. An empty extras fn per kind would be pure ceremony; the next
171// Repository *tightening* is what earns one.
172//
173// Worth recording the asymmetry the shared placement leaves behind: the
174// `Repository` reconciler re-runs only a PARTIAL validator set (see
175// `controller/src/repository.rs`), while `ClusterRepository` re-runs the full
176// aggregate. So the new `scheduleDefaults.jitter` rule is enforced at reconcile for
177// one kind and not the other. That is acceptable precisely because it is
178// admission that matters here — the webhook covers both kinds identically, and the
179// reconcile-side re-check is a defense-in-depth pass, not the gate.