Skip to content

Commit 89e9341

Browse files
committed
cli: Extend bootc container inspect with kernel info
The container-inspect command previously only reported kernel arguments. Extend it to also report kernel information, including whether the image contains a traditional kernel or a Unified Kernel Image (UKI). This consolidates UKI detection logic previously in bootc_composefs::boot into a new kernel module that can find kernels via either the traditional /usr/lib/modules/<version>/vmlinuz path or UKI files in /boot/EFI/Linux/. The ContainerInspect output now includes a "kernel" field with version and unified (boolean) properties, enabling tooling to determine the boot method before installation. Assisted-by: OpenCode (Claude Opus 4.5) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent fa1726b commit 89e9341

File tree

8 files changed

+251
-53
lines changed

8 files changed

+251
-53
lines changed

crates/lib/src/bootc_composefs/boot.rs

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -217,26 +217,6 @@ fi
217217
)
218218
}
219219

220-
/// Returns `true` if detect the target rootfs carries a UKI.
221-
pub(crate) fn container_root_has_uki(root: &Dir) -> Result<bool> {
222-
let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else {
223-
return Ok(false);
224-
};
225-
let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else {
226-
return Ok(false);
227-
};
228-
for entry in efi_linux.entries()? {
229-
let entry = entry?;
230-
let name = entry.file_name();
231-
let name = Path::new(&name);
232-
let extension = name.extension().and_then(|v| v.to_str());
233-
if extension == Some("efi") {
234-
return Ok(true);
235-
}
236-
}
237-
Ok(false)
238-
}
239-
240220
pub fn get_esp_partition(device: &str) -> Result<(String, Option<String>)> {
241221
let device_info = bootc_blockdev::partitions_of(Utf8Path::new(device))?;
242222
let esp = crate::bootloader::esp_in(&device_info)?;
@@ -1295,32 +1275,6 @@ pub(crate) async fn setup_composefs_boot(
12951275
#[cfg(test)]
12961276
mod tests {
12971277
use super::*;
1298-
use cap_std_ext::cap_std;
1299-
1300-
#[test]
1301-
fn test_root_has_uki() -> Result<()> {
1302-
// Test case 1: No boot directory
1303-
let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1304-
assert_eq!(container_root_has_uki(&tempdir)?, false);
1305-
1306-
// Test case 2: boot directory exists but no EFI/Linux
1307-
tempdir.create_dir(crate::install::BOOT)?;
1308-
assert_eq!(container_root_has_uki(&tempdir)?, false);
1309-
1310-
// Test case 3: boot/EFI/Linux exists but no .efi files
1311-
tempdir.create_dir_all("boot/EFI/Linux")?;
1312-
assert_eq!(container_root_has_uki(&tempdir)?, false);
1313-
1314-
// Test case 4: boot/EFI/Linux exists with non-.efi file
1315-
tempdir.atomic_write("boot/EFI/Linux/readme.txt", b"some file")?;
1316-
assert_eq!(container_root_has_uki(&tempdir)?, false);
1317-
1318-
// Test case 5: boot/EFI/Linux exists with .efi file
1319-
tempdir.atomic_write("boot/EFI/Linux/bootx64.efi", b"fake efi binary")?;
1320-
assert_eq!(container_root_has_uki(&tempdir)?, true);
1321-
1322-
Ok(())
1323-
}
13241278

13251279
#[test]
13261280
fn test_type1_filename_generation() {

crates/lib/src/cli.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1461,7 +1461,8 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14611461
let root = &Dir::open_ambient_dir(&rootfs, cap_std::ambient_authority())?;
14621462
let kargs = crate::bootc_kargs::get_kargs_in_root(root, std::env::consts::ARCH)?;
14631463
let kargs: Vec<String> = kargs.iter_str().map(|s| s.to_owned()).collect();
1464-
let inspect = crate::spec::ContainerInspect { kargs };
1464+
let kernel = crate::kernel::find_kernel(root)?;
1465+
let inspect = crate::spec::ContainerInspect { kargs, kernel };
14651466
serde_json::to_writer_pretty(std::io::stdout().lock(), &inspect)?;
14661467
Ok(())
14671468
}

crates/lib/src/install.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,10 +1341,6 @@ async fn verify_target_fetch(
13411341
Ok(())
13421342
}
13431343

1344-
fn root_has_uki(root: &Dir) -> Result<bool> {
1345-
crate::bootc_composefs::boot::container_root_has_uki(root)
1346-
}
1347-
13481344
/// Preparation for an install; validates and prepares some (thereafter immutable) global state.
13491345
async fn prepare_install(
13501346
config_opts: InstallConfigOpts,
@@ -1418,7 +1414,9 @@ async fn prepare_install(
14181414
tracing::debug!("Target image reference: {target_imgref}");
14191415

14201416
let composefs_required = if let Some(root) = target_rootfs.as_ref() {
1421-
root_has_uki(root)?
1417+
crate::kernel::find_kernel(root)?
1418+
.map(|k| k.unified)
1419+
.unwrap_or(false)
14221420
} else {
14231421
false
14241422
};

crates/lib/src/kernel.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Kernel detection for container images.
2+
//!
3+
//! This module provides functionality to detect kernel information in container
4+
//! images, supporting both traditional kernels (with separate vmlinuz/initrd) and
5+
//! Unified Kernel Images (UKI).
6+
7+
use std::path::Path;
8+
9+
use anyhow::Result;
10+
use cap_std_ext::cap_std::fs::Dir;
11+
use cap_std_ext::dirext::CapStdExtDirExt;
12+
use serde::Serialize;
13+
14+
use crate::bootc_composefs::boot::EFI_LINUX;
15+
16+
/// Information about the kernel in a container image.
17+
#[derive(Debug, Serialize)]
18+
#[serde(rename_all = "kebab-case")]
19+
pub(crate) struct Kernel {
20+
/// The kernel version identifier. For traditional kernels, this is derived from the
21+
/// /usr/lib/modules/<version> directory name. For UKI images, this is the UKI filename
22+
/// (without the .efi extension).
23+
pub(crate) version: String,
24+
/// Whether the kernel is packaged as a UKI (Unified Kernel Image).
25+
pub(crate) unified: bool,
26+
}
27+
28+
/// Find the kernel in a container image root directory.
29+
///
30+
/// This function first attempts to find a traditional kernel layout with
31+
/// `/usr/lib/modules/<version>/vmlinuz`. If that doesn't exist, it falls back
32+
/// to looking for a UKI in `/boot/EFI/Linux/*.efi`.
33+
///
34+
/// Returns `None` if no kernel is found.
35+
pub(crate) fn find_kernel(root: &Dir) -> Result<Option<Kernel>> {
36+
// First, try to find a traditional kernel via ostree_ext
37+
if let Some(kernel_dir) = ostree_ext::bootabletree::find_kernel_dir_fs(root)? {
38+
let version = kernel_dir
39+
.file_name()
40+
.ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {kernel_dir}"))?
41+
.to_owned();
42+
return Ok(Some(Kernel {
43+
version,
44+
unified: false,
45+
}));
46+
}
47+
48+
// Fall back to checking for a UKI
49+
if let Some(uki_filename) = find_uki_filename(root)? {
50+
let version = uki_filename
51+
.strip_suffix(".efi")
52+
.unwrap_or(&uki_filename)
53+
.to_owned();
54+
return Ok(Some(Kernel {
55+
version,
56+
unified: true,
57+
}));
58+
}
59+
60+
Ok(None)
61+
}
62+
63+
/// Returns the filename of the first UKI found in the container root, if any.
64+
///
65+
/// Looks in `/boot/EFI/Linux/*.efi`. If multiple UKIs are present, returns
66+
/// the first one in sorted order for determinism.
67+
fn find_uki_filename(root: &Dir) -> Result<Option<String>> {
68+
let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else {
69+
return Ok(None);
70+
};
71+
let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else {
72+
return Ok(None);
73+
};
74+
75+
let mut uki_files = Vec::new();
76+
for entry in efi_linux.entries()? {
77+
let entry = entry?;
78+
let name = entry.file_name();
79+
let name_path = Path::new(&name);
80+
let extension = name_path.extension().and_then(|v| v.to_str());
81+
if extension == Some("efi") {
82+
if let Some(name_str) = name.to_str() {
83+
uki_files.push(name_str.to_owned());
84+
}
85+
}
86+
}
87+
88+
// Sort for deterministic behavior when multiple UKIs are present
89+
uki_files.sort();
90+
Ok(uki_files.into_iter().next())
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
use cap_std_ext::{cap_std, cap_tempfile, dirext::CapStdExtDirExt};
97+
98+
#[test]
99+
fn test_find_kernel_none() -> Result<()> {
100+
let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
101+
assert!(find_kernel(&tempdir)?.is_none());
102+
Ok(())
103+
}
104+
105+
#[test]
106+
fn test_find_kernel_traditional() -> Result<()> {
107+
let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
108+
tempdir.create_dir_all("usr/lib/modules/6.12.0-100.fc41.x86_64")?;
109+
tempdir.atomic_write(
110+
"usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz",
111+
b"fake kernel",
112+
)?;
113+
114+
let kernel = find_kernel(&tempdir)?.expect("should find kernel");
115+
assert_eq!(kernel.version, "6.12.0-100.fc41.x86_64");
116+
assert!(!kernel.unified);
117+
Ok(())
118+
}
119+
120+
#[test]
121+
fn test_find_kernel_uki() -> Result<()> {
122+
let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
123+
tempdir.create_dir_all("boot/EFI/Linux")?;
124+
tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", b"fake uki")?;
125+
126+
let kernel = find_kernel(&tempdir)?.expect("should find kernel");
127+
assert_eq!(kernel.version, "fedora-6.12.0");
128+
assert!(kernel.unified);
129+
Ok(())
130+
}
131+
132+
#[test]
133+
fn test_find_kernel_traditional_takes_precedence() -> Result<()> {
134+
let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
135+
// Both traditional and UKI exist
136+
tempdir.create_dir_all("usr/lib/modules/6.12.0-100.fc41.x86_64")?;
137+
tempdir.atomic_write(
138+
"usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz",
139+
b"fake kernel",
140+
)?;
141+
tempdir.create_dir_all("boot/EFI/Linux")?;
142+
tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", b"fake uki")?;
143+
144+
let kernel = find_kernel(&tempdir)?.expect("should find kernel");
145+
// Traditional kernel should take precedence
146+
assert_eq!(kernel.version, "6.12.0-100.fc41.x86_64");
147+
assert!(!kernel.unified);
148+
Ok(())
149+
}
150+
151+
#[test]
152+
fn test_find_uki_filename_sorted() -> Result<()> {
153+
let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
154+
tempdir.create_dir_all("boot/EFI/Linux")?;
155+
tempdir.atomic_write("boot/EFI/Linux/zzz.efi", b"fake uki")?;
156+
tempdir.atomic_write("boot/EFI/Linux/aaa.efi", b"fake uki")?;
157+
tempdir.atomic_write("boot/EFI/Linux/mmm.efi", b"fake uki")?;
158+
159+
// Should return first in sorted order
160+
let filename = find_uki_filename(&tempdir)?.expect("should find uki");
161+
assert_eq!(filename, "aaa.efi");
162+
Ok(())
163+
}
164+
}

crates/lib/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod image;
2121
mod install;
2222
pub(crate) mod journal;
2323
mod k8sapitypes;
24+
mod kernel;
2425
mod lints;
2526
mod lsm;
2627
pub(crate) mod metadata;

crates/lib/src/spec.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ pub(crate) struct DeploymentEntry<'a> {
303303
pub(crate) struct ContainerInspect {
304304
/// Kernel arguments embedded in the container image.
305305
pub(crate) kargs: Vec<String>,
306+
/// Information about the kernel in the container image.
307+
pub(crate) kernel: Option<crate::kernel::Kernel>,
306308
}
307309

308310
impl Host {

crates/tests-integration/src/container.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,48 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> {
3232
assert!(kargs.iter().any(|arg| arg == "kargsd-othertest=2"));
3333
assert!(kargs.iter().any(|arg| arg == "testing-kargsd=3"));
3434

35+
// check kernel field
36+
let kernel = inspect
37+
.get("kernel")
38+
.expect("kernel field should be present")
39+
.as_object()
40+
.expect("kernel should be an object");
41+
let version = kernel
42+
.get("version")
43+
.expect("kernel.version should be present")
44+
.as_str()
45+
.expect("kernel.version should be a string");
46+
// Verify version is non-empty (for traditional kernels it's uname-style, for UKI it's the filename)
47+
assert!(!version.is_empty(), "kernel.version should not be empty");
48+
let unified = kernel
49+
.get("unified")
50+
.expect("kernel.unified should be present")
51+
.as_bool()
52+
.expect("kernel.unified should be a boolean");
53+
if let Some(variant) = std::env::var("BOOTC_variant").ok() {
54+
match variant.as_str() {
55+
"ostree" => {
56+
assert!(!unified, "Expected unified=false for ostree variant");
57+
// For traditional kernels, version should look like a uname (contains digits)
58+
assert!(
59+
version.chars().any(|c| c.is_ascii_digit()),
60+
"version should contain version numbers for traditional kernel: {version}"
61+
);
62+
}
63+
"composefs-sealeduki-sdboot" => {
64+
assert!(unified, "Expected unified=true for UKI variant");
65+
// For UKI, version is the filename without .efi extension (should not end with .efi)
66+
assert!(
67+
!version.ends_with(".efi"),
68+
"version should not include .efi extension: {version}"
69+
);
70+
// Version should be non-empty after stripping extension
71+
assert!(!version.is_empty(), "version should not be empty for UKI");
72+
}
73+
o => eprintln!("notice: Unhandled variant for kernel check: {o}"),
74+
}
75+
}
76+
3577
Ok(())
3678
}
3779

docs/src/man/bootc-container-inspect.8.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@ bootc container inspect
88

99
# DESCRIPTION
1010

11-
Output JSON to stdout containing the container image metadata
11+
Output JSON to stdout containing the container image metadata.
12+
13+
# OUTPUT
14+
15+
The command outputs a JSON object with the following fields:
16+
17+
- `kargs`: An array of kernel arguments embedded in the container image.
18+
- `kernel`: An object containing kernel information (or `null` if no kernel is found):
19+
- `version`: The kernel version identifier. For traditional kernels, this is derived from the `/usr/lib/modules/<version>` directory name (equivalent to `uname -r`). For UKI images, this is is the UKI filename without the `.efi` extension - which should usually be the same as the uname.
20+
- `unified`: A boolean indicating whether the kernel is packaged as a UKI (Unified Kernel Image).
1221

1322
# OPTIONS
1423

@@ -27,6 +36,33 @@ Inspect container image metadata:
2736

2837
bootc container inspect
2938

39+
Example output (traditional kernel):
40+
41+
```json
42+
{
43+
"kargs": [
44+
"console=ttyS0",
45+
"quiet"
46+
],
47+
"kernel": {
48+
"version": "6.12.0-0.rc6.51.fc42.x86_64",
49+
"unified": false
50+
}
51+
}
52+
```
53+
54+
Example output (UKI):
55+
56+
```json
57+
{
58+
"kargs": [],
59+
"kernel": {
60+
"version": "7e11ac46e3e022053e7226a20104ac656bf72d1a",
61+
"unified": true
62+
}
63+
}
64+
```
65+
3066
# SEE ALSO
3167

3268
**bootc**(8)

0 commit comments

Comments
 (0)