Skip to main content

greentic_bundle/build/
mod.rs

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