From c251406825f5bd27b12d9b671270aeb9a352453d Mon Sep 17 00:00:00 2001 From: Xiaofeng Wang Date: Thu, 4 Jun 2026 06:08:14 -0400 Subject: [PATCH 1/2] feat: prune old UKI .efi files during upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only the current (new default) and previously-running UKI on the ESP; delete anything older. Without this, every upgrade added ~60-100 MB to the ESP until ukify failed writing a new image. Pruning errors are non-fatal — the upgrade has already committed its boot entry by the time pruning runs, so a permission error on a stale file should not roll back a successful upgrade. Add e2e test that plants a stale .efi before upgrade and asserts it is gone afterward with exactly 2 files remaining. --- src/upgrade.rs | 24 ++++++++++++++++++++++++ tests/e2e.py | 25 ++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/upgrade.rs b/src/upgrade.rs index ae762bf..99c69f3 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -116,6 +116,7 @@ pub fn run(reboot: bool) -> Result<()> { // without a writable efivarfs, e.g. container image builds). set_loader_conf_default(Path::new(EFI_ESP), &digest)?; bootctl_set_default(&digest)?; + prune_old_ukis(&digest)?; } else { patch_bls_entry(Path::new(BOOT_DIR), &digest, &image_ref)?; if !crate::install::has_grub2() { @@ -153,6 +154,29 @@ pub fn run(reboot: bool) -> Result<()> { } } +fn prune_old_ukis(new_digest: &str) -> Result<()> { + let running = current_composefs_digest(); + let keep: std::collections::HashSet<&str> = std::iter::once(new_digest) + .chain(running.as_deref()) + .collect(); + + for entry in fs::read_dir(EFI_LINUX_DIR).with_context(|| format!("reading {EFI_LINUX_DIR}"))? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("efi") { + continue; + } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !keep.contains(stem) { + println!("Pruning old UKI: {}", path.display()); + if let Err(e) = fs::remove_file(&path) { + eprintln!("warning: failed to prune {}: {e}", path.display()); + } + } + } + Ok(()) +} + /// Convert a Type 1 BLS entry (written by `prepare-boot` on the XBOOTLDR/`bootdir`) /// into a UKI .efi on the ESP (`esp`), where systemd-boot always scans. /// Reads the full `options` line — including the `composefs=?` token that diff --git a/tests/e2e.py b/tests/e2e.py index 0051fde..154bba3 100644 --- a/tests/e2e.py +++ b/tests/e2e.py @@ -439,16 +439,27 @@ def test_new_grub_entry_created(child): assert count >= 2, f"expected ≥2 BLS entries after upgrade, got {count}" -def test_new_uki_entry_created(child): - """Two or more UKI .efi files must exist after upgrade.""" +_STALE_UKI_STEM = "0" * 64 +_STALE_UKI = f"/boot/efi/EFI/Linux/{_STALE_UKI_STEM}.efi" + + +def plant_stale_uki(child): + """Plant a fake stale .efi (realistic hex digest stem) to give pruning something to delete.""" + rc, _ = run_cmd(child, f"touch {_STALE_UKI}") + assert rc == 0, "failed to plant stale UKI" + + +def test_uki_pruned(child): + """After upgrade: stale .efi gone, exactly 2 .efi files remain.""" + rc, _ = run_cmd(child, f"test ! -f {_STALE_UKI}") + assert rc == 0, "stale UKI was not pruned after upgrade" rc, out = run_cmd( child, "echo UKICOUNT:$(ls /boot/efi/EFI/Linux/*.efi 2>/dev/null | wc -l)" ) assert rc == 0, "could not count UKI entries" m = re.search(r"UKICOUNT:(\d+)", out) assert m, f"could not parse UKI count from output:\n{out!r}" - count = int(m.group(1)) - assert count >= 2, f"expected ≥2 UKI entries after upgrade, got {count}" + assert int(m.group(1)) == 2, f"expected exactly 2 UKIs after upgrade, got {out}" def test_upgraded_digest_active(child, previous_digest): @@ -541,10 +552,14 @@ def step(name, fn, *args): image_ref = ( f"docker://{REGISTRY_HOST}:{registry.port}/test-image:latest" ) + + if uki: + step("plant_stale_uki", plant_stale_uki, child) + step("switch_to_v2", test_switch, child, image_ref) if uki: - step("new_uki_entry_created", test_new_uki_entry_created, child) + step("uki_pruned", test_uki_pruned, child) else: step("new_grub_entry_created", test_new_grub_entry_created, child) From 2e652b79fd6a0e34116c09537000bdce0dffa936 Mon Sep 17 00:00:00 2001 From: Xiaofeng Wang Date: Thu, 4 Jun 2026 09:32:31 -0400 Subject: [PATCH 2/2] fix: prevent kernel upgrade in Arch stage-specific build steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stage-specific pacman steps (rootfs-grub, rootfs-uki, rootfs-uki-sb) used -Syu which performs a full system upgrade. When the Arch repos publish a new kernel between the rootfs-common and rootfs-grub build steps, pacman installs the new kernel's modules tree — including a vmlinuz placed there by the linux package itself — but without a matching initramfs (dracut only ran in rootfs-common for the original kernel). cfsctl's UsrLibModulesVmlinuz::load_all then finds two kernel directories: the original with initramfs and the upgraded one without. If the upgraded kernel sorts first alphabetically, cfsctl panics at self.initramfs.unwrap() in prepare-boot (upstream bug: should return a clear error instead). Switch to -S --needed in the stage-specific steps so only the listed packages are installed, leaving the kernel unchanged from rootfs-common. --- Containerfile.arch | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Containerfile.arch b/Containerfile.arch index 6df258f..5899086 100644 --- a/Containerfile.arch +++ b/Containerfile.arch @@ -168,7 +168,11 @@ RUN mkdir -p /rootfs/sysroot && \ # grub's module files (same code path as Ubuntu; Arch has 'grub', not 'grub2'). # --------------------------------------------------------------------------- FROM rootfs-common AS rootfs-grub -RUN pacman -r /rootfs --noconfirm -Syu grub efibootmgr +# -S --needed installs only the listed packages without a full system upgrade. +# Avoid -Syu here: upgrading would install a newer kernel in /usr/lib/modules/ +# without a matching initramfs (dracut ran in rootfs-common for the original +# kernel only), causing cfsctl prepare-boot to panic on the kernel-without-initramfs. +RUN pacman -r /rootfs --noconfirm -S --needed grub efibootmgr RUN rm -rf /rootfs/boot/* # --------------------------------------------------------------------------- @@ -177,7 +181,7 @@ RUN rm -rf /rootfs/boot/* # in rootfs-common. # --------------------------------------------------------------------------- FROM rootfs-common AS rootfs-uki -RUN pacman -r /rootfs --noconfirm -Syu systemd-ukify +RUN pacman -r /rootfs --noconfirm -S --needed systemd-ukify RUN rm -rf /rootfs/boot/* # --------------------------------------------------------------------------- @@ -186,7 +190,7 @@ RUN rm -rf /rootfs/boot/* # time. No shim needed: firmware verifies against the UEFI Signature Database. # --------------------------------------------------------------------------- FROM rootfs-common AS rootfs-uki-sb -RUN pacman -r /rootfs --noconfirm -Syu systemd-ukify sbsigntools openssl +RUN pacman -r /rootfs --noconfirm -S --needed systemd-ukify sbsigntools openssl RUN rm -rf /rootfs/boot/* # ---------------------------------------------------------------------------