use chrono::Utc;
use greentic_deploy_spec::{EnvId, Environment, EnvironmentHostConfig, SchemaVersion};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::environment::{EnvironmentStore, LocalFsStore};
use super::{AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record};
const NOUN: &str = "env";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvCreatePayload {
pub environment_id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_org_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvSummary {
pub environment_id: String,
pub name: String,
pub region: Option<String>,
pub tenant_org_id: Option<String>,
pub pack_count: usize,
pub bundle_count: usize,
pub revision_count: usize,
}
impl From<&Environment> for EnvSummary {
fn from(env: &Environment) -> Self {
Self {
environment_id: env.environment_id.as_str().to_string(),
name: env.name.clone(),
region: env.host_config.region.clone(),
tenant_org_id: env.host_config.tenant_org_id.clone(),
pack_count: env.packs.len(),
bundle_count: env.bundles.len(),
revision_count: env.revisions.len(),
}
}
}
pub fn create(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<EnvCreatePayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return schema_outcome("create");
}
let payload = resolve_payload::<EnvCreatePayload>(flags, payload)?;
let env_id = EnvId::try_from(payload.environment_id.as_str())
.map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "create",
target: json!({"environment_id": env_id.as_str()}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
let env = store.transact(&env_id, |locked| -> Result<Environment, OpError> {
if locked.load().is_ok() {
return Err(OpError::Conflict(format!(
"environment `{}` already exists",
locked.env_id()
)));
}
let env = Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: locked.env_id().clone(),
name: payload.name.clone(),
host_config: EnvironmentHostConfig {
env_id: locked.env_id().clone(),
region: payload.region.clone(),
tenant_org_id: payload.tenant_org_id.clone(),
},
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(),
};
locked.save(&env)?;
Ok(env)
})?;
let outcome = OpOutcome::new(
NOUN,
"create",
serde_json::to_value(EnvSummary::from(&env)).expect("EnvSummary is json-safe"),
);
Ok((outcome, super::AuditGens::NONE))
})
}
pub fn update(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<EnvCreatePayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return schema_outcome("update");
}
let payload = resolve_payload::<EnvCreatePayload>(flags, payload)?;
let env_id = EnvId::try_from(payload.environment_id.as_str())
.map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))?;
let mut fields = Vec::new();
if payload.name != payload.environment_id {
fields.push("name");
}
if payload.region.is_some() {
fields.push("region");
}
if payload.tenant_org_id.is_some() {
fields.push("tenant_org_id");
}
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "update",
target: json!({"environment_id": env_id.as_str(), "fields": fields}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
let env = store.transact(&env_id, |locked| -> Result<Environment, OpError> {
let mut env = match locked.load() {
Ok(env) => env,
Err(crate::environment::StoreError::NotFound(id)) => {
return Err(OpError::NotFound(format!("environment `{id}`")));
}
Err(e) => return Err(e.into()),
};
env.name = payload.name.clone();
env.host_config.region = payload.region.clone();
env.host_config.tenant_org_id = payload.tenant_org_id.clone();
locked.save(&env)?;
Ok(env)
})?;
let outcome = OpOutcome::new(
NOUN,
"update",
serde_json::to_value(EnvSummary::from(&env)).expect("EnvSummary is json-safe"),
);
Ok((outcome, super::AuditGens::NONE))
})
}
pub fn list(store: &LocalFsStore, flags: &OpFlags) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"list",
json!({ "input_schema": "no input" }),
));
}
let mut summaries = Vec::new();
for env_id in store.list()? {
let env = store.load(&env_id)?;
summaries.push(EnvSummary::from(&env));
}
Ok(OpOutcome::new(
NOUN,
"list",
json!({ "environments": summaries }),
))
}
pub fn show(store: &LocalFsStore, flags: &OpFlags, env_id: &str) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"show",
json!({ "input_schema": "env_id positional" }),
));
}
let env_id =
EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
if !store.exists(&env_id)? {
return Err(OpError::NotFound(format!("environment `{env_id}`")));
}
let env = store.load(&env_id)?;
let runtime = store.load_runtime(&env_id)?;
Ok(OpOutcome::new(
NOUN,
"show",
json!({
"environment": env,
"runtime": runtime,
}),
))
}
pub fn doctor(store: &LocalFsStore, flags: &OpFlags, env_id: &str) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"doctor",
json!({ "input_schema": "env_id positional" }),
));
}
let env_id =
EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
if !store.exists(&env_id)? {
return Err(OpError::NotFound(format!("environment `{env_id}`")));
}
let env = store.load(&env_id)?;
let runtime = store.load_runtime(&env_id)?;
let validate_result = env.validate();
let bound_slots: Vec<String> = env.packs.iter().map(|b| b.slot.to_string()).collect();
let missing_slots: Vec<String> = greentic_deploy_spec::CapabilitySlot::ALL
.iter()
.copied()
.filter(|s| env.pack_for_slot(*s).is_none())
.map(|s| s.to_string())
.collect();
let registry = crate::env_packs::EnvPackRegistry::with_builtins();
let mut unknown_kinds: Vec<String> = Vec::new();
let mut slot_mismatches: Vec<Value> = Vec::new();
let mut version_skew: Vec<Value> = Vec::new();
for binding in &env.packs {
match registry.resolve_for_slot(binding.slot, &binding.kind) {
Ok(_) => {}
Err(crate::env_packs::RegistryError::Unknown(kind)) => unknown_kinds.push(kind),
Err(crate::env_packs::RegistryError::SlotMismatch {
kind,
expected,
actual,
}) => slot_mismatches.push(json!({
"kind": kind,
"bound_slot": expected.to_string(),
"handler_slot": actual.to_string(),
})),
Err(crate::env_packs::RegistryError::VersionUnsupported {
kind,
requested,
supported,
}) => version_skew.push(json!({
"kind": kind,
"requested": requested,
"supported": supported,
})),
Err(err @ crate::env_packs::RegistryError::DuplicateRegistration(_)) => {
unreachable!("resolve_for_slot never returns {err:?}")
}
}
}
Ok(OpOutcome::new(
NOUN,
"doctor",
json!({
"environment_id": env.environment_id.as_str(),
"validate": match &validate_result {
Ok(()) => json!({"status": "ok"}),
Err(e) => json!({"status": "error", "message": e.to_string()}),
},
"bound_slots": bound_slots,
"missing_slots": missing_slots,
"unknown_kinds": unknown_kinds,
"slot_mismatches": slot_mismatches,
"version_skew": version_skew,
"has_runtime": runtime.is_some(),
"checked_at": Utc::now(),
}),
))
}
pub fn tool_check(
store: &LocalFsStore,
flags: &OpFlags,
env_id: &str,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"tool-check",
json!({ "input_schema": "env_id positional" }),
));
}
let env_id =
EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
if !store.exists(&env_id)? {
return Err(OpError::NotFound(format!("environment `{env_id}`")));
}
let env = store.load(&env_id)?;
let registry = crate::env_packs::EnvPackRegistry::with_builtins();
let mut bindings: Vec<Value> = Vec::with_capacity(env.packs.len());
let mut unresolved_bindings: Vec<Value> = Vec::new();
let mut total_checks = 0usize;
let mut failed_checks = 0usize;
for binding in &env.packs {
match registry.resolve_for_slot(binding.slot, &binding.kind) {
Ok(handler) => {
let checks = handler.preflight();
total_checks += checks.len();
failed_checks += checks.iter().filter(|c| !c.outcome.is_ok()).count();
bindings.push(json!({
"slot": binding.slot.to_string(),
"kind": binding.kind.as_str(),
"checks": checks,
}));
}
Err(e) => unresolved_bindings.push(json!({
"slot": binding.slot.to_string(),
"kind": binding.kind.as_str(),
"error": e.to_string(),
})),
}
}
Ok(OpOutcome::new(
NOUN,
"tool-check",
json!({
"environment_id": env.environment_id.as_str(),
"bindings": bindings,
"unresolved_bindings": unresolved_bindings,
"total_checks": total_checks,
"failed_checks": failed_checks,
"checked_at": Utc::now(),
}),
))
}
pub fn init(store: &LocalFsStore, flags: &OpFlags) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"init",
json!({ "input_schema": "no input" }),
));
}
let env_id = EnvId::try_from(crate::defaults::LOCAL_ENV_ID).map_err(|e| {
OpError::InvalidArgument(format!(
"default env id `{}`: {}",
crate::defaults::LOCAL_ENV_ID,
e
))
})?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "init",
target: json!({"environment_id": env_id.as_str()}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
let (env, outcome) = super::bootstrap::ensure_local_environment(store)?;
let bound_slots: Vec<String> = env.packs.iter().map(|b| b.slot.to_string()).collect();
let mut payload = json!({
"environment_id": env.environment_id.as_str(),
"bound_slots": bound_slots,
"pack_count": env.packs.len(),
});
let payload_obj = payload
.as_object_mut()
.expect("payload constructed as object");
match outcome {
super::bootstrap::LocalEnvOutcome::Created => {
payload_obj.insert("outcome".into(), json!("created"));
}
super::bootstrap::LocalEnvOutcome::AlreadyExists => {
payload_obj.insert("outcome".into(), json!("untouched"));
}
super::bootstrap::LocalEnvOutcome::Healed { added_slots } => {
payload_obj.insert("outcome".into(), json!("healed"));
payload_obj.insert(
"added_slots".into(),
json!(
added_slots
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
),
);
}
}
let outcome = OpOutcome::new(NOUN, "init", payload);
Ok((outcome, super::AuditGens::NONE))
})
}
pub fn destroy(
store: &LocalFsStore,
flags: &OpFlags,
env_id: &str,
confirm: bool,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"destroy",
json!({ "input_schema": "env_id positional + confirm flag" }),
));
}
if !confirm {
return Err(OpError::InvalidArgument(
"destroy requires --confirm".to_string(),
));
}
let env_id =
EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "destroy",
target: json!({"environment_id": env_id.as_str(), "confirm": confirm}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
if !store.exists(&env_id)? {
return Err(OpError::NotFound(format!("environment `{env_id}`")));
}
Err(OpError::NotYetImplemented(
"`op env destroy` requires the retention path (B-phase); use the LocalFsStore root path returned by `op env show` for manual cleanup",
))
})
}
fn resolve_payload<T: serde::de::DeserializeOwned>(
flags: &OpFlags,
payload: Option<T>,
) -> Result<T, OpError> {
if let Some(p) = payload {
return Ok(p);
}
if let Some(path) = &flags.answers {
return super::load_answers::<T>(path);
}
Err(OpError::InvalidArgument(
"no payload provided: pass --answers <path> or supply the payload directly".to_string(),
))
}
fn schema_outcome(op: &'static str) -> Result<OpOutcome, OpError> {
Ok(OpOutcome::new(NOUN, op, env_create_payload_schema()))
}
pub fn env_create_payload_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EnvCreatePayload",
"type": "object",
"required": ["environment_id", "name"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string", "description": "EnvId — kebab-friendly env identifier."},
"name": {"type": "string"},
"region": {"type": ["string", "null"]},
"tenant_org_id": {"type": ["string", "null"]}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tests_common::make_env;
use crate::environment::LocalFsStore;
use tempfile::tempdir;
#[test]
fn create_then_show_roundtrip() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let flags = OpFlags::default();
let outcome = create(
&store,
&flags,
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
}),
)
.unwrap();
assert_eq!(outcome.op, "create");
assert_eq!(outcome.noun, "env");
let show_outcome = show(&store, &flags, "local").unwrap();
assert_eq!(show_outcome.op, "show");
let env_val = show_outcome
.result
.get("environment")
.expect("environment field");
assert_eq!(env_val.get("name").and_then(|v| v.as_str()), Some("local"));
}
#[test]
fn create_rejects_duplicate() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let env = make_env("local");
store.save(&env).unwrap();
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "again".to_string(),
region: None,
tenant_org_id: None,
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn update_rewrites_name_and_region() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let env = make_env("local");
store.save(&env).unwrap();
let outcome = update(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "renamed".to_string(),
region: Some("eu-west-1".to_string()),
tenant_org_id: None,
}),
)
.unwrap();
assert_eq!(
outcome.result.get("name").and_then(|v| v.as_str()),
Some("renamed")
);
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(env.name, "renamed");
assert_eq!(env.host_config.region.as_deref(), Some("eu-west-1"));
}
#[test]
fn update_rejects_missing_env() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = update(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "x".to_string(),
region: None,
tenant_org_id: None,
}),
)
.unwrap_err();
assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
}
#[test]
fn list_returns_sorted_envs() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("alpha")).unwrap();
store.save(&make_env("beta")).unwrap();
store.save(&make_env("gamma")).unwrap();
let outcome = list(&store, &OpFlags::default()).unwrap();
let envs = outcome
.result
.get("environments")
.and_then(|v| v.as_array())
.expect("environments array");
let names: Vec<&str> = envs
.iter()
.filter_map(|e| e.get("environment_id").and_then(|v| v.as_str()))
.collect();
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn init_creates_local_env_when_missing() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let outcome = init(&store, &OpFlags::default()).unwrap();
assert_eq!(outcome.op, "init");
assert_eq!(outcome.noun, "env");
assert_eq!(
outcome.result.get("outcome").and_then(|v| v.as_str()),
Some("created")
);
assert_eq!(
outcome.result.get("pack_count").and_then(|v| v.as_u64()),
Some(5)
);
assert!(outcome.result.get("added_slots").is_none());
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(env.packs.len(), 5);
}
#[test]
fn init_heals_partially_bound_env() {
use crate::defaults::LOCAL_DEPLOYER_PACK;
use greentic_deploy_spec::{CapabilitySlot, EnvPackBinding, PackDescriptor, PackId};
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs = vec![EnvPackBinding {
slot: CapabilitySlot::Deployer,
kind: PackDescriptor::try_new(LOCAL_DEPLOYER_PACK).unwrap(),
pack_ref: PackId::new(LOCAL_DEPLOYER_PACK),
answers_ref: None,
generation: 0,
previous_binding_ref: None,
}];
store.save(&env).unwrap();
let outcome = init(&store, &OpFlags::default()).unwrap();
assert_eq!(
outcome.result.get("outcome").and_then(|v| v.as_str()),
Some("healed")
);
let added: Vec<String> = outcome
.result
.get("added_slots")
.and_then(|v| v.as_array())
.expect("added_slots present on healed")
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
assert_eq!(
added,
vec!["secrets", "telemetry", "sessions", "state"],
"only the 4 missing slots are reported as added"
);
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(env.packs.len(), 5);
}
#[test]
fn init_is_idempotent_and_reports_untouched_on_second_call() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
init(&store, &OpFlags::default()).unwrap();
let outcome = init(&store, &OpFlags::default()).unwrap();
assert_eq!(
outcome.result.get("outcome").and_then(|v| v.as_str()),
Some("untouched")
);
assert!(outcome.result.get("added_slots").is_none());
}
#[test]
fn doctor_reports_missing_slots() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
let missing = outcome
.result
.get("missing_slots")
.and_then(|v| v.as_array())
.expect("missing_slots array");
assert_eq!(
missing.len(),
greentic_deploy_spec::CapabilitySlot::ALL.len()
);
}
#[test]
fn doctor_flags_unknown_kind_and_slot_mismatch() {
use crate::cli::tests_common::make_binding;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
greentic_deploy_spec::CapabilitySlot::Secrets,
"greentic.secrets.acme-vault@1.0.0",
));
env.packs.push(make_binding(
greentic_deploy_spec::CapabilitySlot::State,
"greentic.deployer.local-process@0.1.0",
));
store.save(&env).unwrap();
let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
let unknown = outcome
.result
.get("unknown_kinds")
.and_then(|v| v.as_array())
.expect("unknown_kinds array");
assert_eq!(unknown.len(), 1);
assert!(unknown[0].as_str().unwrap().contains("acme-vault"));
let mismatches = outcome
.result
.get("slot_mismatches")
.and_then(|v| v.as_array())
.expect("slot_mismatches array");
assert_eq!(mismatches.len(), 1);
assert_eq!(
mismatches[0].get("handler_slot").and_then(|v| v.as_str()),
Some("deployer")
);
assert_eq!(
mismatches[0].get("bound_slot").and_then(|v| v.as_str()),
Some("state")
);
}
#[test]
fn doctor_accepts_built_in_bindings() {
use crate::cli::tests_common::make_binding;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
greentic_deploy_spec::CapabilitySlot::Secrets,
"greentic.secrets.dev-store@0.1.0",
));
store.save(&env).unwrap();
let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
for field in ["unknown_kinds", "slot_mismatches", "version_skew"] {
assert!(
outcome
.result
.get(field)
.and_then(|v| v.as_array())
.unwrap()
.is_empty(),
"{field} should be empty for a built-in binding at its supported version"
);
}
}
#[test]
fn doctor_flags_unsupported_version() {
use crate::cli::tests_common::make_binding;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
greentic_deploy_spec::CapabilitySlot::Secrets,
"greentic.secrets.dev-store@9.9.9",
));
store.save(&env).unwrap();
let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
let skew = outcome
.result
.get("version_skew")
.and_then(|v| v.as_array())
.expect("version_skew array");
assert_eq!(skew.len(), 1);
assert_eq!(
skew[0].get("requested").and_then(|v| v.as_str()),
Some("9.9.9")
);
assert_eq!(
skew[0].get("supported").and_then(|v| v.as_str()),
Some("^0.1.0")
);
assert!(
outcome
.result
.get("unknown_kinds")
.and_then(|v| v.as_array())
.unwrap()
.is_empty()
);
}
#[test]
fn tool_check_returns_empty_per_binding_for_local_builtins() {
use crate::cli::tests_common::make_binding;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
for (slot, descriptor) in crate::defaults::LOCAL_DEFAULT_BINDINGS {
env.packs.push(make_binding(*slot, descriptor));
}
store.save(&env).unwrap();
let outcome = tool_check(&store, &OpFlags::default(), "local").unwrap();
let bindings = outcome
.result
.get("bindings")
.and_then(|v| v.as_array())
.expect("bindings array");
assert_eq!(
bindings.len(),
crate::defaults::LOCAL_DEFAULT_BINDINGS.len()
);
for entry in bindings {
let checks = entry
.get("checks")
.and_then(|v| v.as_array())
.expect("checks array on binding");
assert!(
checks.is_empty(),
"Phase A built-in handler should report no external tool checks"
);
}
assert_eq!(
outcome.result.get("total_checks").and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
outcome.result.get("failed_checks").and_then(|v| v.as_u64()),
Some(0)
);
assert!(
outcome
.result
.get("unresolved_bindings")
.and_then(|v| v.as_array())
.unwrap()
.is_empty()
);
}
#[test]
fn tool_check_surfaces_unresolved_bindings_alongside_resolved() {
use crate::cli::tests_common::make_binding;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
greentic_deploy_spec::CapabilitySlot::Secrets,
"greentic.secrets.dev-store@0.1.0",
));
env.packs.push(make_binding(
greentic_deploy_spec::CapabilitySlot::Deployer,
"greentic.deployer.does-not-exist@0.1.0",
));
store.save(&env).unwrap();
let outcome = tool_check(&store, &OpFlags::default(), "local").unwrap();
let bindings = outcome
.result
.get("bindings")
.and_then(|v| v.as_array())
.expect("bindings array");
assert_eq!(bindings.len(), 1, "only the resolvable binding is reported");
let unresolved = outcome
.result
.get("unresolved_bindings")
.and_then(|v| v.as_array())
.expect("unresolved_bindings array");
assert_eq!(unresolved.len(), 1);
assert_eq!(
unresolved[0].get("kind").and_then(|v| v.as_str()),
Some("greentic.deployer.does-not-exist@0.1.0")
);
assert!(
unresolved[0]
.get("error")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false)
);
}
#[test]
fn tool_check_schema_only_returns_input_schema() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let flags = OpFlags {
schema_only: true,
..Default::default()
};
let outcome = tool_check(&store, &flags, "local").unwrap();
assert_eq!(outcome.op, "tool-check");
assert!(outcome.result.get("input_schema").is_some());
}
#[test]
fn tool_check_missing_env_errors_not_found() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = tool_check(&store, &OpFlags::default(), "local").unwrap_err();
assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
}
#[test]
fn destroy_without_confirm_errors() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let err = destroy(&store, &OpFlags::default(), "local", false).unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
#[test]
fn destroy_with_confirm_returns_not_yet_implemented() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let err = destroy(&store, &OpFlags::default(), "local", true).unwrap_err();
assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
}
#[test]
fn create_non_local_env_refuses_and_audits_deny() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "prod".to_string(),
name: "prod".to_string(),
region: None,
tenant_org_id: None,
}),
)
.unwrap_err();
assert!(
matches!(err, OpError::Unauthorized { .. }),
"got {err:?}; deny-path must surface as Unauthorized"
);
let env_json = dir.path().join("prod").join("environment.json");
assert!(
!env_json.exists(),
"deny must not leave behind environment.json"
);
let log = dir.path().join("prod").join("audit").join("events.jsonl");
let raw = std::fs::read_to_string(&log).expect("audit log must exist on deny");
let event: crate::environment::AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
assert_eq!(event.env_id, "prod");
assert_eq!(event.noun, "env");
assert_eq!(event.verb, "create");
matches!(
event.authorization,
crate::environment::AuditDecision::Deny { .. }
);
match event.result {
crate::environment::AuditResult::Error { kind, .. } => {
assert_eq!(kind, "unauthorized");
}
other => panic!("expected Error result, got {other:?}"),
}
}
#[test]
fn create_local_env_writes_ok_audit_event() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
}),
)
.unwrap();
let log = dir.path().join("local").join("audit").join("events.jsonl");
let raw = std::fs::read_to_string(&log).unwrap();
let event: crate::environment::AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
assert_eq!(event.noun, "env");
assert_eq!(event.verb, "create");
matches!(
event.authorization,
crate::environment::AuditDecision::Allow { .. }
);
matches!(event.result, crate::environment::AuditResult::Ok);
}
}