use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;
use crate::environment::Environment;
use crate::ids::{BundleId, DeploymentId, RevisionId};
use crate::revision::{PackListEntry, Revision, RevisionLifecycle, is_valid_transition};
use crate::version::SchemaVersion;
use greentic_types::EnvId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HealthCheckId {
RouteTable,
RuntimeConfig,
SignatureStatus,
ProviderHealth,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
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, Clone, PartialEq, Eq, Error)]
pub enum RevisionLifecycleError {
#[error("revision `{revision_id}` not found in env `{env_id}`")]
NotFound {
env_id: 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("revision `{revision_id}` already exists in env `{env_id}`")]
DuplicateRevision {
env_id: EnvId,
revision_id: RevisionId,
},
#[error("deployment `{deployment_id}` not found in env `{env_id}`")]
DeploymentNotFound {
env_id: EnvId,
deployment_id: DeploymentId,
},
}
impl RevisionLifecycleError {
pub fn env_mutated(&self) -> bool {
matches!(self, Self::HealthGateFailed { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageRevisionPayload {
pub revision_id: RevisionId,
pub deployment_id: DeploymentId,
pub bundle_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle_source_uri: Option<String>,
pub pack_list: Vec<PackListEntry>,
pub pack_list_lock_ref: PathBuf,
pub pack_config_refs: Vec<PathBuf>,
pub config_digest: String,
pub signature_sidecar_ref: PathBuf,
pub drain_seconds: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WarmRevisionPayload {
pub revision_id: RevisionId,
#[serde(with = "health_gate_wire")]
pub health_gate: Result<(), HealthGateFailure>,
pub expected_lifecycle: RevisionLifecycle,
}
mod health_gate_wire {
use super::HealthGateFailure;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Repr {
ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
failure: Option<HealthGateFailure>,
}
pub fn serialize<S: Serializer>(
value: &Result<(), HealthGateFailure>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let repr = match value {
Ok(()) => Repr {
ok: true,
failure: None,
},
Err(failure) => Repr {
ok: false,
failure: Some(failure.clone()),
},
};
repr.serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Result<(), HealthGateFailure>, D::Error> {
let repr = Repr::deserialize(deserializer)?;
match (repr.ok, repr.failure) {
(true, None) => Ok(Ok(())),
(false, Some(failure)) => Ok(Err(failure)),
(true, Some(_)) => Err(serde::de::Error::custom(
"health gate cannot be `ok: true` and carry a `failure`",
)),
(false, None) => Err(serde::de::Error::custom(
"health gate `ok: false` must carry a `failure`",
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevisionTransitionOutcome {
pub revision: Revision,
pub environment: Environment,
pub starting_lifecycle: RevisionLifecycle,
}
#[derive(Debug, Clone)]
pub struct RevisionTransition {
pub revision: Revision,
pub starting_lifecycle: RevisionLifecycle,
}
pub fn walk_revision_chain<F, G>(
env: &mut Environment,
revision_id: RevisionId,
accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
expected_start: Option<RevisionLifecycle>,
on_final: F,
prune_from_splits: bool,
health_gate: G,
) -> Result<RevisionTransition, RevisionLifecycleError>
where
F: FnOnce(&mut Revision),
G: FnOnce(&Environment, &Revision) -> Result<(), HealthGateFailure>,
{
if accepted_chain.is_empty() {
return Err(RevisionLifecycleError::EmptyChain);
}
let final_state = accepted_chain
.last()
.map(|(_, to)| *to)
.expect("chain non-empty: checked above");
let idx = env
.revisions
.iter()
.position(|r| r.revision_id == revision_id)
.ok_or_else(|| RevisionLifecycleError::NotFound {
env_id: env.environment_id.clone(),
revision_id,
})?;
let starting_lifecycle = env.revisions[idx].lifecycle;
if let Some(expected) = expected_start
&& starting_lifecycle != final_state
&& starting_lifecycle != expected
{
return Err(RevisionLifecycleError::Conflict {
revision_id,
actual: starting_lifecycle,
expected_starts: vec![expected],
});
}
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(RevisionLifecycleError::InvalidTransition {
from: *from,
to: *to,
});
}
env.revisions[idx].lifecycle = *to;
chain_advanced = true;
}
}
if env.revisions[idx].lifecycle != final_state {
let expected_starts = accepted_chain.iter().map(|(from, _)| *from).collect();
return Err(RevisionLifecycleError::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(RevisionLifecycleError::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(RevisionLifecycleError::InvalidTransition {
from: prior,
to: RevisionLifecycle::Failed,
});
}
env.revisions[idx].lifecycle = RevisionLifecycle::Failed;
return Err(RevisionLifecycleError::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);
}
}
}
Ok(RevisionTransition {
revision: env.revisions[idx].clone(),
starting_lifecycle,
})
}
pub fn stage_revision(
env: &mut Environment,
payload: StageRevisionPayload,
now: DateTime<Utc>,
) -> Result<Revision, RevisionLifecycleError> {
if env
.revisions
.iter()
.any(|r| r.revision_id == payload.revision_id)
{
return Err(RevisionLifecycleError::DuplicateRevision {
env_id: env.environment_id.clone(),
revision_id: payload.revision_id,
});
}
let bundle_id = env
.bundles
.iter()
.find(|b| b.deployment_id == payload.deployment_id)
.map(|b| b.bundle_id.clone())
.ok_or_else(|| RevisionLifecycleError::DeploymentNotFound {
env_id: env.environment_id.clone(),
deployment_id: payload.deployment_id,
})?;
let next_sequence = env
.revisions
.iter()
.filter(|r| r.deployment_id == payload.deployment_id)
.map(|r| r.sequence)
.max()
.unwrap_or(0)
+ 1;
let revision = Revision {
schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
revision_id: payload.revision_id,
env_id: env.environment_id.clone(),
bundle_id,
deployment_id: payload.deployment_id,
sequence: next_sequence,
created_at: now,
bundle_digest: payload.bundle_digest,
bundle_source_uri: payload.bundle_source_uri,
pack_list: payload.pack_list,
pack_list_lock_ref: payload.pack_list_lock_ref,
pack_config_refs: payload.pack_config_refs,
config_digest: payload.config_digest,
signature_sidecar_ref: payload.signature_sidecar_ref,
lifecycle: RevisionLifecycle::Staged,
staged_at: Some(now),
warmed_at: None,
drain_seconds: payload.drain_seconds,
abort_metrics: Vec::new(),
};
env.revisions.push(revision.clone());
Ok(revision)
}
pub fn warm_revision(
env: &mut Environment,
payload: WarmRevisionPayload,
now: DateTime<Utc>,
) -> Result<RevisionTransition, RevisionLifecycleError> {
let WarmRevisionPayload {
revision_id,
health_gate,
expected_lifecycle,
} = payload;
walk_revision_chain(
env,
revision_id,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Warming),
(RevisionLifecycle::Warming, RevisionLifecycle::Ready),
],
Some(expected_lifecycle),
|r| {
r.warmed_at = Some(now);
},
false,
|_env, _rev| health_gate,
)
}
pub fn drain_revision(
env: &mut Environment,
revision_id: RevisionId,
) -> Result<RevisionTransition, RevisionLifecycleError> {
walk_revision_chain(
env,
revision_id,
&[(RevisionLifecycle::Ready, RevisionLifecycle::Draining)],
None,
|_| {},
false,
|_env, _rev| Ok(()),
)
}
pub fn archive_revision(
env: &mut Environment,
revision_id: RevisionId,
) -> Result<RevisionTransition, RevisionLifecycleError> {
walk_revision_chain(
env,
revision_id,
&[
(RevisionLifecycle::Staged, RevisionLifecycle::Archived),
(RevisionLifecycle::Warming, RevisionLifecycle::Archived),
(RevisionLifecycle::Ready, RevisionLifecycle::Archived),
(RevisionLifecycle::Failed, RevisionLifecycle::Archived),
(RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
(RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
],
None,
|_| {},
true,
|_env, _rev| Ok(()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundle_deployment::{
BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
};
use crate::ids::{CustomerId, PackId, PartyId};
use crate::traffic_split::{TrafficSplit, TrafficSplitEntry};
use crate::version::SemVer;
use chrono::TimeZone;
use serde_json::json;
use std::collections::BTreeMap;
fn env_id() -> EnvId {
EnvId::try_from("local").unwrap()
}
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 12, 12, 0, 0).unwrap()
}
fn deployment(deployment_id: DeploymentId) -> BundleDeployment {
BundleDeployment {
schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
deployment_id,
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 env_with_deployment(deployment_id: DeploymentId) -> Environment {
let mut env = super::super::fresh_environment(
&env_id(),
"local".to_string(),
crate::environment::EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
},
Default::default(),
Default::default(),
Default::default(),
);
env.bundles.push(deployment(deployment_id));
env
}
fn stage_payload(deployment_id: DeploymentId) -> StageRevisionPayload {
StageRevisionPayload {
revision_id: RevisionId::new(),
deployment_id,
bundle_digest: "sha256:00".to_string(),
bundle_source_uri: None,
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"),
drain_seconds: 30,
}
}
fn warm_payload(revision_id: RevisionId) -> WarmRevisionPayload {
WarmRevisionPayload {
revision_id,
health_gate: Ok(()),
expected_lifecycle: RevisionLifecycle::Staged,
}
}
#[test]
fn stage_assigns_per_deployment_sequence_and_staged_lifecycle() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let first = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
assert_eq!(first.sequence, 1);
assert_eq!(first.lifecycle, RevisionLifecycle::Staged);
assert_eq!(first.staged_at, Some(fixed_now()));
assert_eq!(first.bundle_id, BundleId::new("fast2flow"));
let second = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
assert_eq!(second.sequence, 2);
assert_eq!(env.revisions.len(), 2);
}
#[test]
fn stage_records_bundle_source_uri_when_supplied() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let local = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
assert_eq!(local.bundle_source_uri, None);
let pullable = StageRevisionPayload {
bundle_source_uri: Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc".to_string()),
..stage_payload(deployment_id)
};
let staged = stage_revision(&mut env, pullable, fixed_now()).unwrap();
assert_eq!(
staged.bundle_source_uri.as_deref(),
Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc")
);
}
#[test]
fn stage_rejects_duplicate_revision_id() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let payload = stage_payload(deployment_id);
let dup = StageRevisionPayload {
revision_id: payload.revision_id,
..stage_payload(deployment_id)
};
stage_revision(&mut env, payload, fixed_now()).unwrap();
let err = stage_revision(&mut env, dup, fixed_now()).unwrap_err();
assert_eq!(
err,
RevisionLifecycleError::DuplicateRevision {
env_id: env_id(),
revision_id: env.revisions[0].revision_id,
}
);
assert_eq!(env.revisions.len(), 1);
assert!(!err.env_mutated());
}
#[test]
fn stage_rejects_unknown_deployment() {
let mut env = env_with_deployment(DeploymentId::new());
let other = DeploymentId::new();
let err = stage_revision(&mut env, stage_payload(other), fixed_now()).unwrap_err();
assert_eq!(
err,
RevisionLifecycleError::DeploymentNotFound {
env_id: env_id(),
deployment_id: other,
}
);
assert!(env.revisions.is_empty());
}
#[test]
fn warm_advances_staged_to_ready_and_stamps_warmed_at() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
let out = warm_revision(&mut env, warm_payload(rev.revision_id), fixed_now()).unwrap();
assert_eq!(out.revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(out.revision.warmed_at, Some(fixed_now()));
assert_eq!(out.starting_lifecycle, RevisionLifecycle::Staged);
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn warm_gate_failure_flips_to_failed_and_marks_env_mutated() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
let mut payload = warm_payload(rev.revision_id);
payload.health_gate = Err(HealthGateFailure {
failed_checks: vec![HealthCheckId::RouteTable],
message: "route table invalid".to_string(),
});
let err = warm_revision(&mut env, payload, fixed_now()).unwrap_err();
assert!(err.env_mutated(), "gate failure must demand persistence");
assert!(matches!(
err,
RevisionLifecycleError::HealthGateFailed { ref failed_checks, .. }
if failed_checks == &vec![HealthCheckId::RouteTable]
));
assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
assert_eq!(env.revisions[0].warmed_at, None, "on_final must not run");
}
#[test]
fn warm_stale_expected_lifecycle_conflicts_without_mutation() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
env.revisions[0].lifecycle = RevisionLifecycle::Warming;
let err = warm_revision(&mut env, warm_payload(rev.revision_id), fixed_now()).unwrap_err();
assert!(matches!(
err,
RevisionLifecycleError::Conflict {
actual: RevisionLifecycle::Warming,
..
}
));
assert!(!err.env_mutated());
}
#[test]
fn warm_idempotent_retry_skips_precondition_and_gate() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
env.revisions[0].lifecycle = RevisionLifecycle::Ready;
let mut payload = warm_payload(rev.revision_id);
payload.health_gate = Err(HealthGateFailure {
failed_checks: vec![],
message: "transient".to_string(),
});
let later = Utc.with_ymd_and_hms(2026, 6, 12, 13, 0, 0).unwrap();
let out = warm_revision(&mut env, payload, later).unwrap();
assert_eq!(out.revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(out.revision.warmed_at, Some(later), "on_final re-stamps");
assert_eq!(out.starting_lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn drain_requires_ready() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
let err = drain_revision(&mut env, rev.revision_id).unwrap_err();
assert!(matches!(err, RevisionLifecycleError::Conflict { .. }));
env.revisions[0].lifecycle = RevisionLifecycle::Ready;
let out = drain_revision(&mut env, rev.revision_id).unwrap();
assert_eq!(out.revision.lifecycle, RevisionLifecycle::Draining);
assert_eq!(out.starting_lifecycle, RevisionLifecycle::Ready);
}
#[test]
fn archive_walks_draining_to_archived_and_prunes_tracking_list() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
env.revisions[0].lifecycle = RevisionLifecycle::Draining;
env.bundles[0].current_revisions.push(rev.revision_id);
let out = archive_revision(&mut env, rev.revision_id).unwrap();
assert_eq!(out.revision.lifecycle, RevisionLifecycle::Archived);
assert_eq!(out.starting_lifecycle, RevisionLifecycle::Draining);
assert!(env.bundles[0].current_revisions.is_empty());
}
#[test]
fn archive_refuses_live_traffic_reference() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
env.revisions[0].lifecycle = RevisionLifecycle::Ready;
env.traffic_splits.push(TrafficSplit {
schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
env_id: env_id(),
deployment_id,
bundle_id: BundleId::new("fast2flow"),
generation: 0,
entries: vec![TrafficSplitEntry {
revision_id: rev.revision_id,
weight_bps: 10_000,
}],
updated_at: fixed_now(),
updated_by: "tester".to_string(),
idempotency_key: "k1".to_string(),
authorization_ref: PathBuf::from("auth.json"),
previous_split_ref: None,
});
let err = archive_revision(&mut env, rev.revision_id).unwrap_err();
let RevisionLifecycleError::ActiveTrafficReference { splits, .. } = err else {
panic!("expected ActiveTrafficReference, got {err:?}");
};
assert_eq!(splits.len(), 1);
assert_eq!(splits[0].weight_bps, 10_000);
}
#[test]
fn unknown_revision_is_not_found() {
let mut env = env_with_deployment(DeploymentId::new());
let missing = RevisionId::new();
let err = drain_revision(&mut env, missing).unwrap_err();
assert_eq!(
err,
RevisionLifecycleError::NotFound {
env_id: env_id(),
revision_id: missing,
}
);
}
#[test]
fn stage_payload_wire_format_is_pinned() {
let deployment_id = DeploymentId::new();
let payload = stage_payload(deployment_id);
let value = serde_json::to_value(&payload).unwrap();
assert_eq!(
value,
json!({
"revision_id": payload.revision_id.to_string(),
"deployment_id": deployment_id.to_string(),
"bundle_digest": "sha256:00",
"pack_list": [{
"pack_id": "greentic.test.pack",
"version": "1.0.0",
"digest": "sha256:00",
}],
"pack_list_lock_ref": "pack-list.lock",
"pack_config_refs": [],
"config_digest": "sha256:00",
"signature_sidecar_ref": "rev.sig",
"drain_seconds": 30,
})
);
let back: StageRevisionPayload = serde_json::from_value(value).unwrap();
assert_eq!(back.revision_id, payload.revision_id);
}
#[test]
fn warm_payload_wire_format_is_pinned() {
let revision_id = RevisionId::new();
let ok = warm_payload(revision_id);
assert_eq!(
serde_json::to_value(&ok).unwrap(),
json!({
"revision_id": revision_id.to_string(),
"health_gate": {"ok": true},
"expected_lifecycle": "staged",
})
);
let mut failing = warm_payload(revision_id);
failing.health_gate = Err(HealthGateFailure {
failed_checks: vec![HealthCheckId::RouteTable, HealthCheckId::ProviderHealth],
message: "boom".to_string(),
});
let value = serde_json::to_value(&failing).unwrap();
assert_eq!(
value["health_gate"],
json!({
"ok": false,
"failure": {
"failed_checks": ["route-table", "provider-health"],
"message": "boom",
},
})
);
let back: WarmRevisionPayload = serde_json::from_value(value).unwrap();
assert_eq!(back.health_gate, failing.health_gate);
}
#[test]
fn warm_payload_rejects_contradictory_health_gate() {
let err = serde_json::from_value::<WarmRevisionPayload>(json!({
"revision_id": RevisionId::new().to_string(),
"health_gate": {"ok": true, "failure": {"failed_checks": [], "message": "x"}},
"expected_lifecycle": "staged",
}))
.unwrap_err();
assert!(err.to_string().contains("cannot be `ok: true`"), "{err}");
let err = serde_json::from_value::<WarmRevisionPayload>(json!({
"revision_id": RevisionId::new().to_string(),
"health_gate": {"ok": false},
"expected_lifecycle": "staged",
}))
.unwrap_err();
assert!(err.to_string().contains("must carry a `failure`"), "{err}");
}
#[test]
fn revision_transition_outcome_round_trips() {
let deployment_id = DeploymentId::new();
let mut env = env_with_deployment(deployment_id);
let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
let outcome = RevisionTransitionOutcome {
revision: rev,
environment: env,
starting_lifecycle: RevisionLifecycle::Staged,
};
let value = serde_json::to_value(&outcome).unwrap();
assert_eq!(value["starting_lifecycle"], "staged");
let back: RevisionTransitionOutcome = serde_json::from_value(value).unwrap();
assert_eq!(back.revision.revision_id, outcome.revision.revision_id);
assert_eq!(back.starting_lifecycle, RevisionLifecycle::Staged);
}
}