1
0
mirror of https://github.com/containers/bootc.git synced 2026-02-05 15:45:53 +01:00

feat: Add bootc container ukify command

Add a new subcommand that builds a Unified Kernel Image (UKI) by
computing the necessary arguments from a container image and invoking
ukify. This simplifies the sealed image build workflow by having bootc
internally compute:

- The composefs digest (via existing compute-composefs-digest logic)
- Kernel arguments from /usr/lib/bootc/kargs.d/*.toml files
- Paths to kernel, initrd, and os-release

Any additional arguments are passed through to ukify unchanged, allowing
full control over signing, output paths, and other ukify options.

The seal-uki script is updated to use this new command instead of
manually computing these values and invoking ukify directly.

Also adds kargs.d configuration files for the sealed UKI workflow:
- 10-rootfs-rw.toml: Mount root filesystem read-write
- 21-console-hvc0.toml: Console configuration for QEMU/virtio

Closes: #1955

Assisted-by: OpenCode (Opus 4.5)
Signed-off-by: John Eckersberg <jeckersb@redhat.com>
This commit is contained in:
John Eckersberg
2026-01-28 11:42:59 -05:00
committed by Colin Walters
parent 4f51a5fbcb
commit 5d6dd67276
12 changed files with 320 additions and 38 deletions

View File

@@ -12,15 +12,7 @@ shift
secrets=$1
shift
# Compute the composefs digest from the target rootfs
composefs_digest=$(bootc container compute-composefs-digest "${target}")
# Build the kernel command line
# enforcing=0: https://github.com/bootc-dev/bootc/issues/1826
# TODO: pick up kargs from /usr/lib/bootc/kargs.d
cmdline="composefs=${composefs_digest} console=ttyS0,115200n8 console=hvc0 enforcing=0 rw"
# Find the kernel version
# Find the kernel version (needed for output filename)
kver=$(bootc container inspect --rootfs "${target}" --json | jq -r '.kernel.version')
if [ -z "$kver" ] || [ "$kver" = "null" ]; then
echo "Error: No kernel found" >&2
@@ -29,12 +21,14 @@ fi
mkdir -p "${output}"
ukify build \
--linux "${target}/usr/lib/modules/${kver}/vmlinuz" \
--initrd "${target}/usr/lib/modules/${kver}/initramfs.img" \
--uname="${kver}" \
--cmdline "${cmdline}" \
--os-release "@${target}/usr/lib/os-release" \
# Build the UKI using bootc container ukify
# This computes the composefs digest, reads kargs from kargs.d, and invokes ukify
#
# WORKAROUND: SELinux must be permissive for sealed UKI boot
# See https://github.com/bootc-dev/bootc/issues/1826
bootc container ukify --rootfs "${target}" \
--karg enforcing=0 \
-- \
--signtool sbsign \
--secureboot-private-key "${secrets}/secureboot_key" \
--secureboot-certificate "${secrets}/secureboot_cert" \

View File

@@ -0,0 +1,2 @@
# Mount the root filesystem read-write
kargs = ["rw"]

View File

@@ -1,2 +1,3 @@
# https://bugzilla.redhat.com/show_bug.cgi?id=2353887
kargs = ["console=hvc0"]
# console=ttyS0 for QEMU serial, console=hvc0 for virtio/Xen console
kargs = ["console=ttyS0,115200n8", "console=hvc0"]

View File

@@ -392,6 +392,29 @@ pub(crate) enum ContainerOpts {
/// Identifier for image; if not provided, the running image will be used.
image: Option<String>,
},
/// Build a Unified Kernel Image (UKI) using ukify.
///
/// This command computes the necessary arguments from the container image
/// (kernel, initrd, cmdline, os-release) and invokes ukify with them.
/// Any additional arguments after `--` are passed through to ukify unchanged.
///
/// Example:
/// bootc container ukify --rootfs /target -- --output /output/uki.efi
Ukify {
/// Operate on the provided rootfs.
#[clap(long, default_value = "/")]
rootfs: Utf8PathBuf,
/// Additional kernel arguments to append to the cmdline.
/// Can be specified multiple times.
/// This is a temporary workaround and will be removed.
#[clap(long = "karg", hide = true)]
kargs: Vec<String>,
/// Additional arguments to pass to ukify (after `--`).
#[clap(last = true)]
args: Vec<OsString>,
},
}
/// Subcommands which operate on images.
@@ -1598,6 +1621,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
Ok(())
}
ContainerOpts::Ukify {
rootfs,
kargs,
args,
} => crate::ukify::build_ukify(&rootfs, &kargs, &args),
},
Opt::Completion { shell } => {
use clap_complete::aot::generate;

View File

@@ -1587,7 +1587,7 @@ async fn prepare_install(
let composefs_required = if let Some(root) = target_rootfs.as_ref() {
crate::kernel::find_kernel(root)?
.map(|k| k.unified)
.map(|k| k.kernel.unified)
.unwrap_or(false)
} else {
false

View File

@@ -7,6 +7,7 @@
use std::path::Path;
use anyhow::Result;
use camino::Utf8PathBuf;
use cap_std_ext::cap_std::fs::Dir;
use cap_std_ext::dirext::CapStdExtDirExt;
use serde::Serialize;
@@ -25,6 +26,28 @@ pub(crate) struct Kernel {
pub(crate) unified: bool,
}
/// Internal-only kernel wrapper with extra information (paths to
/// vmlinuz, initramfs) that are useful but we don't want to leak out
/// via serialization to inspection.
///
/// `Kernel` implements `From<KernelInternal>` so we can just `.into()`
/// to get the "public" form where needed.
pub(crate) struct KernelInternal {
pub(crate) kernel: Kernel,
/// Path to vmlinuz for traditional kernels.
/// This is `None` for UKI images.
pub(crate) vmlinuz: Option<Utf8PathBuf>,
/// Path to initramfs.img for traditional kernels.
/// This is `None` for UKI images.
pub(crate) initramfs: Option<Utf8PathBuf>,
}
impl From<KernelInternal> for Kernel {
fn from(kernel_internal: KernelInternal) -> Self {
kernel_internal.kernel
}
}
/// Find the kernel in a container image root directory.
///
/// This function first attempts to find a UKI in `/boot/EFI/Linux/*.efi`.
@@ -32,28 +55,38 @@ pub(crate) struct Kernel {
/// layout with `/usr/lib/modules/<version>/vmlinuz`.
///
/// Returns `None` if no kernel is found.
pub(crate) fn find_kernel(root: &Dir) -> Result<Option<Kernel>> {
pub(crate) fn find_kernel(root: &Dir) -> Result<Option<KernelInternal>> {
// 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 {
return Ok(Some(KernelInternal {
kernel: Kernel {
version,
unified: true,
},
vmlinuz: None,
initramfs: None,
}));
}
// 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
if let Some(modules_dir) = ostree_ext::bootabletree::find_kernel_dir_fs(root)? {
let version = modules_dir
.file_name()
.ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {kernel_dir}"))?
.ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {modules_dir}"))?
.to_owned();
return Ok(Some(Kernel {
let vmlinuz = modules_dir.join("vmlinuz");
let initramfs = modules_dir.join("initramfs.img");
return Ok(Some(KernelInternal {
kernel: Kernel {
version,
unified: false,
},
vmlinuz: Some(vmlinuz),
initramfs: Some(initramfs),
}));
}
@@ -93,6 +126,7 @@ fn find_uki_filename(root: &Dir) -> Result<Option<String>> {
#[cfg(test)]
mod tests {
use super::*;
use camino::Utf8Path;
use cap_std_ext::{cap_std, cap_tempfile, dirext::CapStdExtDirExt};
#[test]
@@ -111,9 +145,21 @@ mod tests {
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);
let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel");
assert_eq!(kernel_internal.kernel.version, "6.12.0-100.fc41.x86_64");
assert!(!kernel_internal.kernel.unified);
assert_eq!(
kernel_internal.vmlinuz.as_deref(),
Some(Utf8Path::new(
"usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz"
))
);
assert_eq!(
kernel_internal.initramfs.as_deref(),
Some(Utf8Path::new(
"usr/lib/modules/6.12.0-100.fc41.x86_64/initramfs.img"
))
);
Ok(())
}
@@ -123,9 +169,11 @@ mod tests {
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);
let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel");
assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0");
assert!(kernel_internal.kernel.unified);
assert!(kernel_internal.vmlinuz.is_none());
assert!(kernel_internal.initramfs.is_none());
Ok(())
}
@@ -141,10 +189,10 @@ mod tests {
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");
let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel");
// UKI should take precedence
assert_eq!(kernel.version, "fedora-6.12.0");
assert!(kernel.unified);
assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0");
assert!(kernel_internal.kernel.unified);
Ok(())
}

View File

@@ -93,6 +93,7 @@ pub mod spec;
mod status;
mod store;
mod task;
mod ukify;
mod utils;
#[cfg(feature = "docgen")]

View File

@@ -863,7 +863,7 @@ pub(crate) fn container_inspect(
)?;
let kargs = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?;
let kargs: Vec<String> = kargs.iter_str().map(|s| s.to_owned()).collect();
let kernel = crate::kernel::find_kernel(&root)?;
let kernel = crate::kernel::find_kernel(&root)?.map(Into::into);
let inspect = crate::spec::ContainerInspect { kargs, kernel };
// Determine output format: explicit --format wins, then --json, then default to human-readable

166
crates/lib/src/ukify.rs Normal file
View File

@@ -0,0 +1,166 @@
//! Build Unified Kernel Images (UKI) using ukify.
//!
//! This module provides functionality to build UKIs by computing the necessary
//! arguments from a container image and invoking the ukify tool.
use std::ffi::OsString;
use std::process::Command;
use anyhow::{Context, Result};
use bootc_kernel_cmdline::utf8::Cmdline;
use bootc_utils::CommandRunExt;
use camino::Utf8Path;
use cap_std_ext::cap_std::fs::Dir;
use fn_error_context::context;
use crate::bootc_composefs::digest::compute_composefs_digest;
use crate::composefs_consts::COMPOSEFS_CMDLINE;
/// Build a UKI from the given rootfs.
///
/// This function:
/// 1. Verifies that ukify is available
/// 2. Finds the kernel in the rootfs
/// 3. Computes the composefs digest
/// 4. Reads kernel arguments from kargs.d
/// 5. Appends any additional kargs provided via --karg
/// 6. Invokes ukify with computed arguments plus any pass-through args
#[context("Building UKI")]
pub(crate) fn build_ukify(
rootfs: &Utf8Path,
extra_kargs: &[String],
args: &[OsString],
) -> Result<()> {
// Warn if --karg is used (temporary workaround)
if !extra_kargs.is_empty() {
tracing::warn!(
"The --karg flag is temporary and will be removed as soon as possible \
(https://github.com/bootc-dev/bootc/issues/1826)"
);
}
// Verify ukify is available
if !crate::utils::have_executable("ukify")? {
anyhow::bail!(
"ukify executable not found in PATH. Please install systemd-ukify or equivalent."
);
}
// Open the rootfs directory
let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority())
.with_context(|| format!("Opening rootfs {rootfs}"))?;
// Find the kernel
let kernel = crate::kernel::find_kernel(&root)?
.ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?;
// We can only build a UKI from a traditional kernel, not from an existing UKI
if kernel.kernel.unified {
anyhow::bail!(
"Cannot build UKI: rootfs already contains a UKI at boot/EFI/Linux/{}.efi",
kernel.kernel.version
);
}
// Get paths from the kernel info
let vmlinuz_path = kernel
.vmlinuz
.ok_or_else(|| anyhow::anyhow!("Traditional kernel should have vmlinuz path"))?;
let initramfs_path = kernel
.initramfs
.ok_or_else(|| anyhow::anyhow!("Traditional kernel should have initramfs path"))?;
// Verify kernel and initramfs exist
if !root
.try_exists(&vmlinuz_path)
.context("Checking for vmlinuz")?
{
anyhow::bail!("Kernel not found at {vmlinuz_path}");
}
if !root
.try_exists(&initramfs_path)
.context("Checking for initramfs")?
{
anyhow::bail!("Initramfs not found at {initramfs_path}");
}
// Compute the composefs digest
let composefs_digest = compute_composefs_digest(rootfs, None)?;
// Get kernel arguments from kargs.d
let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?;
// Add the composefs digest
let composefs_param = format!("{COMPOSEFS_CMDLINE}={composefs_digest}");
cmdline.extend(&Cmdline::from(composefs_param));
// Add any extra kargs provided via --karg
for karg in extra_kargs {
cmdline.extend(&Cmdline::from(karg));
}
let cmdline_str = cmdline.to_string();
// Build the ukify command with cwd set to rootfs so paths can be relative
let mut cmd = Command::new("ukify");
cmd.current_dir(rootfs);
cmd.arg("build")
.arg("--linux")
.arg(&vmlinuz_path)
.arg("--initrd")
.arg(&initramfs_path)
.arg("--uname")
.arg(&kernel.kernel.version)
.arg("--cmdline")
.arg(&cmdline_str)
.arg("--os-release")
.arg("@usr/lib/os-release");
// Add pass-through arguments
cmd.args(args);
tracing::debug!("Executing ukify: {:?}", cmd);
// Run ukify
cmd.run_inherited().context("Running ukify")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_build_ukify_no_kernel() {
let tempdir = tempfile::tempdir().unwrap();
let path = Utf8Path::from_path(tempdir.path()).unwrap();
let result = build_ukify(path, &[], &[]);
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("No kernel found") || err.contains("ukify executable not found"),
"Unexpected error message: {err}"
);
}
#[test]
fn test_build_ukify_already_uki() {
let tempdir = tempfile::tempdir().unwrap();
let path = Utf8Path::from_path(tempdir.path()).unwrap();
// Create a UKI structure
fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap();
fs::write(tempdir.path().join("boot/EFI/Linux/test.efi"), b"fake uki").unwrap();
let result = build_ukify(path, &[], &[]);
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("already contains a UKI") || err.contains("ukify executable not found"),
"Unexpected error message: {err}"
);
}
}

View File

@@ -67,7 +67,6 @@ pub(crate) fn find_mount_option<'a>(
.next()
}
#[allow(dead_code)]
pub fn have_executable(name: &str) -> Result<bool> {
let Some(path) = std::env::var_os("PATH") else {
return Ok(false);

View File

@@ -0,0 +1,42 @@
# NAME
bootc-container-ukify - Build a Unified Kernel Image (UKI) using ukify
# SYNOPSIS
bootc container ukify [OPTIONS] [-- UKIFY_ARGS...]
# DESCRIPTION
Build a Unified Kernel Image (UKI) using ukify
This command computes the necessary arguments from the container image
(kernel, initrd, cmdline, os-release) and invokes ukify with them.
Any additional arguments after `--` are passed through to ukify unchanged.
# OPTIONS
<!-- BEGIN GENERATED OPTIONS -->
**ARGS**
Additional arguments to pass to ukify (after `--`)
**--rootfs**=*ROOTFS*
Operate on the provided rootfs
Default: /
<!-- END GENERATED OPTIONS -->
# EXAMPLES
bootc container ukify --rootfs /target -- --output /output/uki.efi
# SEE ALSO
**bootc**(8), **ukify**(1)
# VERSION
<!-- VERSION PLACEHOLDER -->

View File

@@ -21,6 +21,7 @@ Operations which can be executed as part of a container build
|---------|-------------|
| **bootc container inspect** | Output information about the container image |
| **bootc container lint** | Perform relatively inexpensive static analysis checks as part of a container build |
| **bootc container ukify** | Build a Unified Kernel Image (UKI) using ukify |
<!-- END GENERATED SUBCOMMANDS -->