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 = "default_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 = "default_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
111fn default_ttl() -> String {
112    "1h".to_string()
113}
114fn default_max_concurrent() -> u32 {
115    1
116}
117
118impl From<EphemeralSpec> for ProcessSpec {
119    fn from(e: EphemeralSpec) -> Self {
120        let classification = e.classification.unwrap_or_else(default_ephemeral_class);
121        let mut spec = Self {
122            identity: crate::spec::IdentitySpec {
123                parent: e.parent,
124                name_override: None,
125            },
126            classification,
127            intent: Intent {
128                aplicacao: Some(e.aplicacao),
129                ..Intent::default()
130            },
131            boundary: Boundary {
132                preconditions: e.preconditions,
133                postconditions: e.postconditions,
134                timeout: e.verify_timeout,
135            },
136            compliance: Default::default(),
137            depends_on: vec![],
138            signals: Default::default(),
139            lifetime: Lifetime {
140                ephemeral: Some(EphemeralLifetime {
141                    ttl: e.ttl,
142                    teardown_policy: e.teardown,
143                    max_concurrent: e.max_concurrent,
144                    exports: e.exports,
145                }),
146                ..Lifetime::default()
147            },
148            // R5 — propagate routing template (None = no edges).
149            routing: e.routing,
150            // EncapsulatesSpec isn't exposed via EphemeralSpec sugar;
151            // operators wanting Adopt/Observe author the full
152            // (defpoint …) form. Sugar path stays greenfield-Manage.
153            encapsulates: None,
154            suspended: false,
155        };
156        // Belt-and-suspenders: make sure exactly-one Intent invariant holds.
157        spec.intent.nix = None;
158        spec.intent.flux = None;
159        spec.intent.lisp = None;
160        spec.intent.container = None;
161        spec.intent.guest = None;
162        spec
163    }
164}
165
166fn default_ephemeral_class() -> Classification {
167    // Delegates through the substrate `(Gate, Compute)` baseline owner
168    // so the shape lives at ONE workspace-wide site — see
169    // [`Classification::gate_compute`] for the pre-lift ten-callsite
170    // duplication history and the sibling-default correspondence
171    // pinned there.
172    Classification::gate_compute()
173}
174
175/// Compile a `(defephemeral …)` Lisp source into named `EphemeralSpec` values.
176pub fn compile_ephemeral_source(
177    src: &str,
178) -> tatara_lisp::Result<Vec<tatara_lisp::NamedDefinition<EphemeralSpec>>> {
179    tatara_lisp::compile_named::<EphemeralSpec>(src)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::boundary::ConditionKind;
186    use crate::classification::{ConvergencePointType, SubstrateType};
187    use crate::intent::IntentVariant;
188    use crate::lifetime::LifetimeVariant;
189
190    fn demo_overlay() -> AplicacaoIntent {
191        AplicacaoIntent {
192            chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
193            version: "0.5.5".into(),
194            profile: "all-in-one".into(),
195            values_overlay: serde_json::json!({
196                "cluster": { "name": "ephemeral-test-01", "namespace": "demo-test" },
197                "data": { "mysql": { "persistence": { "enabled": false } } },
198                "compliance": { "overlays": [] }
199            }),
200            release_name: Some("demo-app-consolidated".into()),
201            target_namespace: Some("demo-test".into()),
202            install_timeout: Some("25m".into()),
203        }
204    }
205
206    #[test]
207    fn defaults_resolve_for_ephemeral_spec() {
208        let e = EphemeralSpec {
209            aplicacao: demo_overlay(),
210            ttl: default_ttl(),
211            teardown: TeardownPolicy::default(),
212            max_concurrent: default_max_concurrent(),
213            postconditions: vec![],
214            preconditions: vec![],
215            verify_timeout: None,
216            classification: None,
217            parent: None,
218            exports: vec![],
219            routing: None,
220        };
221        let ps: ProcessSpec = e.into();
222        // Intent must resolve to Aplicacao.
223        match ps.intent.variant().unwrap() {
224            IntentVariant::Aplicacao(a) => {
225                assert_eq!(a.profile, "all-in-one");
226                assert_eq!(a.install_timeout.as_deref(), Some("25m"));
227            }
228            other => panic!("expected Aplicacao, got {other:?}"),
229        }
230        // Lifetime must resolve to Ephemeral with defaults.
231        match ps.lifetime.variant().unwrap() {
232            LifetimeVariant::Ephemeral(e) => {
233                assert_eq!(e.ttl, "1h");
234                assert_eq!(e.teardown_policy, TeardownPolicy::Always);
235            }
236            other => panic!("expected ephemeral, got {other:?}"),
237        }
238        // Default classification gates the Process at Compute/Internal.
239        assert_eq!(ps.classification.point_type, ConvergencePointType::Gate);
240        assert_eq!(ps.classification.substrate, SubstrateType::Compute);
241    }
242
243    #[test]
244    fn ephemeral_lisp_round_trip() {
245        let src = r#"
246            (defephemeral closed-loop-attest
247              :aplicacao (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
248                          :version "0.5.5"
249                          :profile "all-in-one"
250                          :values-overlay (:cluster (:name "ephemeral-test-01")
251                                           :data (:mysql (:persistence (:enabled #f)))
252                                           :compliance (:overlays []))
253                          :release-name "demo-app-consolidated"
254                          :target-namespace "demo-test"
255                          :install-timeout "25m")
256              :ttl "1h"
257              :teardown OnAttested
258              :max-concurrent 1
259              :postconditions
260                ((:kind HelmReleaseReleased
261                  :params (:name "demo-app-consolidated"
262                           :namespace "demo-test"))
263                 (:kind ClosedLoopAuth
264                  :params (:issuer (:service "demo-app-issuer" :port 8080)
265                           :consumer (:service "demo-app-gateway" :port 8000)
266                           :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
267        "#;
268        let defs = compile_ephemeral_source(src).expect("compile");
269        assert_eq!(defs.len(), 1);
270        let d = &defs[0];
271        assert_eq!(d.name, "closed-loop-attest");
272
273        // Aplicacao body landed correctly.
274        assert_eq!(
275            d.spec.aplicacao.chart_ref,
276            "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
277        );
278        assert_eq!(d.spec.aplicacao.profile, "all-in-one");
279        assert_eq!(
280            d.spec.aplicacao.target_namespace.as_deref(),
281            Some("demo-test")
282        );
283        // values-overlay JSON is preserved.
284        assert_eq!(
285            d.spec.aplicacao.values_overlay["cluster"]["name"],
286            "ephemeral-test-01"
287        );
288        // Boolean #f is preserved as a typed JSON bool (not the string "false").
289        // tatara-lisp uses Scheme syntax for bools — `#t` / `#f`.
290        assert_eq!(
291            d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
292            false
293        );
294
295        // Lifetime knobs.
296        assert_eq!(d.spec.ttl, "1h");
297        assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
298        assert_eq!(d.spec.max_concurrent, 1);
299
300        // Two postconditions, both typed.
301        assert_eq!(d.spec.postconditions.len(), 2);
302        assert_eq!(
303            d.spec.postconditions[0].kind,
304            ConditionKind::HelmReleaseReleased
305        );
306        assert_eq!(d.spec.postconditions[1].kind, ConditionKind::ClosedLoopAuth);
307
308        // Lowers to ProcessSpec with the right shape.
309        let ps: ProcessSpec = d.spec.clone().into();
310        assert!(matches!(
311            ps.intent.variant().unwrap(),
312            IntentVariant::Aplicacao(_)
313        ));
314        assert!(matches!(
315            ps.lifetime.variant().unwrap(),
316            LifetimeVariant::Ephemeral(_)
317        ));
318        assert_eq!(ps.boundary.postconditions.len(), 2);
319    }
320
321    /// End-to-end: the `:exports` slot on `(defephemeral …)` compiles
322    /// into typed `ExportSpec` values via the Universal-Deserialize
323    /// fallthrough — no per-domain keyword handlers needed.
324    ///
325    /// Receipts (empty-body source) is exercised via the Rust serde
326    /// path only (see `export::tests::export_spec_serde_round_trip`).
327    /// tatara-lisp's empty-kw-form `(:)` currently parses as a single-
328    /// element array rather than a JSON `{}`; the same limitation
329    /// affects `(:permanent)` on Lifetime. Tracked: extend the reader
330    /// to accept `(:foo (:))` ⇒ `{"foo": {}}` as a typed-empty form,
331    /// then re-enable Receipts here.
332    #[test]
333    fn exports_lisp_round_trip() {
334        use crate::export::{ArtifactVariant, ChannelVariant, ExportTrigger, ReportFormat};
335        let src = r#"
336            (defephemeral closed-loop-attest
337              :aplicacao (:chart-ref "oci://x"
338                          :version "1.0.0"
339                          :profile "minimal"
340                          :values-overlay ())
341              :ttl "30m"
342              :teardown OnAttested
343              :exports
344                ((:source  (:test-report (:configmap "junit-results"
345                                          :key       "junit.xml"
346                                          :format    Junit))
347                  :channel (:nats-subject (:subject "pleme.pleme-dev.ephemeral.r1.test-report"
348                                           :stream  "EPHEMERAL_TEST_REPORTS"))
349                  :when    OnAttested)
350                 (:source  (:test-report (:configmap "junit-results"
351                                          :key       "junit.xml"
352                                          :format    Junit))
353                  :channel (:http-event (:signal-type "test-report"))
354                  :when    Always)
355                 (:source  (:run-marker (:labels (:run-id "r1" :phase "end")))
356                  :channel (:http-event (:signal-type "ephemeral-marker"))
357                  :when    Always)))
358        "#;
359        let defs = compile_ephemeral_source(src).expect("compile");
360        assert_eq!(defs.len(), 1);
361        let d = &defs[0];
362        assert_eq!(d.spec.exports.len(), 3);
363
364        // First export — TestReport → NATS subject + OnAttested
365        let r = &d.spec.exports[0];
366        match r.source.variant().unwrap() {
367            ArtifactVariant::TestReport(tr) => {
368                assert_eq!(tr.configmap, "junit-results");
369                assert_eq!(tr.format, ReportFormat::Junit);
370            }
371            other => panic!("expected TestReport, got {other:?}"),
372        }
373        match r.channel.variant().unwrap() {
374            ChannelVariant::NatsSubject(n) => {
375                assert_eq!(n.subject, "pleme.pleme-dev.ephemeral.r1.test-report");
376                assert_eq!(n.stream, "EPHEMERAL_TEST_REPORTS");
377            }
378            other => panic!("expected NatsSubject, got {other:?}"),
379        }
380        assert_eq!(r.when, ExportTrigger::OnAttested);
381
382        // Second export — TestReport → HTTP + Always
383        let t = &d.spec.exports[1];
384        match t.channel.variant().unwrap() {
385            ChannelVariant::HttpEvent(h) => assert_eq!(h.signal_type, "test-report"),
386            other => panic!("expected HttpEvent, got {other:?}"),
387        }
388        assert_eq!(t.when, ExportTrigger::Always);
389
390        // Third export — RunMarker (BTreeMap<String,String> round-trip).
391        // tatara-lisp lowercases + normalizes keyword keys before
392        // handing off to serde_json — kebab `:run-id` may land as
393        // either `run-id` or `runId` depending on the reader path.
394        // Accept either; the round-trip property under test is
395        // "label survives compile" not "exact case-form".
396        let m = &d.spec.exports[2];
397        match m.source.variant().unwrap() {
398            ArtifactVariant::RunMarker(rm) => {
399                assert_eq!(rm.labels.len(), 2);
400                let run_id = rm
401                    .labels
402                    .get("run-id")
403                    .or_else(|| rm.labels.get("runId"))
404                    .or_else(|| rm.labels.get("run_id"))
405                    .expect("run-id label present under some normalization");
406                assert_eq!(run_id, "r1");
407                assert_eq!(rm.labels.get("phase").map(String::as_str), Some("end"));
408            }
409            other => panic!("expected RunMarker, got {other:?}"),
410        }
411
412        // Lowered ProcessSpec carries the exports through unchanged.
413        let ps: ProcessSpec = d.spec.clone().into();
414        assert_eq!(ps.lifetime.ephemeral.as_ref().unwrap().exports.len(), 3);
415    }
416
417    #[test]
418    fn from_impl_clears_other_intent_variants() {
419        // Even if someone constructs an EphemeralSpec by hand and the
420        // resulting ProcessSpec is later mutated, the From bridge sets
421        // every non-Aplicacao slot to None explicitly.
422        let e = EphemeralSpec {
423            aplicacao: demo_overlay(),
424            ttl: "10m".into(),
425            teardown: TeardownPolicy::Never,
426            max_concurrent: 0,
427            postconditions: vec![],
428            preconditions: vec![],
429            verify_timeout: None,
430            classification: None,
431            parent: Some("seph.1".into()),
432            exports: vec![],
433            routing: None,
434        };
435        let ps: ProcessSpec = e.into();
436        assert!(ps.intent.nix.is_none());
437        assert!(ps.intent.flux.is_none());
438        assert!(ps.intent.lisp.is_none());
439        assert!(ps.intent.container.is_none());
440        assert!(ps.intent.guest.is_none());
441        assert!(ps.intent.aplicacao.is_some());
442        assert_eq!(ps.identity.parent.as_deref(), Some("seph.1"));
443    }
444}