use greentic_deploy_spec::{
CapabilitySlot, DEFAULT_LISTEN_ADDR, EnvId, Environment, EnvironmentHostConfig, SchemaVersion,
};
use crate::defaults::{LOCAL_ENV_ID, local_pack_bindings};
use crate::environment::LocalFsStore;
use super::OpError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalEnvOutcome {
Created,
AlreadyExists,
Healed { added_slots: Vec<CapabilitySlot> },
}
pub fn ensure_local_environment(
store: &LocalFsStore,
) -> Result<(Environment, LocalEnvOutcome), OpError> {
let env_id = EnvId::try_from(LOCAL_ENV_ID).map_err(|e| {
OpError::InvalidArgument(format!("default env id `{}`: {}", LOCAL_ENV_ID, e))
})?;
store.transact(
&env_id,
|locked| -> Result<(Environment, LocalEnvOutcome), OpError> {
if let Ok(mut existing) = locked.load() {
let added = fill_missing_default_bindings(&mut existing)?;
if added.is_empty() {
return Ok((existing, LocalEnvOutcome::AlreadyExists));
}
locked.save(&existing)?;
return Ok((existing, LocalEnvOutcome::Healed { added_slots: added }));
}
let packs = local_pack_bindings().map_err(|e| {
OpError::InvalidArgument(format!("default pack binding parse: {e}"))
})?;
let env = Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: locked.env_id().clone(),
name: LOCAL_ENV_ID.to_string(),
host_config: EnvironmentHostConfig {
env_id: locked.env_id().clone(),
region: None,
tenant_org_id: None,
listen_addr: Some(DEFAULT_LISTEN_ADDR),
},
packs,
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
messaging_endpoints: Vec::new(),
revocation: Default::default(),
retention: Default::default(),
health: Default::default(),
};
locked.save(&env)?;
Ok((env, LocalEnvOutcome::Created))
},
)
}
fn fill_missing_default_bindings(env: &mut Environment) -> Result<Vec<CapabilitySlot>, OpError> {
let defaults = local_pack_bindings()
.map_err(|e| OpError::InvalidArgument(format!("default pack binding parse: {e}")))?;
let mut added = Vec::new();
for binding in defaults {
if env.packs.iter().any(|b| b.slot == binding.slot) {
continue;
}
added.push(binding.slot);
env.packs.push(binding);
}
Ok(added)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::defaults::{
LOCAL_DEPLOYER_PACK, LOCAL_SECRETS_PACK, LOCAL_SESSIONS_PACK, LOCAL_STATE_PACK,
LOCAL_TELEMETRY_PACK,
};
use greentic_deploy_spec::CapabilitySlot;
use tempfile::TempDir;
fn store() -> (TempDir, LocalFsStore) {
let tmp = TempDir::new().expect("tempdir");
let store = LocalFsStore::new(tmp.path().to_path_buf());
(tmp, store)
}
#[test]
fn creates_local_env_when_missing() {
let (_tmp, store) = store();
let (env, outcome) = ensure_local_environment(&store).expect("bootstrap");
assert_eq!(outcome, LocalEnvOutcome::Created);
assert_eq!(env.environment_id.as_str(), LOCAL_ENV_ID);
assert_eq!(env.name, LOCAL_ENV_ID);
assert_eq!(env.packs.len(), 5);
env.validate().expect("env is spec-valid");
}
#[test]
fn returns_existing_env_on_second_call() {
let (_tmp, store) = store();
let (first, first_outcome) = ensure_local_environment(&store).expect("first bootstrap");
assert_eq!(first_outcome, LocalEnvOutcome::Created);
let (second, second_outcome) = ensure_local_environment(&store).expect("second bootstrap");
assert_eq!(second_outcome, LocalEnvOutcome::AlreadyExists);
assert_eq!(first, second);
}
#[test]
fn default_bindings_cover_expected_slots_and_descriptors() {
let (_tmp, store) = store();
let (env, _) = ensure_local_environment(&store).expect("bootstrap");
let by_slot: std::collections::BTreeMap<CapabilitySlot, &str> = env
.packs
.iter()
.map(|b| (b.slot, b.kind.as_str()))
.collect();
assert_eq!(
by_slot.get(&CapabilitySlot::Deployer).copied(),
Some(LOCAL_DEPLOYER_PACK)
);
assert_eq!(
by_slot.get(&CapabilitySlot::Secrets).copied(),
Some(LOCAL_SECRETS_PACK)
);
assert_eq!(
by_slot.get(&CapabilitySlot::Telemetry).copied(),
Some(LOCAL_TELEMETRY_PACK)
);
assert_eq!(
by_slot.get(&CapabilitySlot::Sessions).copied(),
Some(LOCAL_SESSIONS_PACK)
);
assert_eq!(
by_slot.get(&CapabilitySlot::State).copied(),
Some(LOCAL_STATE_PACK)
);
assert!(!by_slot.contains_key(&CapabilitySlot::Revocation));
}
#[test]
fn bootstrap_env_has_no_bundles_or_revisions_or_splits() {
let (_tmp, store) = store();
let (env, _) = ensure_local_environment(&store).expect("bootstrap");
assert!(env.bundles.is_empty());
assert!(env.revisions.is_empty());
assert!(env.traffic_splits.is_empty());
assert!(env.credentials_ref.is_none());
}
#[test]
fn bootstrap_env_host_config_has_no_region_or_org() {
let (_tmp, store) = store();
let (env, _) = ensure_local_environment(&store).expect("bootstrap");
assert_eq!(env.host_config.env_id, env.environment_id);
assert!(env.host_config.region.is_none());
assert!(env.host_config.tenant_org_id.is_none());
}
#[test]
fn bootstrap_env_writes_default_listen_addr_so_start_can_resolve_it() {
let (_tmp, store) = store();
let (env, outcome) = ensure_local_environment(&store).expect("bootstrap");
assert_eq!(outcome, LocalEnvOutcome::Created);
assert_eq!(
env.host_config.listen_addr,
Some(DEFAULT_LISTEN_ADDR),
"fresh env must carry the canonical loopback default so `gtc start` \
on an empty env has a deterministic bind address",
);
assert_eq!(env.host_config.resolved_listen_addr(), DEFAULT_LISTEN_ADDR);
}
#[test]
fn bootstrap_heal_path_preserves_user_set_listen_addr() {
use crate::environment::EnvironmentStore;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let (_tmp, store) = store();
let (mut env, _) = ensure_local_environment(&store).expect("bootstrap");
let custom = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
env.host_config.listen_addr = Some(custom);
store.save(&env).expect("user save");
let (reloaded, outcome) = ensure_local_environment(&store).expect("second bootstrap");
assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
assert_eq!(
reloaded.host_config.listen_addr,
Some(custom),
"user's custom bind must survive re-bootstrap",
);
}
#[test]
fn second_call_does_not_overwrite_user_mutations() {
use crate::environment::EnvironmentStore;
let (_tmp, store) = store();
let (mut env, _) = ensure_local_environment(&store).expect("bootstrap");
env.name = "user-renamed".to_string();
env.host_config.region = Some("eu-west-1".to_string());
store.save(&env).expect("user save");
let (reloaded, outcome) = ensure_local_environment(&store).expect("second bootstrap");
assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
assert_eq!(reloaded.name, "user-renamed");
assert_eq!(reloaded.host_config.region.as_deref(), Some("eu-west-1"));
}
use crate::environment::EnvironmentStore;
use greentic_deploy_spec::{EnvPackBinding, PackDescriptor, PackId};
fn seed_empty_local_env(store: &LocalFsStore) -> Environment {
let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
let env = Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: env_id.clone(),
name: LOCAL_ENV_ID.to_string(),
host_config: EnvironmentHostConfig {
env_id,
region: None,
tenant_org_id: None,
listen_addr: None,
},
packs: Vec::new(),
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
messaging_endpoints: Vec::new(),
revocation: Default::default(),
retention: Default::default(),
health: Default::default(),
};
store.save(&env).expect("seed save");
env
}
fn custom_binding(slot: CapabilitySlot, descriptor: &str) -> EnvPackBinding {
EnvPackBinding {
slot,
kind: PackDescriptor::try_new(descriptor).expect("valid descriptor"),
pack_ref: PackId::new(descriptor),
answers_ref: None,
generation: 0,
previous_binding_ref: None,
}
}
#[test]
fn heals_existing_env_with_no_packs() {
let (_tmp, store) = store();
seed_empty_local_env(&store);
let (env, outcome) = ensure_local_environment(&store).expect("bootstrap heal");
match outcome {
LocalEnvOutcome::Healed { added_slots } => {
assert_eq!(
added_slots,
vec![
CapabilitySlot::Deployer,
CapabilitySlot::Secrets,
CapabilitySlot::Telemetry,
CapabilitySlot::Sessions,
CapabilitySlot::State,
],
"all 5 default slots should be reported as added"
);
}
other => panic!("expected Healed, got {other:?}"),
}
assert_eq!(env.packs.len(), 5);
env.validate().expect("env is spec-valid after heal");
let (_again, outcome2) = ensure_local_environment(&store).expect("second bootstrap");
assert_eq!(outcome2, LocalEnvOutcome::AlreadyExists);
}
#[test]
fn heals_existing_env_with_partial_packs() {
let (_tmp, store) = store();
let mut env = seed_empty_local_env(&store);
env.packs.push(custom_binding(
CapabilitySlot::Deployer,
LOCAL_DEPLOYER_PACK,
));
store.save(&env).expect("partial save");
let (env, outcome) = ensure_local_environment(&store).expect("bootstrap heal");
match outcome {
LocalEnvOutcome::Healed { added_slots } => {
assert_eq!(
added_slots,
vec![
CapabilitySlot::Secrets,
CapabilitySlot::Telemetry,
CapabilitySlot::Sessions,
CapabilitySlot::State,
],
"only the 4 missing slots should be reported as added"
);
}
other => panic!("expected Healed, got {other:?}"),
}
assert_eq!(env.packs.len(), 5);
env.validate()
.expect("env is spec-valid after partial heal");
}
#[test]
fn heal_preserves_user_bound_non_default_descriptor() {
let (_tmp, store) = store();
let mut env = seed_empty_local_env(&store);
let custom_secrets = "greentic.secrets.aws-secrets-manager@1.0.0";
env.packs
.push(custom_binding(CapabilitySlot::Secrets, custom_secrets));
store.save(&env).expect("custom-secrets save");
let (env, outcome) = ensure_local_environment(&store).expect("bootstrap heal");
match outcome {
LocalEnvOutcome::Healed { added_slots } => {
assert_eq!(
added_slots,
vec![
CapabilitySlot::Deployer,
CapabilitySlot::Telemetry,
CapabilitySlot::Sessions,
CapabilitySlot::State,
],
"secrets must NOT be re-added; the 4 other defaults fill the gaps"
);
}
other => panic!("expected Healed, got {other:?}"),
}
let secrets_descriptor = env
.packs
.iter()
.find(|b| b.slot == CapabilitySlot::Secrets)
.map(|b| b.kind.as_str())
.expect("secrets slot present");
assert_eq!(
secrets_descriptor, custom_secrets,
"user's custom secrets descriptor must survive bootstrap"
);
assert_eq!(env.packs.len(), 5);
}
#[test]
fn fully_bound_env_yields_already_exists_with_no_healing() {
let (_tmp, store) = store();
ensure_local_environment(&store).expect("first bootstrap");
let (_env, outcome) = ensure_local_environment(&store).expect("second bootstrap");
assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
}
}