use chrono::{DateTime, Utc};
use std::fmt;
use std::time::Duration;
use crate::crd::Process;
use crate::lifetime::TeardownPolicy;
use crate::phase::ProcessPhase;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutoTerminate {
Skip,
Now { reason: TerminateReason },
}
impl AutoTerminate {
pub const fn kind(&self) -> AutoTerminateKind {
match self {
Self::Skip => AutoTerminateKind::Skip,
Self::Now { .. } => AutoTerminateKind::Now,
}
}
pub const fn reason(&self) -> Option<&TerminateReason> {
match self {
Self::Skip => None,
Self::Now { reason } => Some(reason),
}
}
pub const fn is_now(&self) -> bool {
matches!(self, Self::Now { .. })
}
pub const fn is_skip(&self) -> bool {
matches!(self, Self::Skip)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_lisp::DeriveClosedSet)]
#[closed_set(via = "as_str", display, generate_unknown = "auto-terminate kind")]
pub enum AutoTerminateKind {
Skip,
Now,
}
impl AutoTerminateKind {
pub const ALL: [Self; 2] = [Self::Skip, Self::Now];
pub const fn as_str(self) -> &'static str {
match self {
Self::Skip => "Skip",
Self::Now => "Now",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TerminateReason {
TeardownPolicy {
policy: TeardownPolicy,
phase: ProcessPhase,
},
TtlExpired { ttl: String, elapsed: Duration },
}
impl TerminateReason {
pub const fn kind(&self) -> TerminateReasonKind {
match self {
Self::TeardownPolicy { .. } => TerminateReasonKind::TeardownPolicy,
Self::TtlExpired { .. } => TerminateReasonKind::TtlExpired,
}
}
}
impl fmt::Display for TerminateReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TeardownPolicy { policy, phase } => {
write!(
f,
"ephemeral lifetime: teardown_policy={} fired on {}",
policy.as_str(),
phase.as_str(),
)
}
Self::TtlExpired { ttl, elapsed } => {
write!(
f,
"ephemeral lifetime: ttl={} expired (elapsed={}s)",
ttl,
elapsed.as_secs(),
)
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_lisp::DeriveClosedSet)]
#[closed_set(via = "as_str", display, generate_unknown)]
pub enum TerminateReasonKind {
TeardownPolicy,
TtlExpired,
}
impl TerminateReasonKind {
pub const ALL: [Self; 2] = [Self::TeardownPolicy, Self::TtlExpired];
pub const fn as_str(self) -> &'static str {
match self {
Self::TeardownPolicy => "TeardownPolicy",
Self::TtlExpired => "TtlExpired",
}
}
}
pub fn evaluate(
process: &Process,
current_phase: ProcessPhase,
now: DateTime<Utc>,
) -> AutoTerminate {
let Ok(variant) = process.spec.lifetime.variant() else {
return AutoTerminate::Skip;
};
let Some(ephemeral) = variant.as_ephemeral() else {
return AutoTerminate::Skip;
};
if ephemeral.teardown_policy.should_teardown_on(current_phase) {
return AutoTerminate::Now {
reason: TerminateReason::TeardownPolicy {
policy: ephemeral.teardown_policy,
phase: current_phase,
},
};
}
if !is_terminal_or_exit(current_phase) {
if let Some(creation) = process.metadata.creation_timestamp.as_ref() {
if let Ok(ttl) = humantime::parse_duration(&ephemeral.ttl) {
let elapsed = now.signed_duration_since(creation.0).to_std().ok();
if let Some(elapsed) = elapsed {
if elapsed >= ttl {
return AutoTerminate::Now {
reason: TerminateReason::TtlExpired {
ttl: ephemeral.ttl.clone(),
elapsed,
},
};
}
}
}
}
}
AutoTerminate::Skip
}
fn is_terminal_or_exit(p: ProcessPhase) -> bool {
matches!(
p,
ProcessPhase::Exiting | ProcessPhase::Zombie | ProcessPhase::Reaped
)
}
pub fn requeue_with_ttl(process: &Process, now: DateTime<Utc>, default: Duration) -> Duration {
let Ok(variant) = process.spec.lifetime.variant() else {
return default;
};
let Some(e) = variant.as_ephemeral() else {
return default;
};
let Some(creation) = process.metadata.creation_timestamp.as_ref() else {
return default;
};
let Ok(ttl) = humantime::parse_duration(&e.ttl) else {
return default;
};
let elapsed = match now.signed_duration_since(creation.0).to_std() {
Ok(d) => d,
Err(_) => return default,
};
let remaining = ttl.checked_sub(elapsed).unwrap_or(Duration::from_secs(0));
let pick = std::cmp::min(default, remaining);
std::cmp::max(pick, Duration::from_secs(1))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classification::{Classification, ConvergencePointType, SubstrateType};
use crate::crd::ProcessSpec;
use crate::intent::{AplicacaoIntent, Intent};
use crate::lifetime::{EphemeralLifetime, Lifetime, TeardownPolicy};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
fn ephemeral_process(ttl: &str, teardown: TeardownPolicy, age_secs: i64) -> Process {
let spec = ProcessSpec {
identity: Default::default(),
classification: Classification {
point_type: ConvergencePointType::Gate,
substrate: SubstrateType::Compute,
horizon: Default::default(),
calm: Default::default(),
data_classification: Default::default(),
},
intent: Intent {
aplicacao: Some(AplicacaoIntent {
chart_ref: "oci://x".into(),
version: "1".into(),
profile: String::new(),
values_overlay: serde_json::Value::Null,
release_name: None,
target_namespace: None,
install_timeout: None,
}),
..Intent::default()
},
boundary: Default::default(),
compliance: Default::default(),
depends_on: vec![],
signals: Default::default(),
lifetime: Lifetime {
ephemeral: Some(EphemeralLifetime {
ttl: ttl.into(),
teardown_policy: teardown,
max_concurrent: 1,
exports: vec![],
}),
..Lifetime::default()
},
routing: None,
encapsulates: None,
suspended: false,
};
let mut p = Process::new("e", spec);
p.metadata.namespace = Some("ns".into());
let creation = Utc::now() - chrono::Duration::seconds(age_secs);
p.metadata.creation_timestamp = Some(Time(creation));
p
}
fn permanent_process() -> Process {
let spec = ProcessSpec {
identity: Default::default(),
classification: Classification {
point_type: ConvergencePointType::Gate,
substrate: SubstrateType::Compute,
horizon: Default::default(),
calm: Default::default(),
data_classification: Default::default(),
},
intent: Intent {
aplicacao: Some(AplicacaoIntent {
chart_ref: "oci://x".into(),
version: "1".into(),
profile: String::new(),
values_overlay: serde_json::Value::Null,
release_name: None,
target_namespace: None,
install_timeout: None,
}),
..Intent::default()
},
boundary: Default::default(),
compliance: Default::default(),
depends_on: vec![],
signals: Default::default(),
lifetime: Lifetime::default(),
routing: None,
encapsulates: None,
suspended: false,
};
Process::new("e", spec)
}
#[test]
fn permanent_never_auto_terminates() {
let p = permanent_process();
for phase in [
ProcessPhase::Pending,
ProcessPhase::Execing,
ProcessPhase::Running,
ProcessPhase::Attested,
ProcessPhase::Failed,
] {
assert_eq!(evaluate(&p, phase, Utc::now()), AutoTerminate::Skip);
}
}
#[test]
fn always_teardown_fires_on_attested_and_failed() {
let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
let now = Utc::now();
assert!(matches!(
evaluate(&p, ProcessPhase::Attested, now),
AutoTerminate::Now { .. }
));
assert!(matches!(
evaluate(&p, ProcessPhase::Failed, now),
AutoTerminate::Now { .. }
));
assert_eq!(
evaluate(&p, ProcessPhase::Running, now),
AutoTerminate::Skip
);
}
#[test]
fn on_attested_only_fires_on_attested() {
let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
let now = Utc::now();
assert!(matches!(
evaluate(&p, ProcessPhase::Attested, now),
AutoTerminate::Now { .. }
));
assert_eq!(evaluate(&p, ProcessPhase::Failed, now), AutoTerminate::Skip);
}
#[test]
fn on_failed_only_fires_on_failed() {
let p = ephemeral_process("1h", TeardownPolicy::OnFailed, 60);
let now = Utc::now();
assert_eq!(
evaluate(&p, ProcessPhase::Attested, now),
AutoTerminate::Skip
);
assert!(matches!(
evaluate(&p, ProcessPhase::Failed, now),
AutoTerminate::Now { .. }
));
}
#[test]
fn never_skips_phase_terminations_but_still_honors_ttl() {
let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
let now = Utc::now();
assert!(matches!(
evaluate(&p, ProcessPhase::Running, now),
AutoTerminate::Now { .. }
));
assert_eq!(
evaluate(&p, ProcessPhase::Exiting, now),
AutoTerminate::Skip
);
}
#[test]
fn ttl_not_yet_elapsed_is_skip() {
let p = ephemeral_process("1h", TeardownPolicy::Never, 60);
assert_eq!(
evaluate(&p, ProcessPhase::Running, Utc::now()),
AutoTerminate::Skip
);
}
#[test]
fn teardown_reason_string_uses_canonical_projection() {
let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
match evaluate(&p, ProcessPhase::Attested, Utc::now()) {
AutoTerminate::Now { reason } => {
let rendered = reason.to_string();
assert!(
rendered.contains("teardown_policy=OnAttested"),
"expected canonical PascalCase policy, got: {rendered}",
);
assert!(
rendered.contains("fired on Attested"),
"expected canonical PascalCase phase, got: {rendered}",
);
}
other => panic!("expected AutoTerminate::Now, got {other:?}"),
}
let p = ephemeral_process("1h", TeardownPolicy::Always, 60);
match evaluate(&p, ProcessPhase::Failed, Utc::now()) {
AutoTerminate::Now { reason } => {
let rendered = reason.to_string();
assert!(rendered.contains("teardown_policy=Always"));
assert!(rendered.contains("fired on Failed"));
}
other => panic!("expected AutoTerminate::Now, got {other:?}"),
}
}
#[test]
fn terminate_reason_display_matches_pre_lift() {
for policy in TeardownPolicy::ALL {
for phase in ProcessPhase::ALL {
let reason = TerminateReason::TeardownPolicy { policy, phase };
let expected = format!(
"ephemeral lifetime: teardown_policy={} fired on {}",
policy.as_str(),
phase.as_str(),
);
assert_eq!(
reason.to_string(),
expected,
"Display drifted for ({policy:?}, {phase:?})",
);
}
}
for (ttl, elapsed_secs) in [("1h", 0u64), ("30m", 60), ("90s", 100), ("5m30s", 3600)] {
let reason = TerminateReason::TtlExpired {
ttl: ttl.to_string(),
elapsed: Duration::from_secs(elapsed_secs),
};
assert_eq!(
reason.to_string(),
format!("ephemeral lifetime: ttl={ttl} expired (elapsed={elapsed_secs}s)"),
);
}
}
#[test]
fn terminate_reason_kind_truth_table() {
assert_eq!(
TerminateReason::TeardownPolicy {
policy: TeardownPolicy::Always,
phase: ProcessPhase::Attested,
}
.kind(),
TerminateReasonKind::TeardownPolicy,
);
assert_eq!(
TerminateReason::TtlExpired {
ttl: "1h".to_string(),
elapsed: Duration::from_secs(0),
}
.kind(),
TerminateReasonKind::TtlExpired,
);
}
#[test]
fn terminate_reason_kind_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<TerminateReasonKind>();
}
#[test]
fn terminate_reason_kind_display_matches_as_str() {
for kind in TerminateReasonKind::ALL {
assert_eq!(kind.to_string(), kind.as_str());
}
}
#[test]
fn terminate_reason_kind_as_str_is_pascal_case() {
for kind in TerminateReasonKind::ALL {
let s = kind.as_str();
assert!(!s.is_empty(), "as_str empty for {kind:?}");
assert!(
s.chars().next().unwrap().is_ascii_uppercase(),
"as_str not PascalCase for {kind:?}: {s}",
);
assert!(
!s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
"as_str carries separator for {kind:?}: {s}",
);
}
}
#[test]
fn unknown_terminate_reason_kind_errors() {
use std::str::FromStr;
for bad in [
"teardownPolicy",
"TEARDOWN_POLICY",
"Teardown",
"TtlExpire",
"ttl_expired",
"ttlExpired",
"Attested",
"Failed",
"Always",
"OnAttested",
"OnFailed",
"Never",
"Permanent",
"Ephemeral",
] {
let err = TerminateReasonKind::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn evaluate_typed_reason_carries_teardown_payload() {
for (policy, phase) in [
(TeardownPolicy::Always, ProcessPhase::Attested),
(TeardownPolicy::Always, ProcessPhase::Failed),
(TeardownPolicy::OnAttested, ProcessPhase::Attested),
(TeardownPolicy::OnFailed, ProcessPhase::Failed),
] {
let p = ephemeral_process("1h", policy, 60);
match evaluate(&p, phase, Utc::now()) {
AutoTerminate::Now { reason } => {
assert_eq!(reason.kind(), TerminateReasonKind::TeardownPolicy);
assert_eq!(
reason,
TerminateReason::TeardownPolicy { policy, phase },
"typed payload drift for ({policy:?}, {phase:?})",
);
}
other => {
panic!("expected AutoTerminate::Now for ({policy:?}, {phase:?}), got {other:?}",)
}
}
}
}
#[test]
fn evaluate_typed_reason_carries_ttl_payload() {
let p = ephemeral_process("30s", TeardownPolicy::Never, 60);
let now = Utc::now();
match evaluate(&p, ProcessPhase::Running, now) {
AutoTerminate::Now { reason } => {
assert_eq!(reason.kind(), TerminateReasonKind::TtlExpired);
match reason {
TerminateReason::TtlExpired { ttl, elapsed } => {
assert_eq!(ttl, "30s", "ttl should be verbatim spec string");
assert!(
elapsed >= Duration::from_secs(30),
"elapsed should be at least the ttl",
);
}
other => panic!("expected TtlExpired, got {other:?}"),
}
}
other => panic!("expected AutoTerminate::Now, got {other:?}"),
}
}
#[test]
fn auto_terminate_kind_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<AutoTerminateKind>();
}
#[test]
fn auto_terminate_kind_kind_projection_is_exhaustive_over_all() {
let by_all: std::collections::HashSet<_> = AutoTerminateKind::ALL.iter().copied().collect();
let sample_reason = TerminateReason::TtlExpired {
ttl: "1h".into(),
elapsed: Duration::from_secs(0),
};
let by_concrete: std::collections::HashSet<_> = [
AutoTerminate::Skip.kind(),
AutoTerminate::Now {
reason: sample_reason,
}
.kind(),
]
.into_iter()
.collect();
assert_eq!(
by_concrete, by_all,
"kind() projection not exhaustive over ALL"
);
}
#[test]
fn auto_terminate_kind_canonical_names_pinned() {
assert_eq!(AutoTerminateKind::Skip.as_str(), "Skip");
assert_eq!(AutoTerminateKind::Now.as_str(), "Now");
}
#[test]
fn auto_terminate_kind_as_str_is_pascal_case() {
for kind in AutoTerminateKind::ALL {
let s = kind.as_str();
assert!(!s.is_empty(), "as_str empty for {kind:?}");
assert!(
s.chars().next().unwrap().is_ascii_uppercase(),
"as_str not PascalCase for {kind:?}: {s}",
);
assert!(
!s.contains(|c: char| c.is_whitespace() || c == '_' || c == '-'),
"as_str carries separator for {kind:?}: {s}",
);
}
}
#[test]
fn auto_terminate_kind_display_matches_as_str() {
for kind in AutoTerminateKind::ALL {
assert_eq!(kind.to_string(), kind.as_str());
}
}
#[test]
fn unknown_auto_terminate_kind_errors() {
use std::str::FromStr;
for bad in [
"skip",
"now",
"SKIP",
"NOW",
"S",
"N",
"no-op",
"terminate",
"Attested",
"Failed",
"TeardownPolicy",
"TtlExpired",
"Always",
"Permanent",
"Ephemeral",
] {
let err = AutoTerminateKind::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn auto_terminate_reason_projection() {
assert!(AutoTerminate::Skip.reason().is_none());
let reason = TerminateReason::TtlExpired {
ttl: "1h".into(),
elapsed: Duration::from_secs(0),
};
let now = AutoTerminate::Now {
reason: reason.clone(),
};
assert_eq!(now.reason(), Some(&reason));
let teardown = TerminateReason::TeardownPolicy {
policy: TeardownPolicy::OnAttested,
phase: ProcessPhase::Attested,
};
let now = AutoTerminate::Now {
reason: teardown.clone(),
};
assert_eq!(now.reason(), Some(&teardown));
}
#[test]
fn auto_terminate_predicate_pair_is_exhaustive_complement() {
let reason = TerminateReason::TtlExpired {
ttl: "1h".into(),
elapsed: Duration::from_secs(0),
};
for decision in [
AutoTerminate::Skip,
AutoTerminate::Now {
reason: reason.clone(),
},
] {
assert_ne!(
decision.is_now(),
decision.is_skip(),
"predicate pair drift for {decision:?}",
);
assert_eq!(decision.is_now(), decision.kind() == AutoTerminateKind::Now);
assert_eq!(
decision.is_skip(),
decision.kind() == AutoTerminateKind::Skip
);
assert_eq!(decision.reason().is_some(), decision.is_now());
}
}
#[test]
fn evaluate_decision_kind_agrees_with_runtime_behaviour() {
let p = permanent_process();
for phase in [
ProcessPhase::Pending,
ProcessPhase::Running,
ProcessPhase::Attested,
ProcessPhase::Failed,
] {
let decision = evaluate(&p, phase, Utc::now());
assert_eq!(
decision.kind(),
AutoTerminateKind::Skip,
"permanent Process must always Skip; got Now for phase={phase:?}",
);
assert!(decision.reason().is_none());
}
let p = ephemeral_process("1h", TeardownPolicy::OnAttested, 60);
let now = Utc::now();
assert_eq!(
evaluate(&p, ProcessPhase::Attested, now).kind(),
AutoTerminateKind::Now,
);
assert_eq!(
evaluate(&p, ProcessPhase::Running, now).kind(),
AutoTerminateKind::Skip,
);
}
#[test]
fn requeue_picks_min_of_default_and_remaining() {
let p = ephemeral_process("5m", TeardownPolicy::Always, 60);
let now = Utc::now();
let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
assert_eq!(d, Duration::from_secs(30));
let p = ephemeral_process("90s", TeardownPolicy::Always, 80);
let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
assert!(d <= Duration::from_secs(11) && d >= Duration::from_secs(9));
let p = ephemeral_process("90s", TeardownPolicy::Always, 91);
let d = requeue_with_ttl(&p, now, Duration::from_secs(30));
assert_eq!(d, Duration::from_secs(1));
}
}