feat: enhance Ubuntu rootfs build process with improved logging and error handling

This commit is contained in:
2026-09-22 09:30:55 +05:30
parent 1d3644ab7c
commit bd92573158
5 changed files with 273 additions and 62 deletions
+21 -5
View File
@@ -211,7 +211,8 @@ Default runtime storage is `/var/lib/uvm`:
│ └── jailer │ └── jailer
├── images/ ├── images/
│ ├── vmlinux │ ├── vmlinux
│ └── ubuntu.ext4 │ ├── ubuntu.ext4
│ └── ubuntu-build.log
├── vms/ ├── vms/
│ └── vm-<uuid>/ │ └── vm-<uuid>/
│ ├── rootfs.ext4 │ ├── 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. uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs.
The default rootfs builder installs `debootstrap` and `ubuntu-keyring`, uses 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`. amd64 uses `archive.ubuntu.com/ubuntu`; arm64 uses `ports.ubuntu.com/ubuntu-ports`.
Package scripts execute in private mount/PID namespaces with a temporary Both phases execute in private mount/PID namespaces. The apt phase has private
`policy-rc.d` suppressing service startup. No mounts survive the build process. 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 The staged tree retains `/var/lib/dpkg` and apt metadata, validates installed
apt/sudo/sshd/systemd packages, enables SSH/networkd, preserves kernel-provided apt/sudo/sshd/systemd packages, enables SSH/networkd, preserves kernel-provided
static networking, and supplies guest DNS. Template host keys and machine IDs 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 rebuilt, and `install --force` always rebuilds. A failed staging build retains
the old template. Installation holds the existing operation lock throughout; the old template. Installation holds the existing operation lock throughout;
existing VM disks are never upgraded or reprovisioned by installation. 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 The host package list includes `nftables` and `conntrack` as well as the
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux 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. UID/GID allocation, account/home metadata, existing accounts, and password hashes.
`test_install.py` simulates package bootstrap and exercises real ext4 formatting `test_install.py` simulates package bootstrap and exercises real ext4 formatting
and readback for retained dpkg metadata, sudo modes, template configuration, 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, Lifecycle resize tests use fake processes/networking to verify ordering,
single-lock ownership, identity preservation, pending intent, and failure recovery. single-lock ownership, identity preservation, pending intent, and failure recovery.
+9 -2
View File
@@ -70,8 +70,10 @@ sudo env \
``` ```
The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures, The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures,
and native packages. UVM installs the builder prerequisites and records the and native packages. It bootstraps the base release first, then uses apt to
built ext4 image's SHA-256 in the integrity manifest. Building requires root 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. with mount/PID namespace support and internet access to Ubuntu mirrors.
Use `--force` to refresh downloads and rebuild the Ubuntu template: 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 updates the template used for future VMs. Restart a running API server to load
the updated provisioner before creating VMs through the dashboard. 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 For local experimentation only, the old unverified quick-start behavior is
available through an explicit opt-out: available through an explicit opt-out:
+16 -3
View File
@@ -175,9 +175,10 @@ Installation prints the Firecracker, kernel, and rootfs paths under
`/var/lib/uvm/integrity.json`. `/var/lib/uvm/integrity.json`.
The default build installs `debootstrap` and `ubuntu-keyring` on the host, The default build installs `debootstrap` and `ubuntu-keyring` on the host,
checks Ubuntu archive signatures, and builds inside private mount/PID checks Ubuntu archive signatures, bootstraps the base release, then uses apt
namespaces. It requires native root execution with namespace support. Leave to apply updates and install the guest packages. It builds inside private
`UVM_ROOTFS_URL` and `UVM_ROOTFS_SHA256` unset for this mode. To use a prebuilt 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. ext4 image, supply both its URL and SHA-256 instead.
### Scenario: Refresh Firecracker or Guest Assets ### 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. | | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
| `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. | | `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. | | `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 ## 5. Create VMs
+120 -20
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
import hashlib import hashlib
import io import io
import json
import os import os
import shutil import shutil
import sys
import tarfile import tarfile
import tempfile import tempfile
import unittest import unittest
@@ -13,9 +15,9 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from uvm.config import Settings from uvm.config import Settings
from uvm.errors import CommandError, UvmError from uvm.errors import UvmError
from uvm.images import ImageStore 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.integrity import load_manifest, write_manifest
from uvm.state import StateStore from uvm.state import StateStore
from uvm.system import CommandResult, CommandRunner 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: def test_only_verified_current_recipe_images_are_reused(self) -> None:
for verified, marker, force in ( for verified, marker, force in (
(True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False), (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: with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary)) settings = Settings(base=Path(temporary))
@@ -184,35 +186,108 @@ class UbuntuBuildRunner:
if "debootstrap" in args: if "debootstrap" in args:
root = Path(args[-2]) root = Path(args[-2])
if self.failure == "bootstrap": 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( status = "\n\n".join(
f"Package: {package}\nStatus: install ok installed\nVersion: 1.0" f"Package: {package}\nStatus: install ok installed\nVersion: 1.0"
for package in ("apt", "sudo", "openssh-server", "systemd-sysv") for package in ("apt", "sudo", "openssh-server", "systemd-sysv")
) + "\n" ) + "\n"
files = { 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/sudoers": "root ALL=(ALL:ALL) ALL\n",
"etc/ssh/sshd_config": "UsePAM yes\n", "etc/ssh/sshd_config": "UsePAM yes\n",
"etc/ssh/ssh_host_rsa_key": "template host key", "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", "usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary",
"var/lib/dpkg/status": status, "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", "var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n",
} }
if self.failure == "metadata": if self.failure == "metadata":
files["var/lib/dpkg/status"] = "" files["var/lib/dpkg/status"] = ""
self.write_files(root, files)
(root / "usr/bin/sudo").chmod(0o4755)
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(): for relative, contents in files.items():
path = root / relative path = root / relative
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8") path.write_text(contents, encoding="utf-8")
(root / "usr/bin/sudo").chmod(0o4755)
(root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf")
return CommandResult(args, 0) class UbuntuAptStageTests(unittest.TestCase):
if args[0] == "systemctl": def test_apt_script_preserves_arguments_and_stops_after_an_update_failure(self) -> None:
return CommandResult(args, 0) with tempfile.TemporaryDirectory() as temporary:
return self.runner.run(command, **options) 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")), @unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")),
@@ -233,11 +308,23 @@ class UbuntuRootfsBuildTests(unittest.TestCase):
self.assertIn(f"--arch={deb_arch}", bootstrap) self.assertIn(f"--arch={deb_arch}", bootstrap)
self.assertEqual(bootstrap[-1], mirror) self.assertEqual(bootstrap[-1], mirror)
self.assertIn("--force-check-gpg", bootstrap) 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("--mount-proc", bootstrap)
self.assertIn("--kill-child", 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(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): def guest(command):
return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True) 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"): for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"):
self.assertIn("File not found", guest(f"stat {path}").stderr) self.assertIn("File not found", guest(f"stat {path}").stderr)
def test_failed_bootstrap_or_missing_package_metadata_preserves_the_old_template(self) -> None: def test_failed_build_preserves_the_old_template_and_retains_diagnostics(self) -> None:
for failure in ("bootstrap", "metadata"): for failure in ("bootstrap", "packages", "metadata"):
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary: with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary)) settings = Settings(base=Path(temporary))
settings.images_dir.mkdir() settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"old template") settings.rootfs_image.write_bytes(b"old template")
runner = UbuntuBuildRunner(failure=failure) runner = UbuntuBuildRunner(failure=failure)
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type] 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) installer._build_ubuntu_rootfs(settings.rootfs_image)
self.assertEqual(settings.rootfs_image.read_bytes(), b"old template") 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)) self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls))
+96 -21
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import shutil import shutil
import tarfile import tarfile
import tempfile import tempfile
@@ -20,7 +21,7 @@ from .system import CommandRunner, check_kvm, ensure_data_directories, require_r
_UBUNTU_RELEASE = "jammy" _UBUNTU_RELEASE = "jammy"
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v1\n" _ROOTFS_RECIPE = "uvm-ubuntu-jammy-v2\n"
_GUEST_PACKAGES = ( _GUEST_PACKAGES = (
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev", "ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", "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," "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" "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: class Installer:
@@ -258,33 +277,36 @@ class Installer:
with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name: with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name:
temporary = Path(name) temporary = Path(name)
root = temporary / "root" root = temporary / "root"
policy = root / "usr/sbin/policy-rc.d" build_log = temporary / "build.log"
policy.parent.mkdir(parents=True) try:
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8") # Bootstrap one consistent release; apt resolves updated package versions later.
policy.chmod(0o755) self._run_rootfs_stage("Ubuntu bootstrap", (
# Package scripts run natively, with private mounts/PIDs and service startup disabled. *_ROOTFS_NAMESPACE, "env", "DEBIAN_FRONTEND=noninteractive",
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", "debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr",
"--components=main,universe", f"--include={','.join(_GUEST_PACKAGES)}", "--components=main", "--include=ca-certificates,ubuntu-keyring",
f"--extra-suites={_UBUNTU_RELEASE}-updates,{_UBUNTU_RELEASE}-security",
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg", "--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
_UBUNTU_RELEASE, root, mirror, _UBUNTU_RELEASE, root, mirror,
)) ), build_log)
policy.unlink() self._install_ubuntu_packages(root, mirror, build_log)
self._configure_ubuntu_rootfs(root, mirror) self._configure_ubuntu_rootfs(root)
self._validate_ubuntu_rootfs(root) self._validate_ubuntu_rootfs(root)
disk = temporary / "rootfs.ext4" disk = temporary / "rootfs.ext4"
with disk.open("wb") as image: with disk.open("wb") as image:
image.truncate(2 * 1024 * 1024 * 1024) image.truncate(2 * 1024 * 1024 * 1024)
disk.chmod(0o600) disk.chmod(0o600)
self._runner.run(( self._run_rootfs_stage("Ubuntu ext4 formatting", (
"mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES, "mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES,
"-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk, "-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk,
), capture=True) ), build_log)
self._runner.run(("e2fsck", "-f", "-n", disk), capture=True) 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: with disk.open("rb") as image:
os.fsync(image.fileno()) os.fsync(image.fileno())
os.replace(disk, destination) os.replace(disk, destination)
@@ -296,12 +318,65 @@ class Installer:
except (OSError, UnicodeError) as error: except (OSError, UnicodeError) as error:
raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from 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:
files = { sources = root / "etc/apt/sources.list"
"etc/apt/sources.list": "".join( sources.write_text("".join(
f"deb {mirror} {suite} main universe\n" f"deb {mirror} {suite} main universe\n"
for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security") 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/fstab": "/dev/vda / ext4 defaults 0 1\n", "etc/fstab": "/dev/vda / ext4 defaults 0 1\n",
"etc/hostname": "uvm\n", "etc/hostname": "uvm\n",
"etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n", "etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n",