Files
nest/uvm/install.py
T
kstyagi fb80ce7c8a feat: Add storage management for VM creation and resizing
- Introduced storage parameter in VmCreateRequest and VmResizeRequest models.
- Implemented storage validation to ensure minimum and maximum limits.
- Enhanced CLI to support storage options during VM creation and resizing.
- Updated lifecycle management to handle pending resize operations for VM resources.
- Added methods for resizing VM disks and validating storage capacity.
- Updated API endpoints to accommodate new storage functionality.
- Refactored related validation and configuration logic to support storage management.
2026-09-21 19:16:33 +05:30

277 lines
10 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 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