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