Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 88 additions & 9 deletions imagecraft/pack/grubutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

"""GRUB utils."""

import re
import shutil
import subprocess
from pathlib import Path

Expand All @@ -32,6 +34,10 @@
from imagecraft.pack.image import Image
from imagecraft.subprocesses import run

_UUID_REGEX = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)

_ARCH_TO_GRUB_EFI_TARGET: dict[str, str] = {
DebianArchitecture.AMD64: "x86_64-efi",
DebianArchitecture.ARM64: "arm64-efi",
Expand All @@ -41,12 +47,20 @@
_GRUB_BIOS_TARGET = "i386-pc"
_GRUB_BIOS_ARCHS = {DebianArchitecture.AMD64, DebianArchitecture.I386}

# Maps grub EFI target → (grub binary name under /EFI/<id>/, fallback sibling name)
_EFI_TARGET_TO_FILENAMES: dict[str, tuple[str, str]] = {
"x86_64-efi": ("grubx64.efi", "grubx64.efi"),
"arm64-efi": ("grubaa64.efi", "grubaa64.efi"),
"arm-efi": ("grubarm.efi", "grubarm.efi"),
}


def _grub_install(grub_target: str, loop_dev: str) -> None:
def _grub_install(grub_target: str, loop_dev: str, mount_dir: Path) -> None:
"""Install grub in the image.

:param grub_target: target platform to install grub for.
:param loop_dev: loop device to install grub on
:param mount_dir: mount directory for the image
"""
check_grub_install = ["grub-install", "-V"]
if grub_target == _GRUB_BIOS_TARGET:
Expand All @@ -65,6 +79,9 @@ def _grub_install(grub_target: str, loop_dev: str) -> None:
f"--target={grub_target}",
"--uefi-secure-boot",
"--no-nvram",
# Ubuntu's signed grub binary has /EFI/ubuntu compiled in as its
# $prefix, so the config and modules must live there.
"--bootloader-id=ubuntu",
]

update_grub_command = [
Expand Down Expand Up @@ -116,6 +133,68 @@ def _grub_install(grub_target: str, loop_dev: str) -> None:
except FileNotFoundError as err:
raise errors.GRUBInstallError("Missing tool to install grub") from err

_populate_uefi_fallback(grub_target, mount_dir)


def _extract_root_uuid(grub_cfg_src: Path) -> str:
"""Extract rootfs UUID from a GRUB config file."""
text = grub_cfg_src.read_text(encoding="utf-8")
match = _UUID_REGEX.search(text)
if match:
return match.group(0)
return ""


def _populate_uefi_fallback(
grub_target: str, mount_dir: Path, efi_dir: Path = Path("/boot/efi/EFI")
) -> None:
"""Populate UEFI fallback path with grub + config next to BOOT*.EFI.

We intentionally keep BOOT*.EFI as shim and provide grubx64.efi/grub.cfg
in the same directory, which is the location shim looks at first.
"""
if grub_target not in _EFI_TARGET_TO_FILENAMES:
return

grub_fname, fallback_grub_fname = _EFI_TARGET_TO_FILENAMES[grub_target]
grub_src = efi_dir / "ubuntu" / grub_fname
grub_cfg_src = efi_dir / "ubuntu" / "grub.cfg"
boot_grub = efi_dir / "BOOT" / fallback_grub_fname
boot_cfg = efi_dir / "BOOT" / "grub.cfg"
root_grub_cfg = mount_dir / "boot" / "grub" / "grub.cfg"

if not (grub_src.exists() and grub_cfg_src.exists()):
return

boot_grub.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(grub_src, boot_grub)

# Copy modules to EFI partition so GRUB can load ext2/part_gpt drivers
modules_src = Path("/boot/grub") / grub_target
if modules_src.exists():
boot_modules = efi_dir / "BOOT" / grub_target
ubuntu_modules = efi_dir / "ubuntu" / grub_target
if not boot_modules.exists():
shutil.copytree(modules_src, boot_modules, dirs_exist_ok=True)
if not ubuntu_modules.exists():
shutil.copytree(modules_src, ubuntu_modules, dirs_exist_ok=True)

root_uuid = _extract_root_uuid(root_grub_cfg) if root_grub_cfg.exists() else ""
if root_uuid:
stub = "\n".join(
[
"insmod part_gpt",
"insmod ext2",
f"search.fs_uuid {root_uuid} root",
"set prefix=($root)'/boot/grub'",
"configfile $prefix/grub.cfg",
]
)
boot_cfg.write_text(stub + "\n", encoding="utf-8")
grub_cfg_src.write_text(stub + "\n", encoding="utf-8")
else:
shutil.copy2(grub_cfg_src, boot_cfg)


def setup_grub(
image: Image, workdir: Path, arch: str, filesystem_mount: FilesystemMount
Expand Down Expand Up @@ -161,16 +240,15 @@ def setup_grub(
with image.attach_loopdev() as loop_dev:
mounts: list[Mount] = [
*_image_mounts(loop_dev, image.volume.structure, filesystem_mount),
# Use a recursive bind of the host's /dev so that loop devices
# created by losetup (e.g. /dev/loop5) are visible inside the
# chroot. A fresh devtmpfs would not contain them, causing
# grub-install to fail silently when it cannot access the disk.
Mount(
fstype="devtmpfs",
src="devtmpfs-build",
fstype=None,
src="/dev",
relative_mountpoint="/dev",
),
Mount(
fstype="devpts",
src="devpts-build",
relative_mountpoint="/dev/pts",
options=["-o", "nodev,nosuid"],
options=["--rbind"],
),
Mount(fstype="proc", src="proc-build", relative_mountpoint="proc"),
Mount(fstype="sysfs", src="sysfs-build", relative_mountpoint="/sys"),
Expand All @@ -185,6 +263,7 @@ def setup_grub(
target=_grub_install,
grub_target=grub_target,
loop_dev=loop_dev,
mount_dir=mount_dir,
)
except errors.ChrootMountError as err:
# Ignore mounting errors indicating the rootfs does not have
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ dependencies = [
"craft-parts~=2.35.0",
"craft-cli~=3.0",
"craft-platforms~=0.6",
"craft-application~=7.1",
"craft-application~=7.2",
"craft-grammar~=2.3",
"craft-providers~=3.0",
"craft-providers~=3.7",
"pydantic~=2.8",
"pygit2>=1.13.0,<1.15.0; python_version=='3.12'", # pin pygit2 to versions compatible with libgit2-1.7 for core24
"pygit2>=1.19.0,<1.20.0; python_version=='3.14'", # Ubuntu 26.04 and core26
Expand Down
13 changes: 9 additions & 4 deletions tests/spread/boot/classic/imagecraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@ parts:
after: [rootfs]
override-overlay: |
cat << 'EOF' > /etc/default/grub
GRUB_TIMEOUT=1
GRUB_TIMEOUT=0
GRUB_RECORDFAIL_TIMEOUT=0
GRUB_TIMEOUT_STYLE=hidden
GRUB_TERMINAL="console serial"
GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"
GRUB_CMDLINE_LINUX_DEFAULT=""
GRUB_CMDLINE_LINUX="console=ttyS0,115200n8 console=tty1"
EOF

sentinel-service:
Expand All @@ -84,18 +89,18 @@ parts:
cat << EOF > /etc/systemd/system/boot-sentinel.service
[Unit]
Description=Boot sentinel
After=multi-user.target

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo HELLO FROM IMAGECRAFT > $SERIAL_CONSOLE'
ExecStart=/bin/bash -c 'echo HELLO FROM IMAGECRAFT > /var/lib/boot-sentinel.txt; echo HELLO FROM IMAGECRAFT > $SERIAL_CONSOLE || true'
ExecStartPost=/sbin/poweroff

[Install]
WantedBy=multi-user.target
EOF

systemctl enable boot-sentinel.service
mkdir -p /etc/systemd/system/multi-user.target.wants
ln -sf /etc/systemd/system/boot-sentinel.service /etc/systemd/system/multi-user.target.wants/boot-sentinel.service

volumes:
pc:
Expand Down
7 changes: 3 additions & 4 deletions tests/spread/boot/classic/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,17 @@ execute: |
echo "Unsupported arch: $ARCH"
exit 1
fi

cp "$UEFI_VARS" /tmp/uefi_vars.fd

$QEMU_BIN \
script -q -c "timeout -k 10 180 $QEMU_BIN \
$QEMU_ARGS \
-accel kvm -accel tcg,thread=multi \
-smp "$(nproc)" \
-smp \"\$(nproc)\" \
-nographic \
-m 1G \
-drive if=pflash,format=raw,readonly=on,file=$UEFI_CODE \
-drive if=pflash,format=raw,file=/tmp/uefi_vars.fd \
-drive file=pc.img,format=raw,index=0,if=virtio 2>&1 | tee output.log || true
-drive file=pc.img,format=raw,index=0,if=virtio,cache=unsafe" output.log || true

MATCH "HELLO FROM IMAGECRAFT" output.log

Expand Down
2 changes: 1 addition & 1 deletion tests/spread/pack/non-sequential-partitions/task.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
summary: Pack an image with non-sequential partition numbers.

execute: |
imagecraft pack --verbose
imagecraft pack --verbose --ignore=unmaintained

test -f disk.img

Expand Down
2 changes: 1 addition & 1 deletion tests/spread/pack/sector-write/task.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
summary: Data written to sectors outside partitions via the loop device is preserved

execute: |
imagecraft pack --verbose --destructive-mode
imagecraft pack --verbose --ignore=unmaintained --destructive-mode
test -f disk.img

# Verify that sector 100 is actually in the gap between the GPT partition
Expand Down
2 changes: 1 addition & 1 deletion tests/spread/pack/simple/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ prepare: |
sed "s/__BUILD_BASE__/${ID}@${VERSION_ID}/" imagecraft-template.yaml > imagecraft.yaml

execute: |
imagecraft pack --verbose
imagecraft pack --verbose --ignore=unmaintained
test -f disk.img

# Running in destructive mode until we can fix https://github.com/canonical/imagecraft/issues/253
Expand Down
42 changes: 41 additions & 1 deletion tests/unit/pack/test_grubutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
MBRVolume,
)
from imagecraft.pack.chroot import Mount
from imagecraft.pack.grubutil import _image_mounts, _part_num, setup_grub
from imagecraft.pack.grubutil import (
_image_mounts,
_part_num,
_populate_uefi_fallback,
setup_grub,
)
from imagecraft.pack.image import Image


Expand Down Expand Up @@ -309,6 +314,41 @@ def test_setup_grub_mbr_bios(mocker, new_dir, arch):
assert mock_chroot.return_value.execute.call_args.kwargs["grub_target"] == "i386-pc"


@pytest.mark.usefixtures("new_dir")
def test_populate_uefi_fallback_uses_root_grub_cfg(new_dir):
mount_dir = Path(new_dir, "mount")
efi_dir = Path(new_dir, "efi")
root_grub_cfg = mount_dir / "boot" / "grub" / "grub.cfg"
ubuntu_dir = efi_dir / "ubuntu"

root_grub_cfg.parent.mkdir(parents=True, exist_ok=True)
ubuntu_dir.mkdir(parents=True, exist_ok=True)
(mount_dir / "boot" / "grub" / "x86_64-efi").mkdir(parents=True, exist_ok=True)
root_grub_cfg.write_text(
"search.fs_uuid 12345678-1234-1234-1234-123456789abc root\n",
encoding="utf-8",
)
(ubuntu_dir / "grubx64.efi").write_text("grub-binary", encoding="utf-8")
(ubuntu_dir / "grub.cfg").write_text("normal\n", encoding="utf-8")

_populate_uefi_fallback("x86_64-efi", mount_dir, efi_dir=efi_dir)

expected_stub = (
"insmod part_gpt\n"
"insmod ext2\n"
"search.fs_uuid 12345678-1234-1234-1234-123456789abc root\n"
"set prefix=($root)'/boot/grub'\n"
"configfile $prefix/grub.cfg\n"
)
assert (efi_dir / "BOOT" / "grubx64.efi").read_text(
encoding="utf-8"
) == "grub-binary"
assert (efi_dir / "ubuntu" / "grub.cfg").read_text(
encoding="utf-8"
) == expected_stub
assert (efi_dir / "BOOT" / "grub.cfg").read_text(encoding="utf-8") == expected_stub


@pytest.mark.parametrize(
("loop_dev", "volume", "filesystem_mount", "mounts"),
[
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/services/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_lifecycle_args(
cache_dir=Path("cache"),
work_dir=Path("work"),
ignore_local_sources=[".craft"],
ignore_outdated=[".craft"],
ignore_outdated=[".craft", ".spread-reuse.*"],
parallel_build_count=ANY, # Value will vary when tests run locally or in CI
project_vars=ProjectVarInfo.unmarshal(
{
Expand Down
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading