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: akeyless-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/akeyless-*"]
48/// branches: ["main", "release-*"]
49/// prLabels: ["needs-akeyless"]
50/// template:
51/// aplicacao:
52/// chartRef: "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment"
53/// version: "0.5.5"
54/// profile: "gateway-with-internal-saas"
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
147/// What the pool reconciler does when a member reaches `Failed`.
148///
149/// Sibling closed-set lifts on the same `tatara-process` axis:
150/// [`crate::compliance::VerificationPhase::ALL`],
151/// [`crate::signal::SighupStrategy::ALL`],
152/// [`crate::spec::MustReachPhase::ALL`],
153/// [`crate::intent::WorkloadKind::ALL`],
154/// [`crate::export::ReportFormat::ALL`],
155/// [`crate::encapsulates::EncapsulationMode::ALL`],
156/// [`crate::export::ExportTrigger::ALL`],
157/// [`crate::lifetime::TeardownPolicy::ALL`],
158/// [`crate::boundary::ConditionKind::ALL`],
159/// [`crate::lifetime::LifetimeKind::ALL`],
160/// [`crate::intent::IntentKind::ALL`],
161/// [`crate::phase::ProcessPhase::ALL`],
162/// [`crate::signal::ProcessSignal::ALL`].
163#[derive(
164 Clone,
165 Copy,
166 Debug,
167 Default,
168 Serialize,
169 Deserialize,
170 JsonSchema,
171 PartialEq,
172 Eq,
173 Hash,
174 tatara_closed_set::DeriveClosedSet,
175)]
176#[serde(rename_all = "PascalCase")]
177#[closed_set(via = "as_str", generate_unknown, display)]
178pub enum ReplacementPolicy {
179 /// **Default** — Failed member is reaped + replaced immediately
180 /// (pool stays at `desired` count). Most production-like.
181 #[default]
182 ReplaceImmediate,
183 /// Failed member stays for inspection; pool runs short until the
184 /// operator manually reaps it. Useful for debugging.
185 HoldFailed,
186 /// Failed member triggers pool-wide pause: `desired` is
187 /// effectively 0 until the operator manually resumes via a
188 /// pool-status patch. Used for "halt on any failure" workflows.
189 PausePool,
190}
191
192impl ReplacementPolicy {
193 /// The closed set of replacement policies — single source of truth
194 /// that drives the `as_str` / Display / `FromStr` triad and the
195 /// `replaces_failed` / `pauses_on_failure` predicate pair. Adding a
196 /// fourth variant lands at one `ALL` entry + one `as_str` arm + one
197 /// predicate arm per projection — exhaustively checked by the
198 /// compiler (the `[Self; 3]` array literal forces the arity) and by
199 /// the predicate-pair injectivity test below (a new variant must
200 /// land in its own (replaces_failed, pauses_on_failure) bucket or
201 /// the author has to extend the consumer dispatch in
202 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`).
203 pub const ALL: [Self; 3] = [Self::ReplaceImmediate, Self::HoldFailed, Self::PausePool];
204
205 /// Canonical PascalCase wire-format projection — matches the serde
206 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
207 /// enumeration the pool reconciler stamps on the
208 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
209 /// `replacement_policy_as_str_matches_serde` so a variant rename
210 /// can't drift between the typed surface, the CRD enum, the YAML
211 /// wire format AND the operator-facing diagnostic (the
212 /// `desired.rs` Pause reason composes `policy={policy}` via
213 /// Display, not a hard-coded `"PausePool"` literal that would
214 /// silently rot).
215 pub const fn as_str(self) -> &'static str {
216 match self {
217 Self::ReplaceImmediate => "ReplaceImmediate",
218 Self::HoldFailed => "HoldFailed",
219 Self::PausePool => "PausePool",
220 }
221 }
222
223 /// Should the pool auto-spawn a replacement for a Failed member?
224 /// Closed-set match (not `matches!`) so a future variant triggers
225 /// the compiler's exhaustiveness check at this site rather than
226 /// silently defaulting to `false`. Paired with
227 /// `pauses_on_failure` they form the two-axis projection
228 /// consumers in `tatara-pool-reconciler::desired::PoolConvergence`
229 /// pattern-match against — `replaces_failed` true ⇒ emit
230 /// `ReapFailed` per failure; `pauses_on_failure` true with any
231 /// failure ⇒ emit `Pause` and short-circuit. The pair is
232 /// `(true, false) | (false, false) | (false, true)` — pinned
233 /// injective by `replacement_policy_predicate_pair_is_injective`.
234 pub const fn replaces_failed(self) -> bool {
235 match self {
236 Self::ReplaceImmediate => true,
237 Self::HoldFailed | Self::PausePool => false,
238 }
239 }
240
241 /// Should reaching Failed on any member pause the whole pool?
242 /// See `replaces_failed` for the closed-match rationale + the
243 /// predicate-pair contract.
244 pub const fn pauses_on_failure(self) -> bool {
245 match self {
246 Self::PausePool => true,
247 Self::ReplaceImmediate | Self::HoldFailed => false,
248 }
249 }
250}
251
252// `impl FromStr for ReplacementPolicy` + `impl tatara_lisp::ClosedSet for
253// ReplacementPolicy` + `impl fmt::Display for ReplacementPolicy` are
254// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
255// declaration above. `label` delegates to the inherent
256// `ReplacementPolicy::as_str` via `#[closed_set(via = "as_str")]` so the
257// PascalCase wire-format projection stays load-bearing (matches the
258// serde `rename_all = "PascalCase"` output AND the
259// `tatara-pool-reconciler::desired::PoolConvergence` Pause reason
260// emission verbatim) while generic `T: ClosedSet` consumers reach the
261// STABLE workspace-wide name (`label`); Display delegates to the same
262// inherent projection via `#[closed_set(display)]` so the
263// `Pause` reason emitter's `policy={policy}` composition stays
264// pinned on the closed-set algebra rather than on a hand-rolled
265// `fmt::Display` block per implementor.
266
267// `pub struct UnknownReplacementPolicy(pub String)` is generated by
268// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
269// on the enum declaration above. The auto-derived label
270// `"replacement policy"` matches the prior hand-rolled
271// `#[error("unknown replacement policy: {0}")]` verbatim. Symmetric to
272// [`UnknownMemberState`], [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
273// [`crate::export::UnknownReportFormat`],
274// [`crate::export::UnknownChannelKind`],
275// [`crate::export::UnknownExportTrigger`],
276// [`crate::lifetime::UnknownTeardownPolicy`],
277// [`crate::boundary::UnknownConditionKind`], and
278// [`crate::phase::UnknownPhase`].
279
280fn default_free_ttl() -> String {
281 "24h".to_string()
282}
283fn default_max_allocation_ttl() -> String {
284 "4h".to_string()
285}
286
287/// `EphemeralPool.status` — observed pool population state.
288#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
289#[serde(rename_all = "camelCase")]
290pub struct PoolStatus {
291 /// Pool lifecycle phase.
292 #[serde(default)]
293 pub phase: PoolPhase,
294
295 /// When the pool entered the current phase.
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub phase_since: Option<DateTime<Utc>>,
298
299 /// Number of members currently in `Free` state (ready for allocation).
300 #[serde(default)]
301 pub ready_count: u32,
302
303 /// Number of members currently `Allocated`.
304 #[serde(default)]
305 pub allocated_count: u32,
306
307 /// Number of members currently `Spawning` (not yet Attested).
308 #[serde(default)]
309 pub spawning_count: u32,
310
311 /// Number of members currently `Returning` (reset or replace
312 /// in progress).
313 #[serde(default)]
314 pub returning_count: u32,
315
316 /// Member ledger — one entry per pool slot.
317 #[serde(default)]
318 pub members: Vec<PoolMember>,
319
320 /// Operator-visible message (e.g., "scaled down to floor").
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub message: Option<String>,
323
324 /// Standard Kubernetes Conditions.
325 #[serde(default)]
326 pub conditions: Vec<PoolCondition>,
327}
328
329/// One pool slot's state.
330#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
331#[serde(rename_all = "camelCase")]
332pub struct PoolMember {
333 /// `metadata.name` of the backing Process.
334 pub process_name: String,
335 /// Pool member's current slot state.
336 pub state: MemberState,
337 /// When the member entered the current state.
338 pub entered_state_at: DateTime<Utc>,
339 /// If allocated: the AllocationRef holding this slot.
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub allocation_ref: Option<AllocationRef>,
342}
343
344/// Light reference to an `EphemeralAllocation`.
345#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
346#[serde(rename_all = "camelCase")]
347pub struct AllocationRef {
348 pub name: String,
349 pub namespace: String,
350}
351
352/// Per-slot state in the pool's free list.
353///
354/// Sibling closed-sets on the `EphemeralPool` axis: [`ReplacementPolicy::ALL`]
355/// (the on-failure policy that the pool reconciler dispatches against
356/// the [`Self::is_failed`] projection), [`ReturnPolicy::ALL`] (the
357/// release-time disposition that transitions an [`Self::Allocated`]
358/// member into [`Self::Returning`] before it either re-enters
359/// [`Self::Free`] or gets [`Self::Spawning`]'d as a fresh slot).
360#[derive(
361 Clone,
362 Copy,
363 Debug,
364 PartialEq,
365 Eq,
366 Hash,
367 Serialize,
368 Deserialize,
369 JsonSchema,
370 tatara_closed_set::DeriveClosedSet,
371)]
372#[serde(rename_all = "PascalCase")]
373#[closed_set(via = "as_str", generate_unknown, display)]
374pub enum MemberState {
375 /// Pool reconciler is creating/converging the backing Process.
376 Spawning,
377 /// Process is `Attested`; ready for allocation.
378 Free,
379 /// Held by an `EphemeralAllocation`.
380 Allocated,
381 /// Return policy is being applied (Reset → reset Job; Replace →
382 /// Process is being torn down and recreated).
383 Returning,
384 /// Permanent failure — the member needs operator attention.
385 Failed,
386}
387
388impl MemberState {
389 /// The closed set of member states — single source of truth that
390 /// drives the `as_str` / Display / `FromStr` triad AND the
391 /// `is_failed` / `counts_toward_supply` predicate pair. Adding a
392 /// sixth variant lands at one `ALL` entry + one `as_str` arm + one
393 /// arm per predicate — exhaustively checked by the compiler (the
394 /// `[Self; 5]` array literal forces the arity) and by the
395 /// per-variant truth-table contract test (a new variant must
396 /// declare its own `(is_failed, counts_toward_supply)` projection
397 /// or the consumer dispatch in
398 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
399 /// and `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
400 /// will silently bucket it into the wrong lifecycle column).
401 pub const ALL: [Self; 5] = [
402 Self::Spawning,
403 Self::Free,
404 Self::Allocated,
405 Self::Returning,
406 Self::Failed,
407 ];
408
409 /// Canonical PascalCase wire-format projection — matches the serde
410 /// `rename_all = "PascalCase"` output verbatim AND the CRD `enum:`
411 /// enumeration that `ephemeralpools.tatara.pleme.io` stamps on
412 /// `status.members[].state`. Pinned by
413 /// `member_state_as_str_matches_serde` so a variant rename can't
414 /// drift between the typed surface, the CRD enum, the YAML wire
415 /// format AND any future operator-facing diagnostic that composes
416 /// `state={state}` via Display rather than a hard-coded literal
417 /// that would silently rot.
418 pub const fn as_str(self) -> &'static str {
419 match self {
420 Self::Spawning => "Spawning",
421 Self::Free => "Free",
422 Self::Allocated => "Allocated",
423 Self::Returning => "Returning",
424 Self::Failed => "Failed",
425 }
426 }
427
428 /// Is this member in a permanent-failure state — needs operator
429 /// attention? Closed-set match (not `matches!`) so a future variant
430 /// triggers the compiler's exhaustiveness check at this site rather
431 /// than silently defaulting to `false`. Consumed by
432 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile` to
433 /// gate the highest-priority `ReplaceMembers` decision branch — a
434 /// future variant that should also trigger replacement (e.g.
435 /// `MemberState::Quarantined`) flips this predicate at one site
436 /// and inherits the priority-1 dispatch without touching the
437 /// consumer match arm.
438 pub const fn is_failed(self) -> bool {
439 match self {
440 Self::Failed => true,
441 Self::Spawning | Self::Free | Self::Allocated | Self::Returning => false,
442 }
443 }
444
445 /// Does this member contribute to the pool's *available supply*
446 /// (current ready slots + slots coming online)? Closed-set match so
447 /// a future variant triggers the compiler's exhaustiveness check.
448 /// Consumed by
449 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
450 /// — the `(free + spawning)` supply calc collapses into one
451 /// predicate-driven filter, so a future "warming-up" state
452 /// (`MemberState::Warming` between Spawning and Free) plugs into
453 /// the supply count at one site rather than three. Disjoint with
454 /// `is_failed` — pinned by `member_state_failed_implies_no_supply`
455 /// (a Failed member can never count toward supply; the pool
456 /// reconciler would otherwise double-count failures as available
457 /// capacity).
458 pub const fn counts_toward_supply(self) -> bool {
459 match self {
460 Self::Free | Self::Spawning => true,
461 Self::Allocated | Self::Returning | Self::Failed => false,
462 }
463 }
464}
465
466// `impl FromStr for MemberState` + `impl tatara_lisp::ClosedSet for
467// MemberState` + `impl fmt::Display for MemberState` are generated by
468// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
469// above. `label` delegates to the inherent `MemberState::as_str` via
470// `#[closed_set(via = "as_str")]` so the
471// `pool_phase_from_members` supply calc can keep keying on
472// `counts_toward_supply` against the typed variant while a generic
473// `T: ClosedSet` consumer reaches the STABLE workspace-wide name
474// (`label`) without knowing this enum lives in `tatara-process::pool`;
475// Display delegates to the same inherent projection via
476// `#[closed_set(display)]` so the diagnostic emitter's
477// `state={state}` composition stays pinned on the closed-set algebra.
478
479// `pub struct UnknownMemberState(pub String)` is generated by
480// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
481// on the enum declaration above. The auto-derived label `"member state"`
482// matches the prior hand-rolled `#[error("unknown member state: {0}")]`
483// verbatim. Symmetric to [`UnknownReplacementPolicy`],
484// [`UnknownPoolPhase`], [`UnknownReturnPolicy`],
485// [`crate::lifetime::UnknownTeardownPolicy`],
486// [`crate::boundary::UnknownConditionKind`], and
487// [`crate::phase::UnknownPhase`].
488
489/// Pool lifecycle phase (observed across the whole pool population).
490///
491/// Sibling closed-set on the same `EphemeralPool` axis as
492/// [`MemberState::ALL`] (the per-slot lifecycle this phase aggregates
493/// over via [`MemberState::counts_toward_supply`]),
494/// [`ReplacementPolicy::ALL`] (on-failure policy) and
495/// [`ReturnPolicy::ALL`] (release-time disposition). Together with
496/// `MemberState`, this closes the pool reconciler's
497/// `(slot-state, pool-phase)` two-tier observation algebra on the
498/// same closed-set discipline as the rest of `tatara-process`.
499#[derive(
500 Clone,
501 Copy,
502 Debug,
503 PartialEq,
504 Eq,
505 Hash,
506 Serialize,
507 Deserialize,
508 JsonSchema,
509 tatara_closed_set::DeriveClosedSet,
510)]
511#[serde(rename_all = "PascalCase")]
512#[closed_set(via = "as_str", generate_unknown, display)]
513pub enum PoolPhase {
514 /// Just admitted; no members yet.
515 Initializing,
516 /// `ready_count == desired_size`.
517 Steady,
518 /// `ready_count + spawning_count < desired_size` and reconciler
519 /// is creating new members.
520 ScalingUp,
521 /// `ready_count > desired_size` and reconciler is reaping excess.
522 ScalingDown,
523 /// `min_size` constraint violated.
524 Degraded,
525 /// Pool is being deleted; reconciler is reaping all members.
526 Draining,
527}
528
529impl Default for PoolPhase {
530 fn default() -> Self {
531 Self::Initializing
532 }
533}
534
535impl PoolPhase {
536 /// The closed set of pool phases — single source of truth that
537 /// drives the `as_str` / Display / `FromStr` triad AND the
538 /// `is_steady` / `is_terminal` predicate pair. Adding a seventh
539 /// variant lands at one `ALL` entry + one `as_str` arm + one arm
540 /// per predicate — exhaustively checked by the compiler (the
541 /// `[Self; 6]` array literal forces the arity) AND by the
542 /// per-variant truth-table contract test (a new variant must
543 /// declare its own `(is_steady, is_terminal)` projection or any
544 /// future status-aggregator surface — `feira pool list
545 /// --healthy`, the operator-facing condition aggregator, the
546 /// desired-loop heartbeat short-circuit — will silently bucket
547 /// it into the wrong lifecycle column).
548 pub const ALL: [Self; 6] = [
549 Self::Initializing,
550 Self::Steady,
551 Self::ScalingUp,
552 Self::ScalingDown,
553 Self::Degraded,
554 Self::Draining,
555 ];
556
557 /// Canonical PascalCase wire-format projection — matches the
558 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
559 /// `enum:` enumeration that `ephemeralpools.tatara.pleme.io`
560 /// stamps on `status.phase`. Pinned by
561 /// `pool_phase_as_str_matches_serde` so a variant rename can't
562 /// drift between the typed surface, the CRD enum, the YAML wire
563 /// format AND any future operator-facing diagnostic that
564 /// composes `phase={phase}` via Display rather than a hard-coded
565 /// literal that would silently rot. Display + FromStr triad
566 /// over `ALL` mirrors `MemberState` / `ReplacementPolicy` /
567 /// `ReturnPolicy` / `AllocationPhase` / `TeardownPolicy` /
568 /// `ConditionKind` / `ProcessPhase` / `ProcessSignal`.
569 pub const fn as_str(self) -> &'static str {
570 match self {
571 Self::Initializing => "Initializing",
572 Self::Steady => "Steady",
573 Self::ScalingUp => "ScalingUp",
574 Self::ScalingDown => "ScalingDown",
575 Self::Degraded => "Degraded",
576 Self::Draining => "Draining",
577 }
578 }
579
580 /// Is the pool fully converged — supply matches desired, no
581 /// reconciler-driven population change pending? Closed-set match
582 /// (not `matches!`) so a future variant triggers the compiler's
583 /// exhaustiveness check at this site rather than silently
584 /// defaulting to `false`. Paired with `is_terminal` they form
585 /// the two-axis projection that future status aggregators
586 /// (operator-facing fleet health, `feira pool list --healthy`,
587 /// the SSE filter "show non-steady pools") dispatch against —
588 /// `is_steady && !is_terminal` ⇒ converged (goal state);
589 /// `!is_steady && is_terminal` ⇒ being deleted (no future
590 /// spawn); `!is_steady && !is_terminal` ⇒ transient
591 /// (Initializing | ScalingUp | ScalingDown | Degraded — pool
592 /// is in motion toward desired). The impossible bucket
593 /// `(true, true)` — a draining pool that's somehow also steady
594 /// — is pinned empty by `pool_phase_steady_excludes_terminal`.
595 pub const fn is_steady(self) -> bool {
596 match self {
597 Self::Steady => true,
598 Self::Initializing
599 | Self::ScalingUp
600 | Self::ScalingDown
601 | Self::Degraded
602 | Self::Draining => false,
603 }
604 }
605
606 /// Is the pool in its absorbing exit state — deletion-stamped,
607 /// reconciler is reaping every member, no spawn will ever
608 /// happen again? Closed-set match so a future variant triggers
609 /// the compiler's exhaustiveness check. See `is_steady` for the
610 /// predicate-pair contract + bucket definitions.
611 pub const fn is_terminal(self) -> bool {
612 match self {
613 Self::Draining => true,
614 Self::Initializing
615 | Self::Steady
616 | Self::ScalingUp
617 | Self::ScalingDown
618 | Self::Degraded => false,
619 }
620 }
621}
622
623// `impl FromStr for PoolPhase` + `impl tatara_lisp::ClosedSet for PoolPhase`
624// + `impl fmt::Display for PoolPhase` are generated by
625// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration above.
626// `label` delegates to the inherent `PoolPhase::as_str` via
627// `#[closed_set(via = "as_str")]` so the operator-facing
628// `phase={phase}` Display composition keeps reading the same canonical
629// PascalCase projection while a generic `T: ClosedSet` consumer (a
630// status-aggregator filter, the `feira pool list --healthy` predicate, a
631// future SSE event router) can walk every variant without knowing the
632// closed set lives in `tatara-process::pool`; Display delegates to the
633// same inherent projection via `#[closed_set(display)]` so the
634// `phase={phase}` composition stays pinned on the closed-set algebra
635// rather than a hand-rolled `fmt::Display` block.
636
637// `pub struct UnknownPoolPhase(pub String)` is generated by
638// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
639// on the enum declaration above. The auto-derived label `"pool phase"`
640// matches the prior hand-rolled `#[error("unknown pool phase: {0}")]`
641// verbatim. Symmetric to [`UnknownMemberState`],
642// [`UnknownReplacementPolicy`], [`UnknownReturnPolicy`],
643// [`crate::lifetime::UnknownTeardownPolicy`],
644// [`crate::boundary::UnknownConditionKind`], and
645// [`crate::phase::UnknownPhase`].
646
647/// Standard K8s Condition shape (kept local so tatara-process doesn't
648/// depend on k8s_openapi types in its public schema).
649#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
650#[serde(rename_all = "camelCase")]
651pub struct PoolCondition {
652 pub type_: String,
653 pub status: String,
654 pub reason: String,
655 pub message: String,
656 pub last_transition_time: DateTime<Utc>,
657}
658
659/// What the pool does when an allocation releases a member.
660///
661/// Sibling closed-set on the `EphemeralPool` axis:
662/// [`ReplacementPolicy::ALL`]. Sibling closed-sets on the
663/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`]
664/// (the *release*-time counterpart for non-pooled ephemeral envs),
665/// [`crate::boundary::ConditionKind::ALL`],
666/// [`crate::lifetime::LifetimeKind::ALL`],
667/// [`crate::intent::IntentKind::ALL`],
668/// [`crate::phase::ProcessPhase::ALL`],
669/// [`crate::signal::ProcessSignal::ALL`].
670#[derive(
671 Clone,
672 Copy,
673 Debug,
674 Hash,
675 PartialEq,
676 Eq,
677 Serialize,
678 Deserialize,
679 JsonSchema,
680 Default,
681 tatara_closed_set::DeriveClosedSet,
682)]
683#[serde(rename_all = "PascalCase")]
684#[closed_set(via = "as_str", generate_unknown, display)]
685pub enum ReturnPolicy {
686 /// Tear down the Process + create a fresh one. Safe but slow
687 /// (1-2 min spin-up before the slot is Free again).
688 #[default]
689 Replace,
690 /// Keep the Process running; run a typed `:reset` Job that wipes
691 /// state (DB drop, secrets rotate). Fast (~5-10s) but depends on
692 /// the reset Job being correct for the workload. Akeyless-style
693 /// systems are natural fits because the SaaS API is authoritative.
694 Reset,
695 /// Keep the Process indefinitely after release (debugging aid;
696 /// operator must `feira pool reap NAME` to clean up). Useful for
697 /// post-mortem of a flaky test.
698 Keep,
699}
700
701impl ReturnPolicy {
702 /// The closed set of return policies — single source of truth that
703 /// drives the `as_str` / Display / `FromStr` triad and the
704 /// `keeps_process` / `runs_reset_job` predicate pair. Adding a
705 /// fourth variant lands at one `ALL` entry + one `as_str` arm +
706 /// one arm per predicate — exhaustively checked by the compiler
707 /// (the `[Self; 3]` array literal forces the arity) and by the
708 /// predicate-pair injectivity test (a new variant must land in
709 /// its own (keeps_process, runs_reset_job) bucket or the author
710 /// has to extend the consumer dispatch in
711 /// `tatara-pool-reconciler::return_policy::plan_return`).
712 pub const ALL: [Self; 3] = [Self::Replace, Self::Reset, Self::Keep];
713
714 /// Canonical PascalCase wire-format projection — matches the
715 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
716 /// `enum:` enumeration the pool reconciler stamps on the
717 /// `ephemeralpools.tatara.pleme.io` schema. Pinned by
718 /// `return_policy_as_str_matches_serde` so a variant rename can't
719 /// drift between the typed surface, the CRD enum, the YAML wire
720 /// format AND any future operator-facing diagnostic that composes
721 /// `policy={policy}` via Display rather than a hard-coded literal.
722 pub const fn as_str(self) -> &'static str {
723 match self {
724 Self::Replace => "Replace",
725 Self::Reset => "Reset",
726 Self::Keep => "Keep",
727 }
728 }
729
730 /// Does the pool keep the backing Process alive across release?
731 /// Closed-set match (not `matches!`) so a future variant triggers
732 /// the compiler's exhaustiveness check at this site rather than
733 /// silently defaulting to `false`. Paired with `runs_reset_job`
734 /// they form the two-axis projection that the consumer in
735 /// `tatara-pool-reconciler::return_policy::plan_return` matches
736 /// against — `keeps_process` false ⇒ `DeleteAndRespawn`;
737 /// `keeps_process && runs_reset_job` ⇒ `ResetThenFree`;
738 /// `keeps_process && !runs_reset_job` ⇒ `KeepForInspection`. The
739 /// pair is `(false, false) | (true, true) | (true, false)` —
740 /// pinned injective by
741 /// `return_policy_predicate_pair_is_injective`.
742 pub const fn keeps_process(self) -> bool {
743 match self {
744 Self::Replace => false,
745 Self::Reset | Self::Keep => true,
746 }
747 }
748
749 /// Does the policy run a typed `:reset` Job to wipe state in
750 /// place? See `keeps_process` for the closed-match rationale +
751 /// the predicate-pair contract.
752 pub const fn runs_reset_job(self) -> bool {
753 match self {
754 Self::Reset => true,
755 Self::Replace | Self::Keep => false,
756 }
757 }
758}
759
760// `impl FromStr for ReturnPolicy` + `impl tatara_lisp::ClosedSet for
761// ReturnPolicy` + `impl fmt::Display for ReturnPolicy` are generated by
762// `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum declaration
763// above. `label` delegates to the inherent `ReturnPolicy::as_str` via
764// `#[closed_set(via = "as_str")]` so the
765// `tatara-pool-reconciler::return_policy::plan_return` dispatch keeps
766// reading the canonical PascalCase projection that matches the CRD
767// `enum:` literal verbatim, while a generic `T: ClosedSet` consumer
768// plugs in without knowing the enum lives in `tatara-process::pool`;
769// Display delegates to the same inherent projection via
770// `#[closed_set(display)]` so the `policy={policy}` diagnostic
771// composition stays pinned on the closed-set algebra.
772
773// `pub struct UnknownReturnPolicy(pub String)` is generated by
774// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
775// on the enum declaration above. The auto-derived label `"return policy"`
776// matches the prior hand-rolled `#[error("unknown return policy: {0}")]`
777// verbatim. Symmetric to [`UnknownReplacementPolicy`],
778// [`UnknownMemberState`], [`UnknownPoolPhase`],
779// [`crate::lifetime::UnknownTeardownPolicy`],
780// [`crate::boundary::UnknownConditionKind`], and
781// [`crate::phase::UnknownPhase`].
782
783/// Routing selector — matches an `EphemeralAllocation`'s requestor
784/// against pool-eligibility predicates.
785#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
786#[serde(rename_all = "camelCase")]
787pub struct PoolSelector {
788 /// Glob-matched against `EphemeralAllocation.spec.requestor.repo`.
789 /// Empty = match every repo.
790 #[serde(default)]
791 pub repos: Vec<String>,
792
793 /// Glob-matched against `EphemeralAllocation.spec.requestor.branch`.
794 /// Empty = match every branch.
795 #[serde(default)]
796 pub branches: Vec<String>,
797
798 /// PR labels (all-must-match, AND semantics). Empty = no label
799 /// requirement.
800 #[serde(default)]
801 pub pr_labels: Vec<String>,
802
803 /// Allocation `kind` strings this pool can serve (e.g., "github-pr",
804 /// "manual", "ci-run"). Empty = any kind.
805 #[serde(default)]
806 pub kinds: Vec<String>,
807}
808
809impl PoolSelector {
810 /// Does this selector match the given allocation routing key?
811 /// Pure: no side effects.
812 pub fn matches(&self, key: &MatchKey<'_>) -> bool {
813 glob_any(&self.repos, key.repo)
814 && glob_any(&self.branches, key.branch)
815 && labels_subset(&self.pr_labels, key.pr_labels)
816 && kind_any(&self.kinds, key.kind)
817 }
818
819 /// Specificity score — higher = more specific. Used by the
820 /// reconciler to break ties between selectors that all match.
821 pub fn specificity(&self) -> u32 {
822 let mut score = 0;
823 if !self.repos.is_empty() {
824 score += 8;
825 }
826 if !self.branches.is_empty() {
827 score += 4;
828 }
829 score += (self.pr_labels.len() as u32) * 2;
830 if !self.kinds.is_empty() {
831 score += 1;
832 }
833 score
834 }
835}
836
837/// Allocation routing key — what the reconciler matches against pool selectors.
838#[derive(Clone, Copy, Debug)]
839pub struct MatchKey<'a> {
840 pub repo: &'a str,
841 pub branch: &'a str,
842 pub pr_labels: &'a [String],
843 pub kind: &'a str,
844}
845
846fn glob_any(patterns: &[String], value: &str) -> bool {
847 if patterns.is_empty() {
848 return true;
849 }
850 patterns.iter().any(|p| glob_match(p, value))
851}
852
853fn kind_any(kinds: &[String], value: &str) -> bool {
854 if kinds.is_empty() {
855 return true;
856 }
857 kinds.iter().any(|k| k == value)
858}
859
860fn labels_subset(required: &[String], present: &[String]) -> bool {
861 required.iter().all(|r| present.iter().any(|p| p == r))
862}
863
864/// Minimal glob: supports trailing `*` only (e.g., `"pleme-io/*"`,
865/// `"release-*"`). Sufficient for repo/branch routing. Empty pattern
866/// matches anything.
867fn glob_match(pattern: &str, value: &str) -> bool {
868 if pattern.is_empty() {
869 return true;
870 }
871 if let Some(prefix) = pattern.strip_suffix('*') {
872 value.starts_with(prefix)
873 } else {
874 pattern == value
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881 // The closed-set tests below call `T::from_str(bad)` via the
882 // derive-generated `FromStr` impls — bring the trait into scope at
883 // the test module so the lib body doesn't carry an otherwise-unused
884 // `use std::str::FromStr;` at the file head.
885 use std::str::FromStr;
886
887 #[test]
888 fn glob_trailing_star_matches_prefix() {
889 assert!(glob_match("pleme-io/*", "pleme-io/akeyless-deployment"));
890 assert!(!glob_match("pleme-io/*", "drzln/dotfiles"));
891 assert!(glob_match("release-*", "release-2026-05"));
892 assert!(!glob_match("release-*", "main"));
893 assert!(glob_match("main", "main"));
894 assert!(!glob_match("main", "develop"));
895 }
896
897 #[test]
898 fn empty_selector_matches_anything() {
899 let s = PoolSelector::default();
900 assert!(s.matches(&MatchKey {
901 repo: "any/repo",
902 branch: "any-branch",
903 pr_labels: &[],
904 kind: "any",
905 }));
906 }
907
908 #[test]
909 fn repo_glob_filters_match_key() {
910 let s = PoolSelector {
911 repos: vec!["pleme-io/akeyless-*".into()],
912 ..Default::default()
913 };
914 assert!(s.matches(&MatchKey {
915 repo: "pleme-io/akeyless-deployment",
916 branch: "x",
917 pr_labels: &[],
918 kind: "y",
919 }));
920 assert!(!s.matches(&MatchKey {
921 repo: "pleme-io/other-repo",
922 branch: "x",
923 pr_labels: &[],
924 kind: "y",
925 }));
926 }
927
928 #[test]
929 fn pr_labels_require_all() {
930 let s = PoolSelector {
931 pr_labels: vec!["needs-akeyless".into(), "integration".into()],
932 ..Default::default()
933 };
934 // Both labels present → match.
935 assert!(s.matches(&MatchKey {
936 repo: "x",
937 branch: "y",
938 pr_labels: &[
939 "needs-akeyless".into(),
940 "integration".into(),
941 "extra".into()
942 ],
943 kind: "z",
944 }));
945 // One label missing → no match.
946 assert!(!s.matches(&MatchKey {
947 repo: "x",
948 branch: "y",
949 pr_labels: &["needs-akeyless".into()],
950 kind: "z",
951 }));
952 }
953
954 #[test]
955 fn specificity_ranks_more_constrained_higher() {
956 let general = PoolSelector::default();
957 let specific = PoolSelector {
958 repos: vec!["pleme-io/*".into()],
959 branches: vec!["main".into()],
960 pr_labels: vec!["needs-akeyless".into()],
961 kinds: vec!["github-pr".into()],
962 };
963 assert!(specific.specificity() > general.specificity());
964 }
965
966 #[test]
967 fn return_policy_defaults_to_replace() {
968 assert_eq!(ReturnPolicy::default(), ReturnPolicy::Replace);
969 }
970
971 #[test]
972 fn pool_phase_defaults_to_initializing() {
973 assert_eq!(PoolPhase::default(), PoolPhase::Initializing);
974 }
975
976 // ── closed-set algebra contracts for ReplacementPolicy
977 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
978
979 /// Structural well-formedness of [`ReplacementPolicy`] as a
980 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
981 /// testkit lift that pins all three structural invariants (`ALL`
982 /// is non-empty, every variant round-trips through
983 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
984 /// outside the closed set) at ONE call site. Replaces the hand-
985 /// derived `replacement_policy_all_is_unique_and_complete` +
986 /// `replacement_policy_roundtrip_via_as_str` + the empty-input arm
987 /// of `unknown_replacement_policy_errors`. `FromStr` delegates to
988 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
989 /// exercises the same code path the pool reconciler hits when
990 /// parsing a CRD `enum:`-validated value back to the typed policy.
991 #[test]
992 fn replacement_policy_is_well_formed_closed_set() {
993 tatara_closed_set::assert_closed_set_well_formed::<ReplacementPolicy>();
994 }
995
996 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
997 /// output verbatim for every variant. A future variant rename (or
998 /// an `as_str` arm typo) lands here at one site, instead of
999 /// drifting between the typed surface, the CRD enum, and the
1000 /// YAML wire format.
1001 #[test]
1002 fn replacement_policy_as_str_matches_serde() {
1003 for policy in ReplacementPolicy::ALL {
1004 let serialized = serde_json::to_string(&policy).expect("serialize");
1005 let unquoted = serialized
1006 .trim_start_matches('"')
1007 .trim_end_matches('"')
1008 .to_string();
1009 assert_eq!(
1010 unquoted,
1011 policy.as_str(),
1012 "as_str drift for {policy:?}: as_str={} serde={unquoted}",
1013 policy.as_str()
1014 );
1015 }
1016 }
1017
1018 /// The Display impl IS `as_str` — pinning this lets future callers
1019 /// reach for either projection without drift. The operator-facing
1020 /// "policy={policy}" diagnostic in `tatara-pool-reconciler::desired`
1021 /// composes through Display rather than through a hard-coded
1022 /// variant string.
1023 #[test]
1024 fn replacement_policy_display_matches_as_str() {
1025 for policy in ReplacementPolicy::ALL {
1026 assert_eq!(policy.to_string(), policy.as_str());
1027 }
1028 }
1029
1030 /// `FromStr` rejects strings that aren't in the canonical
1031 /// projection — lowercased / typo / cross-axis-leaked — and the
1032 /// error echoes the input verbatim so the operator-facing
1033 /// diagnostic carries the offending value, not a normalized form.
1034 /// The empty-input arm is pinned by
1035 /// [`replacement_policy_is_well_formed_closed_set`] via the
1036 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1037 /// verbatim-echo contract on the [`UnknownReplacementPolicy`]
1038 /// newtype, which the trait's `make_unknown` can't see.
1039 #[test]
1040 fn unknown_replacement_policy_errors() {
1041 for bad in [
1042 "replaceimmediate",
1043 "PAUSEPOOL",
1044 "Replace-Immediate",
1045 "hold_failed",
1046 "Pause",
1047 "Reset",
1048 ] {
1049 let err = ReplacementPolicy::from_str(bad).unwrap_err();
1050 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1051 }
1052 }
1053
1054 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1055 /// documented per-variant on-failure behavior.
1056 #[test]
1057 fn replacement_policy_predicate_truth_tables() {
1058 assert!(ReplacementPolicy::ReplaceImmediate.replaces_failed());
1059 assert!(!ReplacementPolicy::ReplaceImmediate.pauses_on_failure());
1060
1061 assert!(!ReplacementPolicy::HoldFailed.replaces_failed());
1062 assert!(!ReplacementPolicy::HoldFailed.pauses_on_failure());
1063
1064 assert!(!ReplacementPolicy::PausePool.replaces_failed());
1065 assert!(ReplacementPolicy::PausePool.pauses_on_failure());
1066 }
1067
1068 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1069 /// predicates simultaneously — the two on-failure actions
1070 /// (reap-each-failed vs pause-whole-pool) are mutually exclusive.
1071 /// A future `ReplacementPolicy::PauseAndReap` that returned true
1072 /// from both would FAIL here, forcing the author to either pick
1073 /// one bucket or extend the consumer dispatch site in
1074 /// `tatara-pool-reconciler::desired::PoolConvergence::decide`
1075 /// deliberately rather than silently double-firing both branches.
1076 #[test]
1077 fn replacement_policy_predicates_are_disjoint() {
1078 for policy in ReplacementPolicy::ALL {
1079 assert!(
1080 !(policy.replaces_failed() && policy.pauses_on_failure()),
1081 "{policy:?} returns true from both replaces_failed and pauses_on_failure",
1082 );
1083 }
1084 }
1085
1086 /// INJECTIVITY CONTRACT: the pair `(replaces_failed,
1087 /// pauses_on_failure)` is injective across `ALL`. Each variant
1088 /// projects to its own `(bool, bool)` bucket: `(true, false)` =
1089 /// reap; `(false, false)` = hold; `(false, true)` = pause. Pairing
1090 /// this with the disjointness contract above forces a future
1091 /// variant to land in a fresh `(replaces_failed,
1092 /// pauses_on_failure)` bucket — or the author extends the consumer
1093 /// dispatch in `tatara-pool-reconciler::desired::PoolConvergence`
1094 /// to recognize the new projection bucket.
1095 #[test]
1096 fn replacement_policy_predicate_pair_is_injective() {
1097 let projections: Vec<(bool, bool)> = ReplacementPolicy::ALL
1098 .into_iter()
1099 .map(|p| (p.replaces_failed(), p.pauses_on_failure()))
1100 .collect();
1101 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1102 assert_eq!(
1103 projections.len(),
1104 unique.len(),
1105 "predicate pair projection is not injective: {projections:?}",
1106 );
1107 }
1108
1109 /// DEFAULT-AGREEMENT CONTRACT: `ReplacementPolicy::default()`
1110 /// returns the variant tagged `#[default]` in the enum, AND that
1111 /// variant reaps (the production-safe behavior). A future #[default]
1112 /// rename without flipping the predicates fails here.
1113 #[test]
1114 fn replacement_policy_default_replaces_failed() {
1115 let d = ReplacementPolicy::default();
1116 assert_eq!(d, ReplacementPolicy::ReplaceImmediate);
1117 assert!(d.replaces_failed());
1118 assert!(!d.pauses_on_failure());
1119 }
1120
1121 #[test]
1122 fn kinds_filter_to_known_set() {
1123 let s = PoolSelector {
1124 kinds: vec!["github-pr".into(), "manual".into()],
1125 ..Default::default()
1126 };
1127 assert!(s.matches(&MatchKey {
1128 repo: "x",
1129 branch: "y",
1130 pr_labels: &[],
1131 kind: "github-pr",
1132 }));
1133 assert!(!s.matches(&MatchKey {
1134 repo: "x",
1135 branch: "y",
1136 pr_labels: &[],
1137 kind: "scheduled",
1138 }));
1139 }
1140
1141 // ── closed-set algebra contracts for ReturnPolicy
1142 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
1143
1144 /// Structural well-formedness of [`ReturnPolicy`] as a
1145 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1146 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1147 /// above.
1148 #[test]
1149 fn return_policy_is_well_formed_closed_set() {
1150 tatara_closed_set::assert_closed_set_well_formed::<ReturnPolicy>();
1151 }
1152
1153 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1154 /// output verbatim for every variant. A future variant rename (or
1155 /// an `as_str` arm typo) lands here at one site, instead of
1156 /// drifting between the typed surface, the CRD enum, and the
1157 /// YAML wire format.
1158 #[test]
1159 fn return_policy_as_str_matches_serde() {
1160 for policy in ReturnPolicy::ALL {
1161 let serialized = serde_json::to_string(&policy).expect("serialize");
1162 let unquoted = serialized
1163 .trim_start_matches('"')
1164 .trim_end_matches('"')
1165 .to_string();
1166 assert_eq!(
1167 unquoted,
1168 policy.as_str(),
1169 "as_str drift for {policy:?}: as_str={} serde={unquoted}",
1170 policy.as_str()
1171 );
1172 }
1173 }
1174
1175 /// The Display impl IS `as_str` — pinning this lets future callers
1176 /// reach for either projection without drift, mirroring the
1177 /// `ReplacementPolicy` discipline.
1178 #[test]
1179 fn return_policy_display_matches_as_str() {
1180 for policy in ReturnPolicy::ALL {
1181 assert_eq!(policy.to_string(), policy.as_str());
1182 }
1183 }
1184
1185 /// `FromStr` rejects strings that aren't in the canonical
1186 /// projection — lowercased / typo / cross-axis-leaked — and the
1187 /// error echoes the input verbatim so the operator-facing
1188 /// diagnostic carries the offending value, not a normalized form.
1189 /// The empty-input arm is pinned by
1190 /// [`return_policy_is_well_formed_closed_set`] via the
1191 /// `tatara_lisp::ClosedSet` testkit.
1192 #[test]
1193 fn unknown_return_policy_errors() {
1194 for bad in [
1195 "replace",
1196 "RESET",
1197 "Re-place",
1198 "keep_for_inspection",
1199 "DeleteAndRespawn",
1200 "ReplaceImmediate",
1201 ] {
1202 let err = ReturnPolicy::from_str(bad).unwrap_err();
1203 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1204 }
1205 }
1206
1207 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1208 /// documented per-variant on-release behavior.
1209 #[test]
1210 fn return_policy_predicate_truth_tables() {
1211 assert!(!ReturnPolicy::Replace.keeps_process());
1212 assert!(!ReturnPolicy::Replace.runs_reset_job());
1213
1214 assert!(ReturnPolicy::Reset.keeps_process());
1215 assert!(ReturnPolicy::Reset.runs_reset_job());
1216
1217 assert!(ReturnPolicy::Keep.keeps_process());
1218 assert!(!ReturnPolicy::Keep.runs_reset_job());
1219 }
1220
1221 /// IMPLICATION CONTRACT: `runs_reset_job` implies `keeps_process`.
1222 /// You cannot run a typed `:reset` Job against a Process you've
1223 /// just deleted; the impossible bucket `(false, true)` must stay
1224 /// empty. A future variant returning true from `runs_reset_job`
1225 /// while returning false from `keeps_process` fails here, which
1226 /// forces the author to either flip `keeps_process` to true or
1227 /// extend the consumer dispatch site in
1228 /// `tatara-pool-reconciler::return_policy::plan_return`
1229 /// deliberately rather than letting an impossible state slip in.
1230 #[test]
1231 fn return_policy_reset_implies_keeps_process() {
1232 for policy in ReturnPolicy::ALL {
1233 if policy.runs_reset_job() {
1234 assert!(
1235 policy.keeps_process(),
1236 "{policy:?} runs a reset job but does not keep the process",
1237 );
1238 }
1239 }
1240 }
1241
1242 /// INJECTIVITY CONTRACT: the pair `(keeps_process, runs_reset_job)`
1243 /// is injective across `ALL`. Each variant projects to its own
1244 /// `(bool, bool)` bucket: `(false, false)` = delete + respawn;
1245 /// `(true, true)` = reset-in-place; `(true, false)` = keep for
1246 /// inspection. Pairing this with the implication contract above
1247 /// forces a future variant to land in a fresh
1248 /// `(keeps_process, runs_reset_job)` bucket — or the author
1249 /// extends the consumer dispatch in
1250 /// `tatara-pool-reconciler::return_policy::plan_return` to
1251 /// recognize the new projection bucket.
1252 #[test]
1253 fn return_policy_predicate_pair_is_injective() {
1254 let projections: Vec<(bool, bool)> = ReturnPolicy::ALL
1255 .into_iter()
1256 .map(|p| (p.keeps_process(), p.runs_reset_job()))
1257 .collect();
1258 let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
1259 assert_eq!(
1260 projections.len(),
1261 unique.len(),
1262 "predicate pair projection is not injective: {projections:?}",
1263 );
1264 }
1265
1266 /// DEFAULT-AGREEMENT CONTRACT: `ReturnPolicy::default()` returns
1267 /// the variant tagged `#[default]` in the enum, AND that variant
1268 /// is the safe "tear down + respawn" behavior — neither keeps the
1269 /// process nor runs a reset Job. A future `#[default]` rename
1270 /// without flipping the predicates fails here.
1271 #[test]
1272 fn return_policy_default_is_replace_and_neither_predicate_fires() {
1273 let d = ReturnPolicy::default();
1274 assert_eq!(d, ReturnPolicy::Replace);
1275 assert!(!d.keeps_process());
1276 assert!(!d.runs_reset_job());
1277 }
1278
1279 // ── closed-set algebra contracts for MemberState
1280 // (ALL × as_str × FromStr × predicate pair) ────────────────────
1281
1282 /// Structural well-formedness of [`MemberState`] as a
1283 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1284 /// symmetric to [`replacement_policy_is_well_formed_closed_set`]
1285 /// and [`return_policy_is_well_formed_closed_set`] above.
1286 #[test]
1287 fn member_state_is_well_formed_closed_set() {
1288 tatara_closed_set::assert_closed_set_well_formed::<MemberState>();
1289 }
1290
1291 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1292 /// output verbatim for every variant. A future variant rename (or
1293 /// an `as_str` arm typo) lands here at one site, instead of
1294 /// drifting between the typed surface, the CRD enum, and the YAML
1295 /// wire format the pool reconciler stamps on
1296 /// `status.members[].state`.
1297 #[test]
1298 fn member_state_as_str_matches_serde() {
1299 for state in MemberState::ALL {
1300 let serialized = serde_json::to_string(&state).expect("serialize");
1301 let unquoted = serialized
1302 .trim_start_matches('"')
1303 .trim_end_matches('"')
1304 .to_string();
1305 assert_eq!(
1306 unquoted,
1307 state.as_str(),
1308 "as_str drift for {state:?}: as_str={} serde={unquoted}",
1309 state.as_str()
1310 );
1311 }
1312 }
1313
1314 /// The Display impl IS `as_str` — pinning this lets future callers
1315 /// reach for either projection without drift. Any operator-facing
1316 /// "state={state}" diagnostic that composes through Display
1317 /// inherits the canonical wire-format string automatically.
1318 #[test]
1319 fn member_state_display_matches_as_str() {
1320 for state in MemberState::ALL {
1321 assert_eq!(state.to_string(), state.as_str());
1322 }
1323 }
1324
1325 /// `FromStr` rejects strings that aren't in the canonical
1326 /// projection — lowercased / typo / cross-axis-leaked — and
1327 /// the error echoes the input verbatim so the operator-facing
1328 /// diagnostic carries the offending value, not a normalized form.
1329 /// The empty-input arm is pinned by
1330 /// [`member_state_is_well_formed_closed_set`] via the
1331 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1332 /// pin the closed-set REJECTION contract that the trait can't see:
1333 /// `"ReplaceImmediate"`, `"Reset"`, and `"Attested"` are valid
1334 /// labels for sibling enums (`ReplacementPolicy`, `ReturnPolicy`,
1335 /// `ProcessPhase`) but MUST reject here, because the codomains
1336 /// are disjoint.
1337 #[test]
1338 fn unknown_member_state_errors() {
1339 for bad in [
1340 "free",
1341 "SPAWNING",
1342 "Free-State",
1343 "allocated_now",
1344 "ReplaceImmediate", // ReplacementPolicy-axis leak
1345 "Reset", // ReturnPolicy-axis leak
1346 "Attested", // ProcessPhase-axis leak
1347 ] {
1348 let err = MemberState::from_str(bad).unwrap_err();
1349 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1350 }
1351 }
1352
1353 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1354 /// documented per-variant lifecycle role. The pool reconciler's
1355 /// `pool_phase_from_members` supply calc collapses
1356 /// `count_state(Free) + count_state(Spawning)` into one
1357 /// `counts_toward_supply` filter; this table pins the per-variant
1358 /// projection that consumer depends on.
1359 #[test]
1360 fn member_state_predicate_truth_tables() {
1361 assert!(!MemberState::Spawning.is_failed());
1362 assert!(MemberState::Spawning.counts_toward_supply());
1363
1364 assert!(!MemberState::Free.is_failed());
1365 assert!(MemberState::Free.counts_toward_supply());
1366
1367 assert!(!MemberState::Allocated.is_failed());
1368 assert!(!MemberState::Allocated.counts_toward_supply());
1369
1370 assert!(!MemberState::Returning.is_failed());
1371 assert!(!MemberState::Returning.counts_toward_supply());
1372
1373 assert!(MemberState::Failed.is_failed());
1374 assert!(!MemberState::Failed.counts_toward_supply());
1375 }
1376
1377 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1378 /// `is_failed` and `counts_toward_supply` simultaneously — a
1379 /// failed member can never be counted as available capacity. A
1380 /// future variant that returned true from both would FAIL here,
1381 /// forcing the author to either drop it from supply, or extend
1382 /// the consumer's bucketing in
1383 /// `tatara-pool-reconciler::controller_pool::pool_phase_from_members`
1384 /// deliberately rather than silently inflating the pool's supply
1385 /// count with failed slots.
1386 #[test]
1387 fn member_state_failed_implies_no_supply() {
1388 for state in MemberState::ALL {
1389 assert!(
1390 !(state.is_failed() && state.counts_toward_supply()),
1391 "{state:?} returns true from both is_failed and counts_toward_supply — \
1392 a failed member can never be counted as available pool capacity",
1393 );
1394 }
1395 }
1396
1397 /// COVERAGE CONTRACT: every variant lands somewhere — either
1398 /// in supply, or as a failed slot, or as an in-use bucket
1399 /// (`Allocated | Returning`). A future variant that returns
1400 /// `false` from `counts_toward_supply` AND `false` from
1401 /// `is_failed` is fine *iff* it represents an in-use slot; this
1402 /// test pins the existing variants in their declared buckets so
1403 /// the consumer-side dispatch in
1404 /// `tatara-pool-reconciler::pool_decide::decide_pool_reconcile`
1405 /// stays grounded.
1406 #[test]
1407 fn member_state_buckets_cover_every_variant() {
1408 let mut supply = 0u32;
1409 let mut failed = 0u32;
1410 let mut in_use = 0u32;
1411 for state in MemberState::ALL {
1412 match (state.is_failed(), state.counts_toward_supply()) {
1413 (true, false) => failed += 1,
1414 (false, true) => supply += 1,
1415 (false, false) => in_use += 1,
1416 (true, true) => panic!("disjointness already pins this empty for {state:?}"),
1417 }
1418 }
1419 assert_eq!(supply, 2, "supply bucket: Free + Spawning");
1420 assert_eq!(failed, 1, "failed bucket: Failed");
1421 assert_eq!(in_use, 2, "in-use bucket: Allocated + Returning");
1422 assert_eq!(supply + failed + in_use, MemberState::ALL.len() as u32);
1423 }
1424
1425 // ── closed-set algebra contracts for PoolPhase
1426 // (ALL × as_str × FromStr × predicate pair) ────────────────────
1427
1428 /// Structural well-formedness of [`PoolPhase`] as a
1429 /// [`tatara_lisp::ClosedSet`] implementor — testkit lift
1430 /// symmetric to [`member_state_is_well_formed_closed_set`] above.
1431 #[test]
1432 fn pool_phase_is_well_formed_closed_set() {
1433 tatara_closed_set::assert_closed_set_well_formed::<PoolPhase>();
1434 }
1435
1436 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1437 /// output verbatim for every variant. A future variant rename (or
1438 /// an `as_str` arm typo) lands here at one site, instead of
1439 /// drifting between the typed surface, the CRD enum, and the YAML
1440 /// wire format the pool reconciler stamps on `status.phase`.
1441 #[test]
1442 fn pool_phase_as_str_matches_serde() {
1443 for phase in PoolPhase::ALL {
1444 let serialized = serde_json::to_string(&phase).expect("serialize");
1445 let unquoted = serialized
1446 .trim_start_matches('"')
1447 .trim_end_matches('"')
1448 .to_string();
1449 assert_eq!(
1450 unquoted,
1451 phase.as_str(),
1452 "as_str drift for {phase:?}: as_str={} serde={unquoted}",
1453 phase.as_str()
1454 );
1455 }
1456 }
1457
1458 /// The Display impl IS `as_str` — pinning this lets future callers
1459 /// reach for either projection without drift. Any operator-facing
1460 /// "phase={phase}" diagnostic that composes through Display
1461 /// inherits the canonical wire-format string automatically.
1462 #[test]
1463 fn pool_phase_display_matches_as_str() {
1464 for phase in PoolPhase::ALL {
1465 assert_eq!(phase.to_string(), phase.as_str());
1466 }
1467 }
1468
1469 /// `FromStr` rejects strings that aren't in the canonical
1470 /// projection — lowercased / typo / cross-axis-leaked — and
1471 /// the error echoes the input verbatim so the operator-facing
1472 /// diagnostic carries the offending value, not a normalized form.
1473 /// The empty-input arm is pinned by
1474 /// [`pool_phase_is_well_formed_closed_set`] via the
1475 /// `tatara_lisp::ClosedSet` testkit. The cross-axis leak cases
1476 /// (`"Free"`, `"Replace"`, `"Attested"`, `"HoldFailed"`) pin the
1477 /// closed-set REJECTION contract that the trait can't see — those
1478 /// are valid sibling-axis labels but MUST reject here.
1479 #[test]
1480 fn unknown_pool_phase_errors() {
1481 for bad in [
1482 "steady",
1483 "SCALINGUP",
1484 "Scaling-Up",
1485 "scaling_down",
1486 "Free", // MemberState-axis leak
1487 "Replace", // ReturnPolicy-axis leak
1488 "Attested", // ProcessPhase-axis leak
1489 "HoldFailed", // ReplacementPolicy-axis leak
1490 ] {
1491 let err = PoolPhase::from_str(bad).unwrap_err();
1492 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1493 }
1494 }
1495
1496 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1497 /// documented per-variant lifecycle role. Pinning this table at
1498 /// one site means any future status-aggregator surface
1499 /// (`feira pool list --healthy`, the SSE filter, the desired-loop
1500 /// heartbeat short-circuit) reads the same projection that the
1501 /// reconciler writes.
1502 #[test]
1503 fn pool_phase_predicate_truth_tables() {
1504 assert!(!PoolPhase::Initializing.is_steady());
1505 assert!(!PoolPhase::Initializing.is_terminal());
1506
1507 assert!(PoolPhase::Steady.is_steady());
1508 assert!(!PoolPhase::Steady.is_terminal());
1509
1510 assert!(!PoolPhase::ScalingUp.is_steady());
1511 assert!(!PoolPhase::ScalingUp.is_terminal());
1512
1513 assert!(!PoolPhase::ScalingDown.is_steady());
1514 assert!(!PoolPhase::ScalingDown.is_terminal());
1515
1516 assert!(!PoolPhase::Degraded.is_steady());
1517 assert!(!PoolPhase::Degraded.is_terminal());
1518
1519 assert!(!PoolPhase::Draining.is_steady());
1520 assert!(PoolPhase::Draining.is_terminal());
1521 }
1522
1523 /// DISJOINTNESS CONTRACT: no variant returns true from BOTH
1524 /// `is_steady` and `is_terminal` simultaneously — a draining pool
1525 /// is by definition transitioning OUT, not the goal converged
1526 /// state. A future variant that returned true from both would
1527 /// FAIL here, forcing the author to either pick one bucket or
1528 /// extend the consumer dispatch sites (status aggregators,
1529 /// heartbeat short-circuit) deliberately rather than silently
1530 /// double-firing both branches.
1531 #[test]
1532 fn pool_phase_steady_excludes_terminal() {
1533 for phase in PoolPhase::ALL {
1534 assert!(
1535 !(phase.is_steady() && phase.is_terminal()),
1536 "{phase:?} returns true from both is_steady and is_terminal — \
1537 a draining pool is by definition not the converged goal state",
1538 );
1539 }
1540 }
1541
1542 /// COVERAGE CONTRACT: every variant lands somewhere — either the
1543 /// converged goal (`Steady`), the absorbing exit (`Draining`),
1544 /// or the transient bucket (`Initializing | ScalingUp |
1545 /// ScalingDown | Degraded` — pool is in motion toward desired).
1546 /// A future variant that returns `false` from BOTH predicates is
1547 /// fine *iff* it represents an in-motion state; this test pins
1548 /// the existing variants in their declared buckets so the
1549 /// projection consumers stay grounded.
1550 #[test]
1551 fn pool_phase_buckets_cover_every_variant() {
1552 let mut converged = 0u32;
1553 let mut terminal = 0u32;
1554 let mut transient = 0u32;
1555 for phase in PoolPhase::ALL {
1556 match (phase.is_steady(), phase.is_terminal()) {
1557 (true, false) => converged += 1,
1558 (false, true) => terminal += 1,
1559 (false, false) => transient += 1,
1560 (true, true) => panic!("disjointness already pins this empty for {phase:?}"),
1561 }
1562 }
1563 assert_eq!(converged, 1, "converged bucket: Steady");
1564 assert_eq!(terminal, 1, "terminal bucket: Draining");
1565 assert_eq!(
1566 transient, 4,
1567 "transient bucket: Initializing + ScalingUp + ScalingDown + Degraded"
1568 );
1569 assert_eq!(
1570 converged + terminal + transient,
1571 PoolPhase::ALL.len() as u32
1572 );
1573 }
1574
1575 /// DEFAULT-AGREEMENT CONTRACT: `PoolPhase::default()` returns the
1576 /// variant a freshly-admitted pool should land in — `Initializing`
1577 /// — AND that variant is neither steady (no members yet) nor
1578 /// terminal (not deletion-stamped). A future `Default` rename
1579 /// without flipping the predicates fails here.
1580 #[test]
1581 fn pool_phase_default_is_initializing_in_transient_bucket() {
1582 let d = PoolPhase::default();
1583 assert_eq!(d, PoolPhase::Initializing);
1584 assert!(!d.is_steady());
1585 assert!(!d.is_terminal());
1586 }
1587}