Skip to main content

greentic_bundle/build/
mod.rs

1pub mod doctor_secrets;
2pub mod export;
3pub mod lock;
4pub mod manifest;
5pub mod plan;
6pub mod signing;
7pub mod squashfs;
8pub mod warmup;
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, bail};
13use greentic_bundle_reader::{
14    BundleLock as ReaderBundleLock, BundleManifest as ReaderBundleManifest, OpenedBundle,
15};
16use serde::Serialize;
17use tempfile::TempDir;
18
19pub const FUTURE_ARTIFACT_EXTENSION: &str = ".gtbundle";
20pub const BUILD_STATE_DIR: &str = "state/build";
21pub const BUILD_FORMAT_VERSION: &str = "gtbundle-v1";
22
23#[derive(Debug, Clone, Serialize)]
24pub struct BuildResult {
25    pub artifact_path: String,
26    pub build_dir: String,
27    pub manifest_path: String,
28    /// Path to the DSSE signature sidecar emitted next to `artifact_path`,
29    /// present iff the build ran with `--signing-key`.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub signature_path: Option<String>,
32    /// Whether the produced bundle embeds a precompiled component cache
33    /// (`.cache/v1/<engine_profile_id>/artifacts/`). `true` only when
34    /// `--warmup` ran and actually wrote cache artifacts into the build
35    /// directory; dry-run builds always report `false`.
36    pub has_component_cache: bool,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct DoctorReport {
41    pub target: String,
42    pub ok: bool,
43    pub checks: Vec<DoctorCheck>,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct DoctorCheck {
48    pub name: String,
49    pub ok: bool,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub details: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct InspectReport {
56    pub target: String,
57    pub kind: String,
58    pub manifest: ReaderBundleManifest,
59    pub lock: ReaderBundleLock,
60    pub runtime_surface: greentic_bundle_reader::BundleRuntimeSurface,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub contents: Option<Vec<String>>,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct UnbundleResult {
67    pub artifact_path: String,
68    pub output_dir: String,
69}
70
71pub fn build_workspace(
72    root: &Path,
73    output: Option<&Path>,
74    dry_run: bool,
75    warmup: bool,
76    signing: Option<&signing::SigningConfig>,
77) -> Result<BuildResult> {
78    let state = plan::build_state(root)?;
79    ensure_app_packs_materialized(root)?;
80    let artifact = output
81        .map(|path| path.to_path_buf())
82        .unwrap_or_else(|| default_artifact_path(root, &state.manifest.bundle_id));
83    let export_plan = export::export_plan(&state, &artifact);
84    if dry_run {
85        return Ok(BuildResult {
86            artifact_path: export_plan.artifact_path,
87            build_dir: export_plan.build_dir,
88            manifest_path: export_plan.manifest_path,
89            signature_path: None,
90            has_component_cache: false,
91        });
92    }
93    export::write_build_outputs(&state, &artifact, warmup, signing)
94}
95
96/// Refuse to build a bundle that is missing an application it declares.
97///
98/// An app-pack reference that cannot be resolved is skipped during
99/// materialization rather than failing, because `sync_project` also runs while
100/// a workspace is being authored — `add app-pack pack-a` legitimately names a
101/// pack that does not exist yet. By build time that tolerance is a liability:
102/// greentic-demo 1.1.6 shipped five `.gtbundle`s holding only extension
103/// providers and no application, each from a green, exit-0 build, because a
104/// `.gtpack` had quietly stopped being published.
105///
106/// This is deliberately scoped to the build path. `doctor` and `inspect` share
107/// `plan::build_state` and must stay able to open a broken bundle in order to
108/// diagnose it.
109fn ensure_app_packs_materialized(root: &Path) -> Result<()> {
110    let missing = crate::project::missing_app_pack_destinations(root)
111        .with_context(|| format!("check materialized app packs in {}", root.display()))?;
112    if missing.is_empty() {
113        return Ok(());
114    }
115    let detail = missing
116        .iter()
117        .map(|(reference, destination)| {
118            format!("  {reference} -> {} (not found)", destination.display())
119        })
120        .collect::<Vec<_>>()
121        .join("\n");
122    bail!(
123        "bundle declares {} app pack(s) that were not materialized:\n{detail}\n\
124         Each app pack reference must resolve to a pack that exists — check that \
125         it is published and reachable.",
126        missing.len()
127    )
128}
129
130pub fn export_build_dir(
131    build_dir: &Path,
132    output: &Path,
133    dry_run: bool,
134    warmup: bool,
135    signing: Option<&signing::SigningConfig>,
136) -> Result<BuildResult> {
137    let state = plan::load_build_state(build_dir)?;
138    let export_plan = export::export_plan(&state, output);
139    if dry_run {
140        return Ok(BuildResult {
141            artifact_path: export_plan.artifact_path,
142            build_dir: export_plan.build_dir,
143            manifest_path: export_plan.manifest_path,
144            signature_path: None,
145            has_component_cache: false,
146        });
147    }
148    export::write_build_outputs(&state, output, warmup, signing)
149}
150
151pub fn inspect_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<InspectReport> {
152    match (root, artifact) {
153        (Some(root), None) => {
154            let opened = open_workspace_build_dir(root)?;
155            Ok(InspectReport {
156                target: root.display().to_string(),
157                kind: "workspace".to_string(),
158                manifest: opened.manifest.clone(),
159                lock: opened.lock.clone(),
160                runtime_surface: opened.runtime_surface(),
161                contents: None,
162            })
163        }
164        (None, Some(artifact)) => inspect_artifact(artifact),
165        _ => bail!("inspect requires exactly one of workspace root or artifact path"),
166    }
167}
168
169pub fn doctor_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<DoctorReport> {
170    match (root, artifact) {
171        (Some(root), None) => doctor_workspace(root),
172        (None, Some(artifact)) => doctor_artifact(artifact),
173        _ => bail!("doctor requires exactly one of workspace root or artifact path"),
174    }
175}
176
177fn doctor_workspace(root: &Path) -> Result<DoctorReport> {
178    let state = plan::build_state(root)?;
179    let drift_ok = lock::lock_matches_manifest(&state.lock, &state.manifest);
180    let reader_validation = open_workspace_build_dir(root);
181    let reader_ok = reader_validation.is_ok();
182    let mut checks = vec![
183        DoctorCheck {
184            name: "bundle.yaml".to_string(),
185            ok: root.join(crate::project::WORKSPACE_ROOT_FILE).exists(),
186            details: None,
187        },
188        DoctorCheck {
189            name: "bundle.lock.json".to_string(),
190            // Accept either layout, matching `read_bundle_lock`; otherwise doctor
191            // reports ok=false for an artifact-layout dir that `build_state`
192            // opened successfully.
193            ok: crate::project::resolve_lock_path(root).is_some(),
194            details: None,
195        },
196        DoctorCheck {
197            name: "lock drift".to_string(),
198            ok: drift_ok,
199            details: (!drift_ok).then_some(
200                "bundle.lock.json does not match current workspace manifest inputs".to_string(),
201            ),
202        },
203        DoctorCheck {
204            name: "reader validation".to_string(),
205            ok: reader_ok,
206            details: if reader_ok {
207                Some("workspace manifest/lock satisfy reader contract".to_string())
208            } else {
209                Some(
210                    reader_validation
211                        .err()
212                        .map(|error| error.to_string())
213                        .unwrap_or_else(|| {
214                            "workspace manifest/lock do not satisfy reader contract".to_string()
215                        }),
216                )
217            },
218        },
219    ];
220    let staging = temp_build_dir(&state)?;
221    let secrets = doctor_secrets::scan_build_dir(staging.path())?;
222    checks.extend(secret_scan_checks(secrets));
223    Ok(DoctorReport {
224        target: root.display().to_string(),
225        ok: checks.iter().all(|check| check.ok),
226        checks,
227    })
228}
229
230fn doctor_artifact(artifact: &Path) -> Result<DoctorReport> {
231    let opened = greentic_bundle_reader::open_artifact(artifact)
232        .with_context(|| format!("open artifact {}", artifact.display()))?;
233    let mut checks = vec![
234        DoctorCheck {
235            name: "artifact exists".to_string(),
236            ok: artifact.exists(),
237            details: None,
238        },
239        DoctorCheck {
240            name: "manifest embedded".to_string(),
241            ok: !opened.manifest.bundle_id.is_empty(),
242            details: None,
243        },
244        DoctorCheck {
245            name: "lock embedded".to_string(),
246            ok: !opened.lock.bundle_id.is_empty(),
247            details: None,
248        },
249        DoctorCheck {
250            name: "reader validation".to_string(),
251            ok: true,
252            details: Some(format!(
253                "{} opened by {} reader",
254                opened.format_version,
255                opened.source_kind.as_str()
256            )),
257        },
258    ];
259    let secrets = doctor_secrets::scan_artifact(artifact)?;
260    checks.extend(secret_scan_checks(secrets));
261    Ok(DoctorReport {
262        target: artifact.display().to_string(),
263        ok: checks.iter().all(|check| check.ok),
264        checks,
265    })
266}
267
268// Translates a SecretsReport into DoctorCheck entries so the existing JSON
269// schema stays stable for downstream consumers (CI, status pages). Clean
270// scans emit a single `secret-leak scan` pass; every finding becomes its own
271// `secret-leak: <kind>` fail-check with the finding message + path.
272fn secret_scan_checks(report: doctor_secrets::SecretsReport) -> Vec<DoctorCheck> {
273    if report.findings.is_empty() {
274        return vec![DoctorCheck {
275            name: "secret-leak scan".to_string(),
276            ok: true,
277            details: None,
278        }];
279    }
280    report
281        .findings
282        .into_iter()
283        .map(|finding| {
284            let detail = match finding.path.as_deref() {
285                Some(path) => format!("{path}: {}", finding.message),
286                None => finding.message.clone(),
287            };
288            DoctorCheck {
289                name: format!("secret-leak: {}", finding_kind_label(finding.kind)),
290                ok: false,
291                details: Some(detail),
292            }
293        })
294        .collect()
295}
296
297fn finding_kind_label(kind: doctor_secrets::FindingKind) -> &'static str {
298    match kind {
299        doctor_secrets::FindingKind::DevStorePath => "dev-store path",
300        doctor_secrets::FindingKind::SecretValuesPopulated => "secret_values populated",
301        doctor_secrets::FindingKind::NormalizedAnswersLeak => "normalized_answers leak",
302        doctor_secrets::FindingKind::ArchiveBytesContainsDevPath => "archive-bytes dev path",
303    }
304}
305
306fn inspect_artifact(artifact: &Path) -> Result<InspectReport> {
307    let opened = greentic_bundle_reader::open_artifact(artifact)
308        .with_context(|| format!("open artifact {}", artifact.display()))?;
309    Ok(InspectReport {
310        target: artifact.display().to_string(),
311        kind: "artifact".to_string(),
312        manifest: opened.manifest.clone(),
313        lock: opened.lock.clone(),
314        runtime_surface: opened.runtime_surface(),
315        contents: Some(squashfs::list_artifact_contents(artifact)?),
316    })
317}
318
319pub fn unbundle_artifact(artifact: &Path, output_dir: &Path) -> Result<UnbundleResult> {
320    squashfs::unpack_artifact(artifact, output_dir)?;
321    Ok(UnbundleResult {
322        artifact_path: artifact.display().to_string(),
323        output_dir: output_dir.display().to_string(),
324    })
325}
326
327pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf {
328    root.join("dist")
329        .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}"))
330}
331
332fn open_workspace_build_dir(root: &Path) -> Result<OpenedBundle> {
333    let state = plan::build_state(root)?;
334    let staging_dir = temp_build_dir(&state)?;
335    greentic_bundle_reader::open_build_dir_with_source(
336        staging_dir.path(),
337        root.display().to_string(),
338    )
339    .map_err(|error| anyhow::anyhow!(error.to_string()))
340}
341
342fn temp_build_dir(state: &plan::BuildState) -> Result<TempDir> {
343    let staging_dir = tempfile::tempdir().context("create temporary normalized build dir")?;
344    export::write_normalized_build_dir(state, staging_dir.path())?;
345    Ok(staging_dir)
346}