Skip to main content

tatara_process/
allocation.rs

1//! `EphemeralAllocation` CRD — a typed request for a pool member.
2//!
3//! Pairs with `EphemeralPool`: an Allocation is the request side;
4//! the pool reconciler answers it by matching one of its free
5//! Process members and stamping the requestor's identity on the
6//! Allocation's status.
7//!
8//! Topology:
9//! - The requestor (GitHub PR webhook, CI runner, operator running
10//!   `feira allocation request …`) creates an `EphemeralAllocation`.
11//! - The pool reconciler watches Allocations; matches `spec.poolRef`
12//!   (or routes via PoolSelector if `poolRef` is omitted) to a pool;
13//!   picks one Free member; transitions the member to Allocated and
14//!   the Allocation to Bound.
15//! - When the requestor is done, it deletes the Allocation. The pool
16//!   reconciler honors the pool's `returnPolicy` (Reset / Replace /
17//!   Keep).
18
19use chrono::{DateTime, Utc};
20use kube::CustomResource;
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::pool::AllocationRef;
25
26/// `EphemeralAllocation` CRD spec — a typed request for a pool member.
27///
28/// ```yaml
29/// apiVersion: tatara.pleme.io/v1alpha1
30/// kind: EphemeralAllocation
31/// metadata:
32///   name: pr-123-akeyless
33///   namespace: ephemeral-pools
34/// spec:
35///   poolRef:
36///     name: akeyless-attest-pool
37///     namespace: ephemeral-pools
38///   requestor:
39///     kind: github-pr
40///     repo: "pleme-io/akeyless-deployment"
41///     branch: "fix-something"
42///     prNumber: 123
43///     prLabels: ["needs-akeyless"]
44///   ttl: "1h"
45/// ```
46#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)]
47#[kube(
48    group = "tatara.pleme.io",
49    version = "v1alpha1",
50    kind = "EphemeralAllocation",
51    plural = "ephemeralallocations",
52    shortname = "ealloc",
53    namespaced,
54    status = "AllocationStatus",
55    printcolumn = r#"{"name":"Pool","type":"string","jsonPath":".spec.poolRef.name"}"#,
56    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
57    printcolumn = r#"{"name":"Process","type":"string","jsonPath":".status.assignedProcess.name"}"#,
58    printcolumn = r#"{"name":"Requestor","type":"string","jsonPath":".spec.requestor.kind"}"#,
59    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
60)]
61#[serde(rename_all = "camelCase")]
62pub struct AllocationSpec {
63    /// Direct pool reference. When set, skip selector-based routing.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub pool_ref: Option<AllocationRef>,
66
67    /// Who is asking for the env.
68    pub requestor: Requestor,
69
70    /// How long the requestor needs the env (`humantime`). The pool
71    /// reconciler clamps this to `pool.spec.maxAllocationTtl`.
72    /// When unset, falls back to the pool's `template.ttl`.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub ttl: Option<String>,
75
76    /// Operator-supplied notes — surfaced in `feira allocation list`
77    /// for audit / debugging context.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub note: Option<String>,
80}
81
82/// Identity + routing context for a request.
83#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
84#[serde(rename_all = "camelCase")]
85pub struct Requestor {
86    /// Discriminator: `"github-pr"`, `"manual"`, `"ci-run"`,
87    /// `"scheduled"`, … The wire shape is open by design — operators
88    /// may register their own kinds and the [`crate::pool::PoolSelector`]
89    /// matches on raw string equality. The substrate's own emitters
90    /// stamp one of the four canonical kebab-case kinds enumerated by
91    /// [`RequestorKind::ALL`]; [`Requestor::known_kind`] projects the
92    /// open wire field through that closed-set view at ONE site so
93    /// future kind-keyed consumers (pool dashboards, completion lists,
94    /// audit-trail classifiers) sweep the typed variants without
95    /// re-implementing `match self.kind.as_str()` arm-by-arm. Sibling
96    /// shape to [`crate::receipt::ReceiptEnvelope::known_kind`].
97    pub kind: String,
98
99    /// Optional repo identifier (e.g., `"pleme-io/akeyless-deployment"`).
100    /// Matched against `PoolSelector.repos`.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub repo: Option<String>,
103
104    /// Optional branch name. Matched against `PoolSelector.branches`.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub branch: Option<String>,
107
108    /// Optional PR number (for `kind: github-pr`). Surfaces in
109    /// printcolumns + audit.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub pr_number: Option<u64>,
112
113    /// Optional commit SHA (for `kind: github-pr` or `ci-run`).
114    /// Stamped onto the allocated Process for traceability.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub sha: Option<String>,
117
118    /// PR / commit labels — matched as a subset against
119    /// `PoolSelector.prLabels`.
120    #[serde(default)]
121    pub pr_labels: Vec<String>,
122
123    /// Free-form actor — username, CI runner ID, etc.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub actor: Option<String>,
126}
127
128impl Requestor {
129    /// Decode [`Self::kind`] into the typed [`RequestorKind`] variant
130    /// when the wire string matches one of the four substrate-emitted
131    /// canonical kebab-case kinds; `None` when the kind is an
132    /// operator-registered open string (the schema is open by design —
133    /// every allocation remains a valid allocation, but only typed
134    /// kinds participate in closed-set dispatch). The (open `String`,
135    /// closed-typed view) split lets future kind-keyed consumers
136    /// (pool-selector classifiers, dashboard completion, audit-trail
137    /// classifiers) sweep the typed variants without touching the
138    /// open-by-design wire shape. Lifted as the canonical decode site
139    /// so no consumer re-implements the `match self.kind.as_str()` arm-
140    /// by-arm — the closed-set sweep happens through
141    /// [`RequestorKind::from_str`] at ONE site. Sibling shape to
142    /// [`crate::receipt::ReceiptEnvelope::known_kind`].
143    #[must_use]
144    pub fn known_kind(&self) -> Option<RequestorKind> {
145        self.kind.parse().ok()
146    }
147}
148
149/// Closed-set view over the substrate-emitted canonical
150/// [`Requestor::kind`] wire strings — the four kebab-case
151/// discriminators every pleme-io requestor stamps onto an
152/// [`EphemeralAllocation`]: `github-pr` (the [`tatara_github_watcher`-
153/// authored](../../tatara-github-watcher/src/allocation_factory.rs)
154/// PR-driven path), `manual` (operator-authored via `feira allocation
155/// request …`), `ci-run` (non-PR CI driver), and `scheduled` (a
156/// cron-style emitter). The wire field stays `pub kind: String` on
157/// [`Requestor`] so operators can register their own kinds without a
158/// schema bump; this enum is the typed view future kind-keyed
159/// consumers (pool dashboards, LSP completion, audit-trail
160/// classifiers) sweep against.
161///
162/// Pre-lift the four canonical kinds existed only as `&'static str`
163/// literals at four scattered sites — the documentation header on
164/// [`Requestor::kind`], the [`crate::pool::PoolSelector::kinds`]
165/// docstring, the `tatara-github-watcher` allocation factory, and the
166/// per-test `kind: "github-pr".into()` fixtures. A rename of one
167/// canonical kind (e.g. `"github-pr"` → `"github-pull-request"`) had
168/// no compile-time link to the others, so the documentation drifted
169/// independently of the emitter, and the [`PoolSelector::matches`]
170/// kind-filter silently kept matching the old spelling forever. Post-
171/// lift the (canonical-name, typed-variant) pairing binds at ONE site
172/// ([`Self::as_str`]); the `From<RequestorKind> for String` bridge
173/// lets emitters compose `Requestor { kind: RequestorKind::GithubPr.into(), … }`
174/// so the four canonical strings stop appearing as bare `&'static str`
175/// literals at author sites.
176///
177/// Adding a fifth kind (e.g. `Slack` → `"slack"`, `Webhook` →
178/// `"webhook"`) lands at one [`Self::ALL`] entry + one [`Self::as_str`]
179/// arm — exhaustively checked by the compiler (the `[Self; 4]` array
180/// literal forces the arity) AND by the per-variant truth-table tests
181/// below.
182///
183/// Sibling closed-set `ALL`-keyed lifts across the crate:
184/// [`crate::receipt::ReceiptKind::ALL`] (the four substrate-emitted
185/// receipt kinds — direct shape peer, same open-wire + closed-view
186/// split), [`AllocationPhase::ALL`], [`crate::phase::ProcessPhase::ALL`],
187/// [`crate::signal::ProcessSignal::ALL`],
188/// [`crate::boundary::ConditionKind::ALL`],
189/// [`crate::lifetime::TeardownPolicy::ALL`],
190/// [`crate::lifetime::LifetimeKind::ALL`],
191/// [`crate::intent::IntentKind::ALL`],
192/// [`crate::lifetime_clock::TerminateReasonKind::ALL`].
193///
194/// Theory anchor: THEORY.md §III — the typescape; the substrate's own
195/// requestor kinds become a TYPE rather than four `&'static str`
196/// literals at every author + docstring + fixture site. THEORY.md
197/// §V.1 — knowable platform; the closed-set view turns "which kinds
198/// does the substrate actually emit" from a grep job into a method
199/// the compiler enforces exhaustively at every dispatch site.
200#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
201#[closed_set(via = "as_str", generate_unknown, display)]
202pub enum RequestorKind {
203    /// GitHub pull-request webhook — `tatara-github-watcher` stamps
204    /// this on every allocation built from a `PullRequestEvent`.
205    GithubPr,
206    /// Operator-authored allocation — `feira allocation request …`
207    /// and any hand-crafted CR.
208    Manual,
209    /// Non-PR CI driver — a pipeline run that wants an ephemeral env
210    /// without an associated pull request.
211    CiRun,
212    /// Cron-style scheduled emitter — periodic allocation creation
213    /// (e.g. nightly drift detection).
214    Scheduled,
215}
216
217impl RequestorKind {
218    /// The closed set of substrate-emitted requestor kinds — single
219    /// source of truth that drives the [`Self::from_str`] decode sweep
220    /// AND any future enumeration consumer (pool-selector classifiers,
221    /// dashboard completion, `tatara-check` kind enumeration). Adding
222    /// a fifth variant (e.g. `Slack` → `"slack"`) lands at one `ALL`
223    /// entry + one `as_str` arm — exhaustively checked by the compiler
224    /// (the `[Self; 4]` array literal forces the arity) AND by the
225    /// per-variant truth-table tests below.
226    pub const ALL: [Self; 4] = [Self::GithubPr, Self::Manual, Self::CiRun, Self::Scheduled];
227
228    /// Canonical kebab-case wire-format kind — the literal that lands
229    /// in [`Requestor::kind`] when this variant authors the request.
230    /// Pinned to four byte-exact strings the substrate has already
231    /// published (the `tatara-github-watcher` factory, the operator
232    /// fixtures in this file, the `PoolSelector.kinds` filter, the
233    /// CRD printcolumns) — renaming any one is a wire-format change,
234    /// not a typed-internal refactor, and the
235    /// `requestor_kind_canonical_names_pinned` truth-table test fails
236    /// first to keep the substrate honest. Used by [`std::fmt::Display`]
237    /// (single source of truth) and as the `String` projection that
238    /// `From<RequestorKind> for String` ([`Self::into`]) composes so
239    /// emitters can spell `Requestor { kind: RequestorKind::GithubPr.into(), … }`
240    /// without re-typing the canonical literal at every author site.
241    #[must_use]
242    pub const fn as_str(self) -> &'static str {
243        match self {
244            Self::GithubPr => "github-pr",
245            Self::Manual => "manual",
246            Self::CiRun => "ci-run",
247            Self::Scheduled => "scheduled",
248        }
249    }
250}
251
252// `impl FromStr for RequestorKind` + `impl tatara_lisp::ClosedSet for
253// RequestorKind` + `impl std::fmt::Display for RequestorKind` are
254// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
255// declaration above. `label` delegates to the inherent
256// `RequestorKind::as_str` via `#[closed_set(via = "as_str")]` so the
257// kebab-case wire-format projection stays load-bearing (matches the
258// `tatara-github-watcher` factory + the CRD printcolumns + the
259// `PoolSelector.kinds` filter verbatim) while generic `T: ClosedSet`
260// consumers reach the STABLE workspace-wide name (`label`). The
261// `display` flag emits the `f.write_str(self.as_str())` delegation
262// block — the substrate-wide closed-set-enum idiom's third piece —
263// at the same proc-macro site rather than a hand-rolled
264// `fmt::Display` block per implementor.
265
266// `pub struct UnknownRequestorKind(pub String)` is generated by
267// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
268// on the enum declaration above. The auto-derived label `"requestor kind"`
269// matches the prior hand-rolled `#[error("unknown requestor kind: {0}")]`
270// verbatim — pinned generically by clause (5) of
271// `tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>()` (called
272// from `requestor_kind_is_well_formed_closed_set` in the test module).
273// Symmetric to every sibling `Unknown*` error in this crate (e.g.
274// [`UnknownAllocationPhase`], [`crate::receipt::UnknownReceiptKind`],
275// [`crate::phase::UnknownPhase`], [`crate::lifetime::UnknownTeardownPolicy`]).
276
277impl From<RequestorKind> for String {
278    /// Composes [`RequestorKind::as_str`] into an owned `String` so
279    /// every `impl Into<String>` API surface (the `kind:` field
280    /// initializer on [`Requestor`] most notably) accepts the typed
281    /// variant transparently — the call site stays
282    /// `kind: RequestorKind::GithubPr.into()` and the typed → wire
283    /// bridge runs through ONE place. Sibling shape to
284    /// [`crate::receipt::ReceiptKind`]'s `From for String`.
285    fn from(k: RequestorKind) -> Self {
286        k.as_str().to_owned()
287    }
288}
289
290impl From<RequestorKind> for &'static str {
291    fn from(k: RequestorKind) -> Self {
292        k.as_str()
293    }
294}
295
296/// `EphemeralAllocation.status` — observed allocation state.
297#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
298#[serde(rename_all = "camelCase")]
299pub struct AllocationStatus {
300    /// Current lifecycle phase.
301    #[serde(default)]
302    pub phase: AllocationPhase,
303
304    /// When the phase last changed.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub phase_since: Option<DateTime<Utc>>,
307
308    /// Pool that owns the matched member. Set as soon as routing
309    /// resolves; not cleared on release (audit trail).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub bound_pool: Option<AllocationRef>,
312
313    /// The Process backing this allocation, if Bound.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub assigned_process: Option<AllocationRef>,
316
317    /// When the allocation was matched to a Process.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub allocated_at: Option<DateTime<Utc>>,
320
321    /// Wall-clock expiry derived from `spec.ttl` + `allocated_at`.
322    /// The pool reconciler force-returns the member at this point.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub expires_at: Option<DateTime<Utc>>,
325
326    /// Operator-visible message.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub message: Option<String>,
329
330    /// Standard Conditions.
331    #[serde(default)]
332    pub conditions: Vec<AllocationCondition>,
333}
334
335/// Allocation lifecycle phase.
336///
337/// Sibling closed-set lifts on the same `EphemeralAllocation` /
338/// `EphemeralPool` axis: [`crate::pool::ReplacementPolicy::ALL`],
339/// [`crate::pool::ReturnPolicy::ALL`]. Sibling closed-sets on the
340/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`],
341/// [`crate::lifetime::LifetimeKind::ALL`],
342/// [`crate::boundary::ConditionKind::ALL`],
343/// [`crate::intent::IntentKind::ALL`],
344/// [`crate::phase::ProcessPhase::ALL`],
345/// [`crate::signal::ProcessSignal::ALL`].
346#[derive(
347    Clone,
348    Copy,
349    Debug,
350    PartialEq,
351    Eq,
352    Hash,
353    Serialize,
354    Deserialize,
355    JsonSchema,
356    tatara_closed_set::DeriveClosedSet,
357)]
358#[serde(rename_all = "PascalCase")]
359#[closed_set(via = "as_str", generate_unknown, display)]
360pub enum AllocationPhase {
361    /// Admitted; pool selector matching not yet attempted.
362    Pending,
363    /// Routed to a pool but no `Free` member is available — queued.
364    Queued,
365    /// A pool member has been assigned + transitioned to Allocated.
366    Bound,
367    /// `expires_at` reached or requestor deleted; member is returning.
368    Releasing,
369    /// Released; the allocation is a permanent audit record.
370    Released,
371    /// No pool selector matched. The reconciler will retry on each
372    /// pool spec update; surfaced in status so operators see why.
373    NoMatchingPool,
374    /// Pool refused (e.g., `max_size` reached and no member can be
375    /// freed) — operator intervention needed.
376    Failed,
377}
378
379impl Default for AllocationPhase {
380    fn default() -> Self {
381        Self::Pending
382    }
383}
384
385impl AllocationPhase {
386    /// The closed set of allocation phases — single source of truth
387    /// that drives the `as_str` / Display / `FromStr` triad AND the
388    /// `is_terminal` / `needs_pool_routing` predicate pair the
389    /// allocation reconciler's observe/decide split dispatches on.
390    /// Adding an eighth variant lands at one `ALL` entry + one
391    /// `as_str` arm + one arm per predicate — exhaustively checked by
392    /// the compiler (the `[Self; 7]` array literal forces the arity)
393    /// and by the implication test
394    /// (`allocation_phase_terminal_excludes_routing`) so a new
395    /// variant can't claim to be both terminal AND routing-eligible.
396    pub const ALL: [Self; 7] = [
397        Self::Pending,
398        Self::Queued,
399        Self::Bound,
400        Self::Releasing,
401        Self::Released,
402        Self::NoMatchingPool,
403        Self::Failed,
404    ];
405
406    /// Canonical PascalCase wire-format projection — matches the
407    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
408    /// `enum:` enumeration the allocation reconciler stamps on the
409    /// `ephemeralallocations.tatara.pleme.io` schema. Pinned by
410    /// `allocation_phase_as_str_matches_serde` so a variant rename
411    /// can't drift between the typed surface, the CRD enum, the YAML
412    /// wire format AND any operator-facing diagnostic composed via
413    /// Display rather than a hard-coded literal that would silently
414    /// rot.
415    pub const fn as_str(self) -> &'static str {
416        match self {
417            Self::Pending => "Pending",
418            Self::Queued => "Queued",
419            Self::Bound => "Bound",
420            Self::Releasing => "Releasing",
421            Self::Released => "Released",
422            Self::NoMatchingPool => "NoMatchingPool",
423            Self::Failed => "Failed",
424        }
425    }
426
427    /// True iff the allocation has reached an absorbing state —
428    /// `Released` (clean audit record) or `Failed` (pool refused;
429    /// operator intervention needed). The allocation reconciler
430    /// short-circuits both phases to `NoOp` rather than re-running
431    /// the routing / heartbeat ladder against a settled record.
432    ///
433    /// Closed-set match (not `matches!`) so a future variant
434    /// triggers the compiler's exhaustiveness check at this site
435    /// rather than silently defaulting to `false` and letting a new
436    /// terminal phase fall through into pool rebinding. Paired with
437    /// `needs_pool_routing` they form the two-axis projection
438    /// `allocation_decide::AllocationConvergence::decide` matches
439    /// against — the impossible bucket `(true, true)` is pinned
440    /// empty by `allocation_phase_terminal_excludes_routing`.
441    pub const fn is_terminal(self) -> bool {
442        match self {
443            Self::Released | Self::Failed => true,
444            Self::Pending | Self::Queued | Self::Bound | Self::Releasing | Self::NoMatchingPool => {
445                false
446            }
447        }
448    }
449
450    /// True iff the allocation is on the routing path — the
451    /// reconciler still needs to resolve a target pool + look up a
452    /// free member. `Pending` (just admitted), `Queued` (matched
453    /// pool was full last tick), and `NoMatchingPool` (no selector
454    /// matched yet; retry on pool spec updates) all live here. The
455    /// settled non-terminal phases `Bound` (already matched) and
456    /// `Releasing` (being torn down) don't — they short-circuit to
457    /// the heartbeat / release ladder without re-resolving the pool.
458    ///
459    /// Closed-set match (not `matches!`) — same exhaustiveness
460    /// discipline as [`Self::is_terminal`]. Lifts the open-coded
461    /// `phase != Released && phase != Bound` gate that
462    /// `allocation_decide::AllocationConvergenceCtx::observe` used
463    /// to predicate pool resolution on, AND closes the latent gap
464    /// where `Failed` / `Releasing` (neither `Released` nor `Bound`)
465    /// would slip through to the routing branch — a `Failed`
466    /// allocation without a deletion timestamp could be silently
467    /// rebound to a fresh pool member, which is the opposite of
468    /// "operator intervention needed."
469    pub const fn needs_pool_routing(self) -> bool {
470        match self {
471            Self::Pending | Self::Queued | Self::NoMatchingPool => true,
472            Self::Bound | Self::Releasing | Self::Released | Self::Failed => false,
473        }
474    }
475}
476
477// `impl FromStr for AllocationPhase` + `impl tatara_lisp::ClosedSet for
478// AllocationPhase` + `impl std::fmt::Display for AllocationPhase` are
479// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
480// declaration above. `label` delegates to the inherent
481// `AllocationPhase::as_str` via `#[closed_set(via = "as_str")]` so the
482// PascalCase wire-format projection stays load-bearing (matches the serde
483// rename + the CRD `enum:` enumeration the allocation reconciler stamps
484// on the `ephemeralallocations.tatara.pleme.io` schema verbatim) while
485// generic `T: ClosedSet` consumers reach the STABLE workspace-wide name
486// (`label`). The `display` flag emits the `f.write_str(self.as_str())`
487// delegation block at the same proc-macro site rather than a
488// hand-rolled `fmt::Display` block per implementor.
489
490// `pub struct UnknownAllocationPhase(pub String)` is generated by
491// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
492// on the enum declaration above. The auto-derived label `"allocation phase"`
493// matches the prior hand-rolled `#[error("unknown allocation phase: {0}")]`
494// verbatim — pinned generically by clause (5) of
495// `tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>()` (called
496// from `allocation_phase_is_well_formed_closed_set` in the test module).
497// Symmetric to [`crate::pool::UnknownReplacementPolicy`],
498// [`crate::pool::UnknownReturnPolicy`],
499// [`crate::lifetime::UnknownTeardownPolicy`],
500// [`crate::boundary::UnknownConditionKind`], and
501// [`crate::phase::UnknownPhase`].
502
503/// Allocation Condition (same shape as PoolCondition for downstream
504/// uniformity).
505#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
506#[serde(rename_all = "camelCase")]
507pub struct AllocationCondition {
508    pub type_: String,
509    pub status: String,
510    pub reason: String,
511    pub message: String,
512    pub last_transition_time: DateTime<Utc>,
513}
514
515#[cfg(test)]
516mod tests {
517    // `FromStr` lives in scope at the test surface only — the derive
518    // emits `impl ::core::str::FromStr` via the full path so the lib
519    // body no longer reaches `FromStr` directly, but the cross-axis
520    // sweeps + the verbatim-echo contract tests call
521    // `AllocationPhase::from_str(bad)` / `bad.parse::<RequestorKind>()`.
522    use std::str::FromStr;
523
524    use super::*;
525
526    #[test]
527    fn requestor_minimum_shape_round_trips() {
528        let r = Requestor {
529            kind: "github-pr".into(),
530            repo: Some("pleme-io/akeyless-deployment".into()),
531            branch: Some("fix-something".into()),
532            pr_number: Some(123),
533            sha: Some("abc123def".into()),
534            pr_labels: vec!["needs-akeyless".into()],
535            actor: Some("drzln".into()),
536        };
537        let yaml = serde_yaml::to_string(&r).unwrap();
538        assert!(yaml.contains("kind: github-pr"));
539        assert!(yaml.contains("prNumber: 123"));
540        let back: Requestor = serde_yaml::from_str(&yaml).unwrap();
541        assert_eq!(back.kind, "github-pr");
542        assert_eq!(back.pr_number, Some(123));
543    }
544
545    #[test]
546    fn allocation_status_defaults_pending() {
547        let s = AllocationStatus::default();
548        assert_eq!(s.phase, AllocationPhase::Pending);
549        assert!(s.bound_pool.is_none());
550        assert!(s.assigned_process.is_none());
551    }
552
553    #[test]
554    fn allocation_phase_round_trips_via_serde() {
555        for p in [
556            AllocationPhase::Pending,
557            AllocationPhase::Queued,
558            AllocationPhase::Bound,
559            AllocationPhase::Releasing,
560            AllocationPhase::Released,
561            AllocationPhase::NoMatchingPool,
562            AllocationPhase::Failed,
563        ] {
564            let s = serde_yaml::to_string(&p).unwrap();
565            let back: AllocationPhase = serde_yaml::from_str(&s).unwrap();
566            assert_eq!(back, p);
567        }
568    }
569
570    // ── closed-set algebra contracts for AllocationPhase
571    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
572
573    /// `ALL` is the source of truth — pin its closure so a variant
574    /// added without an `ALL` entry fails here via the uniqueness
575    /// check before drifting `FromStr` or the sweep tests below. The
576    /// arity is asserted by the `[Self; 7]` array type itself.
577    ///
578    /// Structural well-formedness of [`AllocationPhase`] as a
579    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
580    /// testkit lift that pins all three structural invariants
581    /// (`ALL` is non-empty, every variant round-trips through
582    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
583    /// outside the closed set) at ONE call site. Replaces the hand-
584    /// derived `allocation_phase_all_is_unique_and_complete` +
585    /// `allocation_phase_roundtrip_via_as_str` + the empty-input arm
586    /// of `unknown_allocation_phase_errors`. `FromStr` delegates to
587    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
588    /// helper exercises the same code path the allocation reconciler
589    /// hits when parsing a CRD `enum:`-validated value back to the
590    /// typed phase.
591    #[test]
592    fn allocation_phase_is_well_formed_closed_set() {
593        tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>();
594    }
595
596    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
597    /// output verbatim for every variant. A future variant rename
598    /// (or an `as_str` arm typo) lands here at one site, instead of
599    /// drifting between the typed surface, the CRD enum, the YAML
600    /// wire format, and the operator-facing reason strings the
601    /// reconciler stamps via Display.
602    #[test]
603    fn allocation_phase_as_str_matches_serde() {
604        for phase in AllocationPhase::ALL {
605            let serialized = serde_json::to_string(&phase).expect("serialize");
606            let unquoted = serialized
607                .trim_start_matches('"')
608                .trim_end_matches('"')
609                .to_string();
610            assert_eq!(
611                unquoted,
612                phase.as_str(),
613                "as_str drift for {phase:?}: as_str={} serde={unquoted}",
614                phase.as_str()
615            );
616        }
617    }
618
619    /// The Display impl IS `as_str` — pinning this lets future
620    /// callers reach for either projection without drift.
621    #[test]
622    fn allocation_phase_display_matches_as_str() {
623        for phase in AllocationPhase::ALL {
624            assert_eq!(phase.to_string(), phase.as_str());
625        }
626    }
627
628    /// `FromStr` rejects strings that aren't in the canonical
629    /// projection — lowercased / typo / unrelated — and the error
630    /// echoes the input verbatim so the operator-facing diagnostic
631    /// carries the offending value, not a normalized form. The
632    /// empty-input arm is pinned by
633    /// [`allocation_phase_is_well_formed_closed_set`] via the
634    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
635    /// verbatim-echo contract on the [`UnknownAllocationPhase`]
636    /// newtype, which the trait's `make_unknown` can't see.
637    #[test]
638    fn unknown_allocation_phase_errors() {
639        for bad in [
640            "pending",
641            "BOUND",
642            "no-matching-pool",
643            "release",
644            "failed_state",
645            "Reaped",
646        ] {
647            let err = AllocationPhase::from_str(bad).unwrap_err();
648            assert_eq!(err.0, bad, "error payload should echo input verbatim");
649        }
650    }
651
652    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
653    /// documented per-variant disposition. `Released` + `Failed` are
654    /// terminal (absorbing); `Pending` / `Queued` / `NoMatchingPool`
655    /// need pool routing; `Bound` / `Releasing` are settled-but-not-
656    /// terminal (heartbeat / release ladder).
657    #[test]
658    fn allocation_phase_predicate_truth_tables() {
659        assert!(!AllocationPhase::Pending.is_terminal());
660        assert!(AllocationPhase::Pending.needs_pool_routing());
661
662        assert!(!AllocationPhase::Queued.is_terminal());
663        assert!(AllocationPhase::Queued.needs_pool_routing());
664
665        assert!(!AllocationPhase::Bound.is_terminal());
666        assert!(!AllocationPhase::Bound.needs_pool_routing());
667
668        assert!(!AllocationPhase::Releasing.is_terminal());
669        assert!(!AllocationPhase::Releasing.needs_pool_routing());
670
671        assert!(AllocationPhase::Released.is_terminal());
672        assert!(!AllocationPhase::Released.needs_pool_routing());
673
674        assert!(!AllocationPhase::NoMatchingPool.is_terminal());
675        assert!(AllocationPhase::NoMatchingPool.needs_pool_routing());
676
677        assert!(AllocationPhase::Failed.is_terminal());
678        assert!(!AllocationPhase::Failed.needs_pool_routing());
679    }
680
681    /// IMPLICATION CONTRACT: `is_terminal → !needs_pool_routing`. A
682    /// terminal allocation cannot also be routing-eligible — that's
683    /// the bug the typed projection closes (a `Failed` allocation
684    /// that's neither `Released` nor `Bound` would otherwise slip
685    /// through the open-coded gate in `observe` and try to rebind to
686    /// a pool member). A future variant that flipped both predicates
687    /// true would fail here, forcing the author to flip one or
688    /// extend the consumer dispatch site in
689    /// `tatara-pool-reconciler::allocation_decide` deliberately
690    /// rather than letting an impossible state slip in.
691    #[test]
692    fn allocation_phase_terminal_excludes_routing() {
693        for phase in AllocationPhase::ALL {
694            assert!(
695                !(phase.is_terminal() && phase.needs_pool_routing()),
696                "{phase:?} is both terminal and routing-eligible",
697            );
698        }
699    }
700
701    /// DEFAULT-AGREEMENT CONTRACT: `AllocationPhase::default()` is
702    /// `Pending` — the entry state, neither terminal nor settled —
703    /// and it lives on the routing path. A future default-variant
704    /// rename without flipping the predicates fails here.
705    #[test]
706    fn allocation_phase_default_is_pending_and_routes() {
707        let d = AllocationPhase::default();
708        assert_eq!(d, AllocationPhase::Pending);
709        assert!(!d.is_terminal());
710        assert!(d.needs_pool_routing());
711    }
712
713    // ── RequestorKind closed-set truth-table ─────────────────────────
714
715    /// Structural well-formedness of [`RequestorKind`] as a
716    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
717    /// testkit lift that pins all three structural invariants
718    /// (`ALL` is non-empty, every variant round-trips through
719    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
720    /// outside the closed set) at ONE call site. Replaces the hand-
721    /// derived `requestor_kind_all_enumerates_each_variant_exactly_once`
722    /// + `requestor_kind_from_str_round_trips_canonical_names` + the
723    /// empty-input arm of `requestor_kind_from_str_rejects_open_kinds`.
724    /// `FromStr` delegates to
725    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
726    /// exercises the same code path
727    /// [`Requestor::known_kind`]'s `Option<RequestorKind>` collapse
728    /// rides on when classifying inbound `Requestor.kind` strings. The
729    /// arity is asserted by the `[Self; 4]` array type itself.
730    #[test]
731    fn requestor_kind_is_well_formed_closed_set() {
732        tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>();
733    }
734
735    /// Byte-exact wire-format pin — renaming any of these is a wire-
736    /// format change (the `tatara-github-watcher` emitter, the CRD
737    /// printcolumns, the `PoolSelector.kinds` filter strings, the
738    /// per-test `kind: "…".into()` fixtures all depend on these
739    /// literals), not a typed-internal refactor.
740    #[test]
741    fn requestor_kind_canonical_names_pinned() {
742        assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
743        assert_eq!(RequestorKind::Manual.as_str(), "manual");
744        assert_eq!(RequestorKind::CiRun.as_str(), "ci-run");
745        assert_eq!(RequestorKind::Scheduled.as_str(), "scheduled");
746    }
747
748    /// `FromStr` rejects strings that aren't in the canonical
749    /// projection — lowercased-mismatch / typo / unrelated — and the
750    /// error echoes the input verbatim so the operator-facing
751    /// diagnostic carries the offending value, not a normalized form.
752    /// The schema is open at the wire layer (operators MAY register
753    /// new kinds and `Requestor::known_kind` collapses them to
754    /// `None`), but the closed-set view is byte-exact. The empty-input
755    /// arm is pinned by [`requestor_kind_is_well_formed_closed_set`]
756    /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
757    /// the verbatim-echo contract on the [`UnknownRequestorKind`]
758    /// newtype, which the trait's `make_unknown` can't see.
759    #[test]
760    fn requestor_kind_from_str_rejects_open_kinds() {
761        for bad in [
762            "github_pr",
763            "GithubPr",
764            "operator-custom-kind",
765            "ci_run",
766            "Scheduled",
767        ] {
768            let err = bad.parse::<RequestorKind>().unwrap_err();
769            assert_eq!(err, UnknownRequestorKind(bad.to_string()));
770        }
771    }
772
773    /// The Display impl IS `as_str` — pinning this lets future
774    /// callers reach for either projection without drift (Display is
775    /// what operator-facing diagnostics compose against).
776    #[test]
777    fn requestor_kind_display_delegates_to_as_str() {
778        for k in RequestorKind::ALL {
779            assert_eq!(format!("{k}"), k.as_str());
780        }
781    }
782
783    /// The `String` projection that `From<RequestorKind> for String`
784    /// ([`RequestorKind::into`]) composes is byte-equal to `as_str`.
785    /// This is the typed → wire bridge — emitters spell
786    /// `kind: RequestorKind::GithubPr.into()` and the canonical
787    /// literal is materialized at ONE place.
788    #[test]
789    fn requestor_kind_into_string_matches_as_str() {
790        for k in RequestorKind::ALL {
791            let s: String = k.into();
792            assert_eq!(s, k.as_str());
793        }
794    }
795
796    /// The typed → wire → typed round-trip: composing a `Requestor`
797    /// with `kind: RequestorKind::X.into()` produces an object whose
798    /// `known_kind()` decodes back to `X`. Pins the bridge invariant
799    /// at the `Requestor` boundary, not just at `RequestorKind`.
800    #[test]
801    fn known_kind_decodes_built_requestors() {
802        for k in RequestorKind::ALL {
803            let r = Requestor {
804                kind: k.into(),
805                repo: None,
806                branch: None,
807                pr_number: None,
808                sha: None,
809                pr_labels: vec![],
810                actor: None,
811            };
812            assert_eq!(r.known_kind(), Some(k), "round-trip failed for {k:?}");
813        }
814    }
815
816    /// Open-by-design: a custom operator-registered kind still
817    /// stamps a valid `Requestor` (no schema rejection), it just
818    /// doesn't project through the closed-set typed view. Mirrors
819    /// `ReceiptEnvelope::known_kind`'s open-kind posture.
820    #[test]
821    fn known_kind_returns_none_for_open_kinds() {
822        let r = Requestor {
823            kind: "operator-custom-kind".into(),
824            repo: None,
825            branch: None,
826            pr_number: None,
827            sha: None,
828            pr_labels: vec![],
829            actor: None,
830        };
831        assert_eq!(r.known_kind(), None);
832    }
833
834    /// The four canonical literals match every previously-published
835    /// fixture / doc anchor in this crate — pinning the bridge to
836    /// existing call sites so any drift fails here before the next
837    /// release ships.
838    #[test]
839    fn requestor_kind_matches_existing_fixture_literals() {
840        // The `requestor_minimum_shape_round_trips` fixture above
841        // composes `kind: "github-pr".into()` verbatim.
842        assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
843        // The `allocation_spec_omits_optional_fields` fixture below
844        // composes `kind: "manual".into()` verbatim.
845        assert_eq!(RequestorKind::Manual.as_str(), "manual");
846    }
847
848    // Per-implementor `unknown_X_message_matches_substrate_convention`
849    // tests removed — clause (5) of
850    // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
851    // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
852    // generically (called above on `RequestorKind` /
853    // `AllocationPhase` through their `*_is_well_formed_closed_set`
854    // sites). The `SET_LABEL` projection is pinned independently by
855    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
856    // together the two contracts guarantee the operator-facing
857    // diagnostic without needing per-enum literal pins.
858
859    #[test]
860    fn allocation_spec_omits_optional_fields() {
861        let s = AllocationSpec {
862            pool_ref: None,
863            requestor: Requestor {
864                kind: "manual".into(),
865                repo: None,
866                branch: None,
867                pr_number: None,
868                sha: None,
869                pr_labels: vec![],
870                actor: None,
871            },
872            ttl: None,
873            note: None,
874        };
875        let yaml = serde_yaml::to_string(&s).unwrap();
876        assert!(!yaml.contains("poolRef"));
877        assert!(!yaml.contains("ttl"));
878        assert!(!yaml.contains("note"));
879    }
880}