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 — a negative deadline marks every slot expired the instant \
61                 it fires (SkipExpired forever), so the schedule never runs. Omit the field for \
62                 no deadline, or use 0 to fire only exactly on time"
63            ),
64        });
65    }
66    Ok(())
67}
68
69/// Admission-only extras for a `SnapshotSchedule`: the jitter 24h cap and the
70/// non-negative `startingDeadlineSeconds` rule. Both TIGHTEN fields that already
71/// exist, so neither may join `validate_backup_schedule`.
72pub fn validate_backup_schedule_admission_extras(
73    spec: &SnapshotScheduleSpec,
74) -> Vec<ValidationError> {
75    let mut errs = Vec::new();
76    push_jitter_bounds(
77        &mut errs,
78        "spec.schedule.jitter",
79        spec.schedule.jitter.as_deref(),
80    );
81    if let Err(e) = validate_starting_deadline_seconds(spec.schedule.starting_deadline_seconds) {
82        errs.push(e);
83    }
84    errs
85}
86
87/// Admission-only extras for a `SnapshotPolicy`: the jitter 24h cap on both
88/// verification tiers. The PARSE half already lives in `validate_backup_config`
89/// (and has since these fields shipped); only the bound is new, so only the bound
90/// is admission-only.
91pub fn validate_backup_config_admission_extras(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
92    let mut errs = Vec::new();
93    let Some(v) = &spec.verification else {
94        return errs;
95    };
96    push_jitter_bounds(
97        &mut errs,
98        "spec.verification.quick.schedule.jitter",
99        v.quick
100            .as_ref()
101            .and_then(|q| q.schedule.as_ref())
102            .and_then(|s| s.jitter.as_deref()),
103    );
104    push_jitter_bounds(
105        &mut errs,
106        "spec.verification.deep.schedule.jitter",
107        v.deep.as_ref().and_then(|d| d.schedule.jitter.as_deref()),
108    );
109    errs
110}
111
112/// Admission-only extras for a `Maintenance`: parse AND bound both jitter windows.
113///
114/// Unlike the other kinds, `validate_maintenance` never validated these fields at
115/// all — it covers the crons and the timezone only — so a garbage window has always
116/// been accepted and silently degraded to *no jitter* at reconcile. That means
117/// stored objects carrying garbage exist, and the parse half is a tightening too:
118/// both halves are admission-only, so those objects keep maintaining themselves
119/// (unspread) until someone edits them.
120pub fn validate_maintenance_admission_extras(spec: &MaintenanceSpec) -> Vec<ValidationError> {
121    let mut errs = Vec::new();
122    push_jitter_bounds(
123        &mut errs,
124        "spec.schedule.quick.jitter",
125        spec.schedule.quick.jitter.as_deref(),
126    );
127    push_jitter_bounds(
128        &mut errs,
129        "spec.schedule.full.jitter",
130        spec.schedule.full.jitter.as_deref(),
131    );
132    errs
133}
134
135/// Admission-only extras for a `RepositoryReplication`: parse AND bound the
136/// schedule jitter. Same history as `Maintenance` — `validate_repository_replication`
137/// checks the cron and timezone but never the jitter, so both halves are new
138/// rejections over a field stored objects already carry.
139pub fn validate_repository_replication_admission_extras(
140    spec: &RepositoryReplicationSpec,
141) -> Vec<ValidationError> {
142    let mut errs = Vec::new();
143    push_jitter_bounds(
144        &mut errs,
145        "spec.schedule.jitter",
146        spec.schedule.jitter.as_deref(),
147    );
148    errs
149}
150
151/// Admission-only extras for a `SnapshotReplication`: the jitter 24h cap. The parse
152/// half already lives in `validate_snapshot_replication`.
153pub fn validate_snapshot_replication_admission_extras(
154    spec: &SnapshotReplicationSpec,
155) -> Vec<ValidationError> {
156    let mut errs = Vec::new();
157    push_jitter_bounds(
158        &mut errs,
159        "spec.schedule.jitter",
160        spec.schedule.jitter.as_deref(),
161    );
162    errs
163}
164
165// `Repository`/`ClusterRepository` deliberately have NO entry here. Everything
166// this change adds to them — `scheduleDefaults.jitter` (parse + bounds) and
167// `moverDefaults.podLabels`/`podAnnotations` (reserved keys, via
168// `super::validate_pod_metadata`) — concerns BRAND-NEW fields, so no stored object
169// can carry a value the rules reject and the rules are safe in
170// `validate_repository`/`validate_cluster_repository`, where the controller
171// re-checks them too. An empty extras fn per kind would be pure ceremony; the next
172// Repository *tightening* is what earns one.
173//
174// Worth recording the asymmetry the shared placement leaves behind: the
175// `Repository` reconciler re-runs only a PARTIAL validator set (see
176// `controller/src/repository.rs`), while `ClusterRepository` re-runs the full
177// aggregate. So the new `scheduleDefaults.jitter` rule is enforced at reconcile for
178// one kind and not the other. That is acceptable precisely because it is
179// admission that matters here — the webhook covers both kinds identically, and the
180// reconcile-side re-check is a defense-in-depth pass, not the gate.