1use std::path::Path;
21
22use anyhow::{Context, Result, bail};
23use greentic_deployer::cli::dispatch::print_outcome;
24use greentic_deployer::cli::env_manifest::ENV_MANIFEST_SCHEMA_V1;
25use greentic_deployer::environment::LocalFsStore;
26use serde_json::json;
27
28use crate::gtbundle;
29
30const BUNDLE_MANIFEST_JSON: &str = "bundle-manifest.json";
32
33fn read_bundle_id_from_dir(dir: &Path) -> Result<String> {
39 let manifest_path = dir.join(BUNDLE_MANIFEST_JSON);
41 if manifest_path.is_file() {
42 let raw = std::fs::read_to_string(&manifest_path)
43 .with_context(|| format!("read {}", manifest_path.display()))?;
44 let doc: serde_json::Value = serde_json::from_str(&raw)
45 .with_context(|| format!("parse {}", manifest_path.display()))?;
46 if let Some(id) = doc.get("bundle_id").and_then(|v| v.as_str())
47 && !id.trim().is_empty()
48 {
49 return Ok(id.to_string());
50 }
51 }
52
53 let yaml_path = dir.join(crate::bundle::BUNDLE_WORKSPACE_MARKER);
55 if yaml_path.is_file() {
56 let raw = std::fs::read_to_string(&yaml_path)
57 .with_context(|| format!("read {}", yaml_path.display()))?;
58 let doc: serde_yaml_bw::Value = serde_yaml_bw::from_str(&raw)
59 .with_context(|| format!("parse {}", yaml_path.display()))?;
60 if let Some(serde_yaml_bw::Value::String(id, _)) = doc
61 .as_mapping()
62 .and_then(|m| m.get(serde_yaml_bw::Value::String("bundle_id".into(), None)))
63 && !id.trim().is_empty()
64 {
65 return Ok(id.clone());
66 }
67 }
68
69 bail!(
70 "cannot determine bundle_id: neither {} nor {} in {} contains a bundle_id field",
71 BUNDLE_MANIFEST_JSON,
72 crate::bundle::BUNDLE_WORKSPACE_MARKER,
73 dir.display(),
74 )
75}
76
77pub fn deploy_bundle_to_env(
87 bundle: &Path,
88 env_id: &str,
89 dry_run: bool,
90 non_interactive: bool,
91 customer_id: Option<&str>,
92) -> Result<()> {
93 let _temp_dir; let archive_path: std::path::PathBuf;
96
97 if bundle.is_file() {
98 if !gtbundle::is_gtbundle_file(bundle) {
99 bail!("{} is not a .gtbundle archive file", bundle.display(),);
100 }
101 archive_path = bundle
102 .canonicalize()
103 .with_context(|| format!("canonicalize {}", bundle.display()))?;
104 _temp_dir = None;
105 } else if bundle.is_dir() {
106 let td = tempfile::tempdir().context("create temporary directory for .gtbundle archive")?;
107 let stem = bundle
108 .file_name()
109 .and_then(|n| n.to_str())
110 .unwrap_or("bundle");
111 let out = td.path().join(format!("{stem}.gtbundle"));
112 gtbundle::create_gtbundle(bundle, &out)
113 .with_context(|| format!("pack directory {} into .gtbundle", bundle.display()))?;
114 archive_path = out
115 .canonicalize()
116 .with_context(|| format!("canonicalize {}", out.display()))?;
117 _temp_dir = Some(td);
118 } else {
119 bail!(
120 "{} does not exist or is not a .gtbundle file / bundle directory",
121 bundle.display(),
122 );
123 };
124
125 let bundle_id = if bundle.is_dir() {
130 read_bundle_id_from_dir(bundle)?
131 } else {
132 let extract_dir = gtbundle::extract_gtbundle_to_temp(&archive_path)
133 .with_context(|| format!("extract {} to read metadata", archive_path.display()))?;
134 let id = read_bundle_id_from_dir(&extract_dir);
135 let _ = std::fs::remove_dir_all(&extract_dir);
137 id?
138 };
139
140 let manifest = build_env_manifest(env_id, &bundle_id, &archive_path, customer_id);
142
143 let manifest_file = tempfile::NamedTempFile::new().context("create temporary manifest file")?;
145 std::fs::write(
146 manifest_file.path(),
147 serde_json::to_string_pretty(&manifest)?,
148 )
149 .context("write temporary manifest file")?;
150
151 let root = LocalFsStore::default_root()
152 .context("cannot locate the environment store: HOME / USERPROFILE not set")?;
153 let store = LocalFsStore::new(root);
154 let outcome = crate::env_mode::apply_manifest_with_store(
155 &store,
156 manifest_file.path(),
157 &manifest,
158 env_id,
159 dry_run,
160 non_interactive,
161 Default::default(),
162 )?;
163 print_outcome(&outcome)?;
164 Ok(())
165}
166
167fn build_env_manifest(
173 env_id: &str,
174 bundle_id: &str,
175 archive_path: &Path,
176 customer_id: Option<&str>,
177) -> serde_json::Value {
178 let mut bundle_entry = json!({
179 "bundle_id": bundle_id,
180 "bundle_path": archive_path.to_string_lossy(),
181 });
182 if let Some(cid) = customer_id {
183 bundle_entry
184 .as_object_mut()
185 .expect("bundle_entry is a json object")
186 .insert("customer_id".to_string(), json!(cid));
187 }
188 json!({
189 "schema": ENV_MANIFEST_SCHEMA_V1,
190 "environment": { "id": env_id },
191 "trust_root": "bootstrap",
192 "bundles": [bundle_entry]
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use greentic_deployer::cli::env_manifest::EnvManifest;
200 use std::fs;
201 use tempfile::tempdir;
202
203 fn make_bundle_dir_with_manifest(dir: &Path, bundle_id: &str) {
205 fs::create_dir_all(dir).unwrap();
206 fs::write(
208 dir.join(crate::bundle::BUNDLE_WORKSPACE_MARKER),
209 "schema_version: 1\n",
210 )
211 .unwrap();
212 let manifest = serde_json::json!({
213 "format_version": "1",
214 "bundle_id": bundle_id,
215 "bundle_name": bundle_id,
216 "requested_mode": "create",
217 "locale": "en",
218 "artifact_extension": "gtbundle",
219 });
220 fs::write(
221 dir.join(BUNDLE_MANIFEST_JSON),
222 serde_json::to_string_pretty(&manifest).unwrap(),
223 )
224 .unwrap();
225 }
226
227 fn make_bundle_dir_with_yaml_only(dir: &Path, bundle_id: &str) {
230 fs::create_dir_all(dir).unwrap();
231 fs::write(
232 dir.join(crate::bundle::BUNDLE_WORKSPACE_MARKER),
233 format!("schema_version: 1\nbundle_id: {bundle_id}\n"),
234 )
235 .unwrap();
236 }
237
238 #[test]
241 fn synthesized_manifest_deserializes_into_env_manifest() {
242 let manifest = json!({
243 "schema": ENV_MANIFEST_SCHEMA_V1,
244 "environment": { "id": "local" },
245 "trust_root": "bootstrap",
246 "bundles": [{
247 "bundle_id": "my-bundle",
248 "bundle_path": "/tmp/my-bundle.gtbundle",
249 }]
250 });
251 let parsed: EnvManifest =
252 serde_json::from_value(manifest).expect("manifest must deserialize into EnvManifest");
253 assert_eq!(parsed.environment.id, "local");
254 assert_eq!(parsed.bundles.len(), 1);
255 assert_eq!(parsed.bundles[0].bundle_id, "my-bundle");
256 assert_eq!(
257 parsed.bundles[0].bundle_path.as_deref(),
258 Some(std::path::Path::new("/tmp/my-bundle.gtbundle"))
259 );
260 }
261
262 #[test]
265 fn bundle_path_in_emitted_manifest_is_absolute() {
266 let manifest = json!({
267 "schema": ENV_MANIFEST_SCHEMA_V1,
268 "environment": { "id": "local" },
269 "trust_root": "bootstrap",
270 "bundles": [{
271 "bundle_id": "test",
272 "bundle_path": "/absolute/path/to/bundle.gtbundle",
273 }]
274 });
275 let path = manifest["bundles"][0]["bundle_path"].as_str().unwrap();
276 assert!(
277 std::path::Path::new(path).is_absolute(),
278 "bundle_path must be absolute, got: {path}"
279 );
280 }
281
282 #[test]
285 fn synthesized_manifest_includes_customer_id_when_provided() {
286 let manifest = build_env_manifest(
293 "staging",
294 "my-bundle",
295 Path::new("/tmp/my-bundle.gtbundle"),
296 Some("acme-billing"),
297 );
298 let parsed: EnvManifest =
299 serde_json::from_value(manifest).expect("manifest must deserialize");
300 assert_eq!(
301 parsed.bundles[0].customer_id.as_deref(),
302 Some("acme-billing"),
303 "customer_id must be present in the synthesized manifest"
304 );
305 }
306
307 #[test]
308 fn synthesized_manifest_omits_customer_id_when_none() {
309 let manifest = build_env_manifest(
310 "local",
311 "my-bundle",
312 Path::new("/tmp/my-bundle.gtbundle"),
313 None,
314 );
315 let parsed: EnvManifest =
316 serde_json::from_value(manifest).expect("manifest must deserialize");
317 assert!(
318 parsed.bundles[0].customer_id.is_none(),
319 "customer_id must be absent when not provided (local env defaults it)"
320 );
321 }
322
323 #[test]
326 fn bundle_id_read_from_manifest_not_filename() {
327 let temp = tempdir().unwrap();
328 let dir = temp.path().join("wrong-filename");
330 make_bundle_dir_with_manifest(&dir, "declared-bundle-id");
331
332 let id = read_bundle_id_from_dir(&dir).unwrap();
333 assert_eq!(
334 id, "declared-bundle-id",
335 "must use declared bundle_id, not directory name"
336 );
337 }
338
339 #[test]
342 fn bundle_id_falls_back_to_bundle_yaml() {
343 let temp = tempdir().unwrap();
344 let dir = temp.path().join("yaml-only");
345 make_bundle_dir_with_yaml_only(&dir, "yaml-declared-id");
346
347 let id = read_bundle_id_from_dir(&dir).unwrap();
348 assert_eq!(id, "yaml-declared-id");
349 }
350
351 #[test]
354 fn missing_bundle_id_is_an_error() {
355 let temp = tempdir().unwrap();
356 let dir = temp.path().join("empty-bundle");
357 fs::create_dir_all(&dir).unwrap();
358 fs::write(
360 dir.join(crate::bundle::BUNDLE_WORKSPACE_MARKER),
361 "schema_version: 1\n",
362 )
363 .unwrap();
364
365 let err = read_bundle_id_from_dir(&dir).unwrap_err();
366 let msg = format!("{err:#}");
367 assert!(
368 msg.contains("cannot determine bundle_id"),
369 "expected clear error, got: {msg}"
370 );
371 }
372
373 #[test]
376 fn non_bundle_path_is_a_clear_error() {
377 let temp = tempdir().unwrap();
378 let not_a_bundle = temp.path().join("random.txt");
379 fs::write(¬_a_bundle, "hello").unwrap();
380
381 let err = deploy_bundle_to_env(¬_a_bundle, "local", true, true, None).unwrap_err();
382 let msg = format!("{err:#}");
383 assert!(
384 msg.contains("not a .gtbundle"),
385 "expected clear error for non-bundle file, got: {msg}"
386 );
387 }
388
389 #[test]
390 fn nonexistent_path_is_a_clear_error() {
391 let err = deploy_bundle_to_env(
392 Path::new("/nonexistent/path.gtbundle"),
393 "local",
394 true,
395 true,
396 None,
397 )
398 .unwrap_err();
399 let msg = format!("{err:#}");
400 assert!(
401 msg.contains("does not exist"),
402 "expected clear error for missing path, got: {msg}"
403 );
404 }
405}