use greentic_deploy_spec::{
BundleId, DeploymentId, Environment, Revision, RevisionId, RevisionLifecycle,
is_valid_transition,
};
use thiserror::Error;
use crate::environment::{Locked, StoreError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HealthCheckId {
RouteTable,
RuntimeConfig,
SignatureStatus,
ProviderHealth,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthGateFailure {
pub failed_checks: Vec<HealthCheckId>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSplitRef {
pub deployment_id: DeploymentId,
pub bundle_id: BundleId,
pub weight_bps: u32,
}
#[derive(Debug, Error)]
pub enum LifecycleError {
#[error("revision `{revision_id}` not found in env `{env_id}`")]
NotFound {
env_id: greentic_deploy_spec::EnvId,
revision_id: RevisionId,
},
#[error("spec rejects transition `{from:?} → {to:?}`")]
InvalidTransition {
from: RevisionLifecycle,
to: RevisionLifecycle,
},
#[error(
"revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?} to apply the requested transition"
)]
Conflict {
revision_id: RevisionId,
actual: RevisionLifecycle,
expected_starts: Vec<RevisionLifecycle>,
},
#[error("transition chain is empty; cannot apply any state change")]
EmptyChain,
#[error(
"revision `{revision_id}` is still referenced by {} live traffic split(s); rebalance via `gtc op traffic set` before archiving", splits.len()
)]
ActiveTrafficReference {
revision_id: RevisionId,
splits: Vec<ActiveSplitRef>,
},
#[error(
"revision `{revision_id}` failed health gate ({} check(s) failed): {message}",
failed_checks.len()
)]
HealthGateFailed {
revision_id: RevisionId,
failed_checks: Vec<HealthCheckId>,
message: String,
},
#[error(transparent)]
Store(#[from] StoreError),
}
pub fn apply_revision_transition<F>(
locked: &Locked<'_>,
revision_id: RevisionId,
accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
on_final: F,
prune_from_splits: bool,
) -> Result<Revision, LifecycleError>
where
F: FnOnce(&mut Revision),
{
apply_revision_transition_with_health_gate(
locked,
revision_id,
accepted_chain,
on_final,
prune_from_splits,
|_env, _revision| Ok(()),
)
}
pub fn apply_revision_transition_with_health_gate<F, G>(
locked: &Locked<'_>,
revision_id: RevisionId,
accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
on_final: F,
prune_from_splits: bool,
health_gate: G,
) -> Result<Revision, LifecycleError>
where
F: FnOnce(&mut Revision),
G: FnOnce(&Environment, &Revision) -> Result<(), HealthGateFailure>,
{
if accepted_chain.is_empty() {
return Err(LifecycleError::EmptyChain);
}
let mut env = locked.load()?;
let idx = env
.revisions
.iter()
.position(|r| r.revision_id == revision_id)
.ok_or_else(|| LifecycleError::NotFound {
env_id: locked.env_id().clone(),
revision_id,
})?;
let mut chain_advanced = false;
for (from, to) in accepted_chain {
if env.revisions[idx].lifecycle == *from {
if !is_valid_transition(*from, *to) {
return Err(LifecycleError::InvalidTransition {
from: *from,
to: *to,
});
}
env.revisions[idx].lifecycle = *to;
chain_advanced = true;
}
}
let final_state = accepted_chain
.last()
.map(|(_, to)| *to)
.expect("chain non-empty: checked above");
if env.revisions[idx].lifecycle != final_state {
let expected_starts = accepted_chain.iter().map(|(from, _)| *from).collect();
return Err(LifecycleError::Conflict {
revision_id,
actual: env.revisions[idx].lifecycle,
expected_starts,
});
}
if prune_from_splits {
let active_refs: Vec<ActiveSplitRef> = env
.traffic_splits
.iter()
.flat_map(|split| {
split
.entries
.iter()
.filter(|entry| entry.revision_id == revision_id)
.map(|entry| ActiveSplitRef {
deployment_id: split.deployment_id,
bundle_id: split.bundle_id.clone(),
weight_bps: entry.weight_bps,
})
})
.collect();
if !active_refs.is_empty() {
return Err(LifecycleError::ActiveTrafficReference {
revision_id,
splits: active_refs,
});
}
}
if chain_advanced && let Err(failure) = health_gate(&env, &env.revisions[idx]) {
let prior = env.revisions[idx].lifecycle;
if !is_valid_transition(prior, RevisionLifecycle::Failed) {
return Err(LifecycleError::InvalidTransition {
from: prior,
to: RevisionLifecycle::Failed,
});
}
env.revisions[idx].lifecycle = RevisionLifecycle::Failed;
locked.save(&env)?;
return Err(LifecycleError::HealthGateFailed {
revision_id,
failed_checks: failure.failed_checks,
message: failure.message,
});
}
on_final(&mut env.revisions[idx]);
if prune_from_splits {
let deployment_id = env.revisions[idx].deployment_id;
for bundle in env.bundles.iter_mut() {
if bundle.deployment_id == deployment_id {
bundle.current_revisions.retain(|rid| *rid != revision_id);
}
}
}
locked.save(&env)?;
Ok(env.revisions[idx].clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::environment::{EnvironmentStore, LocalFsStore};
use chrono::{TimeZone, Utc};
use greentic_deploy_spec::{
BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
Environment, EnvironmentHostConfig, PackId, PackListEntry, PartyId, RevenueShareEntry,
Revision, RevisionId, RevisionLifecycle, RouteBinding, SchemaVersion, SemVer,
TenantSelector, TrafficSplit, TrafficSplitEntry,
};
use std::collections::BTreeMap;
use std::path::PathBuf;
use tempfile::tempdir;
const ENV_ID: &str = "local";
fn env_id() -> EnvId {
EnvId::try_from(ENV_ID).unwrap()
}
fn fixed_now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap()
}
fn make_env() -> Environment {
Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: env_id(),
name: ENV_ID.to_string(),
host_config: EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
},
packs: Vec::new(),
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
messaging_endpoints: Vec::new(),
extensions: Vec::new(),
revocation: Default::default(),
retention: Default::default(),
health: Default::default(),
}
}
fn make_revision(deployment_id: DeploymentId, lifecycle: RevisionLifecycle) -> Revision {
Revision {
schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
revision_id: RevisionId::new(),
env_id: env_id(),
bundle_id: BundleId::new("fast2flow"),
deployment_id,
sequence: 1,
created_at: fixed_now(),
bundle_digest: "sha256:00".to_string(),
pack_list: vec![PackListEntry {
pack_id: PackId::new("greentic.test.pack"),
version: SemVer::new(1, 0, 0),
digest: "sha256:00".to_string(),
source_uri: None,
}],
pack_list_lock_ref: PathBuf::from("pack-list.lock"),
pack_config_refs: Vec::new(),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: PathBuf::from("rev.sig"),
lifecycle,
staged_at: None,
warmed_at: None,
drain_seconds: 30,
abort_metrics: Vec::new(),
}
}
fn make_bundle_deployment() -> BundleDeployment {
BundleDeployment {
schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
deployment_id: DeploymentId::new(),
env_id: env_id(),
bundle_id: BundleId::new("fast2flow"),
customer_id: CustomerId::new("local-dev"),
status: BundleDeploymentStatus::Active,
current_revisions: Vec::new(),
route_binding: RouteBinding {
hosts: vec!["fast2flow.local".to_string()],
path_prefixes: Vec::new(),
tenant_selector: TenantSelector {
tenant: "default".to_string(),
team: "default".to_string(),
},
},
revenue_share: vec![RevenueShareEntry {
party_id: PartyId::new("greentic"),
basis_points: 10_000,
}],
revenue_policy_ref: PathBuf::from("revenue.json"),
usage: None,
created_at: fixed_now(),
authorization_ref: PathBuf::from("auth.json"),
config_overrides: BTreeMap::new(),
}
}
fn seed_one_revision(lifecycle: RevisionLifecycle) -> (LocalFsStore, EnvId, RevisionId) {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path().to_path_buf());
let mut env = make_env();
let bundle = make_bundle_deployment();
let did = bundle.deployment_id;
let revision = make_revision(did, lifecycle);
let rid = revision.revision_id;
env.bundles.push(bundle);
env.bundles[0].current_revisions.push(rid);
env.revisions.push(revision);
store.save(&env).unwrap();
std::mem::forget(dir);
(store, env_id(), rid)
}
#[test]
fn applies_two_hop_chain_to_final_state() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|_| {},
false,
)
})
.unwrap();
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn on_final_runs_once_on_post_advance_revision() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|r| {
r.warmed_at = Some(fixed_now());
},
false,
)
})
.unwrap();
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(revision.warmed_at, Some(fixed_now()));
}
#[test]
fn missing_revision_surfaces_not_found_without_touching_env() {
let (store, env_id, _rid) = seed_one_revision(RevisionLifecycle::Staged);
let ghost = RevisionId::new();
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
ghost,
&[(RevisionLifecycle::Staged, RevisionLifecycle::Warming)],
|_| {},
false,
)
})
.unwrap_err();
match err {
LifecycleError::NotFound {
revision_id,
env_id: e,
} => {
assert_eq!(revision_id, ghost);
assert_eq!(e, env_id);
}
other => panic!("expected NotFound, got `{other:?}`"),
}
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Staged);
}
#[test]
fn revision_outside_chain_surfaces_conflict() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|_| {},
false,
)
})
.unwrap_err();
match err {
LifecycleError::Conflict {
revision_id,
actual,
expected_starts,
} => {
assert_eq!(revision_id, rid);
assert_eq!(actual, RevisionLifecycle::Draining);
assert_eq!(
expected_starts,
vec![RevisionLifecycle::Staged, RevisionLifecycle::Warming]
);
}
other => panic!("expected Conflict, got `{other:?}`"),
}
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Draining);
}
#[test]
fn already_in_final_state_is_idempotent_success() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|_| {},
false,
)
})
.unwrap();
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn empty_chain_returns_empty_chain_error() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(locked, rid, &[], |_| {}, false)
})
.unwrap_err();
assert!(matches!(err, LifecycleError::EmptyChain));
}
#[test]
fn archive_succeeds_and_prunes_current_revisions_when_no_live_traffic() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let archived = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
|_| {},
true,
)
})
.unwrap();
assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
let env = store.load(&env_id).unwrap();
assert!(env.bundles[0].current_revisions.is_empty());
assert!(env.traffic_splits.is_empty());
}
#[test]
fn archive_refuses_when_revision_owns_100_percent_of_a_split() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let mut env = store.load(&env_id).unwrap();
let did = env.bundles[0].deployment_id;
env.traffic_splits.push(TrafficSplit {
schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
env_id: env_id.clone(),
deployment_id: did,
bundle_id: BundleId::new("fast2flow"),
generation: 0,
entries: vec![TrafficSplitEntry {
revision_id: rid,
weight_bps: 10_000,
}],
updated_at: fixed_now(),
updated_by: "test".to_string(),
idempotency_key: "k1".to_string(),
authorization_ref: PathBuf::from("auth.json"),
previous_split_ref: None,
});
env.bundles[0].current_revisions.push(rid);
store.save(&env).unwrap();
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
|_| {},
true,
)
})
.unwrap_err();
match err {
LifecycleError::ActiveTrafficReference {
revision_id,
splits,
} => {
assert_eq!(revision_id, rid);
assert_eq!(splits.len(), 1);
assert_eq!(splits[0].deployment_id, did);
assert_eq!(splits[0].weight_bps, 10_000);
}
other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
}
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
assert_eq!(env.traffic_splits.len(), 1);
assert_eq!(env.traffic_splits[0].entries.len(), 1);
assert!(env.bundles[0].current_revisions.contains(&rid));
}
#[test]
fn archive_refuses_when_revision_owns_partial_traffic_in_a_split() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let mut env = store.load(&env_id).unwrap();
let did = env.bundles[0].deployment_id;
let main_revision = make_revision(did, RevisionLifecycle::Ready);
let main_rid = main_revision.revision_id;
env.revisions.push(main_revision);
env.bundles[0].current_revisions.push(rid);
env.bundles[0].current_revisions.push(main_rid);
env.traffic_splits.push(TrafficSplit {
schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
env_id: env_id.clone(),
deployment_id: did,
bundle_id: BundleId::new("fast2flow"),
generation: 0,
entries: vec![
TrafficSplitEntry {
revision_id: rid,
weight_bps: 3_000,
},
TrafficSplitEntry {
revision_id: main_rid,
weight_bps: 7_000,
},
],
updated_at: fixed_now(),
updated_by: "test".to_string(),
idempotency_key: "k1".to_string(),
authorization_ref: PathBuf::from("auth.json"),
previous_split_ref: None,
});
store.save(&env).unwrap();
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
|_| {},
true,
)
})
.unwrap_err();
match err {
LifecycleError::ActiveTrafficReference {
revision_id,
splits,
} => {
assert_eq!(revision_id, rid);
assert_eq!(splits.len(), 1);
assert_eq!(splits[0].weight_bps, 3_000);
}
other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
}
let env = store.load(&env_id).unwrap();
let sum: u32 = env.traffic_splits[0]
.entries
.iter()
.map(|e| e.weight_bps)
.sum();
assert_eq!(sum, 10_000);
}
#[test]
fn drain_then_archive_walk_retires_a_live_revision_to_terminal() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
let mut env = store.load(&env_id).unwrap();
env.revisions[0].lifecycle = RevisionLifecycle::Inactive;
store.save(&env).unwrap();
let archived = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Archived),
(RevisionLifecycle::Warming, RevisionLifecycle::Archived),
(RevisionLifecycle::Ready, RevisionLifecycle::Archived),
(RevisionLifecycle::Failed, RevisionLifecycle::Archived),
(RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
(RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
],
|_| {},
true,
)
})
.unwrap();
assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
}
#[test]
fn matrix_walks_every_legal_outbound_edge() {
use RevisionLifecycle::*;
for (from, to) in &[
(Inactive, Staged),
(Inactive, Failed),
(Inactive, Archived),
(Staged, Warming),
(Staged, Failed),
(Staged, Archived),
(Warming, Ready),
(Warming, Failed),
(Warming, Archived),
(Ready, Draining),
(Ready, Failed),
(Ready, Archived),
(Draining, Inactive),
(Failed, Staged),
(Failed, Archived),
] {
let (store, env_id, rid) = seed_one_revision(*from);
let result = store.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(locked, rid, &[(*from, *to)], |_| {}, false)
});
assert!(
result.is_ok(),
"matrix edge `{from:?} → {to:?}` was rejected: {:?}",
result.err()
);
assert_eq!(result.unwrap().lifecycle, *to);
}
}
#[test]
fn health_gate_pass_advances_chain_and_runs_on_final() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|r| r.warmed_at = Some(fixed_now()),
false,
|_env, rev| {
assert_eq!(rev.lifecycle, RevisionLifecycle::Ready);
Ok(())
},
)
})
.unwrap();
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(revision.warmed_at, Some(fixed_now()));
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
assert_eq!(env.revisions[0].warmed_at, Some(fixed_now()));
}
#[test]
fn health_gate_failure_persists_failed_and_skips_on_final() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let on_final_ran = std::cell::Cell::new(false);
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|r| {
on_final_ran.set(true);
r.warmed_at = Some(fixed_now());
},
false,
|_env, _rev| {
Err(HealthGateFailure {
failed_checks: vec![
HealthCheckId::RuntimeConfig,
HealthCheckId::SignatureStatus,
],
message: "synthetic test failure".to_string(),
})
},
)
})
.unwrap_err();
match err {
LifecycleError::HealthGateFailed {
revision_id,
failed_checks,
message,
} => {
assert_eq!(revision_id, rid);
assert_eq!(
failed_checks,
vec![HealthCheckId::RuntimeConfig, HealthCheckId::SignatureStatus]
);
assert!(message.contains("synthetic"));
}
other => panic!("expected HealthGateFailed, got `{other:?}`"),
}
assert!(
!on_final_ran.get(),
"on_final must not run on health-gate failure"
);
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
assert_eq!(env.revisions[0].warmed_at, None);
}
#[test]
fn idempotent_retry_skips_gate_and_preserves_ready() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let gate_invoked = std::cell::Cell::new(false);
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|r| r.warmed_at = Some(fixed_now()),
false,
|_env, _rev| {
gate_invoked.set(true);
Err(HealthGateFailure {
failed_checks: vec![HealthCheckId::ProviderHealth],
message: "would have demoted a live revision".to_string(),
})
},
)
})
.unwrap();
assert!(
!gate_invoked.get(),
"gate must not run on idempotent retry against an already-final revision"
);
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(revision.warmed_at, Some(fixed_now()));
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn gate_skipped_on_retry_preserves_live_routed_revision() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let mut env = store.load(&env_id).unwrap();
let did = env.bundles[0].deployment_id;
env.traffic_splits.push(TrafficSplit {
schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
env_id: env_id.clone(),
deployment_id: did,
bundle_id: BundleId::new("fast2flow"),
generation: 0,
entries: vec![TrafficSplitEntry {
revision_id: rid,
weight_bps: 10_000,
}],
updated_at: fixed_now(),
updated_by: "test".to_string(),
idempotency_key: "k1".to_string(),
authorization_ref: PathBuf::from("auth.json"),
previous_split_ref: None,
});
store.save(&env).unwrap();
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|_| {},
false,
|_env, _rev| {
Err(HealthGateFailure {
failed_checks: vec![HealthCheckId::ProviderHealth],
message: "transient — must not demote".to_string(),
})
},
)
})
.unwrap();
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
assert_eq!(env.traffic_splits.len(), 1);
assert_eq!(env.traffic_splits[0].entries.len(), 1);
assert_eq!(env.traffic_splits[0].entries[0].revision_id, rid);
assert_eq!(env.traffic_splits[0].entries[0].weight_bps, 10_000);
}
#[test]
fn apply_revision_transition_remains_a_noop_gate_wrapper() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let revision = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|r| r.warmed_at = Some(fixed_now()),
false,
)
})
.unwrap();
assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(revision.warmed_at, Some(fixed_now()));
}
#[test]
fn gate_failure_with_no_legal_failed_transition_surfaces_invalid_transition() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[(RevisionLifecycle::Ready, RevisionLifecycle::Draining)],
|_| {},
false,
|_env, _rev| {
Err(HealthGateFailure {
failed_checks: vec![HealthCheckId::RouteTable],
message: "unreachable in practice for drain".to_string(),
})
},
)
})
.unwrap_err();
match err {
LifecycleError::InvalidTransition { from, to } => {
assert_eq!(from, RevisionLifecycle::Draining);
assert_eq!(to, RevisionLifecycle::Failed);
}
other => panic!("expected InvalidTransition, got `{other:?}`"),
}
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn gate_is_not_invoked_when_prune_guard_refuses() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
let mut env = store.load(&env_id).unwrap();
let did = env.bundles[0].deployment_id;
env.traffic_splits.push(TrafficSplit {
schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
env_id: env_id.clone(),
deployment_id: did,
bundle_id: BundleId::new("fast2flow"),
generation: 0,
entries: vec![TrafficSplitEntry {
revision_id: rid,
weight_bps: 10_000,
}],
updated_at: fixed_now(),
updated_by: "test".to_string(),
idempotency_key: "k1".to_string(),
authorization_ref: PathBuf::from("auth.json"),
previous_split_ref: None,
});
store.save(&env).unwrap();
let gate_invoked = std::cell::Cell::new(false);
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
|_| {},
true,
|_env, _rev| {
gate_invoked.set(true);
Ok(())
},
)
})
.unwrap_err();
assert!(matches!(err, LifecycleError::ActiveTrafficReference { .. }));
assert!(
!gate_invoked.get(),
"gate must not run when prune guard refuses"
);
}
#[test]
fn gate_failure_with_empty_failed_checks_is_still_persisted() {
let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
let err = store
.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
apply_revision_transition_with_health_gate(
locked,
rid,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
|_| {},
false,
|_env, _rev| {
Err(HealthGateFailure {
failed_checks: Vec::new(),
message: "gate aborted before any check completed".to_string(),
})
},
)
})
.unwrap_err();
match err {
LifecycleError::HealthGateFailed {
failed_checks,
message,
..
} => {
assert!(failed_checks.is_empty());
assert!(message.contains("aborted"));
}
other => panic!("expected HealthGateFailed, got `{other:?}`"),
}
let env = store.load(&env_id).unwrap();
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
}
}