use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::export::ExportSpec;
use crate::phase::ProcessPhase;
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Lifetime {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permanent: Option<PermanentLifetime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ephemeral: Option<EphemeralLifetime>,
}
#[derive(Clone, Debug)]
pub enum LifetimeVariant<'a> {
Permanent(&'a PermanentLifetime),
Ephemeral(&'a EphemeralLifetime),
}
impl LifetimeVariant<'_> {
pub fn kind(&self) -> LifetimeKind {
match self {
Self::Permanent(_) => LifetimeKind::Permanent,
Self::Ephemeral(_) => LifetimeKind::Ephemeral,
}
}
pub fn as_ephemeral(&self) -> Option<&EphemeralLifetime> {
match self {
Self::Ephemeral(e) => Some(e),
Self::Permanent(_) => None,
}
}
pub fn as_permanent(&self) -> Option<&PermanentLifetime> {
match self {
Self::Permanent(p) => Some(p),
Self::Ephemeral(_) => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LifetimeKind {
Permanent,
Ephemeral,
}
impl LifetimeKind {
pub const ALL: [Self; 2] = [Self::Permanent, Self::Ephemeral];
pub const fn as_str(self) -> &'static str {
match self {
Self::Permanent => "permanent",
Self::Ephemeral => "ephemeral",
}
}
pub fn select<'a>(self, lifetime: &'a Lifetime) -> Option<LifetimeVariant<'a>> {
match self {
Self::Permanent => lifetime.permanent.as_ref().map(LifetimeVariant::Permanent),
Self::Ephemeral => lifetime.ephemeral.as_ref().map(LifetimeVariant::Ephemeral),
}
}
}
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
pub enum LifetimeError {
#[error("lifetime has multiple variants set; at most one required")]
Ambiguous,
}
impl Lifetime {
pub fn is_default(&self) -> bool {
self.permanent.is_none() && self.ephemeral.is_none()
}
pub fn variant(&self) -> Result<LifetimeVariant<'_>, LifetimeError> {
use crate::tagged_union::{resolve, ResolveError};
match resolve(LifetimeKind::ALL.into_iter().map(|k| k.select(self))) {
Ok(v) => Ok(v),
Err(ResolveError::None) => Ok(LifetimeVariant::Permanent(&DEFAULT_PERMANENT)),
Err(ResolveError::Many) => Err(LifetimeError::Ambiguous),
}
}
pub fn is_ephemeral(&self) -> bool {
self.ephemeral.is_some()
}
}
const DEFAULT_PERMANENT: PermanentLifetime = PermanentLifetime {};
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PermanentLifetime {}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct EphemeralLifetime {
#[serde(default = "default_ttl")]
pub ttl: String,
#[serde(default)]
pub teardown_policy: TeardownPolicy,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exports: Vec<ExportSpec>,
}
impl EphemeralLifetime {
pub fn has_applicable_exports(&self, phase: ProcessPhase) -> bool {
self.exports.iter().any(|e| e.when.fires_on(phase))
}
pub fn applicable_exports(
&self,
phase: ProcessPhase,
) -> impl Iterator<Item = &ExportSpec> + '_ {
self.exports.iter().filter(move |e| e.when.fires_on(phase))
}
}
impl Default for EphemeralLifetime {
fn default() -> Self {
Self {
ttl: default_ttl(),
teardown_policy: TeardownPolicy::default(),
max_concurrent: default_max_concurrent(),
exports: Vec::new(),
}
}
}
fn default_ttl() -> String {
"1h".to_string()
}
fn default_max_concurrent() -> u32 {
1
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
Default,
tatara_lisp::DeriveClosedSet,
)]
#[serde(rename_all = "PascalCase")]
#[closed_set(via = "as_str", display, generate_unknown)]
pub enum TeardownPolicy {
#[default]
Always,
OnAttested,
OnFailed,
Never,
}
impl TeardownPolicy {
pub const ALL: [Self; 4] = [Self::Always, Self::OnAttested, Self::OnFailed, Self::Never];
pub const fn as_str(self) -> &'static str {
match self {
Self::Always => "Always",
Self::OnAttested => "OnAttested",
Self::OnFailed => "OnFailed",
Self::Never => "Never",
}
}
pub const fn should_teardown_on(self, phase: ProcessPhase) -> bool {
match phase {
ProcessPhase::Attested => matches!(self, Self::Always | Self::OnAttested),
ProcessPhase::Failed => matches!(self, Self::Always | Self::OnFailed),
ProcessPhase::Pending
| ProcessPhase::Forking
| ProcessPhase::Execing
| ProcessPhase::Running
| ProcessPhase::Reconverging
| ProcessPhase::Releasing
| ProcessPhase::Exiting
| ProcessPhase::Zombie
| ProcessPhase::Reaped => false,
}
}
pub const fn should_teardown_on_attested(self) -> bool {
self.should_teardown_on(ProcessPhase::Attested)
}
pub const fn should_teardown_on_failed(self) -> bool {
self.should_teardown_on(ProcessPhase::Failed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_lifetime_resolves_to_permanent() {
let l = Lifetime::default();
assert!(l.is_default());
assert!(!l.is_ephemeral());
assert!(matches!(
l.variant().unwrap(),
LifetimeVariant::Permanent(_)
));
}
#[test]
fn ephemeral_set_resolves() {
let l = Lifetime {
ephemeral: Some(EphemeralLifetime::default()),
..Lifetime::default()
};
assert!(l.is_ephemeral());
match l.variant().unwrap() {
LifetimeVariant::Ephemeral(e) => {
assert_eq!(e.ttl, "1h");
assert_eq!(e.teardown_policy, TeardownPolicy::Always);
assert_eq!(e.max_concurrent, 1);
}
other => panic!("expected ephemeral, got {other:?}"),
}
}
#[test]
fn ambiguous_lifetime_errors() {
let l = Lifetime {
permanent: Some(PermanentLifetime {}),
ephemeral: Some(EphemeralLifetime::default()),
};
assert_eq!(l.variant().unwrap_err(), LifetimeError::Ambiguous);
}
#[test]
fn teardown_policy_dispatch() {
assert!(TeardownPolicy::Always.should_teardown_on_attested());
assert!(TeardownPolicy::Always.should_teardown_on_failed());
assert!(TeardownPolicy::OnAttested.should_teardown_on_attested());
assert!(!TeardownPolicy::OnAttested.should_teardown_on_failed());
assert!(!TeardownPolicy::OnFailed.should_teardown_on_attested());
assert!(TeardownPolicy::OnFailed.should_teardown_on_failed());
assert!(!TeardownPolicy::Never.should_teardown_on_attested());
assert!(!TeardownPolicy::Never.should_teardown_on_failed());
}
#[test]
fn teardown_policy_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<TeardownPolicy>();
}
#[test]
fn teardown_policy_as_str_matches_serde() {
for policy in TeardownPolicy::ALL {
let serialized = serde_json::to_string(&policy)
.expect("TeardownPolicy serializes")
.trim_matches('"')
.to_string();
assert_eq!(
policy.as_str(),
serialized,
"as_str() must match serde output for {policy:?}",
);
}
}
#[test]
fn teardown_policy_display_matches_as_str() {
for policy in TeardownPolicy::ALL {
assert_eq!(policy.to_string(), policy.as_str());
}
}
#[test]
fn unknown_teardown_policy_errors() {
use std::str::FromStr;
for bad in ["always", "ALWAYS", "OnAtested", "Bogus"] {
let err = TeardownPolicy::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn teardown_policy_should_teardown_on_truth_table() {
for policy in TeardownPolicy::ALL {
for phase in ProcessPhase::ALL {
let expected = match phase {
ProcessPhase::Attested => {
matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnAttested)
}
ProcessPhase::Failed => {
matches!(policy, TeardownPolicy::Always | TeardownPolicy::OnFailed)
}
_ => false,
};
assert_eq!(
policy.should_teardown_on(phase),
expected,
"should_teardown_on({policy:?}, {phase:?}) drift",
);
}
}
}
#[test]
fn teardown_policy_legacy_predicates_delegate_to_phase_dispatch() {
for policy in TeardownPolicy::ALL {
assert_eq!(
policy.should_teardown_on_attested(),
policy.should_teardown_on(ProcessPhase::Attested),
"Attested delegate drift for {policy:?}",
);
assert_eq!(
policy.should_teardown_on_failed(),
policy.should_teardown_on(ProcessPhase::Failed),
"Failed delegate drift for {policy:?}",
);
}
}
#[test]
fn serde_round_trip_ephemeral() {
let l = Lifetime {
ephemeral: Some(EphemeralLifetime {
ttl: "30m".into(),
teardown_policy: TeardownPolicy::OnAttested,
max_concurrent: 4,
exports: vec![],
}),
..Lifetime::default()
};
let yaml = serde_yaml::to_string(&l).unwrap();
assert!(yaml.contains("ttl: 30m"));
assert!(yaml.contains("teardownPolicy: OnAttested"));
assert!(!yaml.contains("exports"));
let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
assert!(back.is_ephemeral());
assert!(back.ephemeral.unwrap().exports.is_empty());
}
#[test]
fn applicable_exports_filters_by_trigger() {
use crate::export::{
ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
VectorChannel,
};
let spec_attested = ExportSpec {
source: ArtifactSource {
receipts: Some(ReceiptsSource::default()),
..ArtifactSource::default()
},
channel: VectorChannel {
http_event: Some(HttpEventChannel {
endpoint: None,
signal_type: "receipt".into(),
}),
..VectorChannel::default()
},
when: ExportTrigger::OnAttested,
experiment_id_override: None,
};
let spec_failed = ExportSpec {
when: ExportTrigger::OnFailed,
..spec_attested.clone()
};
let spec_always = ExportSpec {
when: ExportTrigger::Always,
..spec_attested.clone()
};
let lt = EphemeralLifetime {
ttl: "1h".into(),
teardown_policy: TeardownPolicy::OnAttested,
max_concurrent: 1,
exports: vec![spec_attested, spec_failed, spec_always],
};
assert!(lt.has_applicable_exports(ProcessPhase::Attested));
assert_eq!(lt.applicable_exports(ProcessPhase::Attested).count(), 2);
assert!(lt.has_applicable_exports(ProcessPhase::Failed));
assert_eq!(lt.applicable_exports(ProcessPhase::Failed).count(), 2);
for p in [
ProcessPhase::Pending,
ProcessPhase::Forking,
ProcessPhase::Execing,
ProcessPhase::Running,
ProcessPhase::Reconverging,
ProcessPhase::Releasing,
ProcessPhase::Exiting,
ProcessPhase::Zombie,
ProcessPhase::Reaped,
] {
assert!(!lt.has_applicable_exports(p));
assert_eq!(lt.applicable_exports(p).count(), 0);
}
}
#[test]
fn no_exports_means_no_applicable_exports() {
let lt = EphemeralLifetime::default();
assert!(!lt.has_applicable_exports(ProcessPhase::Attested));
assert!(!lt.has_applicable_exports(ProcessPhase::Failed));
}
#[test]
fn lifetime_kind_all_is_unique_and_complete() {
let mut seen = std::collections::HashSet::new();
for kind in LifetimeKind::ALL {
assert!(seen.insert(kind), "duplicate variant in ALL: {kind:?}");
}
assert_eq!(seen.len(), LifetimeKind::ALL.len());
}
#[test]
fn lifetime_kind_as_str_matches_lifetime_field_name() {
for kind in LifetimeKind::ALL {
let l = match kind {
LifetimeKind::Permanent => Lifetime {
permanent: Some(PermanentLifetime {}),
..Lifetime::default()
},
LifetimeKind::Ephemeral => Lifetime {
ephemeral: Some(EphemeralLifetime::default()),
..Lifetime::default()
},
};
let v = serde_json::to_value(&l).expect("Lifetime serializes");
let obj = v.as_object().expect("Lifetime serializes to object");
let keys: Vec<&String> = obj.keys().collect();
assert_eq!(
keys.len(),
1,
"exactly one slot populated for kind {kind:?}, got {keys:?}"
);
assert_eq!(
keys[0],
kind.as_str(),
"as_str() must match serde field name for {kind:?}"
);
}
}
#[test]
fn lifetime_kind_round_trips_through_variant_kind() {
for kind in LifetimeKind::ALL {
let l = single_slot_lifetime(kind);
let v = kind.select(&l).expect("populated slot must select");
assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
assert_eq!(
l.variant().expect("exactly-one variant").kind(),
kind,
"variant() resolver disagreed on {kind:?}"
);
}
}
#[test]
fn lifetime_variant_as_ephemeral_returns_inner_only_for_ephemeral() {
let permanent = PermanentLifetime {};
let v = LifetimeVariant::Permanent(&permanent);
assert!(v.as_ephemeral().is_none());
assert!(v.as_permanent().is_some());
let ephemeral = EphemeralLifetime {
ttl: "42m".into(),
teardown_policy: TeardownPolicy::OnAttested,
max_concurrent: 3,
exports: vec![],
};
let v = LifetimeVariant::Ephemeral(&ephemeral);
let inner = v.as_ephemeral().expect("ephemeral must project");
assert_eq!(inner.ttl, "42m");
assert_eq!(inner.teardown_policy, TeardownPolicy::OnAttested);
assert_eq!(inner.max_concurrent, 3);
assert!(v.as_permanent().is_none());
}
#[test]
fn empty_lifetime_resolves_to_permanent_kind() {
let l = Lifetime::default();
let v = l.variant().expect("default lifetime resolves");
assert_eq!(v.kind(), LifetimeKind::Permanent);
assert!(v.as_permanent().is_some());
assert!(v.as_ephemeral().is_none());
}
fn single_slot_lifetime(kind: LifetimeKind) -> Lifetime {
match kind {
LifetimeKind::Permanent => Lifetime {
permanent: Some(PermanentLifetime {}),
..Lifetime::default()
},
LifetimeKind::Ephemeral => Lifetime {
ephemeral: Some(EphemeralLifetime::default()),
..Lifetime::default()
},
}
}
#[test]
fn exports_round_trip_through_lifetime() {
use crate::export::{
ArtifactSource, ExportSpec, ExportTrigger, HttpEventChannel, ReceiptsSource,
VectorChannel,
};
let l = Lifetime {
ephemeral: Some(EphemeralLifetime {
ttl: "30m".into(),
teardown_policy: TeardownPolicy::OnAttested,
max_concurrent: 1,
exports: vec![ExportSpec {
source: ArtifactSource {
receipts: Some(ReceiptsSource::default()),
..ArtifactSource::default()
},
channel: VectorChannel {
http_event: Some(HttpEventChannel {
endpoint: None,
signal_type: "receipt".into(),
}),
..VectorChannel::default()
},
when: ExportTrigger::OnAttested,
experiment_id_override: None,
}],
}),
..Lifetime::default()
};
let yaml = serde_yaml::to_string(&l).unwrap();
assert!(yaml.contains("exports:"));
assert!(yaml.contains("receipts: {}"));
assert!(yaml.contains("signalType: receipt"));
let back: Lifetime = serde_yaml::from_str(&yaml).unwrap();
let e = back.ephemeral.unwrap();
assert_eq!(e.exports.len(), 1);
assert!(e.exports[0].source.receipts.is_some());
assert!(e.exports[0].channel.http_event.is_some());
}
}