Files
nest/uvm/install.py
T

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