use std::path::PathBuf;
use greentic_deploy_spec::{Environment, Revision};
use greentic_deployer::environment::{HealthCheckId, HealthGateFailure};
pub trait RevisionHealthGate: Send + Sync {
fn check(&self, env: &Environment, revision: &Revision) -> Result<(), HealthGateFailure>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopRevisionHealthGate;
impl RevisionHealthGate for NoopRevisionHealthGate {
fn check(&self, _env: &Environment, _revision: &Revision) -> Result<(), HealthGateFailure> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct StartRevisionHealthGate {
env_root: PathBuf,
}
impl StartRevisionHealthGate {
pub fn new(env_root: PathBuf) -> Self {
Self { env_root }
}
pub fn default_root() -> anyhow::Result<Self> {
let env_root =
greentic_deployer::environment::LocalFsStore::default_root().ok_or_else(|| {
anyhow::anyhow!(
"cannot determine the default environment store root (no home directory)"
)
})?;
Ok(Self { env_root })
}
}
impl RevisionHealthGate for StartRevisionHealthGate {
fn check(&self, env: &Environment, revision: &Revision) -> Result<(), HealthGateFailure> {
let mut failed_checks: Vec<HealthCheckId> = Vec::new();
let mut messages: Vec<String> = Vec::new();
let env_id = env.environment_id.as_str();
match crate::runtime_config::load_in(&self.env_root, env_id) {
Ok(_) => {}
Err(e) => {
failed_checks.push(HealthCheckId::RuntimeConfig);
messages.push(format!("runtime-config load failed: {e:#}"));
}
}
match crate::runtime_config::env_dir_in(&self.env_root, env_id) {
Ok(env_dir) => match greentic_deployer::path_safety::normalize_under_root(
&env_dir,
&revision.signature_sidecar_ref,
) {
Ok(sig_path) if sig_path.is_file() => {}
Ok(sig_path) => {
failed_checks.push(HealthCheckId::SignatureStatus);
messages.push(format!(
"signature_sidecar_ref resolves to `{}`, which is not a regular file",
sig_path.display()
));
}
Err(e) => {
failed_checks.push(HealthCheckId::SignatureStatus);
messages.push(format!(
"signature_sidecar_ref `{}` is not a contained, existing sidecar: {e:#}",
revision.signature_sidecar_ref.display()
));
}
},
Err(e) => {
failed_checks.push(HealthCheckId::SignatureStatus);
messages.push(format!(
"environment id `{env_id}` is not a safe directory segment: {e:#}"
));
}
}
if failed_checks.is_empty() {
Ok(())
} else {
Err(HealthGateFailure {
failed_checks,
message: messages.join("; "),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use greentic_deploy_spec::{
BundleId, DeploymentId, EnvId, EnvironmentHostConfig, PackId, PackListEntry, Revision,
RevisionId, RevisionLifecycle, SchemaVersion, SemVer,
};
use tempfile::TempDir;
const ENV_ID: &str = "local";
fn fixed_now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 22, 12, 0, 0).unwrap()
}
fn env_id() -> EnvId {
EnvId::try_from(ENV_ID).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,
},
packs: Vec::new(),
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
revocation: Default::default(),
retention: Default::default(),
health: Default::default(),
}
}
fn make_revision(sig_ref: PathBuf) -> Revision {
Revision {
schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
revision_id: RevisionId::new(),
env_id: env_id(),
bundle_id: BundleId::new("fast2flow"),
deployment_id: DeploymentId::new(),
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"),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: sig_ref,
lifecycle: RevisionLifecycle::Warming,
staged_at: Some(fixed_now()),
warmed_at: None,
drain_seconds: 30,
abort_metrics: Vec::new(),
}
}
fn seed_env(sig_present: bool) -> (TempDir, PathBuf, Revision) {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
let sig_ref = PathBuf::from("rev.sig");
if sig_present {
std::fs::write(env_dir.join(&sig_ref), b"placeholder").unwrap();
}
let revision = make_revision(sig_ref);
let env_root = tmp.path().to_path_buf();
(tmp, env_root, revision)
}
#[test]
fn noop_always_passes() {
let (_tmp, _env_root, revision) = seed_env(false);
let env = make_env();
let gate = NoopRevisionHealthGate;
assert!(gate.check(&env, &revision).is_ok());
}
#[test]
fn start_passes_when_signature_present_and_no_runtime_config() {
let (_tmp, env_root, revision) = seed_env(true);
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let result = gate.check(&env, &revision);
assert!(result.is_ok(), "expected pass, got `{:?}`", result.err());
}
#[test]
fn start_fails_signature_status_when_sidecar_missing() {
let (_tmp, env_root, revision) = seed_env(false);
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("signature_sidecar_ref"),
"msg: {}",
err.message
);
}
#[test]
fn start_fails_runtime_config_when_file_malformed() {
let (_tmp, env_root, revision) = seed_env(true);
let env_dir = env_root.join(ENV_ID);
std::fs::write(
env_dir.join("runtime-config.json"),
b"{not even close to json",
)
.unwrap();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::RuntimeConfig]);
assert!(
err.message.contains("runtime-config load failed"),
"msg: {}",
err.message
);
}
#[test]
fn start_aggregates_multiple_failures() {
let (_tmp, env_root, revision) = seed_env(false);
let env_dir = env_root.join(ENV_ID);
std::fs::write(env_dir.join("runtime-config.json"), b"{bad").unwrap();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(
err.failed_checks,
vec![HealthCheckId::RuntimeConfig, HealthCheckId::SignatureStatus]
);
assert!(err.message.contains("runtime-config load failed"));
assert!(err.message.contains("signature_sidecar_ref"));
assert!(err.message.contains("; "));
}
#[test]
fn start_fails_signature_status_on_absolute_sidecar_ref() {
let (_tmp, env_root, mut revision) = seed_env(true);
let outside = tempfile::NamedTempFile::new().unwrap();
assert!(outside.path().is_absolute());
revision.signature_sidecar_ref = outside.path().to_path_buf();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[test]
fn start_fails_signature_status_on_parent_traversal_sidecar_ref() {
let (_tmp, env_root, mut revision) = seed_env(true);
std::fs::write(env_root.join("outside.sig"), b"escaped").unwrap();
revision.signature_sidecar_ref = PathBuf::from("../outside.sig");
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[cfg(unix)]
#[test]
fn start_fails_signature_status_on_symlink_escape() {
let (_tmp, env_root, mut revision) = seed_env(false);
let outside_dir = tempfile::tempdir().unwrap();
let outside_file = outside_dir.path().join("real.sig");
std::fs::write(&outside_file, b"escaped").unwrap();
let env_dir = env_root.join(ENV_ID);
std::os::unix::fs::symlink(&outside_file, env_dir.join("rev.sig")).unwrap();
revision.signature_sidecar_ref = PathBuf::from("rev.sig");
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[test]
fn start_rejects_dotdot_env_id_on_both_dependent_checks() {
let (_tmp, env_root, revision) = seed_env(true);
let mut env = make_env();
env.environment_id = EnvId::try_from("..").unwrap();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"signature check must reject `..` env_id, got {:?}",
err.failed_checks
);
assert!(
err.failed_checks.contains(&HealthCheckId::RuntimeConfig),
"runtime-config check must reject `..` env_id, got {:?}",
err.failed_checks
);
assert!(
err.message.contains("not a safe directory segment"),
"msg: {}",
err.message
);
}
#[cfg(unix)]
#[test]
fn start_passes_on_contained_symlink_sidecar() {
let (_tmp, env_root, mut revision) = seed_env(false);
let env_dir = env_root.join(ENV_ID);
std::fs::write(env_dir.join("actual.sig"), b"ok").unwrap();
std::os::unix::fs::symlink(env_dir.join("actual.sig"), env_dir.join("rev.sig")).unwrap();
revision.signature_sidecar_ref = PathBuf::from("rev.sig");
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
assert!(
gate.check(&env, &revision).is_ok(),
"a symlink contained within the env dir should pass"
);
}
#[test]
fn start_passes_with_absent_runtime_config_and_present_signature() {
let (_tmp, env_root, revision) = seed_env(true);
assert!(!env_root.join(ENV_ID).join("runtime-config.json").exists());
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
assert!(gate.check(&env, &revision).is_ok());
}
#[test]
fn dyn_trait_object_dispatch_works_for_both_impls() {
let (_tmp, env_root, revision) = seed_env(true);
let env = make_env();
let gates: Vec<std::sync::Arc<dyn RevisionHealthGate>> = vec![
std::sync::Arc::new(NoopRevisionHealthGate),
std::sync::Arc::new(StartRevisionHealthGate::new(env_root)),
];
for g in &gates {
assert!(g.check(&env, &revision).is_ok());
}
}
#[test]
fn adapts_to_warm_with_health_gate_closure_shape() {
let (_tmp, env_root, revision) = seed_env(true);
let env = make_env();
let gate: std::sync::Arc<dyn RevisionHealthGate> =
std::sync::Arc::new(StartRevisionHealthGate::new(env_root));
let closure = |e: &Environment, r: &Revision| gate.check(e, r);
assert!(closure(&env, &revision).is_ok());
}
}