From ac2f801e75070cc7078fe5a5edb0b73b74c27d6c Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 17 Jul 2026 15:08:07 -0600 Subject: [PATCH 1/2] runtime/build: compile and deliver declared device-tree overlays Shipping a custom device-tree overlay previously meant forking the BSP: the CLI had no notion of one, so a .dtbo could not be compiled from the project or routed onto the boot medium. Add a declarative path. An extension declares its overlays under `device_tree_overlays`, and the runtime build compiles each one, stages it, hands off to the board's delivery hook, and fails the build if anything went undelivered. Placement is at the extension level, alongside the sibling `overlay` key, because a runtime's `packages` list is frequently a bare list of names with nowhere to hang per-overlay objects. The name is a validated basename because it is reused verbatim as the .dtbo filename and the boot-loader argument. Compilation is generated into the in-container build script rather than run in Rust: the dtc/cpp toolchain, the kernel dt-bindings, and the delivery hook all live in the SDK container. The script calls the nativesdk-avocado-dtc-overlay wrapper per overlay, writes an overlays.manifest.json, runs the single fixed-path per-BSP hook ($OECORE_TARGET_SYSROOT/usr/libexec/avocado/device-tree-overlay-deliver), and passes the hook-emitted stone fragment through `stone bundle --overlay`. Two failure modes are hard errors, not silent successes: overlays declared with no hook installed, and overlays staged but left unclaimed by the hook. Fold the declarations and each .dtso's content hash into the runtime-build input hash. Overlays are compiled at runtime-build time, so without this an added or edited overlay would leave the stamp valid and ship the previous bundle unchanged. Signed-off-by: Javier Tia --- src/commands/runtime/build.rs | 70 +++++ src/utils/device_tree_overlay.rs | 492 +++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + src/utils/stamps.rs | 114 +++++++ 4 files changed, 677 insertions(+) create mode 100644 src/utils/device_tree_overlay.rs diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 903813c..b70693d 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1198,6 +1198,14 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ ) })?; + // Device-tree overlays declared by this runtime's extensions (ENG-2134). + // Empty section - and no build-time work - unless at least one is declared. + let device_tree_overlay_section = { + let overlays = + crate::utils::device_tree_overlay::collect_for_runtime(&merged_runtime, parsed)?; + crate::utils::device_tree_overlay::render_build_section(&overlays)? + }; + // Extract extension names from the `extensions` array let mut required_extensions = HashSet::new(); let _extension_type_overrides: HashMap> = HashMap::new(); @@ -2411,6 +2419,8 @@ if [ -n "${{AVOCADO_STONE_INCLUDE_PATHS:-}}" ]; then fi STONE_INCLUDE_FLAGS="$STONE_INCLUDE_FLAGS -i $STONE_INPUT_DIR" +STONE_OVERLAY_FLAG="" +{device_tree_overlay_section} echo -e "\033[94m[INFO]\033[0m Running stone bundle." echo -e " Manifest: $STONE_MANIFEST" echo -e " Output: $STONE_AOS_OUTPUT" @@ -2427,6 +2437,7 @@ stone bundle \ $STONE_INITRD_FLAG \ -m "$STONE_MANIFEST" \ $STONE_INCLUDE_FLAGS \ + $STONE_OVERLAY_FLAG \ --partition-size "var=$FINAL_SIZE" \ -o "$STONE_AOS_OUTPUT" \ --build-dir "$STONE_BUILD_DIR" @@ -2528,6 +2539,7 @@ sign_amf "$AVOCADO_MANIFEST_PATH" manifest_section = manifest_section, update_authority_section = update_authority_section, docker_section = docker_section, + device_tree_overlay_section = device_tree_overlay_section, ); Ok(script) @@ -3041,6 +3053,9 @@ runtimes: assert!(script.contains("VAR_DIR=$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/var-staging")); assert!(script.contains("stone bundle")); assert!(script.contains("mkfs.btrfs")); + // No overlays declared: the device-tree overlay section is inert. + assert!(!script.contains("device-tree overlays (ENG-2134)")); + assert!(script.contains("STONE_OVERLAY_FLAG=\"\"")); } #[test] @@ -3088,6 +3103,61 @@ extensions: assert!(!script.contains("$VAR_DIR/lib/avocado/extensions/")); } + #[test] + fn test_create_build_script_with_device_tree_overlays() { + let temp_dir = TempDir::new().unwrap(); + let config_content = r#" +sdk: + image: "test-image" + +connect: + org: test + +runtimes: + test-runtime: + target: "x86_64" + extensions: + - test-ext + +extensions: + test-ext: + version: "1.0.0" + types: + - sysext + device_tree_overlays: + - name: my-spi + src: overlays/my-spi.dtso +"#; + let config_path = create_test_config_file(&temp_dir, config_content); + let parsed: serde_yaml::Value = serde_yaml::from_str(config_content).unwrap(); + let cmd = RuntimeBuildCommand::new( + "test-runtime".to_string(), + config_path, + false, + Some("x86_64".to_string()), + None, + None, + ); + + let config = Config::load(&cmd.config_path).unwrap(); + let resolved_extensions = vec!["test-ext-1.0.0".to_string()]; + let script = cmd + .create_build_script(&config, &parsed, "x86_64", &resolved_extensions) + .unwrap(); + + // The overlay section is emitted, builds the declared overlay via the + // SDK wrapper, and wires the delivery hook + stone --overlay before the + // bundle call. + assert!(script.contains("device-tree overlays (ENG-2134)")); + assert!(script.contains( + "avocado-dtc-overlay --name \"my-spi\" --src \"/opt/src/overlays/my-spi.dtso\"" + )); + assert!(script.contains("device-tree-overlay-deliver")); + assert!(script.contains("STONE_OVERLAY_FLAG=\"--overlay $DTO_FRAGMENT\"")); + // And the bundle call consumes the flag. + assert!(script.contains("$STONE_OVERLAY_FLAG \\")); + } + #[test] fn test_create_build_script_with_extension_types() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/utils/device_tree_overlay.rs b/src/utils/device_tree_overlay.rs new file mode 100644 index 0000000..eca44b5 --- /dev/null +++ b/src/utils/device_tree_overlay.rs @@ -0,0 +1,492 @@ +//! Collection and validation of device-tree overlay declarations. +//! +//! An extension declares the device-tree overlays it ships under +//! `extensions..device_tree_overlays`, each entry an object: +//! +//! ```yaml +//! ext: +//! my-board: +//! device_tree_overlays: +//! - name: my-spi +//! src: overlays/my-spi.dtso +//! params: +//! speed: "12000000" +//! ``` +//! +//! `name` is authoritative: it is the stone `out` basename +//! (`overlays/.dtbo`), the RPi `config.txt` `dtoverlay=` argument, +//! and the u-boot overlay entry. It must therefore be a safe basename. This is +//! deliberately NOT the pre-existing filesystem `overlay` key (see +//! `overlay_preprocess`), which copies files into an image; a device-tree +//! overlay is compiled and delivered to the boot medium. + +use anyhow::{anyhow, bail, Result}; +use serde_yaml::Value; +use std::collections::BTreeMap; + +/// One device-tree overlay declared by an extension enabled on a runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceTreeOverlay { + /// Authoritative basename used for the .dtbo, the config.txt argument, and + /// the u-boot entry. + pub name: String, + /// Source `.dtso`/`.dts`, relative to the project root (bind-mounted at + /// `/opt/src` in the SDK container). + pub src: String, + /// Optional per-overlay parameters (e.g. `dtoverlay=name,key=value`). + pub params: BTreeMap, + /// The extension that declared this overlay. + pub ext_name: String, +} + +/// Collect the device-tree overlays declared by every extension enabled on the +/// runtime, in declaration order. `merged_runtime` is the resolved runtime +/// value (its `extensions` sequence lists the enabled extensions); `parsed` is +/// the whole config (it holds `extensions..device_tree_overlays`). +/// +/// Names must be unique across the whole runtime: two overlays sharing a name +/// would collide on the same `overlays/.dtbo` output and boot-selection +/// argument, so a duplicate is a hard error rather than a silent +/// last-one-wins. +pub fn collect_for_runtime( + merged_runtime: &Value, + parsed: &Value, +) -> Result> { + let mut out: Vec = Vec::new(); + let mut origin: BTreeMap = BTreeMap::new(); + + let Some(ext_list) = merged_runtime + .get("extensions") + .and_then(|e| e.as_sequence()) + else { + return Ok(out); + }; + + for ext_val in ext_list { + let Some(spec) = + crate::utils::runtime_extension::RuntimeExtensionSpec::parse_entry(ext_val) + else { + continue; + }; + let ext_name = spec.name.as_str(); + + let Some(decls) = parsed + .get("extensions") + .and_then(|e| e.get(ext_name)) + .and_then(|e| e.get("device_tree_overlays")) + else { + continue; + }; + + let seq = decls.as_sequence().ok_or_else(|| { + anyhow!("extension '{ext_name}': device_tree_overlays must be a list") + })?; + + for (idx, entry) in seq.iter().enumerate() { + let overlay = parse_entry(entry, ext_name, idx)?; + if let Some(prev) = origin.insert(overlay.name.clone(), ext_name.to_string()) { + bail!( + "device-tree overlay name '{}' is declared by both '{}' and '{}'; \ + names must be unique across the runtime", + overlay.name, + prev, + ext_name + ); + } + out.push(overlay); + } + } + + Ok(out) +} + +fn parse_entry(entry: &Value, ext_name: &str, idx: usize) -> Result { + let name = entry + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow!("extension '{ext_name}': device_tree_overlays[{idx}] missing 'name'") + })? + .to_string(); + validate_name(&name, ext_name)?; + + let src = entry + .get("src") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + anyhow!( + "extension '{ext_name}': device-tree overlay '{name}' missing a non-empty 'src'" + ) + })? + .to_string(); + + let params = parse_params(entry.get("params"), ext_name, &name)?; + + Ok(DeviceTreeOverlay { + name, + src, + params, + ext_name: ext_name.to_string(), + }) +} + +/// A name is used verbatim as a filename and a boot-loader argument, so it must +/// be a safe basename: no path separators, no whitespace, not a directory dot. +fn validate_name(name: &str, ext_name: &str) -> Result<()> { + if name.is_empty() { + bail!("extension '{ext_name}': device-tree overlay name must not be empty"); + } + if name == "." || name == ".." || name.contains('/') || name.contains(char::is_whitespace) { + bail!( + "extension '{ext_name}': device-tree overlay name '{name}' is not a valid basename \ + (no '/', whitespace, '.' or '..')" + ); + } + Ok(()) +} + +fn parse_params( + value: Option<&Value>, + ext_name: &str, + name: &str, +) -> Result> { + let mut params = BTreeMap::new(); + let Some(value) = value else { + return Ok(params); + }; + let mapping = value.as_mapping().ok_or_else(|| { + anyhow!("extension '{ext_name}': device-tree overlay '{name}' params must be a mapping") + })?; + for (k, v) in mapping { + let key = k.as_str().ok_or_else(|| { + anyhow!( + "extension '{ext_name}': device-tree overlay '{name}' has a non-string param key" + ) + })?; + let val = scalar_to_string(v).ok_or_else(|| { + anyhow!( + "extension '{ext_name}': device-tree overlay '{name}' param '{key}' must be a scalar" + ) + })?; + params.insert(key.to_string(), val); + } + Ok(params) +} + +fn scalar_to_string(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Bool(b) => Some(b.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// Fixed sysroot-relative path of the single per-BSP delivery hook. Each BSP +/// layer installs its own implementation here; because only one BSP's layers +/// are present in a given machine build, exactly one is ever installed. The CLI +/// runs this exact path - it never scans a directory. +pub const DELIVERY_HOOK_RELATIVE: &str = "usr/libexec/avocado/device-tree-overlay-deliver"; + +// In-container guard: fail the build if the hook staged overlays but left any +// unclaimed (declared and compiled, yet not delivered to the boot medium). +const CLAIMED_CHECK_PY: &str = r#"python3 - "$DTBO_STAGING/overlays.manifest.json" <<'AVOCADO_DTO_CHECK_EOF' +import json, sys +data = json.load(open(sys.argv[1])) +if data.get("version") != 1: + sys.exit("[ERROR] overlays.manifest.json: unsupported version") +unclaimed = [o.get("name") for o in data.get("overlays", []) if not o.get("claimed_by")] +if unclaimed: + sys.exit("[ERROR] device-tree overlays staged but not delivered by the BSP hook: " + ", ".join(unclaimed)) +AVOCADO_DTO_CHECK_EOF +"#; + +/// Build the CLI-authored `overlays.manifest.json`. `claimed_by` starts null; +/// the BSP hook sets it per overlay it delivers, and the in-container check +/// then fails on any that stayed null. +fn build_manifest_json(overlays: &[DeviceTreeOverlay]) -> Result { + let arr: Vec = overlays + .iter() + .map(|o| { + serde_json::json!({ + "name": o.name, + "file": format!("{}.dtbo", o.name), + "params": o.params, + "claimed_by": serde_json::Value::Null, + }) + }) + .collect(); + let doc = serde_json::json!({ "version": 1, "overlays": arr }); + Ok(serde_json::to_string_pretty(&doc)?) +} + +/// Render the in-container shell block that builds, stages, delivers, and +/// validates the device-tree overlays, for injection into the runtime build +/// script immediately before `stone bundle`. Empty string when there are no +/// overlays, so the feature is entirely inert unless declared. +/// +/// The block assumes `$STONE_MANIFEST` and `$STONE_INCLUDE_FLAGS` are already +/// set (they are by the time the build reaches the stone step) and leaves +/// `$STONE_INCLUDE_FLAGS` / `$STONE_OVERLAY_FLAG` updated for the bundle call. +pub fn render_build_section(overlays: &[DeviceTreeOverlay]) -> Result { + if overlays.is_empty() { + return Ok(String::new()); + } + + let manifest_json = build_manifest_json(overlays)?; + let mut s = String::new(); + + s.push_str("# --- device-tree overlays (ENG-2134) ---\n"); + s.push_str( + "DTBO_STAGING=\"$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/device-tree-overlays\"\n", + ); + s.push_str("rm -rf \"$DTBO_STAGING\"\n"); + s.push_str("mkdir -p \"$DTBO_STAGING\"\n"); + s.push_str(&format!( + "echo -e \"\\033[94m[INFO]\\033[0m Building {} device-tree overlay(s).\"\n", + overlays.len() + )); + + // name is a validated basename; src is user config, so double-quote it. + for o in overlays { + s.push_str(&format!( + "avocado-dtc-overlay --name \"{}\" --src \"/opt/src/{}\" --out \"$DTBO_STAGING/{}.dtbo\"\n", + o.name, o.src, o.name + )); + } + + s.push_str("cat > \"$DTBO_STAGING/overlays.manifest.json\" <<'AVOCADO_DTO_MANIFEST_EOF'\n"); + s.push_str(&manifest_json); + s.push_str("\nAVOCADO_DTO_MANIFEST_EOF\n"); + + s.push_str(&format!( + "DTO_HOOK=\"$OECORE_TARGET_SYSROOT/{DELIVERY_HOOK_RELATIVE}\"\n" + )); + s.push_str("DTO_FRAGMENT=\"$DTBO_STAGING/delivery.overlay.json\"\n"); + // D1: overlays declared but no hook installed for this BSP is a hard error, + // not a silent skip - otherwise the build succeeds and ships nothing. + s.push_str("if [ ! -x \"$DTO_HOOK\" ]; then\n"); + s.push_str( + " echo \"[ERROR] device-tree overlays are declared but this target provides no delivery hook.\" >&2\n", + ); + s.push_str( + " echo \" Expected an executable at $DTO_HOOK (installed by the BSP layer).\" >&2\n", + ); + s.push_str(" exit 1\n"); + s.push_str("fi\n"); + s.push_str("AVOCADO_DTBO_STAGING=\"$DTBO_STAGING\" \\\n"); + s.push_str("AVOCADO_OVERLAYS_MANIFEST=\"$DTBO_STAGING/overlays.manifest.json\" \\\n"); + s.push_str("AVOCADO_DELIVERY_FRAGMENT=\"$DTO_FRAGMENT\" \\\n"); + s.push_str("AVOCADO_STONE_MANIFEST=\"$STONE_MANIFEST\" \\\n"); + s.push_str(" \"$DTO_HOOK\"\n"); + + s.push_str(CLAIMED_CHECK_PY); + + // D2 (2c): pass the hook-emitted fragment to stone via --overlay, and add + // the staging dir to the -i include path so `in: .dtbo` resolves. + s.push_str("STONE_INCLUDE_FLAGS=\"$STONE_INCLUDE_FLAGS -i $DTBO_STAGING\"\n"); + s.push_str("if [ -f \"$DTO_FRAGMENT\" ]; then\n"); + s.push_str(" STONE_OVERLAY_FLAG=\"--overlay $DTO_FRAGMENT\"\n"); + s.push_str("fi\n"); + s.push_str("# --- end device-tree overlays ---\n"); + + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(yaml: &str) -> Value { + serde_yaml::from_str(yaml).expect("valid test yaml") + } + + // merged_runtime with the given enabled extension names. + fn runtime_with(exts: &[&str]) -> Value { + let list: String = exts.iter().map(|e| format!(" - {e}\n")).collect(); + cfg(&format!("extensions:\n{list}")) + } + + #[test] + fn collects_overlays_in_declaration_order_with_params() { + let parsed = cfg(r#" +extensions: + board-a: + device_tree_overlays: + - name: spi-fast + src: overlays/spi-fast.dtso + params: + speed: "12000000" + cs: 0 + - name: uart-rts + src: overlays/uart-rts.dtso + board-b: + device_tree_overlays: + - name: gpio-leds + src: dts/leds.dtso +"#); + let rt = runtime_with(&["board-a", "board-b"]); + let got = collect_for_runtime(&rt, &parsed).unwrap(); + + assert_eq!( + got.iter().map(|o| o.name.as_str()).collect::>(), + vec!["spi-fast", "uart-rts", "gpio-leds"] + ); + assert_eq!(got[0].src, "overlays/spi-fast.dtso"); + assert_eq!(got[0].ext_name, "board-a"); + assert_eq!(got[0].params.get("speed").unwrap(), "12000000"); + assert_eq!(got[0].params.get("cs").unwrap(), "0"); + assert!(got[1].params.is_empty()); + assert_eq!(got[2].ext_name, "board-b"); + } + + #[test] + fn only_enabled_extensions_contribute() { + let parsed = cfg(r#" +extensions: + enabled: + device_tree_overlays: + - name: yes-overlay + src: a.dtso + disabled: + device_tree_overlays: + - name: no-overlay + src: b.dtso +"#); + let rt = runtime_with(&["enabled"]); + let got = collect_for_runtime(&rt, &parsed).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, "yes-overlay"); + } + + #[test] + fn no_extensions_or_no_declarations_is_empty() { + let parsed = cfg("extensions:\n board:\n packages:\n - vim\n"); + assert!(collect_for_runtime(&cfg("{}"), &parsed).unwrap().is_empty()); + assert!(collect_for_runtime(&runtime_with(&["board"]), &parsed) + .unwrap() + .is_empty()); + } + + #[test] + fn duplicate_name_across_extensions_is_error() { + let parsed = cfg(r#" +extensions: + a: + device_tree_overlays: + - name: clash + src: a.dtso + b: + device_tree_overlays: + - name: clash + src: b.dtso +"#); + let err = collect_for_runtime(&runtime_with(&["a", "b"]), &parsed).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("clash"), "message names the overlay: {msg}"); + assert!( + msg.contains('a') && msg.contains('b'), + "names both exts: {msg}" + ); + } + + #[test] + fn missing_name_or_src_is_error() { + let no_name = cfg("extensions:\n a:\n device_tree_overlays:\n - src: a.dtso\n"); + assert!(collect_for_runtime(&runtime_with(&["a"]), &no_name) + .unwrap_err() + .to_string() + .contains("missing 'name'")); + + let no_src = cfg("extensions:\n a:\n device_tree_overlays:\n - name: x\n"); + assert!(collect_for_runtime(&runtime_with(&["a"]), &no_src) + .unwrap_err() + .to_string() + .contains("'src'")); + } + + #[test] + fn unsafe_name_is_rejected() { + for bad in ["../escape", "sub/dir", "has space", ".", ".."] { + let parsed = cfg(&format!( + "extensions:\n a:\n device_tree_overlays:\n - name: \"{bad}\"\n src: a.dtso\n" + )); + assert!( + collect_for_runtime(&runtime_with(&["a"]), &parsed).is_err(), + "name {bad:?} should be rejected" + ); + } + } + + #[test] + fn declarations_must_be_a_list() { + let parsed = + cfg("extensions:\n a:\n device_tree_overlays:\n name: x\n src: a.dtso\n"); + assert!(collect_for_runtime(&runtime_with(&["a"]), &parsed) + .unwrap_err() + .to_string() + .contains("must be a list")); + } + + fn overlay(name: &str, src: &str) -> DeviceTreeOverlay { + DeviceTreeOverlay { + name: name.to_string(), + src: src.to_string(), + params: BTreeMap::new(), + ext_name: "board".to_string(), + } + } + + #[test] + fn render_is_empty_without_overlays() { + assert_eq!(render_build_section(&[]).unwrap(), ""); + } + + #[test] + fn render_emits_wrapper_hook_and_wiring() { + let overlays = vec![ + overlay("spi-fast", "overlays/spi-fast.dtso"), + overlay("uart-rts", "dts/uart.dtso"), + ]; + let sh = render_build_section(&overlays).unwrap(); + + assert!(sh.contains( + "avocado-dtc-overlay --name \"spi-fast\" --src \"/opt/src/overlays/spi-fast.dtso\" --out \"$DTBO_STAGING/spi-fast.dtbo\"" + )); + assert!( + sh.contains("avocado-dtc-overlay --name \"uart-rts\" --src \"/opt/src/dts/uart.dtso\"") + ); + // Single fixed-path hook, guarded, not a directory scan. + assert!(sh.contains(DELIVERY_HOOK_RELATIVE)); + assert!(sh.contains("if [ ! -x \"$DTO_HOOK\" ]; then")); + // Claim validation is present. + assert!(sh.contains("staged but not delivered by the BSP hook")); + // Stone wiring: fragment via --overlay and staging dir on the -i path. + assert!(sh.contains("STONE_OVERLAY_FLAG=\"--overlay $DTO_FRAGMENT\"")); + assert!(sh.contains("-i $DTBO_STAGING")); + } + + #[test] + fn manifest_json_starts_unclaimed_with_params() { + let mut params = BTreeMap::new(); + params.insert("speed".to_string(), "12000000".to_string()); + let overlays = vec![DeviceTreeOverlay { + name: "spi".to_string(), + src: "a.dtso".to_string(), + params, + ext_name: "board".to_string(), + }]; + let json = build_manifest_json(&overlays).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["version"], 1); + assert_eq!(v["overlays"][0]["name"], "spi"); + assert_eq!(v["overlays"][0]["file"], "spi.dtbo"); + assert_eq!(v["overlays"][0]["params"]["speed"], "12000000"); + assert!( + v["overlays"][0]["claimed_by"].is_null(), + "claimed_by must start null so the hook must set it" + ); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 221a8ed..b4768c3 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod config_edit; pub mod container; +pub mod device_tree_overlay; #[cfg(target_os = "macos")] pub mod disk_writer; pub mod ext_fetch; diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index 764ca77..26f38f6 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -1427,6 +1427,33 @@ pub fn compute_runtime_build_input_hash( docker_images.clone(), ); } + // Device-tree overlays are compiled and delivered at runtime-build + // time, so a declaration change (or an edit to a .dtso the + // declaration points at) must invalidate this stamp. Fold both the + // declaration value and each source's content hash, mirroring how + // the overlay/post_build keys track file contents. + if let Some(dtos) = parsed + .get("extensions") + .and_then(|e| e.get(ext_name)) + .and_then(|ext| ext.get("device_tree_overlays")) + { + hash_data.insert( + serde_yaml::Value::String(format!("ext.{ext_name}.device_tree_overlays")), + dtos.clone(), + ); + if let Some(seq) = dtos.as_sequence() { + for entry in seq { + if let Some(src) = entry.get("src").and_then(|v| v.as_str()) { + hash_data.insert( + serde_yaml::Value::String(format!( + "ext.{ext_name}.dtso_content.{src}" + )), + script_hash_value(project_root, src), + ); + } + } + } + } } } } @@ -3164,6 +3191,93 @@ extensions: ); } + #[test] + fn test_runtime_input_hash_includes_device_tree_overlays() { + let runtime: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-runtime: "*" +extensions: + - board +"#, + ) + .unwrap(); + + let parsed_without: serde_yaml::Value = serde_yaml::from_str( + r#" +extensions: + board: + version: "1.0.0" +"#, + ) + .unwrap(); + + let parsed_with: serde_yaml::Value = serde_yaml::from_str( + r#" +extensions: + board: + version: "1.0.0" + device_tree_overlays: + - name: spi-fast + src: overlays/spi-fast.dtso +"#, + ) + .unwrap(); + + let hash_without = + compute_runtime_build_input_hash(&runtime, "dev", &parsed_without, Path::new(".")) + .unwrap(); + let hash_with = + compute_runtime_build_input_hash(&runtime, "dev", &parsed_with, Path::new(".")) + .unwrap(); + + assert_ne!( + hash_without.config_hash, hash_with.config_hash, + "declaring a device-tree overlay must change the runtime input hash" + ); + } + + #[test] + fn test_runtime_input_hash_tracks_dtso_content() { + let runtime: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-runtime: "*" +extensions: + - board +"#, + ) + .unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str( + r#" +extensions: + board: + version: "1.0.0" + device_tree_overlays: + - name: spi-fast + src: overlays/spi-fast.dtso +"#, + ) + .unwrap(); + + let tmp = tempfile::TempDir::new().unwrap(); + let dtso = tmp.path().join("overlays/spi-fast.dtso"); + std::fs::create_dir_all(dtso.parent().unwrap()).unwrap(); + + std::fs::write(&dtso, "/dts-v1/;\n/plugin/;\n/ { /* v1 */ };\n").unwrap(); + let hash_v1 = + compute_runtime_build_input_hash(&runtime, "dev", &parsed, tmp.path()).unwrap(); + + std::fs::write(&dtso, "/dts-v1/;\n/plugin/;\n/ { /* v2 edited */ };\n").unwrap(); + let hash_v2 = + compute_runtime_build_input_hash(&runtime, "dev", &parsed, tmp.path()).unwrap(); + + assert_ne!( + hash_v1.config_hash, hash_v2.config_hash, + "editing a .dtso's contents must change the runtime input hash" + ); + } + #[test] fn test_runtime_input_hash_includes_var_files() { let runtime_without: serde_yaml::Value = serde_yaml::from_str( From 5d58ff77b51601fb84e2fa514daeb2e53f4e61ee Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Tue, 21 Jul 2026 10:07:22 -0600 Subject: [PATCH 2/2] sdk/install: provision device-tree-overlay tooling from declarations When an extension declared device_tree_overlays, the CLI compiled and delivered the overlays at runtime-build time but never installed the tools that step needs: the nativesdk-avocado-dtc-overlay SDK wrapper and the per-BSP avocado-dtc-overlay-deliver hook had to be staged into the SDK by hand, so a clean install could not build a declared overlay. The target-sysroot install that would carry the hook was also gated on an active compile section, which a no-#include overlay does not require, so a pure path/phandle overlay got no target-sysroot at all. Scan the active extension set (not a single runtime's enabled list, since the SDK install spans every runtime) for device_tree_overlays and, when any declares them, add nativesdk-avocado-dtc-overlay to the SDK packages and avocado-dtc-overlay-deliver to the target-sysroot, widening the target-sysroot trigger to fire on a declaration as well as a compile section. nativesdk-dtc is pulled by the wrapper's RDEPENDS and stone is a baseline SDK package, so neither is listed. A BSP that ships no hook package now fails at install time rather than at the build-time backstop. Signed-off-by: Javier Tia --- src/commands/sdk/install.rs | 50 +++++++++++++++++-- src/utils/device_tree_overlay.rs | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/commands/sdk/install.rs b/src/commands/sdk/install.rs index f90c982..ee57545 100644 --- a/src/commands/sdk/install.rs +++ b/src/commands/sdk/install.rs @@ -344,7 +344,17 @@ impl SdkInstallCommand { // the parallel sysroot installs so all four can run concurrently. let active_compile_sections = find_active_compile_sections(&composed.merged_value, active_extensions); - let need_target_dev = config.has_compile_sections() && !active_compile_sections.is_empty(); + // ENG-2134: an extension declaring device-tree overlays needs the delivery + // hook in the target-sysroot, so provision the target-sysroot even when + // there is no compile section (a no-#include overlay needs no kernel-devsrc). + let provision_dto = + !crate::utils::device_tree_overlay::active_extensions_declaring_overlays( + &composed.merged_value, + active_extensions, + ) + .is_empty(); + let need_target_dev = + (config.has_compile_sections() && !active_compile_sections.is_empty()) || provision_dto; // Prepare target-dev install command if needed (CPU-only prep, no container calls) let target_dev_command = if need_target_dev { @@ -389,6 +399,21 @@ impl SdkInstallCommand { target_sysroot_config_version, ); + // ENG-2134: the per-BSP delivery hook lands in the target-sysroot so the + // runtime build can invoke it. A BSP that ships no hook package fails the + // install here, earlier and clearer than the build-time backstop. + let dto_hook_pkg = if provision_dto { + build_package_spec_with_lock( + lock_file, + target, + &SysrootType::TargetSysroot, + crate::utils::device_tree_overlay::DELIVERY_HOOK_PACKAGE, + "*", + ) + } else { + String::new() + }; + let command = format!( r#" unset RPM_CONFIGDIR @@ -396,13 +421,14 @@ RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ $DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ --disablerepo=${{AVOCADO_TARGET}}-target-ext \ {} {} {} --installroot ${{AVOCADO_PREFIX}}/sdk/target-sysroot \ - install {} {} + install {} {} {} "#, dnf_args_str, exclude_str, yes, target_sysroot_pkg, - all_compile_packages.join(" ") + all_compile_packages.join(" "), + dto_hook_pkg ); Some(( @@ -439,6 +465,7 @@ $DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ config, &bootstrap.sdk_dependencies, &bootstrap.extension_sdk_dependencies, + provision_dto, &mut sdk_pkg_lock, &bootstrap.all_sdk_package_names, &bootstrap.sdk_sysroot, @@ -1673,6 +1700,7 @@ fi config: &Config, sdk_dependencies: &Option>, extension_sdk_dependencies: &HashMap>, + provision_dto: bool, lock_file: &mut LockFile, bootstrap_package_names: &[String], sdk_sysroot: &SysrootType, @@ -1712,6 +1740,22 @@ fi sdk_package_names.extend(self.extract_package_names(ext_deps)); } + // ENG-2134: provision the SDK overlay build wrapper when any active + // extension declares device-tree overlays. Its RDEPENDS pulls + // nativesdk-dtc, and stone is a baseline SDK package, so neither is + // provisioned separately. + if provision_dto { + sdk_packages.push(build_package_spec_with_lock( + lock_file, + target, + sdk_sysroot, + crate::utils::device_tree_overlay::SDK_OVERLAY_PACKAGE, + "*", + )); + sdk_package_names + .push(crate::utils::device_tree_overlay::SDK_OVERLAY_PACKAGE.to_string()); + } + // Track all SDK package names for version query (bootstrap pkgs + new deps) let mut all_sdk_package_names: Vec = bootstrap_package_names.to_vec(); diff --git a/src/utils/device_tree_overlay.rs b/src/utils/device_tree_overlay.rs index eca44b5..e22c9ec 100644 --- a/src/utils/device_tree_overlay.rs +++ b/src/utils/device_tree_overlay.rs @@ -100,6 +100,33 @@ pub fn collect_for_runtime( Ok(out) } +/// Names of the active extensions that declare at least one device-tree overlay. +/// +/// Unlike [`collect_for_runtime`], which scopes to a single runtime's enabled +/// extensions, this scans the whole active-extension set: the SDK install spans +/// every runtime, so the overlay build wrapper and delivery hook must be +/// provisioned whenever any active extension declares overlays. Returns the +/// declaring extension names sorted; an empty result means the feature is inert. +pub fn active_extensions_declaring_overlays( + parsed: &Value, + active_extensions: &std::collections::HashSet, +) -> Vec { + let mut out: Vec = active_extensions + .iter() + .filter(|ext| { + parsed + .get("extensions") + .and_then(|e| e.get(ext.as_str())) + .and_then(|e| e.get("device_tree_overlays")) + .and_then(|d| d.as_sequence()) + .is_some_and(|s| !s.is_empty()) + }) + .cloned() + .collect(); + out.sort(); + out +} + fn parse_entry(entry: &Value, ext_name: &str, idx: usize) -> Result { let name = entry .get("name") @@ -190,6 +217,17 @@ fn scalar_to_string(v: &Value) -> Option { /// runs this exact path - it never scans a directory. pub const DELIVERY_HOOK_RELATIVE: &str = "usr/libexec/avocado/device-tree-overlay-deliver"; +/// nativesdk package providing the `avocado-dtc-overlay` SDK build wrapper. +/// Its RDEPENDS pulls `nativesdk-dtc`, and `stone` is a baseline SDK package +/// (stone bundle runs on every runtime build), so neither is provisioned +/// separately. +pub const SDK_OVERLAY_PACKAGE: &str = "nativesdk-avocado-dtc-overlay"; + +/// Target package providing the per-BSP `device-tree-overlay-deliver` hook +/// (the executable at [`DELIVERY_HOOK_RELATIVE`]). Installed into the +/// target-sysroot so the runtime build can invoke it. +pub const DELIVERY_HOOK_PACKAGE: &str = "avocado-dtc-overlay-deliver"; + // In-container guard: fail the build if the hook staged overlays but left any // unclaimed (declared and compiled, yet not delivered to the boot medium). const CLAIMED_CHECK_PY: &str = r#"python3 - "$DTBO_STAGING/overlays.manifest.json" <<'AVOCADO_DTO_CHECK_EOF' @@ -489,4 +527,48 @@ extensions: "claimed_by must start null so the hook must set it" ); } + + #[test] + fn declaring_overlays_scans_active_set_not_per_runtime() { + let parsed = cfg(r#" +extensions: + board-a: + device_tree_overlays: + - name: spi + src: a.dtso + board-b: + packages: + - vim + board-c: + device_tree_overlays: + - name: gpio + src: c.dtso +"#); + let active: std::collections::HashSet = ["board-a", "board-b"] + .iter() + .map(|s| s.to_string()) + .collect(); + // board-a declares and is active -> included; board-b active but declares + // none -> excluded; board-c declares but is inactive -> excluded. + assert_eq!( + active_extensions_declaring_overlays(&parsed, &active), + vec!["board-a".to_string()] + ); + } + + #[test] + fn declaring_overlays_empty_when_none_active_declare() { + let parsed = cfg("extensions:\n a:\n packages:\n - vim\n"); + let active: std::collections::HashSet = + ["a"].iter().map(|s| s.to_string()).collect(); + assert!(active_extensions_declaring_overlays(&parsed, &active).is_empty()); + } + + #[test] + fn declaring_overlays_ignores_empty_declaration_list() { + let parsed = cfg("extensions:\n a:\n device_tree_overlays: []\n"); + let active: std::collections::HashSet = + ["a"].iter().map(|s| s.to_string()).collect(); + assert!(active_extensions_declaring_overlays(&parsed, &active).is_empty()); + } }