tatara_process/pool.rs
1//! `EphemeralPool` CRD — a population of warm, pre-attested ephemeral
2//! Processes that get *allocated* to requestors (e.g., a GitHub PR
3//! flow) on demand and *returned* (per a typed policy) when the
4//! requestor releases them.
5//!
6//! Compounding move: the pool is a population manager **over the
7//! existing Process algebra**, not a parallel runtime. A pool member
8//! is just a `Process` with `Lifetime::Permanent` while in the free
9//! list; allocation is "the operator (the pool reconciler) flips
10//! that Process's lifetime slot to Ephemeral with the requestor's
11//! TTL." Zero new compute primitive.
12//!
13//! Topology:
14//!
15//! ```text
16//! EphemeralPool (this CRD)
17//! ├── PoolSpec (desired_size, template (EphemeralSpec), return_policy, selector)
18//! ├── PoolStatus (phase, free / allocated / spawning / returning counts, members)
19//! └── owns N Processes via ownerReferences (one per pool slot)
20//!
21//! EphemeralAllocation (see allocation.rs)
22//! ├── AllocationSpec (pool_ref, requestor, requested_at, lifetime override)
23//! └── AllocationStatus (phase, assigned_process_ref, allocated_at, expires_at)
24//! ```
25
26use chrono::{DateTime, Utc};
27use kube::CustomResource;
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30
31use crate::ephemeral::EphemeralSpec;
32
33/// `EphemeralPool` CRD spec — typed pool of warm Processes.
34///
35/// ```yaml
36/// apiVersion: tatara.pleme.io/v1alpha1
37/// kind: EphemeralPool
38/// metadata:
39/// name: attest-pool
40/// namespace: ephemeral-pools
41/// spec:
42/// desiredSize: 3
43/// minSize: 1
44/// maxSize: 5
45/// returnPolicy: Reset
46/// selector:
47/// repos: ["pleme-io/demo-*"]
48/// branches: ["main", "release-*"]
49/// prLabels: ["needs-ephemeral"]
50/// template:
51/// aplicacao:
52/// chartRef: "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
53/// version: "0.5.5"
54/// profile: "all-in-one"
55/// …
56/// ttl: "2h"
57/// teardown: OnAttested
58/// postconditions: [ … ]
59/// ```
60#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)]
61#[kube(
62 group = "tatara.pleme.io",
63 version = "v1alpha1",
64 kind = "EphemeralPool",
65 plural = "ephemeralpools",
66 shortname = "epool",
67 namespaced,
68 status = "PoolStatus",
69 printcolumn = r#"{"name":"Desired","type":"integer","jsonPath":".spec.desiredSize"}"#,
70 printcolumn = r#"{"name":"Ready","type":"integer","jsonPath":".status.readyCount"}"#,
71 printcolumn = r#"{"name":"Allocated","type":"integer","jsonPath":".status.allocatedCount"}"#,
72 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
73 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
74)]
75#[serde(rename_all = "camelCase")]
76pub struct PoolSpec {
77 /// Target number of warm Processes the pool maintains in `Free`
78 /// state (sum of Free + Spawning targets `desired_size`).
79 pub desired_size: u32,
80
81 /// Hard floor on the free count. The reconciler refuses to scale
82 /// below this even on cost-pressure signals. Default = 0.
83 #[serde(default)]
84 pub min_size: u32,
85
86 /// Hard ceiling on total pool members (free + allocated + spawning).
87 /// `0` = no cap. Default = 0.
88 #[serde(default)]
89 pub max_size: u32,
90
91 /// What to do when an allocation releases.
92 #[serde(default)]
93 pub return_policy: ReturnPolicy,
94
95 /// Routing selector — which allocation requests this pool serves.
96 /// The reconciler matches incoming `EphemeralAllocation` CRs
97 /// against this selector (most-specific wins across pools sharing
98 /// a namespace).
99 #[serde(default)]
100 pub selector: PoolSelector,
101
102 /// Template for each pool member — a typed `EphemeralSpec` that
103 /// the reconciler lowers to `ProcessSpec` and instantiates.
104 /// While in the free list each member's lifetime is overridden
105 /// to `Permanent`; allocation flips it back to `Ephemeral` with
106 /// the requestor's TTL.
107 pub template: EphemeralSpec,
108
109 /// How long a pool member may sit in `Free` before the reconciler
110 /// recycles it (humantime). Defends against drift / stale state.
111 /// Default `"24h"`.
112 #[serde(default = "default_free_ttl")]
113 pub free_ttl: String,
114
115 /// Max time the reconciler allows a single allocation to hold a
116 /// member before forcibly returning it (humantime). Hard cap
117 /// independent of the allocation's own TTL. Default `"4h"`.
118 #[serde(default = "default_max_allocation_ttl")]
119 pub max_allocation_ttl: String,
120
121 /// **R5 desired-count loop** — when set non-zero, the pool
122 /// reconciler maintains exactly this many *healthy* (Running or
123 /// Attested) Processes regardless of allocation pressure. Drives
124 /// the "always seeking stability" property: failed members are
125 /// replaced per `replacement_policy`. `0` keeps the legacy
126 /// allocation-driven sizing (desired = floor of free + allocated).
127 ///
128 /// Operator usage: `desired: 5` means "always have 5 of these
129 /// running"; failures auto-replace.
130 #[serde(default)]
131 pub desired: u32,
132
133 /// **R5** — what the pool reconciler does when a member reaches
134 /// `Failed` phase.
135 #[serde(default)]
136 pub replacement_policy: ReplacementPolicy,
137
138 /// **R5** — when true, exactly one healthy member of the pool
139 /// holds the unprefixed-form DNS hostnames declared in
140 /// `template.routing` at any moment. The claim arbiter (see
141 /// `tatara-reconciler::claim`) transfers atomically when the
142 /// holder fails.
143 #[serde(default)]
144 pub stable_name_claim: bool,
145}
146
147impl EphemeralPool {
148 /// Borrow-form metadata-projection primitive on the `metadata.name`
149 /// axis of `EphemeralPool`: returns the K8s object name slice with
150 /// the missing-name corner collapsed to the load-bearing empty-string
151 /// sentinel — the ONE-liner collapse of the paired
152 /// `self.metadata.name.as_deref().unwrap_or("")` incantation every
153 /// pool-side consumer restated by hand pre-lift.
154 ///
155 /// Pre-lift the `.metadata.name.as_deref().unwrap_or("")` chain
156 /// was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
157 /// duplication threshold in `tatara-pool-reconciler`, both keyed
158 /// by the pool's own name slot:
159 /// * `router::pool_name` — the tie-break comparator inside
160 /// `best_match`; a deterministic lexicographic-min-name arbiter
161 /// across two pool candidates whose specificity scores tie.
162 /// * `controller_allocation::reconcile_inner` — the `HashMap<
163 /// pool-name, Vec<PoolMember>>` lookup closure fed into
164 /// `decide_allocation_reconcile`; keys the "which pool members
165 /// back this allocation candidate?" projection at every
166 /// allocation-reconcile pass.
167 ///
168 /// Both sites walked the SAME `.as_deref().unwrap_or("")` chain
169 /// and both wanted the `&str` form the primitive returns — as a
170 /// borrow suitable for lexicographic `str::cmp` in the tie-break
171 /// AND for the `HashMap<String, _>::get(&str)` lookup. Post-lift
172 /// each caller reaches for `pool.name_or_empty()` and the produced
173 /// slice feeds the same downstream comparator / lookup unchanged.
174 ///
175 /// The empty-string fallback is the SAME sentinel the sibling
176 /// borrow-form primitive [`crate::crd::Process::uid_or_empty`]
177 /// returns AND the SAME sentinel the owned-form sibling
178 /// [`crate::crd::Process::owned_name_or_empty`] returns on the
179 /// `metadata.name` axis of the sister CRD — the three primitives
180 /// partition the (borrow-form × owned-form) × (uid × name) corner
181 /// of the metadata-slot family on identical fallback semantics
182 /// (empty string means "the slot is unset"), so a consumer that
183 /// switches between the CRD surfaces based on downstream keying
184 /// requirements never sees a different missing-slot spelling as
185 /// a side effect.
186 ///
187 /// Return-form axis: `&str` mirrors the borrow-first discipline
188 /// of the peer metadata primitives on `Process`
189 /// ([`crate::crd::Process::namespace_or_default`],
190 /// [`crate::crd::Process::name_or_placeholder`],
191 /// [`crate::crd::Process::uid_or_empty`]). The one missing-slot
192 /// corner the chain swallowed pre-lift (missing `metadata.name`)
193 /// collapses to the empty-string sentinel so `str::is_empty` /
194 /// `HashMap::get` on an unnamed pool behaves identically to what
195 /// the pre-lift `.as_deref().unwrap_or("")` chain produced.
196 ///
197 /// A future normalization step (a name-canonicalization pass, a
198 /// case-fold key builder, a per-cluster prefix stripper for
199 /// cross-cluster pool-name aliasing) lands at ONE substrate
200 /// method here and both downstream consumers pick up the upgrade
201 /// mechanically — no per-callsite hand-edit at `pool_name` /
202 /// `reconcile_inner`.
203 ///
204 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
205 /// the `.metadata.name.as_deref().unwrap_or("")` chain recurred
206 /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
207 /// duplication trigger, and is lifted to ONE owner here).
208 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
209 /// the pins bind the missing-name corner + the empty-string
210 /// sentinel byte-shape + the borrow-form `&str` lifetime + the
211 /// byte-identical parity with the pre-lift chain + the fallback-
212 /// value coherence with `Process::uid_or_empty` /
213 /// `Process::owned_name_or_empty` on the metadata-slot × empty-
214 /// sentinel axis, so a regression that drifted any surface at
215 /// `tests::name_or_empty_*` here rather than as silent operator-
216 /// facing skew between the router tie-break and the allocation
217 /// member-lookup on the SAME pool candidate).
218 pub fn name_or_empty(&self) -> &str {
219 self.metadata.name.as_deref().unwrap_or("")
220 }
221
222 /// Owned-form metadata-projection primitive on the `metadata.name`
223 /// axis of `EphemeralPool`: returns an owned `String` copy of the K8s
224 /// object name with the missing-name corner collapsed to the load-
225 /// bearing empty-string sentinel — the ONE-liner collapse of the
226 /// paired `self.metadata.name.clone().unwrap_or_default()` incantation
227 /// every pool-side consumer restated by hand pre-lift.
228 ///
229 /// Pre-lift the `.metadata.name.clone().unwrap_or_default()` chain
230 /// was hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
231 /// duplication threshold in `tatara-pool-reconciler`, both keyed by
232 /// the pool's own name slot in an `owned String` context:
233 /// * `controller_allocation::reconcile_inner` — the
234 /// `HashMap<String, Vec<PoolMember>>` key seed inside a
235 /// `pools.iter().map(|p| ...).collect()` fanout; the map key is
236 /// the owned `String` form because the produced `HashMap<String, _>`
237 /// outlives the pool-list borrow that generated it and the
238 /// downstream `pool_members.get(pool.name_or_empty())` closure
239 /// consumes it as `&str`.
240 /// * `allocation_decide::AllocationConvergenceCtx::observe` — the
241 /// `AllocationRef::name` slot seed stamped on the matched-pool
242 /// handle; the struct literal is `AllocationRef { name: String,
243 /// namespace: String }` and the produced value is threaded through
244 /// the `Decision::decide` transition rule downstream.
245 ///
246 /// Both sites walked the SAME `.clone().unwrap_or_default()` chain
247 /// and both wanted the `String` form the primitive returns — as the
248 /// owned key of a `HashMap<String, _>` and as the `String` slot of
249 /// an `AllocationRef` struct literal. Post-lift each callsite reads
250 /// `pool.owned_name_or_empty()` and the produced value feeds the
251 /// same downstream key / struct-literal slot unchanged.
252 ///
253 /// The empty-string fallback is the SAME sentinel the sibling
254 /// borrow-form primitive [`Self::name_or_empty`] returns AND the
255 /// SAME sentinel the sibling owned-form primitive
256 /// [`crate::crd::Process::owned_name_or_empty`] returns on the
257 /// `metadata.name` axis of the sister CRD — the three primitives
258 /// partition the (borrow-form × owned-form) corner of the metadata-
259 /// name family across BOTH tatara-process CRDs on identical missing-
260 /// slot semantics (empty string means "the slot is unset"), so a
261 /// consumer that switches between the CRD surfaces based on
262 /// downstream ownership requirements never sees a different
263 /// missing-slot spelling as a side effect.
264 ///
265 /// Peer to [`Self::name_or_empty`] on the (return-form × ownership)
266 /// axis pair — closes the corner the pool-side family previously
267 /// left open:
268 ///
269 /// * borrow + empty sentinel → [`Self::name_or_empty`] (router tie-
270 /// break comparator, `HashMap<String, _>::get(&str)` lookup —
271 /// consumers whose downstream keys by `&str` and allocates
272 /// nothing);
273 /// * owned + empty sentinel → **this method** (HashMap-key seed in
274 /// an outliving-borrow context, `AllocationRef::name` struct-
275 /// literal slot — consumers whose downstream requires the owned
276 /// `String` form because the produced value outlives the source-
277 /// pool borrow).
278 ///
279 /// A future normalization step (a name-canonicalization pass, a
280 /// case-fold key builder, a per-cluster prefix stripper for cross-
281 /// cluster pool-name aliasing) lands at ONE substrate method here
282 /// and both downstream consumers pick up the upgrade mechanically —
283 /// no per-callsite hand-edit at `reconcile_inner` /
284 /// `AllocationConvergenceCtx::observe`.
285 ///
286 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
287 /// the `.metadata.name.clone().unwrap_or_default()` chain recurred
288 /// at two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
289 /// duplication trigger, and is lifted to ONE owner here).
290 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
291 /// the pins bind the missing-name corner + the empty-string
292 /// sentinel byte-shape + the owned-form `String` return type + the
293 /// byte-identical parity with the pre-lift chain + the fallback-
294 /// value coherence with [`Self::name_or_empty`] +
295 /// [`crate::crd::Process::owned_name_or_empty`] on the metadata-
296 /// slot × empty-sentinel axis, so a regression that drifted any
297 /// surface at `tests::owned_name_or_empty_*` here rather than as
298 /// silent operator-facing skew between the pool-members lookup key
299 /// and the AllocationRef seed on the SAME pool candidate).
300 pub fn owned_name_or_empty(&self) -> String {
301 self.metadata.name.clone().unwrap_or_default()
302 }
303
304 /// Copy-form metadata-projection primitive on the deletion-tombstone
305 /// axis of `EphemeralPool`: returns `true` iff the K8s API server
306 /// has stamped a `metadata.deletionTimestamp` on this pool (the
307 /// moment the object entered the "being deleted" corner of its
308 /// lifecycle, after which further mutating writes are refused and
309 /// finalizers are drained before the object is actually removed) —
310 /// the ONE-liner collapse of the paired
311 /// `self.metadata.deletion_timestamp.is_some()` incantation every
312 /// pool-side consumer restated by hand pre-lift.
313 ///
314 /// Pre-lift the `.metadata.deletion_timestamp.is_some()` chain was
315 /// hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
316 /// duplication threshold in `tatara-pool-reconciler`, both
317 /// projecting the SAME tombstone-presence predicate on an
318 /// `EphemeralPool` value:
319 /// * `pool_decide::decide_pool_reconcile` — the pure decision
320 /// function's deletion-preempt gate that forces
321 /// [`PoolDecision::Drain`] as soon as the API server stamps
322 /// the tombstone, before the (desired vs actual) supply-arithmetic
323 /// branches get a chance to run. Wired at the very top of the
324 /// decision so a draining pool never spawns / reaps / expires
325 /// through the normal replenishment arithmetic while the
326 /// deletion is in flight.
327 /// * `controller_pool::pool_phase_from_members` — the observed-
328 /// phase composer's tombstone-first arm that returns
329 /// [`PoolPhase::Draining`] regardless of the supply / demand
330 /// arithmetic that would otherwise pick `Ready` / `Scaling` /
331 /// `Degraded`. Keeps the reported phase honest during the
332 /// finalizer drain so operators reading `kubectl get
333 /// ephemeralpools` see the tombstone-present state as
334 /// `Draining`, not as a stale `Ready`.
335 ///
336 /// Both sites walked the SAME `.metadata.deletion_timestamp
337 /// .is_some()` chain and both wanted the `bool` form the primitive
338 /// returns — the `decide_pool_reconcile` site to gate the
339 /// `→ Drain` short-circuit and the `pool_phase_from_members` site
340 /// to gate the `→ Draining` short-circuit. Post-lift each callsite
341 /// reads `pool.is_being_deleted()` and the produced `bool` feeds
342 /// the same downstream short-circuit unchanged.
343 ///
344 /// Sibling to [`crate::crd::Process::is_being_deleted`] on the
345 /// deletion-tombstone axis of the sister CRD — the two primitives
346 /// now partition the tombstone-presence probe across BOTH
347 /// tatara-process CRDs on identical missing-slot semantics
348 /// (present timestamp means "the API server has begun deletion"),
349 /// so an operator or reconciler that switches between the CRD
350 /// surfaces never sees a different tombstone-detection spelling
351 /// as a side effect.
352 ///
353 /// Return-form axis: `bool` matches the copy-form discipline of
354 /// the sibling [`crate::crd::Process::is_being_deleted`] and of
355 /// the pool-side [`crate::phase::ProcessPhase::is_alive`] +
356 /// [`Self::name_or_empty`]-family primitives — the underlying
357 /// slot is a wire-format `Option<Time>` that carries only
358 /// presence information at this axis (the RFC-3339 timestamp
359 /// payload itself is not what the two consumers read; both only
360 /// probe presence to detect the tombstone-stamped state).
361 /// Returning the raw `Option<&Time>` would push the `.is_some()`
362 /// probe back to every callsite, restating the pre-lift chain
363 /// one link shorter without collapsing the primitive.
364 ///
365 /// Peer to [`Self::name_or_empty`] and [`Self::owned_name_or_empty`]
366 /// on the metadata-projection axis for `EphemeralPool`; this method
367 /// opens the presence-probe corner for the tombstone slot. Future
368 /// metadata-presence projections on the pool CRD (an
369 /// `is_being_finalized` projection on
370 /// `metadata.finalizers.is_empty()`'s negation, a `has_owner`
371 /// projection on `metadata.owner_references.is_empty()`'s
372 /// negation) land as peer methods on this same axis.
373 ///
374 /// A future normalization step (a per-tombstone staleness gate
375 /// that returns `false` for a tombstone older than the reconciler's
376 /// grace-period budget, a canonicalization pass that treats a
377 /// tombstone from a paused controller as absent, a cross-cluster
378 /// tombstone-observation clock skew guard) lands at ONE substrate
379 /// method here and both downstream consumers pick up the upgrade
380 /// mechanically — no per-callsite hand-edit at
381 /// `decide_pool_reconcile` / `pool_phase_from_members`.
382 ///
383 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
384 /// the `.metadata.deletion_timestamp.is_some()` chain recurred at
385 /// two hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
386 /// duplication trigger, and is lifted to ONE owner here).
387 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
388 /// the pins bind the missing-tombstone corner + the present-
389 /// tombstone corner + the copy-form `bool` return + the byte-
390 /// identical parity with the pre-lift `.is_some()` chain + the
391 /// cross-CRD coherence with `crate::crd::Process::is_being_deleted`
392 /// on the tombstone axis, so a regression that drifted any surface
393 /// at `tests::is_being_deleted_*` rather than as silent operator-
394 /// facing skew between the pool-reconciler's `→ Drain` decision
395 /// and the observed-phase composer's `→ Draining` report on the
396 /// SAME `EphemeralPool` within one reconcile pass).
397 pub fn is_being_deleted(&self) -> bool {
398 self.metadata.deletion_timestamp.is_some()
399 }
400}
401
402/// What the pool reconciler does when a member reaches `Failed`.
403///
404/// Sibling closed-set lifts on the same `tatara-process` axis:
405/// [`crate::compliance::VerificationPhase::ALL`],
406/// [`crate::signal::SighupStrategy::ALL`],
407/// [`crate::spec::MustReachPhase::ALL`],
408/// [`crate::intent::WorkloadKind::ALL`],
409/// [`crate::export::ReportFormat::ALL`],
410/// [`crate::encapsulates::EncapsulationMode::ALL`],
411/// [`crate::export::ExportTrigger::ALL`],
412/// [`crate::lifetime::TeardownPolicy::ALL`],
413/// [`crate::boundary::ConditionKind::ALL`],
414/// [`crate::lifetime::LifetimeKind::ALL`],
415/// [`crate::intent::IntentKind::ALL`],
416/// [`crate::phase::ProcessPhase::ALL`],
417/// [`crate::signal::ProcessSignal::ALL`].
418#[derive(
419 Clone,
420 Copy,
421 Debug,
422 Default,
423 Serialize,
424 Deserialize,
425 JsonSchema,
426 PartialEq,
427 Eq,
428 Hash,
429 tatara_closed_set::DeriveClosedSet,
430)]
431#[serde(rename_all = "PascalCase")]
432#[closed_set(via = "as_str", generate_unknown, display)]
433pub enum ReplacementPolicy {
434 /// **Default** — Failed member is reaped + replaced immediately
435 /// (pool stays at `desired` count). Most production-like.
436 #[default]
437 ReplaceImmediate,
438 /// Failed member stays for inspection; pool runs short until the
439 /// operator manually reaps it. Useful for debugging.
440 HoldFailed,
441 /// Failed member triggers pool-wide pause: `desired` is
442 /// effectively 0 until the operator manually resumes via a
443 /// pool-status patch. Used for "halt on any failure" workflows.
444 PausePool,
445}
446
447impl ReplacementPolicy {
448 /// The closed set of replacement policies — single source of truth
449 /// that drives the `as_str` / Display / `FromStr` triad and the
450 /// `replaces_failed` / `pauses_on_failure` predicate pair. Adding a
451 /// fourth variant lands at one `ALL` entry + one `as_str` arm + one
452 /// predicate arm per projection — exhaustively checked by the
453 /// compiler (the `[Self; 3]` array literal forces the arity) and by
454 /// the predicate-pair injectivity test below (a new variant must
455 /// land in its own (replaces_failed, pauses_on_failure) bucket or
456 /// the author has to extend the consumer dispatch in
457 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`).
458 pub const ALL: [Self; 3] = [Self::ReplaceImmediate, Self::HoldFailed, Self::PausePool];
459
460 /// Canonical PascalCase wire-format projection — matches the serde
461 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
462 /// enumeration the pool reconciler stamps on the
463 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
464 /// `replacement_policy_as_str_matches_serde` so a variant rename
465 /// can't drift between the typed surface, the CRD enum, the YAML
466 /// wire format AND the operator-facing diagnostic (the
467 /// `desired.rs` Pause reason composes `policy={policy}` via
468 /// Display, not a hard-coded `"PausePool"` literal that would
469 /// silently rot).
470 pub const fn as_str(self) -> &'static str {
471 match self {
472 Self::ReplaceImmediate => "ReplaceImmediate",
473 Self::HoldFailed => "HoldFailed",
474 Self::PausePool => "PausePool",
475 }
476 }
477
478 /// Should the pool auto-spawn a replacement for a Failed member?
479 /// Closed-set match (not `matches!`) so a future variant triggers
480 /// the compiler's exhaustiveness check at this site rather than
481 /// silently defaulting to `false`. Paired with
482 /// `pauses_on_failure` they form the two-axis projection
483 /// consumers in `tatara-pool-reconciler::desired::PoolConvergence`
484 /// pattern-match against — `replaces_failed` true ⇒ emit
485 /// `ReapFailed` per failure; `pauses_on_failure` true with any
486 /// failure ⇒ emit `Pause` and short-circuit. The pair is
487 /// `(true, false) | (false, false) | (false, true)` — pinned
488 /// injective by `replacement_policy_predicate_pair_is_injective`.
489 pub const fn replaces_failed(self) -> bool {
490 match self {
491 Self::ReplaceImmediate => true,
492 Self::HoldFailed | Self::PausePool => false,
493 }
494 }
495
496 /// Should reaching Failed on any member pause the whole pool?
497 /// See `replaces_failed` for the closed-match rationale + the
498 /// predicate-pair contract.
499 pub const fn pauses_on_failure(self) -> bool {
500 match self {
501 Self::PausePool => true,
502 Self::ReplaceImmediate | Self::HoldFailed => false,
503 }
504 }
505}
506
507// `impl FromStr for ReplacementPolicy` + `impl tatara_lisp::ClosedSet for
508// ReplacementPolicy` + `impl fmt::Display for ReplacementPolicy` are
509// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
510// declaration above. `label` delegates to the inherent
511// `ReplacementPolicy::as_str` via `#[closed_set(via = "as_str")]` so the
512// PascalCase wire-format projection stays load-bearing (matches the
513// serde `rename_all = "PascalCase"` output AND the
514// `tatara-pool-reconciler::desired::PoolConvergence` Pause reason
515// emission verbatim) while generic `T: ClosedSet` consumers reach the
516// STABLE workspace-wide name (`label`); Display delegates to the same
517// inherent projection via `#[closed_set(display)]` so the
518// `Pause` reason emitter's `policy={policy}` composition stays
519// pinned on the closed-set algebra rather than on a hand-rolled
520// `fmt::Display` block per implementor.
521
522// `pub struct UnknownReplacementPolicy(pub String)` is generated by
523// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
524// on the enum declaration above. The auto-derived label
525// `"replacement policy"` matches the prior hand-rolled
526// `#[error("unknown replacement policy: {0}")]` verbatim. Symmetric to
527// [`UnknownMemberState`], [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
528// [`crate::export::UnknownReportFormat`],
529// [`crate::export::UnknownChannelKind`],
530// [`crate::export::UnknownExportTrigger`],
531// [`crate::lifetime::UnknownTeardownPolicy`],
532// [`crate::boundary::UnknownConditionKind`], and
533// [`crate::phase::UnknownPhase`].
534
535fn default_free_ttl() -> String {
536 "24h".to_string()
537}
538fn default_max_allocation_ttl() -> String {
539 "4h".to_string()
540}
541
542/// `EphemeralPool.status` — observed pool population state.
543#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
544#[serde(rename_all = "camelCase")]
545pub struct PoolStatus {
546 /// Pool lifecycle phase.
547 #[serde(default)]
548 pub phase: PoolPhase,
549
550 /// When the pool entered the current phase.
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub phase_since: Option<DateTime<Utc>>,
553
554 /// Number of members currently in `Free` state (ready for allocation).
555 #[serde(default)]
556 pub ready_count: u32,
557
558 /// Number of members currently `Allocated`.
559 #[serde(default)]
560 pub allocated_count: u32,
561
562 /// Number of members currently `Spawning` (not yet Attested).
563 #[serde(default)]
564 pub spawning_count: u32,
565
566 /// Number of members currently `Returning` (reset or replace
567 /// in progress).
568 #[serde(default)]
569 pub returning_count: u32,
570
571 /// Member ledger — one entry per pool slot.
572 #[serde(default)]
573 pub members: Vec<PoolMember>,
574
575 /// Operator-visible message (e.g., "scaled down to floor").
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub message: Option<String>,
578
579 /// Standard Kubernetes Conditions.
580 #[serde(default)]
581 pub conditions: Vec<PoolCondition>,
582}
583
584/// One pool slot's state.
585#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
586#[serde(rename_all = "camelCase")]
587pub struct PoolMember {
588 /// `metadata.name` of the backing Process.
589 pub process_name: String,
590 /// Pool member's current slot state.
591 pub state: MemberState,
592 /// When the member entered the current state.
593 pub entered_state_at: DateTime<Utc>,
594 /// If allocated: the AllocationRef holding this slot.
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub allocation_ref: Option<AllocationRef>,
597}
598
599/// Light reference to an `EphemeralAllocation`.
600#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
601#[serde(rename_all = "camelCase")]
602pub struct AllocationRef {
603 pub name: String,
604 pub namespace: String,
605}
606
607impl AllocationRef {
608 /// Substrate constructor for [`AllocationRef`]: composes the
609 /// `(name, namespace)` pair through ONE `impl Into<String>`-gated
610 /// entry point — the ONE-liner collapse of the paired
611 /// `AllocationRef { name: n.into(), namespace: ns.into() }`
612 /// struct-literal incantation every downstream consumer restated
613 /// by hand pre-lift.
614 ///
615 /// Pre-lift the `AllocationRef { name, namespace }` struct-literal
616 /// was hand-authored at FOUR production sites past the ★★ PRIME-
617 /// DIRECTIVE ≥ 2 duplication threshold across the workspace, all
618 /// composing an owned `(name: String, namespace: String)` pair
619 /// under one of two roles:
620 /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
621 /// Bind path — the `assignedProcess` status slot's ref, pairing
622 /// the just-bound member Process name with the allocation's
623 /// containing namespace.
624 /// * `tatara-pool-reconciler::controller_allocation::reconcile_inner`
625 /// Release path — the same `assignedProcess` slot shape, stamped
626 /// at the release-side status patch alongside the (unchanged)
627 /// `boundPool` ref.
628 /// * `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`
629 /// pool-matched handle — the `matched_pool` slot's ref, pairing
630 /// [`EphemeralPool::owned_name_or_empty`] with the pool's
631 /// containing namespace.
632 /// * `tatara-github-watcher::allocation_factory::allocation_from_pr`
633 /// — the `pool_ref` slot on the `AllocationSpec` emitted from a
634 /// PullRequestEvent, pairing the operator-configured pool name
635 /// with the watcher's target namespace.
636 ///
637 /// All FOUR sites walked the SAME two-field struct-literal shape
638 /// — an owned name half, an owned namespace half — differing only
639 /// in provenance. Post-lift each callsite reads
640 /// `AllocationRef::new(name, ns)` and the produced value feeds the
641 /// same downstream slot (`assignedProcess` / `bound_pool` /
642 /// `matched_pool` / `spec.pool_ref`) unchanged. The `impl Into<String>`
643 /// signature accepts every provenance the pre-lift sites carried —
644 /// owned `String` (the reconciler's owned-form projections), `&str`
645 /// (the factory's `n.to_string()` / `namespace.to_string()`
646 /// borrow-to-owned promotions), `Cow<str>`, and every other
647 /// `Into<String>` implementor — so no callsite has to change its
648 /// upstream provenance to route through the primitive.
649 ///
650 /// Return-form axis: owned [`AllocationRef`] — the wire-format
651 /// shape [`crate::pool::AllocationRef`]'s serde `rename_all =
652 /// "camelCase"` produces on both spec (`poolRef`) and status
653 /// (`boundPool` / `assignedProcess`) slots. The primitive owns
654 /// the axis-order `(name, namespace)` — the same order the four
655 /// consumers spelled — so a slot swap surfaces at the
656 /// `allocation_ref_new_positional_axis_order` pin below rather
657 /// than as silent `<namespace>/<name>` inversion downstream.
658 ///
659 /// Peer to the sibling substrate primitives already opened on the
660 /// pool-side (name, namespace) axis pair:
661 /// [`EphemeralPool::name_or_empty`] (borrow-form name),
662 /// [`EphemeralPool::owned_name_or_empty`] (owned-form name); this
663 /// constructor is the composer that folds the owned-form projections
664 /// into the wire-format ref shape.
665 ///
666 /// A future refactor of [`AllocationRef`]'s field set (a
667 /// `resource_kind: String` field for cross-CRD refs, an
668 /// `api_version: String` field for FQN references, a
669 /// canonicalization pass over the namespace half, a non-empty-name
670 /// gate) lands at ONE substrate constructor site here and every
671 /// downstream consumer inherits the upgrade mechanically — no per-
672 /// callsite hand-edit at the FOUR reconciler + factory sites.
673 ///
674 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
675 /// the `AllocationRef { name, namespace }` struct-literal shape
676 /// recurred at four hand-authored sites past the ★★ PRIME-
677 /// DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE owner
678 /// here). THEORY.md §II.1 invariant 5 (composition preserves
679 /// proofs — the pins bind the positional axis-order + the
680 /// `Into<String>` provenance closure + byte-identical parity with
681 /// the pre-lift struct-literal + `PartialEq` coherence with the
682 /// hand-authored form, so a regression that reshaped any surface
683 /// at `tests::allocation_ref_new_*` rather than as silent
684 /// operator-facing skew between the assignedProcess / bound_pool
685 /// / matched_pool / spec.pool_ref slots on the SAME allocation).
686 #[must_use]
687 pub fn new(name: impl Into<String>, namespace: impl Into<String>) -> Self {
688 Self {
689 name: name.into(),
690 namespace: namespace.into(),
691 }
692 }
693}
694
695/// Per-slot state in the pool's free list.
696///
697/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
698/// (the on-failure policy that the pool reconciler dispatches against
699/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
700/// release-time disposition that transitions an [`Self::Allocated`]
701/// member into [`Self::Returning`] before it either re-enters
702/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
703#[derive(
704 Clone,
705 Copy,
706 Debug,
707 PartialEq,
708 Eq,
709 Hash,
710 Serialize,
711 Deserialize,
712 JsonSchema,
713 tatara_closed_set::DeriveClosedSet,
714)]
715#[serde(rename_all = "PascalCase")]
716#[closed_set(via = "as_str", generate_unknown, display)]
717pub enum MemberState {
718 /// Pool reconciler is creating/converging the backing Process.
719 Spawning,
720 /// Process is `Attested`; ready for allocation.
721 Free,
722 /// Held by an `EphemeralAllocation`.
723 Allocated,
724 /// Return policy is being applied (Reset → reset Job; Replace →
725 /// Process is being torn down and recreated).
726 Returning,
727 /// Permanent failure — the member needs operator attention.
728 Failed,
729}
730
731impl MemberState {
732 /// The closed set of member states — single source of truth that
733 /// drives the `as_str` / Display / `FromStr` triad AND the
734 /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
735 /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
736 /// arm per predicate — exhaustively checked by the compiler (the
737 /// `[Self; 5]` array literal forces the arity) and by the
738 /// per-variant truth-table contract test (a new variant must
739 /// declare its own `(is_failed, counts_toward_supply)` projection
740 /// or the consumer dispatch in
741 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
742 /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
743 /// will silently bucket it into the wrong lifecycle column).
744 pub const ALL: [Self; 5] = [
745 Self::Spawning,
746 Self::Free,
747 Self::Allocated,
748 Self::Returning,
749 Self::Failed,
750 ];
751
752 /// Canonical PascalCase wire-format projection — matches the serde
753 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
754 /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
755 /// `status.members[].state`. Pinned by
756 /// `member_state_as_str_matches_serde` so a variant rename can't
757 /// drift between the typed surface, the CRD enum, the YAML wire
758 /// format AND any future operator-facing diagnostic that composes
759 /// `state={state}` via Display rather than a hard-coded literal
760 /// that would silently rot.
761 pub const fn as_str(self) -> &'static str {
762 match self {
763 Self::Spawning => "Spawning",
764 Self::Free => "Free",
765 Self::Allocated => "Allocated",
766 Self::Returning => "Returning",
767 Self::Failed => "Failed",
768 }
769 }
770
771 /// Is this member in a permanent-failure state — needs operator
772 /// attention? Closed-set match (not `matches!`) so a future variant
773 /// triggers the compiler's exhaustiveness check at this site rather
774 /// than silently defaulting to `false`. Consumed by
775 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
776 /// gate the highest-priority `ReplaceMembers` decision branch — a
777 /// future variant that should also trigger replacement (e.g.
778 /// `MemberState::Quarantined`) flips this predicate at one site
779 /// and inherits the priority-1 dispatch without touching the
780 /// consumer match arm.
781 pub const fn is_failed(self) -> bool {
782 match self {
783 Self::Failed => true,
784 Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
785 }
786 }
787
788 /// Does this member contribute to the pool's *available supply*
789 /// (current ready slots + slots coming online)? Closed-set match so
790 /// a future variant triggers the compiler's exhaustiveness check.
791 /// Consumed by
792 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
793 /// — the `(free + spawning)` supply calc collapses into one
794 /// predicate-driven filter, so a future "warming-up" state
795 /// (`MemberState::Warming` between Spawning and Free) plugs into
796 /// the supply count at one site rather than three. Disjoint with
797 /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
798 /// (a Failed member can never count toward supply; the pool
799 /// reconciler would otherwise double-count failures as available
800 /// capacity).
801 pub const fn counts_toward_supply(self) -> bool {
802 match self {
803 Self::Free | Self::Spawning => true,
804 Self::Allocated | Self::Returning | Self::Failed => false,
805 }
806 }
807}
808
809// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
810// MemberState` + `impl fmt::Display for MemberState` are generated by
811// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
812// above. `label` delegates to the inherent `MemberState::as_str` via
813// `#[closed_set(via = "as_str")]` so the
814// `pool_phase_from_members` supply calc can keep keying on
815// `counts_toward_supply` against the typed variant while a generic
816// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
817// (`label`) without knowing this enum lives in `tatara-process::pool`;
818// Display delegates to the same inherent projection via
819// `#[closed_set(display)]` so the diagnostic emitter's
820// `state={state}` composition stays pinned on the closed-set algebra.
821
822// `pub struct UnknownMemberState(pub String)` is generated by
823// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
824// on the enum declaration above. The auto-derived label `"member state"`
825// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
826// verbatim. Symmetric to [`UnknownReplacementPolicy`],
827// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
828// [`crate::lifetime::UnknownTeardownPolicy`],
829// [`crate::boundary::UnknownConditionKind`], and
830// [`crate::phase::UnknownPhase`].
831
832/// Pool lifecycle phase (observed across the whole pool population).
833///
834/// Sibling closed-set on the same `EphemeralPool` axis as
835/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
836/// over via [`MemberState::counts_toward_supply`]),
837/// [`ReplacementPolicy::ALL`] (on-failure policy) and
838/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
839/// `MemberState`, this closes the pool reconciler's
840/// `(slot-state, pool-phase)` two-tier observation algebra on the
841/// same closed-set discipline as the rest of `tatara-process`.
842#[derive(
843 Clone,
844 Copy,
845 Debug,
846 PartialEq,
847 Eq,
848 Hash,
849 Serialize,
850 Deserialize,
851 JsonSchema,
852 tatara_closed_set::DeriveClosedSet,
853)]
854#[serde(rename_all = "PascalCase")]
855#[closed_set(via = "as_str", generate_unknown, display)]
856pub enum PoolPhase {
857 /// Just admitted; no members yet.
858 Initializing,
859 /// `ready_count == desired_size`.
860 Steady,
861 /// `ready_count + spawning_count < desired_size` and reconciler
862 /// is creating new members.
863 ScalingUp,
864 /// `ready_count > desired_size` and reconciler is reaping excess.
865 ScalingDown,
866 /// `min_size` constraint violated.
867 Degraded,
868 /// Pool is being deleted; reconciler is reaping all members.
869 Draining,
870}
871
872impl Default for PoolPhase {
873 fn default() -> Self {
874 Self::Initializing
875 }
876}
877
878impl PoolPhase {
879 /// The closed set of pool phases — single source of truth that
880 /// drives the `as_str` / Display / `FromStr` triad AND the
881 /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
882 /// variant lands at one `ALL` entry + one `as_str` arm + one arm
883 /// per predicate — exhaustively checked by the compiler (the
884 /// `[Self; 6]` array literal forces the arity) AND by the
885 /// per-variant truth-table contract test (a new variant must
886 /// declare its own `(is_steady, is_terminal)` projection or any
887 /// future status-aggregator surface — `feira pool list
888 /// --healthy`, the operator-facing condition aggregator, the
889 /// desired-loop heartbeat short-circuit — will silently bucket
890 /// it into the wrong lifecycle column).
891 pub const ALL: [Self; 6] = [
892 Self::Initializing,
893 Self::Steady,
894 Self::ScalingUp,
895 Self::ScalingDown,
896 Self::Degraded,
897 Self::Draining,
898 ];
899
900 /// Canonical PascalCase wire-format projection — matches the
901 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
902 /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
903 /// stamps on `status.phase`. Pinned by
904 /// `pool_phase_as_str_matches_serde` so a variant rename can't
905 /// drift between the typed surface, the CRD enum, the YAML wire
906 /// format AND any future operator-facing diagnostic that
907 /// composes `phase={phase}` via Display rather than a hard-coded
908 /// literal that would silently rot. Display + FromStr triad
909 /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
910 /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
911 /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
912 pub const fn as_str(self) -> &'static str {
913 match self {
914 Self::Initializing => "Initializing",
915 Self::Steady => "Steady",
916 Self::ScalingUp => "ScalingUp",
917 Self::ScalingDown => "ScalingDown",
918 Self::Degraded => "Degraded",
919 Self::Draining => "Draining",
920 }
921 }
922
923 /// Is the pool fully converged — supply matches desired, no
924 /// reconciler-driven population change pending? Closed-set match
925 /// (not `matches!`) so a future variant triggers the compiler's
926 /// exhaustiveness check at this site rather than silently
927 /// defaulting to `false`. Paired with `is_terminal` they form
928 /// the two-axis projection that future status aggregators
929 /// (operator-facing fleet health, `feira pool list --healthy`,
930 /// the SSE filter "show non-steady pools") dispatch against —
931 /// `is_steady && !is_terminal` ⇒ converged (goal state);
932 /// `!is_steady && is_terminal` ⇒ being deleted (no future
933 /// spawn); `!is_steady && !is_terminal` ⇒ transient
934 /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
935 /// is in motion toward desired). The impossible bucket
936 /// `(true, true)` — a draining pool that's somehow also steady
937 /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
938 pub const fn is_steady(self) -> bool {
939 match self {
940 Self::Steady => true,
941 Self::Initializing
942 | Self::ScalingUp
943 | Self::ScalingDown
944 | Self::Degraded
945 | Self::Draining => false,
946 }
947 }
948
949 /// Is the pool in its absorbing exit state — deletion-stamped,
950 /// reconciler is reaping every member, no spawn will ever
951 /// happen again? Closed-set match so a future variant triggers
952 /// the compiler's exhaustiveness check. See `is_steady` for the
953 /// predicate-pair contract + bucket definitions.
954 pub const fn is_terminal(self) -> bool {
955 match self {
956 Self::Draining => true,
957 Self::Initializing
958 | Self::Steady
959 | Self::ScalingUp
960 | Self::ScalingDown
961 | Self::Degraded => false,
962 }
963 }
964}
965
966// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
967// + `impl fmt::Display for PoolPhase` are generated by
968// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
969// `label` delegates to the inherent `PoolPhase::as_str` via
970// `#[closed_set(via = "as_str")]` so the operator-facing
971// `phase={phase}` Display composition keeps reading the same canonical
972// PascalCase projection while a generic `T: ClosedSet` consumer (a
973// status-aggregator filter, the `feira pool list --healthy` predicate, a
974// future SSE event router) can walk every variant without knowing the
975// closed set lives in `tatara-process::pool`; Display delegates to the
976// same inherent projection via `#[closed_set(display)]` so the
977// `phase={phase}` composition stays pinned on the closed-set algebra
978// rather than a hand-rolled `fmt::Display` block.
979
980// `pub struct UnknownPoolPhase(pub String)` is generated by
981// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
982// on the enum declaration above. The auto-derived label `"pool phase"`
983// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
984// verbatim. Symmetric to [`UnknownMemberState`],
985// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
986// [`crate::lifetime::UnknownTeardownPolicy`],
987// [`crate::boundary::UnknownConditionKind`], and
988// [`crate::phase::UnknownPhase`].
989
990/// Standard K8s Condition shape (kept local so tatara-process doesn't
991/// depend on k8s_openapi types in its public schema).
992#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
993#[serde(rename_all = "camelCase")]
994pub struct PoolCondition {
995 pub type_: String,
996 pub status: String,
997 pub reason: String,
998 pub message: String,
999 pub last_transition_time: DateTime<Utc>,
1000}
1001
1002/// What the pool does when an allocation releases a member.
1003///
1004/// Sibling closed-set on the `EphemeralPool` axis:
1005/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
1006/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
1007/// (the *release*-time counterpart for non-pooled ephemeral envs),
1008/// [`crate::boundary::ConditionKind::ALL`],
1009/// [`crate::lifetime::LifetimeKind::ALL`],
1010/// [`crate::intent::IntentKind::ALL`],
1011/// [`crate::phase::ProcessPhase::ALL`],
1012/// [`crate::signal::ProcessSignal::ALL`].
1013#[derive(
1014 Clone,
1015 Copy,
1016 Debug,
1017 Hash,
1018 PartialEq,
1019 Eq,
1020 Serialize,
1021 Deserialize,
1022 JsonSchema,
1023 Default,
1024 tatara_closed_set::DeriveClosedSet,
1025)]
1026#[serde(rename_all = "PascalCase")]
1027#[closed_set(via = "as_str", generate_unknown, display)]
1028pub enum ReturnPolicy {
1029 /// Tear down the Process + create a fresh one. Safe but slow
1030 /// (1-2 min spin-up before the slot is Free again).
1031 #[default]
1032 Replace,
1033 /// Keep the Process running; run a typed `:reset` Job that wipes
1034 /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
1035 /// the reset Job being correct for the workload. API-authoritative
1036 /// systems are natural fits because the control API owns all state.
1037 Reset,
1038 /// Keep the Process indefinitely after release (debugging aid;
1039 /// operator must `feira pool reap NAME` to clean up). Useful for
1040 /// post-mortem of a flaky test.
1041 Keep,
1042}
1043
1044impl ReturnPolicy {
1045 /// The closed set of return policies — single source of truth that
1046 /// drives the `as_str` / Display / `FromStr` triad and the
1047 /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
1048 /// fourth variant lands at one `ALL` entry + one `as_str` arm +
1049 /// one arm per predicate — exhaustively checked by the compiler
1050 /// (the `[Self; 3]` array literal forces the arity) and by the
1051 /// predicate-pair injectivity test (a new variant must land in
1052 /// its own (keeps_process, runs_reset_job) bucket or the author
1053 /// has to extend the consumer dispatch in
1054 /// `tatara-pool-reconciler::return_policy::plan_return`).
1055 pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
1056
1057 /// Canonical PascalCase wire-format projection — matches the
1058 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
1059 /// `enum:` enumeration the pool reconciler stamps on the
1060 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
1061 /// `return_policy_as_str_matches_serde` so a variant rename can't
1062 /// drift between the typed surface, the CRD enum, the YAML wire
1063 /// format AND any future operator-facing diagnostic that composes
1064 /// `policy={policy}` via Display rather than a hard-coded literal.
1065 pub const fn as_str(self) -> &'static str {
1066 match self {
1067 Self::Replace => "Replace",
1068 Self::Reset => "Reset",
1069 Self::Keep => "Keep",
1070 }
1071 }
1072
1073 /// Does the pool keep the backing Process alive across release?
1074 /// Closed-set match (not `matches!`) so a future variant triggers
1075 /// the compiler's exhaustiveness check at this site rather than
1076 /// silently defaulting to `false`. Paired with `runs_reset_job`
1077 /// they form the two-axis projection that the consumer in
1078 /// `tatara-pool-reconciler::return_policy::plan_return` matches
1079 /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
1080 /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
1081 /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
1082 /// pair is `(false, false) | (true, true) | (true, false)` —
1083 /// pinned injective by
1084 /// `return_policy_predicate_pair_is_injective`.
1085 pub const fn keeps_process(self) -> bool {
1086 match self {
1087 Self::Replace => false,
1088 Self::Reset | Self::Keep => true,
1089 }
1090 }
1091
1092 /// Does the policy run a typed `:reset` Job to wipe state in
1093 /// place? See `keeps_process` for the closed-match rationale +
1094 /// the predicate-pair contract.
1095 pub const fn runs_reset_job(self) -> bool {
1096 match self {
1097 Self::Reset => true,
1098 Self::Replace | Self::Keep => false,
1099 }
1100 }
1101}
1102
1103// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
1104// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
1105// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
1106// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
1107// `#[closed_set(via = "as_str")]` so the
1108// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
1109// reading the canonical PascalCase projection that matches the CRD
1110// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
1111// plugs in without knowing the enum lives in `tatara-process::pool`;
1112// Display delegates to the same inherent projection via
1113// `#[closed_set(display)]` so the `policy={policy}` diagnostic
1114// composition stays pinned on the closed-set algebra.
1115
1116// `pub struct UnknownReturnPolicy(pub String)` is generated by
1117// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
1118// on the enum declaration above. The auto-derived label `"return policy"`
1119// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
1120// verbatim. Symmetric to [`UnknownReplacementPolicy`],
1121// [`UnknownMemberState`], [`UnknownPoolPhase`],
1122// [`crate::lifetime::UnknownTeardownPolicy`],
1123// [`crate::boundary::UnknownConditionKind`], and
1124// [`crate::phase::UnknownPhase`].
1125
1126/// Routing selector — matches an `EphemeralAllocation`'s requestor
1127/// against pool-eligibility predicates.
1128#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
1129#[serde(rename_all = "camelCase")]
1130pub struct PoolSelector {
1131 /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
1132 /// Empty = match every repo.
1133 #[serde(default)]
1134 pub repos: Vec<String>,
1135
1136 /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
1137 /// Empty = match every branch.
1138 #[serde(default)]
1139 pub branches: Vec<String>,
1140
1141 /// PR labels (all-must-match, AND semantics). Empty = no label
1142 /// requirement.
1143 #[serde(default)]
1144 pub pr_labels: Vec<String>,
1145
1146 /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
1147 /// "manual", "ci-run"). Empty = any kind.
1148 #[serde(default)]
1149 pub kinds: Vec<String>,
1150}
1151
1152impl PoolSelector {
1153 /// Does this selector match the given allocation routing key?
1154 /// Pure: no side effects.
1155 pub fn matches(&self, key: &MatchKey<'_>) -> bool {
1156 glob_any(&self.repos, key.repo)
1157 && glob_any(&self.branches, key.branch)
1158 && labels_subset(&self.pr_labels, key.pr_labels)
1159 && kind_any(&self.kinds, key.kind)
1160 }
1161
1162 /// Specificity score — higher = more specific. Used by the
1163 /// reconciler to break ties between selectors that all match.
1164 pub fn specificity(&self) -> u32 {
1165 let mut score = 0;
1166 if !self.repos.is_empty() {
1167 score += 8;
1168 }
1169 if !self.branches.is_empty() {
1170 score += 4;
1171 }
1172 score += (self.pr_labels.len() as u32) * 2;
1173 if !self.kinds.is_empty() {
1174 score += 1;
1175 }
1176 score
1177 }
1178}
1179
1180/// Allocation routing key — what the reconciler matches against pool selectors.
1181#[derive(Clone, Copy, Debug)]
1182pub struct MatchKey<'a> {
1183 pub repo: &'a str,
1184 pub branch: &'a str,
1185 pub pr_labels: &'a [String],
1186 pub kind: &'a str,
1187}
1188
1189fn glob_any(patterns: &[String], value: &str) -> bool {
1190 if patterns.is_empty() {
1191 return true;
1192 }
1193 patterns.iter().any(|p| glob_match(p, value))
1194}
1195
1196fn kind_any(kinds: &[String], value: &str) -> bool {
1197 if kinds.is_empty() {
1198 return true;
1199 }
1200 kinds.iter().any(|k| k == value)
1201}
1202
1203fn labels_subset(required: &[String], present: &[String]) -> bool {
1204 required.iter().all(|r| present.iter().any(|p| p == r))
1205}
1206
1207/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
1208/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
1209/// matches anything.
1210fn glob_match(pattern: &str, value: &str) -> bool {
1211 if pattern.is_empty() {
1212 return true;
1213 }
1214 if let Some(prefix) = pattern.strip_suffix('*') {
1215 value.starts_with(prefix)
1216 } else {
1217 pattern == value
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 // The closed-set tests below call `T::from_str(bad)` via the
1225 // derive-generated `FromStr` impls — bring the trait into scope at
1226 // the test module so the lib body doesn't carry an otherwise-unused
1227 // `use std::str::FromStr;` at the file head.
1228 use std::str::FromStr;
1229
1230 #[test]
1231 fn glob_trailing_star_matches_prefix() {
1232 assert!(glob_match("pleme-io/*", "pleme-io/demo-app"));
1233 assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
1234 assert!(glob_match("release-*", "release-2026-05"));
1235 assert!(!glob_match("release-*", "main"));
1236 assert!(glob_match("main", "main"));
1237 assert!(!glob_match("main", "develop"));
1238 }
1239
1240 #[test]
1241 fn empty_selector_matches_anything() {
1242 let s = PoolSelector::default();
1243 assert!(s.matches(&MatchKey {
1244 repo: "any/repo",
1245 branch: "any-branch",
1246 pr_labels: &[],
1247 kind: "any",
1248 }));
1249 }
1250
1251 #[test]
1252 fn repo_glob_filters_match_key() {
1253 let s = PoolSelector {
1254 repos: vec!["pleme-io/demo-*".into()],
1255 ..Default::default()
1256 };
1257 assert!(s.matches(&MatchKey {
1258 repo: "pleme-io/demo-app",
1259 branch: "x",
1260 pr_labels: &[],
1261 kind: "y",
1262 }));
1263 assert!(!s.matches(&MatchKey {
1264 repo: "pleme-io/other-repo",
1265 branch: "x",
1266 pr_labels: &[],
1267 kind: "y",
1268 }));
1269 }
1270
1271 #[test]
1272 fn pr_labels_require_all() {
1273 let s = PoolSelector {
1274 pr_labels: vec!["needs-ephemeral".into(), "integration".into()],
1275 ..Default::default()
1276 };
1277 // Both labels present → match.
1278 assert!(s.matches(&MatchKey {
1279 repo: "x",
1280 branch: "y",
1281 pr_labels: &[
1282 "needs-ephemeral".into(),
1283 "integration".into(),
1284 "extra".into()
1285 ],
1286 kind: "z",
1287 }));
1288 // One label missing → no match.
1289 assert!(!s.matches(&MatchKey {
1290 repo: "x",
1291 branch: "y",
1292 pr_labels: &["needs-ephemeral".into()],
1293 kind: "z",
1294 }));
1295 }
1296
1297 #[test]
1298 fn specificity_ranks_more_constrained_higher() {
1299 let general = PoolSelector::default();
1300 let specific = PoolSelector {
1301 repos: vec!["pleme-io/*".into()],
1302 branches: vec!["main".into()],
1303 pr_labels: vec!["needs-ephemeral".into()],
1304 kinds: vec!["github-pr".into()],
1305 };
1306 assert!(specific.specificity() > general.specificity());
1307 }
1308
1309 #[test]
1310 fn return_policy_defaults_to_replace() {
1311 assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
1312 }
1313
1314 #[test]
1315 fn pool_phase_defaults_to_initializing() {
1316 assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
1317 }
1318
1319 // ── closed-set algebra contracts for ReplacementPolicy
1320 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
1321
1322 /// Structural well-formedness of [`ReplacementPolicy`] as a
1323 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1324 /// testkit lift that pins all three structural invariants (`ALL`
1325 /// is non-empty, every variant round-trips through
1326 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1327 /// outside the closed set) at ONE call site. Replaces the hand-
1328 /// derived `replacement_policy_all_is_unique_and_complete` +
1329 /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
1330 /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
1331 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1332 /// exercises the same code path the pool reconciler hits when
1333 /// parsing a CRD `enum:`-validated value back to the typed policy.
1334 #[test]
1335 fn replacement_policy_is_well_formed_closed_set() {
1336 tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
1337 }
1338
1339 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1340 /// output verbatim for every variant. A future variant rename (or
1341 /// an `as_str` arm typo) lands here at one site, instead of
1342 /// drifting between the typed surface, the CRD enum, and the
1343 /// YAML wire format.
1344 #[test]
1345 fn replacement_policy_as_str_matches_serde() {
1346 crate::tagged_union::assert_label_matches_serde_serialization::<ReplacementPolicy>();
1347 }
1348
1349 /// The Display impl IS `as_str` — pinning this lets future callers
1350 /// reach for either projection without drift. The operator-facing
1351 /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1352 /// composes through Display rather than through a hard-coded
1353 /// variant string.
1354 #[test]
1355 fn replacement_policy_display_matches_as_str() {
1356 crate::tagged_union::assert_display_matches_label::<ReplacementPolicy>();
1357 }
1358
1359 /// `FromStr` rejects strings that aren't in the canonical
1360 /// projection — lowercased / typo / cross-axis-leaked — and the
1361 /// error echoes the input verbatim so the operator-facing
1362 /// diagnostic carries the offending value, not a normalized form.
1363 /// The empty-input arm is pinned by
1364 /// [`replacement_policy_is_well_formed_closed_set`] via the
1365 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1366 /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1367 /// newtype, which the trait's `make_unknown` can't see.
1368 #[test]
1369 fn unknown_replacement_policy_errors() {
1370 for bad in [
1371 "replaceimmediate",
1372 "PAUSEPOOL",
1373 "Replace-Immediate",
1374 "hold_failed",
1375 "Pause",
1376 "Reset",
1377 ] {
1378 let err = ReplacementPolicy::from_str(bad).unwrap_err();
1379 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1380 }
1381 }
1382
1383 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1384 /// documented per-variant on-failure behavior.
1385 #[test]
1386 fn replacement_policy_predicate_truth_tables() {
1387 assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1388 assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1389
1390 assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1391 assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1392
1393 assert!(!ReplacementPolicy::PausePool.replaces_failed());
1394 assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1395 }
1396
1397 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1398 /// predicates simultaneously — the two on-failure actions
1399 /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1400 /// A future `ReplacementPolicy::PauseAndReap` that returned true
1401 /// from both would FAIL here, forcing the author to either pick
1402 /// one bucket or extend the consumer dispatch site in
1403 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1404 /// deliberately rather than silently double-firing both branches.
1405 #[test]
1406 fn replacement_policy_predicates_are_disjoint() {
1407 for policy in ReplacementPolicy::ALL {
1408 assert!(
1409 !(policy.replaces_failed() && policy.pauses_on_failure()),
1410 "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1411 );
1412 }
1413 }
1414
1415 /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1416 /// pauses_on_failure)` is injective across `ALL`. Each variant
1417 /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1418 /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1419 /// this with the disjointness contract above forces a future
1420 /// variant to land in a fresh `(replaces_failed,
1421 /// pauses_on_failure)` bucket — or the author extends the consumer
1422 /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1423 /// to recognize the new projection bucket.
1424 #[test]
1425 fn replacement_policy_predicate_pair_is_injective() {
1426 let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1427 .into_iter()
1428 .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1429 .collect();
1430 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1431 assert_eq!(
1432 projections.len(),
1433 unique.len(),
1434 "predicate pair projection is not injective: {projections:?}",
1435 );
1436 }
1437
1438 /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1439 /// returns the variant tagged `#[default]` in the enum, AND that
1440 /// variant reaps (the production-safe behavior). A future #[default]
1441 /// rename without flipping the predicates fails here.
1442 #[test]
1443 fn replacement_policy_default_replaces_failed() {
1444 let d = ReplacementPolicy::default();
1445 assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1446 assert!(d.replaces_failed());
1447 assert!(!d.pauses_on_failure());
1448 }
1449
1450 #[test]
1451 fn kinds_filter_to_known_set() {
1452 let s = PoolSelector {
1453 kinds: vec!["github-pr".into(), "manual".into()],
1454 ..Default::default()
1455 };
1456 assert!(s.matches(&MatchKey {
1457 repo: "x",
1458 branch: "y",
1459 pr_labels: &[],
1460 kind: "github-pr",
1461 }));
1462 assert!(!s.matches(&MatchKey {
1463 repo: "x",
1464 branch: "y",
1465 pr_labels: &[],
1466 kind: "scheduled",
1467 }));
1468 }
1469
1470 // ── closed-set algebra contracts for ReturnPolicy
1471 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
1472
1473 /// Structural well-formedness of [`ReturnPolicy`] as a
1474 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1475 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1476 /// above.
1477 #[test]
1478 fn return_policy_is_well_formed_closed_set() {
1479 tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
1480 }
1481
1482 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1483 /// output verbatim for every variant. A future variant rename (or
1484 /// an `as_str` arm typo) lands here at one site, instead of
1485 /// drifting between the typed surface, the CRD enum, and the
1486 /// YAML wire format.
1487 #[test]
1488 fn return_policy_as_str_matches_serde() {
1489 crate::tagged_union::assert_label_matches_serde_serialization::<ReturnPolicy>();
1490 }
1491
1492 /// The Display impl IS `as_str` — pinning this lets future callers
1493 /// reach for either projection without drift, mirroring the
1494 /// `ReplacementPolicy` discipline.
1495 #[test]
1496 fn return_policy_display_matches_as_str() {
1497 crate::tagged_union::assert_display_matches_label::<ReturnPolicy>();
1498 }
1499
1500 /// `FromStr` rejects strings that aren't in the canonical
1501 /// projection — lowercased / typo / cross-axis-leaked — and the
1502 /// error echoes the input verbatim so the operator-facing
1503 /// diagnostic carries the offending value, not a normalized form.
1504 /// The empty-input arm is pinned by
1505 /// [`return_policy_is_well_formed_closed_set`] via the
1506 /// `tatara_lisp::ClosedSet` testkit.
1507 #[test]
1508 fn unknown_return_policy_errors() {
1509 for bad in [
1510 "replace",
1511 "RESET",
1512 "Re-place",
1513 "keep_for_inspection",
1514 "DeleteAndRespawn",
1515 "ReplaceImmediate",
1516 ] {
1517 let err = ReturnPolicy::from_str(bad).unwrap_err();
1518 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1519 }
1520 }
1521
1522 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1523 /// documented per-variant on-release behavior.
1524 #[test]
1525 fn return_policy_predicate_truth_tables() {
1526 assert!(!ReturnPolicy::Replace.keeps_process());
1527 assert!(!ReturnPolicy::Replace.runs_reset_job());
1528
1529 assert!(ReturnPolicy::Reset.keeps_process());
1530 assert!(ReturnPolicy::Reset.runs_reset_job());
1531
1532 assert!(ReturnPolicy::Keep.keeps_process());
1533 assert!(!ReturnPolicy::Keep.runs_reset_job());
1534 }
1535
1536 /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
1537 /// You cannot run a typed `:reset` Job against a Process you've
1538 /// just deleted; the impossible bucket `(false, true)` must stay
1539 /// empty. A future variant returning true from `runs_reset_job`
1540 /// while returning false from `keeps_process` fails here, which
1541 /// forces the author to either flip `keeps_process` to true or
1542 /// extend the consumer dispatch site in
1543 /// `tatara-pool-reconciler::return_policy::plan_return`
1544 /// deliberately rather than letting an impossible state slip in.
1545 #[test]
1546 fn return_policy_reset_implies_keeps_process() {
1547 for policy in ReturnPolicy::ALL {
1548 if policy.runs_reset_job() {
1549 assert!(
1550 policy.keeps_process(),
1551 "{policy:?} runs a reset job but does not keep the process",
1552 );
1553 }
1554 }
1555 }
1556
1557 /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
1558 /// is injective across `ALL`. Each variant projects to its own
1559 /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
1560 /// `(true, true)` = reset-in-place; `(true, false)` = keep for
1561 /// inspection. Pairing this with the implication contract above
1562 /// forces a future variant to land in a fresh
1563 /// `(keeps_process, runs_reset_job)` bucket — or the author
1564 /// extends the consumer dispatch in
1565 /// `tatara-pool-reconciler::return_policy::plan_return` to
1566 /// recognize the new projection bucket.
1567 #[test]
1568 fn return_policy_predicate_pair_is_injective() {
1569 let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
1570 .into_iter()
1571 .map(|p| (p.keeps_process(), p.runs_reset_job()))
1572 .collect();
1573 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1574 assert_eq!(
1575 projections.len(),
1576 unique.len(),
1577 "predicate pair projection is not injective: {projections:?}",
1578 );
1579 }
1580
1581 /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
1582 /// the variant tagged `#[default]` in the enum, AND that variant
1583 /// is the safe "tear down + respawn" behavior — neither keeps the
1584 /// process nor runs a reset Job. A future `#[default]` rename
1585 /// without flipping the predicates fails here.
1586 #[test]
1587 fn return_policy_default_is_replace_and_neither_predicate_fires() {
1588 let d = ReturnPolicy::default();
1589 assert_eq!(d, ReturnPolicy::Replace);
1590 assert!(!d.keeps_process());
1591 assert!(!d.runs_reset_job());
1592 }
1593
1594 // ── closed-set algebra contracts for MemberState
1595 // (ALL × as_str × FromStr × predicate pair) ────────────────────
1596
1597 /// Structural well-formedness of [`MemberState`] as a
1598 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1599 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1600 /// and [`return_policy_is_well_formed_closed_set`] above.
1601 #[test]
1602 fn member_state_is_well_formed_closed_set() {
1603 tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
1604 }
1605
1606 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1607 /// output verbatim for every variant. A future variant rename (or
1608 /// an `as_str` arm typo) lands here at one site, instead of
1609 /// drifting between the typed surface, the CRD enum, and the YAML
1610 /// wire format the pool reconciler stamps on
1611 /// `status.members[].state`.
1612 #[test]
1613 fn member_state_as_str_matches_serde() {
1614 crate::tagged_union::assert_label_matches_serde_serialization::<MemberState>();
1615 }
1616
1617 /// The Display impl IS `as_str` — pinning this lets future callers
1618 /// reach for either projection without drift. Any operator-facing
1619 /// "state={state}" diagnostic that composes through Display
1620 /// inherits the canonical wire-format string automatically.
1621 #[test]
1622 fn member_state_display_matches_as_str() {
1623 crate::tagged_union::assert_display_matches_label::<MemberState>();
1624 }
1625
1626 /// `FromStr` rejects strings that aren't in the canonical
1627 /// projection — lowercased / typo / cross-axis-leaked — and
1628 /// the error echoes the input verbatim so the operator-facing
1629 /// diagnostic carries the offending value, not a normalized form.
1630 /// The empty-input arm is pinned by
1631 /// [`member_state_is_well_formed_closed_set`] via the
1632 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1633 /// pin the closed-set REJECTION contract that the trait can't see:
1634 /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
1635 /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
1636 /// `ProcessPhase`) but MUST reject here, because the codomains
1637 /// are disjoint.
1638 #[test]
1639 fn unknown_member_state_errors() {
1640 for bad in [
1641 "free",
1642 "SPAWNING",
1643 "Free-State",
1644 "allocated_now",
1645 "ReplaceImmediate", // ReplacementPolicy-axis leak
1646 "Reset", // ReturnPolicy-axis leak
1647 "Attested", // ProcessPhase-axis leak
1648 ] {
1649 let err = MemberState::from_str(bad).unwrap_err();
1650 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1651 }
1652 }
1653
1654 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1655 /// documented per-variant lifecycle role. The pool reconciler's
1656 /// `pool_phase_from_members` supply calc collapses
1657 /// `count_state(Free) + count_state(Spawning)` into one
1658 /// `counts_toward_supply` filter; this table pins the per-variant
1659 /// projection that consumer depends on.
1660 #[test]
1661 fn member_state_predicate_truth_tables() {
1662 assert!(!MemberState::Spawning.is_failed());
1663 assert!(MemberState::Spawning.counts_toward_supply());
1664
1665 assert!(!MemberState::Free.is_failed());
1666 assert!(MemberState::Free.counts_toward_supply());
1667
1668 assert!(!MemberState::Allocated.is_failed());
1669 assert!(!MemberState::Allocated.counts_toward_supply());
1670
1671 assert!(!MemberState::Returning.is_failed());
1672 assert!(!MemberState::Returning.counts_toward_supply());
1673
1674 assert!(MemberState::Failed.is_failed());
1675 assert!(!MemberState::Failed.counts_toward_supply());
1676 }
1677
1678 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1679 /// `is_failed` and `counts_toward_supply` simultaneously — a
1680 /// failed member can never be counted as available capacity. A
1681 /// future variant that returned true from both would FAIL here,
1682 /// forcing the author to either drop it from supply, or extend
1683 /// the consumer's bucketing in
1684 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1685 /// deliberately rather than silently inflating the pool's supply
1686 /// count with failed slots.
1687 #[test]
1688 fn member_state_failed_implies_no_supply() {
1689 for state in MemberState::ALL {
1690 assert!(
1691 !(state.is_failed() && state.counts_toward_supply()),
1692 "{state:?} returns true from both is_failed and counts_toward_supply — \
1693 a failed member can never be counted as available pool capacity",
1694 );
1695 }
1696 }
1697
1698 /// COVERAGE CONTRACT: every variant lands somewhere — either
1699 /// in supply, or as a failed slot, or as an in-use bucket
1700 /// (`Allocated | Returning`). A future variant that returns
1701 /// `false` from `counts_toward_supply` AND `false` from
1702 /// `is_failed` is fine *iff* it represents an in-use slot; this
1703 /// test pins the existing variants in their declared buckets so
1704 /// the consumer-side dispatch in
1705 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1706 /// stays grounded.
1707 #[test]
1708 fn member_state_buckets_cover_every_variant() {
1709 let mut supply = 0u32;
1710 let mut failed = 0u32;
1711 let mut in_use = 0u32;
1712 for state in MemberState::ALL {
1713 match (state.is_failed(), state.counts_toward_supply()) {
1714 (true, false) => failed += 1,
1715 (false, true) => supply += 1,
1716 (false, false) => in_use += 1,
1717 (true, true) => panic!("disjointness already pins this empty for {state:?}"),
1718 }
1719 }
1720 assert_eq!(supply, 2, "supply bucket: Free + Spawning");
1721 assert_eq!(failed, 1, "failed bucket: Failed");
1722 assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
1723 assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
1724 }
1725
1726 // ── closed-set algebra contracts for PoolPhase
1727 // (ALL × as_str × FromStr × predicate pair) ────────────────────
1728
1729 /// Structural well-formedness of [`PoolPhase`] as a
1730 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1731 /// symmetric to [`member_state_is_well_formed_closed_set`] above.
1732 #[test]
1733 fn pool_phase_is_well_formed_closed_set() {
1734 tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
1735 }
1736
1737 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1738 /// output verbatim for every variant. A future variant rename (or
1739 /// an `as_str` arm typo) lands here at one site, instead of
1740 /// drifting between the typed surface, the CRD enum, and the YAML
1741 /// wire format the pool reconciler stamps on `status.phase`.
1742 #[test]
1743 fn pool_phase_as_str_matches_serde() {
1744 crate::tagged_union::assert_label_matches_serde_serialization::<PoolPhase>();
1745 }
1746
1747 /// The Display impl IS `as_str` — pinning this lets future callers
1748 /// reach for either projection without drift. Any operator-facing
1749 /// "phase={phase}" diagnostic that composes through Display
1750 /// inherits the canonical wire-format string automatically.
1751 #[test]
1752 fn pool_phase_display_matches_as_str() {
1753 crate::tagged_union::assert_display_matches_label::<PoolPhase>();
1754 }
1755
1756 /// `FromStr` rejects strings that aren't in the canonical
1757 /// projection — lowercased / typo / cross-axis-leaked — and
1758 /// the error echoes the input verbatim so the operator-facing
1759 /// diagnostic carries the offending value, not a normalized form.
1760 /// The empty-input arm is pinned by
1761 /// [`pool_phase_is_well_formed_closed_set`] via the
1762 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1763 /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
1764 /// closed-set REJECTION contract that the trait can't see — those
1765 /// are valid sibling-axis labels but MUST reject here.
1766 #[test]
1767 fn unknown_pool_phase_errors() {
1768 for bad in [
1769 "steady",
1770 "SCALINGUP",
1771 "Scaling-Up",
1772 "scaling_down",
1773 "Free", // MemberState-axis leak
1774 "Replace", // ReturnPolicy-axis leak
1775 "Attested", // ProcessPhase-axis leak
1776 "HoldFailed", // ReplacementPolicy-axis leak
1777 ] {
1778 let err = PoolPhase::from_str(bad).unwrap_err();
1779 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1780 }
1781 }
1782
1783 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1784 /// documented per-variant lifecycle role. Pinning this table at
1785 /// one site means any future status-aggregator surface
1786 /// (`feira pool list --healthy`, the SSE filter, the desired-loop
1787 /// heartbeat short-circuit) reads the same projection that the
1788 /// reconciler writes.
1789 #[test]
1790 fn pool_phase_predicate_truth_tables() {
1791 assert!(!PoolPhase::Initializing.is_steady());
1792 assert!(!PoolPhase::Initializing.is_terminal());
1793
1794 assert!(PoolPhase::Steady.is_steady());
1795 assert!(!PoolPhase::Steady.is_terminal());
1796
1797 assert!(!PoolPhase::ScalingUp.is_steady());
1798 assert!(!PoolPhase::ScalingUp.is_terminal());
1799
1800 assert!(!PoolPhase::ScalingDown.is_steady());
1801 assert!(!PoolPhase::ScalingDown.is_terminal());
1802
1803 assert!(!PoolPhase::Degraded.is_steady());
1804 assert!(!PoolPhase::Degraded.is_terminal());
1805
1806 assert!(!PoolPhase::Draining.is_steady());
1807 assert!(PoolPhase::Draining.is_terminal());
1808 }
1809
1810 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1811 /// `is_steady` and `is_terminal` simultaneously — a draining pool
1812 /// is by definition transitioning OUT, not the goal converged
1813 /// state. A future variant that returned true from both would
1814 /// FAIL here, forcing the author to either pick one bucket or
1815 /// extend the consumer dispatch sites (status aggregators,
1816 /// heartbeat short-circuit) deliberately rather than silently
1817 /// double-firing both branches.
1818 #[test]
1819 fn pool_phase_steady_excludes_terminal() {
1820 for phase in PoolPhase::ALL {
1821 assert!(
1822 !(phase.is_steady() && phase.is_terminal()),
1823 "{phase:?} returns true from both is_steady and is_terminal — \
1824 a draining pool is by definition not the converged goal state",
1825 );
1826 }
1827 }
1828
1829 /// COVERAGE CONTRACT: every variant lands somewhere — either the
1830 /// converged goal (`Steady`), the absorbing exit (`Draining`),
1831 /// or the transient bucket (`Initializing | ScalingUp |
1832 /// ScalingDown | Degraded` — pool is in motion toward desired).
1833 /// A future variant that returns `false` from BOTH predicates is
1834 /// fine *iff* it represents an in-motion state; this test pins
1835 /// the existing variants in their declared buckets so the
1836 /// projection consumers stay grounded.
1837 #[test]
1838 fn pool_phase_buckets_cover_every_variant() {
1839 let mut converged = 0u32;
1840 let mut terminal = 0u32;
1841 let mut transient = 0u32;
1842 for phase in PoolPhase::ALL {
1843 match (phase.is_steady(), phase.is_terminal()) {
1844 (true, false) => converged += 1,
1845 (false, true) => terminal += 1,
1846 (false, false) => transient += 1,
1847 (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
1848 }
1849 }
1850 assert_eq!(converged, 1, "converged bucket: Steady");
1851 assert_eq!(terminal, 1, "terminal bucket: Draining");
1852 assert_eq!(
1853 transient, 4,
1854 "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
1855 );
1856 assert_eq!(
1857 converged + terminal + transient,
1858 PoolPhase::ALL.len() as u32
1859 );
1860 }
1861
1862 /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
1863 /// variant a freshly-admitted pool should land in — `Initializing`
1864 /// — AND that variant is neither steady (no members yet) nor
1865 /// terminal (not deletion-stamped). A future `Default` rename
1866 /// without flipping the predicates fails here.
1867 #[test]
1868 fn pool_phase_default_is_initializing_in_transient_bucket() {
1869 let d = PoolPhase::default();
1870 assert_eq!(d, PoolPhase::Initializing);
1871 assert!(!d.is_steady());
1872 assert!(!d.is_terminal());
1873 }
1874
1875 // ─────────────────────────────────────────────────────────────────
1876 // `EphemeralPool::name_or_empty` — borrow-form metadata-projection
1877 // primitive on the `metadata.name` axis. Pins the missing-slot
1878 // corner, the populated-slot corner, the pre-lift chain-shape
1879 // parity, and the pure-projection discipline that the two
1880 // `tatara-pool-reconciler` consumers routed onto the primitive
1881 // depend on. See the primitive's doc-comment for the full
1882 // migration rationale.
1883 // ─────────────────────────────────────────────────────────────────
1884
1885 fn empty_template() -> EphemeralSpec {
1886 EphemeralSpec {
1887 aplicacao: crate::intent::AplicacaoIntent {
1888 chart_ref: "oci://x".into(),
1889 version: "1".into(),
1890 profile: String::new(),
1891 values_overlay: serde_json::Value::Null,
1892 release_name: None,
1893 target_namespace: None,
1894 install_timeout: None,
1895 },
1896 ttl: "1h".into(),
1897 teardown: crate::lifetime::TeardownPolicy::Always,
1898 max_concurrent: 0,
1899 postconditions: vec![],
1900 preconditions: vec![],
1901 verify_timeout: None,
1902 classification: None,
1903 parent: None,
1904 exports: vec![],
1905 routing: None,
1906 }
1907 }
1908
1909 fn pool_spec() -> PoolSpec {
1910 PoolSpec {
1911 desired_size: 1,
1912 min_size: 0,
1913 max_size: 0,
1914 return_policy: ReturnPolicy::Replace,
1915 selector: PoolSelector::default(),
1916 template: empty_template(),
1917 free_ttl: "24h".into(),
1918 max_allocation_ttl: "4h".into(),
1919 desired: 0,
1920 replacement_policy: ReplacementPolicy::default(),
1921 stable_name_claim: false,
1922 }
1923 }
1924
1925 fn pool_named(name: &str) -> EphemeralPool {
1926 EphemeralPool::new(name, pool_spec())
1927 }
1928
1929 fn pool_unnamed() -> EphemeralPool {
1930 let mut p = EphemeralPool::new("scratch", pool_spec());
1931 p.metadata.name = None;
1932 p
1933 }
1934
1935 #[test]
1936 fn name_or_empty_returns_empty_string_when_metadata_name_is_none() {
1937 let p = pool_unnamed();
1938 assert!(p.metadata.name.is_none(), "fixture invariant");
1939 assert_eq!(p.name_or_empty(), "");
1940 }
1941
1942 #[test]
1943 fn name_or_empty_returns_populated_slot_verbatim() {
1944 let p = pool_named("attest-pool");
1945 assert_eq!(p.name_or_empty(), "attest-pool");
1946 }
1947
1948 #[test]
1949 fn name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
1950 // Corner between `None` (missing slot) and `Some(String::new())`
1951 // (populated slot containing the empty string): the primitive
1952 // MUST fold both to the same `""` byte-shape so a downstream
1953 // `HashMap<String,_>::get(name)` / `str::cmp` sees ONE
1954 // "unnamed pool" bucket regardless of which shape the K8s API
1955 // server materialized. This is byte-identical to what the
1956 // pre-lift `.as_deref().unwrap_or("")` chain produced.
1957 let mut p = pool_named("scratch");
1958 p.metadata.name = Some(String::new());
1959 assert_eq!(p.name_or_empty(), "");
1960 }
1961
1962 #[test]
1963 fn name_or_empty_is_a_pure_projection() {
1964 // Consecutive calls return byte-identical slices — no cached
1965 // state, no mutation on the `EphemeralPool` between calls.
1966 // Guards against a future refactor that plants a cache field
1967 // and drifts one caller from another silently.
1968 let p = pool_named("router-pool");
1969 assert_eq!(p.name_or_empty(), p.name_or_empty());
1970 assert_eq!(p.name_or_empty(), "router-pool");
1971 assert_eq!(p.name_or_empty(), "router-pool");
1972 }
1973
1974 #[test]
1975 fn name_or_empty_matches_pre_lift_chain_verbatim() {
1976 // Byte-identical parity with the two hand-authored
1977 // `.metadata.name.as_deref().unwrap_or("")` chains the
1978 // primitive replaces in `tatara-pool-reconciler::router` and
1979 // `tatara-pool-reconciler::controller_allocation`. Runs across
1980 // the FULL corner set of the metadata.name slot: absent,
1981 // present-with-value, present-with-empty-string.
1982 let cases: [(Option<String>, &str); 3] = [
1983 (None, ""),
1984 (Some("attest-pool".into()), "attest-pool"),
1985 (Some(String::new()), ""),
1986 ];
1987 for (slot, expected) in cases {
1988 let mut p = pool_named("scratch");
1989 p.metadata.name = slot.clone();
1990 let pre_lift = p.metadata.name.as_deref().unwrap_or("");
1991 assert_eq!(pre_lift, expected, "pre-lift chain sanity");
1992 assert_eq!(p.name_or_empty(), pre_lift);
1993 assert_eq!(p.name_or_empty(), expected);
1994 }
1995 }
1996
1997 #[test]
1998 fn name_or_empty_borrows_from_metadata_name_slot() {
1999 // The returned `&str` is tied to the `EphemeralPool`'s
2000 // lifetime — the caller can compare / hash / index without
2001 // allocating. This is the load-bearing property that lets
2002 // the `HashMap<String, _>::get(pool.name_or_empty())` closure
2003 // in `controller_allocation::reconcile_inner` skip cloning.
2004 let p = pool_named("attest-pool");
2005 let s: &str = p.name_or_empty();
2006 assert_eq!(s.as_ptr(), p.metadata.name.as_deref().unwrap().as_ptr());
2007 }
2008
2009 // ─── EphemeralPool::owned_name_or_empty substrate pins ────────────
2010 //
2011 // The owned-form peer of the borrow-form `name_or_empty` primitive
2012 // above. Sibling to the sister-CRD primitive
2013 // `crate::crd::Process::owned_name_or_empty` (owned + empty sentinel
2014 // on `Process::metadata.name`) — the four primitives now partition
2015 // the (borrow × owned) × (name × uid) corner of the metadata-slot
2016 // family on identical missing-slot semantics across BOTH tatara-
2017 // process CRDs (`Process::uid_or_empty` + `Process::owned_name_or_empty`
2018 // + `EphemeralPool::name_or_empty` + this method). Fail-before-pass-
2019 // after granularity: `owned_name_or_empty` did not exist on the pool
2020 // CRD pre-lift; the compiler cannot resolve the name until the impl
2021 // block above is in place, so a rollback of the primitive breaks
2022 // this whole module.
2023 #[test]
2024 fn owned_name_or_empty_returns_empty_string_when_metadata_name_is_none() {
2025 let p = pool_unnamed();
2026 assert!(p.metadata.name.is_none(), "fixture invariant");
2027 assert_eq!(p.owned_name_or_empty(), String::new());
2028 }
2029
2030 #[test]
2031 fn owned_name_or_empty_returns_owned_string_when_slot_is_populated() {
2032 let p = pool_named("attest-pool");
2033 assert_eq!(p.owned_name_or_empty(), "attest-pool");
2034 }
2035
2036 #[test]
2037 fn owned_name_or_empty_returns_empty_string_when_slot_is_explicitly_empty_string() {
2038 // Corner between `None` (missing slot) and `Some(String::new())`
2039 // (populated slot containing the empty string): the primitive
2040 // MUST fold both to the same `""` byte-shape so a downstream
2041 // `HashMap<String,_>::get(name)` sees ONE "unnamed pool" bucket
2042 // regardless of which shape the K8s API server materialized.
2043 // Byte-identical to what the pre-lift `.clone().unwrap_or_default()`
2044 // chain produced.
2045 let mut p = pool_named("scratch");
2046 p.metadata.name = Some(String::new());
2047 assert_eq!(p.owned_name_or_empty(), String::new());
2048 assert!(p.owned_name_or_empty().is_empty());
2049 }
2050
2051 #[test]
2052 fn owned_name_or_empty_is_a_pure_projection() {
2053 // Consecutive calls return byte-identical Strings — no cached
2054 // state, no mutation on the `EphemeralPool` between calls.
2055 // Guards against a future refactor that plants a cache field
2056 // and drifts one caller from another silently.
2057 let p = pool_named("router-pool");
2058 assert_eq!(p.owned_name_or_empty(), p.owned_name_or_empty());
2059 assert_eq!(p.owned_name_or_empty(), "router-pool");
2060 assert_eq!(p.owned_name_or_empty(), "router-pool");
2061 }
2062
2063 #[test]
2064 fn owned_name_or_empty_matches_pre_lift_chain_verbatim() {
2065 // Byte-identical parity with the two hand-authored
2066 // `.metadata.name.clone().unwrap_or_default()` chains the
2067 // primitive replaces in `tatara-pool-reconciler::
2068 // controller_allocation::reconcile_inner` (HashMap key seed)
2069 // and `tatara-pool-reconciler::allocation_decide::
2070 // AllocationConvergenceCtx::observe` (AllocationRef.name slot
2071 // seed). Runs across the FULL corner set of the metadata.name
2072 // slot: absent, present-with-value, present-with-empty-string.
2073 // A regression that inserted a normalization step at the
2074 // primitive the pre-lift chain does NOT apply — or vice versa —
2075 // surfaces here rather than as silent drift between the two
2076 // owned-form callsites and the ONE substrate owner they now
2077 // route through.
2078 let cases: [(Option<String>, &str); 3] = [
2079 (None, ""),
2080 (Some("attest-pool".into()), "attest-pool"),
2081 (Some(String::new()), ""),
2082 ];
2083 for (slot, expected) in cases {
2084 let mut p = pool_named("scratch");
2085 p.metadata.name = slot.clone();
2086 let pre_lift = p.metadata.name.clone().unwrap_or_default();
2087 assert_eq!(pre_lift.as_str(), expected, "pre-lift chain sanity");
2088 assert_eq!(p.owned_name_or_empty(), pre_lift);
2089 assert_eq!(p.owned_name_or_empty().as_str(), expected);
2090 }
2091 }
2092
2093 #[test]
2094 fn owned_name_or_empty_matches_borrow_form_peer_on_populated_slot() {
2095 // Cross-primitive coherence pin at the sibling corner: when the
2096 // slot is present, the borrow-form (`name_or_empty`) and owned-
2097 // form (`owned_name_or_empty`) primitives return the SAME byte
2098 // sequence and differ only in ownership. A regression that
2099 // skewed one form's fallback would surface here rather than as
2100 // silent drift between the router tie-break comparator and the
2101 // AllocationRef seed on the SAME pool.
2102 let p = pool_named("attest-pool");
2103 assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2104 }
2105
2106 #[test]
2107 fn owned_name_or_empty_matches_borrow_form_peer_on_missing_slot() {
2108 // Sibling corner of the coherence pin above: when the slot is
2109 // absent (or explicitly empty), BOTH primitives fold to the
2110 // same empty-string byte-shape. The load-bearing property is
2111 // that a caller who switches between the two return-forms
2112 // based on downstream ownership requirements never sees a
2113 // different missing-slot spelling as a side effect.
2114 let p = pool_unnamed();
2115 assert_eq!(p.name_or_empty(), p.owned_name_or_empty().as_str());
2116 assert_eq!(p.name_or_empty(), "");
2117 assert_eq!(p.owned_name_or_empty(), String::new());
2118 }
2119
2120 // ─── EphemeralPool::is_being_deleted substrate pins ───────────────
2121 //
2122 // Pins the copy-form metadata-projection primitive on the deletion-
2123 // tombstone axis of the pool CRD. Peer to the borrow-form + owned-
2124 // form metadata-fallback family (`name_or_empty`,
2125 // `owned_name_or_empty`); this one opens the presence-probe corner
2126 // for the tombstone slot. Sibling to the sister-CRD primitive
2127 // `crate::crd::Process::is_being_deleted` — the two primitives
2128 // now partition the tombstone-presence probe across BOTH tatara-
2129 // process CRDs on identical missing-slot semantics. Fail-before-
2130 // pass-after granularity: `is_being_deleted` did not exist on the
2131 // pool CRD pre-lift; the compiler cannot resolve the name until
2132 // the impl block above is in place, so a rollback of the primitive
2133 // breaks this whole module.
2134
2135 fn tombstoned_pool() -> EphemeralPool {
2136 let mut p = pool_named("attest-pool");
2137 p.metadata.namespace = Some("ephemeral-pools".into());
2138 p.metadata.deletion_timestamp = Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2139 Utc::now(),
2140 ));
2141 p
2142 }
2143
2144 #[test]
2145 fn is_being_deleted_returns_false_when_deletion_timestamp_is_absent() {
2146 // Missing-tombstone corner pin: the primitive collapses the
2147 // no-tombstone case to `false` so the `→ Drain` short-circuit
2148 // at `decide_pool_reconcile` is NOT taken and the observed-
2149 // phase composer at `pool_phase_from_members` proceeds to its
2150 // normal (free / spawning / allocated) arithmetic branches
2151 // instead of short-circuiting to `PoolPhase::Draining`.
2152 // Matches the pre-lift `.is_some()` chain's `false` byte-
2153 // identically at every consumer's downstream gate.
2154 let mut p = pool_named("attest-pool");
2155 p.metadata.deletion_timestamp = None;
2156 assert!(!p.is_being_deleted());
2157 }
2158
2159 #[test]
2160 fn is_being_deleted_returns_true_when_deletion_timestamp_is_present() {
2161 // Present-tombstone corner pin: the primitive returns `true`
2162 // on any populated `metadata.deletionTimestamp` slot regardless
2163 // of the timestamp payload — the two consumers only read the
2164 // tombstone's PRESENCE, never its RFC-3339 timestamp value.
2165 // A regression that gated the `true` return on the timestamp
2166 // being non-epoch, or parsed the timestamp before returning,
2167 // would surface here rather than as silent skew at the
2168 // `→ Drain` decision or the `→ Draining` phase report on the
2169 // SAME `EphemeralPool`.
2170 let p = tombstoned_pool();
2171 assert!(p.is_being_deleted());
2172 }
2173
2174 #[test]
2175 fn is_being_deleted_is_a_pure_projection() {
2176 // Purity pin: two consecutive calls return byte-identical
2177 // `bool` values (no lazy materialization, no interior
2178 // mutation of `self`). Peer to the sibling
2179 // `name_or_empty_is_a_pure_projection` +
2180 // `owned_name_or_empty_is_a_pure_projection` pins in this
2181 // module and to `is_being_deleted_is_a_pure_projection` on
2182 // the sister-CRD `Process`; all four bind the pure-projection
2183 // discipline on the ONE substrate accessor per metadata slot.
2184 let p = tombstoned_pool();
2185 let a = p.is_being_deleted();
2186 let b = p.is_being_deleted();
2187 assert_eq!(a, b);
2188 assert!(a);
2189 }
2190
2191 #[test]
2192 fn is_being_deleted_matches_pre_lift_pool_reconciler_chain_shape() {
2193 // Parity pin: sweeps the two corners every pre-lift consumer
2194 // plausibly encountered (missing tombstone, present tombstone)
2195 // and compares the substrate call against a hand-authored pre-
2196 // lift chain byte-identically. A regression that reshaped
2197 // either corner would surface here rather than as silent
2198 // operator-facing skew between the pool-reconciler's `→ Drain`
2199 // decision and the observed-phase composer's `→ Draining`
2200 // report on the SAME `EphemeralPool` within one reconcile
2201 // pass.
2202 fn pre_lift(p: &EphemeralPool) -> bool {
2203 p.metadata.deletion_timestamp.is_some()
2204 }
2205 // Missing slot.
2206 let mut p = pool_named("attest-pool");
2207 p.metadata.deletion_timestamp = None;
2208 assert_eq!(p.is_being_deleted(), pre_lift(&p));
2209 // Populated slot.
2210 let p = tombstoned_pool();
2211 assert_eq!(p.is_being_deleted(), pre_lift(&p));
2212 }
2213
2214 #[test]
2215 fn is_being_deleted_composes_with_pool_phase_draining_at_reconcile_preempt() {
2216 // Call-site-shape pin: the `pool_phase_from_members`
2217 // deletion-preempt returns `PoolPhase::Draining` as soon as
2218 // `pool.is_being_deleted()` holds, regardless of the (free +
2219 // spawning) supply arithmetic that would otherwise pick
2220 // `Ready` / `Scaling` / `Degraded`. The `→ Drain` decision at
2221 // `decide_pool_reconcile` composes with the same probe on the
2222 // same tombstone-presence slot. A regression that broadened
2223 // the tombstone probe implicitly (returning `false` on a
2224 // present but zero-timestamp) or narrowed it (requiring an
2225 // additional `.finalizers.is_empty()` conjunct that the two
2226 // consumers never spelled) would surface here rather than as
2227 // silent operator-facing skew between the pool reconciler's
2228 // decision and the observed-phase composer on the SAME
2229 // `EphemeralPool` within one reconcile pass.
2230 let alive = pool_named("attest-pool");
2231 assert!(!alive.is_being_deleted());
2232 let dying = tombstoned_pool();
2233 assert!(dying.is_being_deleted());
2234 }
2235
2236 // ─── AllocationRef::new substrate pins ────────────────────────────
2237 //
2238 // Pins the substrate constructor for [`AllocationRef`] — the
2239 // ONE-liner composer that lifts the paired
2240 // `AllocationRef { name, namespace }` struct-literal every
2241 // downstream consumer restated by hand pre-lift at FOUR production
2242 // sites (2 × controller_allocation.rs assignedProcess seeds, 1 ×
2243 // allocation_decide.rs pool_ref seed, 1 × allocation_factory.rs
2244 // pool_ref seed) onto ONE substrate owner on `AllocationRef`.
2245 // Fail-before-pass-after granularity: `AllocationRef::new` did not
2246 // exist pre-lift; the compiler cannot resolve the name until the
2247 // impl block above is in place, so a rollback of the primitive
2248 // breaks this whole module.
2249
2250 #[test]
2251 fn allocation_ref_new_composes_owned_string_pair_verbatim() {
2252 // Happy-path pin: the constructor materializes an
2253 // `AllocationRef { name: <name>, namespace: <namespace> }`
2254 // byte-identical to the pre-lift struct literal every consumer
2255 // spelled. A regression that dropped either slot (e.g. an
2256 // erroneous `..Default::default()` on a shape that never had
2257 // a Default derive) surfaces here rather than as silent slot
2258 // loss downstream at the assignedProcess / bound_pool /
2259 // matched_pool / spec.pool_ref sinks.
2260 let r = AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
2261 assert_eq!(r.name, "pr-42-demo");
2262 assert_eq!(r.namespace, "ephemeral-pools");
2263 }
2264
2265 #[test]
2266 fn allocation_ref_new_matches_pre_lift_struct_literal_verbatim() {
2267 // Byte-identical parity pin: the substrate constructor and the
2268 // hand-authored struct literal produce equal `AllocationRef`
2269 // values on every provenance the FOUR pre-lift sites carried
2270 // (owned `String` from an owned-form projection; `&str`
2271 // promoted through `.to_string()`). A regression that inserted
2272 // a normalization step at the primitive the pre-lift literal
2273 // does NOT apply — or vice versa — surfaces here rather than
2274 // as silent drift between the four consumers and the ONE
2275 // substrate owner they now route through.
2276 let owned_name = String::from("pr-42-demo");
2277 let owned_ns = String::from("ephemeral-pools");
2278 let lifted = AllocationRef::new(owned_name.clone(), owned_ns.clone());
2279 let pre_lift = AllocationRef {
2280 name: owned_name,
2281 namespace: owned_ns,
2282 };
2283 assert_eq!(lifted, pre_lift);
2284 }
2285
2286 #[test]
2287 fn allocation_ref_new_accepts_str_provenance_via_into_string() {
2288 // `Into<String>` provenance-closure pin: the primitive accepts
2289 // every provenance the pre-lift sites carried. The
2290 // controller_allocation.rs assignedProcess seeds passed owned
2291 // `String` values (a moved `member_process_name` +
2292 // `ns.clone()`); the allocation_factory.rs pool_ref seed
2293 // passed `&str` (`n.to_string()` / `namespace.to_string()`).
2294 // Both provenances produce byte-identical output. A future
2295 // refactor of the constructor signature that demanded owned
2296 // `String` at author sites (dropping `impl Into<String>`)
2297 // would force `.to_string()` back at the FOUR call sites — the
2298 // pin fences that regression at ONE place.
2299 let from_str = AllocationRef::new("pr-42-demo", "ephemeral-pools");
2300 let from_string =
2301 AllocationRef::new(String::from("pr-42-demo"), String::from("ephemeral-pools"));
2302 assert_eq!(from_str, from_string);
2303 // Mixed provenance is also load-bearing: the allocation_decide.rs
2304 // matched_pool seed pairs an owned `String` (from
2305 // `EphemeralPool::owned_name_or_empty()`) with a hand-authored
2306 // `.clone().unwrap_or_default()` — also `String`. The
2307 // controller_allocation.rs paths pair a moved `String` name
2308 // with a `.clone()`-ed `ns: String`. Verify (owned, borrow)
2309 // and (borrow, owned) both compose to the same shape as
2310 // (owned, owned) / (borrow, borrow).
2311 let mixed_a = AllocationRef::new(String::from("pr-42-demo"), "ephemeral-pools");
2312 let mixed_b = AllocationRef::new("pr-42-demo", String::from("ephemeral-pools"));
2313 assert_eq!(from_str, mixed_a);
2314 assert_eq!(from_str, mixed_b);
2315 }
2316
2317 #[test]
2318 fn allocation_ref_new_positional_axis_order_pinned_name_first_namespace_second() {
2319 // Axis-order pin: name is the FIRST positional argument;
2320 // namespace is the SECOND. Reversing the pair at the
2321 // constructor is the exact regression this pin fences — the
2322 // FOUR pre-lift sites all spelled `name` before `namespace`
2323 // (matching the struct definition's field order in
2324 // `pub struct AllocationRef { pub name, pub namespace }`)
2325 // and the wire-format serde output `{ "name": "...",
2326 // "namespace": "..." }` reflects that order. A slot swap at
2327 // the primitive would surface here rather than as silent
2328 // `<namespace>/<name>` inversion at every downstream
2329 // qualified-ref composer that reads `{ref.name}/{ref.namespace}`
2330 // as an audit-log key.
2331 let r = AllocationRef::new("alpha-name", "beta-namespace");
2332 assert_eq!(r.name, "alpha-name");
2333 assert_eq!(r.namespace, "beta-namespace");
2334 assert_ne!(r.name, "beta-namespace");
2335 assert_ne!(r.namespace, "alpha-name");
2336 }
2337
2338 #[test]
2339 fn allocation_ref_new_preserves_empty_string_verbatim() {
2340 // Empty-string sentinel pin: the constructor is pure — it does
2341 // NOT canonicalize empty inputs (does NOT default an empty
2342 // namespace to `"default"`; does NOT reject an empty name).
2343 // Preserves the pre-lift shape the allocation_decide.rs
2344 // matched_pool seed relied on: when the pool's metadata.namespace
2345 // is absent, `.clone().unwrap_or_default()` yields the empty
2346 // string, and the AllocationRef's namespace slot carries that
2347 // empty string verbatim to the downstream `bound_pool` sink.
2348 // A future canonicalization pass (e.g. defaulting to
2349 // `Process::DEFAULT_NAMESPACE`) MUST land here, not at the
2350 // primitive body silently, so the pre-lift consumers' empty-
2351 // sentinel semantics are the visible contract of the new
2352 // constructor.
2353 let r = AllocationRef::new("", "");
2354 assert_eq!(r.name, "");
2355 assert_eq!(r.namespace, "");
2356 let mixed = AllocationRef::new("pr-42-demo", "");
2357 assert_eq!(mixed.name, "pr-42-demo");
2358 assert_eq!(mixed.namespace, "");
2359 }
2360
2361 #[test]
2362 fn allocation_ref_new_composes_with_owned_name_or_empty_pool_projection() {
2363 // Composition pin: the constructor composes with the
2364 // sibling substrate primitive [`EphemeralPool::owned_name_or_empty`]
2365 // at the allocation_decide.rs pool_ref seed — the same
2366 // primitive family the pool CRD already opened. The composed
2367 // pair carries an owned `String` name half (from
2368 // `pool.owned_name_or_empty()`) and an owned `String`
2369 // namespace half (pre-lift chain, to be lifted as
2370 // `owned_namespace_or_empty` in a peer run). A regression
2371 // that broke the primitive's `impl Into<String>` acceptance
2372 // of an owned `String` return type would surface here rather
2373 // than as silent build failure at the pool-reconciler
2374 // matched_pool seed.
2375 let pool = pool_named("attest-pool");
2376 let ns = pool.metadata.namespace.clone().unwrap_or_default();
2377 let r = AllocationRef::new(pool.owned_name_or_empty(), ns.clone());
2378 assert_eq!(r.name, "attest-pool");
2379 assert_eq!(r.namespace, ns);
2380 }
2381
2382 #[test]
2383 fn allocation_ref_new_returns_wire_format_serialization_verbatim() {
2384 // Wire-format pin: the constructor produces an
2385 // [`AllocationRef`] whose serde `rename_all = "camelCase"`
2386 // serialization is byte-identical to the pre-lift struct
2387 // literal's serialization. The `bound_pool` and
2388 // `assignedProcess` slots on `AllocationStatus` (and the
2389 // `poolRef` slot on `AllocationSpec`) all round-trip through
2390 // this shape — the pin fences a regression that added a
2391 // private field or a `#[serde(skip)]` accidentally.
2392 let r = AllocationRef::new("pr-42-demo", "ephemeral-pools");
2393 let yaml = serde_yaml::to_string(&r).expect("AllocationRef serializes to yaml");
2394 assert!(yaml.contains("name: pr-42-demo"), "{yaml}");
2395 assert!(yaml.contains("namespace: ephemeral-pools"), "{yaml}");
2396 let back: AllocationRef =
2397 serde_yaml::from_str(&yaml).expect("AllocationRef round-trips through yaml");
2398 assert_eq!(back, r);
2399 }
2400}