Skip to content

Commit ab943ce

Browse files
authored
Merge pull request #3 from henrywang/more-tests
feat: add upgrade/switch/rollback e2e tests for all 4 boot variants
2 parents e0015e8 + a2afa5f commit ab943ce

9 files changed

Lines changed: 708 additions & 28 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ jobs:
130130
- name: Run e2e tests (UKI+SB)
131131
run: just e2e-uki-secureboot disk-uki-sb.raw
132132

133+
- name: Pull registry image for upgrade tests
134+
run: sudo podman pull docker.io/library/registry:2
135+
136+
- name: Run upgrade/switch/rollback tests (GRUB)
137+
run: just e2e-upgrade composefs-os-test:latest disk.raw
138+
139+
- name: Run upgrade/switch/rollback tests (GRUB + Secure Boot)
140+
run: just e2e-upgrade-secureboot composefs-os-test:latest disk-sb.raw
141+
142+
- name: Run upgrade/switch/rollback tests (UKI)
143+
run: |
144+
OVMF_VARS=$(find /usr/share -name 'OVMF_VARS.fd' ! -name '*.secboot*' ! -name '*.ms.*' | head -1)
145+
just e2e-uki-upgrade composefs-os-uki-test:latest disk-uki.raw "$OVMF_VARS"
146+
147+
- name: Run upgrade/switch/rollback tests (UKI + Secure Boot)
148+
run: just e2e-uki-upgrade-secureboot composefs-os-uki-sb-test:latest disk-uki-sb.raw
149+
133150
- name: Upload disk images on failure
134151
if: failure()
135152
uses: actions/upload-artifact@v4
@@ -260,6 +277,23 @@ jobs:
260277
- name: Run e2e tests (Ubuntu UKI+SB)
261278
run: just e2e-uki-secureboot disk-ubuntu-uki-sb.raw
262279

280+
- name: Pull registry image for upgrade tests
281+
run: sudo podman pull docker.io/library/registry:2
282+
283+
- name: Run upgrade/switch/rollback tests (Ubuntu GRUB)
284+
run: just e2e-upgrade-ubuntu composefs-os-ubuntu-test:latest disk-ubuntu.raw
285+
286+
- name: Run upgrade/switch/rollback tests (Ubuntu GRUB + Secure Boot)
287+
run: just e2e-upgrade-secureboot-ubuntu composefs-os-ubuntu-test:latest disk-ubuntu-sb.raw
288+
289+
- name: Run upgrade/switch/rollback tests (Ubuntu UKI)
290+
run: |
291+
OVMF_VARS=$(find /usr/share -name 'OVMF_VARS.fd' ! -name '*.secboot*' ! -name '*.ms.*' | head -1)
292+
just e2e-uki-upgrade-ubuntu composefs-os-ubuntu-uki-test:latest disk-ubuntu-uki.raw "$OVMF_VARS"
293+
294+
- name: Run upgrade/switch/rollback tests (Ubuntu UKI + Secure Boot)
295+
run: just e2e-uki-upgrade-secureboot-ubuntu composefs-os-ubuntu-uki-sb-test:latest disk-ubuntu-uki-sb.raw
296+
263297
- name: Upload Ubuntu disk images on failure
264298
if: failure()
265299
uses: actions/upload-artifact@v4

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
*.raw
55
*.raw.sb.cer
66
ovmf-vars-*.fd
7+
/tests/__pycache__/
8+
/e2e-console.log

Containerfile.ubuntu

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ RUN mkdir -p /usr/share/efi/EFI/ubuntu && \
7373
# ---------------------------------------------------------------------------
7474
FROM rootfs-base AS rootfs-uki
7575

76+
# systemd-boot: provides bootctl (boot entry management, needed by cbootc at runtime).
77+
# systemd-boot-efi: provides the systemd-bootx64.efi binary copied to the ESP by cbootc install.
78+
# Ubuntu splits these into two packages; on Fedora both are in systemd-boot-unsigned.
7679
RUN apt-get update && apt-get install -y --no-install-recommends \
80+
systemd-boot \
7781
systemd-boot-efi \
7882
systemd-ukify \
7983
&& rm -rf /var/lib/apt/lists/*
@@ -83,7 +87,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
8387
# ---------------------------------------------------------------------------
8488
FROM rootfs-base AS rootfs-uki-sb
8589

90+
# Same rationale as rootfs-uki above.
8691
RUN apt-get update && apt-get install -y --no-install-recommends \
92+
systemd-boot \
8793
systemd-boot-efi \
8894
systemd-ukify \
8995
sbsigntool \

examples/fedora/Containerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ RUN mkdir -p /usr/lib/systemd/system/serial-getty@ttyS0.service.d && \
3434
printf '[Service]\nExecStart=\nExecStart=-/sbin/agetty --autologin root --noclear %%I 115200 vt220\n' \
3535
> /usr/lib/systemd/system/serial-getty@ttyS0.service.d/autologin.conf
3636

37+
# gawk provides 'awk', used by the e2e test's configure_guest_network helper.
3738
# Add your packages here, e.g.:
38-
RUN dnf install -y --setopt=install_weak_deps=False vim htop && dnf clean all
39+
RUN dnf install -y --setopt=install_weak_deps=False gawk && dnf clean all
3940

4041
# --- Do not edit below this line ------------------------------------------
4142

examples/ubuntu/Containerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ RUN mkdir -p /usr/lib/systemd/system/serial-getty@ttyS0.service.d && \
3434
printf '[Service]\nExecStart=\nExecStart=-/sbin/agetty --autologin root --noclear %%I 115200 vt220\n' \
3535
> /usr/lib/systemd/system/serial-getty@ttyS0.service.d/autologin.conf
3636

37+
# gawk provides 'awk', used by the e2e test's configure_guest_network helper.
3738
# Add your packages here, e.g.:
38-
# RUN apt-get install -y --no-install-recommends vim htop \
39-
# && rm -rf /var/lib/apt/lists/*
39+
RUN apt-get update && apt-get install -y --no-install-recommends gawk \
40+
&& rm -rf /var/lib/apt/lists/*
4041

4142
# --- Do not edit below this line ------------------------------------------
4243

justfile

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,51 @@ e2e-uki-secureboot disk="disk-uki-sb.raw" ovmf_vars="":
176176
fi
177177
python3 tests/e2e.py --uki-secureboot --ovmf-vars "$vars" {{disk}}
178178

179+
# ── Upgrade / Switch / Rollback e2e tests ────────────────────────────────────
180+
181+
# Run upgrade/switch/rollback e2e against a GRUB disk image (requires sudo for podman)
182+
e2e-upgrade image=example_image disk="disk.raw":
183+
sudo python3 tests/e2e.py --upgrade --source-image {{image}} {{disk}}
184+
185+
# Run upgrade/switch/rollback e2e against a GRUB + Secure Boot disk image
186+
e2e-upgrade-secureboot image=example_image disk="disk-sb.raw":
187+
sudo python3 tests/e2e.py --upgrade --secure-boot --source-image {{image}} {{disk}}
188+
189+
# Run upgrade/switch/rollback e2e against a UKI/systemd-boot disk image
190+
e2e-uki-upgrade image=example_image_uki disk="disk-uki.raw" ovmf_vars="":
191+
v="{{ovmf_vars}}"; sudo python3 tests/e2e.py --upgrade --uki --source-image {{image}} ${v:+--ovmf-vars "$v"} {{disk}}
192+
193+
# Run upgrade/switch/rollback e2e against a UKI + Secure Boot disk image
194+
e2e-uki-upgrade-secureboot image=example_image_uki_sb disk="disk-uki-sb.raw" ovmf_vars="":
195+
#!/usr/bin/env bash
196+
set -euo pipefail
197+
vars="{{ovmf_vars}}"
198+
if [ -z "$vars" ]; then
199+
just prep-sb-vars {{disk}} ovmf-vars-uki-sb.fd
200+
vars=ovmf-vars-uki-sb.fd
201+
fi
202+
sudo python3 tests/e2e.py --upgrade --uki-secureboot --source-image {{image}} --ovmf-vars "$vars" {{disk}}
203+
204+
# Ubuntu upgrade variants
205+
e2e-upgrade-ubuntu image=example_image_ubuntu disk="disk-ubuntu.raw":
206+
sudo python3 tests/e2e.py --upgrade --source-image {{image}} {{disk}}
207+
208+
e2e-upgrade-secureboot-ubuntu image=example_image_ubuntu disk="disk-ubuntu-sb.raw":
209+
sudo python3 tests/e2e.py --upgrade --secure-boot --source-image {{image}} {{disk}}
210+
211+
e2e-uki-upgrade-ubuntu image=example_image_ubuntu_uki disk="disk-ubuntu-uki.raw" ovmf_vars="":
212+
v="{{ovmf_vars}}"; sudo python3 tests/e2e.py --upgrade --uki --source-image {{image}} ${v:+--ovmf-vars "$v"} {{disk}}
213+
214+
e2e-uki-upgrade-secureboot-ubuntu image=example_image_ubuntu_uki_sb disk="disk-ubuntu-uki-sb.raw" ovmf_vars="":
215+
#!/usr/bin/env bash
216+
set -euo pipefail
217+
vars="{{ovmf_vars}}"
218+
if [ -z "$vars" ]; then
219+
just prep-sb-vars {{disk}} ovmf-vars-ubuntu-uki-sb.fd
220+
vars=ovmf-vars-ubuntu-uki-sb.fd
221+
fi
222+
sudo python3 tests/e2e.py --upgrade --uki-secureboot --source-image {{image}} --ovmf-vars "$vars" {{disk}}
223+
179224
# ── Convenience combos ────────────────────────────────────────────────────────
180225

181226
# Rust checks only — fast, no containers needed
@@ -216,6 +261,16 @@ ci-ubuntu-uki-secureboot: build-base-ubuntu-uki-secureboot (build-example-ubuntu
216261
just install-disk-uki-secureboot {{example_image_ubuntu_uki_sb}} disk-ubuntu-uki-sb.raw 5G
217262
just e2e-uki-secureboot disk-ubuntu-uki-sb.raw
218263

264+
# Full GRUB upgrade/switch/rollback workflow (Fedora)
265+
ci-grub-upgrade: build-base (build-example base_image)
266+
just install-disk
267+
just e2e-upgrade
268+
269+
# Full UKI upgrade/switch/rollback workflow (Fedora)
270+
ci-uki-upgrade: build-base-uki (build-example-uki base_image_uki)
271+
just install-disk-uki
272+
just e2e-uki-upgrade
273+
219274
# ── Cleanup ───────────────────────────────────────────────────────────────────
220275

221276
# Remove Rust build artifacts

src/rollback.rs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,40 @@ fn set_next_entry(id: &str) -> Result<()> {
8888
bail!("neither grub2-editenv nor grub-editenv found in PATH")
8989
}
9090

91+
/// Return the GRUB menuentry index (0-based, newest-first) of the BLS entry
92+
/// whose file stem equals `target_id`.
93+
///
94+
/// Ubuntu's GRUB does not ship blscfg.mod, so cbootc generates a traditional
95+
/// grub.cfg via write_grub_menuentry_cfg, sorted newest-first by mtime.
96+
/// Ubuntu's GRUB does not reliably match `set default=<128-char-id>` against
97+
/// `menuentry --id <id>`, so we write the numeric index instead.
98+
fn grub_numeric_index(target_id: &str) -> Result<usize> {
99+
let dir = Path::new(ENTRIES_DIR);
100+
let mut all: Vec<(SystemTime, String)> = Vec::new();
101+
for item in fs::read_dir(dir).with_context(|| format!("reading {ENTRIES_DIR}"))? {
102+
let item = item.with_context(|| format!("iterating {ENTRIES_DIR}"))?;
103+
let path = item.path();
104+
if path.extension().and_then(|e| e.to_str()) != Some("conf") {
105+
continue;
106+
}
107+
let mtime = item
108+
.metadata()
109+
.and_then(|m| m.modified())
110+
.unwrap_or(SystemTime::UNIX_EPOCH);
111+
let stem = path
112+
.file_stem()
113+
.and_then(|s| s.to_str())
114+
.unwrap_or("")
115+
.to_owned();
116+
all.push((mtime, stem));
117+
}
118+
// Newest-first — same order write_grub_menuentry_cfg uses (index 0 = default).
119+
all.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
120+
all.iter()
121+
.position(|(_, stem)| stem == target_id)
122+
.with_context(|| format!("BLS entry {target_id} not found in {ENTRIES_DIR}"))
123+
}
124+
91125
fn load_uki_entries() -> Result<Vec<BLSEntry>> {
92126
let dir = Path::new(EFI_LINUX_DIR);
93127
let mut entries = Vec::new();
@@ -118,17 +152,6 @@ fn load_uki_entries() -> Result<Vec<BLSEntry>> {
118152
Ok(entries)
119153
}
120154

121-
fn set_next_entry_bootctl(id: &str) -> Result<()> {
122-
let status = Command::new("bootctl")
123-
.args(["set-next", id])
124-
.status()
125-
.context("spawning bootctl set-next")?;
126-
if !status.success() {
127-
bail!("bootctl set-next failed: {status}");
128-
}
129-
Ok(())
130-
}
131-
132155
fn use_systemd_boot() -> bool {
133156
Path::new(EFI_LINUX_DIR).exists() && grubenv_path().is_none()
134157
}
@@ -156,9 +179,19 @@ pub fn run() -> Result<()> {
156179

157180
let id = entry_id(&previous.path);
158181
if systemd_boot {
159-
set_next_entry_bootctl(id)?;
160-
} else {
182+
crate::upgrade::set_loader_conf_default(std::path::Path::new("/boot/efi"), id)?;
183+
crate::upgrade::bootctl_set_default(id)?;
184+
} else if crate::install::has_grub2() {
185+
// Fedora/RHEL: grub2's blscfg.mod reads BLS entries natively and
186+
// matches next_entry=<digest> against each entry's --id.
161187
set_next_entry(id)?;
188+
} else {
189+
// Ubuntu/Debian: no blscfg.mod; grub.cfg is generated by
190+
// write_grub_menuentry_cfg, sorted newest-first. GRUB does not
191+
// reliably match set default=<128-char-digest> against --id, so
192+
// write the numeric position instead.
193+
let index = grub_numeric_index(id)?;
194+
set_next_entry(&index.to_string())?;
162195
}
163196

164197
println!(

src/upgrade.rs

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,56 @@
11
use anyhow::{Context, Result};
22
use chrono::Utc;
33
use serde::{Deserialize, Serialize};
4-
use std::{fs, os::unix::fs::symlink, path::Path, path::PathBuf, process::Command};
4+
use std::{fs, io, os::unix::fs::symlink, path::Path, path::PathBuf, process::Command};
55

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

8+
/// Set the systemd-boot default via `bootctl set-default`, writing the
9+
/// `LoaderEntryDefault` EFI variable (which takes priority over `loader.conf`).
10+
/// Silently skips if `bootctl` is not found (container/install contexts without
11+
/// a writable efivarfs).
12+
pub(crate) fn bootctl_set_default(entry_id: &str) -> Result<()> {
13+
let id_with_efi = format!("{entry_id}.efi");
14+
match Command::new("bootctl")
15+
.args(["set-default", &id_with_efi])
16+
.status()
17+
{
18+
Ok(s) if s.success() => Ok(()),
19+
Ok(s) => anyhow::bail!("bootctl set-default: exited {s}"),
20+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
21+
Err(e) => Err(e).context("spawning bootctl"),
22+
}
23+
}
24+
25+
/// Write (or update) the `default <entry_id>` line in
26+
/// `<esp>/loader/loader.conf`. Used as a fallback for contexts without a
27+
/// writable efivarfs (container image builds, install from live media).
28+
pub(crate) fn set_loader_conf_default(esp: &Path, entry_id: &str) -> Result<()> {
29+
let conf = esp.join("loader/loader.conf");
30+
fs::create_dir_all(conf.parent().unwrap()).context("creating loader dir")?;
31+
let existing = if conf.exists() {
32+
fs::read_to_string(&conf).with_context(|| format!("reading {}", conf.display()))?
33+
} else {
34+
String::new()
35+
};
36+
let mut replaced = false;
37+
let mut lines: Vec<String> = existing
38+
.lines()
39+
.map(|l| {
40+
if l.starts_with("default ") || l == "default" {
41+
replaced = true;
42+
format!("default {entry_id}")
43+
} else {
44+
l.to_owned()
45+
}
46+
})
47+
.collect();
48+
if !replaced {
49+
lines.push(format!("default {entry_id}"));
50+
}
51+
fs::write(&conf, lines.join("\n") + "\n").with_context(|| format!("writing {}", conf.display()))
52+
}
53+
854
const EFI_ESP: &str = "/boot/efi";
955
// UKIs live on the ESP, not XBOOTLDR — systemd-boot always scans its own partition.
1056
const EFI_LINUX_DIR: &str = "/boot/efi/EFI/Linux";
@@ -65,12 +111,22 @@ pub fn run(reboot: bool) -> Result<()> {
65111
println!("Signing UKI ...");
66112
crate::install::sign_efi(&uki_path, Path::new(&sb.key), Path::new(&sb.cert))?;
67113
}
114+
// Make the new UKI the permanent default. Write both the EFI variable
115+
// (via bootctl, highest priority) and loader.conf (fallback for contexts
116+
// without a writable efivarfs, e.g. container image builds).
117+
set_loader_conf_default(Path::new(EFI_ESP), &digest)?;
118+
bootctl_set_default(&digest)?;
68119
} else {
69120
patch_bls_entry(Path::new(BOOT_DIR), &digest, &image_ref)?;
70121
if !crate::install::has_grub2() {
71122
// Ubuntu: regenerate menuentry-based grub.cfg so the new deployment
72123
// appears in the menu (blscfg.mod is not available on Ubuntu).
73124
write_grub_menuentry_cfg(Path::new(BOOT_DIR), crate::install::grub_dir())?;
125+
} else {
126+
// Fedora/RHEL: blscfg.mod reads BLS entries but selects the default
127+
// based on grubenv `default`. Without this update, GRUB continues
128+
// to boot the previous entry regardless of the new BLS conf.
129+
set_grub_default(&digest)?;
74130
}
75131
}
76132

@@ -190,8 +246,9 @@ pub fn patch_bls_entry(bootdir: &Path, digest: &str, image_ref: &str) -> Result<
190246
/// `bootdir/loader/entries/`. Used on distros (Ubuntu/Debian) that do not
191247
/// ship `blscfg.mod` in their GRUB package.
192248
///
193-
/// Each entry gets `--id <digest>` so that rollback's `next_entry=<digest>`
194-
/// in grubenv correctly selects the previous deployment.
249+
/// Entries are sorted newest-first; index 0 is the default boot entry.
250+
/// rollback.rs writes `next_entry=<numeric-index>` (not the digest) for
251+
/// Ubuntu because GRUB does not reliably match long --id values.
195252
pub fn write_grub_menuentry_cfg(bootdir: &Path, grub_subdir: &str) -> Result<()> {
196253
let entries_dir = bootdir.join("loader/entries");
197254
let mut bls: Vec<(std::time::SystemTime, String, String, String)> = Vec::new();
@@ -333,6 +390,30 @@ fn write_state(digest: &str, manifest_digest: Option<&str>) -> Result<()> {
333390
fs::write(STATE_PATH, json).with_context(|| format!("writing {STATE_PATH}"))
334391
}
335392

393+
/// Set the permanent GRUB default to `entry_id` via grub2-editenv/grub-editenv.
394+
/// If grubenv does not exist yet, skips silently (blscfg will fall back to its
395+
/// own sort order).
396+
fn set_grub_default(entry_id: &str) -> Result<()> {
397+
let Some(grubenv) = ["/boot/grub2/grubenv", "/boot/grub/grubenv"]
398+
.iter()
399+
.find(|p| Path::new(p).exists())
400+
else {
401+
return Ok(());
402+
};
403+
for cmd in &["grub2-editenv", "grub-editenv"] {
404+
match Command::new(cmd)
405+
.args([*grubenv, "set", &format!("default={entry_id}")])
406+
.status()
407+
{
408+
Ok(s) if s.success() => return Ok(()),
409+
Ok(s) => anyhow::bail!("{cmd}: exited {s}"),
410+
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
411+
Err(e) => return Err(e).with_context(|| format!("spawning {cmd}")),
412+
}
413+
}
414+
anyhow::bail!("neither grub2-editenv nor grub-editenv found in PATH")
415+
}
416+
336417
fn trigger_reboot() -> Result<()> {
337418
let status = Command::new("systemctl")
338419
.arg("reboot")

0 commit comments

Comments
 (0)