Skip to main content

greentic_setup/
env_deploy.rs

1//! Deploy a bundle into an environment via the env-apply engine.
2//!
3//! `greentic-setup env-deploy <BUNDLE>` synthesizes a
4//! `greentic.env-manifest.v1` document from a `.gtbundle` archive (or
5//! bundle directory) and routes it through the deployer's idempotent
6//! env-apply engine — the same engine behind `gtc op env apply`. The
7//! bundle's declared `bundle_id` is read from the archive metadata, not
8//! inferred from the filename (see module-level doc on "the bundle_id
9//! trap").
10//!
11//! # The bundle_id trap
12//!
13//! Multiple `.gtbundle` filenames can declare the *same* `bundle_id` in
14//! their metadata (e.g. `quickstart-rich.gtbundle` and
15//! `quickstart-rich-v2.gtbundle` both declare `bundle_id:
16//! quickstart-bundle`). Using the filename stem instead of the declared
17//! id would create a *second* deployment instead of converging on /
18//! blue-greening the existing one, breaking the idempotency contract.
19
20use 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
30/// Bundle-manifest filename inside a built `.gtbundle` archive / directory.
31const BUNDLE_MANIFEST_JSON: &str = "bundle-manifest.json";
32
33/// Read `bundle_id` from a bundle directory's metadata.
34///
35/// Tries `bundle-manifest.json` first (the build-time manifest emitted by
36/// `greentic-bundle build`), then falls back to `bundle.yaml` (the workspace
37/// marker). Returns an error when neither file carries a `bundle_id`.
38fn read_bundle_id_from_dir(dir: &Path) -> Result<String> {
39    // Primary: bundle-manifest.json (build artifact).
40    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    // Fallback: bundle.yaml (workspace marker).
54    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
77/// Deploy a bundle into an environment.
78///
79/// Resolves the input to an absolute `.gtbundle` archive, reads the
80/// declared `bundle_id` from the archive metadata, synthesizes an
81/// env-manifest document, and runs it through the env-apply engine.
82///
83/// When `customer_id` is `Some`, it is included in the synthesized
84/// env-manifest so the deployer's `resolve_customer_id` finds it
85/// (required for non-local environments).
86pub 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    // ── Step 1: resolve to an absolute .gtbundle archive file ────────────
94    let _temp_dir; // keep alive until after apply returns
95    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    // ── Step 2: read bundle_id from metadata ────────────────────────────
126    // For a directory input we read straight from the source dir (cheaper
127    // than extracting the archive we just created). For an archive input we
128    // extract to a temp dir, read, and clean up.
129    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        // Best-effort cleanup; ignore errors.
136        let _ = std::fs::remove_dir_all(&extract_dir);
137        id?
138    };
139
140    // ── Step 3: synthesize the env-manifest document ────────────────────
141    let manifest = build_env_manifest(env_id, &bundle_id, &archive_path, customer_id);
142
143    // ── Step 4: write to a temp file and call env-apply ─────────────────
144    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
167/// Build the synthesized single-bundle env-manifest document.
168///
169/// Split out so tests exercise the real construction instead of re-deriving it:
170/// `customer_id` is required by the deployer's `resolve_customer_id` for every
171/// non-`local` env, so dropping it here silently breaks named environments.
172fn 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    /// Build a minimal bundle directory with a `bundle-manifest.json`.
204    fn make_bundle_dir_with_manifest(dir: &Path, bundle_id: &str) {
205        fs::create_dir_all(dir).unwrap();
206        // Minimum viable bundle: marker + manifest
207        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    /// Build a minimal bundle directory with `bundle.yaml` only (no
228    /// `bundle-manifest.json`).
229    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    // ── Schema guard ────────────────────────────────────────────────────
239
240    #[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    // ── bundle_path is absolute ─────────────────────────────────────────
263
264    #[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    // ── customer_id in synthesized manifest ───────────────────────────
283
284    #[test]
285    fn synthesized_manifest_includes_customer_id_when_provided() {
286        // Exercises the REAL construction (`build_env_manifest`). An earlier
287        // version of this test re-derived the manifest inline and so passed even
288        // with the customer_id insertion deleted from production — a tautology.
289        // The deployer's resolve_customer_id REQUIRES this field for every
290        // non-local env; without it, `provider add` hard-fails after the
291        // endpoint, secrets, and bundle link have already been committed.
292        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    // ── bundle_id from bundle-manifest.json, not filename ───────────────
324
325    #[test]
326    fn bundle_id_read_from_manifest_not_filename() {
327        let temp = tempdir().unwrap();
328        // Deliberately name the directory differently from the declared id.
329        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    // ── bundle.yaml fallback ────────────────────────────────────────────
340
341    #[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    // ── neither file -> error ───────────────────────────────────────────
352
353    #[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        // Write a bundle.yaml without bundle_id.
359        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    // ── non-bundle path -> clear error ──────────────────────────────────
374
375    #[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(&not_a_bundle, "hello").unwrap();
380
381        let err = deploy_bundle_to_env(&not_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}