use chrono::Utc;
use greentic_deploy_spec::{EnvId, Environment, EnvironmentHostConfig, validate_public_base_url};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::environment::{EnvironmentStore, LocalFsStore, UpdateEnvironmentPayload};
use super::{
AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
};
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen_addr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_base_url: 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen_addr: Option<std::net::SocketAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_base_url: 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(),
listen_addr: env.host_config.listen_addr,
public_base_url: env.host_config.public_base_url.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 parsed_listen_addr = payload
.listen_addr
.as_deref()
.map(|raw| {
raw.parse::<std::net::SocketAddr>().map_err(|e| {
OpError::InvalidArgument(format!(
"listen_addr {raw:?} is not a valid socket address: {e}"
))
})
})
.transpose()?;
let parsed_public_base_url = parse_optional_public_base_url(&payload.public_base_url)?;
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
.create_environment(
&env_id,
payload.name,
EnvironmentHostConfig {
env_id: env_id.clone(),
region: payload.region,
tenant_org_id: payload.tenant_org_id,
listen_addr: parsed_listen_addr,
public_base_url: parsed_public_base_url,
},
)
.map_err(map_store_err_preserving_noun)?;
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 parsed_public_base_url = parse_optional_public_base_url(&payload.public_base_url)?;
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");
}
if parsed_public_base_url.is_some() {
fields.push("public_base_url");
}
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
.update_environment(
&env_id,
UpdateEnvironmentPayload {
name: Some(payload.name),
region: payload.region,
tenant_org_id: payload.tenant_org_id,
listen_addr: None,
public_base_url: parsed_public_base_url,
},
)
.map_err(map_store_err_preserving_noun)?;
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| s.binds_in_packs())
.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(_)
| crate::env_packs::RegistryError::DeployerMissingCredentials { .. }),
) => {
unreachable!("resolve_for_slot never returns {err:?}")
}
}
}
let mut extension_report = ExtensionDoctor::default();
for ext in &env.extensions {
match registry.resolve_for_slot(greentic_deploy_spec::CapabilitySlot::Extension, &ext.kind)
{
Ok(_) => {}
Err(crate::env_packs::RegistryError::Unknown(kind)) => {
extension_report.unknown_kinds.push(kind)
}
Err(crate::env_packs::RegistryError::SlotMismatch { kind, actual, .. }) => {
extension_report.slot_mismatches.push(json!({
"kind": kind,
"handler_slot": actual.to_string(),
}))
}
Err(crate::env_packs::RegistryError::VersionUnsupported {
kind,
requested,
supported,
}) => extension_report.version_skew.push(json!({
"kind": kind,
"requested": requested,
"supported": supported,
})),
Err(
err @ (crate::env_packs::RegistryError::DuplicateRegistration(_)
| crate::env_packs::RegistryError::DeployerMissingCredentials { .. }),
) => {
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,
"extensions": {
"count": env.extensions.len(),
"unknown_kinds": extension_report.unknown_kinds,
"slot_mismatches": extension_report.slot_mismatches,
"version_skew": extension_report.version_skew,
},
"has_runtime": runtime.is_some(),
"checked_at": Utc::now(),
}),
))
}
#[derive(Default)]
struct ExtensionDoctor {
unknown_kinds: Vec<String>,
slot_mismatches: Vec<Value>,
version_skew: Vec<Value>,
}
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(),
})),
}
}
let mut extension_bindings: Vec<Value> = Vec::with_capacity(env.extensions.len());
let mut extension_unresolved: Vec<Value> = Vec::new();
for ext in &env.extensions {
match registry.resolve_for_slot(greentic_deploy_spec::CapabilitySlot::Extension, &ext.kind)
{
Ok(handler) => {
let checks = handler.preflight();
total_checks += checks.len();
failed_checks += checks.iter().filter(|c| !c.outcome.is_ok()).count();
extension_bindings.push(json!({
"kind": ext.kind.as_str(),
"instance_id": ext.instance_id,
"checks": checks,
}));
}
Err(e) => extension_unresolved.push(json!({
"kind": ext.kind.as_str(),
"instance_id": ext.instance_id,
"error": e.to_string(),
})),
}
}
Ok(OpOutcome::new(
NOUN,
"tool-check",
json!({
"environment_id": env.environment_id.as_str(),
"bindings": bindings,
"unresolved_bindings": unresolved_bindings,
"extension_bindings": extension_bindings,
"extension_unresolved_bindings": extension_unresolved,
"total_checks": total_checks,
"failed_checks": failed_checks,
"checked_at": Utc::now(),
}),
))
}
pub fn init(
store: &LocalFsStore,
flags: &OpFlags,
payload: EnvInitPayload,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"init",
json!({ "input_schema": "optional --public-url" }),
));
}
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 validated_public_base_url = parse_optional_public_base_url(&payload.public_base_url)?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "init",
target: json!({
"environment_id": env_id.as_str(),
"public_base_url_applied": validated_public_base_url.is_some(),
}),
idempotency_key: None,
};
audit_and_record(store, ctx, |committed| {
let (env, outcome) =
super::bootstrap::ensure_local_environment(store, validated_public_base_url.clone())?;
committed.mark_committed();
let trust_root_seed = store
.seed_trust_root_if_absent(&env.environment_id)
.map_err(super::map_store_err_preserving_noun)?;
let trust_root = super::trust_root::trust_root_seed_to_wire_opt(
&env.environment_id,
trust_root_seed.as_ref(),
);
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(),
"public_base_url": env.host_config.public_base_url,
"trust_root": trust_root,
});
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"]},
"listen_addr": {"type": ["string", "null"]},
"public_base_url": {"type": ["string", "null"], "description": "origin-only URL (https://host[:port])"}
}
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnvInitPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_base_url: Option<String>,
}
impl super::dispatch::EnvInitArgs {
pub fn into_payload(self, _flags: &OpFlags) -> Result<EnvInitPayload, OpError> {
Ok(EnvInitPayload {
public_base_url: self.public_url,
})
}
}
pub(super) fn parse_public_base_url(raw: &str) -> Result<String, OpError> {
validate_public_base_url(raw)
.map_err(|e| OpError::InvalidArgument(format!("public_base_url: {e}")))
}
pub(super) fn parse_optional_public_base_url(
raw: &Option<String>,
) -> Result<Option<String>, OpError> {
raw.as_deref().map(parse_public_base_url).transpose()
}
fn reject_inline_plus_answers(
has_inline: bool,
flags: &OpFlags,
verb: &'static str,
) -> Result<(), OpError> {
if has_inline && flags.answers.is_some() {
return Err(OpError::InvalidArgument(format!(
"env {verb}: inline flags and --answers are mutually exclusive; use one or the other"
)));
}
Ok(())
}
fn partial_inline_error(verb: &'static str, missing: &[&str]) -> OpError {
OpError::InvalidArgument(format!(
"env {verb}: inline-flag form requires --environment-id and --name; missing: {}",
missing.join(", ")
))
}
fn require_inline_env_id_and_name(
has_inline: bool,
env_id: Option<String>,
name: Option<String>,
verb: &'static str,
flags: &OpFlags,
) -> Result<Option<(String, String)>, OpError> {
reject_inline_plus_answers(has_inline, flags, verb)?;
if !has_inline {
return Ok(None);
}
let mut missing: Vec<&'static str> = Vec::new();
if env_id.is_none() {
missing.push("--environment-id");
}
if name.is_none() {
missing.push("--name");
}
if !missing.is_empty() {
return Err(partial_inline_error(verb, &missing));
}
Ok(Some((
env_id.expect("checked above"),
name.expect("checked above"),
)))
}
impl super::dispatch::EnvCreateArgs {
fn has_inline_input(&self) -> bool {
self.environment_id.is_some()
|| self.name.is_some()
|| self.region.is_some()
|| self.tenant_org_id.is_some()
|| self.listen_addr.is_some()
|| self.public_url.is_some()
}
pub fn into_payload(
self,
verb: &'static str,
flags: &OpFlags,
) -> Result<Option<EnvCreatePayload>, OpError> {
let has_inline = self.has_inline_input();
let Some((environment_id, name)) = require_inline_env_id_and_name(
has_inline,
self.environment_id,
self.name,
verb,
flags,
)?
else {
return Ok(None);
};
Ok(Some(EnvCreatePayload {
environment_id,
name,
region: self.region,
tenant_org_id: self.tenant_org_id,
listen_addr: self.listen_addr,
public_base_url: self.public_url,
}))
}
}
impl super::dispatch::EnvUpdateArgs {
fn has_inline_input(&self) -> bool {
self.environment_id.is_some()
|| self.name.is_some()
|| self.region.is_some()
|| self.tenant_org_id.is_some()
}
pub fn into_payload(
self,
verb: &'static str,
flags: &OpFlags,
) -> Result<Option<EnvCreatePayload>, OpError> {
let has_inline = self.has_inline_input();
let Some((environment_id, name)) = require_inline_env_id_and_name(
has_inline,
self.environment_id,
self.name,
verb,
flags,
)?
else {
return Ok(None);
};
Ok(Some(EnvCreatePayload {
environment_id,
name,
region: self.region,
tenant_org_id: self.tenant_org_id,
listen_addr: None,
public_base_url: None,
}))
}
}
pub fn set_public_url(
store: &LocalFsStore,
flags: &OpFlags,
env_id: &str,
url: &str,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(
NOUN,
"set-public-url",
json!({ "input_schema": "<env_id> <url> positional" }),
));
}
let env_id =
EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
let validated = parse_public_base_url(url)?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "set-public-url",
target: json!({"environment_id": env_id.as_str()}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
let env = store
.update_environment(
&env_id,
UpdateEnvironmentPayload {
public_base_url: Some(validated),
..Default::default()
},
)
.map_err(map_store_err_preserving_noun)?;
let outcome = OpOutcome::new(
NOUN,
"set-public-url",
json!({
"environment_id": env_id.as_str(),
"host_config": env.host_config,
}),
);
Ok((outcome, super::AuditGens::NONE))
})
}
#[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,
listen_addr: None,
public_base_url: 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,
listen_addr: None,
public_base_url: 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,
listen_addr: None,
public_base_url: 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 create_persists_explicit_listen_addr_and_surfaces_it_in_summary() {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let outcome = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: Some("0.0.0.0:9090".to_string()),
public_base_url: None,
}),
)
.unwrap();
let listen = outcome
.result
.get("listen_addr")
.and_then(|v| v.as_str())
.expect("EnvSummary must expose listen_addr");
assert_eq!(listen, "0.0.0.0:9090");
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let expected = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
assert_eq!(env.host_config.listen_addr, Some(expected));
}
#[test]
fn create_rejects_malformed_listen_addr_before_touching_store() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: Some("not-a-socket-addr".to_string()),
public_base_url: None,
}),
)
.expect_err("malformed listen_addr must be rejected");
let msg = err.to_string();
assert!(
msg.contains("listen_addr") && msg.contains("not-a-socket-addr"),
"error must name the offending field + value, got: {msg}"
);
assert!(store.list().unwrap().is_empty());
}
#[test]
fn create_preserves_corrupt_environment_json_instead_of_overwriting() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let env_dir = dir.path().join("local");
std::fs::create_dir_all(&env_dir).unwrap();
let corrupt_path = env_dir.join("environment.json");
let corrupt_bytes = b"{ this is not valid json";
std::fs::write(&corrupt_path, corrupt_bytes).unwrap();
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.expect_err("create over a corrupt environment.json must fail");
assert!(
!matches!(err, OpError::Conflict(_)),
"expected store/json error, got Conflict (overwrote the file?): {err:?}"
);
let on_disk = std::fs::read(&corrupt_path).unwrap();
assert_eq!(
on_disk, corrupt_bytes,
"create must not have rewritten environment.json"
);
}
#[test]
fn create_with_no_listen_addr_persists_none_so_runtime_falls_back_to_default() {
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,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap();
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(env.host_config.listen_addr, None);
assert_eq!(
env.host_config.resolved_listen_addr(),
greentic_deploy_spec::DEFAULT_LISTEN_ADDR,
);
}
#[test]
fn update_preserves_existing_fields_when_payload_omits_them() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.host_config.region = Some("eu-west-1".to_string());
env.host_config.tenant_org_id = Some("acme".to_string());
env.host_config.public_base_url = Some("https://existing.example.com".to_string());
store.save(&env).unwrap();
update(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "renamed".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
}),
)
.unwrap();
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"),
"region must NOT be wiped when payload omits it"
);
assert_eq!(
env.host_config.tenant_org_id.as_deref(),
Some("acme"),
"tenant_org_id must NOT be wiped when payload omits it"
);
assert_eq!(
env.host_config.public_base_url.as_deref(),
Some("https://existing.example.com"),
"public_base_url must NOT be wiped when payload omits it"
);
}
#[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,
listen_addr: None,
public_base_url: 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(), EnvInitPayload::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(), EnvInitPayload::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(), EnvInitPayload::default()).unwrap();
let outcome = init(&store, &OpFlags::default(), EnvInitPayload::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 init_seeds_operator_key_into_env_trust_root_on_first_run() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
let trust_root = outcome
.result
.get("trust_root")
.expect("init outcome carries `trust_root`");
assert!(
trust_root.is_object(),
"first init must seed and surface the summary, got {trust_root:?}"
);
assert_eq!(
trust_root.get("environment_id").and_then(|v| v.as_str()),
Some("local")
);
let key_id = trust_root
.get("operator_key_id")
.and_then(|v| v.as_str())
.expect("operator_key_id present");
assert!(!key_id.is_empty(), "operator_key_id must not be empty");
assert!(
trust_root
.get("operator_public_key_pem")
.and_then(|v| v.as_str())
.is_some_and(|pem| pem.starts_with("-----BEGIN PUBLIC KEY-----"))
);
assert_eq!(
trust_root.get("trusted_key_count").and_then(|v| v.as_u64()),
Some(1),
"first init seeds exactly one operator key"
);
let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
let keys = listed.result["keys"].as_array().expect("keys array");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0]["key_id"].as_str(), Some(key_id));
}
#[test]
fn second_init_does_not_re_touch_trust_root() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let first = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
let first_key_id = first.result["trust_root"]["operator_key_id"]
.as_str()
.expect("first init seeded a key")
.to_string();
let second = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
let tr = second
.result
.as_object()
.expect("outcome is a JSON object")
.get("trust_root");
assert!(
tr.is_some_and(|v| v.is_null()),
"second init must report `trust_root: null` (got {tr:?})"
);
let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
let keys = listed.result["keys"].as_array().unwrap();
assert_eq!(keys.len(), 1, "second init must not duplicate the key");
assert_eq!(keys[0]["key_id"].as_str(), Some(first_key_id.as_str()));
}
#[test]
fn init_does_not_re_seed_after_operator_key_was_removed() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let first = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
let key_id = first.result["trust_root"]["operator_key_id"]
.as_str()
.expect("first init seeded a key")
.to_string();
super::super::trust_root::remove(
&store,
&OpFlags::default(),
Some(super::super::trust_root::TrustRootRemovePayload {
environment_id: "local".into(),
key_id: key_id.clone(),
}),
)
.unwrap();
let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
assert_eq!(
listed.result["keys"].as_array().unwrap().len(),
0,
"precondition: remove must clear the trust root"
);
let second = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
let tr = second
.result
.as_object()
.expect("outcome is a JSON object")
.get("trust_root");
assert!(
tr.is_some_and(|v| v.is_null()),
"init must not re-grant trust on a revoked key (got {tr:?})"
);
let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
assert_eq!(
listed.result["keys"].as_array().unwrap().len(),
0,
"revoked key must STAY absent across subsequent init runs"
);
}
#[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");
let core_slots = greentic_deploy_spec::CapabilitySlot::ALL
.iter()
.filter(|s| s.binds_in_packs())
.count();
assert_eq!(missing.len(), core_slots);
assert!(
missing
.iter()
.all(|s| s.as_str() != Some("messaging") && s.as_str() != Some("extension")),
"N-per-env slots must not appear in missing_slots"
);
}
#[test]
fn doctor_reports_extensions_separately() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.extensions.push(greentic_deploy_spec::ExtensionBinding {
kind: greentic_deploy_spec::PackDescriptor::try_new("acme.oauth.auth0@1.0.0").unwrap(),
pack_ref: greentic_deploy_spec::PackId::new("pack-ext"),
instance_id: Some("primary".to_string()),
answers_ref: None,
generation: 0,
previous_binding_ref: None,
});
store.save(&env).unwrap();
let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
let ext = outcome
.result
.get("extensions")
.expect("extensions report block");
assert_eq!(ext.get("count").and_then(|v| v.as_u64()), Some(1));
let unknown = ext
.get("unknown_kinds")
.and_then(|v| v.as_array())
.expect("extension unknown_kinds array");
assert_eq!(unknown.len(), 1);
assert!(unknown[0].as_str().unwrap().contains("acme.oauth.auth0"));
let core_unknown = outcome
.result
.get("unknown_kinds")
.and_then(|v| v.as_array())
.expect("core unknown_kinds array");
assert!(core_unknown.is_empty());
}
#[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,
listen_addr: None,
public_base_url: 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,
listen_addr: None,
public_base_url: 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);
}
use super::super::dispatch::{EnvCreateArgs, EnvInitArgs, EnvUpdateArgs};
fn args_with_url(url: &str) -> EnvCreateArgs {
EnvCreateArgs {
environment_id: Some("local".into()),
name: Some("local".into()),
region: None,
tenant_org_id: None,
listen_addr: None,
public_url: Some(url.into()),
}
}
#[test]
fn env_create_args_into_payload_returns_none_when_no_inline_flags() {
let args = EnvCreateArgs {
environment_id: None,
name: None,
region: None,
tenant_org_id: None,
listen_addr: None,
public_url: None,
};
assert!(
args.into_payload("create", &OpFlags::default())
.unwrap()
.is_none()
);
}
#[test]
fn env_create_args_into_payload_with_public_url_round_trips() {
let p = args_with_url("https://chat.example.com")
.into_payload("create", &OpFlags::default())
.unwrap()
.expect("inline path");
assert_eq!(p.environment_id, "local");
assert_eq!(
p.public_base_url.as_deref(),
Some("https://chat.example.com")
);
}
#[test]
fn env_create_args_partial_inline_is_rejected_not_silent_fall_through() {
let args = EnvCreateArgs {
environment_id: None, name: Some("local".into()),
region: None,
tenant_org_id: None,
listen_addr: None,
public_url: Some("https://chat.example.com".into()),
};
let err = args
.into_payload("create", &OpFlags::default())
.expect_err("should reject");
assert!(matches!(err, OpError::InvalidArgument(_)));
}
#[test]
fn env_create_args_inline_plus_answers_rejected() {
let flags = OpFlags {
answers: Some(std::path::PathBuf::from("/tmp/x.json")),
..Default::default()
};
let err = args_with_url("https://chat.example.com")
.into_payload("create", &flags)
.expect_err("should reject");
assert!(matches!(err, OpError::InvalidArgument(_)));
}
#[test]
fn env_create_with_public_url_persists_and_validates() {
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,
listen_addr: None,
public_base_url: Some("https://chat.example.com".to_string()),
}),
)
.unwrap();
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(
env.host_config.public_base_url.as_deref(),
Some("https://chat.example.com")
);
}
#[test]
fn env_create_rejects_invalid_public_url_before_touching_disk() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = create(
&store,
&OpFlags::default(),
Some(EnvCreatePayload {
environment_id: "local".to_string(),
name: "local".to_string(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: Some("https://chat.example.com/path?x=1".to_string()),
}),
)
.expect_err("invalid URL must fail");
assert!(matches!(err, OpError::InvalidArgument(_)));
assert!(!store.exists(&EnvId::try_from("local").unwrap()).unwrap());
}
#[test]
fn env_init_with_public_url_sets_field_on_creation() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let args = EnvInitArgs {
public_url: Some("https://demo.greentic.ai".into()),
};
let payload = args.into_payload(&OpFlags::default()).unwrap();
init(&store, &OpFlags::default(), payload).unwrap();
let env = store
.load(&EnvId::try_from(crate::defaults::LOCAL_ENV_ID).unwrap())
.unwrap();
assert_eq!(
env.host_config.public_base_url.as_deref(),
Some("https://demo.greentic.ai")
);
}
#[test]
fn env_init_rejects_public_url_when_env_already_exists() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
init(
&store,
&OpFlags::default(),
EnvInitPayload {
public_base_url: Some("https://first.example.com".into()),
},
)
.unwrap();
let err = init(
&store,
&OpFlags::default(),
EnvInitPayload {
public_base_url: Some("https://second.example.com".into()),
},
)
.expect_err("second init with --public-url must error");
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
let env = store
.load(&EnvId::try_from(crate::defaults::LOCAL_ENV_ID).unwrap())
.unwrap();
assert_eq!(
env.host_config.public_base_url.as_deref(),
Some("https://first.example.com"),
"existing URL must be preserved"
);
}
#[test]
fn env_init_without_public_url_stays_idempotent_on_existing_env() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
init(
&store,
&OpFlags::default(),
EnvInitPayload {
public_base_url: Some("https://first.example.com".into()),
},
)
.unwrap();
let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
assert_eq!(
outcome.result.get("outcome").and_then(|v| v.as_str()),
Some("untouched")
);
}
#[test]
fn env_init_includes_persisted_public_url_in_outcome() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let outcome = init(
&store,
&OpFlags::default(),
EnvInitPayload {
public_base_url: Some("https://demo.greentic.ai".into()),
},
)
.unwrap();
assert_eq!(
outcome
.result
.get("public_base_url")
.and_then(|v| v.as_str()),
Some("https://demo.greentic.ai"),
"outcome must surface the persisted public_base_url"
);
}
#[test]
fn env_init_rejects_invalid_public_url_up_front() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = init(
&store,
&OpFlags::default(),
EnvInitPayload {
public_base_url: Some("ftp://nope.example.com".into()),
},
)
.expect_err("non-http scheme must fail");
assert!(matches!(err, OpError::InvalidArgument(_)));
assert!(
!store
.exists(&EnvId::try_from(crate::defaults::LOCAL_ENV_ID).unwrap())
.unwrap()
);
}
#[test]
fn set_public_url_updates_existing_env() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
set_public_url(
&store,
&OpFlags::default(),
"local",
"https://chat.example.com",
)
.unwrap();
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(
env.host_config.public_base_url.as_deref(),
Some("https://chat.example.com")
);
}
#[test]
fn set_public_url_strips_trailing_slash() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
set_public_url(
&store,
&OpFlags::default(),
"local",
"https://chat.example.com/",
)
.unwrap();
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(
env.host_config.public_base_url.as_deref(),
Some("https://chat.example.com")
);
}
#[test]
fn set_public_url_rejects_invalid_origin() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let err = set_public_url(
&store,
&OpFlags::default(),
"local",
"https://chat.example.com/path",
)
.expect_err("path-bearing URL must fail");
assert!(matches!(err, OpError::InvalidArgument(_)));
}
#[test]
fn set_public_url_unknown_env_errors_and_no_state_written() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = set_public_url(
&store,
&OpFlags::default(),
"missing",
"https://chat.example.com",
)
.expect_err("missing env must fail");
let _ = err;
assert!(!store.exists(&EnvId::try_from("missing").unwrap()).unwrap());
}
#[test]
fn env_update_args_does_not_expose_public_url_inline() {
let args = EnvUpdateArgs {
environment_id: Some("local".into()),
name: Some("local".into()),
region: Some("eu-west-1".into()),
tenant_org_id: None,
};
let payload = args
.into_payload("update", &OpFlags::default())
.unwrap()
.expect("inline path");
assert_eq!(payload.public_base_url, None);
assert_eq!(payload.listen_addr, None);
assert_eq!(payload.region.as_deref(), Some("eu-west-1"));
}
#[test]
fn env_update_args_rejects_partial_inline() {
let args = EnvUpdateArgs {
environment_id: None, name: Some("local".into()),
region: None,
tenant_org_id: None,
};
let err = args
.into_payload("update", &OpFlags::default())
.expect_err("should reject");
assert!(matches!(err, OpError::InvalidArgument(_)));
}
}