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.