__init__
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Internal implementation package for the uvm command-line application."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Run the uvm command-line application with ``python -m uvm``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Pydantic request and response models for the UVM HTTP API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
from .domain import VmRecord
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
class InstallRequest(BaseModel):
|
||||
force: bool = False
|
||||
|
||||
class Config:
|
||||
extra = "forbid"
|
||||
|
||||
|
||||
class InstallResponse(BaseModel):
|
||||
firecracker: str
|
||||
kernel: str
|
||||
rootfs: str
|
||||
|
||||
|
||||
class VmCreateRequest(BaseModel):
|
||||
cpu: float = Field(default=1, gt=0)
|
||||
ram: str | int | float = "512"
|
||||
guest_ip: IPv4Address | None = None
|
||||
username: str = "root"
|
||||
password: SecretStr = SecretStr("root")
|
||||
|
||||
class Config:
|
||||
extra = "forbid"
|
||||
|
||||
|
||||
class VmResponse(BaseModel):
|
||||
id: str
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: str
|
||||
gateway: str
|
||||
mac: str
|
||||
username: str
|
||||
status: str
|
||||
observed_status: str
|
||||
pid: int | None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
last_error: str | None
|
||||
|
||||
|
||||
class DestroyResponse(BaseModel):
|
||||
id: str
|
||||
status: str = "terminated"
|
||||
|
||||
|
||||
def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmResponse:
|
||||
return VmResponse(
|
||||
id=vm.id,
|
||||
cpu=vm.cpu,
|
||||
ram_mib=vm.ram_mib,
|
||||
guest_ip=vm.guest_ip,
|
||||
gateway=vm.gateway,
|
||||
mac=vm.mac,
|
||||
username=vm.username,
|
||||
status=vm.status,
|
||||
observed_status=observed_status or vm.status,
|
||||
pid=vm.pid,
|
||||
created_at=vm.created_at,
|
||||
updated_at=vm.updated_at,
|
||||
last_error=vm.last_error,
|
||||
)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Application composition root for the uvm command-line executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import Settings
|
||||
from .firecracker.process import FirecrackerProcessManager
|
||||
from .images import ImageStore
|
||||
from .install import Installer
|
||||
from .lifecycle import LifecycleService
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Application:
|
||||
"""Concrete host services used by the CLI; this is not a public SDK."""
|
||||
|
||||
settings: Settings
|
||||
state_store: StateStore
|
||||
installer: Installer
|
||||
lifecycle: LifecycleService
|
||||
|
||||
|
||||
def build_application(emit: Callable[[str], None] | None = print) -> Application:
|
||||
settings = Settings.from_environment()
|
||||
runner = CommandRunner(emit=emit)
|
||||
state_store = StateStore(settings)
|
||||
images = ImageStore(settings, runner)
|
||||
network = NetworkManager(settings, runner)
|
||||
process = FirecrackerProcessManager(settings)
|
||||
lifecycle = LifecycleService(settings, state_store, images, network, process)
|
||||
installer = Installer(settings, runner, state_store, emit=emit)
|
||||
return Application(
|
||||
settings=settings,
|
||||
state_store=state_store,
|
||||
installer=installer,
|
||||
lifecycle=lifecycle,
|
||||
)
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
"""The sole public interface of this project: the local uvm CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from .app import Application, build_application
|
||||
from .domain import VmSpec
|
||||
from .errors import UvmError, ValidationError
|
||||
from .firecracker.config import vcpu_count
|
||||
from .validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
|
||||
|
||||
def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=application.settings.app_name,
|
||||
description="Tiny Firecracker microVM CLI for Ubuntu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--serve",
|
||||
action="store_true",
|
||||
help="launch the FastAPI management server instead of running a CLI command",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="server bind address used with --serve; defaults to 127.0.0.1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=_port_number,
|
||||
default=8000,
|
||||
help="server bind port used with --serve; defaults to 8000",
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command")
|
||||
|
||||
install = subcommands.add_parser(
|
||||
"install",
|
||||
description=(
|
||||
"Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 "
|
||||
"to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
|
||||
"development-only opt-out."
|
||||
),
|
||||
help="install dependencies and assets; SHA-256 values are required by default",
|
||||
)
|
||||
install.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
|
||||
)
|
||||
|
||||
create = subcommands.add_parser("create", help="create and boot a microVM")
|
||||
create.add_argument(
|
||||
"--cpu",
|
||||
default=str(application.settings.default_vcpu),
|
||||
help="CPU capacity, e.g. 1, 2, 0.5 (fractional CPU is advisory)",
|
||||
)
|
||||
create.add_argument(
|
||||
"--ram",
|
||||
default=str(application.settings.default_ram_mib),
|
||||
help="RAM, e.g. 512, 1G, 512M",
|
||||
)
|
||||
create.add_argument(
|
||||
"--host-ip",
|
||||
dest="host_ip",
|
||||
help="guest IP to assign; otherwise uvm allocates one",
|
||||
)
|
||||
create.add_argument(
|
||||
"--username",
|
||||
default=application.settings.default_ssh_user,
|
||||
help="existing guest account to configure; defaults to root",
|
||||
)
|
||||
create.add_argument(
|
||||
"--password",
|
||||
default="root",
|
||||
help="guest login password; defaults to root",
|
||||
)
|
||||
|
||||
subcommands.add_parser("list", help="list VMs")
|
||||
|
||||
ssh = subcommands.add_parser("ssh", help="SSH into a VM")
|
||||
ssh.add_argument("vm", help="VM ID or guest IP")
|
||||
ssh.add_argument("--user", help="override the username stored for the VM")
|
||||
ssh.add_argument("--key")
|
||||
ssh.add_argument(
|
||||
"--insecure-host-key",
|
||||
action="store_true",
|
||||
help="disable SSH host-key verification for this connection",
|
||||
)
|
||||
|
||||
stop = subcommands.add_parser("stop", help="stop a VM")
|
||||
stop.add_argument("vm")
|
||||
|
||||
destroy = subcommands.add_parser("destroy", help="stop and remove a VM")
|
||||
destroy.add_argument("vm")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
try:
|
||||
application = build_application()
|
||||
parser = build_parser(application)
|
||||
args = parser.parse_args(argv)
|
||||
if args.serve:
|
||||
if args.command is not None:
|
||||
parser.error("--serve cannot be combined with a CLI command")
|
||||
return _run_server(application, args)
|
||||
if args.command is None:
|
||||
parser.error("a command is required unless --serve is used")
|
||||
return _run_command(application, args)
|
||||
except UvmError as error:
|
||||
print(f"uvm: error: {error}", file=sys.stderr)
|
||||
return error.exit_code
|
||||
except KeyboardInterrupt:
|
||||
print("uvm: interrupted", file=sys.stderr)
|
||||
return 130
|
||||
|
||||
|
||||
def _run_server(application: Application, args: argparse.Namespace) -> int:
|
||||
from .server import run_server
|
||||
|
||||
run_server(application, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
if args.command == "install":
|
||||
firecracker, assets = application.installer.install(force_assets=args.force)
|
||||
application.state_store.initialize()
|
||||
print()
|
||||
print("uvm installed.")
|
||||
print(f" Firecracker: {firecracker}")
|
||||
print(f" Kernel: {assets.kernel}")
|
||||
print(f" Rootfs: {assets.rootfs}")
|
||||
print()
|
||||
print("Next:")
|
||||
if application.settings.allow_unverified_downloads:
|
||||
print(" Keep UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 for later create commands.")
|
||||
print(" Run your UVM command with: create --cpu 1 --ram 512")
|
||||
return 0
|
||||
|
||||
if args.command == "create":
|
||||
guest_ip = _parse_guest_ip(args.host_ip)
|
||||
vm = application.lifecycle.create(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(args.cpu),
|
||||
ram_mib=parse_ram(args.ram),
|
||||
guest_ip=guest_ip,
|
||||
username=parse_username(args.username),
|
||||
password=parse_password(args.password),
|
||||
)
|
||||
)
|
||||
if vm.cpu < 1.0:
|
||||
print(
|
||||
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU;"
|
||||
)
|
||||
print(" fractional CPU enforcement is not yet applied by this MVP.")
|
||||
print()
|
||||
print(f"VM created: {vm.id}")
|
||||
print(f" IP: {vm.guest_ip}")
|
||||
print(f" RAM: {vm.ram_mib} MiB")
|
||||
print(f" CPU: {vm.cpu}")
|
||||
print(f" TAP: {vm.tap}")
|
||||
print(f" Username: {vm.username}")
|
||||
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
|
||||
print()
|
||||
if vm.password == "root":
|
||||
print("WARNING: the guest is using the default password 'root'. Change it promptly.")
|
||||
return 0
|
||||
|
||||
if args.command == "list":
|
||||
vms = application.lifecycle.list_vms()
|
||||
if not vms:
|
||||
print("No VMs.")
|
||||
return 0
|
||||
print(
|
||||
f"{'ID':<37} {'IP':<16} {'USER':<16} {'CPU':<7} "
|
||||
f"{'RAM':<8} {'STATUS':<10} PID"
|
||||
)
|
||||
for listed in vms:
|
||||
vm = listed.vm
|
||||
pid = vm.pid if vm.pid is not None else ""
|
||||
print(
|
||||
f"{vm.id:<37} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
|
||||
f"{vm.ram_mib:<8} {listed.observed_status:<10} {pid}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "ssh":
|
||||
vm = application.lifecycle.find_for_ssh(args.vm)
|
||||
command = ["ssh"]
|
||||
if args.key:
|
||||
command.extend(("-i", args.key))
|
||||
if args.insecure_host_key:
|
||||
command.extend(
|
||||
(
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
)
|
||||
)
|
||||
else:
|
||||
command.extend(
|
||||
(
|
||||
"-o",
|
||||
f"HostKeyAlias=uvm-{vm.id}",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
)
|
||||
)
|
||||
command.append(f"{args.user or vm.username}@{vm.guest_ip}")
|
||||
os.execvp(command[0], command)
|
||||
return 0
|
||||
|
||||
if args.command == "stop":
|
||||
vm = application.lifecycle.stop(args.vm)
|
||||
print(f"Stopped {vm.id}")
|
||||
return 0
|
||||
|
||||
if args.command == "destroy":
|
||||
vm = application.lifecycle.destroy(args.vm)
|
||||
print(f"Destroyed {vm.id}")
|
||||
return 0
|
||||
|
||||
raise UvmError(f"unsupported command: {args.command}")
|
||||
|
||||
|
||||
def _parse_guest_ip(value: str | None) -> IPv4Address | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return IPv4Address(value)
|
||||
except ValueError as error:
|
||||
raise ValidationError(f"invalid guest IP: {value}") from error
|
||||
|
||||
|
||||
def _port_number(value: str) -> int:
|
||||
try:
|
||||
port = int(value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("port must be an integer") from error
|
||||
if not 1 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
|
||||
return port
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
"""Configuration and filesystem layout for the uvm command-line application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ConfigurationError
|
||||
|
||||
|
||||
DEFAULT_KERNEL_URL = (
|
||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||
"x86_64/kernels/vmlinux.bin"
|
||||
)
|
||||
DEFAULT_ROOTFS_URL = (
|
||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||
"x86_64/rootfs/bionic.rootfs.ext4"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
"""All host-specific values consumed by the application."""
|
||||
|
||||
app_name: str = "uvm"
|
||||
base: Path = Path("/var/lib/uvm")
|
||||
network: IPv4Network = IPv4Network("10.42.0.0/24")
|
||||
gateway: IPv4Address = IPv4Address("10.42.0.1")
|
||||
bridge: str = "uvm0"
|
||||
firecracker_version: str = "v1.16.1"
|
||||
default_ram_mib: int = 512
|
||||
default_vcpu: float = 1
|
||||
default_ssh_user: str = "root"
|
||||
kernel_url: str = DEFAULT_KERNEL_URL
|
||||
rootfs_url: str = DEFAULT_ROOTFS_URL
|
||||
firecracker_sha256: str | None = None
|
||||
firecracker_binary_sha256: str | None = None
|
||||
kernel_sha256: str | None = None
|
||||
rootfs_sha256: str | None = None
|
||||
allow_unverified_downloads: bool = False
|
||||
api_token: str | None = None
|
||||
api_tls_cert: Path | None = None
|
||||
api_tls_key: Path | None = None
|
||||
api_timeout_s: float = 2.0
|
||||
api_socket_timeout_s: float = 5.0
|
||||
terminate_timeout_s: float = 5.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.gateway not in self.network:
|
||||
raise ConfigurationError(
|
||||
f"gateway {self.gateway} is outside configured network {self.network}"
|
||||
)
|
||||
if self.gateway in (self.network.network_address, self.network.broadcast_address):
|
||||
raise ConfigurationError("gateway must be a usable host address")
|
||||
if len(self.bridge) > 15:
|
||||
raise ConfigurationError("bridge name must be 15 characters or fewer")
|
||||
if self.api_token is not None:
|
||||
if not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token):
|
||||
raise ConfigurationError(
|
||||
"UVM_API_TOKEN must contain only visible ASCII characters without whitespace"
|
||||
)
|
||||
|
||||
@property
|
||||
def bin_dir(self) -> Path:
|
||||
return self.base / "bin"
|
||||
|
||||
@property
|
||||
def images_dir(self) -> Path:
|
||||
return self.base / "images"
|
||||
|
||||
@property
|
||||
def vms_dir(self) -> Path:
|
||||
return self.base / "vms"
|
||||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
return self.base / "state.json"
|
||||
|
||||
@property
|
||||
def state_lock_path(self) -> Path:
|
||||
return self.base / "state.lock"
|
||||
|
||||
@property
|
||||
def operation_lock_path(self) -> Path:
|
||||
return self.base / "operations.lock"
|
||||
|
||||
@property
|
||||
def firecracker_binary(self) -> Path:
|
||||
return self.bin_dir / "firecracker"
|
||||
|
||||
@property
|
||||
def jailer_binary(self) -> Path:
|
||||
return self.bin_dir / "jailer"
|
||||
|
||||
@property
|
||||
def kernel_image(self) -> Path:
|
||||
return self.images_dir / "vmlinux"
|
||||
|
||||
@property
|
||||
def rootfs_image(self) -> Path:
|
||||
return self.images_dir / "ubuntu.ext4"
|
||||
|
||||
@property
|
||||
def integrity_manifest_path(self) -> Path:
|
||||
return self.base / "integrity.json"
|
||||
|
||||
def vm_dir(self, vm_id: str) -> Path:
|
||||
return self.vms_dir / vm_id
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "Settings":
|
||||
"""Load optional deployment overrides while retaining script defaults."""
|
||||
|
||||
try:
|
||||
network = IPv4Network(os.environ.get("UVM_NETWORK", "10.42.0.0/24"))
|
||||
gateway = IPv4Address(os.environ.get("UVM_GATEWAY", "10.42.0.1"))
|
||||
except ValueError as error:
|
||||
raise ConfigurationError(f"invalid network configuration: {error}") from error
|
||||
|
||||
return cls(
|
||||
base=Path(os.environ.get("UVM_BASE", "/var/lib/uvm")).expanduser(),
|
||||
network=network,
|
||||
gateway=gateway,
|
||||
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
|
||||
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL),
|
||||
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL),
|
||||
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
|
||||
firecracker_binary_sha256=_optional_environment_value(
|
||||
"UVM_FIRECRACKER_BINARY_SHA256"
|
||||
),
|
||||
kernel_sha256=_optional_environment_value("UVM_KERNEL_SHA256"),
|
||||
rootfs_sha256=_optional_environment_value("UVM_ROOTFS_SHA256"),
|
||||
allow_unverified_downloads=_boolean_environment_value(
|
||||
"UVM_ALLOW_UNVERIFIED_DOWNLOADS", default=False
|
||||
),
|
||||
api_token=_optional_environment_value("UVM_API_TOKEN"),
|
||||
api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"),
|
||||
api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"),
|
||||
)
|
||||
|
||||
|
||||
def _optional_environment_value(name: str) -> str | None:
|
||||
value = os.environ.get(name)
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _optional_environment_path(name: str) -> Path | None:
|
||||
value = _optional_environment_value(name)
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def _boolean_environment_value(name: str, *, default: bool) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ConfigurationError(f"{name} must be one of true/false, yes/no, or 1/0")
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
"""Internal data structures persisted by the uvm CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any
|
||||
|
||||
from .errors import StateError
|
||||
|
||||
|
||||
STATE_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VmSpec:
|
||||
"""Validated resource request supplied to the create command."""
|
||||
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: IPv4Address | None = None
|
||||
username: str = "root"
|
||||
password: str = field(default="root", repr=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VmRecord:
|
||||
"""Persistent record for one local Firecracker VM."""
|
||||
|
||||
id: str
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: str
|
||||
gateway: str
|
||||
tap: str
|
||||
mac: str
|
||||
socket: str
|
||||
config: str
|
||||
log: str
|
||||
disk: str = ""
|
||||
username: str = "root"
|
||||
password: str | None = field(default=None, repr=False)
|
||||
status: str = "starting"
|
||||
pid: int | None = None
|
||||
process_start_time: str | None = None
|
||||
created_at: int = field(default_factory=lambda: int(time.time()))
|
||||
updated_at: int = field(default_factory=lambda: int(time.time()))
|
||||
last_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"cpu": self.cpu,
|
||||
"ram_mib": self.ram_mib,
|
||||
"guest_ip": self.guest_ip,
|
||||
"gateway": self.gateway,
|
||||
"tap": self.tap,
|
||||
"mac": self.mac,
|
||||
"socket": self.socket,
|
||||
"config": self.config,
|
||||
"log": self.log,
|
||||
"disk": self.disk,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
if self.pid is not None:
|
||||
data["pid"] = self.pid
|
||||
if self.process_start_time is not None:
|
||||
data["process_start_time"] = self.process_start_time
|
||||
if self.last_error is not None:
|
||||
data["last_error"] = self.last_error
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> "VmRecord":
|
||||
if not isinstance(value, dict):
|
||||
raise StateError("VM record is not an object")
|
||||
|
||||
required = (
|
||||
"id",
|
||||
"cpu",
|
||||
"ram_mib",
|
||||
"guest_ip",
|
||||
"gateway",
|
||||
"tap",
|
||||
"mac",
|
||||
"socket",
|
||||
"config",
|
||||
"log",
|
||||
)
|
||||
missing = [name for name in required if name not in value]
|
||||
if missing:
|
||||
raise StateError(f"VM record is missing fields: {', '.join(missing)}")
|
||||
|
||||
try:
|
||||
pid_value = value.get("pid")
|
||||
return cls(
|
||||
id=str(value["id"]),
|
||||
cpu=float(value["cpu"]),
|
||||
ram_mib=int(value["ram_mib"]),
|
||||
guest_ip=str(value["guest_ip"]),
|
||||
gateway=str(value["gateway"]),
|
||||
tap=str(value["tap"]),
|
||||
mac=str(value["mac"]),
|
||||
socket=str(value["socket"]),
|
||||
config=str(value["config"]),
|
||||
log=str(value["log"]),
|
||||
disk=str(value.get("disk", "")),
|
||||
username=str(value.get("username", "root")),
|
||||
password=(
|
||||
str(value["password"])
|
||||
if value.get("password") is not None
|
||||
else None
|
||||
),
|
||||
status=str(value.get("status", "unknown")),
|
||||
pid=int(pid_value) if pid_value is not None else None,
|
||||
process_start_time=(
|
||||
str(value["process_start_time"])
|
||||
if value.get("process_start_time") is not None
|
||||
else None
|
||||
),
|
||||
created_at=int(value.get("created_at", int(time.time()))),
|
||||
updated_at=int(value.get("updated_at", value.get("created_at", int(time.time())))),
|
||||
last_error=(
|
||||
str(value["last_error"])
|
||||
if value.get("last_error") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError(f"invalid VM record: {error}") from error
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class State:
|
||||
"""Versioned JSON state document stored under the configured base path."""
|
||||
|
||||
vms: dict[str, VmRecord] = field(default_factory=dict)
|
||||
next_mac_index: int = 1
|
||||
schema_version: int = STATE_SCHEMA_VERSION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"next_mac_index": self.next_mac_index,
|
||||
"vms": {vm_id: vm.to_dict() for vm_id, vm in self.vms.items()},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> "State":
|
||||
if not isinstance(value, dict):
|
||||
raise StateError("state file is not an object")
|
||||
|
||||
try:
|
||||
schema_version = int(value.get("schema_version", 0))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError("state file has an invalid schema_version") from error
|
||||
if schema_version > STATE_SCHEMA_VERSION:
|
||||
raise StateError(
|
||||
f"state schema version {schema_version} is newer than this uvm version"
|
||||
)
|
||||
|
||||
raw_vms = value.get("vms", {})
|
||||
if not isinstance(raw_vms, dict):
|
||||
raise StateError("state file has an invalid vms collection")
|
||||
|
||||
vms = {str(vm_id): VmRecord.from_dict(vm) for vm_id, vm in raw_vms.items()}
|
||||
next_mac_index = value.get("next_mac_index")
|
||||
if next_mac_index is None:
|
||||
next_mac_index = _next_mac_index(vms.values())
|
||||
|
||||
try:
|
||||
next_mac_index = max(1, int(next_mac_index))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError("state file has an invalid next_mac_index") from error
|
||||
|
||||
return cls(
|
||||
schema_version=STATE_SCHEMA_VERSION,
|
||||
vms=vms,
|
||||
next_mac_index=next_mac_index,
|
||||
)
|
||||
|
||||
|
||||
def new_vm_id() -> str:
|
||||
"""Generate an opaque local ID that does not depend on PID or timestamp reuse."""
|
||||
|
||||
return f"vm-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _next_mac_index(vms: object) -> int:
|
||||
maximum = 0
|
||||
for vm in vms:
|
||||
if not isinstance(vm, VmRecord):
|
||||
continue
|
||||
try:
|
||||
parts = vm.mac.split(":")
|
||||
if parts[:3] != ["02", "fc", "00"] or len(parts) != 6:
|
||||
continue
|
||||
maximum = max(maximum, int("".join(parts[3:]), 16))
|
||||
except ValueError:
|
||||
continue
|
||||
return maximum + 1
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Application-specific failures with predictable command-line handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class UvmError(Exception):
|
||||
"""An expected error that should be rendered without a traceback."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class ConfigurationError(UvmError):
|
||||
"""Raised when local uvm configuration is invalid."""
|
||||
|
||||
|
||||
class ValidationError(UvmError):
|
||||
"""Raised when a command argument is invalid."""
|
||||
|
||||
|
||||
class StateError(UvmError):
|
||||
"""Raised when persisted VM state cannot be safely used."""
|
||||
|
||||
|
||||
class CommandError(UvmError):
|
||||
"""Raised when a required host command cannot be executed successfully."""
|
||||
|
||||
|
||||
class FirecrackerError(UvmError):
|
||||
"""Raised when Firecracker cannot be started or configured."""
|
||||
|
||||
|
||||
class TapCreationError(UvmError):
|
||||
"""Raised when TAP setup fails after creating a host interface."""
|
||||
|
||||
def __init__(self, message: str, *, tap_created: bool) -> None:
|
||||
super().__init__(message)
|
||||
self.tap_created = tap_created
|
||||
@@ -0,0 +1 @@
|
||||
"""Internal Firecracker configuration, API, and process adapters."""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Typed Firecracker HTTP requests over a per-VM Unix-domain socket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..errors import FirecrackerError
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path: Path, timeout: float) -> None:
|
||||
super().__init__("localhost", timeout=timeout)
|
||||
self._socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.sock.connect(str(self._socket_path))
|
||||
|
||||
|
||||
class FirecrackerClient:
|
||||
"""Keep Firecracker endpoint details out of lifecycle orchestration."""
|
||||
|
||||
def __init__(self, socket_path: Path, timeout_s: float) -> None:
|
||||
self._socket_path = socket_path
|
||||
self._timeout_s = timeout_s
|
||||
|
||||
def configure_and_start(self, config: Mapping[str, Any]) -> None:
|
||||
machine = config["machine-config"]
|
||||
boot = config["boot-source"]
|
||||
drives = config["drives"]
|
||||
interfaces = config["network-interfaces"]
|
||||
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
|
||||
raise FirecrackerError("invalid Firecracker configuration")
|
||||
if not isinstance(drives, list) or not drives:
|
||||
raise FirecrackerError("Firecracker configuration has no root drive")
|
||||
if not isinstance(interfaces, list) or not interfaces:
|
||||
raise FirecrackerError("Firecracker configuration has no network interface")
|
||||
|
||||
self._request(
|
||||
"PUT",
|
||||
"/machine-config",
|
||||
{
|
||||
"vcpu_count": machine["vcpu_count"],
|
||||
"mem_size_mib": machine["mem_size_mib"],
|
||||
"smt": False,
|
||||
},
|
||||
)
|
||||
self._request("PUT", "/boot-source", boot)
|
||||
self._request("PUT", "/drives/rootfs", drives[0])
|
||||
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
|
||||
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
|
||||
|
||||
def _request(self, method: str, path: str, body: object) -> None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
connection = _UnixHTTPConnection(self._socket_path, self._timeout_s)
|
||||
try:
|
||||
connection.request(
|
||||
method,
|
||||
path,
|
||||
payload,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response_body = response.read().decode("utf-8", errors="replace")
|
||||
except (OSError, http.client.HTTPException) as error:
|
||||
raise FirecrackerError(f"Firecracker API {method} {path}: {error}") from error
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
if response.status >= 300:
|
||||
raise FirecrackerError(
|
||||
f"Firecracker API {method} {path}: {response.status} {response_body}"
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Build and persist Firecracker configuration for one VM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import Settings
|
||||
from ..domain import VmRecord
|
||||
|
||||
|
||||
def vcpu_count(cpu: float) -> int:
|
||||
"""Firecracker accepts whole vCPUs; fractional capacity rounds up for now."""
|
||||
|
||||
return max(1, math.ceil(cpu))
|
||||
|
||||
|
||||
def boot_ip_argument(
|
||||
guest_ip: IPv4Address,
|
||||
gateway: IPv4Address,
|
||||
network: IPv4Network,
|
||||
) -> str:
|
||||
return f"ip={guest_ip}::{gateway}:{network.netmask}::eth0:off"
|
||||
|
||||
|
||||
def build_config(
|
||||
settings: Settings,
|
||||
vm: VmRecord,
|
||||
kernel: Path,
|
||||
rootfs: Path,
|
||||
) -> dict[str, Any]:
|
||||
boot_args = " ".join(
|
||||
(
|
||||
"console=ttyS0",
|
||||
"reboot=k",
|
||||
"panic=1",
|
||||
"pci=off",
|
||||
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"boot-source": {
|
||||
"kernel_image_path": str(kernel),
|
||||
"boot_args": boot_args,
|
||||
},
|
||||
"drives": [
|
||||
{
|
||||
"drive_id": "rootfs",
|
||||
"path_on_host": str(rootfs),
|
||||
"is_root_device": True,
|
||||
"is_read_only": False,
|
||||
}
|
||||
],
|
||||
"machine-config": {
|
||||
"vcpu_count": vcpu_count(vm.cpu),
|
||||
"mem_size_mib": vm.ram_mib,
|
||||
},
|
||||
"network-interfaces": [
|
||||
{
|
||||
"iface_id": "eth0",
|
||||
"guest_mac": vm.mac,
|
||||
"host_dev_name": vm.tap,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_config(path: Path, config: Mapping[str, Any]) -> None:
|
||||
"""Atomically publish a rendered Firecracker configuration file."""
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file:
|
||||
json.dump(config, temporary_file, indent=2)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_name, path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Firecracker process lifecycle and PID identity handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from ..config import Settings
|
||||
from ..domain import VmRecord
|
||||
from ..errors import FirecrackerError
|
||||
|
||||
|
||||
class _PopenLike(Protocol):
|
||||
pid: int
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessInfo:
|
||||
pid: int
|
||||
start_time: str
|
||||
|
||||
|
||||
class FirecrackerProcessManager:
|
||||
"""Start, observe, and terminate detached Firecracker processes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
popen: Callable[..., _PopenLike] = subprocess.Popen,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._popen = popen
|
||||
self._sleep = sleep
|
||||
self._monotonic = monotonic
|
||||
|
||||
def start(self, vm: VmRecord) -> ProcessInfo:
|
||||
socket_path = Path(vm.socket)
|
||||
if socket_path.exists():
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except OSError as error:
|
||||
raise FirecrackerError(
|
||||
f"could not remove stale Firecracker socket {socket_path}: {error}"
|
||||
) from error
|
||||
|
||||
log_path = Path(vm.log)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
process: _PopenLike | None = None
|
||||
try:
|
||||
with log_path.open("ab", buffering=0) as log_file:
|
||||
process = self._popen(
|
||||
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
self._wait_for_socket(process, socket_path, log_path)
|
||||
start_time = self.process_start_time(process.pid)
|
||||
if start_time is None:
|
||||
raise FirecrackerError(
|
||||
f"could not establish a safe process identity for Firecracker process {process.pid}"
|
||||
)
|
||||
return ProcessInfo(pid=process.pid, start_time=start_time)
|
||||
except OSError as error:
|
||||
if process is not None:
|
||||
self._cleanup_started_process(process, socket_path)
|
||||
raise FirecrackerError(f"could not start Firecracker: {error}") from error
|
||||
except BaseException:
|
||||
if process is not None:
|
||||
self._cleanup_started_process(process, socket_path)
|
||||
raise
|
||||
|
||||
def is_alive(self, vm: VmRecord) -> bool:
|
||||
if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(vm.pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
return self.process_start_time(vm.pid) == vm.process_start_time
|
||||
|
||||
def terminate(self, vm: VmRecord) -> None:
|
||||
if vm.pid is None:
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
if vm.pid <= 0:
|
||||
raise FirecrackerError(f"invalid persisted Firecracker PID: {vm.pid}")
|
||||
if vm.process_start_time is None:
|
||||
if not self._pid_exists(vm.pid):
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
raise FirecrackerError(
|
||||
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
|
||||
)
|
||||
if not self.is_alive(vm):
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(vm.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
except OSError as error:
|
||||
raise FirecrackerError(f"could not stop Firecracker process {vm.pid}: {error}") from error
|
||||
|
||||
deadline = self._monotonic() + self._settings.terminate_timeout_s
|
||||
while self.is_alive(vm) and self._monotonic() < deadline:
|
||||
self._sleep(0.05)
|
||||
if self.is_alive(vm):
|
||||
self._terminate_process_id(vm.pid, force=True)
|
||||
self._remove_socket(Path(vm.socket))
|
||||
|
||||
def process_start_time(self, pid: int) -> str | None:
|
||||
"""Read Linux proc start time so a reused PID is not mistaken for a VM."""
|
||||
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
closing_parenthesis = stat.rfind(")")
|
||||
if closing_parenthesis < 0:
|
||||
return None
|
||||
fields = stat[closing_parenthesis + 2 :].split()
|
||||
try:
|
||||
return fields[19]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def _wait_for_socket(
|
||||
self,
|
||||
process: _PopenLike,
|
||||
socket_path: Path,
|
||||
log_path: Path,
|
||||
) -> None:
|
||||
deadline = self._monotonic() + self._settings.api_socket_timeout_s
|
||||
while self._monotonic() < deadline:
|
||||
if socket_path.exists():
|
||||
return
|
||||
if process.poll() is not None:
|
||||
raise FirecrackerError(f"Firecracker exited early; see {log_path}")
|
||||
self._sleep(0.05)
|
||||
raise FirecrackerError("timed out waiting for Firecracker API socket")
|
||||
|
||||
@staticmethod
|
||||
def _remove_socket(socket_path: Path) -> None:
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
# A stale socket is cleaned on the next launch; never hide a successful stop.
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _pid_exists(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
def _cleanup_started_process(self, process: _PopenLike, socket_path: Path) -> None:
|
||||
try:
|
||||
self._terminate_process_id(process.pid)
|
||||
except FirecrackerError:
|
||||
pass
|
||||
deadline = self._monotonic() + self._settings.terminate_timeout_s
|
||||
while process.poll() is None and self._monotonic() < deadline:
|
||||
self._sleep(0.05)
|
||||
if process.poll() is None:
|
||||
try:
|
||||
self._terminate_process_id(process.pid, force=True)
|
||||
except FirecrackerError:
|
||||
pass
|
||||
self._remove_socket(socket_path)
|
||||
|
||||
@staticmethod
|
||||
def _terminate_process_id(pid: int, *, force: bool = False) -> None:
|
||||
if pid <= 0:
|
||||
raise FirecrackerError(f"invalid Firecracker PID: {pid}")
|
||||
signal_to_send = signal.SIGKILL if force else signal.SIGTERM
|
||||
try:
|
||||
os.kill(pid, signal_to_send)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError as error:
|
||||
action = "force-stop" if force else "stop"
|
||||
raise FirecrackerError(f"could not {action} Firecracker process {pid}: {error}") from error
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
"""Guest asset lookup and per-VM writable root filesystem handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY = (
|
||||
"ssh-rsa "
|
||||
"AAAAB3NzaC1yc2EAAAADAQABAAABAQCirWKrc1zDyvZufHGinIRNoeIot+C3idANxtqZyDHL9mYm"
|
||||
"NeQzx9CjbjMgSDJ3xhIPP9mu3MP4Py/u3X5Wey98zN3EPboKdGRf6T2fFviK4i0q85LueDtsK0"
|
||||
"IoWR459w87tC9NMwPb27C8jPqFod6nWfccdhEdM+veKkFh4Dk5TrPfYHDayXDPFEdz7jl0GedEH"
|
||||
"fP9w11LPfa66D7731CdD3tMHAWLYxYmeXo58RXUaP6AgUK8uF/hL+E21q+wgTNPOQuRn2ekOjdu"
|
||||
"J34oHkJ2i48tLqKdGKU6RwfFc3rW3TkeYTUqi3UY9EnwNWFJicez+nZ5bhr5KvRsfSZ2QvBj"
|
||||
)
|
||||
_MODE_PATTERN = re.compile(r"Mode:\s+0*([0-7]+)")
|
||||
_OWNER_PATTERN = re.compile(r"User:\s+(\d+)\s+Group:\s+(\d+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuestAssets:
|
||||
kernel: Path
|
||||
rootfs: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GuestFile:
|
||||
path: Path
|
||||
mode: int
|
||||
uid: int
|
||||
gid: int
|
||||
|
||||
|
||||
class ImageStore:
|
||||
"""Treat downloaded guest assets as templates, never as writable VM disks."""
|
||||
|
||||
def __init__(self, settings: Settings, runner: CommandRunner | None = None) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner or CommandRunner()
|
||||
|
||||
def installed_assets(self) -> GuestAssets:
|
||||
kernel = self._settings.kernel_image
|
||||
rootfs = self._settings.rootfs_image
|
||||
if not kernel.exists() or not rootfs.exists():
|
||||
raise UvmError("guest assets missing. Run: sudo uvm install")
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
kernel_checksum = self._settings.kernel_sha256
|
||||
rootfs_checksum = self._settings.rootfs_sha256
|
||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||
kernel_checksum = kernel_checksum or manifest.checksums.get("kernel")
|
||||
rootfs_checksum = rootfs_checksum or manifest.checksums.get("rootfs")
|
||||
verify_file(
|
||||
kernel,
|
||||
kernel_checksum,
|
||||
"guest kernel",
|
||||
"UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
verify_file(
|
||||
rootfs,
|
||||
rootfs_checksum,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
||||
|
||||
def create_vm_disk(self, source: Path, destination: Path) -> Path:
|
||||
"""Copy the template before Firecracker opens it read-write for a VM."""
|
||||
|
||||
if destination.exists():
|
||||
raise UvmError(f"VM disk already exists: {destination}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
destination.chmod(0o600)
|
||||
except OSError as error:
|
||||
try:
|
||||
destination.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
||||
return destination
|
||||
|
||||
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
|
||||
"""Set an existing guest account password in an offline ext4 disk."""
|
||||
|
||||
password_hash = self._password_hash(password)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
|
||||
temporary = Path(temporary_name)
|
||||
passwd = self._read_guest_file(disk, "/etc/passwd", temporary / "passwd")
|
||||
shadow = self._read_guest_file(disk, "/etc/shadow", temporary / "shadow")
|
||||
sshd_config = self._read_guest_file(
|
||||
disk,
|
||||
"/etc/ssh/sshd_config",
|
||||
temporary / "sshd_config",
|
||||
)
|
||||
authorized_keys = self._read_guest_file(
|
||||
disk,
|
||||
"/root/.ssh/authorized_keys",
|
||||
temporary / "authorized_keys",
|
||||
required=False,
|
||||
)
|
||||
assert passwd is not None
|
||||
assert shadow is not None
|
||||
assert sshd_config is not None
|
||||
|
||||
account_names = {
|
||||
line.split(":", 1)[0]
|
||||
for line in passwd.path.read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line
|
||||
}
|
||||
if username not in account_names:
|
||||
raise UvmError(f"guest user does not exist in rootfs: {username}")
|
||||
|
||||
shadow.path.write_text(
|
||||
_set_shadow_password(
|
||||
shadow.path.read_text(encoding="utf-8"),
|
||||
username,
|
||||
password_hash,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
shadow.path.chmod(shadow.mode)
|
||||
|
||||
sshd_config.path.write_text(
|
||||
_enable_ssh_password_authentication(
|
||||
sshd_config.path.read_text(encoding="utf-8"),
|
||||
username,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sshd_config.path.chmod(sshd_config.mode)
|
||||
|
||||
self._write_guest_file(disk, "/etc/shadow", shadow)
|
||||
self._write_guest_file(disk, "/etc/ssh/sshd_config", sshd_config)
|
||||
if authorized_keys is not None:
|
||||
self._remove_firecracker_demo_key(disk, authorized_keys)
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise UvmError(f"could not provision guest credentials: {error}") from error
|
||||
|
||||
def _password_hash(self, password: str) -> str:
|
||||
result = self._runner.run(
|
||||
("openssl", "passwd", "-6", "-stdin"),
|
||||
capture=True,
|
||||
input_text=f"{password}\n",
|
||||
sensitive=True,
|
||||
)
|
||||
password_hash = result.stdout.strip()
|
||||
if not password_hash.startswith("$6$") or any(
|
||||
character in password_hash for character in ("\n", "\r", ":")
|
||||
):
|
||||
raise UvmError("openssl returned an invalid guest password hash")
|
||||
return password_hash
|
||||
|
||||
def _read_guest_file(
|
||||
self,
|
||||
disk: Path,
|
||||
guest_path: str,
|
||||
destination: Path,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> _GuestFile | None:
|
||||
stat_result = self._runner.run(
|
||||
("debugfs", "-R", f"stat {guest_path}", disk),
|
||||
check=False,
|
||||
capture=True,
|
||||
)
|
||||
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
||||
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
||||
if mode_match is None or owner_match is None:
|
||||
if required:
|
||||
raise UvmError(f"guest rootfs is missing required file: {guest_path}")
|
||||
return None
|
||||
|
||||
self._runner.run(
|
||||
("debugfs", "-R", f"dump {guest_path} {destination}", disk),
|
||||
capture=True,
|
||||
)
|
||||
if not destination.is_file():
|
||||
raise UvmError(f"could not read guest rootfs file: {guest_path}")
|
||||
return _GuestFile(
|
||||
path=destination,
|
||||
mode=int(mode_match.group(1), 8),
|
||||
uid=int(owner_match.group(1)),
|
||||
gid=int(owner_match.group(2)),
|
||||
)
|
||||
|
||||
def _write_guest_file(self, disk: Path, guest_path: str, source: _GuestFile) -> None:
|
||||
self._runner.run(("debugfs", "-w", "-R", f"rm {guest_path}", disk), capture=True)
|
||||
self._runner.run(
|
||||
("debugfs", "-w", "-R", f"write {source.path} {guest_path}", disk),
|
||||
capture=True,
|
||||
)
|
||||
for field, value in (
|
||||
("mode", f"0{0o100000 | source.mode:o}"),
|
||||
("uid", str(source.uid)),
|
||||
("gid", str(source.gid)),
|
||||
):
|
||||
self._runner.run(
|
||||
(
|
||||
"debugfs",
|
||||
"-w",
|
||||
"-R",
|
||||
f"set_inode_field {guest_path} {field} {value}",
|
||||
disk,
|
||||
),
|
||||
capture=True,
|
||||
)
|
||||
|
||||
verification = source.path.with_name(f"{source.path.name}.verify")
|
||||
written = self._read_guest_file(disk, guest_path, verification)
|
||||
assert written is not None
|
||||
if (
|
||||
verification.read_bytes() != source.path.read_bytes()
|
||||
or written.mode != source.mode
|
||||
or written.uid != source.uid
|
||||
or written.gid != source.gid
|
||||
):
|
||||
raise UvmError(f"could not verify updated guest rootfs file: {guest_path}")
|
||||
|
||||
def _remove_firecracker_demo_key(self, disk: Path, authorized_keys: _GuestFile) -> None:
|
||||
contents = authorized_keys.path.read_text(encoding="utf-8")
|
||||
updated = _without_firecracker_demo_key(contents)
|
||||
if updated == contents:
|
||||
return
|
||||
if updated is not None:
|
||||
authorized_keys.path.write_text(updated, encoding="utf-8")
|
||||
authorized_keys.path.chmod(authorized_keys.mode)
|
||||
self._write_guest_file(
|
||||
disk,
|
||||
"/root/.ssh/authorized_keys",
|
||||
authorized_keys,
|
||||
)
|
||||
return
|
||||
|
||||
self._runner.run(
|
||||
("debugfs", "-w", "-R", "rm /root/.ssh/authorized_keys", disk),
|
||||
capture=True,
|
||||
)
|
||||
if self._read_guest_file(
|
||||
disk,
|
||||
"/root/.ssh/authorized_keys",
|
||||
authorized_keys.path.with_name("authorized_keys.verify"),
|
||||
required=False,
|
||||
) is not None:
|
||||
raise UvmError("could not remove the insecure Firecracker demo SSH key")
|
||||
|
||||
|
||||
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
|
||||
lines = contents.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
fields = line.split(":")
|
||||
if fields[0] == username and len(fields) >= 2:
|
||||
fields[1] = password_hash
|
||||
lines[index] = ":".join(fields)
|
||||
return "\n".join(lines) + "\n"
|
||||
raise UvmError(f"guest rootfs has no shadow entry for user: {username}")
|
||||
|
||||
|
||||
def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
||||
directives = ["PasswordAuthentication yes"]
|
||||
if username == "root":
|
||||
directives.append("PermitRootLogin yes")
|
||||
return "# Managed by uvm\n" + "\n".join(directives) + "\n" + contents
|
||||
|
||||
|
||||
def _without_firecracker_demo_key(contents: str) -> str | None:
|
||||
lines = contents.splitlines()
|
||||
retained = [
|
||||
line
|
||||
for line in lines
|
||||
if " ".join(line.split()[:2]) != _FIRECRACKER_DEMO_PUBLIC_KEY
|
||||
]
|
||||
if len(retained) == len(lines):
|
||||
return contents
|
||||
return "\n".join(retained) + "\n" if retained else None
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
"""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
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Checksum policy for downloaded executable and guest-image artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from .errors import UvmError
|
||||
|
||||
|
||||
_SHA256_PATTERN = re.compile(r"[0-9a-fA-F]{64}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegrityManifest:
|
||||
checksums: dict[str, str]
|
||||
verified: bool
|
||||
|
||||
|
||||
def normalize_sha256(value: str, environment_name: str) -> str:
|
||||
digest = value.strip().lower()
|
||||
if not _SHA256_PATTERN.fullmatch(digest):
|
||||
raise UvmError(f"{environment_name} must contain a 64-character SHA-256 digest")
|
||||
return digest
|
||||
|
||||
|
||||
def require_checksum(
|
||||
expected: str | None,
|
||||
label: str,
|
||||
environment_name: str,
|
||||
*,
|
||||
allow_unverified: bool,
|
||||
) -> str | None:
|
||||
if expected is not None:
|
||||
return normalize_sha256(expected, environment_name)
|
||||
if allow_unverified:
|
||||
return None
|
||||
raise UvmError(
|
||||
f"{label} checksum is required. Set {environment_name} to a trusted SHA-256 "
|
||||
"or explicitly set UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 for local development."
|
||||
)
|
||||
|
||||
|
||||
def verify_file(
|
||||
path: Path,
|
||||
expected: str | None,
|
||||
label: str,
|
||||
environment_name: str,
|
||||
*,
|
||||
allow_unverified: bool,
|
||||
) -> None:
|
||||
expected_digest = require_checksum(
|
||||
expected,
|
||||
label,
|
||||
environment_name,
|
||||
allow_unverified=allow_unverified,
|
||||
)
|
||||
if expected_digest is None:
|
||||
return
|
||||
|
||||
actual_digest = sha256_file(path, label)
|
||||
if not hmac.compare_digest(actual_digest, expected_digest):
|
||||
raise UvmError(
|
||||
f"SHA-256 mismatch for {label}: expected {expected_digest}, got {actual_digest}"
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path, label: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as artifact:
|
||||
for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError as error:
|
||||
raise UvmError(f"could not checksum {label} at {path}: {error}") from error
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> IntegrityManifest:
|
||||
if not path.exists():
|
||||
return IntegrityManifest(checksums={}, verified=False)
|
||||
try:
|
||||
with path.open(encoding="utf-8") as manifest_file:
|
||||
raw = json.load(manifest_file)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise UvmError(f"cannot read integrity manifest {path}: {error}") from error
|
||||
if not isinstance(raw, dict):
|
||||
raise UvmError(f"integrity manifest {path} is not an object")
|
||||
|
||||
if "checksums" in raw:
|
||||
raw_checksums = raw["checksums"]
|
||||
verified = raw.get("verified")
|
||||
if not isinstance(raw_checksums, dict) or not isinstance(verified, bool):
|
||||
raise UvmError(f"integrity manifest {path} has an invalid format")
|
||||
else:
|
||||
# Flat manifests were written by earlier uvm versions without provenance.
|
||||
raw_checksums = raw
|
||||
verified = False
|
||||
|
||||
manifest: dict[str, str] = {}
|
||||
for key, value in raw_checksums.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
raise UvmError(f"integrity manifest {path} contains an invalid entry")
|
||||
manifest[key] = normalize_sha256(value, f"integrity manifest entry {key}")
|
||||
return IntegrityManifest(checksums=manifest, verified=verified)
|
||||
|
||||
|
||||
def write_manifest(path: Path, values: Mapping[str, str], *, verified: bool) -> None:
|
||||
normalized = {
|
||||
key: normalize_sha256(value, f"integrity manifest entry {key}")
|
||||
for key, value in values.items()
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file:
|
||||
json.dump(
|
||||
{"checksums": normalized, "verified": verified},
|
||||
temporary_file,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_name, path)
|
||||
os.chmod(path, 0o644)
|
||||
except OSError as error:
|
||||
raise UvmError(f"cannot write integrity manifest {path}: {error}") from error
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Ordered VM lifecycle operations and compensation for partial failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import VmRecord, VmSpec, new_vm_id
|
||||
from .errors import TapCreationError, UvmError
|
||||
from .firecracker.api import FirecrackerClient
|
||||
from .firecracker.config import build_config, write_config
|
||||
from .firecracker.process import FirecrackerProcessManager
|
||||
from .images import ImageStore
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
from .system import check_kvm, ensure_data_directories, require_root
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ListedVm:
|
||||
vm: VmRecord
|
||||
observed_status: str
|
||||
|
||||
|
||||
class LifecycleService:
|
||||
"""The only module allowed to coordinate multiple VM host resources."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
state_store: StateStore,
|
||||
images: ImageStore,
|
||||
network: NetworkManager,
|
||||
process: FirecrackerProcessManager,
|
||||
*,
|
||||
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._state_store = state_store
|
||||
self._images = images
|
||||
self._network = network
|
||||
self._process = process
|
||||
self._client_factory = client_factory
|
||||
self._clock = clock
|
||||
|
||||
def create(self, spec: VmSpec) -> VmRecord:
|
||||
"""Reserve identity first, then create all VM resources in dependency order."""
|
||||
|
||||
require_root()
|
||||
ensure_data_directories(self._settings)
|
||||
check_kvm()
|
||||
if not self._settings.firecracker_binary.exists():
|
||||
raise UvmError("Firecracker is not installed. Run: sudo uvm install")
|
||||
with self._state_store.operation_lock():
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
firecracker_checksum = self._settings.firecracker_binary_sha256
|
||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||
firecracker_checksum = firecracker_checksum or manifest.checksums.get("firecracker")
|
||||
verify_file(
|
||||
self._settings.firecracker_binary,
|
||||
firecracker_checksum,
|
||||
"Firecracker binary",
|
||||
"UVM_FIRECRACKER_BINARY_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
return self._create_locked(spec)
|
||||
|
||||
def _create_locked(self, spec: VmSpec) -> VmRecord:
|
||||
assets = self._images.installed_assets()
|
||||
vm = self._reserve_vm(spec)
|
||||
|
||||
tap_created = False
|
||||
try:
|
||||
runtime_dir = self._settings.vm_dir(vm.id)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=False)
|
||||
runtime_dir.chmod(0o700)
|
||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
|
||||
self._images.provision_credentials(disk, vm.username, vm.password)
|
||||
self._network.ensure_bridge()
|
||||
try:
|
||||
self._network.create_tap(vm.tap)
|
||||
except TapCreationError as error:
|
||||
tap_created = error.tap_created
|
||||
raise
|
||||
else:
|
||||
tap_created = True
|
||||
|
||||
config = build_config(self._settings, vm, assets.kernel, disk)
|
||||
write_config(Path(vm.config), config)
|
||||
|
||||
process_info = self._process.start(vm)
|
||||
vm.pid = process_info.pid
|
||||
vm.process_start_time = process_info.start_time
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
|
||||
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
|
||||
client.configure_and_start(config)
|
||||
except BaseException as error:
|
||||
cleanup_errors = self._rollback_create(vm, tap_created)
|
||||
message = f"failed to create {vm.id}: {error}"
|
||||
if cleanup_errors:
|
||||
message = f"{message}; cleanup failed: {'; '.join(cleanup_errors)}"
|
||||
vm.status = "failed"
|
||||
vm.last_error = message
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
else:
|
||||
self._remove_vm_if_present(vm.id)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(message) from error
|
||||
|
||||
vm.status = "running"
|
||||
vm.last_error = None
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
return vm
|
||||
|
||||
def list_vms(self) -> list[ListedVm]:
|
||||
state = self._state_store.load()
|
||||
listed: list[ListedVm] = []
|
||||
for vm in state.vms.values():
|
||||
observed_status = vm.status
|
||||
if vm.status in {"starting", "running", "stopping", "terminating"}:
|
||||
if not self._process.is_alive(vm):
|
||||
observed_status = "dead"
|
||||
listed.append(ListedVm(vm=vm, observed_status=observed_status))
|
||||
return listed
|
||||
|
||||
def find_for_ssh(self, identifier: str) -> VmRecord:
|
||||
return self._state_store.find_by_id_or_ip(identifier)
|
||||
|
||||
def stop(self, vm_id: str) -> VmRecord:
|
||||
"""Stop one VM while retaining its private writable disk for future use."""
|
||||
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._stop_locked(vm_id)
|
||||
|
||||
def _stop_locked(self, vm_id: str) -> VmRecord:
|
||||
vm = self._begin_transition(vm_id, "stopping")
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
self._network.delete_tap(vm.tap)
|
||||
except BaseException as error:
|
||||
vm.status = "failed"
|
||||
vm.last_error = f"failed to stop VM: {error}"
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
vm.status = "stopped"
|
||||
vm.pid = None
|
||||
vm.process_start_time = None
|
||||
vm.last_error = None
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
return vm
|
||||
|
||||
def destroy(self, vm_id: str) -> VmRecord:
|
||||
"""Stop a VM, remove its private resources, then release its allocation."""
|
||||
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._destroy_locked(vm_id)
|
||||
|
||||
def _destroy_locked(self, vm_id: str) -> VmRecord:
|
||||
vm = self._begin_transition(vm_id, "terminating")
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
self._network.delete_tap(vm.tap)
|
||||
try:
|
||||
shutil.rmtree(self._settings.vm_dir(vm.id))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except BaseException as error:
|
||||
vm.status = "failed"
|
||||
vm.last_error = f"failed to destroy VM: {error}"
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
self._remove_vm_if_present(vm.id)
|
||||
return vm
|
||||
|
||||
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
|
||||
with self._state_store.transaction() as state:
|
||||
vm_id = new_vm_id()
|
||||
used_taps = {existing_vm.tap for existing_vm in state.vms.values()}
|
||||
while vm_id in state.vms or self._network.tap_name(vm_id) in used_taps:
|
||||
vm_id = new_vm_id()
|
||||
guest_ip = self._network.allocate_ip(state.vms.values(), spec.guest_ip)
|
||||
mac = self._network.mac_for(state.next_mac_index)
|
||||
state.next_mac_index += 1
|
||||
now = self._now()
|
||||
runtime_dir = self._settings.vm_dir(vm_id)
|
||||
vm = VmRecord(
|
||||
id=vm_id,
|
||||
cpu=spec.cpu,
|
||||
ram_mib=spec.ram_mib,
|
||||
guest_ip=str(guest_ip),
|
||||
gateway=str(self._settings.gateway),
|
||||
tap=self._network.tap_name(vm_id),
|
||||
mac=mac,
|
||||
socket=str(runtime_dir / "firecracker.sock"),
|
||||
config=str(runtime_dir / "config.json"),
|
||||
log=str(runtime_dir / "firecracker.log"),
|
||||
disk=str(runtime_dir / "rootfs.ext4"),
|
||||
username=spec.username,
|
||||
password=spec.password,
|
||||
status="starting",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
state.vms[vm.id] = vm
|
||||
return vm
|
||||
|
||||
def _begin_transition(self, vm_id: str, target_status: str) -> VmRecord:
|
||||
with self._state_store.transaction() as state:
|
||||
try:
|
||||
vm = state.vms[vm_id]
|
||||
except KeyError as error:
|
||||
raise UvmError(f"VM not found: {vm_id}") from error
|
||||
if vm.status in {"stopping", "terminating"}:
|
||||
raise UvmError(f"VM operation is already in progress: {vm_id}")
|
||||
vm.status = target_status
|
||||
vm.updated_at = self._now()
|
||||
state.vms[vm_id] = vm
|
||||
return vm
|
||||
|
||||
def _replace_vm(self, vm: VmRecord) -> None:
|
||||
with self._state_store.transaction() as state:
|
||||
if vm.id not in state.vms:
|
||||
raise UvmError(f"VM not found: {vm.id}")
|
||||
state.vms[vm.id] = vm
|
||||
|
||||
def _replace_vm_if_present(self, vm: VmRecord) -> None:
|
||||
with self._state_store.transaction() as state:
|
||||
if vm.id in state.vms:
|
||||
state.vms[vm.id] = vm
|
||||
|
||||
def _remove_vm_if_present(self, vm_id: str) -> None:
|
||||
with self._state_store.transaction() as state:
|
||||
state.vms.pop(vm_id, None)
|
||||
|
||||
def _rollback_create(self, vm: VmRecord, tap_created: bool) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if vm.pid is not None:
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
except Exception as error:
|
||||
errors.append(f"process: {error}")
|
||||
if tap_created:
|
||||
try:
|
||||
self._network.delete_tap(vm.tap)
|
||||
except Exception as error:
|
||||
errors.append(f"network: {error}")
|
||||
try:
|
||||
shutil.rmtree(self._settings.vm_dir(vm.id))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as error:
|
||||
errors.append(f"files: {error}")
|
||||
return errors
|
||||
|
||||
def _now(self) -> int:
|
||||
return int(self._clock())
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"""Host bridge, TAP, NAT, and address allocation for local microVMs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from .config import Settings
|
||||
from .domain import VmRecord
|
||||
from .errors import TapCreationError, UvmError, ValidationError
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
class NetworkManager:
|
||||
"""Manage the shared bridge and one TAP device per persisted VM."""
|
||||
|
||||
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner
|
||||
|
||||
def allocate_ip(
|
||||
self,
|
||||
vms: Iterable[VmRecord],
|
||||
requested: IPv4Address | None,
|
||||
) -> IPv4Address:
|
||||
used = {vm.guest_ip for vm in vms}
|
||||
if requested is not None:
|
||||
if requested not in self._settings.network or requested in {
|
||||
self._settings.network.network_address,
|
||||
self._settings.network.broadcast_address,
|
||||
self._settings.gateway,
|
||||
}:
|
||||
raise ValidationError(
|
||||
"guest IP must be an unused address inside "
|
||||
f"{self._settings.network}, excluding {self._settings.gateway}"
|
||||
)
|
||||
if str(requested) in used:
|
||||
raise ValidationError(f"IP already allocated: {requested}")
|
||||
return requested
|
||||
|
||||
for host in self._settings.network.hosts():
|
||||
if host == self._settings.gateway:
|
||||
continue
|
||||
if str(host) not in used:
|
||||
return host
|
||||
raise UvmError(f"no free IPs in {self._settings.network}")
|
||||
|
||||
@staticmethod
|
||||
def mac_for(index: int) -> str:
|
||||
if index < 1 or index > 0xFFFFFF:
|
||||
raise UvmError("no free locally administered MAC addresses remain")
|
||||
return (
|
||||
f"02:fc:00:{(index >> 16) & 255:02x}:"
|
||||
f"{(index >> 8) & 255:02x}:{index & 255:02x}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def tap_name(vm_id: str) -> str:
|
||||
suffix = re.sub(r"[^a-zA-Z0-9]", "", vm_id)[-11:]
|
||||
if not suffix:
|
||||
raise UvmError("cannot derive a TAP name from an empty VM ID")
|
||||
return f"uvm-{suffix}"[:15]
|
||||
|
||||
def ensure_bridge(self) -> None:
|
||||
bridge = self._settings.bridge
|
||||
existing = self._runner.run(("ip", "link", "show", bridge), check=False, capture=True)
|
||||
if existing.returncode != 0:
|
||||
self._runner.run(("ip", "link", "add", bridge, "type", "bridge"))
|
||||
else:
|
||||
self._ensure_existing_link_is_bridge(bridge)
|
||||
self._runner.run(
|
||||
(
|
||||
"ip",
|
||||
"addr",
|
||||
"replace",
|
||||
f"{self._settings.gateway}/{self._settings.network.prefixlen}",
|
||||
"dev",
|
||||
bridge,
|
||||
)
|
||||
)
|
||||
self._runner.run(("ip", "link", "set", bridge, "up"))
|
||||
self._runner.run(("sysctl", "-w", "net.ipv4.ip_forward=1"))
|
||||
self._ensure_masquerade()
|
||||
|
||||
def _ensure_existing_link_is_bridge(self, bridge: str) -> None:
|
||||
details = self._runner.run(
|
||||
("ip", "-j", "-d", "link", "show", "dev", bridge), capture=True
|
||||
)
|
||||
try:
|
||||
links = json.loads(details.stdout)
|
||||
kind = links[0]["linkinfo"]["info_kind"]
|
||||
except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error:
|
||||
raise UvmError(f"could not determine whether existing interface {bridge} is a bridge") from error
|
||||
if kind != "bridge":
|
||||
raise UvmError(f"configured bridge {bridge} exists but is not a Linux bridge")
|
||||
|
||||
def create_tap(self, name: str) -> None:
|
||||
existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
|
||||
if existing.returncode == 0:
|
||||
raise UvmError(f"TAP device already exists: {name}")
|
||||
created = False
|
||||
try:
|
||||
self._runner.run(("ip", "tuntap", "add", "dev", name, "mode", "tap"))
|
||||
created = True
|
||||
self._runner.run(("ip", "link", "set", name, "master", self._settings.bridge))
|
||||
self._runner.run(("ip", "link", "set", name, "up"))
|
||||
except BaseException as error:
|
||||
if created:
|
||||
try:
|
||||
self.delete_tap(name)
|
||||
except UvmError as cleanup_error:
|
||||
raise TapCreationError(
|
||||
f"could not configure TAP device {name}; cleanup also failed: {cleanup_error}",
|
||||
tap_created=True,
|
||||
) from error
|
||||
raise
|
||||
|
||||
def delete_tap(self, name: str) -> None:
|
||||
deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True)
|
||||
if deleted.returncode == 0:
|
||||
return
|
||||
remaining = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
|
||||
if remaining.returncode != 0:
|
||||
return
|
||||
detail = deleted.stderr.strip() or deleted.stdout.strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise UvmError(f"could not delete TAP device {name}{suffix}")
|
||||
|
||||
def _ensure_masquerade(self) -> None:
|
||||
route = self._runner.run(("ip", "route", "show", "default"), capture=True)
|
||||
match = re.search(r"\bdev\s+(\S+)", route.stdout)
|
||||
if match is None:
|
||||
return
|
||||
uplink = match.group(1)
|
||||
rule = (
|
||||
"iptables",
|
||||
"-t",
|
||||
"nat",
|
||||
"-C",
|
||||
"POSTROUTING",
|
||||
"-s",
|
||||
str(self._settings.network),
|
||||
"-o",
|
||||
uplink,
|
||||
"-j",
|
||||
"MASQUERADE",
|
||||
)
|
||||
present = self._runner.run(rule, check=False)
|
||||
if present.returncode != 0:
|
||||
self._runner.run(
|
||||
(
|
||||
"iptables",
|
||||
"-t",
|
||||
"nat",
|
||||
"-A",
|
||||
"POSTROUTING",
|
||||
"-s",
|
||||
str(self._settings.network),
|
||||
"-o",
|
||||
uplink,
|
||||
"-j",
|
||||
"MASQUERADE",
|
||||
)
|
||||
)
|
||||
self._ensure_iptables_rule(
|
||||
(
|
||||
"iptables",
|
||||
"-C",
|
||||
"FORWARD",
|
||||
"-i",
|
||||
self._settings.bridge,
|
||||
"-o",
|
||||
uplink,
|
||||
"-s",
|
||||
str(self._settings.network),
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
),
|
||||
(
|
||||
"iptables",
|
||||
"-A",
|
||||
"FORWARD",
|
||||
"-i",
|
||||
self._settings.bridge,
|
||||
"-o",
|
||||
uplink,
|
||||
"-s",
|
||||
str(self._settings.network),
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
),
|
||||
)
|
||||
self._ensure_iptables_rule(
|
||||
(
|
||||
"iptables",
|
||||
"-C",
|
||||
"FORWARD",
|
||||
"-i",
|
||||
uplink,
|
||||
"-o",
|
||||
self._settings.bridge,
|
||||
"-d",
|
||||
str(self._settings.network),
|
||||
"-m",
|
||||
"conntrack",
|
||||
"--ctstate",
|
||||
"ESTABLISHED,RELATED",
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
),
|
||||
(
|
||||
"iptables",
|
||||
"-A",
|
||||
"FORWARD",
|
||||
"-i",
|
||||
uplink,
|
||||
"-o",
|
||||
self._settings.bridge,
|
||||
"-d",
|
||||
str(self._settings.network),
|
||||
"-m",
|
||||
"conntrack",
|
||||
"--ctstate",
|
||||
"ESTABLISHED,RELATED",
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
),
|
||||
)
|
||||
|
||||
def _ensure_iptables_rule(
|
||||
self,
|
||||
check_rule: tuple[str, ...],
|
||||
add_rule: tuple[str, ...],
|
||||
) -> None:
|
||||
present = self._runner.run(check_rule, check=False)
|
||||
if present.returncode != 0:
|
||||
self._runner.run(add_rule)
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI routers for UVM's local management API."""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared FastAPI dependencies for accessing and protecting UVM services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from typing import cast
|
||||
|
||||
from fastapi import Header, HTTPException, Request, status
|
||||
|
||||
from ..app import Application
|
||||
|
||||
|
||||
def get_application(request: Request) -> Application:
|
||||
return cast(Application, request.app.state.uvm_application)
|
||||
|
||||
|
||||
def get_authorized_application(
|
||||
request: Request,
|
||||
x_uvm_token: str | None = Header(default=None),
|
||||
) -> Application:
|
||||
application = get_application(request)
|
||||
expected_token = application.settings.api_token
|
||||
if not expected_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="UVM_API_TOKEN is not configured",
|
||||
)
|
||||
if not hmac.compare_digest(x_uvm_token or "", expected_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="missing or invalid X-UVM-Token",
|
||||
)
|
||||
return application
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Unauthenticated health endpoint for local liveness checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..api_models import HealthResponse
|
||||
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Host installation endpoint backed by the existing UVM installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..api_models import InstallRequest, InstallResponse
|
||||
from ..app import Application
|
||||
from .dependencies import get_authorized_application
|
||||
|
||||
|
||||
router = APIRouter(tags=["installation"])
|
||||
|
||||
|
||||
@router.post("/install", response_model=InstallResponse)
|
||||
def install(
|
||||
request: InstallRequest,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> InstallResponse:
|
||||
firecracker, assets = application.installer.install(force_assets=request.force)
|
||||
application.state_store.initialize()
|
||||
return InstallResponse(
|
||||
firecracker=str(firecracker),
|
||||
kernel=str(assets.kernel),
|
||||
rootfs=str(assets.rootfs),
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""VM lifecycle endpoints backed by the existing UVM lifecycle service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response
|
||||
from ..app import Application
|
||||
from ..domain import VmSpec
|
||||
from ..validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
from .dependencies import get_authorized_application
|
||||
|
||||
|
||||
router = APIRouter(prefix="/vms", tags=["vms"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[VmResponse])
|
||||
def list_vms(
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> list[VmResponse]:
|
||||
return [
|
||||
vm_response(listed.vm, observed_status=listed.observed_status)
|
||||
for listed in application.lifecycle.list_vms()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=VmResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_vm(
|
||||
request: VmCreateRequest,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
vm = application.lifecycle.create(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(str(request.cpu)),
|
||||
ram_mib=parse_ram(str(request.ram)),
|
||||
guest_ip=request.guest_ip,
|
||||
username=parse_username(request.username),
|
||||
password=parse_password(request.password.get_secret_value()),
|
||||
)
|
||||
)
|
||||
return vm_response(vm)
|
||||
|
||||
|
||||
@router.post("/{vm_id}/stop", response_model=VmResponse)
|
||||
def stop_vm(
|
||||
vm_id: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
return vm_response(application.lifecycle.stop(vm_id))
|
||||
|
||||
|
||||
@router.delete("/{vm_id}", response_model=DestroyResponse)
|
||||
def destroy_vm(
|
||||
vm_id: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> DestroyResponse:
|
||||
vm = application.lifecycle.destroy(vm_id)
|
||||
return DestroyResponse(id=vm.id)
|
||||
|
||||
|
||||
@router.get("/{identifier}", response_model=VmResponse)
|
||||
def get_vm(
|
||||
identifier: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
for listed in application.lifecycle.list_vms():
|
||||
vm = listed.vm
|
||||
if identifier in {vm.id, vm.guest_ip}:
|
||||
return vm_response(vm, observed_status=listed.observed_status)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="VM not found")
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
"""FastAPI application factory and Uvicorn server launcher for uvm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from .app import Application, build_application
|
||||
from .errors import ConfigurationError, UvmError, ValidationError
|
||||
|
||||
|
||||
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
|
||||
"""Build the HTTP API after validating its intended bind address."""
|
||||
|
||||
if host is None:
|
||||
raise UvmError("create_api requires an explicit host. Start the API with uvm --serve.")
|
||||
|
||||
resolved_application = application or build_application(emit=None)
|
||||
_validate_server_settings(
|
||||
resolved_application.settings.api_token,
|
||||
resolved_application.settings.api_tls_cert,
|
||||
resolved_application.settings.api_tls_key,
|
||||
host,
|
||||
)
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
except ModuleNotFoundError as error:
|
||||
raise UvmError(
|
||||
"FastAPI server support is not installed. Install this project with its dependencies."
|
||||
) from error
|
||||
|
||||
from .routers.health import router as health_router
|
||||
from .routers.installation import router as installation_router
|
||||
from .routers.vms import router as vms_router
|
||||
|
||||
api = FastAPI(
|
||||
title="uvm",
|
||||
version="0.1.0",
|
||||
description="Local Firecracker microVM management API.",
|
||||
)
|
||||
api.state.uvm_application = resolved_application
|
||||
|
||||
@api.exception_handler(UvmError)
|
||||
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
|
||||
return JSONResponse(
|
||||
status_code=_http_status_for(error),
|
||||
content={"error": {"message": str(error)}},
|
||||
)
|
||||
|
||||
api.include_router(health_router)
|
||||
api.include_router(installation_router)
|
||||
api.include_router(vms_router)
|
||||
return api
|
||||
|
||||
|
||||
def run_server(application: Application, *, host: str, port: int) -> None:
|
||||
"""Run Uvicorn after enforcing the local-management security boundary."""
|
||||
|
||||
settings = application.settings
|
||||
_validate_server_settings(
|
||||
settings.api_token,
|
||||
settings.api_tls_cert,
|
||||
settings.api_tls_key,
|
||||
host,
|
||||
)
|
||||
try:
|
||||
import uvicorn
|
||||
except ModuleNotFoundError as error:
|
||||
raise UvmError(
|
||||
"Uvicorn server support is not installed. Install this project with its dependencies."
|
||||
) from error
|
||||
|
||||
options: dict[str, Any] = {"host": host, "port": port}
|
||||
if settings.api_tls_cert is not None:
|
||||
options["ssl_certfile"] = str(settings.api_tls_cert)
|
||||
options["ssl_keyfile"] = str(settings.api_tls_key)
|
||||
uvicorn.run(create_api(application, host=host), **options)
|
||||
|
||||
|
||||
def _is_loopback_host(host: str) -> bool:
|
||||
try:
|
||||
return ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_server_settings(
|
||||
api_token: str | None,
|
||||
cert: Path | None,
|
||||
key: Path | None,
|
||||
host: str,
|
||||
) -> None:
|
||||
if not api_token:
|
||||
raise UvmError("UVM_API_TOKEN is required before starting the management API")
|
||||
if (cert is None) != (key is None):
|
||||
raise UvmError("UVM_API_TLS_CERT and UVM_API_TLS_KEY must be configured together")
|
||||
if cert is not None:
|
||||
assert key is not None
|
||||
if not cert.is_file() or not key.is_file():
|
||||
raise UvmError("configured API TLS certificate or key does not exist")
|
||||
try:
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||
except (OSError, ssl.SSLError) as error:
|
||||
raise UvmError(f"configured API TLS certificate or key is invalid: {error}") from error
|
||||
if not _is_loopback_host(host) and cert is None:
|
||||
raise UvmError(
|
||||
"refusing to bind the API to a non-loopback host without TLS. "
|
||||
"Set UVM_API_TLS_CERT and UVM_API_TLS_KEY or bind behind a TLS reverse proxy."
|
||||
)
|
||||
|
||||
|
||||
def _http_status_for(error: UvmError) -> int:
|
||||
if isinstance(error, (ConfigurationError, ValidationError)):
|
||||
return 422
|
||||
message = str(error)
|
||||
if message.startswith("VM not found:"):
|
||||
return 404
|
||||
if "operation is already in progress" in message:
|
||||
return 409
|
||||
if "needs root" in message:
|
||||
return 403
|
||||
return 500
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Locked, atomic persistence for the local JSON VM inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import State, VmRecord
|
||||
from .errors import StateError, UvmError
|
||||
from .system import ensure_data_directories
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Own the JSON state file so callers cannot race IP and VM allocation."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
def initialize(self) -> None:
|
||||
ensure_data_directories(self._settings)
|
||||
with self._locked():
|
||||
if not self._settings.state_path.exists():
|
||||
self._save_unlocked(State())
|
||||
else:
|
||||
try:
|
||||
os.chmod(self._settings.state_path, 0o600)
|
||||
except OSError as error:
|
||||
raise StateError(
|
||||
f"cannot update permissions on {self._settings.state_path}: {error}"
|
||||
) from error
|
||||
|
||||
def load(self) -> State:
|
||||
# Reads do not create /var/lib/uvm, so `uvm list` remains usable before install.
|
||||
# Atomic replacement makes an unlocked reader see either the old or new full document.
|
||||
return self._load_unlocked()
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator[State]:
|
||||
"""Load and commit one state mutation while holding an exclusive lock."""
|
||||
|
||||
ensure_data_directories(self._settings)
|
||||
with self._locked():
|
||||
state = self._load_unlocked()
|
||||
yield state
|
||||
self._save_unlocked(state)
|
||||
|
||||
@contextmanager
|
||||
def operation_lock(self) -> Iterator[None]:
|
||||
"""Serialize external VM lifecycle work across concurrent CLI processes."""
|
||||
|
||||
ensure_data_directories(self._settings)
|
||||
with self._locked_path(self._settings.operation_lock_path):
|
||||
yield
|
||||
|
||||
def get(self, vm_id: str) -> VmRecord:
|
||||
state = self.load()
|
||||
try:
|
||||
return state.vms[vm_id]
|
||||
except KeyError as error:
|
||||
raise UvmError(f"VM not found: {vm_id}") from error
|
||||
|
||||
def find_by_id_or_ip(self, identifier: str) -> VmRecord:
|
||||
state = self.load()
|
||||
if identifier in state.vms:
|
||||
return state.vms[identifier]
|
||||
for vm in state.vms.values():
|
||||
if vm.guest_ip == identifier:
|
||||
return vm
|
||||
raise UvmError(f"VM not found: {identifier}")
|
||||
|
||||
@contextmanager
|
||||
def _locked(self) -> Iterator[None]:
|
||||
with self._locked_path(self._settings.state_lock_path):
|
||||
yield
|
||||
|
||||
@contextmanager
|
||||
def _locked_path(self, lock_path: Path) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def _load_unlocked(self) -> State:
|
||||
path = self._settings.state_path
|
||||
if not path.exists():
|
||||
return State()
|
||||
try:
|
||||
with path.open(encoding="utf-8") as state_file:
|
||||
raw = json.load(state_file)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise StateError(f"cannot read {path}: {error}") from error
|
||||
return State.from_dict(raw)
|
||||
|
||||
def _save_unlocked(self, state: State) -> None:
|
||||
path = self._settings.state_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary_path = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
|
||||
json.dump(state.to_dict(), temporary_file, indent=2, sort_keys=True)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
os.chmod(path, 0o600)
|
||||
self._fsync_parent(path)
|
||||
except OSError as error:
|
||||
raise StateError(f"cannot write {path}: {error}") from error
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fsync_parent(path: Path) -> None:
|
||||
directory = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Small, testable wrappers around required host-level operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import CommandError, UvmError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommandResult:
|
||||
args: tuple[str, ...]
|
||||
returncode: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""Execute host commands while keeping command construction testable."""
|
||||
|
||||
def __init__(self, emit: Callable[[str], None] | None = print) -> None:
|
||||
self._emit = emit
|
||||
|
||||
def run(
|
||||
self,
|
||||
command: Sequence[str | Path],
|
||||
*,
|
||||
check: bool = True,
|
||||
capture: bool = False,
|
||||
input_text: str | None = None,
|
||||
sensitive: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> CommandResult:
|
||||
args = tuple(str(part) for part in command)
|
||||
if self._emit is not None:
|
||||
self._emit(f"+ {shlex.join(args)}")
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
check=False,
|
||||
text=True,
|
||||
input=input_text,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.PIPE if capture else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise CommandError(f"required command was not found: {args[0]}") from error
|
||||
except OSError as error:
|
||||
raise CommandError(f"could not run {args[0]}: {error}") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise CommandError(f"command timed out: {shlex.join(args)}") from error
|
||||
|
||||
result = CommandResult(
|
||||
args=args,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = "" if sensitive else result.stderr.strip() or result.stdout.strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise CommandError(
|
||||
f"command failed ({result.returncode}): {shlex.join(args)}{suffix}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def require_root() -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise UvmError("this command needs root. Run it with sudo.")
|
||||
|
||||
|
||||
def check_kvm() -> None:
|
||||
kvm = Path("/dev/kvm")
|
||||
if not kvm.exists():
|
||||
raise UvmError("/dev/kvm does not exist. Enable hardware virtualization/KVM first.")
|
||||
if not os.access(kvm, os.R_OK | os.W_OK):
|
||||
raise UvmError("no read/write access to /dev/kvm. Run as root or grant KVM access.")
|
||||
|
||||
|
||||
def ensure_data_directories(settings: Settings) -> None:
|
||||
for path in (settings.base, settings.bin_dir, settings.images_dir, settings.vms_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Validation and unit conversion for command-line resource options."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .errors import ValidationError
|
||||
|
||||
|
||||
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
|
||||
_USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?")
|
||||
_MIB_FACTORS = {
|
||||
"b": Decimal(1) / Decimal(1024 * 1024),
|
||||
"k": Decimal(1) / Decimal(1024),
|
||||
"kb": Decimal(1) / Decimal(1024),
|
||||
"ki": Decimal(1) / Decimal(1024),
|
||||
"kib": Decimal(1) / Decimal(1024),
|
||||
"m": Decimal(1),
|
||||
"mb": Decimal(1),
|
||||
"mi": Decimal(1),
|
||||
"mib": Decimal(1),
|
||||
"g": Decimal(1024),
|
||||
"gb": Decimal(1024),
|
||||
"gi": Decimal(1024),
|
||||
"gib": Decimal(1024),
|
||||
}
|
||||
|
||||
|
||||
def parse_ram(value: str) -> int:
|
||||
"""Parse a MiB-default RAM value, including B/K/M/G suffixes."""
|
||||
|
||||
match = _RAM_PATTERN.fullmatch(value)
|
||||
if not match:
|
||||
raise ValidationError(f"invalid RAM value: {value}")
|
||||
|
||||
try:
|
||||
amount = Decimal(match.group(1))
|
||||
except InvalidOperation as error:
|
||||
raise ValidationError(f"invalid RAM value: {value}") from error
|
||||
|
||||
unit = (match.group(2) or "MiB").lower()
|
||||
mib = int(amount * _MIB_FACTORS[unit])
|
||||
if mib < 128:
|
||||
raise ValidationError("RAM must be at least 128 MiB")
|
||||
return mib
|
||||
|
||||
|
||||
def parse_cpu(value: str) -> float:
|
||||
"""Parse a positive finite CPU capacity request."""
|
||||
|
||||
try:
|
||||
cpu = float(value)
|
||||
except ValueError as error:
|
||||
raise ValidationError(f"invalid CPU value: {value}") from error
|
||||
|
||||
if not math.isfinite(cpu) or cpu <= 0:
|
||||
raise ValidationError("CPU must be a finite value greater than 0")
|
||||
return cpu
|
||||
|
||||
|
||||
def parse_username(value: str) -> str:
|
||||
"""Validate a conventional Linux account name."""
|
||||
|
||||
if not _USERNAME_PATTERN.fullmatch(value):
|
||||
raise ValidationError(
|
||||
"username must start with a lowercase letter or underscore and contain "
|
||||
"at most 32 lowercase letters, digits, underscores, or hyphens"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def parse_password(value: str) -> str:
|
||||
"""Validate a guest password without including it in error messages."""
|
||||
|
||||
if not value:
|
||||
raise ValidationError("password must not be empty")
|
||||
if len(value) > 128:
|
||||
raise ValidationError("password must be at most 128 characters")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ValidationError("password must not contain control characters")
|
||||
return value
|
||||
Reference in New Issue
Block a user