use std::collections::BTreeMap;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use greentic_deployer::cli::env_manifest::{
ENV_MANIFEST_FORM_ID, ENV_MANIFEST_FORM_VERSION, EnvManifest, ManifestBundle,
TrustRootDirective, answers_to_manifest, manifest_form_spec,
};
use greentic_deployer::runtime_secrets::{bundle_secret_requirements, manifest_secret_path};
use qa_spec::{AnswerSet, FormSpec};
use serde_json::{Map as JsonMap, Value, json};
use crate::env_mode;
use crate::qa::prompts::prompt_form_spec_answers_with_existing;
pub fn run_env_wizard(
env: &str,
advanced: bool,
dry_run: bool,
non_interactive: bool,
) -> Result<()> {
if non_interactive || !std::io::stdin().is_terminal() {
bail!(
"the environment wizard is interactive; in headless runs pass an env manifest via \
--answers <file> (generate a skeleton with `gtc op env apply --emit-answers-template`)"
);
}
let manifest_path = prompt_manifest_path(env)?;
let initial = load_initial_answers(&manifest_path, env)?;
let spec = manifest_form_spec();
let form_spec = spec_without_question(&spec, "secrets");
let prompted = prompt_form_spec_answers_with_existing(
&form_spec,
"environment",
advanced,
&Value::Object(initial),
)?;
let mut answers = prompted.as_object().cloned().unwrap_or_default();
let existing_from_env = existing_from_env_by_path(&answers);
let manifest_dir = manifest_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let provisional = answers_to_manifest(&answer_set(answers.clone()))?;
let secret_rows =
derive_and_prompt_secrets(&manifest_dir, env, &provisional.bundles, &existing_from_env)?;
if secret_rows.is_empty() {
answers.remove("secrets");
} else {
answers.insert("secrets".to_string(), Value::Array(secret_rows));
}
let manifest = answers_to_manifest(&answer_set(answers))?;
let doc = serde_json::to_value(&manifest)?;
let mut rendered = serde_json::to_string_pretty(&doc)?;
rendered.push('\n');
std::fs::write(&manifest_path, rendered)
.with_context(|| format!("failed to write `{}`", manifest_path.display()))?;
println!(
"\nWrote `{}` — the manifest is the durable artifact; keep it in version control.",
manifest_path.display()
);
env_mode::run_env_apply(&manifest_path, &doc, env, dry_run, false)
}
fn answer_set(answers: JsonMap<String, Value>) -> AnswerSet {
AnswerSet {
form_id: ENV_MANIFEST_FORM_ID.to_string(),
spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
answers: Value::Object(answers),
meta: None,
}
}
fn spec_without_question(spec: &FormSpec, id: &str) -> FormSpec {
let mut reduced = spec.clone();
reduced.questions.retain(|question| question.id != id);
reduced
}
fn existing_from_env_by_path(answers: &JsonMap<String, Value>) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
if let Some(Value::Array(rows)) = answers.get("secrets") {
for row in rows {
if let (Some(path), Some(from_env)) = (
row.get("path").and_then(Value::as_str),
row.get("from_env").and_then(Value::as_str),
) {
map.insert(path.to_string(), from_env.to_string());
}
}
}
map
}
fn bundle_tenant(bundle: &ManifestBundle) -> String {
bundle
.route_binding
.as_ref()
.and_then(|binding| binding.tenant_selector.as_ref())
.map(|selector| selector.tenant.clone())
.unwrap_or_else(|| "default".to_string())
}
fn default_env_var_name(tenant: &str, key: &str) -> String {
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
let key = sanitize(key);
if tenant.is_empty() || tenant.eq_ignore_ascii_case("default") {
key
} else {
format!("{}_{}", sanitize(tenant), key)
}
}
struct DerivedSecret {
path: String,
provider_id: String,
key: String,
tenant: String,
required: bool,
bundle_ids: Vec<String>,
}
fn derive_required_secrets(
manifest_dir: &Path,
env: &str,
bundles: &[ManifestBundle],
) -> (Vec<DerivedSecret>, bool) {
let mut order: Vec<String> = Vec::new();
let mut by_path: BTreeMap<String, DerivedSecret> = BTreeMap::new();
let mut skipped = false;
for bundle in bundles {
let tenant = bundle_tenant(bundle);
let artifact = if bundle.bundle_path.is_absolute() {
bundle.bundle_path.clone()
} else {
manifest_dir.join(&bundle.bundle_path)
};
let Some(bundle_root) = artifact.parent() else {
skipped = true;
continue;
};
if !artifact.exists() {
eprintln!(
" note: bundle `{}` artifact `{}` not found — build it before the \
wizard to auto-detect its secrets (skipping)",
bundle.bundle_id,
artifact.display()
);
skipped = true;
continue;
}
let requirements = match bundle_secret_requirements(bundle_root, env, &tenant) {
Ok(requirements) => requirements,
Err(err) => {
eprintln!(
" note: could not read secrets for bundle `{}`: {err} (skipping)",
bundle.bundle_id
);
skipped = true;
continue;
}
};
for requirement in requirements {
let Some(path) = manifest_secret_path(&requirement.uri, env) else {
continue;
};
match by_path.get_mut(&path) {
Some(existing) => {
existing.required |= requirement.required;
existing.bundle_ids.push(bundle.bundle_id.clone());
}
None => {
order.push(path.clone());
by_path.insert(
path.clone(),
DerivedSecret {
path,
provider_id: requirement.provider_id,
key: requirement.key,
tenant: tenant.clone(),
required: requirement.required,
bundle_ids: vec![bundle.bundle_id.clone()],
},
);
}
}
}
}
let derived = order
.into_iter()
.map(|path| by_path.remove(&path).expect("path was just inserted"))
.collect();
(derived, skipped)
}
fn derive_and_prompt_secrets(
manifest_dir: &Path,
env: &str,
bundles: &[ManifestBundle],
existing_from_env: &BTreeMap<String, String>,
) -> Result<Vec<Value>> {
let (derived, skipped) = derive_required_secrets(manifest_dir, env, bundles);
let preserving = skipped && !existing_from_env.is_empty();
if derived.is_empty() && !preserving {
println!("\nSecrets — the configured bundles declare no secrets; nothing to enter.");
return Ok(Vec::new());
}
if !derived.is_empty() {
println!(
"\nSecrets — the configured bundles need {} secret(s).",
derived.len()
);
println!("Enter the NAME of the environment variable that holds each value.");
println!("(The value itself is never written to the manifest; apply reads it");
println!(" from the environment / dev-store at apply time.)");
}
let mut rows = Vec::with_capacity(derived.len());
let mut taken = BTreeMap::new();
for secret in &derived {
let default = existing_from_env
.get(&secret.path)
.cloned()
.unwrap_or_else(|| default_env_var_name(&secret.tenant, &secret.key));
println!();
println!(
" {} — {} (bundle: {}){}",
secret.key,
secret.provider_id,
secret.bundle_ids.join(", "),
if secret.required { "" } else { " [optional]" }
);
println!(" secret path: {}", secret.path);
let from_env = prompt_env_var_name(&default)?;
taken.insert(secret.path.clone(), ());
rows.push(json!({ "path": secret.path.clone(), "from_env": from_env }));
}
if skipped {
for (path, from_env) in existing_from_env {
if taken.contains_key(path) {
continue;
}
eprintln!(" note: keeping existing secret `{path}` (bundle not rebuilt)");
rows.push(json!({ "path": path, "from_env": from_env }));
}
}
Ok(rows)
}
fn prompt_env_var_name(default: &str) -> Result<String> {
loop {
print!(" > env var name [{default}]: ");
std::io::stdout().flush()?;
let mut line = String::new();
let n = std::io::stdin().read_line(&mut line)?;
if n == 0 {
bail!("unexpected end of input while prompting for env var name");
}
let trimmed = line.trim();
let value = if trimmed.is_empty() { default } else { trimmed };
if value.is_empty() {
println!(" An environment variable name is required.");
continue;
}
return Ok(value.to_string());
}
}
fn prompt_manifest_path(env: &str) -> Result<PathBuf> {
let default = format!("./{env}.env.json");
print!("Manifest file [{default}]: ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let trimmed = line.trim();
Ok(PathBuf::from(if trimmed.is_empty() {
default.as_str()
} else {
trimmed
}))
}
fn load_initial_answers(path: &Path, env: &str) -> Result<JsonMap<String, Value>> {
if !path.exists() {
let mut map = JsonMap::new();
map.insert("environment_id".to_string(), Value::String(env.to_string()));
return Ok(map);
}
let Some(doc) = env_mode::sniff_env_manifest(path) else {
bail!(
"`{}` exists and is not a greentic.env-manifest.v1 document; refusing to overwrite \
it — pick another path or remove the file",
path.display()
);
};
let manifest: EnvManifest = serde_json::from_value(doc)
.with_context(|| format!("`{}` is not a valid env manifest", path.display()))?;
if manifest.environment.id != env {
bail!(
"`{}` targets environment `{}` but --env resolves to `{env}`; pass `--env {}` to \
edit it (the manifest is never silently overridden)",
path.display(),
manifest.environment.id,
manifest.environment.id,
);
}
println!(
"Editing `{}` — existing answers are kept; only missing ones are asked.",
path.display()
);
let answers = manifest_to_answers(&manifest)?;
Ok(answers
.answers
.as_object()
.cloned()
.expect("manifest_to_answers always produces an Object"))
}
pub fn manifest_to_answers(manifest: &EnvManifest) -> Result<AnswerSet> {
let mut map = JsonMap::new();
map.insert(
"environment_id".to_string(),
Value::String(manifest.environment.id.clone()),
);
if let Some(url) = &manifest.environment.public_base_url {
map.insert("public_base_url".to_string(), Value::String(url.clone()));
}
map.insert(
"trust_root_bootstrap".to_string(),
Value::Bool(match manifest.trust_root {
Some(TrustRootDirective::Bootstrap) => true,
None => false,
}),
);
if !manifest.secrets.is_empty() {
let rows = manifest
.secrets
.iter()
.map(|s| json!({"path": s.path, "from_env": s.from_env}))
.collect();
map.insert("secrets".to_string(), Value::Array(rows));
}
if !manifest.bundles.is_empty() {
let rows = manifest
.bundles
.iter()
.map(|b| -> Result<Value> {
let mut row = JsonMap::new();
row.insert("bundle_id".to_string(), Value::String(b.bundle_id.clone()));
row.insert(
"bundle_path".to_string(),
Value::String(b.bundle_path.display().to_string()),
);
if let Some(customer) = &b.customer_id {
row.insert("customer_id".to_string(), Value::String(customer.clone()));
}
if let Some(overrides) = &b.config_overrides {
row.insert(
"config_overrides".to_string(),
Value::String(serde_json::to_string(overrides)?),
);
}
if let Some(binding) = &b.route_binding {
if !binding.hosts.is_empty() {
row.insert(
"route_hosts".to_string(),
Value::String(binding.hosts.join(", ")),
);
}
if !binding.path_prefixes.is_empty() {
row.insert(
"route_path_prefixes".to_string(),
Value::String(binding.path_prefixes.join(", ")),
);
}
if let Some(selector) = &binding.tenant_selector {
row.insert(
"route_tenant".to_string(),
Value::String(selector.tenant.clone()),
);
row.insert(
"route_team".to_string(),
Value::String(selector.team.clone()),
);
}
}
Ok(Value::Object(row))
})
.collect::<Result<Vec<_>>>()?;
map.insert("bundles".to_string(), Value::Array(rows));
}
if !manifest.messaging_endpoints.is_empty() {
let rows = manifest
.messaging_endpoints
.iter()
.map(|ep| {
let mut row = JsonMap::new();
row.insert("name".to_string(), Value::String(ep.name.clone()));
row.insert(
"provider_type".to_string(),
Value::String(ep.provider_type.clone()),
);
if !ep.links.is_empty() {
row.insert("links".to_string(), Value::String(ep.links.join(", ")));
}
if let Some(flow) = &ep.welcome_flow {
row.insert(
"welcome_bundle_id".to_string(),
Value::String(flow.bundle_id.clone()),
);
row.insert(
"welcome_pack_id".to_string(),
Value::String(flow.pack_id.clone()),
);
row.insert(
"welcome_flow_id".to_string(),
Value::String(flow.flow_id.clone()),
);
}
if !ep.secret_refs.is_empty() {
row.insert(
"secret_refs".to_string(),
Value::String(ep.secret_refs.join(", ")),
);
}
Value::Object(row)
})
.collect();
map.insert("messaging_endpoints".to_string(), Value::Array(rows));
}
Ok(AnswerSet {
form_id: ENV_MANIFEST_FORM_ID.to_string(),
spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
answers: Value::Object(map),
meta: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_deployer::cli::bundles::{RouteBindingPayload, TenantSelectorPayload};
use greentic_deployer::cli::env_manifest::{
ENV_MANIFEST_SCHEMA_V1, ManifestBundle, ManifestEndpoint, ManifestEnvironment,
ManifestSecret, ManifestWelcomeFlow,
};
use std::collections::BTreeMap;
fn full_manifest() -> EnvManifest {
EnvManifest {
schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
environment: ManifestEnvironment {
id: "demo".to_string(),
public_base_url: Some("https://demo.example.com".to_string()),
},
trust_root: Some(TrustRootDirective::Bootstrap),
secrets: vec![ManifestSecret {
path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
from_env: "DEMO_BOT_TOKEN".to_string(),
}],
bundles: vec![ManifestBundle {
bundle_id: "realbot".to_string(),
bundle_path: PathBuf::from("./bundles/realbot.gtbundle"),
customer_id: Some("acme".to_string()),
config_overrides: Some(BTreeMap::from([(
"pack-a".to_string(),
BTreeMap::from([("greeting".to_string(), json!("hi"))]),
)])),
route_binding: Some(RouteBindingPayload {
hosts: vec![
"demo.example.com".to_string(),
"alt.example.com".to_string(),
],
path_prefixes: vec!["/bot".to_string(), "/api".to_string()],
tenant_selector: Some(TenantSelectorPayload {
tenant: "acme".to_string(),
team: "support".to_string(),
}),
}),
}],
messaging_endpoints: vec![ManifestEndpoint {
name: "demo-telegram".to_string(),
provider_type: "messaging.telegram.bot".to_string(),
links: vec!["realbot".to_string(), "auditbot".to_string()],
welcome_flow: Some(ManifestWelcomeFlow {
bundle_id: "realbot".to_string(),
pack_id: "pack-a".to_string(),
flow_id: "welcome".to_string(),
}),
secret_refs: vec![
"secret://local/realbot/telegram/token".to_string(),
"secret://local/realbot/telegram/webhook".to_string(),
],
}],
}
}
fn round_trip(manifest: &EnvManifest) -> EnvManifest {
let answers = manifest_to_answers(manifest).expect("manifest converts to answers");
answers_to_manifest(&answers).expect("answers convert back to a manifest")
}
#[test]
fn full_manifest_round_trips_through_the_deployer_converter() {
let original = full_manifest();
let back = round_trip(&original);
assert_eq!(
serde_json::to_value(&original).unwrap(),
serde_json::to_value(&back).unwrap(),
);
}
#[test]
fn minimal_manifest_round_trips() {
let original = EnvManifest {
schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
environment: ManifestEnvironment {
id: "local".to_string(),
public_base_url: None,
},
trust_root: None,
secrets: Vec::new(),
bundles: Vec::new(),
messaging_endpoints: Vec::new(),
};
let back = round_trip(&original);
assert_eq!(
serde_json::to_value(&original).unwrap(),
serde_json::to_value(&back).unwrap(),
);
}
#[test]
fn empty_config_overrides_stays_an_explicit_clear() {
let mut manifest = full_manifest();
manifest.bundles[0].config_overrides = Some(BTreeMap::new());
let back = round_trip(&manifest);
assert_eq!(back.bundles[0].config_overrides, Some(BTreeMap::new()));
}
#[test]
fn generated_answers_pass_the_form_validation() {
let answers = manifest_to_answers(&full_manifest()).unwrap();
let result = qa_spec::validate(&manifest_form_spec(), &answers.answers);
assert!(
result.valid,
"errors: {:?}, missing: {:?}, unknown: {:?}",
result.errors, result.missing_required, result.unknown_fields
);
}
#[test]
fn load_initial_answers_seeds_env_for_new_files() {
let dir = tempfile::tempdir().unwrap();
let map = load_initial_answers(&dir.path().join("demo.env.json"), "demo").unwrap();
assert_eq!(map.get("environment_id"), Some(&json!("demo")));
assert_eq!(map.len(), 1, "only the env id is pre-seeded: {map:?}");
}
#[test]
fn load_initial_answers_refuses_files_it_does_not_own() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.json");
std::fs::write(&path, r#"{"some": "other document"}"#).unwrap();
let err = load_initial_answers(&path, "demo").unwrap_err();
assert!(
format!("{err:#}").contains("refusing to overwrite"),
"got: {err:#}"
);
}
#[test]
fn load_initial_answers_rejects_env_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.env.json");
let manifest = serde_json::to_string(&full_manifest()).unwrap();
std::fs::write(&path, manifest).unwrap();
let err = load_initial_answers(&path, "local").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("`demo`"), "names the manifest env: {msg}");
assert!(msg.contains("`local`"), "names the --env value: {msg}");
}
#[test]
fn load_initial_answers_preloads_a_matching_manifest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.env.json");
std::fs::write(&path, serde_json::to_string(&full_manifest()).unwrap()).unwrap();
let map = load_initial_answers(&path, "demo").unwrap();
assert_eq!(map.get("environment_id"), Some(&json!("demo")));
assert!(map.get("secrets").is_some_and(Value::is_array));
assert!(map.get("bundles").is_some_and(Value::is_array));
}
#[test]
fn wizard_is_interactive_only() {
let err = run_env_wizard("demo", false, false, true).unwrap_err();
assert!(
format!("{err:#}").contains("--answers"),
"points at the headless alternative: {err:#}"
);
}
#[test]
fn spec_without_question_drops_only_the_named_question() {
let spec = manifest_form_spec();
let reduced = spec_without_question(&spec, "secrets");
assert!(
spec.questions.iter().any(|q| q.id == "secrets"),
"fixture has the secrets question"
);
assert!(
reduced.questions.iter().all(|q| q.id != "secrets"),
"secrets question is dropped"
);
assert_eq!(
reduced.questions.len(),
spec.questions.len() - 1,
"exactly one question removed"
);
}
#[test]
fn existing_from_env_by_path_indexes_preloaded_secrets() {
let answers = json!({
"secrets": [
{"path": "legal/_/messaging-telegram/telegram_bot_token", "from_env": "LEGAL_TOK"},
{"path": "acct/_/messaging-telegram/telegram_bot_token", "from_env": "ACCT_TOK"}
]
});
let map = existing_from_env_by_path(answers.as_object().unwrap());
assert_eq!(
map.get("legal/_/messaging-telegram/telegram_bot_token")
.map(String::as_str),
Some("LEGAL_TOK")
);
assert_eq!(map.len(), 2);
assert!(existing_from_env_by_path(&JsonMap::new()).is_empty());
}
#[test]
fn default_env_var_name_prefixes_non_default_tenant() {
assert_eq!(
default_env_var_name("legal", "telegram_bot_token"),
"LEGAL_TELEGRAM_BOT_TOKEN"
);
assert_eq!(
default_env_var_name("default", "telegram_bot_token"),
"TELEGRAM_BOT_TOKEN"
);
assert_eq!(default_env_var_name("", "api_key"), "API_KEY");
assert_eq!(
default_env_var_name("my-tenant", "bot.token"),
"MY_TENANT_BOT_TOKEN"
);
}
fn bundle_from(value: Value) -> ManifestBundle {
serde_json::from_value(value).expect("valid manifest bundle")
}
fn built_bundle_with_telegram_secret(root: &Path, workspace: &str) {
let pack_dir = root.join(workspace).join("packs/messaging-telegram");
std::fs::create_dir_all(pack_dir.join("assets")).unwrap();
std::fs::write(pack_dir.join("pack.yaml"), "id: messaging-telegram\n").unwrap();
std::fs::write(
pack_dir.join("assets/secret-requirements.json"),
r#"[{"key":"TELEGRAM_BOT_TOKEN","required":true}]"#,
)
.unwrap();
std::fs::write(
root.join(workspace).join("realbot.gtbundle"),
b"squashfs-placeholder",
)
.unwrap();
}
#[test]
fn derive_required_secrets_reads_built_bundle_packs() {
let dir = tempfile::tempdir().unwrap();
built_bundle_with_telegram_secret(dir.path(), "ws-legal");
let bundle = bundle_from(json!({
"bundle_id": "realbot-legal",
"bundle_path": "ws-legal/realbot.gtbundle",
"route_binding": {
"hosts": [],
"path_prefixes": ["/legal"],
"tenant_selector": {"tenant": "legal", "team": "default"}
}
}));
let (derived, skipped) =
derive_required_secrets(dir.path(), "local", std::slice::from_ref(&bundle));
assert!(!skipped);
assert_eq!(derived.len(), 1);
assert_eq!(
derived[0].path,
"legal/_/messaging-telegram/telegram_bot_token"
);
assert_eq!(derived[0].tenant, "legal");
assert_eq!(derived[0].bundle_ids, vec!["realbot-legal".to_string()]);
}
#[test]
fn derive_required_secrets_dedups_same_path_across_bundles() {
let dir = tempfile::tempdir().unwrap();
built_bundle_with_telegram_secret(dir.path(), "ws-a");
built_bundle_with_telegram_secret(dir.path(), "ws-b");
let bundles = [
bundle_from(json!({
"bundle_id": "a", "bundle_path": "ws-a/realbot.gtbundle",
"route_binding": {"hosts": [], "path_prefixes": ["/a"],
"tenant_selector": {"tenant": "shared", "team": "default"}}
})),
bundle_from(json!({
"bundle_id": "b", "bundle_path": "ws-b/realbot.gtbundle",
"route_binding": {"hosts": [], "path_prefixes": ["/b"],
"tenant_selector": {"tenant": "shared", "team": "default"}}
})),
];
let (derived, _) = derive_required_secrets(dir.path(), "local", &bundles);
assert_eq!(derived.len(), 1, "deduped by path");
assert_eq!(
derived[0].bundle_ids,
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn derive_required_secrets_skips_unbuilt_bundle() {
let dir = tempfile::tempdir().unwrap();
let bundle = bundle_from(json!({
"bundle_id": "missing",
"bundle_path": "ws-missing/realbot.gtbundle"
}));
let (derived, skipped) =
derive_required_secrets(dir.path(), "local", std::slice::from_ref(&bundle));
assert!(derived.is_empty());
assert!(skipped, "missing artifact flags skipped");
}
}