Use Python boot package instead of debian-cd shell scripts

Replace the debian-cd git clone and shell script invocation in
ISOBuilder with the new Python boot configurators.

Key changes to builder.py:
- make_bootable() creates a boot configurator and calls its
  make_bootable() method instead of cloning debian-cd
- make_iso() gets mkisofs_opts directly from the configurator
  instead of reading a serialized file
- add_live_filesystem() links kernel/initrd with names expected
  by the boot configurators (vmlinuz/initrd, hwe-vmlinuz/hwe-initrd)
- _extract_casper_uuids() updated for the new initrd naming scheme
- Refactor config storage to use a single _config dict
- Add limit_length parameter to Logger for long xorriso commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael.hudson@canonical.com 2026-02-11 14:26:08 +13:00
parent edf0acbeac
commit 516d8b8913
No known key found for this signature in database
GPG Key ID: 80E627A0AB757E23

View File

@ -1,6 +1,5 @@
import contextlib
import json
import os
import pathlib
import shlex
import shutil
@ -8,6 +7,7 @@ import subprocess
import sys
from isobuilder.apt_state import AptStateManager
from isobuilder.boot import make_boot_configurator_for_arch
from isobuilder.gpg_key import EphemeralGPGKey
from isobuilder.pool_builder import PoolBuilder
@ -114,27 +114,34 @@ class ISOBuilder:
self.workdir = workdir
self.logger = Logger()
self.iso_root = workdir.joinpath("iso-root")
self._series = self._arch = self._gpg_key = self._apt_state = None
self._config: dict | None = None
self._gpg_key = self._apt_state = None
# UTILITY STUFF
def _read_config(self):
with self.workdir.joinpath("config.json").open() as fp:
data = json.load(fp)
self._series = data["series"]
self._arch = data["arch"]
self._config = json.load(fp)
@property
def config(self):
if self._config is None:
self._read_config()
return self._config
def save_config(self):
with self.workdir.joinpath("config.json").open("w") as fp:
json.dump(self._config, fp)
@property
def arch(self):
if self._arch is None:
self._read_config()
return self._arch
return self.config["arch"]
@property
def series(self):
if self._series is None:
if self._config is None:
self._read_config()
return self._series
return self._config["series"]
@property
def gpg_key(self):
@ -162,8 +169,8 @@ class ISOBuilder:
dot_disk.mkdir()
self.logger.log("saving config")
with self.workdir.joinpath("config.json").open("w") as fp:
json.dump({"arch": arch, "series": series}, fp)
self._config = {"arch": arch, "series": series}
self.save_config()
self.logger.log("populating .disk")
dot_disk.joinpath("base_installable").touch()
@ -219,9 +226,8 @@ class ISOBuilder:
# can verify it's booting from the right media.
with self.logger.logged("extracting casper uuids"):
casper_dir = self.iso_root.joinpath("casper")
prefix = "filesystem.initrd-"
dot_disk = self.iso_root.joinpath(".disk")
for initrd in casper_dir.glob(f"{prefix}*"):
for initrd in casper_dir.glob("*initrd"):
initrddir = self.workdir.joinpath("initrd")
with self.logger.logged(
f"unpacking {initrd.name} ...", done_msg="... done"
@ -231,9 +237,7 @@ class ISOBuilder:
# - Platforms with early firmware: subdirs like "main/" or "early/"
# containing conf/uuid.conf
# - Other platforms: conf/uuid.conf directly in the root
# Try to find uuid.conf in both locations. The [uuid_conf] = confs
# unpacking asserts exactly one match; multiple matches would
# indicate an unexpected initrd structure.
# Try to find uuid.conf in both locations.
confs = list(initrddir.glob("*/conf/uuid.conf"))
if confs:
[uuid_conf] = confs
@ -242,33 +246,31 @@ class ISOBuilder:
else:
raise Exception("uuid.conf not found")
self.logger.log(f"found {uuid_conf.relative_to(initrddir)}")
uuid_conf.rename(
dot_disk.joinpath("casper-uuid-" + initrd.name[len(prefix) :])
)
if initrd.name == "initrd":
suffix = "generic"
elif initrd.name == "hwe-initrd":
suffix = "generic-hwe"
else:
raise Exception(f"unexpected initrd name {initrd.name}")
uuid_conf.rename(dot_disk.joinpath(f"casper-uuid-{suffix}"))
shutil.rmtree(initrddir)
def add_live_filesystem(self, artifact_prefix: pathlib.Path):
# Link build artifacts into the ISO's casper directory. We use hardlinks
# (not copies) for filesystem efficiency - they reference the same inode.
#
# Artifacts come from the layered build with names like "for-iso.base.squashfs"
# and need to be renamed for casper. The prefix is stripped, so:
# for-iso.base.squashfs -> base.squashfs
# for-iso.kernel-generic -> filesystem.kernel-generic
#
# Kernel and initrd get the extra "filesystem." prefix because debian-cd
# expects names like filesystem.kernel-* and filesystem.initrd-*.
casper_dir = self.iso_root.joinpath("casper")
artifact_dir = artifact_prefix.parent
filename_prefix = artifact_prefix.name
def link(src, target_name):
def link(src: pathlib.Path, target_name: str):
target = casper_dir.joinpath(target_name)
self.logger.log(
f"creating link from $ISOROOT/casper/{target_name} to $src/{src.name}"
)
target.hardlink_to(src)
kernel_name = "vmlinuz"
if self.arch == "ppc64el":
kernel_name = "vmlinux"
with self.logger.logged(
f"linking artifacts from {casper_dir} to {artifact_dir}"
):
@ -276,64 +278,42 @@ class ISOBuilder:
for path in artifact_dir.glob(f"{filename_prefix}*.{ext}"):
newname = path.name[len(filename_prefix) :]
link(path, newname)
for item in "kernel", "initrd":
for path in artifact_dir.glob(f"{filename_prefix}{item}-*"):
newname = "filesystem." + path.name[len(filename_prefix) :]
link(path, newname)
for suffix, prefix in (
("-generic", ""),
("-generic-hwe", "hwe-"),
):
if artifact_dir.joinpath(f"{filename_prefix}kernel{suffix}").exists():
link(
artifact_dir.joinpath(f"{filename_prefix}kernel{suffix}"),
f"{prefix}{kernel_name}",
)
link(
artifact_dir.joinpath(f"{filename_prefix}initrd{suffix}"),
f"{prefix}initrd",
)
self._extract_casper_uuids()
def make_bootable(self, project: str, capproject: str, subarch: str):
# debian-cd is Ubuntu's CD/ISO image build system. It contains
# architecture and series-specific boot configuration scripts that set up
# GRUB, syslinux, EFI boot, etc. The tools/boot/$series/boot-$arch script
# knows how to make an ISO bootable for each architecture.
#
# TODO: The boot configuration logic should eventually be ported directly
# into isobuilder to avoid this external dependency and git clone.
debian_cd_dir = self.workdir.joinpath("debian-cd")
with self.logger.logged("cloning debian-cd"):
self.logger.run(
[
"git",
"clone",
"--depth=1",
"https://git.launchpad.net/~ubuntu-cdimage/debian-cd/+git/ubuntu",
debian_cd_dir,
],
)
# Override apt-selection to use our ISO's apt configuration instead of
# debian-cd's default. This ensures the boot scripts get packages from
# the correct repository when installing boot packages.
apt_selection = debian_cd_dir.joinpath("tools/apt-selection")
with self.logger.logged("overwriting apt-selection"):
apt_selection.write_text(
"#!/bin/sh\n" f"APT_CONFIG={self.apt_state.apt_conf_path} apt-get $@\n"
)
env = dict(
os.environ,
BASEDIR=str(debian_cd_dir),
DIST=self.series,
PROJECT=project,
CAPPROJECT=capproject,
SUBARCH=subarch,
configurator = make_boot_configurator_for_arch(
self.arch,
self.logger,
self.apt_state,
self.iso_root,
)
configurator.make_bootable(
self.workdir,
project,
capproject,
subarch,
self.iso_root.joinpath("casper/hwe-initrd").exists(),
)
tool_name = f"tools/boot/{self.series}/boot-{self.arch}"
with self.logger.logged(f"running {tool_name} ...", done_msg="... done"):
self.logger.run(
[
debian_cd_dir.joinpath(tool_name),
"1",
self.iso_root,
],
env=env,
)
def checksum(self):
# Generate md5sum.txt for ISO integrity verification.
# - Symlinks are excluded because their targets are already checksummed
# - Files are sorted for deterministic, reproducible output across builds
# - Paths use "./" prefix and we run md5sum from iso_root so the output
# matches what casper-md5check expects.
# matches what users get when they verify with "md5sum -c" from the ISO
all_files = []
for dirpath, dirnames, filenames in self.iso_root.walk():
filepaths = [dirpath.joinpath(filename) for filename in filenames]
@ -351,14 +331,20 @@ class ISOBuilder:
)
def make_iso(self, dest: pathlib.Path, volid: str | None):
# 1.mkisofs_opts is generated by debian-cd's make_bootable step. The "1"
# refers to "pass 1" of the build (a legacy naming convention). It contains
# architecture-specific xorriso options for boot sectors, EFI images, etc.
mkisofs_opts = shlex.split(self.workdir.joinpath("1.mkisofs_opts").read_text())
self.checksum()
# xorriso with "-as mkisofs" runs in mkisofs compatibility mode.
# -r enables Rock Ridge extensions for Unix metadata (permissions, symlinks).
# -iso-level 3 (amd64 only) allows files >4GB which some amd64 ISOs need.
# mkisofs_opts comes from the boot configurator and contains architecture-
# specific options for boot sectors, EFI images, etc.
self.checksum()
configurator = make_boot_configurator_for_arch(
self.arch,
self.logger,
self.apt_state,
self.iso_root,
)
configurator.create_dirs(self.workdir)
mkisofs_opts = configurator.mkisofs_opts()
cmd: list[str | pathlib.Path] = ["xorriso"]
if self.arch == "riscv64":
# For $reasons, xorriso is not run in mkisofs mode on riscv64 only.
@ -380,7 +366,4 @@ class ISOBuilder:
cmd.extend(mkisofs_opts + [self.iso_root, "-o", dest])
with self.logger.logged("running xorriso"):
self.logger.run(cmd, cwd=self.workdir, check=True, limit_length=False)
if self.arch == "riscv64":
debian_cd_dir = self.workdir.joinpath("debian-cd")
add_riscv_gpt = debian_cd_dir.joinpath("tools/add_riscv_gpt")
self.logger.run([add_riscv_gpt, dest], cwd=self.workdir)
configurator.post_process_iso(dest)