"""Installation of host prerequisites, Firecracker, and guest assets.""" from __future__ import annotations import os 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 require_checksum, sha256_file, verify_file, write_manifest from .state import StateStore from .system import CommandRunner, check_kvm, ensure_data_directories, require_root 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, ) require_checksum( self._settings.rootfs_sha256, "guest root filesystem", "UVM_ROOTFS_SHA256", allow_unverified=self._settings.allow_unverified_downloads, ) 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, ) ), ) def install_apt_packages(self) -> None: packages = ( "curl", "jq", "iproute2", "iptables", "nftables", "conntrack", "coreutils", "e2fsprogs", "util-linux", "ca-certificates", "openssl", "openssh-client", ) 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", ) if 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", ) verify_file( kernel, self._settings.kernel_sha256, "guest kernel", "UVM_KERNEL_SHA256", allow_unverified=self._settings.allow_unverified_downloads, ) 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 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