Skip to main content

tatara_process/
crd.rs

1//! The `Process` CRD — `tatara.pleme.io/v1alpha1`.
2
3use chrono::{DateTime, Utc};
4use kube::CustomResource;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use tatara_lisp::DeriveTataraDomain;
8
9use crate::attestation::ProcessAttestation;
10use crate::boundary::Boundary;
11use crate::classification::Classification;
12use crate::compliance::ComplianceSpec;
13use crate::encapsulates::EncapsulatesSpec;
14use crate::identity::Identity;
15use crate::intent::Intent;
16use crate::lifetime::Lifetime;
17use crate::phase::ProcessPhase;
18use crate::routing::RoutingSpec;
19use crate::signal::ProcessSignal;
20use crate::spec::{DependsOn, IdentitySpec, SignalPolicy};
21use crate::status::{BoundaryStatus, ComplianceStatus, FluxResourceRef, ProcessCondition};
22
23/// Process — one element of the tatara convergence lattice, reconciled as a Unix process.
24///
25/// ```yaml
26/// apiVersion: tatara.pleme.io/v1alpha1
27/// kind: Process
28/// metadata:
29///   name: observability-stack
30///   namespace: seph
31/// spec:
32///   identity:
33///     parent: seph.1
34///   classification:
35///     pointType: Gate
36///     substrate: Observability
37///   intent:
38///     nix:
39///       flakeRef: github:pleme-io/k8s?dir=shared/infrastructure
40///       attribute: observability
41///   compliance:
42///     baseline: fedramp-moderate
43///     bindings:
44///       - framework: nist-800-53
45///         controlId: SC-7
46///         phase: AtBoundary
47///   dependsOn:
48///     - name: secret-injection
49/// ```
50#[derive(CustomResource, DeriveTataraDomain, Clone, Debug, Deserialize, Serialize, JsonSchema)]
51#[kube(
52    group = "tatara.pleme.io",
53    version = "v1alpha1",
54    kind = "Process",
55    plural = "processes",
56    shortname = "proc",
57    namespaced,
58    status = "ProcessStatus",
59    printcolumn = r#"{"name":"PID","type":"string","jsonPath":".status.pid"}"#,
60    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
61    printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.classification.pointType"}"#,
62    printcolumn = r#"{"name":"Substrate","type":"string","jsonPath":".spec.classification.substrate"}"#,
63    printcolumn = r#"{"name":"Gen","type":"integer","jsonPath":".status.attestation.generation"}"#,
64    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
65)]
66#[serde(rename_all = "camelCase")]
67#[tatara(keyword = "defpoint")]
68pub struct ProcessSpec {
69    /// Identity (parent, name override).
70    #[serde(default)]
71    pub identity: IdentitySpec,
72
73    /// Lattice position (6 dimensions).
74    pub classification: Classification,
75
76    /// Where rendered artifacts come from. Exactly one variant must be set.
77    pub intent: Intent,
78
79    /// Boundary predicates (preconditions / postconditions).
80    #[serde(default)]
81    pub boundary: Boundary,
82
83    /// Compliance bindings + baseline.
84    #[serde(default)]
85    pub compliance: ComplianceSpec,
86
87    /// Lattice dependencies — must reach phase before we proceed.
88    #[serde(default)]
89    pub depends_on: Vec<DependsOn>,
90
91    /// Signal policy (grace, SIGHUP strategy, start-suspended).
92    #[serde(default)]
93    pub signals: SignalPolicy,
94
95    /// Lifetime — `Permanent` (default, re-converging) or `Ephemeral`
96    /// (auto-SIGTERM per `teardown_policy` + TTL clock).
97    #[serde(default, skip_serializing_if = "Lifetime::is_default")]
98    pub lifetime: Lifetime,
99
100    /// External edges — DNS + Ingress. When `None`, the Process is
101    /// internal-only (matches today's default). See
102    /// [`crate::routing`] for the full shape.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub routing: Option<RoutingSpec>,
105
106    /// Pre-existing in-cluster state this Process wraps. When `None`,
107    /// the Process is greenfield (Manage mode implicitly applied to
108    /// nothing pre-existing). See [`crate::encapsulates`] for the
109    /// three modes (Manage / Adopt / Observe).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub encapsulates: Option<EncapsulatesSpec>,
112
113    /// Soft-suspend marker — reconciler treats as SIGSTOP.
114    /// Same effect as delivering SIGSTOP, but persistent across restarts.
115    #[serde(default)]
116    pub suspended: bool,
117}
118
119// Coordinate primitives — the `(namespace, name)` pair every downstream
120// composer (annotation writers, claim arbiter, boundary evaluator,
121// render owner-metadata seed) pulled by hand from `Process.metadata`
122// pre-lift, each restating the same two `Option<String>`-to-`&str`
123// unwrap incantations with the same two workspace-wide fallback
124// strings sprayed inline. Post-lift the pair lives at ONE substrate
125// primitive on `Process` — a future normalization (case-fold,
126// unicode-safe collation, cross-cluster prefix, a rename of either
127// fallback) lands here and every downstream composer inherits the
128// upgrade mechanically. Peer to `qualified_process_ref` in
129// `tatara-reconciler::ssapply`, whose two `&str` arguments are
130// exactly the pair `Process::coordinates_or_defaults` returns.
131impl Process {
132    /// The K8s canonical default namespace — the fallback every
133    /// consumer of a `Process` whose `metadata.namespace` is `None`
134    /// substitutes. Matches the string K8s itself substitutes on
135    /// namespaced resource writes with no explicit namespace.
136    pub const DEFAULT_NAMESPACE: &'static str = "default";
137
138    /// Workspace-wide fallback for a `Process`'s `metadata.name` when
139    /// it is `None` — the sentinel every annotation writer, claim
140    /// arbiter, and owner-metadata seed substitutes so downstream
141    /// grepping / label-selecting sees a stable spelling rather than
142    /// a per-callsite ad-hoc placeholder (`""`, `"<unnamed>"`, or the
143    /// empty `unwrap_or_default()` fallback). A Process authored
144    /// through the reconciler's fork path always has a name; this
145    /// constant covers the surface where an untyped `Process` value
146    /// (test fixture, dynamic API response, adopted resource pre-
147    /// name-resolution) surfaces without one.
148    pub const UNNAMED_PLACEHOLDER: &'static str = "unnamed";
149
150    /// Namespace slice with the [`Self::DEFAULT_NAMESPACE`] fallback
151    /// applied — the ONE-line collapse of the `metadata.namespace
152    /// .as_deref().unwrap_or("default")` incantation every consumer
153    /// spelled by hand pre-lift.
154    ///
155    /// Peer to [`Self::name_or_placeholder`] on the (metadata slot ×
156    /// fallback shape) axis; both compose through
157    /// [`Self::coordinates_or_defaults`] when a consumer needs the
158    /// pair together (annotation writers, claim-arbiter row builders,
159    /// render owner-metadata seed).
160    pub fn namespace_or_default(&self) -> &str {
161        self.metadata
162            .namespace
163            .as_deref()
164            .unwrap_or(Self::DEFAULT_NAMESPACE)
165    }
166
167    /// Name slice with the [`Self::UNNAMED_PLACEHOLDER`] fallback
168    /// applied — the ONE-line collapse of the `metadata.name.as_deref
169    /// ().unwrap_or("unnamed")` incantation every consumer spelled by
170    /// hand pre-lift.
171    ///
172    /// Peer to [`Self::namespace_or_default`] on the (metadata slot ×
173    /// fallback shape) axis; both compose through
174    /// [`Self::coordinates_or_defaults`] when a consumer needs the
175    /// pair together.
176    pub fn name_or_placeholder(&self) -> &str {
177        self.metadata
178            .name
179            .as_deref()
180            .unwrap_or(Self::UNNAMED_PLACEHOLDER)
181    }
182
183    /// `(namespace, name)` coordinates with the workspace-wide default
184    /// fallbacks applied — the ONE-line collapse of the paired
185    /// `metadata.namespace.as_deref().unwrap_or("default")` +
186    /// `metadata.name.as_deref().unwrap_or("unnamed")` extraction
187    /// every downstream composer restated by hand pre-lift.
188    ///
189    /// Return-tuple order matches the axis order of the substrate's
190    /// paired-composer primitive
191    /// `tatara_reconciler::ssapply::qualified_process_ref(ns, name)`:
192    /// the (namespace, name) pair this method returns feeds that
193    /// primitive positionally without an axis-swap step.
194    pub fn coordinates_or_defaults(&self) -> (&str, &str) {
195        (self.namespace_or_default(), self.name_or_placeholder())
196    }
197}
198
199/// Process status — every field optional until the reconciler writes it.
200#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
201#[serde(rename_all = "camelCase")]
202pub struct ProcessStatus {
203    /// Hierarchical PID path — e.g., `"seph.1.7"`.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub pid: Option<String>,
206
207    /// Parent PID path (mirror of `spec.identity.parent`, resolved at fork).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub parent: Option<String>,
210
211    /// Direct children's PID paths.
212    #[serde(default)]
213    pub children: Vec<String>,
214
215    /// Resolved identity (name + content hash).
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub identity: Option<Identity>,
218
219    /// Current phase.
220    #[serde(default)]
221    pub phase: ProcessPhase,
222
223    /// When the process entered the current phase.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub phase_since: Option<DateTime<Utc>>,
226
227    /// Three-pillar attestation (written at end of every successful cycle).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub attestation: Option<ProcessAttestation>,
230
231    /// FluxCD resources currently owned by this Process.
232    #[serde(default)]
233    pub flux_resources: Vec<FluxResourceRef>,
234
235    /// Boundary verification state.
236    #[serde(default)]
237    pub boundary: BoundaryStatus,
238
239    /// Compliance summary at the latest attestation.
240    #[serde(default)]
241    pub compliance: ComplianceStatus,
242
243    /// Pending signals (delivered, not yet handled).
244    #[serde(default)]
245    pub signal_queue: Vec<ProcessSignal>,
246
247    /// Standard K8s Conditions.
248    #[serde(default)]
249    pub conditions: Vec<ProcessCondition>,
250
251    /// Human-readable last status message.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub message: Option<String>,
254
255    /// Exit code (only set on Failed / Reaped).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub exit_code: Option<i32>,
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::classification::{ConvergencePointType, SubstrateType};
264    use crate::intent::NixIntent;
265
266    #[test]
267    fn minimal_spec_serializes() {
268        let spec = ProcessSpec {
269            identity: IdentitySpec::default(),
270            classification: Classification {
271                point_type: ConvergencePointType::Gate,
272                substrate: SubstrateType::Observability,
273                horizon: Default::default(),
274                calm: Default::default(),
275                data_classification: Default::default(),
276            },
277            intent: Intent {
278                nix: Some(NixIntent {
279                    flake_ref: "github:pleme-io/k8s".into(),
280                    attribute: "obs".into(),
281                    system: None,
282                    attic_cache: None,
283                    extra_args: vec![],
284                    delegate_to_nix_build: false,
285                }),
286                ..Intent::default()
287            },
288            boundary: Default::default(),
289            compliance: Default::default(),
290            depends_on: vec![],
291            signals: Default::default(),
292            lifetime: Default::default(),
293            routing: None,
294            encapsulates: None,
295            suspended: false,
296        };
297        let yaml = serde_yaml::to_string(&spec).unwrap();
298        assert!(yaml.contains("pointType: Gate"));
299        assert!(yaml.contains("substrate: Observability"));
300        assert!(yaml.contains("flakeRef: github:pleme-io/k8s"));
301    }
302
303    // ─── Process::coordinates_or_defaults substrate pins ────────────────
304    //
305    // Pins the (namespace, name) coordinate-primitive family on the
306    // (metadata slot × fallback shape) axis. Fail-before-pass-after
307    // granularity: a regression that flipped either fallback string,
308    // swapped the return-tuple axis order, or dropped the
309    // `Option::as_deref` unwrap surfaces here rather than as silent
310    // drift at every downstream annotation writer / claim-arbiter row
311    // builder / render owner-metadata seed.
312
313    fn empty_spec() -> ProcessSpec {
314        ProcessSpec {
315            identity: IdentitySpec::default(),
316            classification: Classification {
317                point_type: ConvergencePointType::Gate,
318                substrate: SubstrateType::Compute,
319                horizon: Default::default(),
320                calm: Default::default(),
321                data_classification: Default::default(),
322            },
323            intent: Intent::default(),
324            boundary: Default::default(),
325            compliance: Default::default(),
326            depends_on: vec![],
327            signals: Default::default(),
328            lifetime: Default::default(),
329            routing: None,
330            encapsulates: None,
331            suspended: false,
332        }
333    }
334
335    #[test]
336    fn default_namespace_constant_is_k8s_canonical_default() {
337        // Pins the load-bearing convention that this primitive's
338        // namespace fallback matches K8s's own implicit-namespace
339        // spelling. A regression that renamed this to "kube-system"
340        // or any other K8s-reserved name would silently misroute
341        // every downstream namespaced-Api call on a Process without
342        // a metadata.namespace.
343        assert_eq!(Process::DEFAULT_NAMESPACE, "default");
344    }
345
346    #[test]
347    fn unnamed_placeholder_constant_matches_prior_annotation_writer_fallback() {
348        // Pins the load-bearing convention that this primitive's name
349        // fallback matches the exact spelling every annotation writer
350        // (tatara-reconciler::ssapply::inject_annotations,
351        // tatara-reconciler::render::render, and
352        // tatara-reconciler::table_controller's claim-row builder)
353        // was hand-authoring pre-lift ("unnamed", NOT "<unnamed>" or
354        // ""). A regression that renamed this would break the
355        // annotation-writer / claim-arbiter grep contract silently.
356        assert_eq!(Process::UNNAMED_PLACEHOLDER, "unnamed");
357    }
358
359    #[test]
360    fn namespace_or_default_falls_back_when_metadata_namespace_is_none() {
361        let mut p = Process::new("some-proc", empty_spec());
362        p.metadata.namespace = None;
363        assert_eq!(p.namespace_or_default(), Process::DEFAULT_NAMESPACE);
364    }
365
366    #[test]
367    fn namespace_or_default_returns_metadata_slice_when_some() {
368        let mut p = Process::new("some-proc", empty_spec());
369        p.metadata.namespace = Some("prod-app".into());
370        assert_eq!(p.namespace_or_default(), "prod-app");
371    }
372
373    #[test]
374    fn name_or_placeholder_falls_back_when_metadata_name_is_none() {
375        let mut p = Process::new("real-name", empty_spec());
376        p.metadata.name = None;
377        assert_eq!(p.name_or_placeholder(), Process::UNNAMED_PLACEHOLDER);
378    }
379
380    #[test]
381    fn name_or_placeholder_returns_metadata_slice_when_some() {
382        let p = Process::new("api-gateway", empty_spec());
383        assert_eq!(p.name_or_placeholder(), "api-gateway");
384    }
385
386    #[test]
387    fn coordinates_or_defaults_composes_both_halves() {
388        // Both slots present — returns metadata slices in
389        // (namespace, name) axis order.
390        let mut p = Process::new("api", empty_spec());
391        p.metadata.namespace = Some("staging".into());
392        assert_eq!(p.coordinates_or_defaults(), ("staging", "api"));
393    }
394
395    #[test]
396    fn coordinates_or_defaults_falls_back_on_both_slots() {
397        // Both slots None — returns (DEFAULT_NAMESPACE,
398        // UNNAMED_PLACEHOLDER) in axis order.
399        let mut p = Process::new("scratch", empty_spec());
400        p.metadata.name = None;
401        p.metadata.namespace = None;
402        assert_eq!(
403            p.coordinates_or_defaults(),
404            (Process::DEFAULT_NAMESPACE, Process::UNNAMED_PLACEHOLDER)
405        );
406    }
407
408    #[test]
409    fn coordinates_or_defaults_mixes_slotted_and_fallback_halves() {
410        // Namespace set, name missing — the (namespace, name) tuple
411        // pins each half independently. A regression that returned
412        // BOTH fallbacks when EITHER metadata slot was None would
413        // surface here rather than at every downstream reader.
414        let mut p = Process::new("kept-name", empty_spec());
415        p.metadata.namespace = Some("prod".into());
416        assert_eq!(p.coordinates_or_defaults(), ("prod", "kept-name"));
417
418        // Name set, namespace missing — the peer corner.
419        let mut q = Process::new("api", empty_spec());
420        q.metadata.namespace = None;
421        assert_eq!(
422            q.coordinates_or_defaults(),
423            (Process::DEFAULT_NAMESPACE, "api")
424        );
425    }
426
427    #[test]
428    fn coordinates_or_defaults_axis_order_matches_qualified_process_ref() {
429        // Pins the load-bearing convention that the return-tuple
430        // axis order is (namespace, name) — the exact positional
431        // argument order the substrate's paired-composer primitive
432        // `tatara_reconciler::ssapply::qualified_process_ref(ns,
433        // name)` consumes. A regression that swapped the tuple
434        // slots would silently misroute every annotation writer /
435        // claim-arbiter row / owner-metadata seed built by feeding
436        // this pair into the composer — every downstream `<ns>/
437        // <name>` grep would suddenly see `<name>/<ns>`. The test
438        // verifies the tuple's first slot is what a hand-authored
439        // `.metadata.namespace.as_deref()...` produced pre-lift, and
440        // the second slot is what `.metadata.name.as_deref()...`
441        // produced.
442        let mut p = Process::new("app", empty_spec());
443        p.metadata.namespace = Some("infra".into());
444        let (ns, name) = p.coordinates_or_defaults();
445        assert_eq!(ns, "infra"); // NOT "app"
446        assert_eq!(name, "app"); // NOT "infra"
447    }
448}