Skip to content

Commit 0e6f4a8

Browse files
committed
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>
1 parent 5c52b25 commit 0e6f4a8

11 files changed

Lines changed: 297 additions & 20 deletions

File tree

contrib/packaging/seal-uki

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,7 @@ shift
1212
secrets=$1
1313
shift
1414

15-
# Compute the composefs digest from the target rootfs
16-
composefs_digest=$(bootc container compute-composefs-digest "${target}")
17-
18-
# Build the kernel command line
19-
# enforcing=0: https://github.com/bootc-dev/bootc/issues/1826
20-
# TODO: pick up kargs from /usr/lib/bootc/kargs.d
21-
cmdline="composefs=${composefs_digest} console=ttyS0,115200n8 console=hvc0 enforcing=0 rw"
22-
23-
# Find the kernel version
15+
# Find the kernel version (needed for output filename)
2416
kver=$(bootc container inspect --rootfs "${target}" --json | jq -r '.kernel.version')
2517
if [ -z "$kver" ] || [ "$kver" = "null" ]; then
2618
echo "Error: No kernel found" >&2
@@ -29,12 +21,14 @@ fi
2921

3022
mkdir -p "${output}"
3123

32-
ukify build \
33-
--linux "${target}/usr/lib/modules/${kver}/vmlinuz" \
34-
--initrd "${target}/usr/lib/modules/${kver}/initramfs.img" \
35-
--uname="${kver}" \
36-
--cmdline "${cmdline}" \
37-
--os-release "@${target}/usr/lib/os-release" \
24+
# Build the UKI using bootc container ukify
25+
# This computes the composefs digest, reads kargs from kargs.d, and invokes ukify
26+
#
27+
# WORKAROUND: SELinux must be permissive for sealed UKI boot
28+
# See https://github.com/bootc-dev/bootc/issues/1826
29+
bootc container ukify --rootfs "${target}" \
30+
--karg enforcing=0 \
31+
-- \
3832
--signtool sbsign \
3933
--secureboot-private-key "${secrets}/secureboot_key" \
4034
--secureboot-certificate "${secrets}/secureboot_cert" \
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Mount the root filesystem read-write
2+
kargs = ["rw"]
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# https://bugzilla.redhat.com/show_bug.cgi?id=2353887
2-
kargs = ["console=hvc0"]
2+
# console=ttyS0 for QEMU serial, console=hvc0 for virtio/Xen console
3+
kargs = ["console=ttyS0,115200n8", "console=hvc0"]

crates/lib/src/cli.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,29 @@ pub(crate) enum ContainerOpts {
392392
/// Identifier for image; if not provided, the running image will be used.
393393
image: Option<String>,
394394
},
395+
/// Build a Unified Kernel Image (UKI) using ukify.
396+
///
397+
/// This command computes the necessary arguments from the container image
398+
/// (kernel, initrd, cmdline, os-release) and invokes ukify with them.
399+
/// Any additional arguments after `--` are passed through to ukify unchanged.
400+
///
401+
/// Example:
402+
/// bootc container ukify --rootfs /target -- --output /output/uki.efi
403+
Ukify {
404+
/// Operate on the provided rootfs.
405+
#[clap(long, default_value = "/")]
406+
rootfs: Utf8PathBuf,
407+
408+
/// Additional kernel arguments to append to the cmdline.
409+
/// Can be specified multiple times.
410+
/// This is a temporary workaround and will be removed.
411+
#[clap(long = "karg", hide = true)]
412+
kargs: Vec<String>,
413+
414+
/// Additional arguments to pass to ukify (after `--`).
415+
#[clap(last = true)]
416+
args: Vec<OsString>,
417+
},
395418
}
396419

397420
/// Subcommands which operate on images.
@@ -1598,6 +1621,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15981621

15991622
Ok(())
16001623
}
1624+
ContainerOpts::Ukify {
1625+
rootfs,
1626+
kargs,
1627+
args,
1628+
} => crate::ukify::build_ukify(&rootfs, &kargs, &args),
16011629
},
16021630
Opt::Completion { shell } => {
16031631
use clap_complete::aot::generate;

crates/lib/src/kernel.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use std::path::Path;
88

99
use anyhow::Result;
10+
use camino::Utf8PathBuf;
1011
use cap_std_ext::cap_std::fs::Dir;
1112
use cap_std_ext::dirext::CapStdExtDirExt;
1213
use serde::Serialize;
@@ -23,6 +24,24 @@ pub(crate) struct Kernel {
2324
pub(crate) version: String,
2425
/// Whether the kernel is packaged as a UKI (Unified Kernel Image).
2526
pub(crate) unified: bool,
27+
/// For traditional kernels, the path to the modules directory (e.g., `usr/lib/modules/6.12.0`).
28+
/// This is `None` for UKI images.
29+
#[serde(skip_serializing_if = "Option::is_none")]
30+
pub(crate) modules_dir: Option<Utf8PathBuf>,
31+
}
32+
33+
impl Kernel {
34+
/// Returns the path to vmlinuz for traditional kernels.
35+
/// Returns `None` for UKI images.
36+
pub(crate) fn vmlinuz_path(&self) -> Option<Utf8PathBuf> {
37+
self.modules_dir.as_ref().map(|d| d.join("vmlinuz"))
38+
}
39+
40+
/// Returns the path to initramfs.img for traditional kernels.
41+
/// Returns `None` for UKI images.
42+
pub(crate) fn initramfs_path(&self) -> Option<Utf8PathBuf> {
43+
self.modules_dir.as_ref().map(|d| d.join("initramfs.img"))
44+
}
2645
}
2746

2847
/// Find the kernel in a container image root directory.
@@ -42,18 +61,20 @@ pub(crate) fn find_kernel(root: &Dir) -> Result<Option<Kernel>> {
4261
return Ok(Some(Kernel {
4362
version,
4463
unified: true,
64+
modules_dir: None,
4565
}));
4666
}
4767

4868
// Fall back to checking for a traditional kernel via ostree_ext
49-
if let Some(kernel_dir) = ostree_ext::bootabletree::find_kernel_dir_fs(root)? {
50-
let version = kernel_dir
69+
if let Some(modules_dir) = ostree_ext::bootabletree::find_kernel_dir_fs(root)? {
70+
let version = modules_dir
5171
.file_name()
52-
.ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {kernel_dir}"))?
72+
.ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {modules_dir}"))?
5373
.to_owned();
5474
return Ok(Some(Kernel {
5575
version,
5676
unified: false,
77+
modules_dir: Some(modules_dir),
5778
}));
5879
}
5980

@@ -93,6 +114,7 @@ fn find_uki_filename(root: &Dir) -> Result<Option<String>> {
93114
#[cfg(test)]
94115
mod tests {
95116
use super::*;
117+
use camino::Utf8Path;
96118
use cap_std_ext::{cap_std, cap_tempfile, dirext::CapStdExtDirExt};
97119

98120
#[test]
@@ -114,6 +136,22 @@ mod tests {
114136
let kernel = find_kernel(&tempdir)?.expect("should find kernel");
115137
assert_eq!(kernel.version, "6.12.0-100.fc41.x86_64");
116138
assert!(!kernel.unified);
139+
assert_eq!(
140+
kernel.modules_dir.as_deref(),
141+
Some(Utf8Path::new("usr/lib/modules/6.12.0-100.fc41.x86_64"))
142+
);
143+
assert_eq!(
144+
kernel.vmlinuz_path().as_deref(),
145+
Some(Utf8Path::new(
146+
"usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz"
147+
))
148+
);
149+
assert_eq!(
150+
kernel.initramfs_path().as_deref(),
151+
Some(Utf8Path::new(
152+
"usr/lib/modules/6.12.0-100.fc41.x86_64/initramfs.img"
153+
))
154+
);
117155
Ok(())
118156
}
119157

@@ -126,6 +164,9 @@ mod tests {
126164
let kernel = find_kernel(&tempdir)?.expect("should find kernel");
127165
assert_eq!(kernel.version, "fedora-6.12.0");
128166
assert!(kernel.unified);
167+
assert!(kernel.modules_dir.is_none());
168+
assert!(kernel.vmlinuz_path().is_none());
169+
assert!(kernel.initramfs_path().is_none());
129170
Ok(())
130171
}
131172

crates/lib/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ pub mod spec;
9393
mod status;
9494
mod store;
9595
mod task;
96+
mod ukify;
9697
mod utils;
9798

9899
#[cfg(feature = "docgen")]

crates/lib/src/status.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,7 @@ mod tests {
11021102
kernel: Some(crate::kernel::Kernel {
11031103
version: "6.12.0-100.fc41.x86_64".into(),
11041104
unified: false,
1105+
modules_dir: Some("usr/lib/modules/6.12.0-100.fc41.x86_64".into()),
11051106
}),
11061107
};
11071108
let mut w = Vec::new();
@@ -1122,6 +1123,7 @@ mod tests {
11221123
kernel: Some(crate::kernel::Kernel {
11231124
version: "6.12.0-100.fc41.x86_64".into(),
11241125
unified: true,
1126+
modules_dir: None,
11251127
}),
11261128
};
11271129
let mut w = Vec::new();

crates/lib/src/ukify.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//! Build Unified Kernel Images (UKI) using ukify.
2+
//!
3+
//! This module provides functionality to build UKIs by computing the necessary
4+
//! arguments from a container image and invoking the ukify tool.
5+
6+
use std::ffi::OsString;
7+
use std::process::Command;
8+
9+
use anyhow::{Context, Result};
10+
use bootc_kernel_cmdline::utf8::Cmdline;
11+
use bootc_utils::CommandRunExt;
12+
use camino::Utf8Path;
13+
use cap_std_ext::cap_std::fs::Dir;
14+
use fn_error_context::context;
15+
16+
use crate::bootc_composefs::digest::compute_composefs_digest;
17+
use crate::composefs_consts::COMPOSEFS_CMDLINE;
18+
19+
/// Build a UKI from the given rootfs.
20+
///
21+
/// This function:
22+
/// 1. Verifies that ukify is available
23+
/// 2. Finds the kernel in the rootfs
24+
/// 3. Computes the composefs digest
25+
/// 4. Reads kernel arguments from kargs.d
26+
/// 5. Appends any additional kargs provided via --karg
27+
/// 6. Invokes ukify with computed arguments plus any pass-through args
28+
#[context("Building UKI")]
29+
pub(crate) fn build_ukify(
30+
rootfs: &Utf8Path,
31+
extra_kargs: &[String],
32+
args: &[OsString],
33+
) -> Result<()> {
34+
// Warn if --karg is used (temporary workaround)
35+
if !extra_kargs.is_empty() {
36+
tracing::warn!(
37+
"The --karg flag is temporary and will be removed as soon as possible \
38+
(https://github.com/bootc-dev/bootc/issues/1826)"
39+
);
40+
}
41+
42+
// Verify ukify is available
43+
if !crate::utils::have_executable("ukify")? {
44+
anyhow::bail!(
45+
"ukify executable not found in PATH. Please install systemd-ukify or equivalent."
46+
);
47+
}
48+
49+
// Open the rootfs directory
50+
let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority())
51+
.with_context(|| format!("Opening rootfs {rootfs}"))?;
52+
53+
// Find the kernel
54+
let kernel = crate::kernel::find_kernel(&root)?
55+
.ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?;
56+
57+
// We can only build a UKI from a traditional kernel, not from an existing UKI
58+
if kernel.unified {
59+
anyhow::bail!(
60+
"Cannot build UKI: rootfs already contains a UKI at boot/EFI/Linux/{}.efi",
61+
kernel.version
62+
);
63+
}
64+
65+
// Get paths from the kernel info
66+
let vmlinuz_path = kernel
67+
.vmlinuz_path()
68+
.ok_or_else(|| anyhow::anyhow!("Traditional kernel should have vmlinuz path"))?;
69+
let initramfs_path = kernel
70+
.initramfs_path()
71+
.ok_or_else(|| anyhow::anyhow!("Traditional kernel should have initramfs path"))?;
72+
73+
// Verify kernel and initramfs exist
74+
if !root
75+
.try_exists(&vmlinuz_path)
76+
.context("Checking for vmlinuz")?
77+
{
78+
anyhow::bail!("Kernel not found at {vmlinuz_path}");
79+
}
80+
if !root
81+
.try_exists(&initramfs_path)
82+
.context("Checking for initramfs")?
83+
{
84+
anyhow::bail!("Initramfs not found at {initramfs_path}");
85+
}
86+
87+
// Compute the composefs digest
88+
let composefs_digest = compute_composefs_digest(rootfs, None)?;
89+
90+
// Get kernel arguments from kargs.d
91+
let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?;
92+
93+
// Add the composefs digest
94+
let composefs_param = format!("{COMPOSEFS_CMDLINE}={composefs_digest}");
95+
cmdline.extend(&Cmdline::from(composefs_param));
96+
97+
// Add any extra kargs provided via --karg
98+
for karg in extra_kargs {
99+
cmdline.extend(&Cmdline::from(karg));
100+
}
101+
102+
let cmdline_str = cmdline.to_string();
103+
104+
// Build the ukify command with cwd set to rootfs so paths can be relative
105+
let mut cmd = Command::new("ukify");
106+
cmd.current_dir(rootfs);
107+
cmd.arg("build")
108+
.arg("--linux")
109+
.arg(&vmlinuz_path)
110+
.arg("--initrd")
111+
.arg(&initramfs_path)
112+
.arg("--uname")
113+
.arg(&kernel.version)
114+
.arg("--cmdline")
115+
.arg(&cmdline_str)
116+
.arg("--os-release")
117+
.arg("@usr/lib/os-release");
118+
119+
// Add pass-through arguments
120+
cmd.args(args);
121+
122+
tracing::debug!("Executing ukify: {:?}", cmd);
123+
124+
// Run ukify
125+
cmd.run_inherited().context("Running ukify")?;
126+
127+
Ok(())
128+
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::*;
133+
use std::fs;
134+
135+
#[test]
136+
fn test_build_ukify_no_kernel() {
137+
let tempdir = tempfile::tempdir().unwrap();
138+
let path = Utf8Path::from_path(tempdir.path()).unwrap();
139+
140+
let result = build_ukify(path, &[], &[]);
141+
assert!(result.is_err());
142+
let err = format!("{:#}", result.unwrap_err());
143+
assert!(
144+
err.contains("No kernel found") || err.contains("ukify executable not found"),
145+
"Unexpected error message: {err}"
146+
);
147+
}
148+
149+
#[test]
150+
fn test_build_ukify_already_uki() {
151+
let tempdir = tempfile::tempdir().unwrap();
152+
let path = Utf8Path::from_path(tempdir.path()).unwrap();
153+
154+
// Create a UKI structure
155+
fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap();
156+
fs::write(tempdir.path().join("boot/EFI/Linux/test.efi"), b"fake uki").unwrap();
157+
158+
let result = build_ukify(path, &[], &[]);
159+
assert!(result.is_err());
160+
let err = format!("{:#}", result.unwrap_err());
161+
assert!(
162+
err.contains("already contains a UKI") || err.contains("ukify executable not found"),
163+
"Unexpected error message: {err}"
164+
);
165+
}
166+
}

crates/lib/src/utils.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ pub(crate) fn find_mount_option<'a>(
6767
.next()
6868
}
6969

70-
#[allow(dead_code)]
7170
pub fn have_executable(name: &str) -> Result<bool> {
7271
let Some(path) = std::env::var_os("PATH") else {
7372
return Ok(false);

0 commit comments

Comments
 (0)