492 lines
21 KiB
Python
492 lines
21 KiB
Python
"""Installation of host prerequisites, Firecracker, and guest assets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import tarfile
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
from .config import Settings
|
|
from .errors import UvmError
|
|
from .images import GuestAssets
|
|
from .integrity import load_manifest, require_checksum, sha256_file, verify_file, write_manifest
|
|
from .state import StateStore
|
|
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
|
|
|
|
|
|
_UBUNTU_RELEASE = "jammy"
|
|
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v2\n"
|
|
_GUEST_PACKAGES = (
|
|
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
|
|
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl",
|
|
)
|
|
# Keep the image mountable by the older Firecracker quickstart guest kernels.
|
|
_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:
|
|
"""Install exactly the local host dependencies used by the uvm CLI."""
|
|
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
runner: CommandRunner,
|
|
state_store: StateStore,
|
|
*,
|
|
emit: Callable[[str], None] | None = print,
|
|
) -> None:
|
|
self._settings = settings
|
|
self._runner = runner
|
|
self._state_store = state_store
|
|
self._emit = emit
|
|
|
|
def install(self, *, force_assets: bool = False) -> tuple[Path, GuestAssets]:
|
|
require_root()
|
|
ensure_data_directories(self._settings)
|
|
with self._state_store.operation_lock():
|
|
return self._install_locked(force_assets=force_assets)
|
|
|
|
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
|
|
self._validate_integrity_policy()
|
|
if self._settings.allow_unverified_downloads and self._emit is not None:
|
|
self._emit(
|
|
"WARNING: downloads are not checksum verified. Set UVM_*_SHA256 values "
|
|
"and UVM_ALLOW_UNVERIFIED_DOWNLOADS=0 to enforce verification."
|
|
)
|
|
self.install_apt_packages()
|
|
check_kvm()
|
|
firecracker = self.install_firecracker_binary(force=force_assets)
|
|
assets = self.install_guest_assets(force=force_assets)
|
|
self._write_integrity_manifest(firecracker, assets)
|
|
return firecracker, assets
|
|
|
|
def _validate_integrity_policy(self) -> None:
|
|
require_checksum(
|
|
self._settings.firecracker_sha256,
|
|
"Firecracker archive",
|
|
"UVM_FIRECRACKER_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
require_checksum(
|
|
self._settings.kernel_sha256,
|
|
"guest kernel",
|
|
"UVM_KERNEL_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
if self._settings.rootfs_url:
|
|
require_checksum(
|
|
self._settings.rootfs_sha256,
|
|
"guest root filesystem",
|
|
"UVM_ROOTFS_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
elif self._settings.rootfs_sha256:
|
|
raise UvmError(
|
|
"UVM_ROOTFS_SHA256 requires UVM_ROOTFS_URL for a prebuilt image; "
|
|
"unset UVM_ROOTFS_SHA256 to build the default Ubuntu rootfs"
|
|
)
|
|
|
|
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
|
|
write_manifest(
|
|
self._settings.integrity_manifest_path,
|
|
{
|
|
"firecracker": sha256_file(firecracker, "Firecracker binary"),
|
|
"jailer": sha256_file(self._settings.jailer_binary, "Jailer binary"),
|
|
"kernel": sha256_file(assets.kernel, "guest kernel"),
|
|
"rootfs": sha256_file(assets.rootfs, "guest root filesystem"),
|
|
},
|
|
verified=all(
|
|
(
|
|
self._settings.firecracker_sha256,
|
|
self._settings.kernel_sha256,
|
|
self._settings.rootfs_sha256 if self._settings.rootfs_url else True,
|
|
)
|
|
),
|
|
)
|
|
|
|
def install_apt_packages(self) -> None:
|
|
packages = (
|
|
"curl",
|
|
"jq",
|
|
"iproute2",
|
|
"iptables",
|
|
"nftables",
|
|
"conntrack",
|
|
"coreutils",
|
|
"e2fsprogs",
|
|
"util-linux",
|
|
"ca-certificates",
|
|
"openssl",
|
|
"openssh-client",
|
|
)
|
|
if not self._settings.rootfs_url:
|
|
packages += ("debootstrap", "ubuntu-keyring")
|
|
self._runner.run(("apt-get", "update"))
|
|
self._runner.run(("apt-get", "install", "-y", *packages))
|
|
|
|
def install_firecracker_binary(self, *, force: bool = False) -> Path:
|
|
architecture = self._settings.architecture
|
|
|
|
firecracker = self._settings.firecracker_binary
|
|
jailer = self._settings.jailer_binary
|
|
if (
|
|
firecracker.exists()
|
|
and jailer.exists()
|
|
and self._settings.allow_unverified_downloads
|
|
and not force
|
|
):
|
|
return firecracker
|
|
|
|
archive = self._settings.base / (
|
|
f"firecracker-{self._settings.firecracker_version}-{architecture}.tgz"
|
|
)
|
|
url = (
|
|
"https://github.com/firecracker-microvm/firecracker/releases/download/"
|
|
f"{self._settings.firecracker_version}/"
|
|
f"firecracker-{self._settings.firecracker_version}-{architecture}.tgz"
|
|
)
|
|
if force or not archive.exists():
|
|
self.download(
|
|
url,
|
|
archive,
|
|
expected_sha256=self._settings.firecracker_sha256,
|
|
label="Firecracker archive",
|
|
environment_name="UVM_FIRECRACKER_SHA256",
|
|
)
|
|
verify_file(
|
|
archive,
|
|
self._settings.firecracker_sha256,
|
|
"Firecracker archive",
|
|
"UVM_FIRECRACKER_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
|
|
try:
|
|
with tarfile.open(archive, "r:gz") as tar:
|
|
self._extract_archive_safely(tar, self._settings.base)
|
|
except (OSError, tarfile.TarError) as error:
|
|
raise UvmError(f"could not extract Firecracker archive {archive}: {error}") from error
|
|
|
|
release_dir = self._settings.base / (
|
|
f"release-{self._settings.firecracker_version}-{architecture}"
|
|
)
|
|
source_firecracker = self._find_release_binary(release_dir, "firecracker-")
|
|
source_jailer = self._find_release_binary(release_dir, "jailer-")
|
|
if source_firecracker is None or source_jailer is None:
|
|
raise UvmError("Firecracker archive layout was not recognized")
|
|
|
|
try:
|
|
self._settings.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_firecracker, firecracker)
|
|
firecracker.chmod(0o755)
|
|
shutil.copy2(source_jailer, jailer)
|
|
jailer.chmod(0o755)
|
|
except OSError as error:
|
|
raise UvmError(f"could not install Firecracker binaries: {error}") from error
|
|
return firecracker
|
|
|
|
def install_guest_assets(self, *, force: bool = False) -> GuestAssets:
|
|
kernel = self._settings.kernel_image
|
|
rootfs = self._settings.rootfs_image
|
|
if force or not kernel.exists():
|
|
self.download(
|
|
self._settings.kernel_url,
|
|
kernel,
|
|
expected_sha256=self._settings.kernel_sha256,
|
|
label="guest kernel",
|
|
environment_name="UVM_KERNEL_SHA256",
|
|
)
|
|
verify_file(
|
|
kernel,
|
|
self._settings.kernel_sha256,
|
|
"guest kernel",
|
|
"UVM_KERNEL_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
if not self._settings.rootfs_url:
|
|
self._install_ubuntu_rootfs(rootfs, force=force)
|
|
elif force or not rootfs.exists():
|
|
self.download(
|
|
self._settings.rootfs_url,
|
|
rootfs,
|
|
expected_sha256=self._settings.rootfs_sha256,
|
|
label="guest root filesystem",
|
|
environment_name="UVM_ROOTFS_SHA256",
|
|
)
|
|
if self._settings.rootfs_url:
|
|
verify_file(
|
|
rootfs,
|
|
self._settings.rootfs_sha256,
|
|
"guest root filesystem",
|
|
"UVM_ROOTFS_SHA256",
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
|
|
|
def _install_ubuntu_rootfs(self, destination: Path, *, force: bool) -> None:
|
|
if destination.is_file() and not force:
|
|
manifest = load_manifest(self._settings.integrity_manifest_path)
|
|
checksum = manifest.checksums.get("rootfs")
|
|
if manifest.verified and checksum:
|
|
verify_file(destination, checksum, "Ubuntu rootfs", "integrity manifest", allow_unverified=False)
|
|
marker = self._runner.run(
|
|
("debugfs", "-R", "cat /etc/uvm-image", destination), capture=True,
|
|
)
|
|
if marker.stdout == _ROOTFS_RECIPE:
|
|
return
|
|
# Rebuild old demo images and unverified caches rather than promoting their trust.
|
|
self._build_ubuntu_rootfs(destination)
|
|
|
|
def _build_ubuntu_rootfs(self, destination: Path) -> None:
|
|
architecture = "arm64" if self._settings.architecture == "aarch64" else "amd64"
|
|
mirror = (
|
|
"https://ports.ubuntu.com/ubuntu-ports" if architecture == "arm64"
|
|
else "https://archive.ubuntu.com/ubuntu"
|
|
)
|
|
if self._emit is not None:
|
|
self._emit("Building Ubuntu 22.04 rootfs with sudo, OpenSSH, and apt/dpkg metadata")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name:
|
|
temporary = Path(name)
|
|
root = temporary / "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._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)
|
|
directory = os.open(destination.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
except (OSError, UnicodeError) as error:
|
|
raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from error
|
|
|
|
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",
|
|
"etc/resolv.conf": "nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
|
|
"etc/machine-id": "",
|
|
"etc/uvm-image": _ROOTFS_RECIPE,
|
|
"etc/systemd/network/10-uvm.network": (
|
|
"[Match]\nName=eth0\n\n[Network]\n"
|
|
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
|
),
|
|
}
|
|
for relative, contents in files.items():
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.unlink(missing_ok=True) # In particular, replace a resolver symlink with guest DNS.
|
|
path.write_text(contents, encoding="utf-8")
|
|
path.chmod(0o644)
|
|
machine_id = root / "var/lib/dbus/machine-id"
|
|
machine_id.parent.mkdir(parents=True, exist_ok=True)
|
|
machine_id.unlink(missing_ok=True)
|
|
machine_id.symlink_to("../../../etc/machine-id")
|
|
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
|
|
key.unlink()
|
|
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service"), capture=True)
|
|
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)
|
|
|
|
@staticmethod
|
|
def _validate_ubuntu_rootfs(root: Path) -> None:
|
|
for relative in (
|
|
"etc/passwd", "etc/shadow", "etc/group", "etc/sudoers", "etc/ssh/sshd_config",
|
|
"usr/bin/sudo", "usr/bin/apt-get", "usr/sbin/sshd", "usr/lib/systemd/systemd",
|
|
"var/lib/dpkg/status", "var/lib/dpkg/info/apt.list", "var/lib/dpkg/info/sudo.list",
|
|
):
|
|
if not (root / relative).is_file():
|
|
raise UvmError(f"built Ubuntu rootfs is missing /{relative}")
|
|
installed = set()
|
|
for paragraph in (root / "var/lib/dpkg/status").read_text(encoding="utf-8").split("\n\n"):
|
|
fields = dict(line.split(": ", 1) for line in paragraph.splitlines() if ": " in line)
|
|
if fields.get("Status") == "install ok installed":
|
|
installed.add(fields.get("Package"))
|
|
if not {"apt", "sudo", "openssh-server", "systemd-sysv"}.issubset(installed):
|
|
raise UvmError("built Ubuntu rootfs has incomplete apt/dpkg package metadata")
|
|
if not (root / "usr/bin/sudo").stat().st_mode & 0o4000:
|
|
raise UvmError("built Ubuntu rootfs sudo is missing its setuid permission")
|
|
|
|
def download(
|
|
self,
|
|
url: str,
|
|
destination: Path,
|
|
*,
|
|
expected_sha256: str | None,
|
|
label: str,
|
|
environment_name: str,
|
|
) -> None:
|
|
"""Download to a sibling temporary file before atomically publishing it."""
|
|
|
|
if self._emit is not None:
|
|
self._emit(f"Downloading {url}")
|
|
require_checksum(
|
|
expected_sha256,
|
|
label,
|
|
environment_name,
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
prefix=f".{destination.name}.",
|
|
suffix=".tmp",
|
|
dir=destination.parent,
|
|
)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as temporary_file:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=60) as response:
|
|
shutil.copyfileobj(response, temporary_file)
|
|
except (OSError, urllib.error.URLError) as error:
|
|
raise UvmError(f"could not download {url}: {error}") from error
|
|
temporary_file.flush()
|
|
os.fsync(temporary_file.fileno())
|
|
verify_file(
|
|
Path(temporary_name),
|
|
expected_sha256,
|
|
label,
|
|
environment_name,
|
|
allow_unverified=self._settings.allow_unverified_downloads,
|
|
)
|
|
os.replace(temporary_name, destination)
|
|
finally:
|
|
try:
|
|
os.unlink(temporary_name)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def _extract_archive_safely(tar: tarfile.TarFile, destination: Path) -> None:
|
|
root = destination.resolve()
|
|
for member in tar.getmembers():
|
|
target = (destination / member.name).resolve()
|
|
if target != root and root not in target.parents:
|
|
raise UvmError("Firecracker archive contains an unsafe path")
|
|
tar.extractall(destination, filter="data")
|
|
|
|
@staticmethod
|
|
def _find_release_binary(release_dir: Path, prefix: str) -> Path | None:
|
|
if not release_dir.exists():
|
|
return None
|
|
candidates = sorted(
|
|
path
|
|
for path in release_dir.rglob(f"{prefix}*")
|
|
if path.is_file() and path.name.startswith(prefix)
|
|
)
|
|
return candidates[0] if candidates else None
|