consortium_builder/pipeline/
bitbake.rs1use 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#[derive(Debug, Clone)]
35pub enum BitbakeAction {
36 CopyIntoRecipe {
38 recipe_files_dir: PathBuf,
40 },
41 InvokeBitbake {
43 build_dir: PathBuf,
45 recipe: String,
47 dry_run: bool,
54 },
55}
56
57#[derive(Error, Debug)]
59pub enum BitbakeError {
60 #[error(transparent)]
62 Copy(#[from] DistAssemblyError),
63
64 #[error("`bitbake` not found on PATH; source a Yocto build environment first")]
66 BitbakeNotFound,
67
68 #[error("bitbake failed: {0}")]
70 Failed(String),
71
72 #[error("failed to write generated recipe {path}: {source}")]
74 RecipeWrite {
75 path: String,
77 source: std::io::Error,
79 },
80}
81
82#[derive(Debug, Clone)]
89pub struct RecipeSpec {
90 pub recipe_name: String,
92 pub deploy_root: String,
94 pub app_bin: String,
96 pub firmware: Vec<String>,
98 pub ta: Option<String>,
100 pub has_www: bool,
102 pub systemd_unit: String,
104 pub dts_files: Vec<String>,
108 pub boot_files: Vec<String>,
112}
113
114pub 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 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 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 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 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 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 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 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 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
273pub 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
287pub async fn run(action: &BitbakeAction, dist_root: &Path) -> Result<(), BitbakeError> {
289 run_with_reporter(action, dist_root, None, None).await
290}
291
292pub 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 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
337async 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 #[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 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 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 assert!(recipe.contains("${D}/opt/consortium/dts/consortium-uio.dtso"));
473 assert!(recipe.contains("file://boot"));
475 assert!(recipe.contains("Bootloader input"));
476 assert!(!recipe.contains("/opt/consortium/boot"));
477 assert!(!recipe.contains("/opt/consortium/kernel"));
480 assert!(recipe.contains("FILESEXTRAPATHS:prepend := \"${THISDIR}:\""));
482 assert!(recipe.contains("file://firmware/cm33.elf"));
483 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 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 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}