Files
2026-09-04 20:56:18 +00:00

287 lines
11 KiB
Python

"""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 DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, 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_guest_asset_architecture()
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_guest_asset_architecture(self) -> None:
architecture = os.uname().machine
default_kernel = self._settings.kernel_url == DEFAULT_KERNEL_URL
default_rootfs = self._settings.rootfs_url == DEFAULT_ROOTFS_URL
if architecture == "aarch64" and (default_kernel or default_rootfs):
raise UvmError(
"default guest assets support x86_64 only; set both UVM_KERNEL_URL "
"and UVM_ROOTFS_URL to compatible aarch64 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",
"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 = os.uname().machine
if architecture not in ("x86_64", "aarch64"):
raise UvmError(f"unsupported host architecture: {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