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