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 {
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 routing: e.routing,
150 encapsulates: None,
154 suspended: false,
155 };
156 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 Classification::gate_compute()
173}
174
175pub 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 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 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 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 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 assert_eq!(
285 d.spec.aplicacao.values_overlay["cluster"]["name"],
286 "ephemeral-test-01"
287 );
288 assert_eq!(
291 d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
292 false
293 );
294
295 assert_eq!(d.spec.ttl, "1h");
297 assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
298 assert_eq!(d.spec.max_concurrent, 1);
299
300 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 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 #[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 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 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 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 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 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}