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 = "crate::lifetime::default_ephemeral_ttl")]
59 pub ttl: String,
60
61 #[serde(default)]
63 pub teardown: TeardownPolicy,
64
65 #[serde(default = "crate::lifetime::default_ephemeral_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
111impl From<EphemeralSpec> for ProcessSpec {
125 fn from(e: EphemeralSpec) -> Self {
126 let classification = e.classification.unwrap_or_else(default_ephemeral_class);
127 let mut spec = Self {
128 identity: crate::spec::IdentitySpec {
129 parent: e.parent,
130 name_override: None,
131 },
132 classification,
133 intent: Intent {
134 aplicacao: Some(e.aplicacao),
135 ..Intent::default()
136 },
137 boundary: Boundary {
138 preconditions: e.preconditions,
139 postconditions: e.postconditions,
140 timeout: e.verify_timeout,
141 },
142 compliance: Default::default(),
143 depends_on: vec![],
144 signals: Default::default(),
145 lifetime: Lifetime::ephemeral(EphemeralLifetime {
152 ttl: e.ttl,
153 teardown_policy: e.teardown,
154 max_concurrent: e.max_concurrent,
155 exports: e.exports,
156 }),
157 routing: e.routing,
159 encapsulates: None,
163 suspended: false,
164 };
165 spec.intent.nix = None;
167 spec.intent.flux = None;
168 spec.intent.lisp = None;
169 spec.intent.container = None;
170 spec.intent.guest = None;
171 spec
172 }
173}
174
175fn default_ephemeral_class() -> Classification {
176 Classification::gate_compute()
182}
183
184pub fn compile_ephemeral_source(
186 src: &str,
187) -> tatara_lisp::Result<Vec<tatara_lisp::NamedDefinition<EphemeralSpec>>> {
188 tatara_lisp::compile_named::<EphemeralSpec>(src)
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::boundary::ConditionKind;
195 use crate::classification::{ConvergencePointType, SubstrateType};
196 use crate::intent::IntentVariant;
197 use crate::lifetime::LifetimeVariant;
198
199 fn demo_overlay() -> AplicacaoIntent {
200 AplicacaoIntent {
201 chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-demo-app".into(),
202 version: "0.5.5".into(),
203 profile: "all-in-one".into(),
204 values_overlay: serde_json::json!({
205 "cluster": { "name": "ephemeral-test-01", "namespace": "demo-test" },
206 "data": { "mysql": { "persistence": { "enabled": false } } },
207 "compliance": { "overlays": [] }
208 }),
209 release_name: Some("demo-app-consolidated".into()),
210 target_namespace: Some("demo-test".into()),
211 install_timeout: Some("25m".into()),
212 }
213 }
214
215 #[test]
216 fn defaults_resolve_for_ephemeral_spec() {
217 let e = EphemeralSpec {
218 aplicacao: demo_overlay(),
219 ttl: crate::lifetime::default_ephemeral_ttl(),
220 teardown: TeardownPolicy::default(),
221 max_concurrent: crate::lifetime::default_ephemeral_max_concurrent(),
222 postconditions: vec![],
223 preconditions: vec![],
224 verify_timeout: None,
225 classification: None,
226 parent: None,
227 exports: vec![],
228 routing: None,
229 };
230 let ps: ProcessSpec = e.into();
231 match ps.intent.variant().unwrap() {
233 IntentVariant::Aplicacao(a) => {
234 assert_eq!(a.profile, "all-in-one");
235 assert_eq!(a.install_timeout.as_deref(), Some("25m"));
236 }
237 other => panic!("expected Aplicacao, got {other:?}"),
238 }
239 match ps.lifetime.variant().unwrap() {
241 LifetimeVariant::Ephemeral(e) => {
242 assert_eq!(e.ttl, "1h");
243 assert_eq!(e.teardown_policy, TeardownPolicy::Always);
244 }
245 other => panic!("expected ephemeral, got {other:?}"),
246 }
247 assert_eq!(ps.classification.point_type, ConvergencePointType::Gate);
249 assert_eq!(ps.classification.substrate, SubstrateType::Compute);
250 }
251
252 #[test]
253 fn ephemeral_lisp_round_trip() {
254 let src = r#"
255 (defephemeral closed-loop-attest
256 :aplicacao (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
257 :version "0.5.5"
258 :profile "all-in-one"
259 :values-overlay (:cluster (:name "ephemeral-test-01")
260 :data (:mysql (:persistence (:enabled #f)))
261 :compliance (:overlays []))
262 :release-name "demo-app-consolidated"
263 :target-namespace "demo-test"
264 :install-timeout "25m")
265 :ttl "1h"
266 :teardown OnAttested
267 :max-concurrent 1
268 :postconditions
269 ((:kind HelmReleaseReleased
270 :params (:name "demo-app-consolidated"
271 :namespace "demo-test"))
272 (:kind ClosedLoopAuth
273 :params (:issuer (:service "demo-app-issuer" :port 8080)
274 :consumer (:service "demo-app-gateway" :port 8000)
275 :probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
276 "#;
277 let defs = compile_ephemeral_source(src).expect("compile");
278 assert_eq!(defs.len(), 1);
279 let d = &defs[0];
280 assert_eq!(d.name, "closed-loop-attest");
281
282 assert_eq!(
284 d.spec.aplicacao.chart_ref,
285 "oci://ghcr.io/pleme-io/charts/lareira-demo-app"
286 );
287 assert_eq!(d.spec.aplicacao.profile, "all-in-one");
288 assert_eq!(
289 d.spec.aplicacao.target_namespace.as_deref(),
290 Some("demo-test")
291 );
292 assert_eq!(
294 d.spec.aplicacao.values_overlay["cluster"]["name"],
295 "ephemeral-test-01"
296 );
297 assert_eq!(
300 d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
301 false
302 );
303
304 assert_eq!(d.spec.ttl, "1h");
306 assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
307 assert_eq!(d.spec.max_concurrent, 1);
308
309 assert_eq!(d.spec.postconditions.len(), 2);
311 assert_eq!(
312 d.spec.postconditions[0].kind,
313 ConditionKind::HelmReleaseReleased
314 );
315 assert_eq!(d.spec.postconditions[1].kind, ConditionKind::ClosedLoopAuth);
316
317 let ps: ProcessSpec = d.spec.clone().into();
319 assert!(matches!(
320 ps.intent.variant().unwrap(),
321 IntentVariant::Aplicacao(_)
322 ));
323 assert!(matches!(
324 ps.lifetime.variant().unwrap(),
325 LifetimeVariant::Ephemeral(_)
326 ));
327 assert_eq!(ps.boundary.postconditions.len(), 2);
328 }
329
330 #[test]
342 fn exports_lisp_round_trip() {
343 use crate::export::{ArtifactVariant, ChannelVariant, ExportTrigger, ReportFormat};
344 let src = r#"
345 (defephemeral closed-loop-attest
346 :aplicacao (:chart-ref "oci://x"
347 :version "1.0.0"
348 :profile "minimal"
349 :values-overlay ())
350 :ttl "30m"
351 :teardown OnAttested
352 :exports
353 ((:source (:test-report (:configmap "junit-results"
354 :key "junit.xml"
355 :format Junit))
356 :channel (:nats-subject (:subject "pleme.pleme-dev.ephemeral.r1.test-report"
357 :stream "EPHEMERAL_TEST_REPORTS"))
358 :when OnAttested)
359 (:source (:test-report (:configmap "junit-results"
360 :key "junit.xml"
361 :format Junit))
362 :channel (:http-event (:signal-type "test-report"))
363 :when Always)
364 (:source (:run-marker (:labels (:run-id "r1" :phase "end")))
365 :channel (:http-event (:signal-type "ephemeral-marker"))
366 :when Always)))
367 "#;
368 let defs = compile_ephemeral_source(src).expect("compile");
369 assert_eq!(defs.len(), 1);
370 let d = &defs[0];
371 assert_eq!(d.spec.exports.len(), 3);
372
373 let r = &d.spec.exports[0];
375 match r.source.variant().unwrap() {
376 ArtifactVariant::TestReport(tr) => {
377 assert_eq!(tr.configmap, "junit-results");
378 assert_eq!(tr.format, ReportFormat::Junit);
379 }
380 other => panic!("expected TestReport, got {other:?}"),
381 }
382 match r.channel.variant().unwrap() {
383 ChannelVariant::NatsSubject(n) => {
384 assert_eq!(n.subject, "pleme.pleme-dev.ephemeral.r1.test-report");
385 assert_eq!(n.stream, "EPHEMERAL_TEST_REPORTS");
386 }
387 other => panic!("expected NatsSubject, got {other:?}"),
388 }
389 assert_eq!(r.when, ExportTrigger::OnAttested);
390
391 let t = &d.spec.exports[1];
393 match t.channel.variant().unwrap() {
394 ChannelVariant::HttpEvent(h) => assert_eq!(h.signal_type, "test-report"),
395 other => panic!("expected HttpEvent, got {other:?}"),
396 }
397 assert_eq!(t.when, ExportTrigger::Always);
398
399 let m = &d.spec.exports[2];
406 match m.source.variant().unwrap() {
407 ArtifactVariant::RunMarker(rm) => {
408 assert_eq!(rm.labels.len(), 2);
409 let run_id = rm
410 .labels
411 .get("run-id")
412 .or_else(|| rm.labels.get("runId"))
413 .or_else(|| rm.labels.get("run_id"))
414 .expect("run-id label present under some normalization");
415 assert_eq!(run_id, "r1");
416 assert_eq!(rm.labels.get("phase").map(String::as_str), Some("end"));
417 }
418 other => panic!("expected RunMarker, got {other:?}"),
419 }
420
421 let ps: ProcessSpec = d.spec.clone().into();
423 assert_eq!(ps.lifetime.ephemeral.as_ref().unwrap().exports.len(), 3);
424 }
425
426 #[test]
427 fn from_impl_clears_other_intent_variants() {
428 let e = EphemeralSpec {
432 aplicacao: demo_overlay(),
433 ttl: "10m".into(),
434 teardown: TeardownPolicy::Never,
435 max_concurrent: 0,
436 postconditions: vec![],
437 preconditions: vec![],
438 verify_timeout: None,
439 classification: None,
440 parent: Some("seph.1".into()),
441 exports: vec![],
442 routing: None,
443 };
444 let ps: ProcessSpec = e.into();
445 assert!(ps.intent.nix.is_none());
446 assert!(ps.intent.flux.is_none());
447 assert!(ps.intent.lisp.is_none());
448 assert!(ps.intent.container.is_none());
449 assert!(ps.intent.guest.is_none());
450 assert!(ps.intent.aplicacao.is_some());
451 assert_eq!(ps.identity.parent.as_deref(), Some("seph.1"));
452 }
453}