Skip to main content

tatara_process/
lib.rs

1//! Process CRD — the K8s-as-Unix-processes wire format.
2//!
3//! A `Process` is one element of the tatara convergence lattice.
4//! Clusters, HelmReleases, migrations, tests — all are Processes.
5//! The reconciliation loop *is* Unix: fork → exec → wait → exit → reap.
6
7pub mod allocation;
8pub mod attestation;
9pub mod boundary;
10pub mod classification;
11pub mod compliance;
12pub mod crd;
13pub mod encapsulates;
14pub mod env;
15pub mod ephemeral;
16pub mod export;
17pub mod hostname;
18pub mod identity;
19pub mod intent;
20pub mod lifetime;
21pub mod lifetime_clock;
22pub mod matrix;
23pub mod phase;
24pub mod pool;
25pub mod receipt;
26pub mod routing;
27pub mod signal;
28pub mod spec;
29pub mod status;
30pub mod table;
31pub mod tagged_union;
32
33pub mod prelude {
34    pub use crate::allocation::{
35        AllocationCondition, AllocationPhase, AllocationSpec, AllocationStatus,
36        EphemeralAllocation, Requestor,
37    };
38    pub use crate::attestation::ProcessAttestation;
39    pub use crate::boundary::{Boundary, Condition, ConditionKind, UnknownConditionKind};
40    pub use crate::classification::{
41        Arity, CalmClassification, Classification, ConvergencePointType, DataClassification,
42        Horizon, HorizonKind, OptimizationDirection, SubstrateType, UnknownCalmClassification,
43        UnknownConvergencePointType, UnknownDataClassification, UnknownHorizonKind,
44        UnknownOptimizationDirection, UnknownSubstrateType,
45    };
46    pub use crate::compliance::{
47        ComplianceBinding, ComplianceSpec, UnknownVerificationPhase, VerificationPhase,
48    };
49    pub use crate::crd::{Process, ProcessSpec, ProcessStatus};
50    pub use crate::encapsulates::{
51        BareWorkload, EncapsulatesSpec, EncapsulationKind, EncapsulationKindError,
52        EncapsulationKindVariant, EncapsulationMode, EncapsulationTarget, ExistingHelmRelease,
53        ExistingKustomization, UnknownEncapsulationMode, UnknownEncapsulationTarget,
54    };
55    pub use crate::ephemeral::{compile_ephemeral_source, EphemeralSpec};
56    pub use crate::export::{
57        ArtifactError, ArtifactKind, ArtifactSource, ArtifactVariant, ChannelError, ChannelKind,
58        ChannelVariant, ExportSpec, ExportTrigger, HttpEventChannel, NatsSubjectChannel,
59        ProcessSnapshotSource, ReceiptsSource, ReportFormat, ReportPayloadShape, RunMarkerSource,
60        StdoutChannel, TestReportSource, UnknownArtifactKind, UnknownChannelKind,
61        UnknownExportTrigger, UnknownReportFormat, VectorChannel, DEFAULT_NATS_URL,
62        DEFAULT_VECTOR_INGEST,
63    };
64    pub use crate::hostname::{
65        ephemeral_id_from_spec, fmt_fqdn, fmt_fqdn_stable, resolve_ephemeral_id, HostnameError,
66        EPHEMERAL_ID_HASH_LEN,
67    };
68    pub use crate::identity::{content_hash, derive_identity, format_process_address, Identity};
69    pub use crate::intent::{
70        AplicacaoIntent, ContainerIntent, FluxIntent, GuestIntent, HelmLifecyclePolicy,
71        HelmRemediationPolicy, Intent, IntentError, IntentKind, IntentVariant, LispIntent,
72        NixIntent, UnknownWorkloadKind, WorkloadKind, FLUX_HELM_DEFAULT_INTERVAL,
73        HELM_LIFECYCLE_DEFAULT_RETRIES, HELM_LIFECYCLE_DEFAULT_TIMEOUT,
74    };
75    pub use crate::lifetime::{
76        EphemeralLifetime, Lifetime, LifetimeError, LifetimeKind, LifetimeVariant,
77        PermanentLifetime, TeardownPolicy, UnknownTeardownPolicy,
78    };
79    pub use crate::lifetime_clock::{
80        evaluate as lifetime_clock_evaluate, AutoTerminate, AutoTerminateKind, TerminateReason,
81        TerminateReasonKind, UnknownAutoTerminateKind, UnknownTerminateReasonKind,
82    };
83    pub use crate::matrix::{
84        compile_env_matrix_source, EnvMatrixSpec, MatrixAxis, MatrixBudget, NamedEphemeral,
85        SelectStrategy, SelectStrategyKind, UnknownSelectStrategyKind,
86    };
87    pub use crate::phase::{ProcessPhase, UnknownPhase};
88    pub use crate::pool::{
89        AllocationRef, EphemeralPool, MatchKey, MemberState, PoolCondition, PoolMember, PoolPhase,
90        PoolSelector, PoolSpec, PoolStatus, ReplacementPolicy, ReturnPolicy, UnknownMemberState,
91        UnknownPoolPhase, UnknownReplacementPolicy,
92    };
93    pub use crate::receipt::{
94        default_receipt_config_map_name, ReceiptEnvelope, ReceiptError, ReceiptKind,
95        RECEIPT_CM_SUFFIX, RECEIPT_VERSION,
96    };
97    pub use crate::routing::{RoutingBackend, RoutingHostname, RoutingSpec};
98    pub use crate::signal::{ProcessSignal, SighupStrategy, UnknownSighupStrategy};
99    pub use crate::spec::{
100        DependsOn, IdentitySpec, MustReachPhase, SignalPolicy, UnknownMustReachPhase,
101    };
102    pub use crate::status::{
103        BoundaryStatus, CheckedCondition, ComplianceStatus, FluxResourceRef, ProcessCondition,
104        RenderedResourceCoords,
105    };
106    pub use crate::table::{
107        ClaimRecord, ProcessEntry, ProcessTable, ProcessTableSpec, ProcessTableStatus,
108    };
109}
110
111/// CRD API group for every tatara CRD.
112pub const GROUP: &str = "tatara.pleme.io";
113/// CRD version for this module.
114pub const VERSION: &str = "v1alpha1";
115
116/// Annotation keys the reconciler reads/writes on owned FluxCD resources.
117pub mod annotations {
118    pub const MANAGED_BY: &str = "tatara.pleme.io/managed-by";
119    pub const PROCESS: &str = "tatara.pleme.io/process";
120    pub const PID: &str = "tatara.pleme.io/pid";
121    pub const CONTENT_HASH: &str = "tatara.pleme.io/content-hash";
122    pub const ATTESTATION_ROOT: &str = "tatara.pleme.io/attestation-root";
123    pub const GENERATION: &str = "tatara.pleme.io/generation";
124    pub const SIGNAL: &str = "tatara.pleme.io/signal";
125    /// Stamped by the reconciler when transitioning into `Releasing`
126    /// — records which terminal-reached gate the Process came from
127    /// (`Attested` or `Failed`) so `handle_releasing` can pick the
128    /// matching `ExportTrigger` set + the correct post-Releasing
129    /// destination (`Exiting` from Attested, `Zombie` from Failed).
130    pub const RELEASED_FROM: &str = "tatara.pleme.io/released-from";
131    /// Labels the export-worker Jobs the reconciler emits during
132    /// `Releasing`. Selector: `tatara.pleme.io/role=export`.
133    pub const ROLE: &str = "tatara.pleme.io/role";
134    /// Index of an export inside `lifetime.ephemeral.exports`.
135    /// Stamped on the corresponding tatara-export-worker Job + its
136    /// receipt ConfigMap so the reconciler can correlate them
137    /// without re-parsing the spec JSON.
138    pub const EXPORT_INDEX: &str = "tatara.pleme.io/export-index";
139}
140
141/// Standard finalizer for the Process reconciler.
142pub const PROCESS_FINALIZER: &str = "tatara.pleme.io/process-finalizer";
143
144/// Shared schemars helpers — emit OpenAPI schemas Kubernetes accepts.
145/// Free-form `serde_json::Value` fields default to an *empty* schema
146/// in schemars, which the K8s API server rejects with "type: Required
147/// value: must not be empty for specified object fields". The typed
148/// workaround is to emit `{type: object, x-kubernetes-preserve-unknown-
149/// fields: true}` — same shape kube-rs's own helpers produce.
150pub mod schema_helpers {
151    use schemars::{gen::SchemaGenerator, schema::Schema};
152    /// Schema for a free-form JSON object field. Apply via
153    /// `#[schemars(schema_with = "tatara_process::schema_helpers::preserve_unknown_object")]`
154    /// on any `serde_json::Value` / `BTreeMap<String, serde_json::Value>`
155    /// field exposed through a CRD.
156    pub fn preserve_unknown_object(_g: &mut SchemaGenerator) -> Schema {
157        serde_json::from_value(serde_json::json!({
158            "type": "object",
159            "x-kubernetes-preserve-unknown-fields": true
160        }))
161        .expect("static JSON literal parses as Schema")
162    }
163}
164
165// ── Lisp → ProcessSpec compile bridge ──────────────────────────────────
166//
167// `(defpoint NAME :k v …)` compiles to a `NamedDefinition<ProcessSpec>`.
168// The derive on ProcessSpec handles every field via the serde Deserialize
169// fallthrough — no hand-rolled keyword parsing needed.
170
171/// A named ProcessSpec as produced by `compile_source`.
172pub type Definition = tatara_lisp::NamedDefinition<crate::crd::ProcessSpec>;
173
174/// Compile a Lisp source string into a list of named ProcessSpecs.
175/// Each top-level `(defpoint NAME …)` form becomes one `Definition`.
176pub fn compile_source(src: &str) -> tatara_lisp::Result<Vec<Definition>> {
177    tatara_lisp::compile_named::<crate::crd::ProcessSpec>(src)
178}
179
180/// Register every domain owned by this crate with the global Lisp
181/// dispatcher. Call once per binary, typically near the top of `main`.
182/// After this call, `tatara_lisp::domain::lookup("defpoint")` and
183/// `lookup("defephemeral")` both resolve to the right typed compiler.
184///
185/// Idempotent — registering the same type twice is a no-op.
186pub fn register_all() {
187    tatara_lisp::domain::register::<crate::crd::ProcessSpec>();
188    tatara_lisp::domain::register::<crate::ephemeral::EphemeralSpec>();
189}
190
191#[cfg(test)]
192mod compile_tests {
193    use super::compile_source;
194    use crate::classification::{ConvergencePointType, SubstrateType};
195    use crate::compliance::VerificationPhase;
196    use crate::spec::MustReachPhase;
197
198    /// The full derive-powered pipeline — no hand-rolled parsing anywhere.
199    /// Every field travels: Lisp → Sexp → serde_json → typed ProcessSpec.
200    #[test]
201    fn full_processspec_round_trip_via_derive() {
202        let src = r#"
203            (defpoint observability-stack
204              :identity       (:parent "seph.1")
205              :classification (:point-type Gate
206                               :substrate Observability
207                               :horizon (:kind Bounded)
208                               :calm Monotone
209                               :data-classification Internal)
210              :intent         (:nix (:flake-ref "github:pleme-io/k8s"
211                                     :attribute "observability"
212                                     :attic-cache "main"))
213              :boundary       (:postconditions
214                                 ((:kind KustomizationHealthy
215                                   :params (:name "observability-stack"
216                                            :namespace "flux-system"))
217                                  (:kind PromQL
218                                   :params (:query "up == 1")))
219                               :timeout "15m")
220              :compliance     (:baseline "fedramp-moderate"
221                               :bindings ((:framework "nist-800-53"
222                                           :control-id "SC-7"
223                                           :phase AtBoundary)))
224              :depends-on     ((:name "secret-injection" :must-reach Attested))
225              :signals        (:sigterm-grace-seconds 480
226                               :sighup-strategy Reconverge))
227        "#;
228        let defs = compile_source(src).expect("compile");
229        assert_eq!(defs.len(), 1);
230        let d = &defs[0];
231        assert_eq!(d.name, "observability-stack");
232
233        // identity
234        assert_eq!(d.spec.identity.parent.as_deref(), Some("seph.1"));
235
236        // classification (enums deserialized via symbol → string)
237        assert_eq!(d.spec.classification.point_type, ConvergencePointType::Gate);
238        assert_eq!(
239            d.spec.classification.substrate,
240            SubstrateType::Observability
241        );
242
243        // intent (tagged-union with one of four options)
244        let nix = d.spec.intent.nix.as_ref().expect("nix intent");
245        assert_eq!(nix.flake_ref, "github:pleme-io/k8s");
246        assert_eq!(nix.attribute, "observability");
247        assert_eq!(nix.attic_cache.as_deref(), Some("main"));
248
249        // boundary (Vec<nested struct with params object>)
250        assert_eq!(d.spec.boundary.postconditions.len(), 2);
251        assert_eq!(d.spec.boundary.timeout.as_deref(), Some("15m"));
252
253        // compliance (Vec<binding with enum phase>)
254        assert_eq!(
255            d.spec.compliance.baseline.as_deref(),
256            Some("fedramp-moderate")
257        );
258        assert_eq!(d.spec.compliance.bindings.len(), 1);
259        assert_eq!(
260            d.spec.compliance.bindings[0].phase,
261            VerificationPhase::AtBoundary
262        );
263
264        // depends_on (Vec<struct with enum>)
265        assert_eq!(d.spec.depends_on.len(), 1);
266        assert_eq!(d.spec.depends_on[0].must_reach, MustReachPhase::Attested);
267
268        // signals (numeric + enum defaults)
269        assert_eq!(d.spec.signals.sigterm_grace_seconds, 480);
270    }
271
272    #[test]
273    fn missing_required_field_errors() {
274        // `:classification` has no #[serde(default)] — omit it and compile must fail.
275        let src = r#"(defpoint x :intent (:nix (:flake-ref "f" :attribute "a")))"#;
276        assert!(compile_source(src).is_err());
277    }
278
279    #[test]
280    fn serde_default_fields_are_optional() {
281        // Omit every #[serde(default)] field — compile must succeed because
282        // the derive honors serde defaults.
283        let src = r#"
284            (defpoint x
285              :classification (:point-type Transform :substrate Compute)
286              :intent (:flux (:git-repository "g" :path ".")))
287        "#;
288        let defs = compile_source(src).expect("compile");
289        assert_eq!(defs.len(), 1);
290        let d = &defs[0];
291        assert!(d.spec.depends_on.is_empty());
292        assert!(d.spec.boundary.postconditions.is_empty());
293        assert!(d.spec.compliance.bindings.is_empty());
294        assert!(!d.spec.suspended);
295        // Lifetime defaults to Permanent (no variant set, resolver still works).
296        assert!(d.spec.lifetime.is_default());
297        assert!(!d.spec.lifetime.is_ephemeral());
298    }
299
300    /// Registering all process-owned domains is idempotent and resolves
301    /// both `defpoint` (ProcessSpec) and `defephemeral` (EphemeralSpec).
302    #[test]
303    fn register_all_resolves_defpoint_and_defephemeral() {
304        use tatara_lisp::domain::lookup;
305        super::register_all();
306        super::register_all(); // idempotent
307        assert!(lookup("defpoint").is_some(), "defpoint must resolve");
308        assert!(
309            lookup("defephemeral").is_some(),
310            "defephemeral must resolve"
311        );
312    }
313
314    /// End-to-end: a `(defpoint …)` form may carry the full ephemeral
315    /// shape directly — `:intent (:aplicacao …)` + `:lifetime (:ephemeral …)`.
316    /// This is what the `(defephemeral …)` sugar lowers to via `From`.
317    #[test]
318    fn defpoint_with_aplicacao_intent_and_ephemeral_lifetime() {
319        use crate::intent::IntentVariant;
320        use crate::lifetime::{LifetimeVariant, TeardownPolicy};
321        let src = r#"
322            (defpoint closed-loop-attest
323              :classification (:point-type Gate :substrate Compute)
324              :intent (:aplicacao
325                        (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
326                         :version "0.5.5"
327                         :profile "all-in-one"
328                         :values-overlay (:cluster (:name "ephemeral-test-01"))
329                         :target-namespace "demo-test"))
330              :boundary (:postconditions
331                          ((:kind HelmReleaseReleased
332                            :params (:name "demo-app-consolidated"
333                                     :namespace "demo-test"))
334                           (:kind ClosedLoopAuth
335                            :params (:issuer (:service "demo-app-issuer" :port 8080)
336                                     :consumer (:service "demo-app-gateway" :port 8000)
337                                     :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
338              :lifetime (:ephemeral (:ttl "1h"
339                                     :teardown-policy OnAttested
340                                     :max-concurrent 1)))
341        "#;
342        let defs = compile_source(src).expect("compile");
343        assert_eq!(defs.len(), 1);
344        let d = &defs[0];
345
346        // Aplicacao intent landed.
347        match d.spec.intent.variant().unwrap() {
348            IntentVariant::Aplicacao(a) => {
349                assert_eq!(a.profile, "all-in-one");
350                assert_eq!(a.version, "0.5.5");
351                assert_eq!(a.target_namespace.as_deref(), Some("demo-test"));
352                assert_eq!(a.values_overlay["cluster"]["name"], "ephemeral-test-01");
353            }
354            other => panic!("expected Aplicacao, got {other:?}"),
355        }
356
357        // Ephemeral lifetime landed with the right teardown policy.
358        match d.spec.lifetime.variant().unwrap() {
359            LifetimeVariant::Ephemeral(e) => {
360                assert_eq!(e.ttl, "1h");
361                assert_eq!(e.teardown_policy, TeardownPolicy::OnAttested);
362                assert_eq!(e.max_concurrent, 1);
363            }
364            other => panic!("expected ephemeral, got {other:?}"),
365        }
366
367        // Two typed postconditions including ClosedLoopAuth.
368        assert_eq!(d.spec.boundary.postconditions.len(), 2);
369        assert_eq!(
370            d.spec.boundary.postconditions[1].kind,
371            crate::boundary::ConditionKind::ClosedLoopAuth
372        );
373    }
374}