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        for kind in MustReachPhase::ALL {
217            let serialized = serde_json::to_string(&kind)
218                .expect("MustReachPhase serializes")
219                .trim_matches('"')
220                .to_string();
221            assert_eq!(
222                kind.as_str(),
223                serialized,
224                "as_str() must match serde output for {kind:?}",
225            );
226        }
227    }
228
229    /// CROSS-CRATE CANONICAL-KEY CONTRACT: `MustReachPhase::as_str()`
230    /// matches the canonical `ProcessPhase::as_str()` of the phase it
231    /// projects to. The two enums share the PascalCase wire format
232    /// because `MustReachPhase` is a typed subset of `ProcessPhase`'s
233    /// safe gating checkpoints; a rename on either side (a phase
234    /// rename in `ProcessPhase::as_str` OR an `as_str` arm typo here)
235    /// surfaces here at one site, not buried in a reconciler diagnostic
236    /// that quietly drifted away from the typed-phase surface.
237    #[test]
238    fn must_reach_phase_as_str_matches_process_phase_as_str() {
239        for kind in MustReachPhase::ALL {
240            assert_eq!(
241                kind.as_str(),
242                kind.as_process_phase().as_str(),
243                "MustReachPhase::as_str() and ProcessPhase::as_str() drift for {kind:?}",
244            );
245        }
246    }
247
248    /// The Display impl IS `as_str` — pinning this lets future callers
249    /// reach for either projection without drift. If a reviewer
250    /// accidentally re-introduces an inline match in Display, this test
251    /// would fail the moment a variant rename touches one site but not
252    /// the other.
253    #[test]
254    fn must_reach_phase_display_matches_as_str() {
255        for kind in MustReachPhase::ALL {
256            assert_eq!(kind.to_string(), kind.as_str());
257        }
258    }
259
260    /// `FromStr` rejects strings that aren't in the canonical
261    /// projection — lowercased / typo / non-checkpoint phase names —
262    /// and the error echoes the input verbatim so the operator-facing
263    /// diagnostic carries the offending value, not a normalized form.
264    /// Non-checkpoint phases like `Pending` / `Failed` / `Reaped`
265    /// (which are legal `ProcessPhase`s but NOT valid
266    /// `MustReachPhase` checkpoints) MUST fail to parse — that's the
267    /// whole point of the closed subset. The empty-input arm is
268    /// pinned by [`must_reach_phase_is_well_formed_closed_set`] via
269    /// the `tatara_lisp::ClosedSet` testkit; the cases here pin the
270    /// verbatim-echo contract on the [`UnknownMustReachPhase`]
271    /// newtype, which the trait's `make_unknown` can't see, AND the
272    /// closed-subset contract (non-checkpoint phases reject) the
273    /// trait's structural surface can't express.
274    #[test]
275    fn unknown_must_reach_phase_errors() {
276        use std::str::FromStr;
277        for bad in [
278            "running", "ATTESTED", "Atested", "Pending", "Failed", "Reaped",
279        ] {
280            let err = MustReachPhase::from_str(bad).unwrap_err();
281            assert_eq!(err.0, bad, "error payload should echo input verbatim");
282        }
283    }
284
285    /// DELEGATION CONTRACT: the `From<MustReachPhase> for ProcessPhase`
286    /// impl agrees with the typed `as_process_phase()` projection it
287    /// delegates to, for every variant. A regression that re-introduces
288    /// an inline match in the `From` impl fails here the moment
289    /// `as_process_phase` is the source of truth. Pairs with the
290    /// `as_str` cross-crate test above — together they pin that the
291    /// projection's value AND wire-format are coherent.
292    #[test]
293    fn must_reach_phase_from_delegates_to_as_process_phase() {
294        for kind in MustReachPhase::ALL {
295            let via_from: ProcessPhase = kind.into();
296            assert_eq!(
297                via_from,
298                kind.as_process_phase(),
299                "From<MustReachPhase> drift for {kind:?}",
300            );
301        }
302    }
303
304    /// SUBSET CONTRACT: every `MustReachPhase` variant projects to a
305    /// `ProcessPhase` that is `is_running()` — i.e. one of the live
306    /// gating checkpoints (`Running` or `Attested`). This pins the
307    /// closed subset's invariant at the type level: a future
308    /// `MustReachPhase::Released` (e.g. wait for the target to reach
309    /// `Reaped`) would FAIL this test, forcing the author to either
310    /// rename the predicate (`is_running` is wrong for that case) or
311    /// reconsider whether `MustReachPhase` is the right surface (it
312    /// shouldn't be — `Released` belongs on a separate "wait for
313    /// terminal-reached gate" closed set). The compiler enforces
314    /// closure-on-arity; this test enforces closure-on-semantics.
315    #[test]
316    fn must_reach_phase_projects_only_to_live_checkpoints() {
317        for kind in MustReachPhase::ALL {
318            let p = kind.as_process_phase();
319            assert!(
320                p.is_running(),
321                "{kind:?} → {p:?} must be a live checkpoint (Running or Attested)",
322            );
323        }
324    }
325
326    /// INJECTIVITY CONTRACT: distinct `MustReachPhase` variants project
327    /// to distinct `ProcessPhase` values. Pairing this with the subset
328    /// contract above forces a future variant addition to land on a
329    /// fresh live checkpoint — collapsing two `MustReachPhase` variants
330    /// onto the same `ProcessPhase` (e.g. two flavors of `Running`)
331    /// silently makes `from` lossy, which `tatara-reconciler::boundary::
332    /// check_depends_on`'s diagnostic ("need {required}") would
333    /// quietly degrade.
334    #[test]
335    fn must_reach_phase_projection_is_injective() {
336        let mut seen = std::collections::HashSet::new();
337        for kind in MustReachPhase::ALL {
338            let p = kind.as_process_phase();
339            assert!(
340                seen.insert(p),
341                "MustReachPhase projection collision: {kind:?} → {p:?}",
342            );
343        }
344        assert_eq!(seen.len(), MustReachPhase::ALL.len());
345    }
346}