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}