Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 5 additions & 18 deletions src/commands/ext/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use anyhow::{Context, Result};
use std::collections::HashMap;
use std::sync::Arc;

use super::find_ext_in_mapping;
use crate::commands::sdk::SdkCompileCommand;
use crate::utils::config::{ComposedConfig, Config, ExtensionLocation};
use crate::utils::container::{RunConfig, SdkContainer, TuiContext};
Expand Down Expand Up @@ -363,23 +362,11 @@ impl ExtBuildCommand {
// For local extensions, this uses get_merged_ext_config which reads from the file
let ext_config = match &extension_location {
ExtensionLocation::Remote { .. } => {
// Use the already-merged config from `parsed` which contains remote extension configs
// Then apply target-specific overrides manually
// Use find_ext_in_mapping to handle template keys like "avocado-bsp-{{ avocado.target }}"
let ext_section = find_ext_in_mapping(parsed, &self.extension, &target);
if let Some(ext_val) = ext_section {
let base_ext = ext_val.clone();
// Check for target-specific override within this extension
let target_override = ext_val.get(&target).cloned();
if let Some(override_val) = target_override {
// Merge target override into base, filtering out other target sections
Some(config.merge_target_override(base_ext, override_val, &target))
} else {
Some(base_ext)
}
} else {
None
}
// Resolve the remote/path-sourced ext's config from the composed
// value, honoring its `target-<name>:` overrides (overlay + `--tag`)
// — the same result the Local path gets via get_merged_ext_config.
// Shared with `ext image`.
super::resolve_remote_ext_config(config, parsed, &self.extension, &target)
}
ExtensionLocation::Local { config_path, .. } => {
// For local extensions, read from the file with proper target merging
Expand Down
47 changes: 8 additions & 39 deletions src/commands/ext/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,48 +408,17 @@ impl ExtImageCommand {
// For local extensions, this uses get_merged_ext_config which reads from the file
let ext_config = match &extension_location {
ExtensionLocation::Remote { .. } => {
// Use the already-merged config from `parsed` which contains remote extension configs
// Then apply target-specific overrides manually
// Use find_ext_in_mapping to handle template keys like "avocado-bsp-{{ avocado.target }}"
let ext_section = find_ext_in_mapping(parsed, &self.extension, &target);

if self.verbose {
if let Some(all_ext) = parsed.get("extensions") {
if let Some(ext_map) = all_ext.as_mapping() {
let ext_names: Vec<_> =
ext_map.keys().filter_map(|k| k.as_str()).collect();
eprintln!(
"[DEBUG] Available extensions in composed config: {ext_names:?}"
);
}
if let Some(ext_map) = parsed.get("extensions").and_then(|e| e.as_mapping()) {
let ext_names: Vec<_> = ext_map.keys().filter_map(|k| k.as_str()).collect();
eprintln!("[DEBUG] Available extensions in composed config: {ext_names:?}");
}
eprintln!(
"[DEBUG] Looking for extension '{}' in composed config, found: {}",
self.extension,
ext_section.is_some()
);
if let Some(ext_val) = &ext_section {
eprintln!(
"[DEBUG] Extension '{}' config:\n{}",
self.extension,
serde_yaml::to_string(ext_val).unwrap_or_default()
);
}
}

if let Some(ext_val) = ext_section {
let base_ext = ext_val.clone();
// Check for target-specific override within this extension
let target_override = ext_val.get(&target).cloned();
if let Some(override_val) = target_override {
// Merge target override into base, filtering out other target sections
Some(config.merge_target_override(base_ext, override_val, &target))
} else {
Some(base_ext)
}
} else {
None
}
// Resolve the remote/path-sourced ext's config from the composed
// value, honoring its `target-<name>:` overrides (overlay + `--tag`)
// — the same result the Local path gets via get_merged_ext_config.
// Shared with `ext build`.
super::resolve_remote_ext_config(config, parsed, &self.extension, &target)
}
ExtensionLocation::Local { config_path, .. } => {
// For local extensions, read from the file with proper target merging
Expand Down
102 changes: 102 additions & 0 deletions src/commands/ext/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ pub(crate) fn find_ext_in_mapping<'a>(
None
}

/// Resolve a remote / path-sourced extension's effective config from the
/// composed value, applying its `target-<name>:` (and legacy bare `<name>:`)
/// per-target overrides. Any `kernel-<spec>:` override keys are stripped, not
/// applied — the kernel version isn't known at ext build/image time, so we pass
/// `resolved_kver: None` (the same as `get_merged_ext_config`, keeping the
/// `Local` and remote paths consistent).
///
/// Shared by `ext build` and `ext image`. The composed config holds the base
/// extension keys plus its `target-<name>:` sub-sections (as
/// `merge_installed_remote_extensions` produces for a path/remote source);
/// running it through `resolve_overrides_in_value` makes the runtime-build path
/// honor the same overrides the standalone `Local` path gets via
/// `get_merged_ext_config` — otherwise only a bare `<target>:` key matched and
/// the preferred `target-<name>:` form (overlay + `--tag`) was silently ignored.
/// Returns `None` when the extension isn't present in the composed config.
pub(crate) fn resolve_remote_ext_config(
config: &crate::utils::config::Config,
parsed: &serde_yaml::Value,
extension_name: &str,
target: &str,
) -> Option<serde_yaml::Value> {
let ext_val = find_ext_in_mapping(parsed, extension_name, target)?;
Some(config.resolve_overrides_in_value(
ext_val.clone(),
target,
None,
&format!("extensions.{extension_name}"),
))
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -68,6 +98,78 @@ mod tests {
serde_yaml::from_str(yaml).unwrap()
}

/// Regression: a remote / path-sourced extension in a runtime build has the
/// base keys plus `target-<name>:` sub-sections in the composed value. The
/// shared resolver (used by `ext build` + `ext image`) must honor the
/// preferred `target-<name>:` overlay + `--tag`, not just a bare `<target>:`
/// key, and must strip the override sub-keys rather than leak them.
#[test]
fn test_resolve_remote_ext_config_target_prefix_override() {
let yaml = r#"
supported_targets:
- raspberrypi4
- qemux86-64
sdk:
image: "docker.io/avocadolinux/sdk:apollo-edge"
extensions:
kos-layer-boardconf:
version: 2026.7.0
image:
type: kab
args: '-b -t kos.layer -v 2026.7.0 --tag {{ avocado.target }}'
target-raspberrypi4:
overlay: overlay/raspberrypi4
target-qemux86-64:
overlay: overlay/qemu-x64
image:
args: '-b -t kos.layer -v 2026.7.0 --tag qemu-x64'
"#;
let config = crate::utils::config::Config::load_from_yaml_str(yaml).unwrap();
let parsed = make_config(yaml);

// qemux86-64: target overlay + tag win, base image.type kept, overrides stripped.
let q = resolve_remote_ext_config(&config, &parsed, "kos-layer-boardconf", "qemux86-64")
.expect("extension present");
assert_eq!(
q.get("overlay").and_then(|v| v.as_str()),
Some("overlay/qemu-x64")
);
let q_args = q
.get("image")
.and_then(|i| i.get("args"))
.and_then(|v| v.as_str())
.unwrap();
assert!(q_args.contains("--tag qemu-x64"), "got: {q_args}");
assert_eq!(
q.get("image")
.and_then(|i| i.get("type"))
.and_then(|v| v.as_str()),
Some("kab")
);
assert!(q.get("target-qemux86-64").is_none());
assert!(q.get("target-raspberrypi4").is_none());

// raspberrypi4: its overlay wins (no image override → base args remain).
let r = resolve_remote_ext_config(&config, &parsed, "kos-layer-boardconf", "raspberrypi4")
.expect("extension present");
assert_eq!(
r.get("overlay").and_then(|v| v.as_str()),
Some("overlay/raspberrypi4")
);

// A target with no matching override gets the base only — no overlay
// leaks from a sibling target-* section.
let o = resolve_remote_ext_config(&config, &parsed, "kos-layer-boardconf", "qemuarm64")
.expect("extension present");
assert!(o.get("overlay").is_none());
assert!(o.get("target-qemux86-64").is_none());

// Absent extension → None.
assert!(
resolve_remote_ext_config(&config, &parsed, "does-not-exist", "qemux86-64").is_none()
);
}

#[test]
fn test_find_ext_direct_lookup() {
let config = make_config(
Expand Down
83 changes: 83 additions & 0 deletions src/utils/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3044,6 +3044,12 @@ impl Config {
/// 3. `target-<name>:` match
/// 4. `kernel-<spec>:` matches (in source order)
///
/// A matched `target-<name>:` (and legacy bare-target) block is resolved
/// RECURSIVELY with the same target + resolved kernel before being merged,
/// so override keys nested inside it — notably `kernel-<spec>:` — compose
/// (per-board × per-kernel). Without this a nested `kernel-<spec>:` would
/// leak through as a literal key instead of being applied/stripped.
///
/// `section_path` is for diagnostic context only.
pub fn resolve_overrides_in_value(
&self,
Expand Down Expand Up @@ -3129,9 +3135,14 @@ impl Config {

let mut merged = serde_yaml::Value::Mapping(map);
if let Some(v) = legacy_target_match {
// Recurse so override keys nested inside the target block (e.g.
// kernel-<spec>:) resolve against the same target + kernel instead
// of leaking through as literal keys.
let v = self.resolve_overrides_in_value(v, current_target, resolved_kver, section_path);
merged = self.merge_values(merged, v);
}
if let Some(v) = target_match {
let v = self.resolve_overrides_in_value(v, current_target, resolved_kver, section_path);
merged = self.merge_values(merged, v);
}
for v in kernel_matches {
Expand Down Expand Up @@ -8033,6 +8044,78 @@ extensions:
std::fs::remove_file(temp_file).ok();
}

#[test]
fn test_nested_kernel_under_target_resolves() {
// A shared BSP-style extension: per-board packages live under
// target-<name>, with kernel-<spec> package blocks NESTED inside the
// target block. The resolver must recurse into the matched target
// block and apply the kernel block matching the resolved kernel.
let config =
Config::load_from_str("supported_targets: [\"raspberrypi4\", \"raspberrypi5\"]\n")
.unwrap();

let ext: serde_yaml::Value = serde_yaml::from_str(
r#"
packages:
common-pkg: '*'
target-raspberrypi5:
packages:
board-pkg: '*'
kernel-6.6.*:
packages:
rpivid-hevc: '*'
kernel-6.12.*:
packages:
rpi-hevc-dec: '*'
drm-shmem-helper: '*'
"#,
)
.unwrap();

// raspberrypi5 on a 6.12 kernel -> base + target + nested 6.12 block.
let r = config.resolve_overrides_in_value(
ext.clone(),
"raspberrypi5",
Some("6.12.25"),
"extensions.avocado-bsp",
);
let pkgs = r
.get("packages")
.and_then(|p| p.as_mapping())
.expect("packages");
assert!(pkgs.contains_key("common-pkg"));
assert!(pkgs.contains_key("board-pkg"));
assert!(pkgs.contains_key("rpi-hevc-dec"));
assert!(pkgs.contains_key("drm-shmem-helper"));
assert!(!pkgs.contains_key("rpivid-hevc"));
// No override keys leak through, at the section level or into packages.
assert!(r.get("target-raspberrypi5").is_none());
assert!(r.get("kernel-6.12.*").is_none());
assert!(!pkgs.contains_key("kernel-6.12.*"));
assert!(!pkgs.contains_key("kernel-6.6.*"));

// raspberrypi5 on a 6.6 kernel -> nested 6.6 block instead.
let r66 = config.resolve_overrides_in_value(
ext.clone(),
"raspberrypi5",
Some("6.6.90"),
"extensions.avocado-bsp",
);
let p66 = r66.get("packages").and_then(|p| p.as_mapping()).unwrap();
assert!(p66.contains_key("rpivid-hevc"));
assert!(!p66.contains_key("rpi-hevc-dec"));

// Unknown kernel (build/image path, resolved_kver = None): nested
// kernel blocks are stripped, not applied and not leaked.
let rnone =
config.resolve_overrides_in_value(ext, "raspberrypi5", None, "extensions.avocado-bsp");
let pnone = rnone.get("packages").and_then(|p| p.as_mapping()).unwrap();
assert!(pnone.contains_key("board-pkg"));
assert!(!pnone.contains_key("rpi-hevc-dec"));
assert!(!pnone.contains_key("rpivid-hevc"));
assert!(!pnone.contains_key("kernel-6.12.*"));
}

#[test]
fn test_edge_cases_and_error_conditions() {
// Test configuration with only target-specific sections
Expand Down
Loading