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/// The mover's credential `Secret` is not in the workload namespace. Parks at
269/// `phase: Pending` until the user creates it (or enables projection).
270pub const MISSING_CREDENTIALS_GATE: StructuralGate = StructuralGate {
271 applies_to: GateScope::SnapshotOrRestore,
272 condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
273 blocked_status: CONDITION_FALSE,
274 reason: consts::MISSING_CREDENTIALS_REASON,
275 severity: GateSeverity::Fail,
276};
277
278/// Same condition as [`MISSING_CREDENTIALS_GATE`], different missing
279/// dependency: the workload-identity `ServiceAccount` the backend names.
280/// kopiur never creates it.
281pub const MISSING_SERVICE_ACCOUNT_GATE: StructuralGate = StructuralGate {
282 applies_to: GateScope::SnapshotOrRestore,
283 condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
284 blocked_status: CONDITION_FALSE,
285 reason: consts::MISSING_SERVICE_ACCOUNT_REASON,
286 severity: GateSeverity::Fail,
287};
288
289/// Same condition as [`MISSING_CREDENTIALS_GATE`], a third missing
290/// dependency: the backend's `tls.caBundleRef` ConfigMap (the PEM CA bundle
291/// for a private-CA S3 endpoint). kopiur never creates it, and a ConfigMap
292/// that never appears never self-heals — the exact phase-invisible park shape
293/// of #359.
294pub const MISSING_CA_BUNDLE_GATE: StructuralGate = StructuralGate {
295 applies_to: GateScope::SnapshotOrRestore,
296 condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
297 blocked_status: CONDITION_FALSE,
298 reason: consts::MISSING_CA_BUNDLE_REASON,
299 severity: GateSeverity::Fail,
300};
301
302/// Inverted polarity: the per-`Snapshot` hold the mass-deletion breaker
303/// applies. Released only by the `allow-mass-deletion` acknowledgement on the
304/// repository, so it is squarely "needs a human".
305pub const DELETION_HELD_GATE: StructuralGate = StructuralGate {
306 applies_to: GateScope::Snapshot,
307 condition: consts::DELETION_HELD_CONDITION,
308 blocked_status: CONDITION_TRUE,
309 reason: consts::MASS_DELETION_BREAKER_REASON,
310 severity: GateSeverity::Fail,
311};
312
313/// The repository-level view of the same breaker: a whole wave is held.
314pub const MASS_DELETION_HELD_GATE: StructuralGate = StructuralGate {
315 applies_to: GateScope::Repository,
316 condition: consts::MASS_DELETION_HELD_CONDITION,
317 blocked_status: CONDITION_TRUE,
318 reason: consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
319 severity: GateSeverity::Fail,
320};
321
322/// A backup refused because its repository is `mode: ReadOnly`.
323///
324/// Unlike the other rows this is NOT an invisible park: the writer
325/// (`snapshot/mod.rs`) sets `phase: Failed` in the same status patch, so a
326/// phase-based check already sees the object. It is registered for EXPLANATORY
327/// value — it turns "this backup failed" into "this backup was refused because
328/// the repository is read-only" without a second lookup. Hence WARN, not Fail:
329/// a read-only repository is a legitimate, deliberate configuration (a
330/// replication target, an archived repo served for restores only), so a
331/// green/red verdict must not hinge on it.
332pub const REPOSITORY_READ_ONLY_GATE: StructuralGate = StructuralGate {
333 applies_to: GateScope::Snapshot,
334 condition: consts::REPOSITORY_WRITABLE_CONDITION,
335 blocked_status: CONDITION_FALSE,
336 reason: consts::REPOSITORY_READ_ONLY_REASON,
337 severity: GateSeverity::Warn,
338};
339
340/// Version skew, one kind removed: a previous run of this schedule sits at a
341/// phase string this build cannot interpret, so it can never be observed to
342/// finish. Under the default `concurrencyPolicy: Forbid` the schedule stops
343/// firing FOREVER while looking perfectly healthy — the concurrency gate is
344/// doing exactly what it was asked to. The out-of-band change that clears it is
345/// finishing the operator rollout (or deleting the wedged `Snapshot`), which is
346/// squarely "needs a human", so: Fail.
347pub const SCHEDULE_BLOCKED_GATE: StructuralGate = StructuralGate {
348 applies_to: GateScope::SnapshotSchedule,
349 condition: consts::SCHEDULE_RUNNABLE_CONDITION,
350 blocked_status: CONDITION_FALSE,
351 reason: consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
352 severity: GateSeverity::Fail,
353};
354
355/// A `SnapshotPolicy` referencing at least one repository that is not `Ready`.
356/// The policy reconciler keeps processing the READY subset (multi-repository
357/// fan-out, #368) but defers backups/retention/adoption/verification against
358/// the rest, and requeues until every repository recovers. Warn, not Fail: a
359/// repository deliberately taken down (a migration, a powered-off NAS target)
360/// is a plausible operator choice, the ready subset keeps working, and the
361/// per-child parks behind the same outage already carry their own reporting
362/// (`kopiur_snapshot_gated`, `KopiurRepositoryNotReady`) — so this gate must
363/// explain, not independently turn a diagnostic red.
364pub const POLICY_REPOSITORY_NOT_READY_GATE: StructuralGate = StructuralGate {
365 applies_to: GateScope::SnapshotPolicy,
366 condition: consts::REPOSITORIES_READY_CONDITION,
367 blocked_status: CONDITION_FALSE,
368 reason: consts::REPOSITORY_NOT_READY_REASON,
369 severity: GateSeverity::Warn,
370};
371
372/// A `SnapshotSchedule` whose members × repositories cross-product exceeds the
373/// fan-out cap: the fired slot was SKIPPED, and every future slot will keep
374/// skipping until the selector is narrowed or the policy's repository list
375/// shrunk — no backups run while everything else looks healthy, the exact
376/// silent-wedge shape of #359. The writer asserts BOTH polarities each fire
377/// pass, so the gate self-clears the moment a slot mints fully. Promoted into
378/// the registry by the #368 M10 gates/doctor checklist (previously a
379/// controller-internal condition doctor could not see).
380pub const SCHEDULE_FANOUT_CAPPED_GATE: StructuralGate = StructuralGate {
381 applies_to: GateScope::SnapshotSchedule,
382 condition: consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
383 blocked_status: CONDITION_TRUE,
384 reason: consts::FANOUT_TOO_LARGE_REASON,
385 severity: GateSeverity::Fail,
386};
387
388/// A backup whose DIRECT source PVC (`spec.sources[].pvc`) does not exist at
389/// launch time. The `Snapshot` parks at `phase: Pending` on the slow
390/// structural cadence and, once the controller's missing-source deadline
391/// passes, flips terminally `Failed` — a PVC that never reappears never
392/// self-heals, and only recreating it (or repointing the policy's sources) can
393/// clear the block. Written on the Snapshot only for the direct-source case: a
394/// vanished operator-staged claim (copyMethod Snapshot/Clone) is a restage
395/// race and stays a plain transient retry, never this gate.
396pub const SOURCE_PVC_MISSING_GATE: StructuralGate = StructuralGate {
397 applies_to: GateScope::Snapshot,
398 condition: consts::SOURCE_PVC_AVAILABLE_CONDITION,
399 blocked_status: CONDITION_FALSE,
400 reason: consts::SOURCE_PVC_MISSING_REASON,
401 severity: GateSeverity::Fail,
402};
403
404/// A `spec.seed` in migrate mode whose SOURCE repository is missing or not
405/// `Ready` (issue #380). The repository parks `Pending` with `Seeded=False`
406/// and re-checks; nothing in kopiur can bring the source up, so it is squarely
407/// "needs a human" — and the park is phase-INVISIBLE in the worst way: a
408/// brand-new repository sitting at `Pending` looks identical to one that is
409/// simply still bootstrapping, and it will sit there for as long as the source
410/// is down.
411///
412/// WARN rather than Fail, for two reasons that point the same way. The
413/// registry pins one severity per condition+scope
414/// (`gate_rows_are_unique_and_internally_consistent`), and the sibling
415/// [`SEEDING_GATE`] on this same condition *must* be Warn — a seed legitimately
416/// runs for hours, and a healthy copy must not turn a diagnostic red. And on
417/// its own merits this park is the same shape as
418/// [`POLICY_REPOSITORY_NOT_READY_GATE`]: a source repository that is still
419/// bootstrapping alongside this one (the ordinary DR bring-up) comes up by
420/// itself, and one deliberately taken down is a plausible operator choice. The
421/// block is reported either way, with the writer's message — which is what
422/// makes it worth registering.
423pub const SEED_SOURCE_NOT_READY_GATE: StructuralGate = StructuralGate {
424 applies_to: GateScope::Repository,
425 condition: consts::SEEDED_CONDITION,
426 blocked_status: CONDITION_FALSE,
427 reason: consts::WAITING_FOR_SEED_SOURCE_REASON,
428 severity: GateSeverity::Warn,
429};
430
431/// A migrate-mode `spec.seed` whose LOCAL backend and resolved SOURCE
432/// repository disagree on workload identity (issue #380).
433///
434/// The blob arm of this rule is refused at admission
435/// (`validate_replication_auth`), but a `seed.from.repository` reference hides
436/// the source's backend from a spec-only validator, so the controller
437/// re-applies the same rule once it has resolved the source and parks here
438/// instead. Without the park the CR is admitted, a Job is launched, and the
439/// failure surfaces as a bare cloud auth error from whichever side the pod's
440/// single ServiceAccount is not.
441///
442/// WARN for the same reason every other `Seeded` row is: the registry pins one
443/// severity per condition+scope, and the in-flight [`SEEDING_GATE`] must not
444/// turn a diagnostic red. Nothing is lost — the message names both
445/// ServiceAccounts and the two ways out, and the repository is not `Ready`,
446/// which a diagnostic fails on independently.
447pub const SEED_SOURCE_AUTH_CONFLICT_GATE: StructuralGate = StructuralGate {
448 applies_to: GateScope::Repository,
449 condition: consts::SEEDED_CONDITION,
450 blocked_status: CONDITION_FALSE,
451 reason: consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
452 severity: GateSeverity::Warn,
453};
454
455/// A seeding bootstrap Job is in flight (issue #380): the repository is
456/// copying a whole repository across and is legitimately not `Ready` yet.
457///
458/// Registered for EXPLANATORY value, like [`REPOSITORY_READ_ONLY_GATE`]: a seed
459/// runs for hours by design (its Job deadline defaults to 24h), so a
460/// diagnostic must be able to say "seeding" rather than "stuck at Pending".
461/// Hence WARN — progress is not a fault, and this row must never on its own
462/// turn a diagnostic red.
463pub const SEEDING_GATE: StructuralGate = StructuralGate {
464 applies_to: GateScope::Repository,
465 condition: consts::SEEDED_CONDITION,
466 blocked_status: CONDITION_FALSE,
467 reason: consts::SEEDING_REASON,
468 severity: GateSeverity::Warn,
469};
470
471/// A `spec.seed` whose SOURCE answered but holds no kopia repository. Retried
472/// automatically every ~2 minutes, so it clears itself the moment the source
473/// exists — but until then the repository never becomes `Ready`, and the fix
474/// (repoint `spec.seed.from`) is squarely out-of-band.
475pub const SEED_SOURCE_NOT_FOUND_GATE: StructuralGate = StructuralGate {
476 applies_to: GateScope::Repository,
477 condition: consts::SEEDED_CONDITION,
478 blocked_status: CONDITION_FALSE,
479 reason: consts::SEED_SOURCE_NOT_FOUND_REASON,
480 severity: GateSeverity::Warn,
481};
482
483/// A `spec.seed` whose source is a real repository holding zero snapshots, with
484/// `allowEmptySource` at its `false` default. Blocking `Ready` is the point: a
485/// valid-but-empty mirror is nearly always mis-pointed.
486pub const SEED_SOURCE_EMPTY_GATE: StructuralGate = StructuralGate {
487 applies_to: GateScope::Repository,
488 condition: consts::SEEDED_CONDITION,
489 blocked_status: CONDITION_FALSE,
490 reason: consts::SEED_SOURCE_EMPTY_REASON,
491 severity: GateSeverity::Warn,
492};
493
494/// A migrate-mode seed whose post-verify found snapshots missing. The next
495/// attempt resumes the copy, so this converges on its own — but a seed that
496/// keeps being cut short needs a human to raise its deadline.
497pub const SEED_INCOMPLETE_GATE: StructuralGate = StructuralGate {
498 applies_to: GateScope::Repository,
499 condition: consts::SEEDED_CONDITION,
500 blocked_status: CONDITION_FALSE,
501 reason: consts::SEED_INCOMPLETE_REASON,
502 severity: GateSeverity::Warn,
503};
504
505/// A seed that was armed and left the repository holding ZERO snapshots. Like
506/// [`SEED_INCOMPLETE_GATE`], the next attempt resumes the copy.
507pub const SEED_LEFT_EMPTY_GATE: StructuralGate = StructuralGate {
508 applies_to: GateScope::Repository,
509 condition: consts::SEEDED_CONDITION,
510 blocked_status: CONDITION_FALSE,
511 reason: consts::SEED_LEFT_EMPTY_REASON,
512 severity: GateSeverity::Warn,
513};
514
515/// The mover-skew guard: the running mover image predates `spec.seed`, ignored
516/// it, and initialized an EMPTY repository. Genuinely terminal and genuinely
517/// human-actionable (upgrade the image, delete the empty repository and the
518/// terminal bootstrap Job) — the ONE row here that would earn `Fail` on its own
519/// merits. It is `Warn` because the registry pins one severity per
520/// condition+scope and its siblings must not turn a healthy in-progress seed
521/// red; nothing is lost, because a repository in this state is also not `Ready`,
522/// which `doctor`'s repository check fails on independently.
523pub const SEED_MOVER_TOO_OLD_GATE: StructuralGate = StructuralGate {
524 applies_to: GateScope::Repository,
525 condition: consts::SEEDED_CONDITION,
526 blocked_status: CONDITION_FALSE,
527 reason: consts::SEED_MOVER_TOO_OLD_REASON,
528 severity: GateSeverity::Warn,
529};
530
531/// A `Restore` whose repository referent does not exist (issue #393): the
532/// explicit `spec.repository` object, or the `source.fromPolicy`
533/// `SnapshotPolicy` the repository ref is derived from.
534///
535/// The readiness gate cannot verify a repository it cannot even look up, so the
536/// restore parks at `phase: Pending` and — critically — its
537/// `policy.waitTimeout` window is NOT opened (`status.waitStartedAt` stays
538/// unstamped) until the referent appears and its repository becomes `Ready`.
539/// Before #393 the gate fell through unverified here, so a slow-arriving
540/// referent silently spent the window; for a `fromPolicy` source, whose
541/// `onMissingSnapshot` defaults to `Continue`, a spent window means an EMPTY
542/// volume.
543///
544/// WARN, not Fail, for the reason [`POLICY_REPOSITORY_NOT_READY_GATE`] is:
545/// referents applied moments apart by GitOps are the ordinary case and resolve
546/// themselves within a requeue or two, so this must explain the park rather
547/// than independently turn a diagnostic red. What it must never be is INVISIBLE
548/// — a restore parked on a `SnapshotPolicy` that was never applied looks
549/// identical, by phase, to one that is simply still resolving.
550pub const RESTORE_REFERENT_MISSING_GATE: StructuralGate = StructuralGate {
551 applies_to: GateScope::SnapshotOrRestore,
552 condition: consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
553 blocked_status: CONDITION_FALSE,
554 reason: consts::RESTORE_REFERENT_MISSING_REASON,
555 severity: GateSeverity::Warn,
556};
557
558/// Every human-actionable structural gate kopiur's reconcilers can park an
559/// object on.
560///
561/// Adding a gate to a reconciler means adding a row here; the CLI picks it up
562/// with no change, which is the whole point (#359). A condition that merely
563/// *reports* health (`IndexBlobHealth`, `BackendReachable`,
564/// `SecurityContextCompatible`) is NOT a gate — it blocks nothing — and a
565/// time-bounded wait (`SourceStaged`, `PreflightFailed`) is not one either,
566/// because it resolves itself into a terminal phase on its own.
567///
568/// Most rows are phase-INVISIBLE parks (the object sits at `phase: Pending`
569/// looking unremarkable), which is what makes the registry load-bearing. One
570/// row — [`REPOSITORY_READ_ONLY_GATE`] — is phase-visible and registered
571/// anyway, for the explanation it adds to an already-visible failure.
572///
573/// Each row is also a named `const` so a reconciler can write its condition
574/// FROM the row (`io::upsert_gate`) instead of restating the triple at the call
575/// site. A row nobody writes, or a writer nobody registered, is caught by the
576/// controller's `every_registered_gate_has_a_writer` drift test.
577pub const STRUCTURAL_GATES: &[StructuralGate] = &[
578 PRIVILEGED_MOVER_GATE,
579 MISSING_CREDENTIALS_GATE,
580 MISSING_SERVICE_ACCOUNT_GATE,
581 MISSING_CA_BUNDLE_GATE,
582 DELETION_HELD_GATE,
583 MASS_DELETION_HELD_GATE,
584 REPOSITORY_READ_ONLY_GATE,
585 SCHEDULE_BLOCKED_GATE,
586 SCHEDULE_FANOUT_CAPPED_GATE,
587 POLICY_REPOSITORY_NOT_READY_GATE,
588 SOURCE_PVC_MISSING_GATE,
589 RESTORE_REFERENT_MISSING_GATE,
590 SEED_SOURCE_NOT_READY_GATE,
591 SEED_SOURCE_AUTH_CONFLICT_GATE,
592 SEEDING_GATE,
593 SEED_SOURCE_NOT_FOUND_GATE,
594 SEED_SOURCE_EMPTY_GATE,
595 SEED_INCOMPLETE_GATE,
596 SEED_LEFT_EMPTY_GATE,
597 SEED_MOVER_TOO_OLD_GATE,
598];
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use std::collections::HashSet;
604
605 #[test]
606 fn every_gate_row_is_well_formed() {
607 // Tripwire: a row added with an empty/typo'd string, or a polarity that
608 // is neither True nor False, would make the gate silently unmatchable
609 // on both sides of the contract.
610 assert!(
611 !STRUCTURAL_GATES.is_empty(),
612 "the registry is the shared gate list; an empty one means doctor checks nothing"
613 );
614 for g in STRUCTURAL_GATES {
615 assert!(
616 !g.condition.is_empty(),
617 "{g:?}: condition must be non-empty"
618 );
619 assert!(!g.reason.is_empty(), "{g:?}: reason must be non-empty");
620 assert!(
621 g.blocked_status == CONDITION_TRUE || g.blocked_status == CONDITION_FALSE,
622 "{g:?}: blocked_status must be a Kubernetes condition status"
623 );
624 // The row must match itself and reject the opposite polarity.
625 assert!(g.trips(g.condition, g.blocked_status), "{g:?}: self-match");
626 let opposite = if g.blocked_status == CONDITION_TRUE {
627 CONDITION_FALSE
628 } else {
629 CONDITION_TRUE
630 };
631 assert!(
632 !g.trips(g.condition, opposite),
633 "{g:?}: must not trip on the opposite polarity"
634 );
635 assert!(
636 !g.trips("SomeOtherCondition", g.blocked_status),
637 "{g:?}: must not trip on another condition type"
638 );
639 }
640 }
641
642 #[test]
643 fn a_live_condition_selects_exactly_one_row() {
644 // The whole registry must be a FUNCTION of a live condition: one
645 // (type, status, reason) triple in, at most one row out. Rows 2 and 3
646 // share condition+status+scope and differ only by reason, so a
647 // consumer filtering on `trips` alone would double-report a single
648 // wedged Snapshot. `matches` is the matcher that cannot.
649 for g in STRUCTURAL_GATES {
650 let hits: Vec<_> = STRUCTURAL_GATES
651 .iter()
652 .filter(|c| c.matches(g.condition, g.blocked_status, g.reason))
653 .collect();
654 assert_eq!(hits.len(), 1, "{g:?}: must select exactly one row");
655 assert_eq!(hits[0], g, "{g:?}: must select ITSELF");
656 }
657
658 // The concrete case the reviewer called out, spelled out end to end.
659 let creds_secret: Vec<_> = STRUCTURAL_GATES
660 .iter()
661 .filter(|g| {
662 g.matches(
663 consts::CREDENTIALS_AVAILABLE_CONDITION,
664 CONDITION_FALSE,
665 consts::MISSING_CREDENTIALS_REASON,
666 )
667 })
668 .collect();
669 assert_eq!(creds_secret.len(), 1);
670 assert_eq!(creds_secret[0].reason, consts::MISSING_CREDENTIALS_REASON);
671
672 // ...and the coarse filter deliberately still matches BOTH rows, which
673 // is why it must never be used to identify a row.
674 let coarse = STRUCTURAL_GATES
675 .iter()
676 .filter(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
677 .count();
678 assert_eq!(coarse, 3, "`trips` is reason-agnostic by design");
679
680 // An unregistered reason for a gated condition: `matches` finds
681 // nothing, `trips` still flags it. That asymmetry is the reason
682 // `trips` is kept rather than removed.
683 assert!(!STRUCTURAL_GATES.iter().any(|g| g.matches(
684 consts::CREDENTIALS_AVAILABLE_CONDITION,
685 CONDITION_FALSE,
686 "SomeFutureReason"
687 )));
688 assert!(
689 STRUCTURAL_GATES
690 .iter()
691 .any(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
692 );
693 }
694
695 #[test]
696 fn blocked_is_true_mirrors_the_status_string() {
697 // The bool a registry-driven `upsert_condition` writer passes. Derived
698 // from the row, never hand-translated per call site.
699 for g in STRUCTURAL_GATES {
700 assert_eq!(
701 g.blocked_is_true(),
702 g.blocked_status == CONDITION_TRUE,
703 "{g:?}"
704 );
705 }
706 // Both polarities are actually exercised by the live registry, so this
707 // can never rot into a one-sided assertion.
708 assert!(STRUCTURAL_GATES.iter().any(|g| g.blocked_is_true()));
709 assert!(STRUCTURAL_GATES.iter().any(|g| !g.blocked_is_true()));
710 }
711
712 #[test]
713 fn gate_rows_are_unique_and_internally_consistent() {
714 // A copy-paste duplicate would double-report the same block. Two rows
715 // MAY share a condition+scope when the writer stamps different reasons
716 // for it (CredentialsAvailable: missing Secret vs missing SA) — but
717 // then they must agree on polarity and severity, or a diagnostic's
718 // verdict would depend on which row it happened to match first.
719 let mut seen: HashSet<(&str, &str, GateScope)> = HashSet::new();
720 for g in STRUCTURAL_GATES {
721 assert!(
722 seen.insert((g.condition, g.reason, g.applies_to)),
723 "{g:?}: duplicate condition+reason+scope row"
724 );
725 }
726 for a in STRUCTURAL_GATES {
727 for b in STRUCTURAL_GATES {
728 if a.condition == b.condition && a.applies_to == b.applies_to {
729 assert_eq!(
730 a.blocked_status, b.blocked_status,
731 "{a:?} / {b:?}: same condition+scope, different polarity"
732 );
733 assert_eq!(
734 a.severity, b.severity,
735 "{a:?} / {b:?}: same condition+scope, different severity"
736 );
737 }
738 }
739 }
740 }
741
742 #[test]
743 fn every_seeded_false_reason_this_build_writes_has_a_row() {
744 // The `Seeded` condition is the one gated condition with a WIDE reason
745 // set, and a partial registration is worse than none: a reason with no
746 // row still trips `StructuralGate::trips`, so `kubectl kopiur doctor`
747 // classifies it as UNREGISTERED and tells the operator "the operator is
748 // newer than the plugin — upgrade the plugin". That would be a false
749 // diagnosis handed to someone mid-disaster-recovery, whose actual
750 // problem is a mis-pointed seed source.
751 //
752 // So: every reason this build can stamp on `Seeded=False` must select
753 // exactly one row. The list is assembled from the three park/progress
754 // reasons plus the shared failure set, so a new reason added to
755 // `consts::SEED_FAILURE_REASONS` fails here until it is registered.
756 let mut reasons: Vec<&str> = vec![
757 consts::WAITING_FOR_SEED_SOURCE_REASON,
758 consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
759 consts::SEEDING_REASON,
760 ];
761 reasons.extend_from_slice(consts::SEED_FAILURE_REASONS);
762 for reason in reasons {
763 let hits: Vec<_> = STRUCTURAL_GATES
764 .iter()
765 .filter(|g| g.matches(consts::SEEDED_CONDITION, CONDITION_FALSE, reason))
766 .collect();
767 assert_eq!(
768 hits.len(),
769 1,
770 "Seeded=False reason `{reason}` selects {} rows, not 1 — doctor would report it \
771 as an unknown reason from a newer operator",
772 hits.len()
773 );
774 assert!(hits[0].applies_to.covers_repository());
775 }
776 // Guard against the reverse rot: every registered `Seeded` row must be
777 // one of those reasons, so a row nobody can write cannot linger.
778 for g in STRUCTURAL_GATES
779 .iter()
780 .filter(|g| g.condition == consts::SEEDED_CONDITION)
781 {
782 assert!(
783 g.reason == consts::WAITING_FOR_SEED_SOURCE_REASON
784 || g.reason == consts::SEED_SOURCE_AUTH_CONFLICT_REASON
785 || g.reason == consts::SEEDING_REASON
786 || consts::SEED_FAILURE_REASONS.contains(&g.reason),
787 "{g:?}: registered but not a reason this build writes"
788 );
789 }
790 }
791
792 /// Every `Seeded=False` failure reason must be EXPLAINED in the docs, not
793 /// just registered (#380).
794 ///
795 /// The registry above guarantees `kubectl kopiur doctor` recognizes a
796 /// reason; it says nothing about whether a human who reads it can find out
797 /// what to do. These reasons surface in exactly one situation — a disaster
798 /// recovery that is not going well — so an unexplained one is worse here
799 /// than almost anywhere else in kopiur. The two pages are the two places
800 /// someone actually looks: the troubleshooting table (symptom → fix) and the
801 /// DR scenario (the reason table plus the retry/terminal contract).
802 ///
803 /// A reason added to `consts::SEED_FAILURE_REASONS` — or to the
804 /// terminal-until-edited park set the body extends it with — fails here
805 /// until both pages mention it by name.
806 #[test]
807 fn every_seed_failure_reason_is_documented_on_both_user_facing_pages() {
808 // CARGO_MANIFEST_DIR = crates/api; the docs tree is at the repo root.
809 // Same relative-path approach `crates/api/tests/examples_match_crd_shapes.rs`
810 // uses to reach `deploy/examples`.
811 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
812 for page in [
813 "docs/troubleshooting.md",
814 "docs/scenarios/dr-with-replicated-repository.md",
815 ] {
816 let path = root.join(page);
817 let text = std::fs::read_to_string(&path)
818 .unwrap_or_else(|e| panic!("{page} must exist and be readable: {e}"));
819 // The mover's failure classes, PLUS the one park reason that is
820 // equally terminal-until-edited: a migrate-mode workload-identity
821 // conflict is re-checked forever and never clears by itself, so
822 // doctor prints it at someone who needs the same lookup. The two
823 // progress/park reasons that DO clear on their own
824 // (`Seeding`, `WaitingForSeedSource`) are deliberately not required
825 // here — they describe motion, not a thing to look up.
826 let documented: Vec<&str> = consts::SEED_FAILURE_REASONS
827 .iter()
828 .copied()
829 .chain(std::iter::once(consts::SEED_SOURCE_AUTH_CONFLICT_REASON))
830 .collect();
831 for reason in documented {
832 assert!(
833 text.contains(reason),
834 "{page} never mentions the `Seeded=False` reason `{reason}` — doctor will \
835 print it at a user who then has nowhere to look it up"
836 );
837 }
838 }
839 }
840
841 #[test]
842 fn expected_gates_are_registered_with_expected_scope_and_severity() {
843 // Pins the exact contract M3's doctor consumes. A row removed, or its
844 // severity/scope quietly changed, fails here rather than silently
845 // changing what a cluster diagnostic reports.
846 let expected: &[(&str, &str, &str, GateScope, GateSeverity)] = &[
847 (
848 consts::MOVER_PERMITTED_CONDITION,
849 CONDITION_FALSE,
850 consts::PRIVILEGED_MOVER_NOT_PERMITTED_REASON,
851 GateScope::SnapshotOrRestore,
852 GateSeverity::Fail,
853 ),
854 (
855 consts::CREDENTIALS_AVAILABLE_CONDITION,
856 CONDITION_FALSE,
857 consts::MISSING_CREDENTIALS_REASON,
858 GateScope::SnapshotOrRestore,
859 GateSeverity::Fail,
860 ),
861 (
862 consts::CREDENTIALS_AVAILABLE_CONDITION,
863 CONDITION_FALSE,
864 consts::MISSING_SERVICE_ACCOUNT_REASON,
865 GateScope::SnapshotOrRestore,
866 GateSeverity::Fail,
867 ),
868 (
869 consts::CREDENTIALS_AVAILABLE_CONDITION,
870 CONDITION_FALSE,
871 consts::MISSING_CA_BUNDLE_REASON,
872 GateScope::SnapshotOrRestore,
873 GateSeverity::Fail,
874 ),
875 (
876 consts::DELETION_HELD_CONDITION,
877 CONDITION_TRUE,
878 consts::MASS_DELETION_BREAKER_REASON,
879 GateScope::Snapshot,
880 GateSeverity::Fail,
881 ),
882 (
883 consts::MASS_DELETION_HELD_CONDITION,
884 CONDITION_TRUE,
885 consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
886 GateScope::Repository,
887 GateSeverity::Fail,
888 ),
889 (
890 consts::REPOSITORY_WRITABLE_CONDITION,
891 CONDITION_FALSE,
892 consts::REPOSITORY_READ_ONLY_REASON,
893 GateScope::Snapshot,
894 GateSeverity::Warn,
895 ),
896 (
897 consts::SCHEDULE_RUNNABLE_CONDITION,
898 CONDITION_FALSE,
899 consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
900 GateScope::SnapshotSchedule,
901 GateSeverity::Fail,
902 ),
903 (
904 consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
905 CONDITION_TRUE,
906 consts::FANOUT_TOO_LARGE_REASON,
907 GateScope::SnapshotSchedule,
908 GateSeverity::Fail,
909 ),
910 (
911 consts::REPOSITORIES_READY_CONDITION,
912 CONDITION_FALSE,
913 consts::REPOSITORY_NOT_READY_REASON,
914 GateScope::SnapshotPolicy,
915 GateSeverity::Warn,
916 ),
917 (
918 consts::SOURCE_PVC_AVAILABLE_CONDITION,
919 CONDITION_FALSE,
920 consts::SOURCE_PVC_MISSING_REASON,
921 GateScope::Snapshot,
922 GateSeverity::Fail,
923 ),
924 (
925 consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
926 CONDITION_FALSE,
927 consts::RESTORE_REFERENT_MISSING_REASON,
928 GateScope::SnapshotOrRestore,
929 GateSeverity::Warn,
930 ),
931 (
932 consts::SEEDED_CONDITION,
933 CONDITION_FALSE,
934 consts::WAITING_FOR_SEED_SOURCE_REASON,
935 GateScope::Repository,
936 GateSeverity::Warn,
937 ),
938 (
939 consts::SEEDED_CONDITION,
940 CONDITION_FALSE,
941 consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
942 GateScope::Repository,
943 GateSeverity::Warn,
944 ),
945 (
946 consts::SEEDED_CONDITION,
947 CONDITION_FALSE,
948 consts::SEEDING_REASON,
949 GateScope::Repository,
950 GateSeverity::Warn,
951 ),
952 (
953 consts::SEEDED_CONDITION,
954 CONDITION_FALSE,
955 consts::SEED_SOURCE_NOT_FOUND_REASON,
956 GateScope::Repository,
957 GateSeverity::Warn,
958 ),
959 (
960 consts::SEEDED_CONDITION,
961 CONDITION_FALSE,
962 consts::SEED_SOURCE_EMPTY_REASON,
963 GateScope::Repository,
964 GateSeverity::Warn,
965 ),
966 (
967 consts::SEEDED_CONDITION,
968 CONDITION_FALSE,
969 consts::SEED_INCOMPLETE_REASON,
970 GateScope::Repository,
971 GateSeverity::Warn,
972 ),
973 (
974 consts::SEEDED_CONDITION,
975 CONDITION_FALSE,
976 consts::SEED_LEFT_EMPTY_REASON,
977 GateScope::Repository,
978 GateSeverity::Warn,
979 ),
980 (
981 consts::SEEDED_CONDITION,
982 CONDITION_FALSE,
983 consts::SEED_MOVER_TOO_OLD_REASON,
984 GateScope::Repository,
985 GateSeverity::Warn,
986 ),
987 ];
988 assert_eq!(
989 STRUCTURAL_GATES.len(),
990 expected.len(),
991 "a gate row was added or removed — update the pinned expectations \
992 (and M3's doctor coverage) deliberately"
993 );
994 for (condition, status, reason, scope, severity) in expected {
995 let row = STRUCTURAL_GATES
996 .iter()
997 .find(|g| g.condition == *condition && g.reason == *reason)
998 .unwrap_or_else(|| panic!("{condition}/{reason} must be registered"));
999 assert_eq!(row.blocked_status, *status, "{condition}/{reason} polarity");
1000 assert_eq!(row.applies_to, *scope, "{condition}/{reason} scope");
1001 assert_eq!(row.severity, *severity, "{condition}/{reason} severity");
1002 }
1003 }
1004
1005 /// The #393 park must be visible to `doctor` on a `Restore` — and must not
1006 /// make any OTHER condition read as a gate.
1007 ///
1008 /// The registry's coarse [`StructuralGate::trips`] filter is
1009 /// reason-agnostic, so a row registered on a condition other reconcilers
1010 /// also write (`Ready`, `Resolved`, …) would make every unrelated reason on
1011 /// that condition report as "a gate from a newer operator". This row's
1012 /// condition is therefore written by exactly one gate and one reason.
1013 #[test]
1014 fn the_restore_referent_gate_is_restore_scoped_and_owns_its_condition() {
1015 let row = STRUCTURAL_GATES
1016 .iter()
1017 .find(|g| g.reason == consts::RESTORE_REFERENT_MISSING_REASON)
1018 .expect("the #393 referent-missing park must be registered");
1019 // Reachable from a Restore (the only scope whose `covers_restore` is true).
1020 assert!(row.applies_to.covers_restore());
1021 assert_eq!(row.applies_to, GateScope::SnapshotOrRestore);
1022 // Warn: referents applied moments apart by GitOps resolve themselves.
1023 assert_eq!(row.severity, GateSeverity::Warn);
1024 assert_eq!(row.condition, consts::RESTORE_REFERENT_AVAILABLE_CONDITION);
1025 assert!(row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "False"));
1026 assert!(!row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "True"));
1027 // Exactly one row owns this condition, so the coarse filter can only
1028 // fire for the one reason this build writes on it.
1029 assert_eq!(
1030 STRUCTURAL_GATES
1031 .iter()
1032 .filter(|g| g.condition == consts::RESTORE_REFERENT_AVAILABLE_CONDITION)
1033 .count(),
1034 1
1035 );
1036 // ...and it is NOT the `Ready` condition, which every Restore writes with
1037 // many reasons — registering there would misreport all of them.
1038 assert_ne!(row.condition, consts::READY_CONDITION);
1039 assert!(
1040 !STRUCTURAL_GATES
1041 .iter()
1042 .any(|g| g.condition == consts::READY_CONDITION)
1043 );
1044 }
1045
1046 #[test]
1047 fn every_scope_classifier_is_consistent() {
1048 // Each scope covers exactly one of the four kind families a consumer
1049 // reads from separate lists (work CRs, repositories, schedules,
1050 // policies), so a diagnostic can dispatch on scope without
1051 // double-listing a gate.
1052 for scope in [
1053 GateScope::SnapshotOrRestore,
1054 GateScope::Snapshot,
1055 GateScope::Repository,
1056 GateScope::SnapshotSchedule,
1057 GateScope::SnapshotPolicy,
1058 ] {
1059 let work = scope.covers_snapshot() || scope.covers_restore();
1060 let families = [
1061 work,
1062 scope.covers_repository(),
1063 scope.covers_snapshot_schedule(),
1064 scope.covers_snapshot_policy(),
1065 ];
1066 assert_eq!(
1067 families.iter().filter(|f| **f).count(),
1068 1,
1069 "{scope:?} must cover exactly one kind family, got {families:?}"
1070 );
1071 }
1072 }
1073
1074 #[test]
1075 fn severity_labels_are_stable_and_distinct() {
1076 assert_eq!(GateSeverity::Fail.label(), "Fail");
1077 assert_eq!(GateSeverity::Warn.label(), "Warn");
1078 }
1079}