Skip to main content

greentic_bundle/build/
plan.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde_yaml_bw::Value;
6
7use super::manifest::{BundleManifest, ResolvedReferencePolicy, ResolvedTargetSummary};
8
9#[derive(Debug, Clone)]
10pub struct BuildState {
11    pub root: PathBuf,
12    pub build_dir: PathBuf,
13    pub manifest: BundleManifest,
14    pub lock: crate::project::BundleLock,
15    pub bundle_yaml: String,
16    pub resolved_files: Vec<(String, String)>,
17    pub setup_files: Vec<(String, String)>,
18    pub asset_files: Vec<(String, Vec<u8>)>,
19}
20
21pub fn build_state(root: &Path) -> Result<BuildState> {
22    let lock = crate::project::read_bundle_lock(root)
23        .with_context(|| format!("read {}", root.join(crate::project::LOCK_FILE).display()))?;
24    let bundle_yaml = fs::read_to_string(root.join(crate::project::WORKSPACE_ROOT_FILE))
25        .with_context(|| {
26            format!(
27                "read {}",
28                root.join(crate::project::WORKSPACE_ROOT_FILE).display()
29            )
30        })?;
31
32    let bundle_doc = parse_yaml_document(&bundle_yaml);
33    let bundle_id = yaml_string(&bundle_doc, "bundle_id").unwrap_or_else(|| lock.bundle_id.clone());
34    let bundle_name = yaml_string(&bundle_doc, "bundle_name").unwrap_or_else(|| bundle_id.clone());
35    let requested_mode =
36        yaml_string(&bundle_doc, "mode").unwrap_or_else(|| lock.requested_mode.clone());
37    let locale = yaml_string(&bundle_doc, "locale").unwrap_or_else(|| "en".to_string());
38    let app_packs = yaml_string_list(&bundle_doc, "app_packs");
39    let extension_providers = yaml_string_list(&bundle_doc, "extension_providers");
40    let catalogs = yaml_string_list(&bundle_doc, "remote_catalogs");
41    let hooks = yaml_string_list(&bundle_doc, "hooks");
42    let subscriptions = yaml_string_list(&bundle_doc, "subscriptions");
43    let capabilities = yaml_string_list(&bundle_doc, "capabilities");
44    let resolved_files = collect_files(root, &root.join("resolved"))?;
45    let setup_files = collect_named_files(root, &lock.setup_state_files)?;
46    let asset_files = collect_asset_files(root)?;
47    let resolved_targets = resolved_files
48        .iter()
49        .filter_map(|(name, contents)| parse_resolved_target(name, contents))
50        .collect();
51
52    let manifest = BundleManifest {
53        format_version: crate::build::BUILD_FORMAT_VERSION.to_string(),
54        bundle_id,
55        bundle_name,
56        requested_mode,
57        locale,
58        artifact_extension: crate::build::FUTURE_ARTIFACT_EXTENSION.to_string(),
59        generated_resolved_files: resolved_files
60            .iter()
61            .map(|(name, _)| name.clone())
62            .collect(),
63        generated_setup_files: setup_files.iter().map(|(name, _)| name.clone()).collect(),
64        app_packs,
65        extension_providers,
66        catalogs,
67        hooks,
68        subscriptions,
69        capabilities,
70        resolved_targets,
71    };
72
73    let build_dir = root
74        .join(crate::build::BUILD_STATE_DIR)
75        .join(&manifest.bundle_id)
76        .join("normalized");
77    Ok(BuildState {
78        root: root.to_path_buf(),
79        build_dir,
80        manifest,
81        lock,
82        bundle_yaml,
83        resolved_files,
84        setup_files,
85        asset_files,
86    })
87}
88
89pub fn load_build_state(build_dir: &Path) -> Result<BuildState> {
90    let manifest_raw = fs::read_to_string(build_dir.join("bundle-manifest.json"))?;
91    let lock_raw = fs::read_to_string(build_dir.join("bundle-lock.json"))?;
92    let bundle_yaml = fs::read_to_string(build_dir.join("bundle.yaml"))?;
93    let manifest = serde_json::from_str::<BundleManifest>(&manifest_raw)?;
94    let lock = serde_json::from_str::<crate::project::BundleLock>(&lock_raw)?;
95    let resolved_files = collect_files(build_dir, &build_dir.join("resolved"))?;
96    let setup_files = collect_files(build_dir, &build_dir.join("state").join("setup"))?;
97    let asset_files = collect_asset_files(build_dir)?;
98    Ok(BuildState {
99        root: build_dir.to_path_buf(),
100        build_dir: build_dir.to_path_buf(),
101        manifest,
102        lock,
103        bundle_yaml,
104        resolved_files,
105        setup_files,
106        asset_files,
107    })
108}
109
110fn collect_asset_files(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
111    let mut files = Vec::new();
112    // `packs/` and `providers/` only ship `.gtpack` archives; everything else
113    // there is build noise (manifest snapshots, scratch files) and must not be
114    // re-bundled.
115    for relative_root in ["packs", "providers"] {
116        let dir = root.join(relative_root);
117        if !dir.exists() {
118            continue;
119        }
120        for entry in walk(&dir)? {
121            if entry.extension().and_then(|value| value.to_str()) != Some("gtpack") {
122                continue;
123            }
124            let rel = entry
125                .strip_prefix(root)
126                .unwrap_or(&entry)
127                .display()
128                .to_string();
129            files.push((rel, fs::read(&entry)?));
130        }
131    }
132    // `.providers/`, `assets/`, and `tenants/` carry deploy-time configuration
133    // the setup wizard wrote (provider envelopes, contract cache, per-tenant
134    // client overlays, allow-rule gmaps). The runtime needs all of these to
135    // resolve provider configs and serve per-tenant client config; a build that
136    // drops them produces an artifact that renders the default skin and breaks
137    // JWT verification on cloud deploys.
138    for relative_root in [".providers", "assets", "tenants"] {
139        let dir = root.join(relative_root);
140        if !dir.exists() {
141            continue;
142        }
143        for entry in walk(&dir)? {
144            let rel = entry
145                .strip_prefix(root)
146                .unwrap_or(&entry)
147                .display()
148                .to_string();
149            files.push((rel, fs::read(&entry)?));
150        }
151    }
152    files.sort_by(|a, b| a.0.cmp(&b.0));
153    files.dedup_by(|left, right| left.0 == right.0);
154    Ok(files)
155}
156
157fn collect_files(root: &Path, dir: &Path) -> Result<Vec<(String, String)>> {
158    if !dir.exists() {
159        return Ok(Vec::new());
160    }
161    let mut files = Vec::new();
162    for entry in walk(dir)? {
163        let rel = entry
164            .strip_prefix(root)
165            .unwrap_or(&entry)
166            .display()
167            .to_string();
168        let contents = fs::read_to_string(&entry)?;
169        files.push((rel, contents));
170    }
171    files.sort_by(|a, b| a.0.cmp(&b.0));
172    Ok(files)
173}
174
175fn collect_named_files(root: &Path, names: &[String]) -> Result<Vec<(String, String)>> {
176    let mut files = Vec::new();
177    for name in names {
178        let path = root.join(name);
179        if path.exists() {
180            files.push((name.clone(), fs::read_to_string(path)?));
181        }
182    }
183    files.sort_by(|a, b| a.0.cmp(&b.0));
184    Ok(files)
185}
186
187fn walk(dir: &Path) -> Result<Vec<PathBuf>> {
188    let mut out = Vec::new();
189    for entry in fs::read_dir(dir)? {
190        let entry = entry?;
191        let path = entry.path();
192        if entry.file_type()?.is_dir() {
193            out.extend(walk(&path)?);
194        } else {
195            out.push(path);
196        }
197    }
198    out.sort();
199    Ok(out)
200}
201
202fn parse_yaml_document(raw: &str) -> Option<Value> {
203    serde_yaml_bw::from_str(raw).ok()
204}
205
206fn yaml_string(doc: &Option<Value>, key: &str) -> Option<String> {
207    doc.as_ref()?.get(key)?.as_str().map(ToOwned::to_owned)
208}
209
210fn yaml_string_list(doc: &Option<Value>, key: &str) -> Vec<String> {
211    match doc.as_ref().and_then(|value| value.get(key)) {
212        Some(Value::Sequence(items)) => items
213            .iter()
214            .filter_map(|item| item.as_str().map(ToOwned::to_owned))
215            .collect(),
216        Some(Value::Null(_)) | None => Vec::new(),
217        Some(Value::String(value, _)) => vec![value.clone()],
218        _ => Vec::new(),
219    }
220}
221
222fn parse_resolved_target(path: &str, raw: &str) -> Option<ResolvedTargetSummary> {
223    let doc = parse_yaml_document(raw)?;
224    let tenant = doc.get("tenant")?.as_str()?.to_string();
225    let default_policy = doc
226        .get("policy")
227        .and_then(|value| value.get("default"))
228        .and_then(Value::as_str)
229        .unwrap_or("forbidden")
230        .to_string();
231    let tenant_gmap = doc
232        .get("policy")
233        .and_then(|value| value.get("source"))
234        .and_then(|value| value.get("tenant_gmap"))
235        .and_then(Value::as_str)?
236        .to_string();
237    let team_gmap = doc
238        .get("policy")
239        .and_then(|value| value.get("source"))
240        .and_then(|value| value.get("team_gmap"))
241        .and_then(Value::as_str)
242        .map(ToOwned::to_owned);
243    let team = doc
244        .get("team")
245        .and_then(Value::as_str)
246        .map(ToOwned::to_owned);
247    Some(ResolvedTargetSummary {
248        path: path.to_string(),
249        tenant,
250        team,
251        default_policy,
252        tenant_gmap,
253        team_gmap,
254        app_pack_policies: parse_reference_policies(&doc),
255    })
256}
257
258fn parse_reference_policies(doc: &Value) -> Vec<ResolvedReferencePolicy> {
259    let Some(Value::Sequence(items)) = doc.get("app_packs") else {
260        return Vec::new();
261    };
262
263    items
264        .iter()
265        .filter_map(|item| {
266            let reference = item.get("reference")?.as_str()?.to_string();
267            let policy = item
268                .get("policy")
269                .and_then(Value::as_str)
270                .unwrap_or("unset")
271                .to_string();
272            Some(ResolvedReferencePolicy { reference, policy })
273        })
274        .collect()
275}