From bd92573158d03db8c6af7e8774d39b740b7f2a8a Mon Sep 17 00:00:00 2001 From: kstyagi Date: Tue, 22 Sep 2026 09:30:55 +0530 Subject: [PATCH] feat: enhance Ubuntu rootfs build process with improved logging and error handling --- DEVELOPER.md | 26 ++++++-- README.md | 11 +++- USAGE.md | 19 +++++- tests/test_install.py | 140 ++++++++++++++++++++++++++++++++++++------ uvm/install.py | 139 +++++++++++++++++++++++++++++++---------- 5 files changed, 273 insertions(+), 62 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index c3bfa11..f786681 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -211,7 +211,8 @@ Default runtime storage is `/var/lib/uvm`: │ └── jailer ├── images/ │ ├── vmlinux -│ └── ubuntu.ext4 +│ ├── ubuntu.ext4 +│ └── ubuntu-build.log ├── vms/ │ └── vm-/ │ ├── rootfs.ext4 @@ -713,10 +714,17 @@ Explicit kernel/rootfs URLs and trusted digests must match the host architecture uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs. The default rootfs builder installs `debootstrap` and `ubuntu-keyring`, uses -`--force-check-gpg`, and includes main/universe plus updates/security suites. +`--force-check-gpg`, and bootstraps only the base release's main component with +CA certificates and the Ubuntu keyring. Do not pass updates/security as +`--extra-suites`: debootstrap can download base archives but import newer +pre-dependency metadata, producing an empty `dpkg --install` invocation. +After bootstrap, apt sees main/universe plus updates/security, runs update +with `APT::Update::Error-Mode=any`, upgrades the base, and installs guest packages. amd64 uses `archive.ubuntu.com/ubuntu`; arm64 uses `ports.ubuntu.com/ubuntu-ports`. -Package scripts execute in private mount/PID namespaces with a temporary -`policy-rc.d` suppressing service startup. No mounts survive the build process. +Both phases execute in private mount/PID namespaces. The apt phase has private +dev/proc and read-only sys mounts, and recreates `policy-rc.d` to suppress service +startup after Ubuntu's debootstrap script removes it. Cleanup tolerates an +already-removed policy file. No mounts survive the build process. The staged tree retains `/var/lib/dpkg` and apt metadata, validates installed apt/sudo/sshd/systemd packages, enables SSH/networkd, preserves kernel-provided static networking, and supplies guest DNS. Template host keys and machine IDs @@ -729,6 +737,11 @@ permit reuse of a built template. Old demo images and unverified caches are rebuilt, and `install --force` always rebuilds. A failed staging build retains the old template. Installation holds the existing operation lock throughout; existing VM disks are never upgraded or reprovisioned by installation. +Stage command output and debootstrap's detailed log are atomically saved to +`images/ubuntu-build.log` (mode `0600`) on success or failure before deleting +staging files. Errors name that durable log; the last lines contain the latest +stage's diagnostics and failure summary. The recipe marker is bumped whenever +the build workflow changes so an earlier cached template cannot skip it. The host package list includes `nftables` and `conntrack` as well as the existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux @@ -924,7 +937,10 @@ growth, shrinking, geometry, and retained data/password-hash/host-key fixtures. UID/GID allocation, account/home metadata, existing accounts, and password hashes. `test_install.py` simulates package bootstrap and exercises real ext4 formatting and readback for retained dpkg metadata, sudo modes, template configuration, -staging failures, and checksum provenance. It does not run debootstrap or boot a VM. +staging failures, durable build diagnostics, and checksum provenance. The apt +shell script runs against stub mount/chroot commands to check argument handling +and stop-on-error behavior. Tests simulate Ubuntu removing `policy-rc.d` after +bootstrap. They do not run debootstrap or boot a VM. Lifecycle resize tests use fake processes/networking to verify ordering, single-lock ownership, identity preservation, pending intent, and failure recovery. diff --git a/README.md b/README.md index 3c9499a..a258277 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,10 @@ sudo env \ ``` The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures, -and native packages. UVM installs the builder prerequisites and records the -built ext4 image's SHA-256 in the integrity manifest. Building requires root +and native packages. It bootstraps the base release first, then uses apt to +apply updates/security fixes and install the guest packages. UVM installs the +builder prerequisites and records the built ext4 image's SHA-256 in the +integrity manifest. Building requires root with mount/PID namespace support and internet access to Ubuntu mirrors. Use `--force` to refresh downloads and rebuild the Ubuntu template: @@ -95,6 +97,11 @@ Existing VM disks retain their original OS and credentials; installation updates the template used for future VMs. Restart a running API server to load the updated provisioner before creating VMs through the dashboard. +If the build fails, its detailed bootstrap/package output is retained in +`/var/lib/uvm/images/ubuntu-build.log` (mode `0600`). Correct the build failure +and rerun installation before creating a custom-user VM; a failed build leaves +the previous template installed. + For local experimentation only, the old unverified quick-start behavior is available through an explicit opt-out: diff --git a/USAGE.md b/USAGE.md index f79bc77..c4ae9ee 100644 --- a/USAGE.md +++ b/USAGE.md @@ -175,9 +175,10 @@ Installation prints the Firecracker, kernel, and rootfs paths under `/var/lib/uvm/integrity.json`. The default build installs `debootstrap` and `ubuntu-keyring` on the host, -checks Ubuntu archive signatures, and builds inside private mount/PID -namespaces. It requires native root execution with namespace support. Leave -`UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt +checks Ubuntu archive signatures, bootstraps the base release, then uses apt +to apply updates and install the guest packages. It builds inside private +mount/PID namespaces and requires native root execution with namespace support. +Leave `UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt ext4 image, supply both its URL and SHA-256 instead. ### Scenario: Refresh Firecracker or Guest Assets @@ -227,6 +228,18 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. | | `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. | | `unshare: Operation not permitted` | Run the builder as root on a host permitting private mount/PID namespaces. | +| `Ubuntu rootfs build failed` | Read `images/ubuntu-build.log` under `UVM_BASE`, fix the reported bootstrap/apt error, and rerun installation. | + +The latest image-build log is retained after temporary build directories are +cleaned up, including detailed debootstrap and apt output: + +```sh +sudo tail -n 100 /var/lib/uvm/images/ubuntu-build.log +``` + +A failed build leaves the previous rootfs template installed. If creation then +reports missing sudo, complete the image installation successfully before +retrying creation. The username itself does not cause that error. ## 5. Create VMs diff --git a/tests/test_install.py b/tests/test_install.py index 018e501..b774a9e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2,8 +2,10 @@ from __future__ import annotations import hashlib import io +import json import os import shutil +import sys import tarfile import tempfile import unittest @@ -13,9 +15,9 @@ from types import SimpleNamespace from unittest.mock import Mock, patch from uvm.config import Settings -from uvm.errors import CommandError, UvmError +from uvm.errors import UvmError from uvm.images import ImageStore -from uvm.install import Installer, _ROOTFS_RECIPE +from uvm.install import Installer, _GUEST_APT_SCRIPT, _ROOTFS_RECIPE from uvm.integrity import load_manifest, write_manifest from uvm.state import StateStore from uvm.system import CommandResult, CommandRunner @@ -154,7 +156,7 @@ class InstallerArchitectureTests(unittest.TestCase): def test_only_verified_current_recipe_images_are_reused(self) -> None: for verified, marker, force in ( (True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False), - (True, "", False), (True, _ROOTFS_RECIPE, True), + (True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True), ): with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary: settings = Settings(base=Path(temporary)) @@ -184,36 +186,109 @@ class UbuntuBuildRunner: if "debootstrap" in args: root = Path(args[-2]) if self.failure == "bootstrap": - raise CommandError("bootstrap interrupted") + self.write_files(root, {"debootstrap/debootstrap.log": "dpkg: --install needs at least one package archive file argument\n"}) + return CommandResult(args, 1, stderr="W: Failure while unpacking the base system\n") + self.write_files(root, { + "etc/passwd": "root:x:0:0::/root:/bin/bash\n", + "etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n", + "etc/apt/sources.list": "deb https://ports.ubuntu.com/ubuntu-ports jammy main\n", + "usr/bin/apt-get": "apt binary", + "var/lib/dpkg/status": "Package: apt\nStatus: install ok installed\nVersion: 1.0\n", + "var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n", + "var/log/bootstrap.log": "Base system installed successfully.\n", + }) + for relative in ("dev", "proc", "sys", "usr/sbin"): + (root / relative).mkdir(parents=True, exist_ok=True) + (root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf") + # Ubuntu's real script removes this, even if it existed before bootstrap. + (root / "usr/sbin/policy-rc.d").unlink(missing_ok=True) + return CommandResult(args, 0, stdout="I: Base system installed successfully.\n") + if "uvm-guest-packages" in args: + root = Path(args[args.index("uvm-guest-packages") + 1]) + if (root / "usr/sbin/policy-rc.d").read_text() != "#!/bin/sh\nexit 101\n": + raise AssertionError("guest services must be disabled during apt provisioning") + sources = (root / "etc/apt/sources.list").read_text() + if "jammy-updates main universe" not in sources or "jammy-security main universe" not in sources: + raise AssertionError("apt must see updates and security before installing guest packages") + if self.failure == "packages": + return CommandResult(args, 100, stdout="Reading package lists...\n", stderr="E: unmet guest dependencies\n") status = "\n\n".join( f"Package: {package}\nStatus: install ok installed\nVersion: 1.0" for package in ("apt", "sudo", "openssh-server", "systemd-sysv") ) + "\n" files = { - "etc/passwd": "root:x:0:0::/root:/bin/bash\n", - "etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n", "etc/sudoers": "root ALL=(ALL:ALL) ALL\n", "etc/ssh/sshd_config": "UsePAM yes\n", "etc/ssh/ssh_host_rsa_key": "template host key", - "usr/bin/sudo": "sudo binary", "usr/bin/apt-get": "apt binary", + "usr/bin/sudo": "sudo binary", "usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary", "var/lib/dpkg/status": status, - "var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n", "var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n", } if self.failure == "metadata": files["var/lib/dpkg/status"] = "" - for relative, contents in files.items(): - path = root / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") + self.write_files(root, files) (root / "usr/bin/sudo").chmod(0o4755) - (root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf") - return CommandResult(args, 0) + return CommandResult(args, 0, stdout="Configured guest packages.\n") if args[0] == "systemctl": return CommandResult(args, 0) return self.runner.run(command, **options) + @staticmethod + def write_files(root: Path, files: dict[str, str]) -> None: + for relative, contents in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + + +class UbuntuAptStageTests(unittest.TestCase): + def test_apt_script_preserves_arguments_and_stops_after_an_update_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + root = base / "guest with spaces" + policy = root / "usr/sbin/policy-rc.d" + policy.parent.mkdir(parents=True) + policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8") + tools = base / "tools" + tools.mkdir() + for name in ("mount", "chroot"): + tool = tools / name + tool.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\nfrom pathlib import Path\n" + "name = Path(sys.argv[0]).name\n" + "print(json.dumps([name, *sys.argv[1:]]))\n" + "if name == 'chroot':\n" + " assert (Path(sys.argv[1]) / 'usr/sbin/policy-rc.d').is_file()\n" + " if os.environ.get('FAIL_UPDATE') == '1' and sys.argv[-1] == 'update':\n" + " sys.exit(100)\n", + encoding="utf-8", + ) + tool.chmod(0o755) + runner = CommandRunner(emit=None) + for fail in (False, True): + with self.subTest(fail=fail): + result = runner.run(( + "env", f"PATH={tools}", f"FAIL_UPDATE={int(fail)}", + "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT, + "uvm-guest-packages", root, "sudo", "openssh-server", + ), check=False, capture=True) + events = [json.loads(line) for line in result.stdout.splitlines()] + mounts = [event for event in events if event[0] == "mount"] + self.assertEqual([event[-1] for event in mounts], [ + str(root / "dev"), str(root / "dev"), str(root / "proc"), str(root / "sys"), + ]) + calls = [event for event in events if event[0] == "chroot"] + self.assertEqual(result.returncode, 100 if fail else 0) + self.assertEqual(len(calls), 1 if fail else 3) + self.assertEqual(calls[0][-3:], ["-o", "APT::Update::Error-Mode=any", "update"]) + for call in calls: + self.assertEqual(call[1:6], [str(root), "/usr/bin/env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get"]) + if not fail: + self.assertEqual(calls[1][-1], "dist-upgrade") + self.assertEqual(calls[2][-3:], ["install", "sudo", "openssh-server"]) + @unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")), "e2fsprogs is required for offline Ubuntu image checks") @@ -233,11 +308,23 @@ class UbuntuRootfsBuildTests(unittest.TestCase): self.assertIn(f"--arch={deb_arch}", bootstrap) self.assertEqual(bootstrap[-1], mirror) self.assertIn("--force-check-gpg", bootstrap) - self.assertIn("--extra-suites=jammy-updates,jammy-security", bootstrap) + self.assertIn("--components=main", bootstrap) + self.assertIn("--include=ca-certificates,ubuntu-keyring", bootstrap) + self.assertFalse(any(part.startswith("--extra-suites") for part in bootstrap)) self.assertIn("--mount-proc", bootstrap) self.assertIn("--kill-child", bootstrap) + packages = next(call for call in runner.calls if "uvm-guest-packages" in call) + self.assertLess(runner.calls.index(bootstrap), runner.calls.index(packages)) + self.assertIn("sudo", packages) + self.assertIn("openssh-server", packages) + self.assertIn("--mount-proc", packages) + self.assertIn("--kill-child", packages) self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600) - self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image]) + log = settings.images_dir / "ubuntu-build.log" + self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log}) + self.assertEqual(log.stat().st_mode & 0o777, 0o600) + self.assertIn("Base system installed successfully.", log.read_text()) + self.assertIn("Configured guest packages.", log.read_text()) def guest(command): return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True) @@ -252,16 +339,29 @@ class UbuntuRootfsBuildTests(unittest.TestCase): for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"): self.assertIn("File not found", guest(f"stat {path}").stderr) - def test_failed_bootstrap_or_missing_package_metadata_preserves_the_old_template(self) -> None: - for failure in ("bootstrap", "metadata"): + def test_failed_build_preserves_the_old_template_and_retains_diagnostics(self) -> None: + for failure in ("bootstrap", "packages", "metadata"): with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary: settings = Settings(base=Path(temporary)) settings.images_dir.mkdir() settings.rootfs_image.write_bytes(b"old template") runner = UbuntuBuildRunner(failure=failure) installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type] - with self.assertRaises(UvmError): + log = settings.images_dir / "ubuntu-build.log" + with self.assertRaisesRegex(UvmError, "details saved to .*ubuntu-build.log"): installer._build_ubuntu_rootfs(settings.rootfs_image) self.assertEqual(settings.rootfs_image.read_bytes(), b"old template") - self.assertEqual(list(settings.images_dir.iterdir()), [settings.rootfs_image]) + self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log}) + self.assertEqual(log.stat().st_mode & 0o777, 0o600) + details = log.read_text() + if failure == "bootstrap": + self.assertIn("--install needs at least one package archive", details) + self.assertIn("Failure while unpacking", details) + self.assertFalse(any("uvm-guest-packages" in call for call in runner.calls)) + elif failure == "packages": + self.assertIn("Reading package lists...", details) + self.assertIn("E: unmet guest dependencies", details) + self.assertLess(details.index("Base system installed successfully."), details.index("E: unmet guest dependencies")) + else: + self.assertIn("incomplete apt/dpkg package metadata", details) self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls)) diff --git a/uvm/install.py b/uvm/install.py index ded2449..a1b9fda 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shlex import shutil import tarfile import tempfile @@ -20,7 +21,7 @@ from .system import CommandRunner, check_kvm, ensure_data_directories, require_r _UBUNTU_RELEASE = "jammy" -_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v1\n" +_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v2\n" _GUEST_PACKAGES = ( "ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev", "dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", @@ -30,6 +31,24 @@ _EXT4_FEATURES = ( "none,has_journal,ext_attr,resize_inode,dir_index,filetype,extent,64bit," "flex_bg,sparse_super,large_file,huge_file,dir_nlink,extra_isize,metadata_csum" ) +_ROOTFS_NAMESPACE = ( + "unshare", "--mount", "--propagation", "private", "--pid", "--fork", + "--kill-child", "--mount-proc", +) +_GUEST_APT_SCRIPT = """\ +root=$1 +shift +mount --rbind /dev "$root/dev" +mount --make-rslave "$root/dev" +mount -t proc proc "$root/proc" +mount -t sysfs -o ro sysfs "$root/sys" +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\ + apt-get -o APT::Update::Error-Mode=any update +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\ + apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade +chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\ + apt-get -y --no-install-recommends install "$@" +""" class Installer: @@ -258,33 +277,36 @@ class Installer: with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name: temporary = Path(name) root = temporary / "root" - policy = root / "usr/sbin/policy-rc.d" - policy.parent.mkdir(parents=True) - policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8") - policy.chmod(0o755) - # Package scripts run natively, with private mounts/PIDs and service startup disabled. - self._runner.run(( - "unshare", "--mount", "--propagation", "private", "--pid", "--fork", - "--kill-child", "--mount-proc", "env", "DEBIAN_FRONTEND=noninteractive", - "debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr", - "--components=main,universe", f"--include={','.join(_GUEST_PACKAGES)}", - f"--extra-suites={_UBUNTU_RELEASE}-updates,{_UBUNTU_RELEASE}-security", - "--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg", - _UBUNTU_RELEASE, root, mirror, - )) - policy.unlink() - self._configure_ubuntu_rootfs(root, mirror) - self._validate_ubuntu_rootfs(root) + build_log = temporary / "build.log" + try: + # Bootstrap one consistent release; apt resolves updated package versions later. + self._run_rootfs_stage("Ubuntu bootstrap", ( + *_ROOTFS_NAMESPACE, "env", "DEBIAN_FRONTEND=noninteractive", + "debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr", + "--components=main", "--include=ca-certificates,ubuntu-keyring", + "--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg", + _UBUNTU_RELEASE, root, mirror, + ), build_log) + self._install_ubuntu_packages(root, mirror, build_log) + self._configure_ubuntu_rootfs(root) + self._validate_ubuntu_rootfs(root) - disk = temporary / "rootfs.ext4" - with disk.open("wb") as image: - image.truncate(2 * 1024 * 1024 * 1024) - disk.chmod(0o600) - self._runner.run(( - "mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES, - "-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk, - ), capture=True) - self._runner.run(("e2fsck", "-f", "-n", disk), capture=True) + disk = temporary / "rootfs.ext4" + with disk.open("wb") as image: + image.truncate(2 * 1024 * 1024 * 1024) + disk.chmod(0o600) + self._run_rootfs_stage("Ubuntu ext4 formatting", ( + "mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES, + "-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk, + ), build_log) + self._run_rootfs_stage("Ubuntu filesystem check", ("e2fsck", "-f", "-n", disk), build_log) + except (UvmError, OSError, UnicodeError) as error: + try: + log = self._save_rootfs_build_log(destination, root, build_log, error=error) + except OSError as log_error: + raise UvmError(f"Ubuntu rootfs build failed: {error}; could not save diagnostics: {log_error}") from error + raise UvmError(f"Ubuntu rootfs build failed: {error}; details saved to {log}") from error + self._save_rootfs_build_log(destination, root, build_log) with disk.open("rb") as image: os.fsync(image.fileno()) os.replace(disk, destination) @@ -296,12 +318,65 @@ class Installer: except (OSError, UnicodeError) as error: raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from error - def _configure_ubuntu_rootfs(self, root: Path, mirror: str) -> None: + def _install_ubuntu_packages(self, root: Path, mirror: str, build_log: Path) -> None: + sources = root / "etc/apt/sources.list" + sources.write_text("".join( + f"deb {mirror} {suite} main universe\n" + for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security") + ), encoding="utf-8") + sources.chmod(0o644) + # Ubuntu's debootstrap script removes its own policy-rc.d on success. + policy = root / "usr/sbin/policy-rc.d" + policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8") + policy.chmod(0o755) + try: + self._run_rootfs_stage("Ubuntu guest package installation", ( + *_ROOTFS_NAMESPACE, "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT, + "uvm-guest-packages", root, *_GUEST_PACKAGES, + ), build_log) + finally: + policy.unlink(missing_ok=True) + + def _run_rootfs_stage(self, stage: str, command: tuple[str | Path, ...], log: Path) -> None: + if self._emit is not None: + self._emit(stage) + with log.open("a", encoding="utf-8") as output: + output.write(f"\n=== {stage} ===\n+ {shlex.join(map(str, command))}\n") + result = self._runner.run(command, check=False, capture=True) + output.write(result.stdout) + output.write(result.stderr) + if result.returncode != 0: + raise UvmError(f"{stage} failed (exit {result.returncode})") + + @staticmethod + def _save_rootfs_build_log(destination: Path, root: Path, build_log: Path, *, error: Exception | None = None) -> Path: + log = destination.with_name("ubuntu-build.log") + descriptor, name = tempfile.mkstemp(prefix=".ubuntu-log-", dir=destination.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "wb") as output: + # Completed bootstrap details precede apt output; failed bootstrap details follow it. + for source in (root / "var/log/bootstrap.log", build_log, root / "debootstrap/debootstrap.log"): + if source.is_file(): + output.write(f"\n=== {source.name} ===\n".encode("utf-8")) + with source.open("rb") as content: + shutil.copyfileobj(content, output) + if error is not None: + output.write(f"\nUbuntu rootfs build failed: {error}\n".encode("utf-8", errors="replace")) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, log) + directory = os.open(log.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + temporary.unlink(missing_ok=True) + return log + + def _configure_ubuntu_rootfs(self, root: Path) -> None: files = { - "etc/apt/sources.list": "".join( - f"deb {mirror} {suite} main universe\n" - for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security") - ), "etc/fstab": "/dev/vda / ext4 defaults 0 1\n", "etc/hostname": "uvm\n", "etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n",