Skip to content

Commit ee5d1d6

Browse files
committed
fix: use loader.conf default instead of bootctl for UKI boot selection
bootctl set-default / set-next require either the bootctl binary in PATH or a writable efivarfs (backing OVMF_VARS flash device). Both may be absent: the Ubuntu container image does not guarantee bootctl is on PATH, and test VMs often run with a read-only combined OVMF_CODE.fd that has no separate writable VARS. Fix: write `default <entry-id>` directly into /boot/efi/loader/loader.conf. Systemd-boot reads this file at every boot and honours it as the default entry when no EFI variable preference is set. The file is on the FAT ESP which is always writable when cbootc runs. upgrade.rs: replace `bootctl set-default` with set_loader_conf_default() rollback.rs: replace `bootctl set-next` / set_next_entry_bootctl() with the same helper (persistent rollback — stays on old entry until the next explicit upgrade, which is the correct behaviour here) tests/e2e.py: replace test_bootctl_next_entry_set (EFI var check) with test_loader_conf_default_set (loader.conf check); drop _diag
1 parent 81393dd commit ee5d1d6

3 files changed

Lines changed: 43 additions & 42 deletions

File tree

src/rollback.rs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,6 @@ fn load_uki_entries() -> Result<Vec<BLSEntry>> {
152152
Ok(entries)
153153
}
154154

155-
fn set_next_entry_bootctl(id: &str) -> Result<()> {
156-
let status = Command::new("bootctl")
157-
.args(["set-next", id])
158-
.status()
159-
.context("spawning bootctl set-next")?;
160-
if !status.success() {
161-
bail!("bootctl set-next failed: {status}");
162-
}
163-
Ok(())
164-
}
165-
166155
fn use_systemd_boot() -> bool {
167156
Path::new(EFI_LINUX_DIR).exists() && grubenv_path().is_none()
168157
}
@@ -190,7 +179,7 @@ pub fn run() -> Result<()> {
190179

191180
let id = entry_id(&previous.path);
192181
if systemd_boot {
193-
set_next_entry_bootctl(id)?;
182+
crate::upgrade::set_loader_conf_default(std::path::Path::new("/boot/efi"), id)?;
194183
} else if crate::install::has_grub2() {
195184
// Fedora/RHEL: grub2's blscfg.mod reads BLS entries natively and
196185
// matches next_entry=<digest> against each entry's --id.

src/upgrade.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,36 @@ use std::{fs, os::unix::fs::symlink, path::Path, path::PathBuf, process::Command
55

66
use crate::{cfsctl, config, signing};
77

8+
/// Write (or update) the `default <entry_id>` line in
9+
/// `<esp>/loader/loader.conf` so systemd-boot boots `entry_id` on the next
10+
/// reboot. Writing the file directly avoids any dependency on `bootctl` or a
11+
/// writable efivarfs — both of which may be absent in container/VM contexts.
12+
pub(crate) fn set_loader_conf_default(esp: &Path, entry_id: &str) -> Result<()> {
13+
let conf = esp.join("loader/loader.conf");
14+
fs::create_dir_all(conf.parent().unwrap()).context("creating loader dir")?;
15+
let existing = if conf.exists() {
16+
fs::read_to_string(&conf).with_context(|| format!("reading {}", conf.display()))?
17+
} else {
18+
String::new()
19+
};
20+
let mut replaced = false;
21+
let mut lines: Vec<String> = existing
22+
.lines()
23+
.map(|l| {
24+
if l.starts_with("default ") || l == "default" {
25+
replaced = true;
26+
format!("default {entry_id}")
27+
} else {
28+
l.to_owned()
29+
}
30+
})
31+
.collect();
32+
if !replaced {
33+
lines.push(format!("default {entry_id}"));
34+
}
35+
fs::write(&conf, lines.join("\n") + "\n").with_context(|| format!("writing {}", conf.display()))
36+
}
37+
838
const EFI_ESP: &str = "/boot/efi";
939
// UKIs live on the ESP, not XBOOTLDR — systemd-boot always scans its own partition.
1040
const EFI_LINUX_DIR: &str = "/boot/efi/EFI/Linux";
@@ -65,6 +95,11 @@ pub fn run(reboot: bool) -> Result<()> {
6595
println!("Signing UKI ...");
6696
crate::install::sign_efi(&uki_path, Path::new(&sb.key), Path::new(&sb.cert))?;
6797
}
98+
// Make the new UKI the permanent default so systemd-boot boots it on
99+
// the next reboot regardless of how the EFI/Linux/*.efi entries sort.
100+
// Write loader.conf directly — does not require `bootctl` or a
101+
// writable efivarfs (both may be absent in container/VM contexts).
102+
set_loader_conf_default(Path::new(EFI_ESP), &digest)?;
68103
} else {
69104
patch_bls_entry(Path::new(BOOT_DIR), &digest, &image_ref)?;
70105
if !crate::install::has_grub2() {

tests/e2e.py

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -438,14 +438,13 @@ def test_grubenv_next_entry_set(child):
438438
)
439439

440440

441-
def test_bootctl_next_entry_set(child):
442-
"""After rollback, the LoaderEntryOneShot EFI variable must be present."""
443-
efivar_guid = "4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
444-
rc, _ = run_cmd(
445-
child,
446-
f"test -e /sys/firmware/efi/efivars/LoaderEntryOneShot-{efivar_guid}",
441+
def test_loader_conf_default_set(child):
442+
"""After rollback, /boot/efi/loader/loader.conf must have a 'default' entry."""
443+
rc, out = run_cmd(child, "cat /boot/efi/loader/loader.conf 2>/dev/null || true")
444+
assert rc == 0, "could not read loader.conf"
445+
assert re.search(r"^default\s+\S", out, re.MULTILINE), (
446+
f"no 'default' line in /boot/efi/loader/loader.conf:\n{out!r}"
447447
)
448-
assert rc == 0, "LoaderEntryOneShot EFI variable not set after cbootc rollback"
449448

450449

451450
def test_rolled_back_digest_active(child, expected_digest):
@@ -456,22 +455,6 @@ def test_rolled_back_digest_active(child, expected_digest):
456455
)
457456

458457

459-
def _diag(child, label):
460-
"""Run diagnostic commands for debugging; never raises."""
461-
print(f"\n--- DIAG: {label} ---")
462-
for cmd in [
463-
"which bootctl 2>&1 || echo 'bootctl not found'",
464-
"bootctl --version 2>&1 | head -1 || true",
465-
"mount | grep -E 'boot|efi' || echo 'no boot/efi mounts'",
466-
"ls /boot/efi/EFI/Linux/ 2>&1 || echo 'EFI/Linux not accessible'",
467-
"ls /sys/firmware/efi/efivars/ 2>&1 | wc -l || echo 'no efivars'",
468-
"bootctl status 2>&1 | head -20 || true",
469-
]:
470-
_, out = run_cmd(child, cmd)
471-
print(out.strip())
472-
print(f"--- END DIAG: {label} ---\n")
473-
474-
475458
def run_upgrade_sequence(disk_image, ovmf_code, registry, uki=False,
476459
secure_boot=False, ovmf_vars=None):
477460
"""Three-boot upgrade → rollback sequence on a sparse copy of disk_image.
@@ -512,9 +495,6 @@ def step(name, fn, *args):
512495
step("configure_guest_network", configure_guest_network, child)
513496
step("wait_for_network", wait_for_network, child)
514497

515-
if uki:
516-
_diag(child, "before switch_to_v2 (UKI)")
517-
518498
image_ref = (
519499
f"docker://{REGISTRY_HOST}:{registry.port}/test-image:latest"
520500
)
@@ -537,13 +517,10 @@ def step(name, fn, *args):
537517
if secure_boot:
538518
step("secure_boot_enabled_boot2", test_secure_boot_enabled, child)
539519

540-
if uki:
541-
_diag(child, "before rollback_succeeds (UKI Boot 2)")
542-
543520
step("rollback_succeeds", test_rollback_succeeds, child)
544521

545522
if uki:
546-
step("bootctl_next_entry_set", test_bootctl_next_entry_set, child)
523+
step("loader_conf_default_set", test_loader_conf_default_set, child)
547524
else:
548525
step("grubenv_next_entry_set", test_grubenv_next_entry_set, child)
549526

0 commit comments

Comments
 (0)