This commit is contained in:
its.kstyagi@gmail.com
2026-09-04 20:56:18 +00:00
commit 1022f24c34
43 changed files with 6160 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Internal implementation package for the uvm command-line application."""
+9
View File
@@ -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())
+76
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Internal Firecracker configuration, API, and process adapters."""
+82
View File
@@ -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}"
)
+94
View File
@@ -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
+205
View File
@@ -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
View File
@@ -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
View File
@@ -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
+145
View File
@@ -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
+284
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""FastAPI routers for UVM's local management API."""
+33
View File
@@ -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
+15
View File
@@ -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()
+26
View File
@@ -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),
)
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+91
View File
@@ -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)
+83
View File
@@ -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