1use 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#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "camelCase")]
52#[tatara(keyword = "defephemeral")]
53pub struct EphemeralSpec {
54 pub aplicacao: AplicacaoIntent,
56
57 #[serde(default = "default_ttl")]
59 pub ttl: String,
60
61 #[serde(default)]
63 pub teardown: TeardownPolicy,
64
65 #[serde(default = "default_max_concurrent")]
68 pub max_concurrent: u32,
69
70 #[serde(default)]
74 pub postconditions: Vec<Condition>,
75
76 #[serde(default)]
79 pub preconditions: Vec<Condition>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub verify_timeout: Option<String>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub classification: Option<Classification>,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub parent: Option<String>,
93
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub exports: Vec<ExportSpec>,
100
101 #[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::ephemeral(EphemeralLifetime {
146 ttl: e.ttl,
147 teardown_policy: e.teardown,
148 max_concurrent: e.max_concurrent,
149 exports: e.exports,
150 }),
151 routing: e.routing,
153 encapsulates: None,
157 suspended: false,
158 };
159 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 Classification::gate_compute()
176}
177
178pub 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 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 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 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 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 assert_eq!(
288 d.spec.aplicacao.values_overlay["cluster"]["name"],
289 "ephemeral-test-01"
290 );
291 assert_eq!(
294 d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
295 false
296 );
297
298 assert_eq!(d.spec.ttl, "1h");
300 assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
301 assert_eq!(d.spec.max_concurrent, 1);
302
303 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 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 #[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 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 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 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 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 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}