Skip to main content

tatara_process/
spec.rs

1//! `ProcessSpec` sub-structures — IdentitySpec, DependsOn, SignalPolicy.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::phase::ProcessPhase;
7use crate::signal::SighupStrategy;
8
9/// Identity configuration for a Process.
10#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "camelCase")]
12pub struct IdentitySpec {
13    /// Parent PID path (None for init/PID 1).
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub parent: Option<String>,
16    /// Human name override — if set, used verbatim instead of the content hash.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub name_override: Option<String>,
19}
20
21/// Dependency edge — constrains this Process to wait for another to reach a phase.
22#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "camelCase")]
24pub struct DependsOn {
25    /// Target Process `metadata.name`.
26    pub name: String,
27    /// Target Process namespace. Defaults to this Process's namespace.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub namespace: Option<String>,
30    /// Minimum phase the target must reach before we proceed past Forking.
31    #[serde(default)]
32    pub must_reach: MustReachPhase,
33}
34
35/// Allowed "must reach" phases for a dependency — restricted to the
36/// useful gating checkpoints `Running` (alive + boundary preconditions
37/// held) and `Attested` (alive + boundary postconditions held + three-
38/// pillar attestation written). Authoring a `DependsOn { must_reach:
39/// Forking }` is meaningless; the closed set rules it out at the type
40/// level.
41///
42/// Sibling closed-set lifts on the same `ProcessSpec` axis:
43/// [`crate::lifetime::LifetimeKind::ALL`],
44/// [`crate::lifetime::TeardownPolicy::ALL`],
45/// [`crate::boundary::ConditionKind::ALL`],
46/// [`crate::phase::ProcessPhase::ALL`],
47/// [`crate::signal::ProcessSignal::ALL`].
48#[derive(
49    Clone,
50    Copy,
51    Debug,
52    PartialEq,
53    Eq,
54    Hash,
55    Serialize,
56    Deserialize,
57    JsonSchema,
58    Default,
59    tatara_closed_set::DeriveClosedSet,
60)]
61#[serde(rename_all = "PascalCase")]
62#[closed_set(via = "as_str", display, generate_unknown = "must-reach phase")]
63pub enum MustReachPhase {
64    Running,
65    #[default]
66    Attested,
67}
68
69impl MustReachPhase {
70    /// The closed set of must-reach phases — single source of truth that
71    /// drives the `as_str` / Display / `FromStr` triad and the typed
72    /// `as_process_phase` projection. Adding a third variant (e.g. a
73    /// future `Released` checkpoint that waits for the target Process to
74    /// have exited cleanly) lands at one `ALL` entry, one `as_str` arm,
75    /// and one `as_process_phase` arm — exhaustively checked by the
76    /// compiler (the `[Self; 2]` array literal forces the arity).
77    pub const ALL: [Self; 2] = [Self::Running, Self::Attested];
78
79    /// Canonical PascalCase wire-format projection — matches the serde
80    /// `rename_all = "PascalCase"` output verbatim AND the canonical
81    /// `ProcessPhase::as_str()` projection on the phase this variant
82    /// gates against. Used by Display (single source of truth), by
83    /// `FromStr` to identify the variant from its annotation / status-
84    /// field representation, and by operator-facing diagnostic strings
85    /// (`tatara-reconciler::boundary::check_depends_on` stamps the
86    /// required phase via `Display` rather than reaching for `{:?}`
87    /// Debug formatting). Pinned by `must_reach_phase_as_str_matches_serde`
88    /// AND by `must_reach_phase_as_str_matches_process_phase_as_str` so
89    /// a rename on either side surfaces at one site.
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Running => "Running",
93            Self::Attested => "Attested",
94        }
95    }
96
97    /// Typed projection into the canonical `ProcessPhase` this variant
98    /// gates against. The `From<MustReachPhase> for ProcessPhase` impl
99    /// delegates here so callers reach for whichever surface fits (the
100    /// `From` for `into()` flows, this `const fn` for const contexts).
101    /// Pinned by `must_reach_phase_from_delegates_to_as_process_phase`.
102    pub const fn as_process_phase(self) -> ProcessPhase {
103        match self {
104            Self::Running => ProcessPhase::Running,
105            Self::Attested => ProcessPhase::Attested,
106        }
107    }
108}
109
110// `impl FromStr for MustReachPhase` +
111// `impl tatara_lisp::ClosedSet for MustReachPhase` +
112// `impl fmt::Display for MustReachPhase` +
113// `pub struct UnknownMustReachPhase(pub String)` are all generated
114// by `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
115// "as_str", display, generate_unknown = "must-reach phase")]` on
116// the enum declaration above. `label` delegates to the inherent
117// `MustReachPhase::as_str` — the PascalCase wire-vocabulary
118// projection stays load-bearing (matches the serde rename AND the
119// canonical `ProcessPhase::as_str` of the phase this variant gates
120// against, pinned by
121// `must_reach_phase_as_str_matches_process_phase_as_str`), while
122// generic `T: ClosedSet` consumers reach the STABLE workspace-wide
123// name (`label`). The explicit `generate_unknown = "must-reach
124// phase"` label carries the hyphenated wording that the
125// auto-derived `pascal_to_spaced_lowercase("MustReachPhase")` →
126// "must reach phase" projection cannot produce — the prior
127// hand-rolled `#[error("unknown must-reach phase: {0}")]`
128// annotation kept the hyphen, and the explicit attribute preserves
129// it through the lift. Symmetric to every other
130// `#[derive(DeriveClosedSet)]` implementor across the crate.
131
132impl From<MustReachPhase> for ProcessPhase {
133    fn from(v: MustReachPhase) -> Self {
134        v.as_process_phase()
135    }
136}
137
138/// Signal policy — how the Process responds to signals.
139#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
140#[serde(rename_all = "camelCase")]
141pub struct SignalPolicy {
142    /// Grace before escalating SIGTERM → SIGKILL.
143    #[serde(default = "default_sigterm_grace")]
144    pub sigterm_grace_seconds: u32,
145    /// Permit force-reap via SIGKILL (default: allow).
146    #[serde(default = "default_true")]
147    pub sigkill_force: bool,
148    /// How SIGHUP is handled.
149    #[serde(default)]
150    pub sighup_strategy: SighupStrategy,
151    /// Start suspended — requires SIGCONT to transition past Forking.
152    #[serde(default)]
153    pub start_suspended: bool,
154}
155
156impl Default for SignalPolicy {
157    fn default() -> Self {
158        Self {
159            sigterm_grace_seconds: default_sigterm_grace(),
160            sigkill_force: true,
161            sighup_strategy: SighupStrategy::default(),
162            start_suspended: false,
163        }
164    }
165}
166
167fn default_sigterm_grace() -> u32 {
168    480
169}
170fn default_true() -> bool {
171    true
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn must_reach_default_is_attested() {
180        assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
181    }
182
183    #[test]
184    fn signal_policy_defaults() {
185        let p = SignalPolicy::default();
186        assert_eq!(p.sigterm_grace_seconds, 480);
187        assert!(p.sigkill_force);
188        assert!(!p.start_suspended);
189    }
190
191    // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
192    //    as_process_phase) ──────────────────────────────────────────────
193
194    /// Structural well-formedness of [`MustReachPhase`] as a
195    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
196    /// testkit lift that pins all three structural invariants (`ALL`
197    /// is non-empty, every variant round-trips through `label ↔
198    /// parse_label`, labels are pairwise distinct, `""` is outside the
199    /// closed set) at ONE call site. Replaces the hand-derived
200    /// `must_reach_phase_all_is_unique_and_complete` +
201    /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
202    /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
203    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
204    /// exercises the same code path the reconciler hits when parsing
205    /// a CRD `enum:`-validated value back to the typed checkpoint.
206    #[test]
207    fn must_reach_phase_is_well_formed_closed_set() {
208        tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
209    }
210
211    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
212    /// output verbatim for every variant. A future variant rename (or
213    /// an `as_str` arm typo) lands here at one site.
214    #[test]
215    fn must_reach_phase_as_str_matches_serde() {
216        crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
217    }
218
219    /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
220    /// matches the canonical `ProcessPhase::as_str()` of the phase it
221    /// projects to. The two enums share the PascalCase wire format
222    /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
223    /// safe gating checkpoints; a rename on either side (a phase
224    /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
225    /// surfaces here at one site, not buried in a reconciler diagnostic
226    /// that quietly drifted away from the typed-phase surface.
227    #[test]
228    fn must_reach_phase_as_str_matches_process_phase_as_str() {
229        for kind in MustReachPhase::ALL {
230            assert_eq!(
231                kind.as_str(),
232                kind.as_process_phase().as_str(),
233                "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
234            );
235        }
236    }
237
238    /// The Display impl IS `as_str` — pinning this lets future callers
239    /// reach for either projection without drift. If a reviewer
240    /// accidentally re-introduces an inline match in Display, this test
241    /// would fail the moment a variant rename touches one site but not
242    /// the other.
243    #[test]
244    fn must_reach_phase_display_matches_as_str() {
245        crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
246    }
247
248    /// `FromStr` rejects strings that aren't in the canonical
249    /// projection — lowercased / typo / non-checkpoint phase names —
250    /// and the error echoes the input verbatim so the operator-facing
251    /// diagnostic carries the offending value, not a normalized form.
252    /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
253    /// (which are legal `ProcessPhase`s but NOT valid
254    /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
255    /// whole point of the closed subset. The empty-input arm is
256    /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
257    /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
258    /// verbatim-echo contract on the [`UnknownMustReachPhase`]
259    /// newtype, which the trait's `make_unknown` can't see, AND the
260    /// closed-subset contract (non-checkpoint phases reject) the
261    /// trait's structural surface can't express.
262    #[test]
263    fn unknown_must_reach_phase_errors() {
264        use std::str::FromStr;
265        for bad in [
266            "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
267        ] {
268            let err = MustReachPhase::from_str(bad).unwrap_err();
269            assert_eq!(err.0, bad, "error payload should echo input verbatim");
270        }
271    }
272
273    /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
274    /// impl agrees with the typed `as_process_phase()` projection it
275    /// delegates to, for every variant. A regression that re-introduces
276    /// an inline match in the `From` impl fails here the moment
277    /// `as_process_phase` is the source of truth. Pairs with the
278    /// `as_str` cross-crate test above — together they pin that the
279    /// projection's value AND wire-format are coherent.
280    #[test]
281    fn must_reach_phase_from_delegates_to_as_process_phase() {
282        for kind in MustReachPhase::ALL {
283            let via_from: ProcessPhase = kind.into();
284            assert_eq!(
285                via_from,
286                kind.as_process_phase(),
287                "From<MustReachPhase> drift for {kind:?}",
288            );
289        }
290    }
291
292    /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
293    /// `ProcessPhase` that is `is_running()` — i.e. one of the live
294    /// gating checkpoints (`Running` or `Attested`). This pins the
295    /// closed subset's invariant at the type level: a future
296    /// `MustReachPhase::Released` (e.g. wait for the target to reach
297    /// `Reaped`) would FAIL this test, forcing the author to either
298    /// rename the predicate (`is_running` is wrong for that case) or
299    /// reconsider whether `MustReachPhase` is the right surface (it
300    /// shouldn't be — `Released` belongs on a separate "wait for
301    /// terminal-reached gate" closed set). The compiler enforces
302    /// closure-on-arity; this test enforces closure-on-semantics.
303    #[test]
304    fn must_reach_phase_projects_only_to_live_checkpoints() {
305        for kind in MustReachPhase::ALL {
306            let p = kind.as_process_phase();
307            assert!(
308                p.is_running(),
309                "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
310            );
311        }
312    }
313
314    /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
315    /// to distinct `ProcessPhase` values. Pairing this with the subset
316    /// contract above forces a future variant addition to land on a
317    /// fresh live checkpoint — collapsing two `MustReachPhase` variants
318    /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
319    /// silently makes `from` lossy, which `tatara-reconciler::boundary::
320    /// check_depends_on`'s diagnostic ("need {required}") would
321    /// quietly degrade.
322    #[test]
323    fn must_reach_phase_projection_is_injective() {
324        let mut seen = std::collections::HashSet::new();
325        for kind in MustReachPhase::ALL {
326            let p = kind.as_process_phase();
327            assert!(
328                seen.insert(p),
329                "MustReachPhase projection collision: {kind:?} → {p:?}",
330            );
331        }
332        assert_eq!(seen.len(), MustReachPhase::ALL.len());
333    }
334}