use std::path::{Path, PathBuf};
use thiserror::Error;
use tokio::process::Command;
use crate::compile::utils::run_command;
use crate::progress::BuildReporter;
use super::dist::{DistAssemblyError, copy_dir_recursive};
#[derive(Debug, Clone)]
pub enum BitbakeAction {
CopyIntoRecipe {
recipe_files_dir: PathBuf,
},
InvokeBitbake {
build_dir: PathBuf,
recipe: String,
dry_run: bool,
},
}
#[derive(Error, Debug)]
pub enum BitbakeError {
#[error(transparent)]
Copy(#[from] DistAssemblyError),
#[error("`bitbake` not found on PATH; source a Yocto build environment first")]
BitbakeNotFound,
#[error("bitbake failed: {0}")]
Failed(String),
#[error("failed to write generated recipe {path}: {source}")]
RecipeWrite {
path: String,
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct RecipeSpec {
pub recipe_name: String,
pub deploy_root: String,
pub app_bin: String,
pub firmware: Vec<String>,
pub ta: Option<String>,
pub has_www: bool,
pub systemd_unit: String,
pub dts_files: Vec<String>,
pub boot_files: Vec<String>,
}
pub fn render_recipe(spec: &RecipeSpec) -> String {
let deploy = spec.deploy_root.trim_end_matches('/');
let mut src_uri: Vec<String> = Vec::new();
let mut install: Vec<String> = Vec::new();
let mut files: Vec<String> = Vec::new();
src_uri.push(format!("file://app/{}", spec.app_bin));
install.push(format!(" install -d ${{D}}{deploy}/app"));
install.push(format!(
" install -m 0755 ${{WORKDIR}}/app/{bin} ${{D}}{deploy}/app/{bin}",
bin = spec.app_bin
));
files.push(format!("{deploy}/app"));
if !spec.firmware.is_empty() {
install.push(" install -d ${D}${nonarch_base_libdir}/firmware".to_string());
for fw in &spec.firmware {
src_uri.push(format!("file://firmware/{fw}"));
install.push(format!(
" install -m 0644 ${{WORKDIR}}/firmware/{fw} \
${{D}}${{nonarch_base_libdir}}/firmware/{fw}"
));
}
files.push("${nonarch_base_libdir}/firmware".to_string());
}
src_uri.push(format!("file://systemd/{}", spec.systemd_unit));
install.push(" install -d ${D}${systemd_system_unitdir}".to_string());
install.push(format!(
" install -m 0644 ${{WORKDIR}}/systemd/{unit} ${{D}}${{systemd_system_unitdir}}/{unit}",
unit = spec.systemd_unit
));
files.push(format!("${{systemd_system_unitdir}}/{}", spec.systemd_unit));
if spec.has_www {
src_uri.push("file://www".to_string());
install.push(format!(" install -d ${{D}}{deploy}/www"));
install.push(format!(" cp -R ${{WORKDIR}}/www/. ${{D}}{deploy}/www/"));
files.push(format!("{deploy}/www"));
}
if let Some(ta) = &spec.ta {
src_uri.push(format!("file://ta/{ta}"));
install.push(" install -d ${D}${nonarch_base_libdir}/optee_armtz".to_string());
install.push(format!(
" install -m 0444 ${{WORKDIR}}/ta/{ta} ${{D}}${{nonarch_base_libdir}}/optee_armtz/{ta}"
));
files.push("${nonarch_base_libdir}/optee_armtz".to_string());
}
let mut guidance: Vec<String> = Vec::new();
if !spec.dts_files.is_empty() {
src_uri.push("file://dts".to_string());
let overlays: Vec<&String> = spec
.dts_files
.iter()
.filter(|n| n.ends_with(".dtso"))
.collect();
let merged: Vec<&String> = spec
.dts_files
.iter()
.filter(|n| n.ends_with(".dts"))
.collect();
if !overlays.is_empty() {
install.push(format!(" install -d ${{D}}{deploy}/dts"));
for name in &overlays {
install.push(format!(
" install -m 0644 ${{WORKDIR}}/dts/linux/{name} ${{D}}{deploy}/dts/{name}"
));
}
files.push(format!("{deploy}/dts"));
}
for name in &merged {
guidance.push(format!(
"# Kernel input: dts/linux/{name} is a full board device tree. Add it to\n\
# your kernel recipe (e.g. copy into the kernel source and extend\n\
# KERNEL_DEVICETREE), then boot that .dtb. It is NOT a rootfs file."
));
}
}
if !spec.boot_files.is_empty() {
src_uri.push("file://boot".to_string());
guidance.push(
"# Bootloader input: dist/boot/ carries the kernel-command-line files\n\
# (boot.cmd -> boot.scr for i.MX, or extlinux-uio.cfg for STM32). Wire\n\
# them into your image/u-boot recipe as dist/boot/README describes; they\n\
# are NOT rootfs files."
.to_string(),
);
}
let src_uri = src_uri
.iter()
.map(|u| format!(" {u} \\"))
.collect::<Vec<_>>()
.join("\n");
let install = install.join("\n");
let files = files
.iter()
.map(|f| format!(" {f} \\"))
.collect::<Vec<_>>()
.join("\n");
let guidance = if guidance.is_empty() {
String::new()
} else {
format!("\n{}\n\n", guidance.join("\n#\n"))
};
format!(
"# Auto-generated by Consortium — do not edit by hand.\n\
#\n\
# Installs the assembled `dist/` tree. Copy this recipe together with the\n\
# sibling `dist/` subdirectories into a Yocto layer, then `bitbake {pn}`.\n\
{guidance}\
SUMMARY = \"Consortium application bundle (generated)\"\n\
LICENSE = \"CLOSED\"\n\
\n\
# Artifacts live next to this recipe under the staged `dist/` subdirectories.\n\
FILESEXTRAPATHS:prepend := \"${{THISDIR}}:\"\n\
\n\
SRC_URI = \"\\\n{src_uri}\n\"\n\
\n\
S = \"${{WORKDIR}}\"\n\
\n\
inherit systemd\n\
SYSTEMD_SERVICE:${{PN}} = \"{unit}\"\n\
\n\
do_install() {{\n{install}\n}}\n\
\n\
FILES:${{PN}} += \"\\\n{files}\n\"\n",
pn = spec.recipe_name,
unit = spec.systemd_unit,
)
}
pub async fn write_recipe(dist_root: &Path, spec: &RecipeSpec) -> Result<PathBuf, BitbakeError> {
let path = dist_root.join(format!("{}.bb", spec.recipe_name));
tokio::fs::write(&path, render_recipe(spec))
.await
.map_err(|source| BitbakeError::RecipeWrite {
path: path.display().to_string(),
source,
})?;
Ok(path)
}
pub async fn run(action: &BitbakeAction, dist_root: &Path) -> Result<(), BitbakeError> {
run_with_reporter(action, dist_root, None, None).await
}
pub async fn run_with_reporter(
action: &BitbakeAction,
dist_root: &Path,
recipe: Option<&RecipeSpec>,
reporter: Option<&dyn BuildReporter>,
) -> Result<(), BitbakeError> {
match action {
BitbakeAction::CopyIntoRecipe { recipe_files_dir } => {
copy_dir_recursive(dist_root, recipe_files_dir).await?;
Ok(())
}
BitbakeAction::InvokeBitbake {
build_dir,
recipe: recipe_name,
dry_run,
} => {
if *dry_run {
if let Some(spec) = recipe {
let path = write_recipe(dist_root, spec).await?;
if let Some(reporter) = reporter {
reporter.command_output(&format!(
"[dry-run] wrote recipe {}\n",
path.display()
));
}
}
if let Some(reporter) = reporter {
reporter.command_output(&format!(
"[dry-run] skipped `bitbake {recipe_name}` in {}\n",
build_dir.display()
));
}
return Ok(());
}
invoke_bitbake_with("bitbake", build_dir, recipe_name, reporter).await
}
}
}
async fn invoke_bitbake_with(
program: &str,
build_dir: &Path,
recipe: &str,
reporter: Option<&dyn BuildReporter>,
) -> Result<(), BitbakeError> {
let mut cmd = Command::new(program);
cmd.arg(recipe).current_dir(build_dir);
let status = run_command(&mut cmd, reporter)
.await
.map_err(|_| BitbakeError::BitbakeNotFound)?;
if !status.success() {
return Err(BitbakeError::Failed(format!(
"`{program} {recipe}` exited with status: {status}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("consortium-bitbake-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch");
dir
}
#[tokio::test]
async fn copy_into_recipe_stages_dist_tree() {
let base = scratch("copy");
let dist = base.join("dist");
std::fs::create_dir_all(dist.join("app")).unwrap();
std::fs::write(dist.join("app").join("bin"), b"binary").unwrap();
let recipe_files = base.join("recipe/files");
run(
&BitbakeAction::CopyIntoRecipe {
recipe_files_dir: recipe_files.clone(),
},
&dist,
)
.await
.expect("copy succeeds");
assert_eq!(
std::fs::read_to_string(recipe_files.join("app").join("bin")).unwrap(),
"binary"
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn invoke_bitbake_reports_not_found() {
let base = scratch("notfound");
let err = invoke_bitbake_with("consortium-not-a-real-bitbake", &base, "image", None)
.await
.expect_err("missing bitbake is an error");
assert!(matches!(err, BitbakeError::BitbakeNotFound));
let _ = std::fs::remove_dir_all(&base);
}
#[derive(Default)]
struct CapturingReporter {
output: std::sync::Mutex<String>,
}
impl BuildReporter for CapturingReporter {
fn step_started(&self, _step: crate::progress::BuildStep<'_>) {}
fn step_succeeded(&self, _step: crate::progress::BuildStep<'_>) {}
fn step_failed(&self, _step: crate::progress::BuildStep<'_>) {}
fn command_output(&self, output: &str) {
self.output.lock().unwrap().push_str(output);
}
}
#[tokio::test]
async fn dry_run_reports_command_without_invoking_bitbake() {
let base = scratch("dryrun");
let reporter = CapturingReporter::default();
run_with_reporter(
&BitbakeAction::InvokeBitbake {
build_dir: base.join("yocto-build"),
recipe: "consortium-image".to_string(),
dry_run: true,
},
&base,
None,
Some(&reporter),
)
.await
.expect("dry run succeeds without bitbake on PATH");
let out = reporter.output.lock().unwrap().clone();
assert!(out.contains("[dry-run]"), "output was: {out}");
assert!(
out.contains("bitbake consortium-image"),
"output was: {out}"
);
let _ = std::fs::remove_dir_all(&base);
}
fn full_spec() -> RecipeSpec {
RecipeSpec {
recipe_name: "meta-consortium".to_string(),
deploy_root: "/opt/consortium".to_string(),
app_bin: "melt-pot-app".to_string(),
firmware: vec!["cm33.elf".to_string()],
ta: Some("uuid.ta".to_string()),
has_www: true,
systemd_unit: "consortium-app.service".to_string(),
dts_files: vec!["consortium-uio.dtso".to_string()],
boot_files: vec!["boot.cmd".to_string(), "README".to_string()],
}
}
#[test]
fn render_recipe_covers_every_artifact() {
let recipe = render_recipe(&full_spec());
assert!(recipe.contains("${D}/opt/consortium/app/melt-pot-app"));
assert!(recipe.contains("${nonarch_base_libdir}/firmware/cm33.elf"));
assert!(recipe.contains("${systemd_system_unitdir}/consortium-app.service"));
assert!(recipe.contains("${D}/opt/consortium/www"));
assert!(recipe.contains("${nonarch_base_libdir}/optee_armtz/uuid.ta"));
assert!(recipe.contains("${D}/opt/consortium/dts/consortium-uio.dtso"));
assert!(recipe.contains("file://boot"));
assert!(recipe.contains("Bootloader input"));
assert!(!recipe.contains("/opt/consortium/boot"));
assert!(!recipe.contains("/opt/consortium/kernel"));
assert!(recipe.contains("FILESEXTRAPATHS:prepend := \"${THISDIR}:\""));
assert!(recipe.contains("file://firmware/cm33.elf"));
assert!(recipe.contains("inherit systemd"));
assert!(recipe.contains("SYSTEMD_SERVICE:${PN} = \"consortium-app.service\""));
}
#[test]
fn render_recipe_merged_dts_is_a_kernel_input_not_installed() {
let spec = RecipeSpec {
dts_files: vec!["consortium-imx95-19x19-evk.dts".to_string()],
boot_files: Vec::new(),
..full_spec()
};
let recipe = render_recipe(&spec);
assert!(recipe.contains("file://dts"));
assert!(recipe.contains("Kernel input"));
assert!(recipe.contains("KERNEL_DEVICETREE"));
assert!(!recipe.contains("${D}/opt/consortium/dts"));
}
#[test]
fn render_recipe_omits_absent_optionals() {
let spec = RecipeSpec {
firmware: Vec::new(),
ta: None,
has_www: false,
dts_files: Vec::new(),
boot_files: Vec::new(),
..full_spec()
};
let recipe = render_recipe(&spec);
assert!(recipe.contains("/opt/consortium/app/melt-pot-app"));
assert!(!recipe.contains("firmware"));
assert!(!recipe.contains("optee_armtz"));
assert!(!recipe.contains("www"));
assert!(!recipe.contains("/dts"));
assert!(!recipe.contains("file://boot"));
}
#[tokio::test]
async fn dry_run_writes_recipe_and_skips_bitbake() {
let base = scratch("recipe");
let reporter = CapturingReporter::default();
let spec = full_spec();
run_with_reporter(
&BitbakeAction::InvokeBitbake {
build_dir: base.join("yocto-build"),
recipe: "meta-consortium".to_string(),
dry_run: true,
},
&base,
Some(&spec),
Some(&reporter),
)
.await
.expect("dry run writes recipe without bitbake");
let bb = base.join("meta-consortium.bb");
assert!(bb.exists(), "recipe should be written");
assert_eq!(
std::fs::read_to_string(&bb).unwrap(),
render_recipe(&spec),
"written recipe matches the renderer"
);
let out = reporter.output.lock().unwrap().clone();
assert!(out.contains("wrote recipe"), "output was: {out}");
assert!(out.contains("skipped `bitbake"), "output was: {out}");
let _ = std::fs::remove_dir_all(&base);
}
}