feat: enhance Ubuntu rootfs build process with improved logging and error handling
This commit is contained in:
+21
-5
@@ -211,7 +211,8 @@ Default runtime storage is `/var/lib/uvm`:
|
||||
│ └── jailer
|
||||
├── images/
|
||||
│ ├── vmlinux
|
||||
│ └── ubuntu.ext4
|
||||
│ ├── ubuntu.ext4
|
||||
│ └── ubuntu-build.log
|
||||
├── vms/
|
||||
│ └── vm-<uuid>/
|
||||
│ ├── 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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+120
-20
@@ -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,35 +186,108 @@ 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"] = ""
|
||||
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():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
if args[0] == "systemctl":
|
||||
return CommandResult(args, 0)
|
||||
return self.runner.run(command, **options)
|
||||
|
||||
|
||||
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")),
|
||||
@@ -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))
|
||||
|
||||
+96
-21
@@ -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",
|
||||
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,universe", f"--include={','.join(_GUEST_PACKAGES)}",
|
||||
f"--extra-suites={_UBUNTU_RELEASE}-updates,{_UBUNTU_RELEASE}-security",
|
||||
"--components=main", "--include=ca-certificates,ubuntu-keyring",
|
||||
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
|
||||
_UBUNTU_RELEASE, root, mirror,
|
||||
))
|
||||
policy.unlink()
|
||||
self._configure_ubuntu_rootfs(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((
|
||||
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,
|
||||
), capture=True)
|
||||
self._runner.run(("e2fsck", "-f", "-n", disk), capture=True)
|
||||
), 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:
|
||||
files = {
|
||||
"etc/apt/sources.list": "".join(
|
||||
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/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",
|
||||
|
||||
Reference in New Issue
Block a user