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 = "crate::serde_defaults::default_sigterm_grace_seconds")]
144    pub sigterm_grace_seconds: u32,
145    /// Permit force-reap via SIGKILL (default: allow).
146    #[serde(default = "crate::serde_defaults::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: crate::serde_defaults::default_sigterm_grace_seconds(),
160            sigkill_force: true,
161            sighup_strategy: SighupStrategy::default(),
162            start_suspended: false,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn must_reach_default_is_attested() {
173        assert_eq!(MustReachPhase::default(), MustReachPhase::Attested);
174    }
175
176    #[test]
177    fn signal_policy_defaults() {
178        let p = SignalPolicy::default();
179        assert_eq!(p.sigterm_grace_seconds, 480);
180        assert!(p.sigkill_force);
181        assert!(!p.start_suspended);
182    }
183
184    // ── closed-set algebra for MustReachPhase (ALL × as_str × FromStr ×
185    //    as_process_phase) ──────────────────────────────────────────────
186
187    /// Structural well-formedness of [`MustReachPhase`] as a
188    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
189    /// testkit lift that pins all three structural invariants (`ALL`
190    /// is non-empty, every variant round-trips through `label ↔
191    /// parse_label`, labels are pairwise distinct, `""` is outside the
192    /// closed set) at ONE call site. Replaces the hand-derived
193    /// `must_reach_phase_all_is_unique_and_complete` +
194    /// `must_reach_phase_roundtrip_via_as_str` + the empty-input arm
195    /// of `unknown_must_reach_phase_errors`. `FromStr` delegates to
196    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
197    /// exercises the same code path the reconciler hits when parsing
198    /// a CRD `enum:`-validated value back to the typed checkpoint.
199    #[test]
200    fn must_reach_phase_is_well_formed_closed_set() {
201        tatara_closed_set::assert_closed_set_well_formed::<MustReachPhase>();
202    }
203
204    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
205    /// output verbatim for every variant. A future variant rename (or
206    /// an `as_str` arm typo) lands here at one site.
207    #[test]
208    fn must_reach_phase_as_str_matches_serde() {
209        crate::tagged_union::assert_label_matches_serde_serialization::<MustReachPhase>();
210    }
211
212    /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
213    /// matches the canonical `ProcessPhase::as_str()` of the phase it
214    /// projects to. The two enums share the PascalCase wire format
215    /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
216    /// safe gating checkpoints; a rename on either side (a phase
217    /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
218    /// surfaces here at one site, not buried in a reconciler diagnostic
219    /// that quietly drifted away from the typed-phase surface.
220    #[test]
221    fn must_reach_phase_as_str_matches_process_phase_as_str() {
222        for kind in MustReachPhase::ALL {
223            assert_eq!(
224                kind.as_str(),
225                kind.as_process_phase().as_str(),
226                "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
227            );
228        }
229    }
230
231    /// The Display impl IS `as_str` — pinning this lets future callers
232    /// reach for either projection without drift. If a reviewer
233    /// accidentally re-introduces an inline match in Display, this test
234    /// would fail the moment a variant rename touches one site but not
235    /// the other.
236    #[test]
237    fn must_reach_phase_display_matches_as_str() {
238        crate::tagged_union::assert_display_matches_label::<MustReachPhase>();
239    }
240
241    /// `FromStr` rejects strings that aren't in the canonical
242    /// projection — lowercased / typo / non-checkpoint phase names —
243    /// and the error echoes the input verbatim so the operator-facing
244    /// diagnostic carries the offending value, not a normalized form.
245    /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
246    /// (which are legal `ProcessPhase`s but NOT valid
247    /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
248    /// whole point of the closed subset. The empty-input arm is
249    /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
250    /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
251    /// verbatim-echo contract on the [`UnknownMustReachPhase`]
252    /// newtype, which the trait's `make_unknown` can't see, AND the
253    /// closed-subset contract (non-checkpoint phases reject) the
254    /// trait's structural surface can't express.
255    #[test]
256    fn unknown_must_reach_phase_errors() {
257        use std::str::FromStr;
258        for bad in [
259            "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
260        ] {
261            let err = MustReachPhase::from_str(bad).unwrap_err();
262            assert_eq!(err.0, bad, "error payload should echo input verbatim");
263        }
264    }
265
266    /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
267    /// impl agrees with the typed `as_process_phase()` projection it
268    /// delegates to, for every variant. A regression that re-introduces
269    /// an inline match in the `From` impl fails here the moment
270    /// `as_process_phase` is the source of truth. Pairs with the
271    /// `as_str` cross-crate test above — together they pin that the
272    /// projection's value AND wire-format are coherent.
273    #[test]
274    fn must_reach_phase_from_delegates_to_as_process_phase() {
275        for kind in MustReachPhase::ALL {
276            let via_from: ProcessPhase = kind.into();
277            assert_eq!(
278                via_from,
279                kind.as_process_phase(),
280                "From<MustReachPhase> drift for {kind:?}",
281            );
282        }
283    }
284
285    /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
286    /// `ProcessPhase` that is `is_running()` — i.e. one of the live
287    /// gating checkpoints (`Running` or `Attested`). This pins the
288    /// closed subset's invariant at the type level: a future
289    /// `MustReachPhase::Released` (e.g. wait for the target to reach
290    /// `Reaped`) would FAIL this test, forcing the author to either
291    /// rename the predicate (`is_running` is wrong for that case) or
292    /// reconsider whether `MustReachPhase` is the right surface (it
293    /// shouldn't be — `Released` belongs on a separate "wait for
294    /// terminal-reached gate" closed set). The compiler enforces
295    /// closure-on-arity; this test enforces closure-on-semantics.
296    #[test]
297    fn must_reach_phase_projects_only_to_live_checkpoints() {
298        for kind in MustReachPhase::ALL {
299            let p = kind.as_process_phase();
300            assert!(
301                p.is_running(),
302                "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
303            );
304        }
305    }
306
307    /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
308    /// to distinct `ProcessPhase` values. Pairing this with the subset
309    /// contract above forces a future variant addition to land on a
310    /// fresh live checkpoint — collapsing two `MustReachPhase` variants
311    /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
312    /// silently makes `from` lossy, which `tatara-reconciler::boundary::
313    /// check_depends_on`'s diagnostic ("need {required}") would
314    /// quietly degrade.
315    #[test]
316    fn must_reach_phase_projection_is_injective() {
317        let mut seen = std::collections::HashSet::new();
318        for kind in MustReachPhase::ALL {
319            let p = kind.as_process_phase();
320            assert!(
321                seen.insert(p),
322                "MustReachPhase projection collision: {kind:?} → {p:?}",
323            );
324        }
325        assert_eq!(seen.len(), MustReachPhase::ALL.len());
326    }
327}