1use 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#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "camelCase")]
54#[tatara(keyword = "defephemeral")]
55pub struct EphemeralSpec {
56 pub aplicacao: AplicacaoIntent,
58
59 #[serde(default = "default_ttl")]
61 pub ttl: String,
62
63 #[serde(default)]
65 pub teardown: TeardownPolicy,
66
67 #[serde(default = "default_max_concurrent")]
70 pub max_concurrent: u32,
71
72 #[serde(default)]
76 pub postconditions: Vec<Condition>,
77
78 #[serde(default)]
81 pub preconditions: Vec<Condition>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub verify_timeout: Option<String>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub classification: Option<Classification>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub parent: Option<String>,
95
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub exports: Vec<ExportSpec>,
102
103 #[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 routing: e.routing,
152 encapsulates: None,
156 suspended: false,
157 };
158 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
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::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 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 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 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 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 assert_eq!(
287 d.spec.aplicacao.values_overlay["cluster"]["name"],
288 "ephemeral-test-01"
289 );
290 assert_eq!(
293 d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
294 false
295 );
296
297 assert_eq!(d.spec.ttl, "1h");
299 assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
300 assert_eq!(d.spec.max_concurrent, 1);
301
302 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 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 #[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 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 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 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 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 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}