use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tatara_lisp_derive::TataraDomain as DeriveTataraDomain;
use crate::boundary::{Boundary, Condition};
use crate::classification::{
Classification, ConvergencePointType, DataClassification, Horizon, SubstrateType,
};
use crate::crd::ProcessSpec;
use crate::export::ExportSpec;
use crate::intent::{AplicacaoIntent, Intent};
use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
use crate::routing::RoutingSpec;
#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defephemeral")]
pub struct EphemeralSpec {
pub aplicacao: AplicacaoIntent,
#[serde(default = "default_ttl")]
pub ttl: String,
#[serde(default)]
pub teardown: TeardownPolicy,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
#[serde(default)]
pub postconditions: Vec<Condition>,
#[serde(default)]
pub preconditions: Vec<Condition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classification: Option<Classification>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exports: Vec<ExportSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing: Option<RoutingSpec>,
}
fn default_ttl() -> String {
"1h".to_string()
}
fn default_max_concurrent() -> u32 {
1
}
impl From<EphemeralSpec> for ProcessSpec {
fn from(e: EphemeralSpec) -> Self {
let classification = e.classification.unwrap_or_else(default_ephemeral_class);
let mut spec = Self {
identity: crate::spec::IdentitySpec {
parent: e.parent,
name_override: None,
},
classification,
intent: Intent {
aplicacao: Some(e.aplicacao),
..Intent::default()
},
boundary: Boundary {
preconditions: e.preconditions,
postconditions: e.postconditions,
timeout: e.verify_timeout,
},
compliance: Default::default(),
depends_on: vec![],
signals: Default::default(),
lifetime: Lifetime {
ephemeral: Some(EphemeralLifetime {
ttl: e.ttl,
teardown_policy: e.teardown,
max_concurrent: e.max_concurrent,
exports: e.exports,
}),
..Lifetime::default()
},
routing: e.routing,
encapsulates: None,
suspended: false,
};
spec.intent.nix = None;
spec.intent.flux = None;
spec.intent.lisp = None;
spec.intent.container = None;
spec.intent.guest = None;
spec
}
}
fn default_ephemeral_class() -> Classification {
Classification {
point_type: ConvergencePointType::Gate,
substrate: SubstrateType::Compute,
horizon: Horizon::default(),
calm: Default::default(),
data_classification: DataClassification::default(),
}
}
pub fn compile_ephemeral_source(
src: &str,
) -> tatara_lisp::Result<Vec<tatara_lisp::NamedDefinition<EphemeralSpec>>> {
tatara_lisp::compile_named::<EphemeralSpec>(src)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::boundary::ConditionKind;
use crate::intent::IntentVariant;
use crate::lifetime::LifetimeVariant;
fn akeyless_overlay() -> AplicacaoIntent {
AplicacaoIntent {
chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment".into(),
version: "0.5.5".into(),
profile: "gateway-with-internal-saas".into(),
values_overlay: serde_json::json!({
"cluster": { "name": "ephemeral-test-01", "namespace": "akeyless-test" },
"data": { "mysql": { "persistence": { "enabled": false } } },
"compliance": { "overlays": [] }
}),
release_name: Some("akeyless-saas-consolidated".into()),
target_namespace: Some("akeyless-test".into()),
install_timeout: Some("25m".into()),
}
}
#[test]
fn defaults_resolve_for_ephemeral_spec() {
let e = EphemeralSpec {
aplicacao: akeyless_overlay(),
ttl: default_ttl(),
teardown: TeardownPolicy::default(),
max_concurrent: default_max_concurrent(),
postconditions: vec![],
preconditions: vec![],
verify_timeout: None,
classification: None,
parent: None,
exports: vec![],
routing: None,
};
let ps: ProcessSpec = e.into();
match ps.intent.variant().unwrap() {
IntentVariant::Aplicacao(a) => {
assert_eq!(a.profile, "gateway-with-internal-saas");
assert_eq!(a.install_timeout.as_deref(), Some("25m"));
}
other => panic!("expected Aplicacao, got {other:?}"),
}
match ps.lifetime.variant().unwrap() {
LifetimeVariant::Ephemeral(e) => {
assert_eq!(e.ttl, "1h");
assert_eq!(e.teardown_policy, TeardownPolicy::Always);
}
other => panic!("expected ephemeral, got {other:?}"),
}
assert_eq!(ps.classification.point_type, ConvergencePointType::Gate);
assert_eq!(ps.classification.substrate, SubstrateType::Compute);
}
#[test]
fn ephemeral_lisp_round_trip() {
let src = r#"
(defephemeral akeyless-closed-loop-attest
:aplicacao (:chart-ref "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment"
:version "0.5.5"
:profile "gateway-with-internal-saas"
:values-overlay (:cluster (:name "ephemeral-test-01")
:data (:mysql (:persistence (:enabled #f)))
:compliance (:overlays []))
:release-name "akeyless-saas-consolidated"
:target-namespace "akeyless-test"
:install-timeout "25m")
:ttl "1h"
:teardown OnAttested
:max-concurrent 1
:postconditions
((:kind HelmReleaseReleased
:params (:name "akeyless-saas-consolidated"
:namespace "akeyless-test"))
(:kind ClosedLoopAuth
:params (:issuer (:service "akeyless-saas-akeyless-gator" :port 8080)
:consumer (:service "akeyless-saas-akeyless-gateway" :port 8000)
:probeImage "ghcr.io/pleme-io/closed-loop-probe:0.1.0"))))
"#;
let defs = compile_ephemeral_source(src).expect("compile");
assert_eq!(defs.len(), 1);
let d = &defs[0];
assert_eq!(d.name, "akeyless-closed-loop-attest");
assert_eq!(
d.spec.aplicacao.chart_ref,
"oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment"
);
assert_eq!(d.spec.aplicacao.profile, "gateway-with-internal-saas");
assert_eq!(
d.spec.aplicacao.target_namespace.as_deref(),
Some("akeyless-test")
);
assert_eq!(
d.spec.aplicacao.values_overlay["cluster"]["name"],
"ephemeral-test-01"
);
assert_eq!(
d.spec.aplicacao.values_overlay["data"]["mysql"]["persistence"]["enabled"],
false
);
assert_eq!(d.spec.ttl, "1h");
assert_eq!(d.spec.teardown, TeardownPolicy::OnAttested);
assert_eq!(d.spec.max_concurrent, 1);
assert_eq!(d.spec.postconditions.len(), 2);
assert_eq!(
d.spec.postconditions[0].kind,
ConditionKind::HelmReleaseReleased
);
assert_eq!(
d.spec.postconditions[1].kind,
ConditionKind::ClosedLoopAuth
);
let ps: ProcessSpec = d.spec.clone().into();
assert!(matches!(
ps.intent.variant().unwrap(),
IntentVariant::Aplicacao(_)
));
assert!(matches!(
ps.lifetime.variant().unwrap(),
LifetimeVariant::Ephemeral(_)
));
assert_eq!(ps.boundary.postconditions.len(), 2);
}
#[test]
fn exports_lisp_round_trip() {
use crate::export::{ArtifactVariant, ChannelVariant, ExportTrigger, ReportFormat};
let src = r#"
(defephemeral akeyless-closed-loop-attest
:aplicacao (:chart-ref "oci://x"
:version "1.0.0"
:profile "minimal"
:values-overlay ())
:ttl "30m"
:teardown OnAttested
:exports
((:source (:test-report (:configmap "junit-results"
:key "junit.xml"
:format Junit))
:channel (:nats-subject (:subject "pleme.pleme-dev.ephemeral.r1.test-report"
:stream "EPHEMERAL_TEST_REPORTS"))
:when OnAttested)
(:source (:test-report (:configmap "junit-results"
:key "junit.xml"
:format Junit))
:channel (:http-event (:signal-type "test-report"))
:when Always)
(:source (:run-marker (:labels (:run-id "r1" :phase "end")))
:channel (:http-event (:signal-type "ephemeral-marker"))
:when Always)))
"#;
let defs = compile_ephemeral_source(src).expect("compile");
assert_eq!(defs.len(), 1);
let d = &defs[0];
assert_eq!(d.spec.exports.len(), 3);
let r = &d.spec.exports[0];
match r.source.variant().unwrap() {
ArtifactVariant::TestReport(tr) => {
assert_eq!(tr.configmap, "junit-results");
assert_eq!(tr.format, ReportFormat::Junit);
}
other => panic!("expected TestReport, got {other:?}"),
}
match r.channel.variant().unwrap() {
ChannelVariant::NatsSubject(n) => {
assert_eq!(n.subject, "pleme.pleme-dev.ephemeral.r1.test-report");
assert_eq!(n.stream, "EPHEMERAL_TEST_REPORTS");
}
other => panic!("expected NatsSubject, got {other:?}"),
}
assert_eq!(r.when, ExportTrigger::OnAttested);
let t = &d.spec.exports[1];
match t.channel.variant().unwrap() {
ChannelVariant::HttpEvent(h) => assert_eq!(h.signal_type, "test-report"),
other => panic!("expected HttpEvent, got {other:?}"),
}
assert_eq!(t.when, ExportTrigger::Always);
let m = &d.spec.exports[2];
match m.source.variant().unwrap() {
ArtifactVariant::RunMarker(rm) => {
assert_eq!(rm.labels.len(), 2);
let run_id = rm
.labels
.get("run-id")
.or_else(|| rm.labels.get("runId"))
.or_else(|| rm.labels.get("run_id"))
.expect("run-id label present under some normalization");
assert_eq!(run_id, "r1");
assert_eq!(rm.labels.get("phase").map(String::as_str), Some("end"));
}
other => panic!("expected RunMarker, got {other:?}"),
}
let ps: ProcessSpec = d.spec.clone().into();
assert_eq!(ps.lifetime.ephemeral.as_ref().unwrap().exports.len(), 3);
}
#[test]
fn from_impl_clears_other_intent_variants() {
let e = EphemeralSpec {
aplicacao: akeyless_overlay(),
ttl: "10m".into(),
teardown: TeardownPolicy::Never,
max_concurrent: 0,
postconditions: vec![],
preconditions: vec![],
verify_timeout: None,
classification: None,
parent: Some("seph.1".into()),
exports: vec![],
routing: None,
};
let ps: ProcessSpec = e.into();
assert!(ps.intent.nix.is_none());
assert!(ps.intent.flux.is_none());
assert!(ps.intent.lisp.is_none());
assert!(ps.intent.container.is_none());
assert!(ps.intent.guest.is_none());
assert!(ps.intent.aplicacao.is_some());
assert_eq!(ps.identity.parent.as_deref(), Some("seph.1"));
}
}