kopiur_api/gates.rs
1//! The **structural-gate registry**: the one enumeration of the condition
2//! `type`/`status`/`reason` triples that mean "this object is blocked on
3//! something only a human can change".
4//!
5//! A *structural* gate is not a transient retry. It never self-heals: the
6//! reconciler parks the object (usually at `phase: Pending`) and waits for an
7//! out-of-band change — a namespace opt-in annotation, a credentials `Secret`,
8//! an acknowledgement timestamp. Because the phase itself stays unremarkable,
9//! anything that diagnoses a cluster by phase alone reports all-green while the
10//! work is wedged (issue #359: `kubectl kopiur doctor` passed all checks with a
11//! `Snapshot` stuck on `MoverPermitted=False`).
12//!
13//! The fix is to make the gate set **shared by construction**. The controller
14//! writes conditions from these rows and the CLI's `doctor` iterates the same
15//! rows, so a gate added on the server side cannot be invisible to the client
16//! side: there is exactly one list, in `kopiur-api`, which both depend on.
17//!
18//! This module is pure data + pure functions — no `kube::Client`, no `tokio` —
19//! per the `api` ↔ `controller` split.
20
21use crate::consts;
22
23/// Which CR kinds a structural gate's condition is written on.
24///
25/// Deliberately coarse (kinds, not selectors): a gate row answers "when I look
26/// at an object of THIS kind, is this condition meaningful?", which is all a
27/// diagnostic needs to avoid hunting for a condition that can never appear.
28///
29/// ```
30/// use kopiur_api::gates::GateScope;
31///
32/// // A privileged-mover refusal is written on both work kinds.
33/// assert!(GateScope::SnapshotOrRestore.covers_snapshot());
34/// assert!(GateScope::SnapshotOrRestore.covers_restore());
35/// // The mass-deletion breaker's per-Snapshot hold is Snapshot-only.
36/// assert!(GateScope::Snapshot.covers_snapshot());
37/// assert!(!GateScope::Snapshot.covers_restore());
38/// // Repository gates live on Repository/ClusterRepository.
39/// assert!(GateScope::Repository.covers_repository());
40/// assert!(!GateScope::Repository.covers_snapshot());
41/// // A schedule-level block lives on the SnapshotSchedule itself.
42/// assert!(GateScope::SnapshotSchedule.covers_snapshot_schedule());
43/// assert!(!GateScope::Snapshot.covers_snapshot_schedule());
44/// // A recipe-level block lives on the SnapshotPolicy itself.
45/// assert!(GateScope::SnapshotPolicy.covers_snapshot_policy());
46/// assert!(!GateScope::SnapshotPolicy.covers_snapshot());
47/// ```
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49pub enum GateScope {
50 /// Written on both `Snapshot` and `Restore` CRs (the two "work" kinds that
51 /// launch mover Jobs, and whose reconcilers share the gate).
52 SnapshotOrRestore,
53 /// Written on `Snapshot` CRs only.
54 Snapshot,
55 /// Written on `Repository` and `ClusterRepository` CRs.
56 Repository,
57 /// Written on `SnapshotSchedule` CRs only — the schedule itself is blocked,
58 /// as opposed to any individual run being blocked.
59 SnapshotSchedule,
60 /// Written on `SnapshotPolicy` CRs only — the recipe itself is (partially)
61 /// blocked, as opposed to any individual run being blocked.
62 SnapshotPolicy,
63}
64
65impl GateScope {
66 /// Whether a `Snapshot` can carry a gate of this scope. Exhaustive.
67 pub fn covers_snapshot(self) -> bool {
68 match self {
69 Self::SnapshotOrRestore | Self::Snapshot => true,
70 Self::Repository | Self::SnapshotSchedule | Self::SnapshotPolicy => false,
71 }
72 }
73
74 /// Whether a `Restore` can carry a gate of this scope. Exhaustive.
75 pub fn covers_restore(self) -> bool {
76 match self {
77 Self::SnapshotOrRestore => true,
78 Self::Snapshot | Self::Repository | Self::SnapshotSchedule | Self::SnapshotPolicy => {
79 false
80 }
81 }
82 }
83
84 /// Whether a `Repository`/`ClusterRepository` can carry a gate of this
85 /// scope. Exhaustive.
86 pub fn covers_repository(self) -> bool {
87 match self {
88 Self::Repository => true,
89 Self::SnapshotOrRestore
90 | Self::Snapshot
91 | Self::SnapshotSchedule
92 | Self::SnapshotPolicy => false,
93 }
94 }
95
96 /// Whether a `SnapshotSchedule` can carry a gate of this scope. Exhaustive.
97 pub fn covers_snapshot_schedule(self) -> bool {
98 match self {
99 Self::SnapshotSchedule => true,
100 Self::SnapshotOrRestore | Self::Snapshot | Self::Repository | Self::SnapshotPolicy => {
101 false
102 }
103 }
104 }
105
106 /// Whether a `SnapshotPolicy` can carry a gate of this scope. Exhaustive.
107 pub fn covers_snapshot_policy(self) -> bool {
108 match self {
109 Self::SnapshotPolicy => true,
110 Self::SnapshotOrRestore
111 | Self::Snapshot
112 | Self::Repository
113 | Self::SnapshotSchedule => false,
114 }
115 }
116}
117
118/// How loudly a tripped gate should be reported.
119///
120/// `Fail` means "this will never complete until a human acts"; `Warn` means
121/// "this is blocked, but the block may well be the configuration you asked
122/// for" — so it must not, on its own, turn a diagnostic red.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum GateSeverity {
125 /// Work is wedged and cannot progress without an out-of-band change.
126 Fail,
127 /// Work is refused, but the refusal is a plausible deliberate choice.
128 Warn,
129}
130
131impl GateSeverity {
132 /// Stable display string (exhaustive `match`), for CLI output and tests.
133 pub fn label(self) -> &'static str {
134 match self {
135 Self::Fail => "Fail",
136 Self::Warn => "Warn",
137 }
138 }
139}
140
141/// The Kubernetes condition `status` string `"True"`. Named so a gate row's
142/// polarity is spelled out rather than being a bare literal at the row.
143pub const CONDITION_TRUE: &str = "True";
144/// The Kubernetes condition `status` string `"False"`.
145pub const CONDITION_FALSE: &str = "False";
146
147/// One human-actionable structural gate: a condition `type`, the `status` that
148/// means BLOCKED, the `reason` the writer stamps, the CR kinds it appears on,
149/// and how loudly to report it.
150///
151/// Polarity is per-row rather than implied, because kopiur has gates of both
152/// shapes: `MoverPermitted=False` blocks, and so does `DeletionHeld=True`.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub struct StructuralGate {
155 /// The CR kinds this gate's condition is written on.
156 pub applies_to: GateScope,
157 /// The condition `type` (a `consts` string, never a literal at the call site).
158 pub condition: &'static str,
159 /// The condition `status` that means BLOCKED — [`CONDITION_FALSE`] or
160 /// [`CONDITION_TRUE`].
161 pub blocked_status: &'static str,
162 /// The `reason` the reconciler stamps when it writes the blocked condition.
163 pub reason: &'static str,
164 /// How loudly a tripped gate is reported.
165 pub severity: GateSeverity,
166}
167
168impl StructuralGate {
169 /// Whether a live condition **is** this gate: `type`, `status`, AND `reason`
170 /// all match.
171 ///
172 /// This is the matcher consumers should reach for. `condition` + `status`
173 /// alone do not identify a row — `CredentialsAvailable=False` is written
174 /// with several different reasons and therefore has several rows — so
175 /// filtering the registry on [`trips`](Self::trips) alone yields multiple
176 /// hits for one live condition and double-reports it. `matches` selects exactly one row per
177 /// live condition, which is the property
178 /// `gate_rows_are_unique_and_internally_consistent` pins.
179 ///
180 /// ```
181 /// use kopiur_api::gates::STRUCTURAL_GATES;
182 ///
183 /// // One live condition off a wedged Snapshot ⇒ exactly one registry row.
184 /// let hits: Vec<_> = STRUCTURAL_GATES
185 /// .iter()
186 /// .filter(|g| g.matches("CredentialsAvailable", "False", "MissingCredentialsSecret"))
187 /// .collect();
188 /// assert_eq!(hits.len(), 1);
189 /// assert_eq!(hits[0].reason, "MissingCredentialsSecret");
190 /// // The other reason selects the other row, never both.
191 /// assert!(!hits[0].matches("CredentialsAvailable", "False", "MissingServiceAccount"));
192 /// ```
193 pub fn matches(&self, condition_type: &str, status: &str, reason: &str) -> bool {
194 self.trips(condition_type, status) && reason == self.reason
195 }
196
197 /// The **reason-agnostic** coarse filter: whether a live condition's
198 /// `type`/`status` pair is the blocked polarity of this gate's condition.
199 ///
200 /// The polarity comparison lives here so no consumer re-derives it (the
201 /// `!= "True"` / `== "True"` mix-ups this registry exists to prevent). Use
202 /// it to answer "is this condition one of the ones we gate on at all?" —
203 /// notably when a live condition carries a reason NO row covers (a newer
204 /// operator's reason string), where [`matches`](Self::matches) would
205 /// silently report nothing. To identify WHICH row a condition is, use
206 /// `matches`: `trips` can match several rows sharing a condition+polarity.
207 ///
208 /// ```
209 /// use kopiur_api::gates::{STRUCTURAL_GATES, GateScope};
210 ///
211 /// let mover = STRUCTURAL_GATES
212 /// .iter()
213 /// .find(|g| g.condition == "MoverPermitted")
214 /// .expect("the privileged-mover gate is registered");
215 /// assert!(mover.trips("MoverPermitted", "False"));
216 /// assert!(!mover.trips("MoverPermitted", "True"));
217 /// assert!(!mover.trips("Ready", "False"));
218 /// assert_eq!(mover.applies_to, GateScope::SnapshotOrRestore);
219 ///
220 /// // Coarse by design: an unregistered reason still flags the condition.
221 /// assert!(mover.trips("MoverPermitted", "False"));
222 /// ```
223 pub fn trips(&self, condition_type: &str, status: &str) -> bool {
224 condition_type == self.condition && status == self.blocked_status
225 }
226
227 /// [`blocked_status`](Self::blocked_status) as the `bool` a condition writer
228 /// passes when it writes this gate as BLOCKED — `true` for
229 /// [`CONDITION_TRUE`], `false` for [`CONDITION_FALSE`].
230 ///
231 /// The controller's `upsert_condition` takes a `bool`, so without this every
232 /// registry-driven writer would hand-translate the status string at its own
233 /// call site — exactly the per-site re-derivation this registry exists to
234 /// remove. Any other string reads as `false`; the
235 /// `every_gate_row_is_well_formed` tripwire makes that unreachable.
236 ///
237 /// ```
238 /// use kopiur_api::gates::STRUCTURAL_GATES;
239 ///
240 /// let mover = STRUCTURAL_GATES
241 /// .iter()
242 /// .find(|g| g.condition == "MoverPermitted")
243 /// .expect("registered");
244 /// assert!(!mover.blocked_is_true()); // MoverPermitted=False blocks
245 ///
246 /// let held = STRUCTURAL_GATES
247 /// .iter()
248 /// .find(|g| g.condition == "DeletionHeld")
249 /// .expect("registered");
250 /// assert!(held.blocked_is_true()); // DeletionHeld=True blocks
251 /// ```
252 pub fn blocked_is_true(&self) -> bool {
253 self.blocked_status == CONDITION_TRUE
254 }
255}
256
257/// An elevated mover in a namespace that has not opted in. The admin adds the
258/// `privileged-movers` annotation out-of-band; until then the object sits at
259/// `phase: Pending` — the exact shape of #359.
260pub const PRIVILEGED_MOVER_GATE: StructuralGate = StructuralGate {
261 applies_to: GateScope::SnapshotOrRestore,
262 condition: consts::MOVER_PERMITTED_CONDITION,
263 blocked_status: CONDITION_FALSE,
264 reason: consts::PRIVILEGED_MOVER_NOT_PERMITTED_REASON,
265 severity: GateSeverity::Fail,
266};
267
268/// A `stream` source in a namespace that has not opted in to
269/// [`STREAM_EXEC_ANNOTATION`](consts::STREAM_EXEC_ANNOTATION). Shares
270/// `MoverPermitted` with [`PRIVILEGED_MOVER_GATE`] — both answer "may this mover
271/// run here at all?" — but carries its own reason, because the fix is a different
272/// annotation and conflating them would send an admin to the wrong one.
273pub const STREAM_EXEC_GATE: StructuralGate = StructuralGate {
274 applies_to: GateScope::SnapshotOrRestore,
275 condition: consts::MOVER_PERMITTED_CONDITION,
276 blocked_status: CONDITION_FALSE,
277 reason: consts::STREAM_EXEC_NOT_PERMITTED_REASON,
278 severity: GateSeverity::Fail,
279};
280
281/// The mover's credential `Secret` is not in the workload namespace. Parks at
282/// `phase: Pending` until the user creates it (or enables projection).
283pub const MISSING_CREDENTIALS_GATE: StructuralGate = StructuralGate {
284 applies_to: GateScope::SnapshotOrRestore,
285 condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
286 blocked_status: CONDITION_FALSE,
287 reason: consts::MISSING_CREDENTIALS_REASON,
288 severity: GateSeverity::Fail,
289};
290
291/// Same condition as [`MISSING_CREDENTIALS_GATE`], different missing
292/// dependency: the workload-identity `ServiceAccount` the backend names.
293/// kopiur never creates it.
294pub const MISSING_SERVICE_ACCOUNT_GATE: StructuralGate = StructuralGate {
295 applies_to: GateScope::SnapshotOrRestore,
296 condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
297 blocked_status: CONDITION_FALSE,
298 reason: consts::MISSING_SERVICE_ACCOUNT_REASON,
299 severity: GateSeverity::Fail,
300};
301
302/// Same condition as [`MISSING_CREDENTIALS_GATE`], a third missing
303/// dependency: the backend's `tls.caBundleRef` ConfigMap (the PEM CA bundle
304/// for a private-CA S3 endpoint). kopiur never creates it, and a ConfigMap
305/// that never appears never self-heals — the exact phase-invisible park shape
306/// of #359.
307pub const MISSING_CA_BUNDLE_GATE: StructuralGate = StructuralGate {
308 applies_to: GateScope::SnapshotOrRestore,
309 condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
310 blocked_status: CONDITION_FALSE,
311 reason: consts::MISSING_CA_BUNDLE_REASON,
312 severity: GateSeverity::Fail,
313};
314
315/// Inverted polarity: the per-`Snapshot` hold the mass-deletion breaker
316/// applies. Released only by the `allow-mass-deletion` acknowledgement on the
317/// repository, so it is squarely "needs a human".
318pub const DELETION_HELD_GATE: StructuralGate = StructuralGate {
319 applies_to: GateScope::Snapshot,
320 condition: consts::DELETION_HELD_CONDITION,
321 blocked_status: CONDITION_TRUE,
322 reason: consts::MASS_DELETION_BREAKER_REASON,
323 severity: GateSeverity::Fail,
324};
325
326/// The repository-level view of the same breaker: a whole wave is held.
327pub const MASS_DELETION_HELD_GATE: StructuralGate = StructuralGate {
328 applies_to: GateScope::Repository,
329 condition: consts::MASS_DELETION_HELD_CONDITION,
330 blocked_status: CONDITION_TRUE,
331 reason: consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
332 severity: GateSeverity::Fail,
333};
334
335/// A backup refused because its repository is `mode: ReadOnly`.
336///
337/// Unlike the other rows this is NOT an invisible park: the writer
338/// (`snapshot/mod.rs`) sets `phase: Failed` in the same status patch, so a
339/// phase-based check already sees the object. It is registered for EXPLANATORY
340/// value — it turns "this backup failed" into "this backup was refused because
341/// the repository is read-only" without a second lookup. Hence WARN, not Fail:
342/// a read-only repository is a legitimate, deliberate configuration (a
343/// replication target, an archived repo served for restores only), so a
344/// green/red verdict must not hinge on it.
345pub const REPOSITORY_READ_ONLY_GATE: StructuralGate = StructuralGate {
346 applies_to: GateScope::Snapshot,
347 condition: consts::REPOSITORY_WRITABLE_CONDITION,
348 blocked_status: CONDITION_FALSE,
349 reason: consts::REPOSITORY_READ_ONLY_REASON,
350 severity: GateSeverity::Warn,
351};
352
353/// Version skew, one kind removed: a previous run of this schedule sits at a
354/// phase string this build cannot interpret, so it can never be observed to
355/// finish. Under the default `concurrencyPolicy: Forbid` the schedule stops
356/// firing FOREVER while looking perfectly healthy — the concurrency gate is
357/// doing exactly what it was asked to. The out-of-band change that clears it is
358/// finishing the operator rollout (or deleting the wedged `Snapshot`), which is
359/// squarely "needs a human", so: Fail.
360pub const SCHEDULE_BLOCKED_GATE: StructuralGate = StructuralGate {
361 applies_to: GateScope::SnapshotSchedule,
362 condition: consts::SCHEDULE_RUNNABLE_CONDITION,
363 blocked_status: CONDITION_FALSE,
364 reason: consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
365 severity: GateSeverity::Fail,
366};
367
368/// A `SnapshotPolicy` referencing at least one repository that is not `Ready`.
369/// The policy reconciler keeps processing the READY subset (multi-repository
370/// fan-out, #368) but defers backups/retention/adoption/verification against
371/// the rest, and requeues until every repository recovers. Warn, not Fail: a
372/// repository deliberately taken down (a migration, a powered-off NAS target)
373/// is a plausible operator choice, the ready subset keeps working, and the
374/// per-child parks behind the same outage already carry their own reporting
375/// (`kopiur_snapshot_gated`, `KopiurRepositoryNotReady`) — so this gate must
376/// explain, not independently turn a diagnostic red.
377pub const POLICY_REPOSITORY_NOT_READY_GATE: StructuralGate = StructuralGate {
378 applies_to: GateScope::SnapshotPolicy,
379 condition: consts::REPOSITORIES_READY_CONDITION,
380 blocked_status: CONDITION_FALSE,
381 reason: consts::REPOSITORY_NOT_READY_REASON,
382 severity: GateSeverity::Warn,
383};
384
385/// A `SnapshotSchedule` whose members × repositories cross-product exceeds the
386/// fan-out cap: the fired slot was SKIPPED, and every future slot will keep
387/// skipping until the selector is narrowed or the policy's repository list
388/// shrunk — no backups run while everything else looks healthy, the exact
389/// silent-wedge shape of #359. The writer asserts BOTH polarities each fire
390/// pass, so the gate self-clears the moment a slot mints fully. Promoted into
391/// the registry by the #368 M10 gates/doctor checklist (previously a
392/// controller-internal condition doctor could not see).
393pub const SCHEDULE_FANOUT_CAPPED_GATE: StructuralGate = StructuralGate {
394 applies_to: GateScope::SnapshotSchedule,
395 condition: consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
396 blocked_status: CONDITION_TRUE,
397 reason: consts::FANOUT_TOO_LARGE_REASON,
398 severity: GateSeverity::Fail,
399};
400
401/// A backup whose DIRECT source PVC (`spec.sources[].pvc`) does not exist at
402/// launch time. The `Snapshot` parks at `phase: Pending` on the slow
403/// structural cadence and, once the controller's missing-source deadline
404/// passes, flips terminally `Failed` — a PVC that never reappears never
405/// self-heals, and only recreating it (or repointing the policy's sources) can
406/// clear the block. Written on the Snapshot only for the direct-source case: a
407/// vanished operator-staged claim (copyMethod Snapshot/Clone) is a restage
408/// race and stays a plain transient retry, never this gate.
409pub const SOURCE_PVC_MISSING_GATE: StructuralGate = StructuralGate {
410 applies_to: GateScope::Snapshot,
411 condition: consts::SOURCE_PVC_AVAILABLE_CONDITION,
412 blocked_status: CONDITION_FALSE,
413 reason: consts::SOURCE_PVC_MISSING_REASON,
414 severity: GateSeverity::Fail,
415};
416
417/// A `Snapshot` or `Restore` whose `mover.inheritSecurityContextFrom` names a
418/// LIVE-pod source that resolved nothing — no pod matched the selector (the
419/// workload is scaled to zero, which is precisely when a quiesced backup is
420/// most useful), the named container is absent, or the matched pod sets neither
421/// a container nor a pod-level `securityContext` — and whose recipe pins no
422/// fallback `runAsUser` to proceed on (#464).
423///
424/// The run parks at `phase: Pending` and re-checks on the slow structural
425/// cadence. Holding is deliberate: a backup taken as the wrong UID is worse
426/// than a backup that did not run. Nothing in kopiur can scale a workload up,
427/// fix a label selector, or invent the identity the data expects, so this is
428/// squarely "needs a human" — hence Fail, and hence registered: before #464 the
429/// hold surfaced as a generic transient `MissingDependency` with no condition
430/// naming the selector that matched nothing.
431///
432/// Its own condition rather than a reason on the advisory
433/// `SecurityContextInherited` — see
434/// [`consts::SECURITY_CONTEXT_RESOLVED_CONDITION`] for why that distinction is
435/// load-bearing for `kubectl kopiur doctor`.
436pub const INHERIT_SOURCE_MISSING_GATE: StructuralGate = StructuralGate {
437 applies_to: GateScope::SnapshotOrRestore,
438 condition: consts::SECURITY_CONTEXT_RESOLVED_CONDITION,
439 blocked_status: CONDITION_FALSE,
440 reason: consts::INHERIT_SOURCE_MISSING_REASON,
441 severity: GateSeverity::Fail,
442};
443
444/// A `spec.seed` in migrate mode whose SOURCE repository is missing or not
445/// `Ready` (issue #380). The repository parks `Pending` with `Seeded=False`
446/// and re-checks; nothing in kopiur can bring the source up, so it is squarely
447/// "needs a human" — and the park is phase-INVISIBLE in the worst way: a
448/// brand-new repository sitting at `Pending` looks identical to one that is
449/// simply still bootstrapping, and it will sit there for as long as the source
450/// is down.
451///
452/// WARN rather than Fail, for two reasons that point the same way. The
453/// registry pins one severity per condition+scope
454/// (`gate_rows_are_unique_and_internally_consistent`), and the sibling
455/// [`SEEDING_GATE`] on this same condition *must* be Warn — a seed legitimately
456/// runs for hours, and a healthy copy must not turn a diagnostic red. And on
457/// its own merits this park is the same shape as
458/// [`POLICY_REPOSITORY_NOT_READY_GATE`]: a source repository that is still
459/// bootstrapping alongside this one (the ordinary DR bring-up) comes up by
460/// itself, and one deliberately taken down is a plausible operator choice. The
461/// block is reported either way, with the writer's message — which is what
462/// makes it worth registering.
463pub const SEED_SOURCE_NOT_READY_GATE: StructuralGate = StructuralGate {
464 applies_to: GateScope::Repository,
465 condition: consts::SEEDED_CONDITION,
466 blocked_status: CONDITION_FALSE,
467 reason: consts::WAITING_FOR_SEED_SOURCE_REASON,
468 severity: GateSeverity::Warn,
469};
470
471/// A migrate-mode `spec.seed` whose LOCAL backend and resolved SOURCE
472/// repository disagree on workload identity (issue #380).
473///
474/// The blob arm of this rule is refused at admission
475/// (`validate_replication_auth`), but a `seed.from.repository` reference hides
476/// the source's backend from a spec-only validator, so the controller
477/// re-applies the same rule once it has resolved the source and parks here
478/// instead. Without the park the CR is admitted, a Job is launched, and the
479/// failure surfaces as a bare cloud auth error from whichever side the pod's
480/// single ServiceAccount is not.
481///
482/// WARN for the same reason every other `Seeded` row is: the registry pins one
483/// severity per condition+scope, and the in-flight [`SEEDING_GATE`] must not
484/// turn a diagnostic red. Nothing is lost — the message names both
485/// ServiceAccounts and the two ways out, and the repository is not `Ready`,
486/// which a diagnostic fails on independently.
487pub const SEED_SOURCE_AUTH_CONFLICT_GATE: StructuralGate = StructuralGate {
488 applies_to: GateScope::Repository,
489 condition: consts::SEEDED_CONDITION,
490 blocked_status: CONDITION_FALSE,
491 reason: consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
492 severity: GateSeverity::Warn,
493};
494
495/// A seeding bootstrap Job is in flight (issue #380): the repository is
496/// copying a whole repository across and is legitimately not `Ready` yet.
497///
498/// Registered for EXPLANATORY value, like [`REPOSITORY_READ_ONLY_GATE`]: a seed
499/// runs for hours by design (its Job deadline defaults to 24h), so a
500/// diagnostic must be able to say "seeding" rather than "stuck at Pending".
501/// Hence WARN — progress is not a fault, and this row must never on its own
502/// turn a diagnostic red.
503pub const SEEDING_GATE: StructuralGate = StructuralGate {
504 applies_to: GateScope::Repository,
505 condition: consts::SEEDED_CONDITION,
506 blocked_status: CONDITION_FALSE,
507 reason: consts::SEEDING_REASON,
508 severity: GateSeverity::Warn,
509};
510
511/// A `spec.seed` whose SOURCE answered but holds no kopia repository. Retried
512/// automatically every ~2 minutes, so it clears itself the moment the source
513/// exists — but until then the repository never becomes `Ready`, and the fix
514/// (repoint `spec.seed.from`) is squarely out-of-band.
515pub const SEED_SOURCE_NOT_FOUND_GATE: StructuralGate = StructuralGate {
516 applies_to: GateScope::Repository,
517 condition: consts::SEEDED_CONDITION,
518 blocked_status: CONDITION_FALSE,
519 reason: consts::SEED_SOURCE_NOT_FOUND_REASON,
520 severity: GateSeverity::Warn,
521};
522
523/// A `spec.seed` whose source is a real repository holding zero snapshots, with
524/// `allowEmptySource` at its `false` default. Blocking `Ready` is the point: a
525/// valid-but-empty mirror is nearly always mis-pointed.
526pub const SEED_SOURCE_EMPTY_GATE: StructuralGate = StructuralGate {
527 applies_to: GateScope::Repository,
528 condition: consts::SEEDED_CONDITION,
529 blocked_status: CONDITION_FALSE,
530 reason: consts::SEED_SOURCE_EMPTY_REASON,
531 severity: GateSeverity::Warn,
532};
533
534/// A migrate-mode seed whose post-verify found snapshots missing. The next
535/// attempt resumes the copy, so this converges on its own — but a seed that
536/// keeps being cut short needs a human to raise its deadline.
537pub const SEED_INCOMPLETE_GATE: StructuralGate = StructuralGate {
538 applies_to: GateScope::Repository,
539 condition: consts::SEEDED_CONDITION,
540 blocked_status: CONDITION_FALSE,
541 reason: consts::SEED_INCOMPLETE_REASON,
542 severity: GateSeverity::Warn,
543};
544
545/// A seed that was armed and left the repository holding ZERO snapshots. Like
546/// [`SEED_INCOMPLETE_GATE`], the next attempt resumes the copy.
547pub const SEED_LEFT_EMPTY_GATE: StructuralGate = StructuralGate {
548 applies_to: GateScope::Repository,
549 condition: consts::SEEDED_CONDITION,
550 blocked_status: CONDITION_FALSE,
551 reason: consts::SEED_LEFT_EMPTY_REASON,
552 severity: GateSeverity::Warn,
553};
554
555/// The mover-skew guard: the running mover image predates `spec.seed`, ignored
556/// it, and initialized an EMPTY repository. Genuinely terminal and genuinely
557/// human-actionable (upgrade the image, delete the empty repository and the
558/// terminal bootstrap Job) — the ONE row here that would earn `Fail` on its own
559/// merits. It is `Warn` because the registry pins one severity per
560/// condition+scope and its siblings must not turn a healthy in-progress seed
561/// red; nothing is lost, because a repository in this state is also not `Ready`,
562/// which `doctor`'s repository check fails on independently.
563pub const SEED_MOVER_TOO_OLD_GATE: StructuralGate = StructuralGate {
564 applies_to: GateScope::Repository,
565 condition: consts::SEEDED_CONDITION,
566 blocked_status: CONDITION_FALSE,
567 reason: consts::SEED_MOVER_TOO_OLD_REASON,
568 severity: GateSeverity::Warn,
569};
570
571/// A `Restore` whose repository referent does not exist (issue #393): the
572/// explicit `spec.repository` object, or the `source.fromPolicy`
573/// `SnapshotPolicy` the repository ref is derived from.
574///
575/// The readiness gate cannot verify a repository it cannot even look up, so the
576/// restore parks at `phase: Pending` and — critically — its
577/// `policy.waitTimeout` window is NOT opened (`status.waitStartedAt` stays
578/// unstamped) until the referent appears and its repository becomes `Ready`.
579/// Before #393 the gate fell through unverified here, so a slow-arriving
580/// referent silently spent the window; for a `fromPolicy` source, whose
581/// `onMissingSnapshot` defaults to `Continue`, a spent window means an EMPTY
582/// volume.
583///
584/// WARN, not Fail, for the reason [`POLICY_REPOSITORY_NOT_READY_GATE`] is:
585/// referents applied moments apart by GitOps are the ordinary case and resolve
586/// themselves within a requeue or two, so this must explain the park rather
587/// than independently turn a diagnostic red. What it must never be is INVISIBLE
588/// — a restore parked on a `SnapshotPolicy` that was never applied looks
589/// identical, by phase, to one that is simply still resolving.
590pub const RESTORE_REFERENT_MISSING_GATE: StructuralGate = StructuralGate {
591 applies_to: GateScope::SnapshotOrRestore,
592 condition: consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
593 blocked_status: CONDITION_FALSE,
594 reason: consts::RESTORE_REFERENT_MISSING_REASON,
595 severity: GateSeverity::Warn,
596};
597
598/// Every human-actionable structural gate kopiur's reconcilers can park an
599/// object on.
600///
601/// Adding a gate to a reconciler means adding a row here; the CLI picks it up
602/// with no change, which is the whole point (#359). A condition that merely
603/// *reports* health (`IndexBlobHealth`, `BackendReachable`,
604/// `SecurityContextCompatible`) is NOT a gate — it blocks nothing — and a
605/// time-bounded wait (`SourceStaged`, `PreflightFailed`) is not one either,
606/// because it resolves itself into a terminal phase on its own.
607///
608/// Most rows are phase-INVISIBLE parks (the object sits at `phase: Pending`
609/// looking unremarkable), which is what makes the registry load-bearing. One
610/// row — [`REPOSITORY_READ_ONLY_GATE`] — is phase-visible and registered
611/// anyway, for the explanation it adds to an already-visible failure.
612///
613/// Each row is also a named `const` so a reconciler can write its condition
614/// FROM the row (`io::upsert_gate`) instead of restating the triple at the call
615/// site. A row nobody writes, or a writer nobody registered, is caught by the
616/// controller's `every_registered_gate_has_a_writer` drift test.
617pub const STRUCTURAL_GATES: &[StructuralGate] = &[
618 PRIVILEGED_MOVER_GATE,
619 STREAM_EXEC_GATE,
620 MISSING_CREDENTIALS_GATE,
621 MISSING_SERVICE_ACCOUNT_GATE,
622 MISSING_CA_BUNDLE_GATE,
623 DELETION_HELD_GATE,
624 MASS_DELETION_HELD_GATE,
625 REPOSITORY_READ_ONLY_GATE,
626 SCHEDULE_BLOCKED_GATE,
627 SCHEDULE_FANOUT_CAPPED_GATE,
628 POLICY_REPOSITORY_NOT_READY_GATE,
629 SOURCE_PVC_MISSING_GATE,
630 INHERIT_SOURCE_MISSING_GATE,
631 RESTORE_REFERENT_MISSING_GATE,
632 SEED_SOURCE_NOT_READY_GATE,
633 SEED_SOURCE_AUTH_CONFLICT_GATE,
634 SEEDING_GATE,
635 SEED_SOURCE_NOT_FOUND_GATE,
636 SEED_SOURCE_EMPTY_GATE,
637 SEED_INCOMPLETE_GATE,
638 SEED_LEFT_EMPTY_GATE,
639 SEED_MOVER_TOO_OLD_GATE,
640];
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use std::collections::HashSet;
646
647 #[test]
648 fn every_gate_row_is_well_formed() {
649 // Tripwire: a row added with an empty/typo'd string, or a polarity that
650 // is neither True nor False, would make the gate silently unmatchable
651 // on both sides of the contract.
652 assert!(
653 !STRUCTURAL_GATES.is_empty(),
654 "the registry is the shared gate list; an empty one means doctor checks nothing"
655 );
656 for g in STRUCTURAL_GATES {
657 assert!(
658 !g.condition.is_empty(),
659 "{g:?}: condition must be non-empty"
660 );
661 assert!(!g.reason.is_empty(), "{g:?}: reason must be non-empty");
662 assert!(
663 g.blocked_status == CONDITION_TRUE || g.blocked_status == CONDITION_FALSE,
664 "{g:?}: blocked_status must be a Kubernetes condition status"
665 );
666 // The row must match itself and reject the opposite polarity.
667 assert!(g.trips(g.condition, g.blocked_status), "{g:?}: self-match");
668 let opposite = if g.blocked_status == CONDITION_TRUE {
669 CONDITION_FALSE
670 } else {
671 CONDITION_TRUE
672 };
673 assert!(
674 !g.trips(g.condition, opposite),
675 "{g:?}: must not trip on the opposite polarity"
676 );
677 assert!(
678 !g.trips("SomeOtherCondition", g.blocked_status),
679 "{g:?}: must not trip on another condition type"
680 );
681 }
682 }
683
684 #[test]
685 fn a_live_condition_selects_exactly_one_row() {
686 // The whole registry must be a FUNCTION of a live condition: one
687 // (type, status, reason) triple in, at most one row out. Rows 2 and 3
688 // share condition+status+scope and differ only by reason, so a
689 // consumer filtering on `trips` alone would double-report a single
690 // wedged Snapshot. `matches` is the matcher that cannot.
691 for g in STRUCTURAL_GATES {
692 let hits: Vec<_> = STRUCTURAL_GATES
693 .iter()
694 .filter(|c| c.matches(g.condition, g.blocked_status, g.reason))
695 .collect();
696 assert_eq!(hits.len(), 1, "{g:?}: must select exactly one row");
697 assert_eq!(hits[0], g, "{g:?}: must select ITSELF");
698 }
699
700 // The concrete case the reviewer called out, spelled out end to end.
701 let creds_secret: Vec<_> = STRUCTURAL_GATES
702 .iter()
703 .filter(|g| {
704 g.matches(
705 consts::CREDENTIALS_AVAILABLE_CONDITION,
706 CONDITION_FALSE,
707 consts::MISSING_CREDENTIALS_REASON,
708 )
709 })
710 .collect();
711 assert_eq!(creds_secret.len(), 1);
712 assert_eq!(creds_secret[0].reason, consts::MISSING_CREDENTIALS_REASON);
713
714 // ...and the coarse filter deliberately still matches BOTH rows, which
715 // is why it must never be used to identify a row.
716 let coarse = STRUCTURAL_GATES
717 .iter()
718 .filter(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
719 .count();
720 assert_eq!(coarse, 3, "`trips` is reason-agnostic by design");
721
722 // An unregistered reason for a gated condition: `matches` finds
723 // nothing, `trips` still flags it. That asymmetry is the reason
724 // `trips` is kept rather than removed.
725 assert!(!STRUCTURAL_GATES.iter().any(|g| g.matches(
726 consts::CREDENTIALS_AVAILABLE_CONDITION,
727 CONDITION_FALSE,
728 "SomeFutureReason"
729 )));
730 assert!(
731 STRUCTURAL_GATES
732 .iter()
733 .any(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
734 );
735 }
736
737 #[test]
738 fn blocked_is_true_mirrors_the_status_string() {
739 // The bool a registry-driven `upsert_condition` writer passes. Derived
740 // from the row, never hand-translated per call site.
741 for g in STRUCTURAL_GATES {
742 assert_eq!(
743 g.blocked_is_true(),
744 g.blocked_status == CONDITION_TRUE,
745 "{g:?}"
746 );
747 }
748 // Both polarities are actually exercised by the live registry, so this
749 // can never rot into a one-sided assertion.
750 assert!(STRUCTURAL_GATES.iter().any(|g| g.blocked_is_true()));
751 assert!(STRUCTURAL_GATES.iter().any(|g| !g.blocked_is_true()));
752 }
753
754 #[test]
755 fn gate_rows_are_unique_and_internally_consistent() {
756 // A copy-paste duplicate would double-report the same block. Two rows
757 // MAY share a condition+scope when the writer stamps different reasons
758 // for it (CredentialsAvailable: missing Secret vs missing SA) — but
759 // then they must agree on polarity and severity, or a diagnostic's
760 // verdict would depend on which row it happened to match first.
761 let mut seen: HashSet<(&str, &str, GateScope)> = HashSet::new();
762 for g in STRUCTURAL_GATES {
763 assert!(
764 seen.insert((g.condition, g.reason, g.applies_to)),
765 "{g:?}: duplicate condition+reason+scope row"
766 );
767 }
768 for a in STRUCTURAL_GATES {
769 for b in STRUCTURAL_GATES {
770 if a.condition == b.condition && a.applies_to == b.applies_to {
771 assert_eq!(
772 a.blocked_status, b.blocked_status,
773 "{a:?} / {b:?}: same condition+scope, different polarity"
774 );
775 assert_eq!(
776 a.severity, b.severity,
777 "{a:?} / {b:?}: same condition+scope, different severity"
778 );
779 }
780 }
781 }
782 }
783
784 #[test]
785 fn every_seeded_false_reason_this_build_writes_has_a_row() {
786 // The `Seeded` condition is the one gated condition with a WIDE reason
787 // set, and a partial registration is worse than none: a reason with no
788 // row still trips `StructuralGate::trips`, so `kubectl kopiur doctor`
789 // classifies it as UNREGISTERED and tells the operator "the operator is
790 // newer than the plugin — upgrade the plugin". That would be a false
791 // diagnosis handed to someone mid-disaster-recovery, whose actual
792 // problem is a mis-pointed seed source.
793 //
794 // So: every reason this build can stamp on `Seeded=False` must select
795 // exactly one row. The list is assembled from the three park/progress
796 // reasons plus the shared failure set, so a new reason added to
797 // `consts::SEED_FAILURE_REASONS` fails here until it is registered.
798 let mut reasons: Vec<&str> = vec![
799 consts::WAITING_FOR_SEED_SOURCE_REASON,
800 consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
801 consts::SEEDING_REASON,
802 ];
803 reasons.extend_from_slice(consts::SEED_FAILURE_REASONS);
804 for reason in reasons {
805 let hits: Vec<_> = STRUCTURAL_GATES
806 .iter()
807 .filter(|g| g.matches(consts::SEEDED_CONDITION, CONDITION_FALSE, reason))
808 .collect();
809 assert_eq!(
810 hits.len(),
811 1,
812 "Seeded=False reason `{reason}` selects {} rows, not 1 — doctor would report it \
813 as an unknown reason from a newer operator",
814 hits.len()
815 );
816 assert!(hits[0].applies_to.covers_repository());
817 }
818 // Guard against the reverse rot: every registered `Seeded` row must be
819 // one of those reasons, so a row nobody can write cannot linger.
820 for g in STRUCTURAL_GATES
821 .iter()
822 .filter(|g| g.condition == consts::SEEDED_CONDITION)
823 {
824 assert!(
825 g.reason == consts::WAITING_FOR_SEED_SOURCE_REASON
826 || g.reason == consts::SEED_SOURCE_AUTH_CONFLICT_REASON
827 || g.reason == consts::SEEDING_REASON
828 || consts::SEED_FAILURE_REASONS.contains(&g.reason),
829 "{g:?}: registered but not a reason this build writes"
830 );
831 }
832 }
833
834 /// Every `Seeded=False` failure reason must be EXPLAINED in the docs, not
835 /// just registered (#380).
836 ///
837 /// The registry above guarantees `kubectl kopiur doctor` recognizes a
838 /// reason; it says nothing about whether a human who reads it can find out
839 /// what to do. These reasons surface in exactly one situation — a disaster
840 /// recovery that is not going well — so an unexplained one is worse here
841 /// than almost anywhere else in kopiur. The two pages are the two places
842 /// someone actually looks: the troubleshooting table (symptom → fix) and the
843 /// DR scenario (the reason table plus the retry/terminal contract).
844 ///
845 /// A reason added to `consts::SEED_FAILURE_REASONS` — or to the
846 /// terminal-until-edited park set the body extends it with — fails here
847 /// until both pages mention it by name.
848 #[test]
849 fn every_seed_failure_reason_is_documented_on_both_user_facing_pages() {
850 // CARGO_MANIFEST_DIR = crates/api; the docs tree is at the repo root.
851 // Same relative-path approach `crates/api/tests/examples_match_crd_shapes.rs`
852 // uses to reach `deploy/examples`.
853 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
854 for page in [
855 "docs/src/content/docs/troubleshooting.mdx",
856 "docs/src/content/docs/scenarios/dr-with-replicated-repository.mdx",
857 ] {
858 let path = root.join(page);
859 let text = std::fs::read_to_string(&path)
860 .unwrap_or_else(|e| panic!("{page} must exist and be readable: {e}"));
861 // The mover's failure classes, PLUS the one park reason that is
862 // equally terminal-until-edited: a migrate-mode workload-identity
863 // conflict is re-checked forever and never clears by itself, so
864 // doctor prints it at someone who needs the same lookup. The two
865 // progress/park reasons that DO clear on their own
866 // (`Seeding`, `WaitingForSeedSource`) are deliberately not required
867 // here — they describe motion, not a thing to look up.
868 let documented: Vec<&str> = consts::SEED_FAILURE_REASONS
869 .iter()
870 .copied()
871 .chain(std::iter::once(consts::SEED_SOURCE_AUTH_CONFLICT_REASON))
872 .collect();
873 for reason in documented {
874 assert!(
875 text.contains(reason),
876 "{page} never mentions the `Seeded=False` reason `{reason}` — doctor will \
877 print it at a user who then has nowhere to look it up"
878 );
879 }
880 }
881 }
882
883 #[test]
884 fn expected_gates_are_registered_with_expected_scope_and_severity() {
885 // Pins the exact contract M3's doctor consumes. A row removed, or its
886 // severity/scope quietly changed, fails here rather than silently
887 // changing what a cluster diagnostic reports.
888 let expected: &[(&str, &str, &str, GateScope, GateSeverity)] = &[
889 (
890 consts::MOVER_PERMITTED_CONDITION,
891 CONDITION_FALSE,
892 consts::PRIVILEGED_MOVER_NOT_PERMITTED_REASON,
893 GateScope::SnapshotOrRestore,
894 GateSeverity::Fail,
895 ),
896 (
897 consts::MOVER_PERMITTED_CONDITION,
898 CONDITION_FALSE,
899 consts::STREAM_EXEC_NOT_PERMITTED_REASON,
900 GateScope::SnapshotOrRestore,
901 GateSeverity::Fail,
902 ),
903 (
904 consts::CREDENTIALS_AVAILABLE_CONDITION,
905 CONDITION_FALSE,
906 consts::MISSING_CREDENTIALS_REASON,
907 GateScope::SnapshotOrRestore,
908 GateSeverity::Fail,
909 ),
910 (
911 consts::CREDENTIALS_AVAILABLE_CONDITION,
912 CONDITION_FALSE,
913 consts::MISSING_SERVICE_ACCOUNT_REASON,
914 GateScope::SnapshotOrRestore,
915 GateSeverity::Fail,
916 ),
917 (
918 consts::CREDENTIALS_AVAILABLE_CONDITION,
919 CONDITION_FALSE,
920 consts::MISSING_CA_BUNDLE_REASON,
921 GateScope::SnapshotOrRestore,
922 GateSeverity::Fail,
923 ),
924 (
925 consts::DELETION_HELD_CONDITION,
926 CONDITION_TRUE,
927 consts::MASS_DELETION_BREAKER_REASON,
928 GateScope::Snapshot,
929 GateSeverity::Fail,
930 ),
931 (
932 consts::MASS_DELETION_HELD_CONDITION,
933 CONDITION_TRUE,
934 consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
935 GateScope::Repository,
936 GateSeverity::Fail,
937 ),
938 (
939 consts::REPOSITORY_WRITABLE_CONDITION,
940 CONDITION_FALSE,
941 consts::REPOSITORY_READ_ONLY_REASON,
942 GateScope::Snapshot,
943 GateSeverity::Warn,
944 ),
945 (
946 consts::SCHEDULE_RUNNABLE_CONDITION,
947 CONDITION_FALSE,
948 consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
949 GateScope::SnapshotSchedule,
950 GateSeverity::Fail,
951 ),
952 (
953 consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
954 CONDITION_TRUE,
955 consts::FANOUT_TOO_LARGE_REASON,
956 GateScope::SnapshotSchedule,
957 GateSeverity::Fail,
958 ),
959 (
960 consts::REPOSITORIES_READY_CONDITION,
961 CONDITION_FALSE,
962 consts::REPOSITORY_NOT_READY_REASON,
963 GateScope::SnapshotPolicy,
964 GateSeverity::Warn,
965 ),
966 (
967 consts::SOURCE_PVC_AVAILABLE_CONDITION,
968 CONDITION_FALSE,
969 consts::SOURCE_PVC_MISSING_REASON,
970 GateScope::Snapshot,
971 GateSeverity::Fail,
972 ),
973 (
974 consts::SECURITY_CONTEXT_RESOLVED_CONDITION,
975 CONDITION_FALSE,
976 consts::INHERIT_SOURCE_MISSING_REASON,
977 GateScope::SnapshotOrRestore,
978 GateSeverity::Fail,
979 ),
980 (
981 consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
982 CONDITION_FALSE,
983 consts::RESTORE_REFERENT_MISSING_REASON,
984 GateScope::SnapshotOrRestore,
985 GateSeverity::Warn,
986 ),
987 (
988 consts::SEEDED_CONDITION,
989 CONDITION_FALSE,
990 consts::WAITING_FOR_SEED_SOURCE_REASON,
991 GateScope::Repository,
992 GateSeverity::Warn,
993 ),
994 (
995 consts::SEEDED_CONDITION,
996 CONDITION_FALSE,
997 consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
998 GateScope::Repository,
999 GateSeverity::Warn,
1000 ),
1001 (
1002 consts::SEEDED_CONDITION,
1003 CONDITION_FALSE,
1004 consts::SEEDING_REASON,
1005 GateScope::Repository,
1006 GateSeverity::Warn,
1007 ),
1008 (
1009 consts::SEEDED_CONDITION,
1010 CONDITION_FALSE,
1011 consts::SEED_SOURCE_NOT_FOUND_REASON,
1012 GateScope::Repository,
1013 GateSeverity::Warn,
1014 ),
1015 (
1016 consts::SEEDED_CONDITION,
1017 CONDITION_FALSE,
1018 consts::SEED_SOURCE_EMPTY_REASON,
1019 GateScope::Repository,
1020 GateSeverity::Warn,
1021 ),
1022 (
1023 consts::SEEDED_CONDITION,
1024 CONDITION_FALSE,
1025 consts::SEED_INCOMPLETE_REASON,
1026 GateScope::Repository,
1027 GateSeverity::Warn,
1028 ),
1029 (
1030 consts::SEEDED_CONDITION,
1031 CONDITION_FALSE,
1032 consts::SEED_LEFT_EMPTY_REASON,
1033 GateScope::Repository,
1034 GateSeverity::Warn,
1035 ),
1036 (
1037 consts::SEEDED_CONDITION,
1038 CONDITION_FALSE,
1039 consts::SEED_MOVER_TOO_OLD_REASON,
1040 GateScope::Repository,
1041 GateSeverity::Warn,
1042 ),
1043 ];
1044 assert_eq!(
1045 STRUCTURAL_GATES.len(),
1046 expected.len(),
1047 "a gate row was added or removed — update the pinned expectations \
1048 (and M3's doctor coverage) deliberately"
1049 );
1050 for (condition, status, reason, scope, severity) in expected {
1051 let row = STRUCTURAL_GATES
1052 .iter()
1053 .find(|g| g.condition == *condition && g.reason == *reason)
1054 .unwrap_or_else(|| panic!("{condition}/{reason} must be registered"));
1055 assert_eq!(row.blocked_status, *status, "{condition}/{reason} polarity");
1056 assert_eq!(row.applies_to, *scope, "{condition}/{reason} scope");
1057 assert_eq!(row.severity, *severity, "{condition}/{reason} severity");
1058 }
1059 }
1060
1061 /// The #393 park must be visible to `doctor` on a `Restore` — and must not
1062 /// make any OTHER condition read as a gate.
1063 ///
1064 /// The registry's coarse [`StructuralGate::trips`] filter is
1065 /// reason-agnostic, so a row registered on a condition other reconcilers
1066 /// also write (`Ready`, `Resolved`, …) would make every unrelated reason on
1067 /// that condition report as "a gate from a newer operator". This row's
1068 /// condition is therefore written by exactly one gate and one reason.
1069 #[test]
1070 fn the_restore_referent_gate_is_restore_scoped_and_owns_its_condition() {
1071 let row = STRUCTURAL_GATES
1072 .iter()
1073 .find(|g| g.reason == consts::RESTORE_REFERENT_MISSING_REASON)
1074 .expect("the #393 referent-missing park must be registered");
1075 // Reachable from a Restore (the only scope whose `covers_restore` is true).
1076 assert!(row.applies_to.covers_restore());
1077 assert_eq!(row.applies_to, GateScope::SnapshotOrRestore);
1078 // Warn: referents applied moments apart by GitOps resolve themselves.
1079 assert_eq!(row.severity, GateSeverity::Warn);
1080 assert_eq!(row.condition, consts::RESTORE_REFERENT_AVAILABLE_CONDITION);
1081 assert!(row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "False"));
1082 assert!(!row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "True"));
1083 // Exactly one row owns this condition, so the coarse filter can only
1084 // fire for the one reason this build writes on it.
1085 assert_eq!(
1086 STRUCTURAL_GATES
1087 .iter()
1088 .filter(|g| g.condition == consts::RESTORE_REFERENT_AVAILABLE_CONDITION)
1089 .count(),
1090 1
1091 );
1092 // ...and it is NOT the `Ready` condition, which every Restore writes with
1093 // many reasons — registering there would misreport all of them.
1094 assert_ne!(row.condition, consts::READY_CONDITION);
1095 assert!(
1096 !STRUCTURAL_GATES
1097 .iter()
1098 .any(|g| g.condition == consts::READY_CONDITION)
1099 );
1100 }
1101
1102 #[test]
1103 fn every_scope_classifier_is_consistent() {
1104 // Each scope covers exactly one of the four kind families a consumer
1105 // reads from separate lists (work CRs, repositories, schedules,
1106 // policies), so a diagnostic can dispatch on scope without
1107 // double-listing a gate.
1108 for scope in [
1109 GateScope::SnapshotOrRestore,
1110 GateScope::Snapshot,
1111 GateScope::Repository,
1112 GateScope::SnapshotSchedule,
1113 GateScope::SnapshotPolicy,
1114 ] {
1115 let work = scope.covers_snapshot() || scope.covers_restore();
1116 let families = [
1117 work,
1118 scope.covers_repository(),
1119 scope.covers_snapshot_schedule(),
1120 scope.covers_snapshot_policy(),
1121 ];
1122 assert_eq!(
1123 families.iter().filter(|f| **f).count(),
1124 1,
1125 "{scope:?} must cover exactly one kind family, got {families:?}"
1126 );
1127 }
1128 }
1129
1130 #[test]
1131 fn severity_labels_are_stable_and_distinct() {
1132 assert_eq!(GateSeverity::Fail.label(), "Fail");
1133 assert_eq!(GateSeverity::Warn.label(), "Warn");
1134 }
1135
1136 #[test]
1137 fn the_inherit_hold_is_registered_and_the_advisory_report_is_not() {
1138 // #464. The HOLD — a live-pod `inheritSecurityContextFrom` that resolved
1139 // nothing, with no pinned fallback — is a real park a human must clear,
1140 // so it must be visible to `kubectl kopiur doctor` on both work kinds.
1141 let hits: Vec<_> = STRUCTURAL_GATES
1142 .iter()
1143 .filter(|g| {
1144 g.matches(
1145 consts::SECURITY_CONTEXT_RESOLVED_CONDITION,
1146 CONDITION_FALSE,
1147 consts::INHERIT_SOURCE_MISSING_REASON,
1148 )
1149 })
1150 .collect();
1151 assert_eq!(hits.len(), 1, "the inherit hold selects exactly one row");
1152 assert_eq!(hits[0].severity, GateSeverity::Fail);
1153 assert!(hits[0].applies_to.covers_snapshot());
1154 assert!(hits[0].applies_to.covers_restore());
1155
1156 // …and `SecurityContextInherited` must stay OUT of the registry. It is
1157 // written with five advisory reasons (InheritFallback,
1158 // InheritPinnedNoUid, InheritOverridden, MissingRecordedIdentity,
1159 // RecordedPinnedNoUid), all on runs that PROCEEDED. Registering any one
1160 // of them would make `doctor`'s `first_gate` report the other four as
1161 // "blocked with a reason this plugin does not know (the operator is
1162 // newer than the plugin)" — a false diagnosis handed to somebody
1163 // mid-incident. The string is spelled out because the const is
1164 // deliberately controller-side, not in this crate.
1165 assert!(
1166 !STRUCTURAL_GATES
1167 .iter()
1168 .any(|g| g.condition == "SecurityContextInherited"),
1169 "SecurityContextInherited is advisory; registering one of its reasons would make \
1170 doctor false-diagnose the rest as an operator/plugin skew (#464)"
1171 );
1172 }
1173}