tatara_process/lifetime.rs
1//! Process lifetime — Permanent (re-converging) vs Ephemeral (auto-SIGTERM
2//! on Attested / TTL / Failed).
3//!
4//! The wire shape follows the same "exactly-one-optional-field" pattern as
5//! `Intent` — one tagged-union idiom across the typescape.
6//!
7//! Lisp authoring:
8//! ```lisp
9//! :lifetime (:permanent)
10//! :lifetime (:ephemeral :ttl "1h"
11//! :teardown OnAttested
12//! :max-concurrent 1)
13//! ```
14//!
15//! Default = `Permanent` — every existing Process keeps its current behavior.
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::export::ExportSpec;
21use crate::phase::ProcessPhase;
22
23/// Lifetime slot on `ProcessSpec`. Exactly one variant should be populated;
24/// when both are unset the resolver returns `Permanent`.
25#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct Lifetime {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub permanent: Option<PermanentLifetime>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub ephemeral: Option<EphemeralLifetime>,
32}
33
34/// Resolved enum view used by the reconciler.
35#[derive(Clone, Debug)]
36pub enum LifetimeVariant<'a> {
37 Permanent(&'a PermanentLifetime),
38 Ephemeral(&'a EphemeralLifetime),
39}
40
41impl LifetimeVariant<'_> {
42 /// Reverse projection — every borrowed variant knows its
43 /// `LifetimeKind` discriminator. Pairs with `LifetimeKind::select`
44 /// so `LifetimeKind::select(lifetime).map(|v| v.kind())` round-trips
45 /// the closed set on the populated side; pinned by
46 /// `lifetime_kind_round_trips_through_variant_kind`.
47 pub fn kind(&self) -> LifetimeKind {
48 match self {
49 Self::Permanent(_) => LifetimeKind::Permanent,
50 Self::Ephemeral(_) => LifetimeKind::Ephemeral,
51 }
52 }
53
54 /// Projection to the inner `EphemeralLifetime` iff this variant is
55 /// `Ephemeral`. ONE site owns the "give me only the ephemeral case"
56 /// shape every consumer of the lifetime clock previously hand-rolled
57 /// via `let Ok(LifetimeVariant::Ephemeral(e)) = ...`; pinned by
58 /// `lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral`.
59 pub fn as_ephemeral(&self) -> Option<&EphemeralLifetime> {
60 match self {
61 Self::Ephemeral(e) => Some(e),
62 Self::Permanent(_) => None,
63 }
64 }
65
66 /// Projection to the inner `PermanentLifetime` iff this variant is
67 /// `Permanent`. Symmetric counterpart to [`Self::as_ephemeral`].
68 pub fn as_permanent(&self) -> Option<&PermanentLifetime> {
69 match self {
70 Self::Permanent(p) => Some(p),
71 Self::Ephemeral(_) => None,
72 }
73 }
74}
75
76/// Closed-set discriminator over `Lifetime`'s two tagged-union slots.
77/// Single source of truth that drives `Lifetime::variant`'s ambiguity
78/// resolver, the reverse `LifetimeVariant::kind` projection, and any
79/// `select`-style routing. Adding a third lifetime variant (e.g. a
80/// future `Burst` slot for budget-capped non-TTL lifetimes) lands at
81/// one `ALL` entry + one `as_str` arm + one `select` arm + one
82/// `LifetimeVariant::kind` arm — exhaustively checked by the compiler.
83///
84/// Sibling closed-set lift to [`crate::intent::IntentKind`] on the
85/// same `ProcessSpec` axis. Same shape, smaller closed set, same
86/// compounding pattern.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88pub enum LifetimeKind {
89 Permanent,
90 Ephemeral,
91}
92
93impl LifetimeKind {
94 /// The closed set of lifetime kinds — single source of truth that
95 /// drives `Lifetime::variant`'s sweep so a variant added without
96 /// an `ALL` entry never reaches the resolver.
97 pub const ALL: [Self; 2] = [Self::Permanent, Self::Ephemeral];
98
99 /// Canonical lower-case wire-format key — matches the serde
100 /// `rename_all = "camelCase"` field name on `Lifetime`. Pinned by
101 /// `lifetime_kind_as_str_matches_lifetime_field_name`.
102 pub const fn as_str(self) -> &'static str {
103 match self {
104 Self::Permanent => "permanent",
105 Self::Ephemeral => "ephemeral",
106 }
107 }
108
109 /// Project a `Lifetime` borrow into the optional typed variant view
110 /// for this kind. Returns `None` iff the matching slot is `None`.
111 /// Composes the closed-set sweep `Lifetime::variant` loops over.
112 pub fn select<'a>(self, lifetime: &'a Lifetime) -> Option<LifetimeVariant<'a>> {
113 match self {
114 Self::Permanent => lifetime.permanent.as_ref().map(LifetimeVariant::Permanent),
115 Self::Ephemeral => lifetime.ephemeral.as_ref().map(LifetimeVariant::Ephemeral),
116 }
117 }
118}
119
120#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
121pub enum LifetimeError {
122 #[error("lifetime has multiple variants set; at most one required")]
123 Ambiguous,
124}
125
126impl Lifetime {
127 /// True when no variant is set — treated as `Permanent` by the resolver.
128 pub fn is_default(&self) -> bool {
129 self.permanent.is_none() && self.ephemeral.is_none()
130 }
131
132 /// Resolve to a variant view. Empty resolves to `Permanent` (a static
133 /// borrow on the embedded `DEFAULT_PERMANENT`); ambiguous (both set) is
134 /// an error.
135 ///
136 /// Sweeps over `LifetimeKind::ALL` so a third variant added with an
137 /// `ALL` entry is structurally honored at this site — no parallel
138 /// `is_some()` count, no per-variant if-let chain.
139 pub fn variant(&self) -> Result<LifetimeVariant<'_>, LifetimeError> {
140 use crate::tagged_union::{resolve, ResolveError};
141 match resolve(LifetimeKind::ALL.into_iter().map(|k| k.select(self))) {
142 Ok(v) => Ok(v),
143 Err(ResolveError::None) => Ok(LifetimeVariant::Permanent(&DEFAULT_PERMANENT)),
144 Err(ResolveError::Many) => Err(LifetimeError::Ambiguous),
145 }
146 }
147
148 /// True iff `ephemeral` is set.
149 pub fn is_ephemeral(&self) -> bool {
150 self.ephemeral.is_some()
151 }
152}
153
154const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
155
156/// Permanent lifetime — the existing Process behavior. SIGHUP re-converges;
157/// SIGTERM terminates only on explicit operator action.
158#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
159#[serde(rename_all = "camelCase")]
160pub struct PermanentLifetime {}
161
162/// Ephemeral lifetime — Process auto-terminates per `teardown_policy`.
163///
164/// Phase semantics:
165/// - On `Attested` with `teardown_policy ∈ {OnAttested, Always}`:
166/// reconciler delivers SIGTERM, Process drives Exiting → Zombie → Reaped.
167/// - On `Failed` with `teardown_policy ∈ {OnFailed, Always}`:
168/// same. Otherwise Process stays at Failed for forensic inspection.
169/// - `ttl` is a `humantime` duration (`"1h"`, `"30m"`) checked at every
170/// reconcile loop tick. TTL expiry while in any non-terminal phase
171/// forces SIGTERM regardless of `teardown_policy`.
172#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
173#[serde(rename_all = "camelCase")]
174pub struct EphemeralLifetime {
175 /// `humantime`-parseable duration from `phaseSince(Forking)` after
176 /// which the Process is force-SIGTERM'd.
177 #[serde(default = "default_ttl")]
178 pub ttl: String,
179
180 /// When the Process auto-terminates.
181 #[serde(default)]
182 pub teardown_policy: TeardownPolicy,
183
184 /// Cluster-wide concurrency budget across ephemeral Processes that
185 /// share the same `spec.identity.name_override` / chart_ref.
186 /// `0` = no cap. Enforced by the reconciler before transitioning out
187 /// of `Pending`.
188 #[serde(default = "default_max_concurrent")]
189 pub max_concurrent: u32,
190
191 /// Declared exports — what artifacts survive teardown and where
192 /// they flow. Empty (default) = nothing survives, matching the
193 /// "ephemeral leaves no trace" posture. Each `ExportSpec` is
194 /// independently triggered during the reconciler's `Releasing`
195 /// phase against the terminal `ProcessPhase` reached.
196 ///
197 /// See [`crate::export`] for the full type. All exports flow
198 /// through the pleme-io Vector + NATS layer — there is no
199 /// per-spec ad-hoc sink.
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub exports: Vec<ExportSpec>,
202}
203
204impl EphemeralLifetime {
205 /// True iff any declared export's [`crate::export::ExportTrigger`]
206 /// fires for the given terminal-reached phase. The reconciler
207 /// uses this to decide whether to route `Attested`/`Failed`
208 /// through `Releasing` (the export window) or skip straight to
209 /// `Exiting`/`Zombie`.
210 ///
211 /// Returns `false` when the export list is empty or no trigger
212 /// matches — both cases collapse to the existing teardown path.
213 pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
214 self.exports.iter().any(|e| e.when.fires_on(phase))
215 }
216
217 /// Iterate over the exports whose trigger fires on `phase`.
218 /// The reconciler's `handle_releasing` consumes this to emit
219 /// one tatara-export-worker Job per surviving spec.
220 pub fn applicable_exports(
221 &self,
222 phase: ProcessPhase,
223 ) -> impl Iterator<Item = &ExportSpec> + '_ {
224 self.exports.iter().filter(move |e| e.when.fires_on(phase))
225 }
226}
227
228impl Default for EphemeralLifetime {
229 fn default() -> Self {
230 Self {
231 ttl: default_ttl(),
232 teardown_policy: TeardownPolicy::default(),
233 max_concurrent: default_max_concurrent(),
234 exports: Vec::new(),
235 }
236 }
237}
238
239fn default_ttl() -> String {
240 "1h".to_string()
241}
242fn default_max_concurrent() -> u32 {
243 1
244}
245
246/// When an ephemeral Process self-terminates.
247///
248/// Aligns with `ProcessPhase` (`Attested` / `Failed`) rather than borrowing
249/// foreign success/failure language — typed phases are the source of truth.
250#[derive(
251 Clone,
252 Copy,
253 Debug,
254 PartialEq,
255 Eq,
256 Hash,
257 Serialize,
258 Deserialize,
259 JsonSchema,
260 Default,
261 tatara_closed_set::DeriveClosedSet,
262)]
263#[serde(rename_all = "PascalCase")]
264#[closed_set(via = "as_str", display, generate_unknown)]
265pub enum TeardownPolicy {
266 /// SIGTERM as soon as the Process reaches `Attested` or `Failed`.
267 #[default]
268 Always,
269 /// SIGTERM only on `Attested`. Leave `Failed` Processes for inspection.
270 OnAttested,
271 /// SIGTERM only on `Failed`. Leave `Attested` Processes running until
272 /// TTL or explicit operator SIGTERM.
273 OnFailed,
274 /// Never auto-terminate (TTL still applies).
275 Never,
276}
277
278impl TeardownPolicy {
279 /// The closed set of teardown policies — single source of truth that
280 /// drives the `as_str` / Display / `FromStr` triad and the typed
281 /// `should_teardown_on` dispatch over `ProcessPhase`. Adding a fifth
282 /// variant lands at one `ALL` entry + one `as_str` arm + one
283 /// `should_teardown_on` arm — exhaustively checked by the compiler
284 /// (the `[Self; 4]` array literal forces the arity).
285 ///
286 /// Sibling closed-set lifts on the same `ProcessSpec` axis:
287 /// [`super::intent::IntentKind::ALL`], [`super::LifetimeKind::ALL`],
288 /// [`crate::boundary::ConditionKind::ALL`],
289 /// [`crate::phase::ProcessPhase::ALL`],
290 /// [`crate::signal::ProcessSignal::ALL`].
291 pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
292
293 /// Canonical PascalCase wire-format projection — matches the serde
294 /// `rename_all = "PascalCase"` output verbatim. Used by Display
295 /// (single source of truth), by `FromStr` to identify the variant
296 /// from its annotation / status-field representation, and by
297 /// operator-facing reason strings the reconciler stamps without
298 /// reaching for `{:?}` Debug formatting. Pinned by
299 /// `teardown_policy_as_str_matches_serde`.
300 pub const fn as_str(self) -> &'static str {
301 match self {
302 Self::Always => "Always",
303 Self::OnAttested => "OnAttested",
304 Self::OnFailed => "OnFailed",
305 Self::Never => "Never",
306 }
307 }
308
309 /// True iff, given a `ProcessPhase`, this policy says "tear down."
310 /// ONE typed dispatch over the typed phase enum that replaces the
311 /// pair of hand-rolled `matches!(self, Self::Always | Self::OnX)`
312 /// predicates `lifetime_clock::evaluate` previously branched on.
313 /// Non-terminal phases (`Pending` / `Forking` / `Execing` / `Running`
314 /// / `Reconverging` / `Releasing` / `Exiting` / `Zombie` / `Reaped`)
315 /// always return `false` — teardown is a terminal-phase decision.
316 ///
317 /// The legacy [`Self::should_teardown_on_attested`] /
318 /// [`Self::should_teardown_on_failed`] predicates remain as thin
319 /// delegates so existing call sites keep their narrow signatures;
320 /// the truth table is pinned by
321 /// `teardown_policy_legacy_predicates_delegate_to_phase_dispatch`.
322 pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
323 match phase {
324 ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
325 ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
326 ProcessPhase::Pending
327 | ProcessPhase::Forking
328 | ProcessPhase::Execing
329 | ProcessPhase::Running
330 | ProcessPhase::Reconverging
331 | ProcessPhase::Releasing
332 | ProcessPhase::Exiting
333 | ProcessPhase::Zombie
334 | ProcessPhase::Reaped => false,
335 }
336 }
337
338 /// Thin delegate to [`Self::should_teardown_on`] for the `Attested`
339 /// case — kept so existing call sites (notably the truth-table
340 /// test in this module) keep their narrow signature without
341 /// reaching for the typed-phase variant.
342 pub const fn should_teardown_on_attested(self) -> bool {
343 self.should_teardown_on(ProcessPhase::Attested)
344 }
345
346 /// Symmetric delegate to [`Self::should_teardown_on`] for the
347 /// `Failed` case.
348 pub const fn should_teardown_on_failed(self) -> bool {
349 self.should_teardown_on(ProcessPhase::Failed)
350 }
351}
352
353// `impl fmt::Display for TeardownPolicy` + `impl FromStr for
354// TeardownPolicy` + `impl tatara_lisp::ClosedSet for TeardownPolicy` +
355// `pub struct UnknownTeardownPolicy(pub String)` are generated by
356// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
357// "as_str", display, generate_unknown)]` on the enum declaration above.
358// The auto-derived label `"teardown policy"` matches the prior hand-
359// rolled `#[error("unknown teardown policy: {0}")]` verbatim. The
360// inherent `as_str` projection stays load-bearing — the PascalCase
361// wire-format that matches the serde rename + the reconciler's reason-
362// string emission verbatim — while the trait method `label` gives
363// generic consumers a STABLE name across the 36+ workspace-wide
364// closed-set implementors.
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn default_lifetime_resolves_to_permanent() {
372 let l = Lifetime::default();
373 assert!(l.is_default());
374 assert!(!l.is_ephemeral());
375 assert!(matches!(
376 l.variant().unwrap(),
377 LifetimeVariant::Permanent(_)
378 ));
379 }
380
381 #[test]
382 fn ephemeral_set_resolves() {
383 let l = Lifetime {
384 ephemeral: Some(EphemeralLifetime::default()),
385 ..Lifetime::default()
386 };
387 assert!(l.is_ephemeral());
388 match l.variant().unwrap() {
389 LifetimeVariant::Ephemeral(e) => {
390 assert_eq!(e.ttl, "1h");
391 assert_eq!(e.teardown_policy, TeardownPolicy::Always);
392 assert_eq!(e.max_concurrent, 1);
393 }
394 other => panic!("expected ephemeral, got {other:?}"),
395 }
396 }
397
398 #[test]
399 fn ambiguous_lifetime_errors() {
400 let l = Lifetime {
401 permanent: Some(PermanentLifetime {}),
402 ephemeral: Some(EphemeralLifetime::default()),
403 };
404 assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
405 }
406
407 #[test]
408 fn teardown_policy_dispatch() {
409 assert!(TeardownPolicy::Always.should_teardown_on_attested());
410 assert!(TeardownPolicy::Always.should_teardown_on_failed());
411 assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
412 assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
413 assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
414 assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
415 assert!(!TeardownPolicy::Never.should_teardown_on_attested());
416 assert!(!TeardownPolicy::Never.should_teardown_on_failed());
417 }
418
419 // ── closed-set algebra for TeardownPolicy (ALL × as_str × FromStr ×
420 // should_teardown_on(phase)) ─
421
422 /// Structural well-formedness of [`TeardownPolicy`] as a
423 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
424 /// testkit lift that pins all three structural invariants (`ALL`
425 /// is non-empty, every variant round-trips through `label ↔
426 /// parse_label`, labels are pairwise distinct, `""` is outside the
427 /// closed set) at ONE call site. Replaces the hand-derived
428 /// `teardown_policy_all_is_unique_and_complete` +
429 /// `teardown_policy_roundtrip_via_as_str` + the empty-input arm of
430 /// `unknown_teardown_policy_errors`. `FromStr` delegates to
431 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
432 /// exercises the same code path the reconciler hits when parsing a
433 /// CRD `enum:`-validated value back to the typed policy.
434 #[test]
435 fn teardown_policy_is_well_formed_closed_set() {
436 tatara_closed_set::assert_closed_set_well_formed::<TeardownPolicy>();
437 }
438
439 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
440 /// output verbatim for every variant. A future variant rename
441 /// (or an `as_str` arm typo) lands here at one site. The reason
442 /// string `lifetime_clock::evaluate` stamps reaches for the same
443 /// projection via `Display`, so a Debug-vs-canonical drift would
444 /// surface here, not in operator-facing reason strings.
445 #[test]
446 fn teardown_policy_as_str_matches_serde() {
447 for policy in TeardownPolicy::ALL {
448 let serialized = serde_json::to_string(&policy)
449 .expect("TeardownPolicy serializes")
450 .trim_matches('"')
451 .to_string();
452 assert_eq!(
453 policy.as_str(),
454 serialized,
455 "as_str() must match serde output for {policy:?}",
456 );
457 }
458 }
459
460 /// The Display impl IS `as_str` — pinning this lets future
461 /// callers (notably `lifetime_clock::evaluate`'s reason string)
462 /// reach for either projection without drift.
463 #[test]
464 fn teardown_policy_display_matches_as_str() {
465 for policy in TeardownPolicy::ALL {
466 assert_eq!(policy.to_string(), policy.as_str());
467 }
468 }
469
470 /// `FromStr` rejects strings that aren't in the canonical
471 /// projection — lowercased / typo / unrelated — and the error
472 /// echoes the input verbatim so the operator-facing diagnostic
473 /// carries the offending value, not a normalized form. The
474 /// empty-input arm is pinned by
475 /// [`teardown_policy_is_well_formed_closed_set`] via the
476 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
477 /// verbatim-echo contract on the [`UnknownTeardownPolicy`]
478 /// newtype, which the trait's `make_unknown` can't see.
479 #[test]
480 fn unknown_teardown_policy_errors() {
481 use std::str::FromStr;
482 for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
483 let err = TeardownPolicy::from_str(bad).unwrap_err();
484 assert_eq!(err.0, bad, "error payload should echo input verbatim");
485 }
486 }
487
488 /// TRUTH-TABLE CONTRACT: `should_teardown_on(phase)` agrees with
489 /// the documented (policy, phase) → bool table for every variant
490 /// at every typed phase. The two terminal phases (Attested,
491 /// Failed) carry the policy-specific result; every non-terminal
492 /// phase returns `false`. The closed-set sweep over both
493 /// `TeardownPolicy::ALL` and `ProcessPhase::ALL` means a new
494 /// variant in either enum reaches this test by iteration — no
495 /// per-test array maintenance.
496 #[test]
497 fn teardown_policy_should_teardown_on_truth_table() {
498 for policy in TeardownPolicy::ALL {
499 for phase in ProcessPhase::ALL {
500 let expected = match phase {
501 ProcessPhase::Attested => {
502 matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
503 }
504 ProcessPhase::Failed => {
505 matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
506 }
507 _ => false,
508 };
509 assert_eq!(
510 policy.should_teardown_on(phase),
511 expected,
512 "should_teardown_on({policy:?}, {phase:?}) drift",
513 );
514 }
515 }
516 }
517
518 /// DELEGATION CONTRACT: the legacy `should_teardown_on_attested` /
519 /// `should_teardown_on_failed` predicates agree with the typed
520 /// `should_teardown_on(phase)` dispatch they delegate to, for
521 /// every variant. A regression that re-introduces an inline
522 /// `matches!` in either legacy predicate fails here the moment
523 /// `should_teardown_on` is the source of truth.
524 #[test]
525 fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
526 for policy in TeardownPolicy::ALL {
527 assert_eq!(
528 policy.should_teardown_on_attested(),
529 policy.should_teardown_on(ProcessPhase::Attested),
530 "Attested delegate drift for {policy:?}",
531 );
532 assert_eq!(
533 policy.should_teardown_on_failed(),
534 policy.should_teardown_on(ProcessPhase::Failed),
535 "Failed delegate drift for {policy:?}",
536 );
537 }
538 }
539
540 #[test]
541 fn serde_round_trip_ephemeral() {
542 let l = Lifetime {
543 ephemeral: Some(EphemeralLifetime {
544 ttl: "30m".into(),
545 teardown_policy: TeardownPolicy::OnAttested,
546 max_concurrent: 4,
547 exports: vec![],
548 }),
549 ..Lifetime::default()
550 };
551 let yaml = serde_yaml::to_string(&l).unwrap();
552 assert!(yaml.contains("ttl: 30m"));
553 assert!(yaml.contains("teardownPolicy: OnAttested"));
554 // Empty exports skip-serialize — explicit zero-trace default.
555 assert!(!yaml.contains("exports"));
556 let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
557 assert!(back.is_ephemeral());
558 assert!(back.ephemeral.unwrap().exports.is_empty());
559 }
560
561 #[test]
562 fn applicable_exports_filters_by_trigger() {
563 use crate::export::{
564 ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
565 VectorChannel,
566 };
567 let spec_attested = ExportSpec {
568 source: ArtifactSource {
569 receipts: Some(ReceiptsSource::default()),
570 ..ArtifactSource::default()
571 },
572 channel: VectorChannel {
573 http_event: Some(HttpEventChannel {
574 endpoint: None,
575 signal_type: "receipt".into(),
576 }),
577 ..VectorChannel::default()
578 },
579 when: ExportTrigger::OnAttested,
580 experiment_id_override: None,
581 };
582 let spec_failed = ExportSpec {
583 when: ExportTrigger::OnFailed,
584 ..spec_attested.clone()
585 };
586 let spec_always = ExportSpec {
587 when: ExportTrigger::Always,
588 ..spec_attested.clone()
589 };
590
591 let lt = EphemeralLifetime {
592 ttl: "1h".into(),
593 teardown_policy: TeardownPolicy::OnAttested,
594 max_concurrent: 1,
595 exports: vec![spec_attested, spec_failed, spec_always],
596 };
597
598 // Attested gate fires OnAttested + Always — 2 of 3.
599 assert!(lt.has_applicable_exports(ProcessPhase::Attested));
600 assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
601
602 // Failed gate fires OnFailed + Always — 2 of 3.
603 assert!(lt.has_applicable_exports(ProcessPhase::Failed));
604 assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
605
606 // Other phases never route through Releasing.
607 for p in [
608 ProcessPhase::Pending,
609 ProcessPhase::Forking,
610 ProcessPhase::Execing,
611 ProcessPhase::Running,
612 ProcessPhase::Reconverging,
613 ProcessPhase::Releasing,
614 ProcessPhase::Exiting,
615 ProcessPhase::Zombie,
616 ProcessPhase::Reaped,
617 ] {
618 assert!(!lt.has_applicable_exports(p));
619 assert_eq!(lt.applicable_exports(p).count(), 0);
620 }
621 }
622
623 #[test]
624 fn no_exports_means_no_applicable_exports() {
625 let lt = EphemeralLifetime::default();
626 assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
627 assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
628 }
629
630 /// `ALL` is the source of truth for the resolver sweep — pin its
631 /// closure so a variant added without an `ALL` entry fails here
632 /// (via the uniqueness check) before drifting `variant()`.
633 #[test]
634 fn lifetime_kind_all_is_unique_and_complete() {
635 let mut seen = std::collections::HashSet::new();
636 for kind in LifetimeKind::ALL {
637 assert!(seen.insert(kind), "duplicate variant in ALL: {kind:?}");
638 }
639 assert_eq!(seen.len(), LifetimeKind::ALL.len());
640 }
641
642 /// CANONICAL-KEY CONTRACT: each variant's `as_str()` matches the
643 /// camelCase serde field name on `Lifetime`. A future rename of
644 /// any field lands here at one site.
645 #[test]
646 fn lifetime_kind_as_str_matches_lifetime_field_name() {
647 for kind in LifetimeKind::ALL {
648 let l = match kind {
649 LifetimeKind::Permanent => Lifetime {
650 permanent: Some(PermanentLifetime {}),
651 ..Lifetime::default()
652 },
653 LifetimeKind::Ephemeral => Lifetime {
654 ephemeral: Some(EphemeralLifetime::default()),
655 ..Lifetime::default()
656 },
657 };
658 let v = serde_json::to_value(&l).expect("Lifetime serializes");
659 let obj = v.as_object().expect("Lifetime serializes to object");
660 let keys: Vec<&String> = obj.keys().collect();
661 assert_eq!(
662 keys.len(),
663 1,
664 "exactly one slot populated for kind {kind:?}, got {keys:?}"
665 );
666 assert_eq!(
667 keys[0],
668 kind.as_str(),
669 "as_str() must match serde field name for {kind:?}"
670 );
671 }
672 }
673
674 /// ROUND-TRIP CONTRACT: `LifetimeKind::select(lifetime).map(|v|
675 /// v.kind()) == Some(kind)`. The reverse `LifetimeVariant::kind`
676 /// projection composes the closed set in both directions — a
677 /// regression that misroutes a select arm (e.g. `Self::Permanent =>
678 /// l.ephemeral.as_ref()...`) fails loudly here.
679 #[test]
680 fn lifetime_kind_round_trips_through_variant_kind() {
681 for kind in LifetimeKind::ALL {
682 let l = single_slot_lifetime(kind);
683 let v = kind.select(&l).expect("populated slot must select");
684 assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
685 // And the resolver lands on the same variant.
686 assert_eq!(
687 l.variant().expect("exactly-one variant").kind(),
688 kind,
689 "variant() resolver disagreed on {kind:?}"
690 );
691 }
692 }
693
694 /// `as_ephemeral` returns `Some` iff the variant is `Ephemeral`.
695 /// Pins the lift of the `let Ok(LifetimeVariant::Ephemeral(e)) = ...`
696 /// pattern that `lifetime_clock::evaluate` + `requeue_with_ttl`
697 /// previously hand-rolled.
698 #[test]
699 fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
700 let permanent = PermanentLifetime {};
701 let v = LifetimeVariant::Permanent(&permanent);
702 assert!(v.as_ephemeral().is_none());
703 assert!(v.as_permanent().is_some());
704
705 let ephemeral = EphemeralLifetime {
706 ttl: "42m".into(),
707 teardown_policy: TeardownPolicy::OnAttested,
708 max_concurrent: 3,
709 exports: vec![],
710 };
711 let v = LifetimeVariant::Ephemeral(&ephemeral);
712 let inner = v.as_ephemeral().expect("ephemeral must project");
713 assert_eq!(inner.ttl, "42m");
714 assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
715 assert_eq!(inner.max_concurrent, 3);
716 assert!(v.as_permanent().is_none());
717 }
718
719 /// EMPTY-RESOLVES-TO-PERMANENT CONTRACT: the resolver's "no slot
720 /// set" outcome is `Permanent`, not an error. Pin via the
721 /// closed-set kind projection so a future variant added to the
722 /// closed set (and to the `Lifetime` struct) without updating
723 /// the default resolution would surface here — the default
724 /// stays `Permanent` regardless of the closed set's arity.
725 #[test]
726 fn empty_lifetime_resolves_to_permanent_kind() {
727 let l = Lifetime::default();
728 let v = l.variant().expect("default lifetime resolves");
729 assert_eq!(v.kind(), LifetimeKind::Permanent);
730 assert!(v.as_permanent().is_some());
731 assert!(v.as_ephemeral().is_none());
732 }
733
734 /// Construct a `Lifetime` with exactly the given kind's slot
735 /// populated by a minimal valid inner spec. Shared across the
736 /// closed-set property tests so they each cover every variant
737 /// without restating the construction table.
738 fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
739 match kind {
740 LifetimeKind::Permanent => Lifetime {
741 permanent: Some(PermanentLifetime {}),
742 ..Lifetime::default()
743 },
744 LifetimeKind::Ephemeral => Lifetime {
745 ephemeral: Some(EphemeralLifetime::default()),
746 ..Lifetime::default()
747 },
748 }
749 }
750
751 #[test]
752 fn exports_round_trip_through_lifetime() {
753 use crate::export::{
754 ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
755 VectorChannel,
756 };
757 let l = Lifetime {
758 ephemeral: Some(EphemeralLifetime {
759 ttl: "30m".into(),
760 teardown_policy: TeardownPolicy::OnAttested,
761 max_concurrent: 1,
762 exports: vec![ExportSpec {
763 source: ArtifactSource {
764 receipts: Some(ReceiptsSource::default()),
765 ..ArtifactSource::default()
766 },
767 channel: VectorChannel {
768 http_event: Some(HttpEventChannel {
769 endpoint: None,
770 signal_type: "receipt".into(),
771 }),
772 ..VectorChannel::default()
773 },
774 when: ExportTrigger::OnAttested,
775 experiment_id_override: None,
776 }],
777 }),
778 ..Lifetime::default()
779 };
780 let yaml = serde_yaml::to_string(&l).unwrap();
781 assert!(yaml.contains("exports:"));
782 assert!(yaml.contains("receipts: {}"));
783 assert!(yaml.contains("signalType: receipt"));
784 let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
785 let e = back.ephemeral.unwrap();
786 assert_eq!(e.exports.len(), 1);
787 assert!(e.exports[0].source.receipts.is_some());
788 assert!(e.exports[0].channel.http_event.is_some());
789 }
790}