1use 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
24pub 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
41pub 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
69pub 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 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 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 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 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 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 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 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}