Skip to main content

greentic_setup/
env_mode.rs

1//! Env-manifest mode: route a `greentic.env-manifest.v1` answers document
2//! to the deployer's env-apply engine (operator-surface PR-4).
3//!
4//! `greentic-setup --answers <manifest>` is porcelain over
5//! `greentic_deployer::cli::env_apply::apply` — the same engine behind
6//! `gtc op env apply`. The manifest stays the durable artifact; setup adds
7//! routing (schema sniff), the `--env` cross-check, and flag mapping
8//! (`--dry-run` → `ApplyMode::DryRun`, `--non-interactive` → the headless
9//! contract). Env mode never starts the web UI: the manifest names every
10//! input, and the engine's missing-inputs contract + TTY fill-in own the
11//! gaps.
12
13use std::collections::BTreeMap;
14use std::path::Path;
15
16use anyhow::{Context, Result, bail};
17use greentic_deployer::cli::env_apply::{self, ApplyMode, ApplyOptions};
18use greentic_deployer::cli::env_manifest::ENV_MANIFEST_SCHEMA_V1;
19use greentic_deployer::cli::{OpFlags, OpOutcome, dispatch::print_outcome};
20use greentic_deployer::environment::LocalFsStore;
21use greentic_deployer::runtime_secrets::SecretValue;
22use serde_json::Value;
23
24/// Positive identification of an env-manifest answers document.
25///
26/// Returns the parsed document when `path` reads and parses (JSON first,
27/// YAML fallback — mirroring the engine's own `--answers` loader) as an
28/// object whose `schema` is exactly [`ENV_MANIFEST_SCHEMA_V1`]. Every
29/// failure mode (unreadable file, parse error, any other shape) returns
30/// `None` so the caller falls through to the existing bundle-onboarding
31/// path and its established error messages — the sniff must never invent
32/// a new failure for a document it doesn't own.
33pub fn sniff_env_manifest(path: &Path) -> Option<Value> {
34    let bytes = std::fs::read(path).ok()?;
35    let doc: Value = serde_json::from_slice(&bytes)
36        .ok()
37        .or_else(|| serde_yaml_bw::from_slice(&bytes).ok())?;
38    (doc.get("schema").and_then(Value::as_str) == Some(ENV_MANIFEST_SCHEMA_V1)).then_some(doc)
39}
40
41/// Env-mode entry: build the per-user store (the same default root
42/// `gtc op` uses) and run the engine, printing the standard
43/// `{op, noun, result}` envelope on stdout — stderr carries the engine's
44/// human plan rows, byte-compatible with `gtc op env apply`.
45pub fn run_env_apply(
46    answers_path: &Path,
47    manifest: &Value,
48    resolved_env: &str,
49    dry_run: bool,
50    non_interactive: bool,
51    prefilled_secrets: BTreeMap<String, SecretValue>,
52) -> Result<()> {
53    let root = LocalFsStore::default_root()
54        .context("cannot locate the environment store: HOME / USERPROFILE not set")?;
55    let store = LocalFsStore::new(root);
56    let outcome = apply_manifest_with_store(
57        &store,
58        answers_path,
59        manifest,
60        resolved_env,
61        dry_run,
62        non_interactive,
63        prefilled_secrets,
64    )?;
65    print_outcome(&outcome)?;
66    Ok(())
67}
68
69/// Engine call with the store injected (tests pass a temp-rooted store).
70///
71/// The `--env` cross-check happens here: when the manifest names an
72/// environment id that differs from the resolved `--env`, that is an
73/// error — the manifest is never silently overridden, and the flag never
74/// silently retargets the manifest. A manifest without a readable
75/// `environment.id` is NOT rejected here; the engine's validation owns
76/// that diagnosis and reports it precisely.
77pub fn apply_manifest_with_store(
78    store: &LocalFsStore,
79    answers_path: &Path,
80    manifest: &Value,
81    resolved_env: &str,
82    dry_run: bool,
83    non_interactive: bool,
84    prefilled_secrets: BTreeMap<String, SecretValue>,
85) -> Result<OpOutcome> {
86    if let Some(manifest_env) = manifest.pointer("/environment/id").and_then(Value::as_str)
87        && manifest_env != resolved_env
88    {
89        bail!(
90            "answers manifest `{}` targets environment `{manifest_env}` but --env resolves to \
91             `{resolved_env}`; pass `--env {manifest_env}` to apply it (the manifest is never \
92             silently overridden)",
93            answers_path.display(),
94        );
95    }
96    let flags = OpFlags {
97        schema_only: false,
98        answers: Some(answers_path.to_path_buf()),
99    };
100    let opts = ApplyOptions {
101        mode: if dry_run {
102            ApplyMode::DryRun
103        } else {
104            ApplyMode::Apply
105        },
106        // Audit principal: mutations composed through setup's porcelain say so.
107        updated_by: Some("greentic-setup".to_string()),
108        yes: false,
109        non_interactive,
110        prefilled_secrets,
111    };
112    env_apply::apply(store, &flags, opts)
113        .with_context(|| format!("env-manifest apply failed for `{}`", answers_path.display()))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde_json::json;
120    use std::path::PathBuf;
121
122    fn write(dir: &Path, name: &str, contents: &str) -> PathBuf {
123        let path = dir.join(name);
124        std::fs::write(&path, contents).unwrap();
125        path
126    }
127
128    fn manifest_value(env_id: &str) -> Value {
129        json!({"schema": ENV_MANIFEST_SCHEMA_V1, "environment": {"id": env_id}})
130    }
131
132    #[test]
133    fn sniff_identifies_manifest_json_and_yaml() {
134        let dir = tempfile::tempdir().unwrap();
135        let json_path = write(dir.path(), "env.json", &manifest_value("local").to_string());
136        assert!(sniff_env_manifest(&json_path).is_some());
137
138        let yaml_path = write(
139            dir.path(),
140            "env.yaml",
141            &format!("schema: {ENV_MANIFEST_SCHEMA_V1}\nenvironment:\n  id: local\n"),
142        );
143        assert!(sniff_env_manifest(&yaml_path).is_some());
144    }
145
146    #[test]
147    fn sniff_falls_through_on_everything_else() {
148        let dir = tempfile::tempdir().unwrap();
149        // Bundle-shaped setup answers: valid JSON, no env-manifest schema.
150        let bundle_answers = write(
151            dir.path(),
152            "answers.json",
153            r#"{"setup_answers": {"telegram": {"token": "secret://x"}}}"#,
154        );
155        assert!(sniff_env_manifest(&bundle_answers).is_none());
156        // A different schema string.
157        let other_schema = write(
158            dir.path(),
159            "other.json",
160            r#"{"schema": "greentic.something-else.v1"}"#,
161        );
162        assert!(sniff_env_manifest(&other_schema).is_none());
163        // Unparseable and missing files fall through silently — the bundle
164        // path owns their (established) error messages.
165        let garbage = write(dir.path(), "garbage.bin", "\u{0}\u{1}not-a-doc{{{");
166        assert!(sniff_env_manifest(&garbage).is_none());
167        assert!(sniff_env_manifest(&dir.path().join("missing.json")).is_none());
168    }
169
170    #[test]
171    fn env_mismatch_is_an_error_not_an_override() {
172        let dir = tempfile::tempdir().unwrap();
173        let store = LocalFsStore::new(dir.path().join("store"));
174        let manifest = manifest_value("demo");
175        let path = write(dir.path(), "demo.env.json", &manifest.to_string());
176
177        let err = apply_manifest_with_store(
178            &store,
179            &path,
180            &manifest,
181            "local",
182            false,
183            true,
184            Default::default(),
185        )
186        .unwrap_err();
187        let msg = format!("{err:#}");
188        assert!(msg.contains("`demo`"), "names the manifest env: {msg}");
189        assert!(msg.contains("`local`"), "names the --env value: {msg}");
190        // Rejected before the engine ran: the store root was never created.
191        assert!(!dir.path().join("store").exists());
192    }
193
194    #[test]
195    fn dry_run_routes_to_the_engine_and_mutates_nothing() {
196        let dir = tempfile::tempdir().unwrap();
197        let store_root = dir.path().join("store");
198        std::fs::create_dir_all(&store_root).unwrap();
199        let store = LocalFsStore::new(&store_root);
200        let manifest = manifest_value("local");
201        let path = write(dir.path(), "local.env.json", &manifest.to_string());
202
203        let outcome = apply_manifest_with_store(
204            &store,
205            &path,
206            &manifest,
207            "local",
208            true,
209            true,
210            Default::default(),
211        )
212        .expect("dry-run succeeds");
213        assert_eq!(outcome.noun, "env");
214        assert_eq!(outcome.op, "apply");
215        assert_eq!(outcome.result["mode"], "dry-run", "{}", outcome.result);
216        // A preview never mutates: the store root is still empty.
217        assert_eq!(std::fs::read_dir(&store_root).unwrap().count(), 0);
218    }
219
220    #[test]
221    fn non_interactive_missing_secret_surfaces_the_engine_report() {
222        let dir = tempfile::tempdir().unwrap();
223        let store = LocalFsStore::new(dir.path().join("store"));
224        let manifest = json!({
225            "schema": ENV_MANIFEST_SCHEMA_V1,
226            "environment": {"id": "local"},
227            "trust_root": "bootstrap",
228            "secrets": [{
229                "path": "default/_/messaging-telegram/telegram_bot_token",
230                "from_env": "GREENTIC_SETUP_PR4_TEST_UNSET"
231            }]
232        });
233        let path = write(dir.path(), "local.env.json", &manifest.to_string());
234
235        let err = apply_manifest_with_store(
236            &store,
237            &path,
238            &manifest,
239            "local",
240            false,
241            true,
242            Default::default(),
243        )
244        .unwrap_err();
245        // The engine's missing-inputs report surfaces verbatim through the
246        // anyhow chain: count, env-var name, and manifest secret path.
247        let msg = format!("{err:#}");
248        assert!(msg.contains("missing input(s)"), "got: {msg}");
249        assert!(msg.contains("GREENTIC_SETUP_PR4_TEST_UNSET"), "got: {msg}");
250        assert!(
251            msg.contains("default/_/messaging-telegram/telegram_bot_token"),
252            "got: {msg}"
253        );
254    }
255}