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 contextlib
import json import json
import os
import pathlib import pathlib
import shlex import shlex
import shutil import shutil
@ -8,6 +7,7 @@ import subprocess
import sys import sys
from isobuilder.apt_state import AptStateManager from isobuilder.apt_state import AptStateManager
from isobuilder.boot import make_boot_configurator_for_arch
from isobuilder.gpg_key import EphemeralGPGKey from isobuilder.gpg_key import EphemeralGPGKey
from isobuilder.pool_builder import PoolBuilder from isobuilder.pool_builder import PoolBuilder
@ -114,27 +114,34 @@ class ISOBuilder:
self.workdir = workdir self.workdir = workdir
self.logger = Logger() self.logger = Logger()
self.iso_root = workdir.joinpath("iso-root") 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 # UTILITY STUFF
def _read_config(self): def _read_config(self):
with self.workdir.joinpath("config.json").open() as fp: with self.workdir.joinpath("config.json").open() as fp:
data = json.load(fp) self._config = json.load(fp)
self._series = data["series"]
self._arch = data["arch"] @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 @property
def arch(self): def arch(self):
if self._arch is None: return self.config["arch"]
self._read_config()
return self._arch
@property @property
def series(self): def series(self):
if self._series is None: if self._config is None:
self._read_config() self._read_config()
return self._series return self._config["series"]
@property @property
def gpg_key(self): def gpg_key(self):
@ -162,8 +169,8 @@ class ISOBuilder:
dot_disk.mkdir() dot_disk.mkdir()
self.logger.log("saving config") self.logger.log("saving config")
with self.workdir.joinpath("config.json").open("w") as fp: self._config = {"arch": arch, "series": series}
json.dump({"arch": arch, "series": series}, fp) self.save_config()
self.logger.log("populating .disk") self.logger.log("populating .disk")
dot_disk.joinpath("base_installable").touch() dot_disk.joinpath("base_installable").touch()
@ -219,9 +226,8 @@ class ISOBuilder:
# can verify it's booting from the right media. # can verify it's booting from the right media.
with self.logger.logged("extracting casper uuids"): with self.logger.logged("extracting casper uuids"):
casper_dir = self.iso_root.joinpath("casper") casper_dir = self.iso_root.joinpath("casper")
prefix = "filesystem.initrd-"
dot_disk = self.iso_root.joinpath(".disk") 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") initrddir = self.workdir.joinpath("initrd")
with self.logger.logged( with self.logger.logged(
f"unpacking {initrd.name} ...", done_msg="... done" f"unpacking {initrd.name} ...", done_msg="... done"
@ -231,9 +237,7 @@ class ISOBuilder:
# - Platforms with early firmware: subdirs like "main/" or "early/" # - Platforms with early firmware: subdirs like "main/" or "early/"
# containing conf/uuid.conf # containing conf/uuid.conf
# - Other platforms: conf/uuid.conf directly in the root # - Other platforms: conf/uuid.conf directly in the root
# Try to find uuid.conf in both locations. The [uuid_conf] = confs # Try to find uuid.conf in both locations.
# unpacking asserts exactly one match; multiple matches would
# indicate an unexpected initrd structure.
confs = list(initrddir.glob("*/conf/uuid.conf")) confs = list(initrddir.glob("*/conf/uuid.conf"))
if confs: if confs:
[uuid_conf] = confs [uuid_conf] = confs
@ -242,33 +246,31 @@ class ISOBuilder:
else: else:
raise Exception("uuid.conf not found") raise Exception("uuid.conf not found")
self.logger.log(f"found {uuid_conf.relative_to(initrddir)}") self.logger.log(f"found {uuid_conf.relative_to(initrddir)}")
uuid_conf.rename( if initrd.name == "initrd":
dot_disk.joinpath("casper-uuid-" + initrd.name[len(prefix) :]) 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) shutil.rmtree(initrddir)
def add_live_filesystem(self, artifact_prefix: pathlib.Path): 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") casper_dir = self.iso_root.joinpath("casper")
artifact_dir = artifact_prefix.parent artifact_dir = artifact_prefix.parent
filename_prefix = artifact_prefix.name filename_prefix = artifact_prefix.name
def link(src, target_name): def link(src: pathlib.Path, target_name: str):
target = casper_dir.joinpath(target_name) target = casper_dir.joinpath(target_name)
self.logger.log( self.logger.log(
f"creating link from $ISOROOT/casper/{target_name} to $src/{src.name}" f"creating link from $ISOROOT/casper/{target_name} to $src/{src.name}"
) )
target.hardlink_to(src) target.hardlink_to(src)
kernel_name = "vmlinuz"
if self.arch == "ppc64el":
kernel_name = "vmlinux"
with self.logger.logged( with self.logger.logged(
f"linking artifacts from {casper_dir} to {artifact_dir}" 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}"): for path in artifact_dir.glob(f"{filename_prefix}*.{ext}"):
newname = path.name[len(filename_prefix) :] newname = path.name[len(filename_prefix) :]
link(path, newname) link(path, newname)
for item in "kernel", "initrd": for suffix, prefix in (
for path in artifact_dir.glob(f"{filename_prefix}{item}-*"): ("-generic", ""),
newname = "filesystem." + path.name[len(filename_prefix) :] ("-generic-hwe", "hwe-"),
link(path, newname) ):
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() self._extract_casper_uuids()
def make_bootable(self, project: str, capproject: str, subarch: str): def make_bootable(self, project: str, capproject: str, subarch: str):
# debian-cd is Ubuntu's CD/ISO image build system. It contains configurator = make_boot_configurator_for_arch(
# architecture and series-specific boot configuration scripts that set up self.arch,
# GRUB, syslinux, EFI boot, etc. The tools/boot/$series/boot-$arch script self.logger,
# knows how to make an ISO bootable for each architecture. self.apt_state,
# self.iso_root,
# TODO: The boot configuration logic should eventually be ported directly )
# into isobuilder to avoid this external dependency and git clone. configurator.make_bootable(
debian_cd_dir = self.workdir.joinpath("debian-cd") self.workdir,
with self.logger.logged("cloning debian-cd"): project,
self.logger.run( capproject,
[ subarch,
"git", self.iso_root.joinpath("casper/hwe-initrd").exists(),
"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,
) )
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): def checksum(self):
# Generate md5sum.txt for ISO integrity verification. # Generate md5sum.txt for ISO integrity verification.
# - Symlinks are excluded because their targets are already checksummed # - Symlinks are excluded because their targets are already checksummed
# - Files are sorted for deterministic, reproducible output across builds # - Files are sorted for deterministic, reproducible output across builds
# - Paths use "./" prefix and we run md5sum from iso_root so the output # - 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 = [] all_files = []
for dirpath, dirnames, filenames in self.iso_root.walk(): for dirpath, dirnames, filenames in self.iso_root.walk():
filepaths = [dirpath.joinpath(filename) for filename in filenames] filepaths = [dirpath.joinpath(filename) for filename in filenames]
@ -351,14 +331,20 @@ class ISOBuilder:
) )
def make_iso(self, dest: pathlib.Path, volid: str | None): 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. # xorriso with "-as mkisofs" runs in mkisofs compatibility mode.
# -r enables Rock Ridge extensions for Unix metadata (permissions, symlinks). # -r enables Rock Ridge extensions for Unix metadata (permissions, symlinks).
# -iso-level 3 (amd64 only) allows files >4GB which some amd64 ISOs need. # -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"] cmd: list[str | pathlib.Path] = ["xorriso"]
if self.arch == "riscv64": if self.arch == "riscv64":
# For $reasons, xorriso is not run in mkisofs mode on riscv64 only. # 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]) cmd.extend(mkisofs_opts + [self.iso_root, "-o", dest])
with self.logger.logged("running xorriso"): with self.logger.logged("running xorriso"):
self.logger.run(cmd, cwd=self.workdir, check=True, limit_length=False) self.logger.run(cmd, cwd=self.workdir, check=True, limit_length=False)
if self.arch == "riscv64": configurator.post_process_iso(dest)
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)