Skip to main content

consortium_builder/pipeline/
bitbake.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Stage 8 (optional): hand the assembled `dist/` tree off to Yocto/bitbake.
18//!
19//! There is no in-repo Yocto layer to match conventions against, so this stage is
20//! deliberately thin and decoupled: either stage `dist/` under a recipe's
21//! `files/` directory (for `SRC_URI`), or invoke `bitbake` on a recipe.
22
23use std::path::{Path, PathBuf};
24
25use thiserror::Error;
26use tokio::process::Command;
27
28use crate::compile::utils::run_command;
29use crate::progress::BuildReporter;
30
31use super::dist::{DistAssemblyError, copy_dir_recursive};
32
33/// What to do with the assembled `dist/` tree.
34#[derive(Debug, Clone)]
35pub enum BitbakeAction {
36    /// Copy `dist/` under a recipe's `files/` directory so a `SRC_URI` picks it up.
37    CopyIntoRecipe {
38        /// The recipe's `files/` directory to stage into.
39        recipe_files_dir: PathBuf,
40    },
41    /// Invoke `bitbake <recipe>` in a Yocto build directory.
42    InvokeBitbake {
43        /// The Yocto build directory (where `bitbake` is run).
44        build_dir: PathBuf,
45        /// The recipe to build.
46        recipe: String,
47        /// When set, resolve and report the `bitbake` command without running it.
48        ///
49        /// This supports splitting the build across machines: the Consortium
50        /// build assembles `dist/` on one host, and the actual `bitbake` runs on
51        /// a separate Yocto host that need not have the artifacts locally yet. The
52        /// stage succeeds without requiring `bitbake` on the local PATH.
53        dry_run: bool,
54    },
55}
56
57/// Error type for the bitbake hand-off.
58#[derive(Error, Debug)]
59pub enum BitbakeError {
60    /// Staging `dist/` into the recipe failed.
61    #[error(transparent)]
62    Copy(#[from] DistAssemblyError),
63
64    /// `bitbake` was not found on PATH.
65    #[error("`bitbake` not found on PATH; source a Yocto build environment first")]
66    BitbakeNotFound,
67
68    /// `bitbake` exited non-zero.
69    #[error("bitbake failed: {0}")]
70    Failed(String),
71
72    /// Writing the generated `.bb` recipe failed.
73    #[error("failed to write generated recipe {path}: {source}")]
74    RecipeWrite {
75        /// The recipe path being written.
76        path: String,
77        /// The underlying I/O error.
78        source: std::io::Error,
79    },
80}
81
82/// The on-target install manifest for a generated `.bb` recipe.
83///
84/// Built from the pipeline's resolved outputs and used to render a self-contained
85/// Yocto recipe that installs the assembled `dist/` tree. All artifact fields are
86/// bare file names under the corresponding `dist/` subdirectory (e.g. `app.elf`
87/// lives at `dist/app/app.elf`).
88#[derive(Debug, Clone)]
89pub struct RecipeSpec {
90    /// The recipe/package name (`PN`); also the `.bb` file stem.
91    pub recipe_name: String,
92    /// On-target install prefix from `[profile] deploy_root` (e.g. `/opt/consortium`).
93    pub deploy_root: String,
94    /// The application binary file name under `dist/app/`.
95    pub app_bin: String,
96    /// Firmware ELF file names under `dist/firmware/` (e.g. `cm33.elf`).
97    pub firmware: Vec<String>,
98    /// The signed TA file name under `dist/ta/`, when present.
99    pub ta: Option<String>,
100    /// Whether an HMI `dist/www/` tree was assembled.
101    pub has_www: bool,
102    /// The systemd unit file name under `dist/systemd/` (e.g. `consortium-app.service`).
103    pub systemd_unit: String,
104    /// Device-tree file names under `dist/dts/linux/`: a `.dtso` overlay is
105    /// installed into the rootfs (applied at runtime via configfs); a merged
106    /// `.dts` is a kernel build input, staged but not installed.
107    pub dts_files: Vec<String>,
108    /// Boot-args file names under `dist/boot/` (e.g. `boot.cmd`,
109    /// `extlinux-uio.cfg`, `README`). These are bootloader/image inputs, staged
110    /// for the integrator but not installed into the rootfs.
111    pub boot_files: Vec<String>,
112}
113
114/// Renders a self-contained Yocto `.bb` recipe that installs the assembled
115/// `dist/` tree.
116///
117/// The recipe is written next to the `dist/` subdirectories (see [`write_recipe`])
118/// and reaches them via `FILESEXTRAPATHS:prepend := "${THISDIR}:"`, so dropping
119/// the recipe plus the `dist/` tree into a Yocto layer is enough to `bitbake` it.
120pub fn render_recipe(spec: &RecipeSpec) -> String {
121    let deploy = spec.deploy_root.trim_end_matches('/');
122    let mut src_uri: Vec<String> = Vec::new();
123    let mut install: Vec<String> = Vec::new();
124    let mut files: Vec<String> = Vec::new();
125
126    // Application binary → {deploy_root}/app/<bin> (matches the systemd unit's
127    // ExecStart/WorkingDirectory).
128    src_uri.push(format!("file://app/{}", spec.app_bin));
129    install.push(format!("    install -d ${{D}}{deploy}/app"));
130    install.push(format!(
131        "    install -m 0755 ${{WORKDIR}}/app/{bin} ${{D}}{deploy}/app/{bin}",
132        bin = spec.app_bin
133    ));
134    files.push(format!("{deploy}/app"));
135
136    // Firmware ELFs → /lib/firmware/<core>.elf for remoteproc's kernel loader.
137    if !spec.firmware.is_empty() {
138        install.push("    install -d ${D}${nonarch_base_libdir}/firmware".to_string());
139        for fw in &spec.firmware {
140            src_uri.push(format!("file://firmware/{fw}"));
141            install.push(format!(
142                "    install -m 0644 ${{WORKDIR}}/firmware/{fw} \
143                 ${{D}}${{nonarch_base_libdir}}/firmware/{fw}"
144            ));
145        }
146        files.push("${nonarch_base_libdir}/firmware".to_string());
147    }
148
149    // systemd unit → the distro's system unit dir, registered via SYSTEMD_SERVICE.
150    src_uri.push(format!("file://systemd/{}", spec.systemd_unit));
151    install.push("    install -d ${D}${systemd_system_unitdir}".to_string());
152    install.push(format!(
153        "    install -m 0644 ${{WORKDIR}}/systemd/{unit} ${{D}}${{systemd_system_unitdir}}/{unit}",
154        unit = spec.systemd_unit
155    ));
156    files.push(format!("${{systemd_system_unitdir}}/{}", spec.systemd_unit));
157
158    // HMI assets → {deploy_root}/www (served by the app).
159    if spec.has_www {
160        src_uri.push("file://www".to_string());
161        install.push(format!("    install -d ${{D}}{deploy}/www"));
162        install.push(format!("    cp -R ${{WORKDIR}}/www/. ${{D}}{deploy}/www/"));
163        files.push(format!("{deploy}/www"));
164    }
165
166    // Signed TA → /lib/optee_armtz/<uuid>.ta (OP-TEE's TA search path).
167    if let Some(ta) = &spec.ta {
168        src_uri.push(format!("file://ta/{ta}"));
169        install.push("    install -d ${D}${nonarch_base_libdir}/optee_armtz".to_string());
170        install.push(format!(
171            "    install -m 0444 ${{WORKDIR}}/ta/{ta} ${{D}}${{nonarch_base_libdir}}/optee_armtz/{ta}"
172        ));
173        files.push("${nonarch_base_libdir}/optee_armtz".to_string());
174    }
175
176    // Device tree. A `.dtso` overlay is applied at runtime (configfs, enabled by
177    // the CONFIG_OF_OVERLAY fragment), so the rootfs is a reasonable home for it.
178    // A merged `.dts` is a *kernel build input* (compiled to a `.dtb` by the
179    // kernel), so it is staged but not installed here — the integrator adds it to
180    // their kernel recipe (see the guidance block below). The kernel config
181    // fragment is likewise a build input, merged by a separate kernel `.bbappend`
182    // (see `render_kernel_bbappend`).
183    let mut guidance: Vec<String> = Vec::new();
184    if !spec.dts_files.is_empty() {
185        src_uri.push("file://dts".to_string());
186        let overlays: Vec<&String> = spec
187            .dts_files
188            .iter()
189            .filter(|n| n.ends_with(".dtso"))
190            .collect();
191        let merged: Vec<&String> = spec
192            .dts_files
193            .iter()
194            .filter(|n| n.ends_with(".dts"))
195            .collect();
196        if !overlays.is_empty() {
197            install.push(format!("    install -d ${{D}}{deploy}/dts"));
198            for name in &overlays {
199                install.push(format!(
200                    "    install -m 0644 ${{WORKDIR}}/dts/linux/{name} ${{D}}{deploy}/dts/{name}"
201                ));
202            }
203            files.push(format!("{deploy}/dts"));
204        }
205        for name in &merged {
206            guidance.push(format!(
207                "# Kernel input: dts/linux/{name} is a full board device tree. Add it to\n\
208                 #   your kernel recipe (e.g. copy into the kernel source and extend\n\
209                 #   KERNEL_DEVICETREE), then boot that .dtb. It is NOT a rootfs file."
210            ));
211        }
212    }
213
214    // Boot-args files → staged for the bootloader/image, not installed to the
215    // rootfs. `dist/boot/README` documents the per-family integration.
216    if !spec.boot_files.is_empty() {
217        src_uri.push("file://boot".to_string());
218        guidance.push(
219            "# Bootloader input: dist/boot/ carries the kernel-command-line files\n\
220             #   (boot.cmd -> boot.scr for i.MX, or extlinux-uio.cfg for STM32). Wire\n\
221             #   them into your image/u-boot recipe as dist/boot/README describes; they\n\
222             #   are NOT rootfs files."
223                .to_string(),
224        );
225    }
226
227    let src_uri = src_uri
228        .iter()
229        .map(|u| format!("    {u} \\"))
230        .collect::<Vec<_>>()
231        .join("\n");
232    let install = install.join("\n");
233    let files = files
234        .iter()
235        .map(|f| format!("    {f} \\"))
236        .collect::<Vec<_>>()
237        .join("\n");
238    // Non-rootfs artifacts (kernel/bootloader inputs) get a commented guidance
239    // block rather than a `do_install`, so the recipe stays a pure rootfs bundle.
240    let guidance = if guidance.is_empty() {
241        String::new()
242    } else {
243        format!("\n{}\n\n", guidance.join("\n#\n"))
244    };
245
246    format!(
247        "# Auto-generated by Consortium — do not edit by hand.\n\
248         #\n\
249         # Installs the assembled `dist/` tree. Copy this recipe together with the\n\
250         # sibling `dist/` subdirectories into a Yocto layer, then `bitbake {pn}`.\n\
251         {guidance}\
252         SUMMARY = \"Consortium application bundle (generated)\"\n\
253         LICENSE = \"CLOSED\"\n\
254         \n\
255         # Artifacts live next to this recipe under the staged `dist/` subdirectories.\n\
256         FILESEXTRAPATHS:prepend := \"${{THISDIR}}:\"\n\
257         \n\
258         SRC_URI = \"\\\n{src_uri}\n\"\n\
259         \n\
260         S = \"${{WORKDIR}}\"\n\
261         \n\
262         inherit systemd\n\
263         SYSTEMD_SERVICE:${{PN}} = \"{unit}\"\n\
264         \n\
265         do_install() {{\n{install}\n}}\n\
266         \n\
267         FILES:${{PN}} += \"\\\n{files}\n\"\n",
268        pn = spec.recipe_name,
269        unit = spec.systemd_unit,
270    )
271}
272
273/// Renders and writes `spec`'s recipe to `<dist_root>/<recipe_name>.bb`,
274/// returning the written path. Placing it at the `dist/` root lets the recipe's
275/// `${THISDIR}`-relative `SRC_URI` reach the staged artifact subdirectories.
276pub async fn write_recipe(dist_root: &Path, spec: &RecipeSpec) -> Result<PathBuf, BitbakeError> {
277    let path = dist_root.join(format!("{}.bb", spec.recipe_name));
278    tokio::fs::write(&path, render_recipe(spec))
279        .await
280        .map_err(|source| BitbakeError::RecipeWrite {
281            path: path.display().to_string(),
282            source,
283        })?;
284    Ok(path)
285}
286
287/// Executes `action` against the assembled `dist_root`.
288pub async fn run(action: &BitbakeAction, dist_root: &Path) -> Result<(), BitbakeError> {
289    run_with_reporter(action, dist_root, None, None).await
290}
291
292/// Executes an action against the assembled dist root, streaming command output.
293///
294/// `recipe` is the install manifest used to emit a generated `.bb` on a dry-run
295/// invoke; it is ignored for a real invoke or a copy-into-recipe action.
296pub async fn run_with_reporter(
297    action: &BitbakeAction,
298    dist_root: &Path,
299    recipe: Option<&RecipeSpec>,
300    reporter: Option<&dyn BuildReporter>,
301) -> Result<(), BitbakeError> {
302    match action {
303        BitbakeAction::CopyIntoRecipe { recipe_files_dir } => {
304            copy_dir_recursive(dist_root, recipe_files_dir).await?;
305            Ok(())
306        }
307        BitbakeAction::InvokeBitbake {
308            build_dir,
309            recipe: recipe_name,
310            dry_run,
311        } => {
312            if *dry_run {
313                // Emit the recipe and report the deferred command; the actual
314                // build runs later, on the (possibly separate) Yocto host.
315                if let Some(spec) = recipe {
316                    let path = write_recipe(dist_root, spec).await?;
317                    if let Some(reporter) = reporter {
318                        reporter.command_output(&format!(
319                            "[dry-run] wrote recipe {}\n",
320                            path.display()
321                        ));
322                    }
323                }
324                if let Some(reporter) = reporter {
325                    reporter.command_output(&format!(
326                        "[dry-run] skipped `bitbake {recipe_name}` in {}\n",
327                        build_dir.display()
328                    ));
329                }
330                return Ok(());
331            }
332            invoke_bitbake_with("bitbake", build_dir, recipe_name, reporter).await
333        }
334    }
335}
336
337/// Invokes `program <recipe>` in `build_dir`. The `program` seam lets tests drive
338/// the not-found path deterministically without touching the process PATH.
339async fn invoke_bitbake_with(
340    program: &str,
341    build_dir: &Path,
342    recipe: &str,
343    reporter: Option<&dyn BuildReporter>,
344) -> Result<(), BitbakeError> {
345    let mut cmd = Command::new(program);
346    cmd.arg(recipe).current_dir(build_dir);
347
348    let status = run_command(&mut cmd, reporter)
349        .await
350        .map_err(|_| BitbakeError::BitbakeNotFound)?;
351
352    if !status.success() {
353        return Err(BitbakeError::Failed(format!(
354            "`{program} {recipe}` exited with status: {status}"
355        )));
356    }
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn scratch(tag: &str) -> PathBuf {
365        let dir =
366            std::env::temp_dir().join(format!("consortium-bitbake-{tag}-{}", std::process::id()));
367        let _ = std::fs::remove_dir_all(&dir);
368        std::fs::create_dir_all(&dir).expect("create scratch");
369        dir
370    }
371
372    #[tokio::test]
373    async fn copy_into_recipe_stages_dist_tree() {
374        let base = scratch("copy");
375        let dist = base.join("dist");
376        std::fs::create_dir_all(dist.join("app")).unwrap();
377        std::fs::write(dist.join("app").join("bin"), b"binary").unwrap();
378
379        let recipe_files = base.join("recipe/files");
380        run(
381            &BitbakeAction::CopyIntoRecipe {
382                recipe_files_dir: recipe_files.clone(),
383            },
384            &dist,
385        )
386        .await
387        .expect("copy succeeds");
388
389        assert_eq!(
390            std::fs::read_to_string(recipe_files.join("app").join("bin")).unwrap(),
391            "binary"
392        );
393        let _ = std::fs::remove_dir_all(&base);
394    }
395
396    #[tokio::test]
397    async fn invoke_bitbake_reports_not_found() {
398        let base = scratch("notfound");
399        let err = invoke_bitbake_with("consortium-not-a-real-bitbake", &base, "image", None)
400            .await
401            .expect_err("missing bitbake is an error");
402        assert!(matches!(err, BitbakeError::BitbakeNotFound));
403        let _ = std::fs::remove_dir_all(&base);
404    }
405
406    /// A recording reporter that captures `command_output` calls.
407    #[derive(Default)]
408    struct CapturingReporter {
409        output: std::sync::Mutex<String>,
410    }
411
412    impl BuildReporter for CapturingReporter {
413        fn step_started(&self, _step: crate::progress::BuildStep<'_>) {}
414        fn step_succeeded(&self, _step: crate::progress::BuildStep<'_>) {}
415        fn step_failed(&self, _step: crate::progress::BuildStep<'_>) {}
416        fn command_output(&self, output: &str) {
417            self.output.lock().unwrap().push_str(output);
418        }
419    }
420
421    #[tokio::test]
422    async fn dry_run_reports_command_without_invoking_bitbake() {
423        let base = scratch("dryrun");
424        let reporter = CapturingReporter::default();
425        // The build_dir need not contain bitbake; a dry run must not spawn it.
426        run_with_reporter(
427            &BitbakeAction::InvokeBitbake {
428                build_dir: base.join("yocto-build"),
429                recipe: "consortium-image".to_string(),
430                dry_run: true,
431            },
432            &base,
433            None,
434            Some(&reporter),
435        )
436        .await
437        .expect("dry run succeeds without bitbake on PATH");
438
439        let out = reporter.output.lock().unwrap().clone();
440        assert!(out.contains("[dry-run]"), "output was: {out}");
441        assert!(
442            out.contains("bitbake consortium-image"),
443            "output was: {out}"
444        );
445        let _ = std::fs::remove_dir_all(&base);
446    }
447
448    fn full_spec() -> RecipeSpec {
449        RecipeSpec {
450            recipe_name: "meta-consortium".to_string(),
451            deploy_root: "/opt/consortium".to_string(),
452            app_bin: "melt-pot-app".to_string(),
453            firmware: vec!["cm33.elf".to_string()],
454            ta: Some("uuid.ta".to_string()),
455            has_www: true,
456            systemd_unit: "consortium-app.service".to_string(),
457            dts_files: vec!["consortium-uio.dtso".to_string()],
458            boot_files: vec!["boot.cmd".to_string(), "README".to_string()],
459        }
460    }
461
462    #[test]
463    fn render_recipe_covers_every_artifact() {
464        let recipe = render_recipe(&full_spec());
465        // Install targets for each staged artifact class.
466        assert!(recipe.contains("${D}/opt/consortium/app/melt-pot-app"));
467        assert!(recipe.contains("${nonarch_base_libdir}/firmware/cm33.elf"));
468        assert!(recipe.contains("${systemd_system_unitdir}/consortium-app.service"));
469        assert!(recipe.contains("${D}/opt/consortium/www"));
470        assert!(recipe.contains("${nonarch_base_libdir}/optee_armtz/uuid.ta"));
471        // A `.dtso` overlay is installed into the rootfs (runtime configfs).
472        assert!(recipe.contains("${D}/opt/consortium/dts/consortium-uio.dtso"));
473        // Boot files are staged but not installed; a guidance comment explains them.
474        assert!(recipe.contains("file://boot"));
475        assert!(recipe.contains("Bootloader input"));
476        assert!(!recipe.contains("/opt/consortium/boot"));
477        // The kernel config fragment is a build-time input, never installed to
478        // the rootfs by this app recipe.
479        assert!(!recipe.contains("/opt/consortium/kernel"));
480        // SRC_URI resolves relative to the recipe via THISDIR.
481        assert!(recipe.contains("FILESEXTRAPATHS:prepend := \"${THISDIR}:\""));
482        assert!(recipe.contains("file://firmware/cm33.elf"));
483        // systemd registration.
484        assert!(recipe.contains("inherit systemd"));
485        assert!(recipe.contains("SYSTEMD_SERVICE:${PN} = \"consortium-app.service\""));
486    }
487
488    #[test]
489    fn render_recipe_merged_dts_is_a_kernel_input_not_installed() {
490        let spec = RecipeSpec {
491            dts_files: vec!["consortium-imx95-19x19-evk.dts".to_string()],
492            boot_files: Vec::new(),
493            ..full_spec()
494        };
495        let recipe = render_recipe(&spec);
496        // A merged `.dts` is staged but not installed into the rootfs.
497        assert!(recipe.contains("file://dts"));
498        assert!(recipe.contains("Kernel input"));
499        assert!(recipe.contains("KERNEL_DEVICETREE"));
500        assert!(!recipe.contains("${D}/opt/consortium/dts"));
501    }
502
503    #[test]
504    fn render_recipe_omits_absent_optionals() {
505        let spec = RecipeSpec {
506            firmware: Vec::new(),
507            ta: None,
508            has_www: false,
509            dts_files: Vec::new(),
510            boot_files: Vec::new(),
511            ..full_spec()
512        };
513        let recipe = render_recipe(&spec);
514        assert!(recipe.contains("/opt/consortium/app/melt-pot-app"));
515        assert!(!recipe.contains("firmware"));
516        assert!(!recipe.contains("optee_armtz"));
517        assert!(!recipe.contains("www"));
518        assert!(!recipe.contains("/dts"));
519        assert!(!recipe.contains("file://boot"));
520    }
521
522    #[tokio::test]
523    async fn dry_run_writes_recipe_and_skips_bitbake() {
524        let base = scratch("recipe");
525        let reporter = CapturingReporter::default();
526        let spec = full_spec();
527
528        run_with_reporter(
529            &BitbakeAction::InvokeBitbake {
530                build_dir: base.join("yocto-build"),
531                recipe: "meta-consortium".to_string(),
532                dry_run: true,
533            },
534            &base,
535            Some(&spec),
536            Some(&reporter),
537        )
538        .await
539        .expect("dry run writes recipe without bitbake");
540
541        // The recipe lands at the dist root, next to the staged subdirectories.
542        let bb = base.join("meta-consortium.bb");
543        assert!(bb.exists(), "recipe should be written");
544        assert_eq!(
545            std::fs::read_to_string(&bb).unwrap(),
546            render_recipe(&spec),
547            "written recipe matches the renderer"
548        );
549
550        let out = reporter.output.lock().unwrap().clone();
551        assert!(out.contains("wrote recipe"), "output was: {out}");
552        assert!(out.contains("skipped `bitbake"), "output was: {out}");
553        let _ = std::fs::remove_dir_all(&base);
554    }
555}