diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index d669a441e..c4c3b495c 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -217,26 +217,6 @@ fi ) } -/// Returns `true` if detect the target rootfs carries a UKI. -pub(crate) fn container_root_has_uki(root: &Dir) -> Result { - let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else { - return Ok(false); - }; - let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else { - return Ok(false); - }; - for entry in efi_linux.entries()? { - let entry = entry?; - let name = entry.file_name(); - let name = Path::new(&name); - let extension = name.extension().and_then(|v| v.to_str()); - if extension == Some("efi") { - return Ok(true); - } - } - Ok(false) -} - pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { let device_info = bootc_blockdev::partitions_of(Utf8Path::new(device))?; let esp = crate::bootloader::esp_in(&device_info)?; @@ -1295,32 +1275,6 @@ pub(crate) async fn setup_composefs_boot( #[cfg(test)] mod tests { use super::*; - use cap_std_ext::cap_std; - - #[test] - fn test_root_has_uki() -> Result<()> { - // Test case 1: No boot directory - let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; - assert_eq!(container_root_has_uki(&tempdir)?, false); - - // Test case 2: boot directory exists but no EFI/Linux - tempdir.create_dir(crate::install::BOOT)?; - assert_eq!(container_root_has_uki(&tempdir)?, false); - - // Test case 3: boot/EFI/Linux exists but no .efi files - tempdir.create_dir_all("boot/EFI/Linux")?; - assert_eq!(container_root_has_uki(&tempdir)?, false); - - // Test case 4: boot/EFI/Linux exists with non-.efi file - tempdir.atomic_write("boot/EFI/Linux/readme.txt", b"some file")?; - assert_eq!(container_root_has_uki(&tempdir)?, false); - - // Test case 5: boot/EFI/Linux exists with .efi file - tempdir.atomic_write("boot/EFI/Linux/bootx64.efi", b"fake efi binary")?; - assert_eq!(container_root_has_uki(&tempdir)?, true); - - Ok(()) - } #[test] fn test_type1_filename_generation() { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 952efd79d..787063663 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -319,11 +319,22 @@ pub(crate) enum InstallOpts { /// Subcommands which can be executed as part of a container build. #[derive(Debug, clap::Subcommand, PartialEq, Eq)] pub(crate) enum ContainerOpts { - /// Output JSON to stdout containing the container image metadata. + /// Output information about the container image. + /// + /// By default, a human-readable summary is output. Use --json or --format + /// to change the output format. Inspect { /// Operate on the provided rootfs. #[clap(long, default_value = "/")] rootfs: Utf8PathBuf, + + /// Output in JSON format. + #[clap(long)] + json: bool, + + /// The output format. + #[clap(long, conflicts_with = "json")] + format: Option, }, /// Perform relatively inexpensive static analysis checks as part of a container /// build. @@ -1457,14 +1468,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } Opt::Container(opts) => match opts { - ContainerOpts::Inspect { rootfs } => { - let root = &Dir::open_ambient_dir(&rootfs, cap_std::ambient_authority())?; - let kargs = crate::bootc_kargs::get_kargs_in_root(root, std::env::consts::ARCH)?; - let kargs: Vec = kargs.iter_str().map(|s| s.to_owned()).collect(); - let inspect = crate::spec::ContainerInspect { kargs }; - serde_json::to_writer_pretty(std::io::stdout().lock(), &inspect)?; - Ok(()) - } + ContainerOpts::Inspect { + rootfs, + json, + format, + } => crate::status::container_inspect(&rootfs, json, format), ContainerOpts::Lint { rootfs, fatal_warnings, diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index a9e978386..1a3142242 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1341,10 +1341,6 @@ async fn verify_target_fetch( Ok(()) } -fn root_has_uki(root: &Dir) -> Result { - crate::bootc_composefs::boot::container_root_has_uki(root) -} - /// Preparation for an install; validates and prepares some (thereafter immutable) global state. async fn prepare_install( config_opts: InstallConfigOpts, @@ -1418,7 +1414,9 @@ async fn prepare_install( tracing::debug!("Target image reference: {target_imgref}"); let composefs_required = if let Some(root) = target_rootfs.as_ref() { - root_has_uki(root)? + crate::kernel::find_kernel(root)? + .map(|k| k.unified) + .unwrap_or(false) } else { false }; diff --git a/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs new file mode 100644 index 000000000..3d63845fb --- /dev/null +++ b/crates/lib/src/kernel.rs @@ -0,0 +1,164 @@ +//! Kernel detection for container images. +//! +//! This module provides functionality to detect kernel information in container +//! images, supporting both traditional kernels (with separate vmlinuz/initrd) and +//! Unified Kernel Images (UKI). + +use std::path::Path; + +use anyhow::Result; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use serde::Serialize; + +use crate::bootc_composefs::boot::EFI_LINUX; + +/// Information about the kernel in a container image. +#[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Kernel { + /// The kernel version identifier. For traditional kernels, this is derived from the + /// /usr/lib/modules/ directory name. For UKI images, this is the UKI filename + /// (without the .efi extension). + pub(crate) version: String, + /// Whether the kernel is packaged as a UKI (Unified Kernel Image). + pub(crate) unified: bool, +} + +/// Find the kernel in a container image root directory. +/// +/// This function first attempts to find a UKI in `/boot/EFI/Linux/*.efi`. +/// If that doesn't exist, it falls back to looking for a traditional kernel +/// layout with `/usr/lib/modules//vmlinuz`. +/// +/// Returns `None` if no kernel is found. +pub(crate) fn find_kernel(root: &Dir) -> Result> { + // First, try to find a UKI + if let Some(uki_filename) = find_uki_filename(root)? { + let version = uki_filename + .strip_suffix(".efi") + .unwrap_or(&uki_filename) + .to_owned(); + return Ok(Some(Kernel { + version, + unified: true, + })); + } + + // Fall back to checking for a traditional kernel via ostree_ext + if let Some(kernel_dir) = ostree_ext::bootabletree::find_kernel_dir_fs(root)? { + let version = kernel_dir + .file_name() + .ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {kernel_dir}"))? + .to_owned(); + return Ok(Some(Kernel { + version, + unified: false, + })); + } + + Ok(None) +} + +/// Returns the filename of the first UKI found in the container root, if any. +/// +/// Looks in `/boot/EFI/Linux/*.efi`. If multiple UKIs are present, returns +/// the first one in sorted order for determinism. +fn find_uki_filename(root: &Dir) -> Result> { + let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else { + return Ok(None); + }; + let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else { + return Ok(None); + }; + + let mut uki_files = Vec::new(); + for entry in efi_linux.entries()? { + let entry = entry?; + let name = entry.file_name(); + let name_path = Path::new(&name); + let extension = name_path.extension().and_then(|v| v.to_str()); + if extension == Some("efi") { + if let Some(name_str) = name.to_str() { + uki_files.push(name_str.to_owned()); + } + } + } + + // Sort for deterministic behavior when multiple UKIs are present + uki_files.sort(); + Ok(uki_files.into_iter().next()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::{cap_std, cap_tempfile, dirext::CapStdExtDirExt}; + + #[test] + fn test_find_kernel_none() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert!(find_kernel(&tempdir)?.is_none()); + Ok(()) + } + + #[test] + fn test_find_kernel_traditional() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("usr/lib/modules/6.12.0-100.fc41.x86_64")?; + tempdir.atomic_write( + "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz", + b"fake kernel", + )?; + + let kernel = find_kernel(&tempdir)?.expect("should find kernel"); + assert_eq!(kernel.version, "6.12.0-100.fc41.x86_64"); + assert!(!kernel.unified); + Ok(()) + } + + #[test] + fn test_find_kernel_uki() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("boot/EFI/Linux")?; + tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", b"fake uki")?; + + let kernel = find_kernel(&tempdir)?.expect("should find kernel"); + assert_eq!(kernel.version, "fedora-6.12.0"); + assert!(kernel.unified); + Ok(()) + } + + #[test] + fn test_find_kernel_uki_takes_precedence() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + // Both traditional and UKI exist + tempdir.create_dir_all("usr/lib/modules/6.12.0-100.fc41.x86_64")?; + tempdir.atomic_write( + "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz", + b"fake kernel", + )?; + tempdir.create_dir_all("boot/EFI/Linux")?; + tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", b"fake uki")?; + + let kernel = find_kernel(&tempdir)?.expect("should find kernel"); + // UKI should take precedence + assert_eq!(kernel.version, "fedora-6.12.0"); + assert!(kernel.unified); + Ok(()) + } + + #[test] + fn test_find_uki_filename_sorted() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("boot/EFI/Linux")?; + tempdir.atomic_write("boot/EFI/Linux/zzz.efi", b"fake uki")?; + tempdir.atomic_write("boot/EFI/Linux/aaa.efi", b"fake uki")?; + tempdir.atomic_write("boot/EFI/Linux/mmm.efi", b"fake uki")?; + + // Should return first in sorted order + let filename = find_uki_filename(&tempdir)?.expect("should find uki"); + assert_eq!(filename, "aaa.efi"); + Ok(()) + } +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 905f167ad..9f8a0d31f 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -21,6 +21,7 @@ mod image; mod install; pub(crate) mod journal; mod k8sapitypes; +mod kernel; mod lints; mod lsm; pub(crate) mod metadata; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 743e1eb2f..d832fedf9 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -1,6 +1,7 @@ //! The definition for host system state. use std::fmt::Display; + use std::str::FromStr; use anyhow::Result; @@ -303,6 +304,8 @@ pub(crate) struct DeploymentEntry<'a> { pub(crate) struct ContainerInspect { /// Kernel arguments embedded in the container image. pub(crate) kargs: Vec, + /// Information about the kernel in the container image. + pub(crate) kernel: Option, } impl Host { diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 30b8a39dd..fca86f884 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -801,6 +801,77 @@ fn human_readable_output(mut out: impl Write, host: &Host, verbose: bool) -> Res Ok(()) } +/// Output container inspection in human-readable format +fn container_inspect_print_human( + inspect: &crate::spec::ContainerInspect, + mut out: impl Write, +) -> Result<()> { + // Collect rows to determine the max label width + let mut rows: Vec<(&str, String)> = Vec::new(); + + if let Some(kernel) = &inspect.kernel { + rows.push(("Kernel", kernel.version.clone())); + let kernel_type = if kernel.unified { "UKI" } else { "vmlinuz" }; + rows.push(("Type", kernel_type.to_string())); + } else { + rows.push(("Kernel", "".to_string())); + } + + let kargs = if inspect.kargs.is_empty() { + "".to_string() + } else { + inspect.kargs.join(" ") + }; + rows.push(("Kargs", kargs)); + + // Find the max label width for right-alignment + let max_label_len = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0); + + for (label, value) in rows { + write_row_name(&mut out, label, max_label_len)?; + writeln!(out, "{value}")?; + } + + Ok(()) +} + +/// Inspect a container image and output information about it. +pub(crate) fn container_inspect( + rootfs: &camino::Utf8Path, + json: bool, + format: Option, +) -> Result<()> { + let root = cap_std_ext::cap_std::fs::Dir::open_ambient_dir( + rootfs, + cap_std_ext::cap_std::ambient_authority(), + )?; + let kargs = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; + let kargs: Vec = kargs.iter_str().map(|s| s.to_owned()).collect(); + let kernel = crate::kernel::find_kernel(&root)?; + let inspect = crate::spec::ContainerInspect { kargs, kernel }; + + // Determine output format: explicit --format wins, then --json, then default to human-readable + let format = format.unwrap_or(if json { + OutputFormat::Json + } else { + OutputFormat::HumanReadable + }); + + let mut out = std::io::stdout().lock(); + match format { + OutputFormat::Json => { + serde_json::to_writer_pretty(&mut out, &inspect)?; + } + OutputFormat::Yaml => { + serde_yaml::to_writer(&mut out, &inspect)?; + } + OutputFormat::HumanReadable => { + container_inspect_print_human(&inspect, &mut out)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1007,4 +1078,60 @@ mod tests { // Verbose output should include download-only status as "no" for normal staged deployments assert!(w.contains("Download-only: no")); } + + #[test] + fn test_container_inspect_human_readable() { + let inspect = crate::spec::ContainerInspect { + kargs: vec!["console=ttyS0".into(), "quiet".into()], + kernel: Some(crate::kernel::Kernel { + version: "6.12.0-100.fc41.x86_64".into(), + unified: false, + }), + }; + let mut w = Vec::new(); + container_inspect_print_human(&inspect, &mut w).unwrap(); + let output = String::from_utf8(w).unwrap(); + let expected = indoc::indoc! { r" + Kernel: 6.12.0-100.fc41.x86_64 + Type: vmlinuz + Kargs: console=ttyS0 quiet + "}; + similar_asserts::assert_eq!(output, expected); + } + + #[test] + fn test_container_inspect_human_readable_uki() { + let inspect = crate::spec::ContainerInspect { + kargs: vec![], + kernel: Some(crate::kernel::Kernel { + version: "6.12.0-100.fc41.x86_64".into(), + unified: true, + }), + }; + let mut w = Vec::new(); + container_inspect_print_human(&inspect, &mut w).unwrap(); + let output = String::from_utf8(w).unwrap(); + let expected = indoc::indoc! { r" + Kernel: 6.12.0-100.fc41.x86_64 + Type: UKI + Kargs: + "}; + similar_asserts::assert_eq!(output, expected); + } + + #[test] + fn test_container_inspect_human_readable_no_kernel() { + let inspect = crate::spec::ContainerInspect { + kargs: vec!["console=ttyS0".into()], + kernel: None, + }; + let mut w = Vec::new(); + container_inspect_print_human(&inspect, &mut w).unwrap(); + let output = String::from_utf8(w).unwrap(); + let expected = indoc::indoc! { r" + Kernel: + Kargs: console=ttyS0 + "}; + similar_asserts::assert_eq!(output, expected); + } } diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 3d09cadf9..25554f78a 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -24,7 +24,7 @@ pub(crate) fn test_bootc_status() -> Result<()> { pub(crate) fn test_bootc_container_inspect() -> Result<()> { let sh = Shell::new()?; let inspect: serde_json::Value = - serde_json::from_str(&cmd!(sh, "bootc container inspect").read()?)?; + serde_json::from_str(&cmd!(sh, "bootc container inspect --json").read()?)?; // check kargs processing let kargs = inspect.get("kargs").unwrap().as_array().unwrap(); @@ -32,6 +32,48 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> { assert!(kargs.iter().any(|arg| arg == "kargsd-othertest=2")); assert!(kargs.iter().any(|arg| arg == "testing-kargsd=3")); + // check kernel field + let kernel = inspect + .get("kernel") + .expect("kernel field should be present") + .as_object() + .expect("kernel should be an object"); + let version = kernel + .get("version") + .expect("kernel.version should be present") + .as_str() + .expect("kernel.version should be a string"); + // Verify version is non-empty (for traditional kernels it's uname-style, for UKI it's the filename) + assert!(!version.is_empty(), "kernel.version should not be empty"); + let unified = kernel + .get("unified") + .expect("kernel.unified should be present") + .as_bool() + .expect("kernel.unified should be a boolean"); + if let Some(variant) = std::env::var("BOOTC_variant").ok() { + match variant.as_str() { + "ostree" => { + assert!(!unified, "Expected unified=false for ostree variant"); + // For traditional kernels, version should look like a uname (contains digits) + assert!( + version.chars().any(|c| c.is_ascii_digit()), + "version should contain version numbers for traditional kernel: {version}" + ); + } + "composefs-sealeduki-sdboot" => { + assert!(unified, "Expected unified=true for UKI variant"); + // For UKI, version is the filename without .efi extension (should not end with .efi) + assert!( + !version.ends_with(".efi"), + "version should not include .efi extension: {version}" + ); + // Version should be non-empty after stripping extension + assert!(!version.is_empty(), "version should not be empty for UKI"); + } + o => eprintln!("notice: Unhandled variant for kernel check: {o}"), + } + } + Ok(()) } diff --git a/docs/src/man/bootc-container-inspect.8.md b/docs/src/man/bootc-container-inspect.8.md index 758ff4186..034e723c9 100644 --- a/docs/src/man/bootc-container-inspect.8.md +++ b/docs/src/man/bootc-container-inspect.8.md @@ -8,7 +8,16 @@ bootc container inspect # DESCRIPTION -Output JSON to stdout containing the container image metadata +Output JSON to stdout containing the container image metadata. + +# OUTPUT + +The command outputs a JSON object with the following fields: + +- `kargs`: An array of kernel arguments embedded in the container image. +- `kernel`: An object containing kernel information (or `null` if no kernel is found): + - `version`: The kernel version identifier. For vmlinuz kernels, this is derived from the `/usr/lib/modules/` directory name (equivalent to `uname -r`). For UKI images, this is the UKI filename without the `.efi` extension - which should usually be the same as the uname. + - `unified`: A boolean indicating whether the kernel is packaged as a UKI (Unified Kernel Image). # OPTIONS @@ -19,6 +28,19 @@ Output JSON to stdout containing the container image metadata Default: / +**--json** + + Output in JSON format + +**--format**=*FORMAT* + + The output format + + Possible values: + - humanreadable + - yaml + - json + # EXAMPLES @@ -27,6 +49,33 @@ Inspect container image metadata: bootc container inspect +Example output (vmlinuz kernel): + +```json +{ + "kargs": [ + "console=ttyS0", + "quiet" + ], + "kernel": { + "version": "6.12.0-0.rc6.51.fc42.x86_64", + "unified": false + } +} +``` + +Example output (UKI): + +```json +{ + "kargs": [], + "kernel": { + "version": "7e11ac46e3e022053e7226a20104ac656bf72d1a", + "unified": true + } +} +``` + # SEE ALSO **bootc**(8) diff --git a/docs/src/man/bootc-container.8.md b/docs/src/man/bootc-container.8.md index 0cc53849a..c19567a11 100644 --- a/docs/src/man/bootc-container.8.md +++ b/docs/src/man/bootc-container.8.md @@ -19,7 +19,7 @@ Operations which can be executed as part of a container build | Command | Description | |---------|-------------| -| **bootc container inspect** | Output JSON to stdout containing the container image metadata | +| **bootc container inspect** | Output information about the container image | | **bootc container lint** | Perform relatively inexpensive static analysis checks as part of a container build |