Skip to main content

tatara_process/
ephemeral.rs

1//! `EphemeralSpec` — the operator-facing typed surface for ephemeral
2//! Aplicacao installations.
3//!
4//! `EphemeralSpec` is *sugar* on top of `ProcessSpec`. The compounding move
5//! is to keep one wire format (`Process`, the Unix-process CRD) and let
6//! ephemeral envs be a Process with `:intent (:aplicacao …)` +
7//! `:lifetime (:ephemeral …)`. This struct gives that combination a
8//! dedicated `(defephemeral …)` keyword and a typed `From` bridge so
9//! authoring stays first-class without forking the CRD.
10//!
11//! Lisp authoring:
12//! ```lisp
13//! (defephemeral closed-loop-attest
14//!   :aplicacao  (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
15//!                :version "0.5.5"
16//!                :profile "all-in-one"
17//!                :values-overlay (:cluster (:name "ephemeral-test-01")
18//!                                 :persistence false))
19//!   :ttl        "1h"
20//!   :teardown   OnAttested
21//!   :postconditions
22//!     ((:kind HelmReleaseReleased
23//!       :params (:name "demo-app-consolidated"
24//!                :namespace "demo-test"))
25//!      (:kind ClosedLoopAuth
26//!       :params (:issuer (:service "demo-app-issuer" :port 8080)
27//!                :consumer (:service "demo-app-gateway" :port 8000)
28//!                :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
29//! ```
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use tatara_lisp::DeriveTataraDomain;
34
35use crate::boundary::{Boundary, Condition};
36use crate::classification::Classification;
37use crate::crd::ProcessSpec;
38use crate::export::ExportSpec;
39use crate::intent::{AplicacaoIntent, Intent};
40use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
41use crate::routing::RoutingSpec;
42
43/// `EphemeralSpec` — typed wrapper that authors `(defephemeral …)`.
44///
45/// Lowers to a `ProcessSpec` via `From<EphemeralSpec>` — the bridge is
46/// pure-typed, no string substitution. Defaults to `point_type = Gate`,
47/// `substrate = Compute`, `data_classification = Internal` — every field
48/// can be overridden via the full `(defpoint …)` form when the operator
49/// needs the lower-level surface.
50#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "camelCase")]
52#[tatara(keyword = "defephemeral")]
53pub struct EphemeralSpec {
54    /// The Aplicacao chart + profile + overlay to install.
55    pub aplicacao: AplicacaoIntent,
56
57    /// TTL — `humantime` duration (`"1h"`, `"30m"`).
58    #[serde(default = "crate::lifetime::default_ephemeral_ttl")]
59    pub ttl: String,
60
61    /// When the ephemeral Process auto-terminates.
62    #[serde(default)]
63    pub teardown: TeardownPolicy,
64
65    /// Cluster-wide concurrency budget across ephemeral Processes sharing
66    /// the same `:aplicacao :chart-ref`. `0` = no cap.
67    #[serde(default = "crate::lifetime::default_ephemeral_max_concurrent")]
68    pub max_concurrent: u32,
69
70    /// Boundary postconditions evaluated before reaching `Attested`.
71    /// Typically `HelmReleaseReleased` plus one or more `ClosedLoopAuth`
72    /// / `JobAttested` checks for test suites + closed-loop probes.
73    #[serde(default)]
74    pub postconditions: Vec<Condition>,
75
76    /// Optional boundary preconditions (Namespace, Issuer, PullSecret
77    /// readiness etc.).
78    #[serde(default)]
79    pub preconditions: Vec<Condition>,
80
81    /// VERIFY-phase timeout. Empty = controller default.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub verify_timeout: Option<String>,
84
85    /// Optional Process classification override. When omitted, defaults
86    /// to `Gate / Compute / Internal / Bounded / NonMonotone`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub classification: Option<Classification>,
89
90    /// Optional parent PID path.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub parent: Option<String>,
93
94    /// Declared exports — sugar that propagates through to
95    /// `lifetime.ephemeral.exports` on the lowered `ProcessSpec`.
96    /// Default empty = zero-trace ephemeral (nothing survives
97    /// teardown). See [`crate::export`] for the full type.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub exports: Vec<ExportSpec>,
100
101    /// Routing template — DNS + Ingress declarations inherited by
102    /// the materialized `ProcessSpec`. When set on a pool's
103    /// `template`, every member receives the same shape; each
104    /// member's content-hash form differs by its own canonical
105    /// spec (which differs across members by slot index).
106    /// See [`crate::routing`].
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub routing: Option<RoutingSpec>,
109}
110
111// `default_ttl` + `default_max_concurrent` bindings for the two serde
112// `#[serde(default = "…")]` slots above route through the ONE
113// substrate owner [`crate::lifetime::default_ephemeral_ttl`] +
114// [`crate::lifetime::default_ephemeral_max_concurrent`] — peer of
115// the [`EphemeralLifetime`] serde-default slots on the SAME
116// workspace-canonical "ephemeral wire-form defaults" axis.
117// Pre-lift both slots carried their own private
118// `fn default_*` shims that returned bytewise-identical `"1h"` /
119// `1` values as the peer [`EphemeralLifetime`] slots — one of THREE
120// (TTL) and TWO (max-concurrent) restatements past the ★★ PRIME-
121// DIRECTIVE ≥ 2 duplication threshold. See the substrate owner's
122// doc-comment for the full migration rationale.
123
124impl From<EphemeralSpec> for ProcessSpec {
125    fn from(e: EphemeralSpec) -> Self {
126        let classification = e.classification.unwrap_or_else(default_ephemeral_class);
127        let mut spec = Self {
128            identity: crate::spec::IdentitySpec {
129                parent: e.parent,
130                name_override: None,
131            },
132            classification,
133            intent: Intent {
134                aplicacao: Some(e.aplicacao),
135                ..Intent::default()
136            },
137            boundary: Boundary {
138                preconditions: e.preconditions,
139                postconditions: e.postconditions,
140                timeout: e.verify_timeout,
141            },
142            compliance: Default::default(),
143            depends_on: vec![],
144            signals: Default::default(),
145            // Routes through the ONE substrate composer
146            // [`Lifetime::ephemeral`] — pre-lift this was one of
147            // ELEVEN+ hand-authored `Lifetime { ephemeral: Some(<e>),
148            // .. }` sites past the ★★ PRIME-DIRECTIVE ≥ 2 threshold.
149            // See the composer's doc-comment for the full migration
150            // rationale.
151            lifetime: Lifetime::ephemeral(EphemeralLifetime {
152                ttl: e.ttl,
153                teardown_policy: e.teardown,
154                max_concurrent: e.max_concurrent,
155                exports: e.exports,
156            }),
157            // R5 — propagate routing template (None = no edges).
158            routing: e.routing,
159            // EncapsulatesSpec isn't exposed via EphemeralSpec sugar;
160            // operators wanting Adopt/Observe author the full
161            // (defpoint …) form. Sugar path stays greenfield-Manage.
162            encapsulates: None,
163            suspended: false,
164        };
165        // Belt-and-suspenders: make sure exactly-one Intent invariant holds.
166        spec.intent.nix = None;
167        spec.intent.flux = None;
168        spec.intent.lisp = None;
169        spec.intent.container = None;
170        spec.intent.guest = None;
171        spec
172    }
173}
174
175fn default_ephemeral_class() -> Classification {
176    // Delegates through the substrate `(Gate, Compute)` baseline owner
177    // so the shape lives at ONE workspace-wide site — see
178    // [`Classification::gate_compute`] for the pre-lift ten-callsite
179    // duplication history and the sibling-default correspondence
180    // pinned there.
181    Classification::gate_compute()
182}
183
184/// Compile a `(defephemeral …)` Lisp source into named `EphemeralSpec` values.
185pub fn compile_ephemeral_source(
186    src: &str,
187) -> tatara_lisp::Result<Vec<tatara_lisp::NamedDefinition<EphemeralSpec>>> {
188    tatara_lisp::compile_named::<EphemeralSpec>(src)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::boundary::ConditionKind;
195    use crate::classification::{ConvergencePointType, SubstrateType};
196    use crate::intent::IntentVariant;
197    use crate::lifetime::LifetimeVariant;
198
199    fn demo_overlay() -> AplicacaoIntent {
200        AplicacaoIntent {
201            chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
202            version: "0.5.5".into(),
203            profile: "all-in-one".into(),
204            values_overlay: serde_json::json!({
205                "cluster": { "name": "ephemeral-test-01", "namespace": "demo-test" },
206                "data": { "mysql": { "persistence": { "enabled": false } } },
207                "compliance": { "overlays": [] }
208            }),
209            release_name: Some("demo-app-consolidated".into()),
210            target_namespace: Some("demo-test".into()),
211            install_timeout: Some("25m".into()),
212        }
213    }
214
215    #[test]
216    fn defaults_resolve_for_ephemeral_spec() {
217        let e = EphemeralSpec {
218            aplicacao: demo_overlay(),
219            ttl: crate::lifetime::default_ephemeral_ttl(),
220            teardown: TeardownPolicy::default(),
221            max_concurrent: crate::lifetime::default_ephemeral_max_concurrent(),
222            postconditions: vec![],
223            preconditions: vec![],
224            verify_timeout: None,
225            classification: None,
226            parent: None,
227            exports: vec![],
228            routing: None,
229        };
230        let ps: ProcessSpec = e.into();
231        // Intent must resolve to Aplicacao.
232        match ps.intent.variant().unwrap() {
233            IntentVariant::Aplicacao(a) => {
234                assert_eq!(a.profile, "all-in-one");
235                assert_eq!(a.install_timeout.as_deref(), Some("25m"));
236            }
237            other => panic!("expected Aplicacao, got {other:?}"),
238        }
239        // Lifetime must resolve to Ephemeral with defaults.
240        match ps.lifetime.variant().unwrap() {
241            LifetimeVariant::Ephemeral(e) => {
242                assert_eq!(e.ttl, "1h");
243                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
244            }
245            other => panic!("expected ephemeral, got {other:?}"),
246        }
247        // Default classification gates the Process at Compute/Internal.
248        assert_eq!(ps.classification.point_type, ConvergencePointType::Gate);
249        assert_eq!(ps.classification.substrate, SubstrateType::Compute);
250    }
251
252    #[test]
253    fn ephemeral_lisp_round_trip() {
254        let src = r#"
255            (defephemeral closed-loop-attest
256              :aplicacao (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
257                          :version "0.5.5"
258                          :profile "all-in-one"
259                          :values-overlay (:cluster (:name "ephemeral-test-01")
260                                           :data (:mysql (:persistence (:enabled #f)))
261                                           :compliance (:overlays []))
262                          :release-name "demo-app-consolidated"
263                          :target-namespace "demo-test"
264                          :install-timeout "25m")
265              :ttl "1h"
266              :teardown OnAttested
267              :max-concurrent 1
268              :postconditions
269                ((:kind HelmReleaseReleased
270                  :params (:name "demo-app-consolidated"
271                           :namespace "demo-test"))
272                 (:kind ClosedLoopAuth
273                  :params (:issuer (:service "demo-app-issuer" :port 8080)
274                           :consumer (:service "demo-app-gateway" :port 8000)
275                           :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
276        "#;
277        let defs = compile_ephemeral_source(src).expect("compile");
278        assert_eq!(defs.len(), 1);
279        let d = &defs[0];
280        assert_eq!(d.name, "closed-loop-attest");
281
282        // Aplicacao body landed correctly.
283        assert_eq!(
284            d.spec.aplicacao.chart_ref,
285            "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
286        );
287        assert_eq!(d.spec.aplicacao.profile, "all-in-one");
288        assert_eq!(
289            d.spec.aplicacao.target_namespace.as_deref(),
290            Some("demo-test")
291        );
292        // values-overlay JSON is preserved.
293        assert_eq!(
294            d.spec.aplicacao.values_overlay["cluster"]["name"],
295            "ephemeral-test-01"
296        );
297        // Boolean #f is preserved as a typed JSON bool (not the string "false").
298        // tatara-lisp uses Scheme syntax for bools — `#t` / `#f`.
299        assert_eq!(
300            d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
301            false
302        );
303
304        // Lifetime knobs.
305        assert_eq!(d.spec.ttl, "1h");
306        assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
307        assert_eq!(d.spec.max_concurrent, 1);
308
309        // Two postconditions, both typed.
310        assert_eq!(d.spec.postconditions.len(), 2);
311        assert_eq!(
312            d.spec.postconditions[0].kind,
313            ConditionKind::HelmReleaseReleased
314        );
315        assert_eq!(d.spec.postconditions[1].kind, ConditionKind::ClosedLoopAuth);
316
317        // Lowers to ProcessSpec with the right shape.
318        let ps: ProcessSpec = d.spec.clone().into();
319        assert!(matches!(
320            ps.intent.variant().unwrap(),
321            IntentVariant::Aplicacao(_)
322        ));
323        assert!(matches!(
324            ps.lifetime.variant().unwrap(),
325            LifetimeVariant::Ephemeral(_)
326        ));
327        assert_eq!(ps.boundary.postconditions.len(), 2);
328    }
329
330    /// End-to-end: the `:exports` slot on `(defephemeral …)` compiles
331    /// into typed `ExportSpec` values via the Universal-Deserialize
332    /// fallthrough — no per-domain keyword handlers needed.
333    ///
334    /// Receipts (empty-body source) is exercised via the Rust serde
335    /// path only (see `export::tests::export_spec_serde_round_trip`).
336    /// tatara-lisp's empty-kw-form `(:)` currently parses as a single-
337    /// element array rather than a JSON `{}`; the same limitation
338    /// affects `(:permanent)` on Lifetime. Tracked: extend the reader
339    /// to accept `(:foo (:))` ⇒ `{"foo": {}}` as a typed-empty form,
340    /// then re-enable Receipts here.
341    #[test]
342    fn exports_lisp_round_trip() {
343        use crate::export::{ArtifactVariant, ChannelVariant, ExportTrigger, ReportFormat};
344        let src = r#"
345            (defephemeral closed-loop-attest
346              :aplicacao (:chart-ref "oci://x"
347                          :version "1.0.0"
348                          :profile "minimal"
349                          :values-overlay ())
350              :ttl "30m"
351              :teardown OnAttested
352              :exports
353                ((:source  (:test-report (:configmap "junit-results"
354                                          :key       "junit.xml"
355                                          :format    Junit))
356                  :channel (:nats-subject (:subject "pleme.pleme-dev.ephemeral.r1.test-report"
357                                           :stream  "EPHEMERAL_TEST_REPORTS"))
358                  :when    OnAttested)
359                 (:source  (:test-report (:configmap "junit-results"
360                                          :key       "junit.xml"
361                                          :format    Junit))
362                  :channel (:http-event (:signal-type "test-report"))
363                  :when    Always)
364                 (:source  (:run-marker (:labels (:run-id "r1" :phase "end")))
365                  :channel (:http-event (:signal-type "ephemeral-marker"))
366                  :when    Always)))
367        "#;
368        let defs = compile_ephemeral_source(src).expect("compile");
369        assert_eq!(defs.len(), 1);
370        let d = &defs[0];
371        assert_eq!(d.spec.exports.len(), 3);
372
373        // First export — TestReport → NATS subject + OnAttested
374        let r = &d.spec.exports[0];
375        match r.source.variant().unwrap() {
376            ArtifactVariant::TestReport(tr) => {
377                assert_eq!(tr.configmap, "junit-results");
378                assert_eq!(tr.format, ReportFormat::Junit);
379            }
380            other => panic!("expected TestReport, got {other:?}"),
381        }
382        match r.channel.variant().unwrap() {
383            ChannelVariant::NatsSubject(n) => {
384                assert_eq!(n.subject, "pleme.pleme-dev.ephemeral.r1.test-report");
385                assert_eq!(n.stream, "EPHEMERAL_TEST_REPORTS");
386            }
387            other => panic!("expected NatsSubject, got {other:?}"),
388        }
389        assert_eq!(r.when, ExportTrigger::OnAttested);
390
391        // Second export — TestReport → HTTP + Always
392        let t = &d.spec.exports[1];
393        match t.channel.variant().unwrap() {
394            ChannelVariant::HttpEvent(h) => assert_eq!(h.signal_type, "test-report"),
395            other => panic!("expected HttpEvent, got {other:?}"),
396        }
397        assert_eq!(t.when, ExportTrigger::Always);
398
399        // Third export — RunMarker (BTreeMap<String,String> round-trip).
400        // tatara-lisp lowercases + normalizes keyword keys before
401        // handing off to serde_json — kebab `:run-id` may land as
402        // either `run-id` or `runId` depending on the reader path.
403        // Accept either; the round-trip property under test is
404        // "label survives compile" not "exact case-form".
405        let m = &d.spec.exports[2];
406        match m.source.variant().unwrap() {
407            ArtifactVariant::RunMarker(rm) => {
408                assert_eq!(rm.labels.len(), 2);
409                let run_id = rm
410                    .labels
411                    .get("run-id")
412                    .or_else(|| rm.labels.get("runId"))
413                    .or_else(|| rm.labels.get("run_id"))
414                    .expect("run-id label present under some normalization");
415                assert_eq!(run_id, "r1");
416                assert_eq!(rm.labels.get("phase").map(String::as_str), Some("end"));
417            }
418            other => panic!("expected RunMarker, got {other:?}"),
419        }
420
421        // Lowered ProcessSpec carries the exports through unchanged.
422        let ps: ProcessSpec = d.spec.clone().into();
423        assert_eq!(ps.lifetime.ephemeral.as_ref().unwrap().exports.len(), 3);
424    }
425
426    #[test]
427    fn from_impl_clears_other_intent_variants() {
428        // Even if someone constructs an EphemeralSpec by hand and the
429        // resulting ProcessSpec is later mutated, the From bridge sets
430        // every non-Aplicacao slot to None explicitly.
431        let e = EphemeralSpec {
432            aplicacao: demo_overlay(),
433            ttl: "10m".into(),
434            teardown: TeardownPolicy::Never,
435            max_concurrent: 0,
436            postconditions: vec![],
437            preconditions: vec![],
438            verify_timeout: None,
439            classification: None,
440            parent: Some("seph.1".into()),
441            exports: vec![],
442            routing: None,
443        };
444        let ps: ProcessSpec = e.into();
445        assert!(ps.intent.nix.is_none());
446        assert!(ps.intent.flux.is_none());
447        assert!(ps.intent.lisp.is_none());
448        assert!(ps.intent.container.is_none());
449        assert!(ps.intent.guest.is_none());
450        assert!(ps.intent.aplicacao.is_some());
451        assert_eq!(ps.identity.parent.as_deref(), Some("seph.1"));
452    }
453}