Skip to main content

greentic_bundle/build/
mod.rs

1pub mod export;
2pub mod lock;
3pub mod manifest;
4pub mod plan;
5pub mod squashfs;
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result, bail};
10use greentic_bundle_reader::{
11    BundleLock as ReaderBundleLock, BundleManifest as ReaderBundleManifest, OpenedBundle,
12};
13use serde::Serialize;
14use tempfile::TempDir;
15
16pub const FUTURE_ARTIFACT_EXTENSION: &str = ".gtbundle";
17pub const BUILD_STATE_DIR: &str = "state/build";
18pub const BUILD_FORMAT_VERSION: &str = "gtbundle-v1";
19
20#[derive(Debug, Clone, Serialize)]
21pub struct BuildResult {
22    pub artifact_path: String,
23    pub build_dir: String,
24    pub manifest_path: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DoctorReport {
29    pub target: String,
30    pub ok: bool,
31    pub checks: Vec<DoctorCheck>,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct DoctorCheck {
36    pub name: String,
37    pub ok: bool,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub details: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct InspectReport {
44    pub target: String,
45    pub kind: String,
46    pub manifest: ReaderBundleManifest,
47    pub lock: ReaderBundleLock,
48    pub runtime_surface: greentic_bundle_reader::BundleRuntimeSurface,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub contents: Option<Vec<String>>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct UnbundleResult {
55    pub artifact_path: String,
56    pub output_dir: String,
57}
58
59pub fn build_workspace(root: &Path, output: Option<&Path>, dry_run: bool) -> Result<BuildResult> {
60    let state = plan::build_state(root)?;
61    let artifact = output
62        .map(|path| path.to_path_buf())
63        .unwrap_or_else(|| default_artifact_path(root, &state.manifest.bundle_id));
64    let export_plan = export::export_plan(&state, &artifact);
65    if dry_run {
66        return Ok(BuildResult {
67            artifact_path: export_plan.artifact_path,
68            build_dir: export_plan.build_dir,
69            manifest_path: export_plan.manifest_path,
70        });
71    }
72    export::write_build_outputs(&state, &artifact)
73}
74
75pub fn export_build_dir(build_dir: &Path, output: &Path, dry_run: bool) -> Result<BuildResult> {
76    let state = plan::load_build_state(build_dir)?;
77    let export_plan = export::export_plan(&state, output);
78    if dry_run {
79        return Ok(BuildResult {
80            artifact_path: export_plan.artifact_path,
81            build_dir: export_plan.build_dir,
82            manifest_path: export_plan.manifest_path,
83        });
84    }
85    export::write_build_outputs(&state, output)
86}
87
88pub fn inspect_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<InspectReport> {
89    match (root, artifact) {
90        (Some(root), None) => {
91            let opened = open_workspace_build_dir(root)?;
92            Ok(InspectReport {
93                target: root.display().to_string(),
94                kind: "workspace".to_string(),
95                manifest: opened.manifest.clone(),
96                lock: opened.lock.clone(),
97                runtime_surface: opened.runtime_surface(),
98                contents: None,
99            })
100        }
101        (None, Some(artifact)) => inspect_artifact(artifact),
102        _ => bail!("inspect requires exactly one of workspace root or artifact path"),
103    }
104}
105
106pub fn doctor_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<DoctorReport> {
107    match (root, artifact) {
108        (Some(root), None) => doctor_workspace(root),
109        (None, Some(artifact)) => doctor_artifact(artifact),
110        _ => bail!("doctor requires exactly one of workspace root or artifact path"),
111    }
112}
113
114fn doctor_workspace(root: &Path) -> Result<DoctorReport> {
115    let state = plan::build_state(root)?;
116    let drift_ok = lock::lock_matches_manifest(&state.lock, &state.manifest);
117    let reader_validation = open_workspace_build_dir(root);
118    let reader_ok = reader_validation.is_ok();
119    let checks = vec![
120        DoctorCheck {
121            name: "bundle.yaml".to_string(),
122            ok: root.join(crate::project::WORKSPACE_ROOT_FILE).exists(),
123            details: None,
124        },
125        DoctorCheck {
126            name: "bundle.lock.json".to_string(),
127            ok: root.join(crate::project::LOCK_FILE).exists(),
128            details: None,
129        },
130        DoctorCheck {
131            name: "lock drift".to_string(),
132            ok: drift_ok,
133            details: (!drift_ok).then_some(
134                "bundle.lock.json does not match current workspace manifest inputs".to_string(),
135            ),
136        },
137        DoctorCheck {
138            name: "reader validation".to_string(),
139            ok: reader_ok,
140            details: if reader_ok {
141                Some("workspace manifest/lock satisfy reader contract".to_string())
142            } else {
143                Some(
144                    reader_validation
145                        .err()
146                        .map(|error| error.to_string())
147                        .unwrap_or_else(|| {
148                            "workspace manifest/lock do not satisfy reader contract".to_string()
149                        }),
150                )
151            },
152        },
153    ];
154    Ok(DoctorReport {
155        target: root.display().to_string(),
156        ok: checks.iter().all(|check| check.ok),
157        checks,
158    })
159}
160
161fn doctor_artifact(artifact: &Path) -> Result<DoctorReport> {
162    let opened = greentic_bundle_reader::open_artifact(artifact)
163        .with_context(|| format!("open artifact {}", artifact.display()))?;
164    let checks = vec![
165        DoctorCheck {
166            name: "artifact exists".to_string(),
167            ok: artifact.exists(),
168            details: None,
169        },
170        DoctorCheck {
171            name: "manifest embedded".to_string(),
172            ok: !opened.manifest.bundle_id.is_empty(),
173            details: None,
174        },
175        DoctorCheck {
176            name: "lock embedded".to_string(),
177            ok: !opened.lock.bundle_id.is_empty(),
178            details: None,
179        },
180        DoctorCheck {
181            name: "reader validation".to_string(),
182            ok: true,
183            details: Some(format!(
184                "{} opened by {} reader",
185                opened.format_version,
186                opened.source_kind.as_str()
187            )),
188        },
189    ];
190    Ok(DoctorReport {
191        target: artifact.display().to_string(),
192        ok: checks.iter().all(|check| check.ok),
193        checks,
194    })
195}
196
197fn inspect_artifact(artifact: &Path) -> Result<InspectReport> {
198    let opened = greentic_bundle_reader::open_artifact(artifact)
199        .with_context(|| format!("open artifact {}", artifact.display()))?;
200    Ok(InspectReport {
201        target: artifact.display().to_string(),
202        kind: "artifact".to_string(),
203        manifest: opened.manifest.clone(),
204        lock: opened.lock.clone(),
205        runtime_surface: opened.runtime_surface(),
206        contents: Some(squashfs::list_artifact_contents(artifact)?),
207    })
208}
209
210pub fn unbundle_artifact(artifact: &Path, output_dir: &Path) -> Result<UnbundleResult> {
211    squashfs::unpack_artifact(artifact, output_dir)?;
212    Ok(UnbundleResult {
213        artifact_path: artifact.display().to_string(),
214        output_dir: output_dir.display().to_string(),
215    })
216}
217
218pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf {
219    root.join("dist")
220        .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}"))
221}
222
223fn open_workspace_build_dir(root: &Path) -> Result<OpenedBundle> {
224    let state = plan::build_state(root)?;
225    let staging_dir = temp_build_dir(&state)?;
226    greentic_bundle_reader::open_build_dir_with_source(
227        staging_dir.path(),
228        root.display().to_string(),
229    )
230    .map_err(|error| anyhow::anyhow!(error.to_string()))
231}
232
233fn temp_build_dir(state: &plan::BuildState) -> Result<TempDir> {
234    let staging_dir = tempfile::tempdir().context("create temporary normalized build dir")?;
235    export::write_normalized_build_dir(state, staging_dir.path())?;
236    Ok(staging_dir)
237}