Refactor VM lifecycle management and enhance validation
- Introduced a new method `installed_kernel` in `ImageStore` to handle kernel verification and checksum validation. - Updated `Installer` to include additional packages: `nftables` and `conntrack`. - Refined `LifecycleService` to improve error handling and state management during VM creation, starting, stopping, and deletion. - Enhanced port management with new methods for setting and syncing ports, including validation for port rules. - Improved error handling in `StateStore` to raise specific `NotFoundError` and `ConflictError` exceptions. - Updated network management to ensure proper TAP device handling and firewall integration. - Enhanced API responses in `vms.py` to include policy status for better client-side handling. - Added validation for VM names and ports in `validation.py` to enforce stricter rules and prevent conflicts.
This commit is contained in:
+37
-9
@@ -3,8 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
from .domain import VmRecord
|
||||
|
||||
@@ -14,10 +15,9 @@ class HealthResponse(BaseModel):
|
||||
|
||||
|
||||
class InstallRequest(BaseModel):
|
||||
force: bool = False
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
class Config:
|
||||
extra = "forbid"
|
||||
force: bool = False
|
||||
|
||||
|
||||
class InstallResponse(BaseModel):
|
||||
@@ -26,19 +26,31 @@ class InstallResponse(BaseModel):
|
||||
rootfs: str
|
||||
|
||||
|
||||
class PortRuleModel(BaseModel):
|
||||
"""A private guest TCP port; HTTP and HTTPS are descriptive labels only."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
port: int = Field(strict=True, ge=1, le=65535)
|
||||
protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP"
|
||||
|
||||
|
||||
class VmCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
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"
|
||||
name: str | None = None
|
||||
ports: list[PortRuleModel] | None = None
|
||||
no_default_ports: bool = Field(default=False, strict=True)
|
||||
|
||||
|
||||
class VmResponse(BaseModel):
|
||||
id: str
|
||||
name: str | None
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: str
|
||||
@@ -47,6 +59,9 @@ class VmResponse(BaseModel):
|
||||
username: str
|
||||
status: str
|
||||
observed_status: str
|
||||
ports: list[PortRuleModel] | None
|
||||
policy_status: str
|
||||
operation: str | None
|
||||
pid: int | None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
@@ -58,9 +73,15 @@ class DestroyResponse(BaseModel):
|
||||
status: str = "terminated"
|
||||
|
||||
|
||||
def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmResponse:
|
||||
def vm_response(
|
||||
vm: VmRecord,
|
||||
*,
|
||||
observed_status: str | None = None,
|
||||
policy_status: str = "unknown",
|
||||
) -> VmResponse:
|
||||
return VmResponse(
|
||||
id=vm.id,
|
||||
name=vm.name,
|
||||
cpu=vm.cpu,
|
||||
ram_mib=vm.ram_mib,
|
||||
guest_ip=vm.guest_ip,
|
||||
@@ -68,7 +89,14 @@ def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmRespon
|
||||
mac=vm.mac,
|
||||
username=vm.username,
|
||||
status=vm.status,
|
||||
observed_status=observed_status or vm.status,
|
||||
observed_status=observed_status if observed_status is not None else vm.status,
|
||||
ports=(
|
||||
[PortRuleModel(port=rule.port, protocol=rule.protocol) for rule in vm.ports]
|
||||
if vm.ports is not None
|
||||
else None
|
||||
),
|
||||
policy_status=policy_status,
|
||||
operation=vm.operation,
|
||||
pid=vm.pid,
|
||||
created_at=vm.created_at,
|
||||
updated_at=vm.updated_at,
|
||||
|
||||
+491
-40
@@ -3,22 +3,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import codecs
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
|
||||
from .app import Application, build_application
|
||||
from .domain import VmSpec
|
||||
from .domain import PortRule, VmRecord, VmSpec
|
||||
from .errors import UvmError, ValidationError
|
||||
from .firecracker.config import vcpu_count
|
||||
from .validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
from .validation import (
|
||||
parse_cpu,
|
||||
parse_name,
|
||||
parse_password,
|
||||
parse_ports,
|
||||
parse_ram,
|
||||
parse_timeout,
|
||||
parse_username,
|
||||
)
|
||||
|
||||
|
||||
class _CommandParser(argparse.ArgumentParser):
|
||||
def __init__(self, *args, ssh_command: bool = False, **kwargs) -> None:
|
||||
super().__init__(*args, allow_abbrev=False, **kwargs)
|
||||
self._ssh_command = ssh_command
|
||||
|
||||
def parse_known_args(self, args=None, namespace=None):
|
||||
if not self._ssh_command:
|
||||
return super().parse_known_args(args, namespace)
|
||||
values = list(sys.argv[1:] if args is None else args)
|
||||
remote_command = []
|
||||
if "--" in values:
|
||||
separator = values.index("--")
|
||||
values, remote_command = values[:separator], values[separator + 1 :]
|
||||
# REMAINDER would swallow SSH options placed after the VM identifier.
|
||||
parsed, unknown = super().parse_known_args(values, namespace)
|
||||
parsed.remote_command = remote_command
|
||||
return parsed, unknown
|
||||
|
||||
|
||||
def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=application.settings.app_name,
|
||||
description="Tiny Firecracker microVM CLI for Ubuntu.",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--serve",
|
||||
@@ -36,7 +68,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
default=8000,
|
||||
help="server bind port used with --serve; defaults to 8000",
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command")
|
||||
subcommands = parser.add_subparsers(dest="command", parser_class=_CommandParser)
|
||||
|
||||
install = subcommands.add_parser(
|
||||
"install",
|
||||
@@ -53,7 +85,8 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
|
||||
)
|
||||
|
||||
create = subcommands.add_parser("create", help="create and boot a microVM")
|
||||
create = subcommands.add_parser("create", help="create and boot a microVM detached")
|
||||
create.add_argument("--name", help="unique local VM name")
|
||||
create.add_argument(
|
||||
"--cpu",
|
||||
default=str(application.settings.default_vcpu),
|
||||
@@ -65,6 +98,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
help="RAM, e.g. 512, 1G, 512M",
|
||||
)
|
||||
create.add_argument(
|
||||
"--guest-ip",
|
||||
"--host-ip",
|
||||
dest="host_ip",
|
||||
help="guest IP to assign; otherwise uvm allocates one",
|
||||
@@ -74,36 +108,133 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
default=application.settings.default_ssh_user,
|
||||
help="existing guest account to configure; defaults to root",
|
||||
)
|
||||
create.add_argument(
|
||||
credentials = create.add_mutually_exclusive_group()
|
||||
credentials.add_argument(
|
||||
"--password",
|
||||
default="root",
|
||||
help="guest login password; defaults to root",
|
||||
)
|
||||
credentials.add_argument(
|
||||
"--password-stdin",
|
||||
action="store_true",
|
||||
help="read the guest password from one line of stdin",
|
||||
)
|
||||
port_help = (
|
||||
"private guest PORT[:TYPE], e.g. 22,80:HTTP,443:HTTPS; "
|
||||
"repeat or separate with commas (TYPE: HTTP, HTTPS, TCP; default: TCP); "
|
||||
"no host-public ports, forwarding, or proxy"
|
||||
)
|
||||
create.add_argument("--ports", action="append", default=[], help=port_help)
|
||||
create.add_argument(
|
||||
"--no-default-ports", action="store_true", help="do not add the default 22:TCP rule"
|
||||
)
|
||||
create.add_argument("--json", action="store_true")
|
||||
|
||||
subcommands.add_parser("list", help="list VMs")
|
||||
listing = subcommands.add_parser("list", help="list VMs")
|
||||
listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
|
||||
output = listing.add_mutually_exclusive_group()
|
||||
output.add_argument("--json", action="store_true")
|
||||
output.add_argument("-q", "--quiet", action="store_true", help="print only VM IDs")
|
||||
|
||||
ssh = subcommands.add_parser("ssh", help="SSH into a VM")
|
||||
ssh.add_argument("vm", help="VM ID or guest IP")
|
||||
inspect = subcommands.add_parser("inspect", help="inspect one VM")
|
||||
inspect.add_argument("vm", help="VM ID, name, or guest IP")
|
||||
inspect.add_argument("--json", action="store_true")
|
||||
|
||||
for action in ("start", "stop", "restart", "delete"):
|
||||
command = subcommands.add_parser(
|
||||
action,
|
||||
aliases=["destroy"] if action == "delete" else [],
|
||||
help=f"{action} selected VMs",
|
||||
)
|
||||
targets = command.add_mutually_exclusive_group(required=True)
|
||||
targets.add_argument(
|
||||
"vms", nargs="*", default=[], metavar="VM", help="VM IDs, names, or guest IPs"
|
||||
)
|
||||
targets.add_argument("--all", dest="all_vms", action="store_true", help="select all VMs")
|
||||
command.add_argument("--yes", "-y", action="store_true", help="skip confirmation")
|
||||
command.add_argument("--dry-run", action="store_true", help="show targets without changes")
|
||||
command.add_argument("--json", action="store_true")
|
||||
if action != "start":
|
||||
command.add_argument(
|
||||
"--timeout",
|
||||
help=(
|
||||
"seconds to wait after SIGTERM before SIGKILL "
|
||||
"(defaults to the configured timeout, normally 5s); "
|
||||
"does not coordinate guest shutdown"
|
||||
),
|
||||
)
|
||||
|
||||
ssh = subcommands.add_parser(
|
||||
"ssh",
|
||||
ssh_command=True,
|
||||
help="SSH into a VM",
|
||||
usage="%(prog)s [options] VM [-- COMMAND ...]",
|
||||
epilog="Append a remote command after a literal --, e.g. ssh VM -- uname -a.",
|
||||
)
|
||||
ssh.add_argument("vm", help="VM ID, name, or guest IP")
|
||||
ssh.add_argument("--user", help="override the username stored for the VM")
|
||||
ssh.add_argument("--key")
|
||||
ssh.add_argument("--port", type=_port_number, default=22, help="guest SSH port (default: 22)")
|
||||
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")
|
||||
logs = subcommands.add_parser("logs", help="read a VM's local Firecracker log")
|
||||
logs.add_argument("vm", help="VM ID, name, or guest IP")
|
||||
logs.add_argument("--tail", type=_line_count, default=100, metavar="N")
|
||||
logs.add_argument("-f", "--follow", action="store_true")
|
||||
|
||||
destroy = subcommands.add_parser("destroy", help="stop and remove a VM")
|
||||
destroy.add_argument("vm")
|
||||
ports = subcommands.add_parser(
|
||||
"ports",
|
||||
help="manage enforced private-IP guest ports (not public ports)",
|
||||
description=(
|
||||
"Manage private-IP TCP allowlists. HTTP/HTTPS are TCP labels, not proxies "
|
||||
"or TLS configuration. No host-public ports or NAT rules are created."
|
||||
),
|
||||
)
|
||||
port_commands = ports.add_subparsers(dest="ports_command", required=True)
|
||||
for action in ("list", "open", "close", "set", "sync"):
|
||||
command = port_commands.add_parser(action, help=f"{action} private-IP port rules")
|
||||
command.add_argument("--json", action="store_true")
|
||||
if action in {"list", "sync"}:
|
||||
targets = command.add_mutually_exclusive_group(required=True)
|
||||
targets.add_argument(
|
||||
"vms" if action == "sync" else "vm",
|
||||
nargs="*" if action == "sync" else "?",
|
||||
default=[] if action == "sync" else None,
|
||||
metavar="VM",
|
||||
help="VM ID, name, or guest IP",
|
||||
)
|
||||
targets.add_argument("--all", dest="all_vms", action="store_true")
|
||||
else:
|
||||
command.add_argument("vm", help="VM ID, name, or guest IP")
|
||||
if action == "open":
|
||||
command.add_argument("--ports", action="append", required=True, help=port_help)
|
||||
else:
|
||||
selection = command.add_mutually_exclusive_group(required=True)
|
||||
selection.add_argument("--ports", action="append", default=[], help=port_help)
|
||||
if action == "close":
|
||||
selection.add_argument(
|
||||
"--all", dest="all_ports", action="store_true", help="close all ports of this VM"
|
||||
)
|
||||
else:
|
||||
selection.add_argument("--none", action="store_true", help="deny all inbound ports")
|
||||
if action != "list":
|
||||
command.add_argument("--dry-run", action="store_true", help="show changes without applying")
|
||||
if action in {"close", "set", "sync"}:
|
||||
command.add_argument("--yes", "-y", action="store_true", help="skip confirmation")
|
||||
|
||||
doctor = subcommands.add_parser("doctor", help="check local host readiness and policy health")
|
||||
doctor.add_argument("--json", action="store_true")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
try:
|
||||
application = build_application()
|
||||
application = build_application(emit=lambda message: print(message, file=sys.stderr))
|
||||
parser = build_parser(application)
|
||||
args = parser.parse_args(argv)
|
||||
if args.serve:
|
||||
@@ -113,9 +244,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.command is None:
|
||||
parser.error("a command is required unless --serve is used")
|
||||
return _run_command(application, args)
|
||||
except ValidationError as error:
|
||||
print(f"uvm: error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
except UvmError as error:
|
||||
print(f"uvm: error: {error}", file=sys.stderr)
|
||||
return error.exit_code
|
||||
except OSError as error:
|
||||
print(f"uvm: error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print("uvm: interrupted", file=sys.stderr)
|
||||
return 130
|
||||
@@ -146,54 +283,125 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
|
||||
if args.command == "create":
|
||||
guest_ip = _parse_guest_ip(args.host_ip)
|
||||
ports = parse_ports(args.ports)
|
||||
if not args.no_default_ports and not any(rule.port == 22 for rule in ports):
|
||||
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
|
||||
password = args.password
|
||||
if args.password_stdin:
|
||||
password = sys.stdin.readline(131).removesuffix("\n").removesuffix("\r")
|
||||
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),
|
||||
password=parse_password(password),
|
||||
name=parse_name(args.name) if args.name is not None else None,
|
||||
ports=ports,
|
||||
)
|
||||
)
|
||||
if vm.cpu < 1.0:
|
||||
print(
|
||||
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU;"
|
||||
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU; "
|
||||
"fractional CPU enforcement is not yet applied.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
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.")
|
||||
if password == "root":
|
||||
print(
|
||||
"WARNING: the guest is using the default password 'root'. Change it promptly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if args.json:
|
||||
_print_json(_vm_output(
|
||||
vm, policy_status="unmanaged" if vm.ports is None else "enforced"
|
||||
))
|
||||
else:
|
||||
print(f"VM created: {vm.id}")
|
||||
if vm.name is not None:
|
||||
print(f" Name: {vm.name}")
|
||||
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" Private ports: {_format_ports(vm.ports)}")
|
||||
if any(rule.port == 22 for rule in vm.ports or ()):
|
||||
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
|
||||
return 0
|
||||
|
||||
if args.command == "list":
|
||||
vms = application.lifecycle.list_vms()
|
||||
vms = [
|
||||
listed for listed in application.lifecycle.list_vms()
|
||||
if not args.status or listed.observed_status in args.status
|
||||
]
|
||||
if args.json:
|
||||
_print_json([
|
||||
_vm_output(listed.vm, listed.observed_status, listed.policy_status)
|
||||
for listed in vms
|
||||
])
|
||||
return 0
|
||||
if args.quiet:
|
||||
for listed in vms:
|
||||
print(listed.vm.id)
|
||||
return 0
|
||||
if not vms:
|
||||
print("No VMs.")
|
||||
return 0
|
||||
print(
|
||||
f"{'ID':<37} {'IP':<16} {'USER':<16} {'CPU':<7} "
|
||||
f"{'RAM':<8} {'STATUS':<10} PID"
|
||||
f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} "
|
||||
f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} 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}"
|
||||
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
|
||||
f"{vm.ram_mib:<8} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "inspect":
|
||||
listed = application.lifecycle.inspect(args.vm)
|
||||
data = _vm_output(listed.vm, listed.observed_status, listed.policy_status)
|
||||
if args.json:
|
||||
_print_json(data)
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if key == "ports":
|
||||
value = _format_ports(listed.vm.ports)
|
||||
print(f"{key}: {value if value is not None else '-'}")
|
||||
return 0
|
||||
|
||||
if args.command in {"start", "stop", "restart", "delete", "destroy"} or (
|
||||
args.command == "ports" and args.ports_command == "sync"
|
||||
):
|
||||
action = "ports-sync" if args.command == "ports" else args.command
|
||||
if action == "destroy":
|
||||
action = "delete"
|
||||
timeout = None
|
||||
if action in {"stop", "restart", "delete"}:
|
||||
timeout = parse_timeout(
|
||||
args.timeout if args.timeout is not None else str(application.settings.terminate_timeout_s)
|
||||
)
|
||||
targets = application.lifecycle.resolve_targets(args.vms, all_vms=args.all_vms)
|
||||
vm_ids = tuple(vm.id for vm in targets)
|
||||
if args.dry_run:
|
||||
_print_preview(action, targets, as_json=args.json)
|
||||
return 0
|
||||
if not targets:
|
||||
return _print_results([], as_json=args.json)
|
||||
if action == "delete" or args.all_vms:
|
||||
if not _confirm(f"{action} VMs: {', '.join(vm_ids)}?", yes=args.yes):
|
||||
if args.json:
|
||||
_print_json([])
|
||||
return 0
|
||||
results = application.lifecycle.run_many(action, vm_ids, timeout=timeout)
|
||||
return _print_results(results, as_json=args.json)
|
||||
|
||||
if args.command == "ssh":
|
||||
user = parse_username(args.user) if args.user is not None else None
|
||||
vm = application.lifecycle.find_for_ssh(args.vm)
|
||||
command = ["ssh"]
|
||||
command = ["ssh", "-p", str(args.port)]
|
||||
if args.key:
|
||||
command.extend(("-i", args.key))
|
||||
if args.insecure_host_key:
|
||||
@@ -214,23 +422,266 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
)
|
||||
)
|
||||
command.append(f"{args.user or vm.username}@{vm.guest_ip}")
|
||||
command.append(f"{user or vm.username}@{vm.guest_ip}")
|
||||
command.extend(args.remote_command)
|
||||
os.execvp(command[0], command)
|
||||
return 0
|
||||
|
||||
if args.command == "stop":
|
||||
vm = application.lifecycle.stop(args.vm)
|
||||
print(f"Stopped {vm.id}")
|
||||
if args.command == "logs":
|
||||
_show_logs(application.lifecycle.log_path(args.vm), args.tail, follow=args.follow)
|
||||
return 0
|
||||
|
||||
if args.command == "destroy":
|
||||
vm = application.lifecycle.destroy(args.vm)
|
||||
print(f"Destroyed {vm.id}")
|
||||
if args.command == "ports":
|
||||
mode = args.ports_command
|
||||
if mode == "list":
|
||||
listed_vms = (
|
||||
application.lifecycle.list_vms() if args.all_vms
|
||||
else [application.lifecycle.inspect(args.vm)]
|
||||
)
|
||||
if args.json:
|
||||
_print_json([
|
||||
_vm_output(listed.vm, listed.observed_status, listed.policy_status)
|
||||
for listed in listed_vms
|
||||
])
|
||||
elif not listed_vms:
|
||||
print("No VMs.")
|
||||
else:
|
||||
print(f"{'ID':<37} {'NAME':<20} {'IP':<16} {'POLICY':<12} PRIVATE-IP PORTS")
|
||||
for listed in listed_vms:
|
||||
vm = listed.vm
|
||||
print(
|
||||
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} "
|
||||
f"{listed.policy_status:<12} {_format_ports(vm.ports)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
requested = parse_ports(args.ports)
|
||||
listed = application.lifecycle.inspect(args.vm)
|
||||
vm = listed.vm
|
||||
vm_id = vm.id
|
||||
before = vm.ports
|
||||
if mode in {"open", "close"} and before is None:
|
||||
raise UvmError(
|
||||
f"{vm_id} has an unmanaged port policy; use ports set on a stopped VM first"
|
||||
)
|
||||
service_mode = mode
|
||||
if mode == "close" and args.all_ports:
|
||||
service_mode = "set"
|
||||
requested = ()
|
||||
if service_mode == "set":
|
||||
after = requested
|
||||
elif service_mode == "open":
|
||||
merged = {rule.port: rule for rule in before}
|
||||
for rule in requested:
|
||||
merged.setdefault(rule.port, rule)
|
||||
after = tuple(merged[port] for port in sorted(merged))
|
||||
else:
|
||||
removed = {rule.port for rule in requested}
|
||||
after = tuple(rule for rule in before if rule.port not in removed)
|
||||
if args.dry_run:
|
||||
_print_preview(
|
||||
f"ports-{mode}", [vm], as_json=args.json,
|
||||
changes={"ports": {"before": _ports_output(before), "after": _ports_output(after)}},
|
||||
)
|
||||
return 0
|
||||
if (
|
||||
before == after == ()
|
||||
and vm.operation is None
|
||||
and (vm.status, listed.observed_status, listed.policy_status) in {
|
||||
("running", "running", "enforced"),
|
||||
("stopped", "stopped", "inactive"),
|
||||
}
|
||||
):
|
||||
if args.json:
|
||||
_print_json(_vm_output(vm, listed.observed_status, listed.policy_status))
|
||||
else:
|
||||
print(f"{vm_id}: no port changes.")
|
||||
return 0
|
||||
removes_ssh = (
|
||||
any(rule.port == 22 for rule in before or ())
|
||||
and not any(rule.port == 22 for rule in after)
|
||||
)
|
||||
if service_mode == "set" or removes_ssh:
|
||||
if not _confirm(
|
||||
f"{mode} private-IP ports for {vm_id}: "
|
||||
f"{_format_ports(before)} -> {_format_ports(after)}?",
|
||||
yes=args.yes,
|
||||
):
|
||||
if args.json:
|
||||
_print_json([])
|
||||
return 0
|
||||
updated = application.lifecycle.set_ports(vm_id, requested, mode=service_mode)
|
||||
if args.json:
|
||||
_print_json(_vm_output(
|
||||
updated, policy_status="enforced" if updated.status == "running" else "inactive"
|
||||
))
|
||||
else:
|
||||
print(f"{updated.id} private-IP ports: {_format_ports(updated.ports)}")
|
||||
return 0
|
||||
|
||||
if args.command == "doctor":
|
||||
checks = [
|
||||
{"name": check["name"], "ok": check["ok"], "message": check["message"]}
|
||||
for check in application.lifecycle.diagnostics()
|
||||
]
|
||||
if args.json:
|
||||
_print_json(checks)
|
||||
else:
|
||||
for check in checks:
|
||||
print(f"{'OK' if check['ok'] else 'FAIL'} {check['name']}: {check['message']}")
|
||||
return 0 if all(check["ok"] for check in checks) else 1
|
||||
|
||||
raise UvmError(f"unsupported command: {args.command}")
|
||||
|
||||
|
||||
def _vm_output(
|
||||
vm: VmRecord, observed_status: str | None = None, policy_status: str = "unknown"
|
||||
) -> dict:
|
||||
# This is an output whitelist, not the persisted record (which contains credentials).
|
||||
return {
|
||||
"id": vm.id,
|
||||
"name": vm.name,
|
||||
"cpu": vm.cpu,
|
||||
"ram_mib": vm.ram_mib,
|
||||
"guest_ip": vm.guest_ip,
|
||||
"gateway": vm.gateway,
|
||||
"tap": vm.tap,
|
||||
"mac": vm.mac,
|
||||
"username": vm.username,
|
||||
"status": vm.status,
|
||||
"observed_status": observed_status if observed_status is not None else vm.status,
|
||||
"policy_status": policy_status,
|
||||
"ports": _ports_output(vm.ports),
|
||||
"pid": vm.pid,
|
||||
"disk": vm.disk,
|
||||
"disk_ready": vm.disk_ready,
|
||||
"socket": vm.socket,
|
||||
"config": vm.config,
|
||||
"log": vm.log,
|
||||
"operation": vm.operation,
|
||||
"boot_id": vm.boot_id,
|
||||
"created_at": vm.created_at,
|
||||
"updated_at": vm.updated_at,
|
||||
"last_error": vm.last_error,
|
||||
}
|
||||
|
||||
|
||||
def _ports_output(ports: tuple[PortRule, ...] | None) -> list[dict] | None:
|
||||
return None if ports is None else [rule.to_dict() for rule in ports]
|
||||
|
||||
|
||||
def _format_ports(ports: tuple[PortRule, ...] | None) -> str:
|
||||
if ports is None:
|
||||
return "unmanaged"
|
||||
return ",".join(f"{rule.port}:{rule.protocol}" for rule in ports) or "(none)"
|
||||
|
||||
|
||||
def _print_json(value: object) -> None:
|
||||
print(json.dumps(value, indent=2))
|
||||
|
||||
|
||||
def _print_preview(
|
||||
action: str, targets: Sequence[VmRecord], *, as_json: bool, changes: dict | None = None
|
||||
) -> None:
|
||||
if as_json:
|
||||
data = {"dry_run": True, "action": action, "targets": [_vm_output(vm) for vm in targets]}
|
||||
if changes is not None:
|
||||
data["changes"] = changes
|
||||
_print_json(data)
|
||||
else:
|
||||
print(f"Dry run: {action}")
|
||||
if not targets:
|
||||
print("No VMs.")
|
||||
for vm in targets:
|
||||
print(f" {vm.id} {vm.name or '-'} {vm.guest_ip}")
|
||||
if changes is not None:
|
||||
for field, change in changes.items():
|
||||
print(f" {field}: {json.dumps(change['before'])} -> {json.dumps(change['after'])}")
|
||||
|
||||
|
||||
def _confirm(message: str, *, yes: bool) -> bool:
|
||||
if yes:
|
||||
return True
|
||||
if not sys.stdin.isatty():
|
||||
raise UvmError("confirmation requires a TTY; use --yes or preview with --dry-run")
|
||||
print(f"{message} [y/N] ", end="", file=sys.stderr, flush=True)
|
||||
if sys.stdin.readline().strip().lower() in {"y", "yes"}:
|
||||
return True
|
||||
print("Cancelled.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def _print_results(results: Sequence, *, as_json: bool) -> int:
|
||||
if as_json:
|
||||
_print_json([
|
||||
{"id": result.id, "action": result.action, "outcome": result.outcome, "error": result.error}
|
||||
for result in results
|
||||
])
|
||||
elif not results:
|
||||
print("No VMs.")
|
||||
else:
|
||||
for result in results:
|
||||
detail = f": {result.error}" if result.error else ""
|
||||
print(f"{result.id} {result.action}: {result.outcome}{detail}")
|
||||
if any(result.outcome == "interrupted" for result in results):
|
||||
return 130
|
||||
return 1 if any(result.outcome == "failed" for result in results) else 0
|
||||
|
||||
|
||||
def _show_logs(path: Path, tail: int, *, follow: bool) -> None:
|
||||
with path.open("rb") as stream:
|
||||
end = stream.seek(0, os.SEEK_END)
|
||||
position = end
|
||||
remaining = tail
|
||||
# Locate the starting line backwards without loading or retaining the whole log.
|
||||
while position > 0 and remaining:
|
||||
size = min(position, 65536)
|
||||
position -= size
|
||||
stream.seek(position)
|
||||
block = stream.read(size)
|
||||
if position + size == end and block.endswith(b"\n"):
|
||||
block = block[:-1]
|
||||
newlines = block.count(b"\n")
|
||||
if newlines >= remaining:
|
||||
offset = len(block)
|
||||
for _ in range(remaining):
|
||||
offset = block.rfind(b"\n", 0, offset)
|
||||
position += offset + 1
|
||||
break
|
||||
remaining -= newlines
|
||||
stream.seek(position)
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
while stream.tell() < end:
|
||||
block = stream.read(min(65536, end - stream.tell()))
|
||||
if not block:
|
||||
break
|
||||
sys.stdout.write(decoder.decode(block))
|
||||
if not follow:
|
||||
sys.stdout.write(decoder.decode(b"", final=True))
|
||||
return
|
||||
sys.stdout.flush()
|
||||
while True:
|
||||
block = stream.read(65536)
|
||||
if block:
|
||||
sys.stdout.write(decoder.decode(block))
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
if os.fstat(stream.fileno()).st_size < stream.tell():
|
||||
stream.seek(0)
|
||||
decoder.reset()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def _line_count(value: str) -> int:
|
||||
try:
|
||||
count = int(value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("tail must be a nonnegative integer") from error
|
||||
if count < 0:
|
||||
raise argparse.ArgumentTypeError("tail must be a nonnegative integer")
|
||||
return count
|
||||
|
||||
|
||||
def _parse_guest_ip(value: str | None) -> IPv4Address | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
+73
-2
@@ -8,10 +8,37 @@ from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any
|
||||
|
||||
from .errors import StateError
|
||||
from .errors import StateError, ValidationError
|
||||
|
||||
|
||||
STATE_SCHEMA_VERSION = 1
|
||||
STATE_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PortRule:
|
||||
"""One private guest TCP port; HTTP/HTTPS are descriptive service labels."""
|
||||
|
||||
port: int
|
||||
protocol: str = "TCP"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if type(self.port) is not int or not 1 <= self.port <= 65535:
|
||||
raise ValidationError("port must be an integer between 1 and 65535")
|
||||
if not isinstance(self.protocol, str) or self.protocol.upper() not in {"TCP", "HTTP", "HTTPS"}:
|
||||
raise ValidationError("port type must be TCP, HTTP, or HTTPS")
|
||||
object.__setattr__(self, "protocol", self.protocol.upper())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"port": self.port, "protocol": self.protocol}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> "PortRule":
|
||||
if not isinstance(value, dict) or set(value) - {"port", "protocol"}:
|
||||
raise StateError("invalid port rule")
|
||||
try:
|
||||
return cls(port=value["port"], protocol=value.get("protocol", "TCP"))
|
||||
except (KeyError, ValidationError) as error:
|
||||
raise StateError(f"invalid port rule: {error}") from error
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -23,6 +50,8 @@ class VmSpec:
|
||||
guest_ip: IPv4Address | None = None
|
||||
username: str = "root"
|
||||
password: str = field(default="root", repr=False)
|
||||
name: str | None = None
|
||||
ports: tuple[PortRule, ...] = (PortRule(22),)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -48,6 +77,16 @@ class VmRecord:
|
||||
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
|
||||
name: str | None = None
|
||||
ports: tuple[PortRule, ...] | None = None
|
||||
disk_ready: bool = False
|
||||
runtime_owned: bool = False
|
||||
tap_owned: bool = False
|
||||
operation: str | None = None
|
||||
boot_id: str | None = None
|
||||
spawn_pending: bool = False
|
||||
network: str | None = None
|
||||
bridge: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
@@ -67,6 +106,16 @@ class VmRecord:
|
||||
"status": self.status,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"name": self.name,
|
||||
"ports": [port.to_dict() for port in self.ports] if self.ports is not None else None,
|
||||
"disk_ready": self.disk_ready,
|
||||
"runtime_owned": self.runtime_owned,
|
||||
"tap_owned": self.tap_owned,
|
||||
"operation": self.operation,
|
||||
"boot_id": self.boot_id,
|
||||
"spawn_pending": self.spawn_pending,
|
||||
"network": self.network,
|
||||
"bridge": self.bridge,
|
||||
}
|
||||
if self.pid is not None:
|
||||
data["pid"] = self.pid
|
||||
@@ -99,6 +148,16 @@ class VmRecord:
|
||||
|
||||
try:
|
||||
pid_value = value.get("pid")
|
||||
raw_ports = value.get("ports")
|
||||
if raw_ports is not None and not isinstance(raw_ports, list):
|
||||
raise StateError("VM ports must be a list or null for a legacy VM")
|
||||
ports = tuple(PortRule.from_dict(port) for port in raw_ports) if raw_ports is not None else None
|
||||
if ports is not None and len({port.port for port in ports}) != len(ports):
|
||||
raise StateError("VM has duplicate TCP port rules")
|
||||
legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"}
|
||||
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
|
||||
if name in value and type(value[name]) is not bool:
|
||||
raise StateError(f"VM {name} must be a boolean")
|
||||
return cls(
|
||||
id=str(value["id"]),
|
||||
cpu=float(value["cpu"]),
|
||||
@@ -131,6 +190,16 @@ class VmRecord:
|
||||
if value.get("last_error") is not None
|
||||
else None
|
||||
),
|
||||
name=str(value["name"]) if value.get("name") is not None else None,
|
||||
ports=ports,
|
||||
disk_ready=value.get("disk_ready", legacy_ready),
|
||||
runtime_owned=value.get("runtime_owned", bool(value.get("disk"))),
|
||||
tap_owned=value.get("tap_owned", bool(value.get("pid"))),
|
||||
operation=str(value["operation"]) if value.get("operation") is not None else None,
|
||||
boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None,
|
||||
spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"),
|
||||
network=str(value["network"]) if value.get("network") is not None else None,
|
||||
bridge=str(value["bridge"]) if value.get("bridge") is not None else None,
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError(f"invalid VM record: {error}") from error
|
||||
@@ -170,6 +239,8 @@ class State:
|
||||
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()}
|
||||
if any(vm_id != vm.id for vm_id, vm in vms.items()):
|
||||
raise StateError("VM record ID does not match its registry key")
|
||||
next_mac_index = value.get("next_mac_index")
|
||||
if next_mac_index is None:
|
||||
next_mac_index = _next_mac_index(vms.values())
|
||||
|
||||
@@ -23,6 +23,14 @@ class StateError(UvmError):
|
||||
"""Raised when persisted VM state cannot be safely used."""
|
||||
|
||||
|
||||
class NotFoundError(UvmError):
|
||||
"""A requested registry entry does not exist."""
|
||||
|
||||
|
||||
class ConflictError(UvmError):
|
||||
"""An allocation or pending operation prevents the requested action."""
|
||||
|
||||
|
||||
class CommandError(UvmError):
|
||||
"""Raised when a required host command cannot be executed successfully."""
|
||||
|
||||
|
||||
+368
-96
@@ -2,8 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -26,6 +31,50 @@ class _PopenLike(Protocol):
|
||||
class ProcessInfo:
|
||||
pid: int
|
||||
start_time: str
|
||||
boot_id: str | None = None
|
||||
|
||||
|
||||
def _libc_pidfd(name: str, *arguments: int | None) -> int:
|
||||
# Some Linux Python builds omit pidfd bindings even when libc supports them.
|
||||
try:
|
||||
function = getattr(ctypes.CDLL(None, use_errno=True), name)
|
||||
except (AttributeError, OSError) as error:
|
||||
raise OSError(errno.ENOSYS, f"{name} is unavailable") from error
|
||||
function.argtypes = (
|
||||
[ctypes.c_int, ctypes.c_uint]
|
||||
if name == "pidfd_open"
|
||||
else [ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint]
|
||||
)
|
||||
function.restype = ctypes.c_int
|
||||
result = function(*arguments)
|
||||
if result == -1:
|
||||
error_number = ctypes.get_errno()
|
||||
raise OSError(error_number, os.strerror(error_number))
|
||||
return result
|
||||
|
||||
|
||||
def _open_pidfd(pid: int) -> int:
|
||||
native = getattr(os, "pidfd_open", None)
|
||||
if native is not None:
|
||||
return native(pid, 0)
|
||||
return _libc_pidfd("pidfd_open", pid, 0)
|
||||
|
||||
|
||||
def _signal_pidfd(pidfd: int, signal_number: int) -> None:
|
||||
native = getattr(signal, "pidfd_send_signal", None)
|
||||
if native is not None:
|
||||
native(pidfd, signal_number)
|
||||
else:
|
||||
_libc_pidfd("pidfd_send_signal", pidfd, signal_number, None, 0)
|
||||
|
||||
|
||||
def _pidfd_exited(pidfd: int) -> bool:
|
||||
poller = select.poll()
|
||||
poller.register(pidfd, select.POLLIN)
|
||||
events = poller.poll(0)
|
||||
if any(event & (select.POLLERR | select.POLLNVAL) for _, event in events):
|
||||
raise OSError(errno.EIO, "could not observe the Firecracker pidfd")
|
||||
return any(event & (select.POLLIN | select.POLLHUP) for _, event in events)
|
||||
|
||||
|
||||
class FirecrackerProcessManager:
|
||||
@@ -43,103 +92,334 @@ class FirecrackerProcessManager:
|
||||
self._popen = popen
|
||||
self._sleep = sleep
|
||||
self._monotonic = monotonic
|
||||
self._children: dict[int, _PopenLike] = {}
|
||||
|
||||
def start(self, vm: VmRecord) -> ProcessInfo:
|
||||
socket_path = Path(vm.socket)
|
||||
if socket_path.exists():
|
||||
def start(
|
||||
self,
|
||||
vm: VmRecord,
|
||||
*,
|
||||
on_spawn: Callable[[ProcessInfo], None] | None = None,
|
||||
) -> ProcessInfo:
|
||||
"""Publish identity before readiness; never replace an ambiguous process."""
|
||||
|
||||
observation = self.observe(vm)
|
||||
if observation != "gone":
|
||||
raise FirecrackerError(
|
||||
f"refusing to start Firecracker: existing process is {observation}"
|
||||
)
|
||||
boot_id = self._boot_id()
|
||||
if boot_id is None:
|
||||
raise FirecrackerError("could not establish the current host boot identity")
|
||||
|
||||
# Check support before creating a child that we might be unable to stop safely.
|
||||
try:
|
||||
pidfd = _open_pidfd(os.getpid())
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except OSError as error:
|
||||
raise FirecrackerError(
|
||||
f"could not remove stale Firecracker socket {socket_path}: {error}"
|
||||
) from error
|
||||
_signal_pidfd(pidfd, 0)
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
except (OSError, NotImplementedError) as error:
|
||||
raise FirecrackerError(
|
||||
f"safe pidfd support is required to start Firecracker: {error}"
|
||||
) from error
|
||||
|
||||
socket_path = Path(vm.socket)
|
||||
self._remove_socket(socket_path)
|
||||
|
||||
log_path = Path(vm.log)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
process: _PopenLike | None = None
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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,
|
||||
previous_identity = (
|
||||
vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending,
|
||||
)
|
||||
vm.pid = None
|
||||
vm.process_start_time = None
|
||||
vm.boot_id = boot_id
|
||||
vm.operation = "spawning"
|
||||
vm.spawn_pending = True
|
||||
try:
|
||||
process = self._popen(
|
||||
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
# Popen cleans up an exec/fork failure, but an interrupt is uncertain.
|
||||
if not isinstance(error, InterruptedError):
|
||||
(
|
||||
vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending,
|
||||
) = previous_identity
|
||||
raise
|
||||
vm.pid = process.pid
|
||||
self._children[process.pid] = process
|
||||
vm.process_start_time = self.process_start_time(process.pid)
|
||||
if vm.process_start_time is None:
|
||||
raise FirecrackerError(
|
||||
f"could not establish a safe process identity for Firecracker process {process.pid}"
|
||||
)
|
||||
info = ProcessInfo(process.pid, vm.process_start_time, boot_id)
|
||||
if on_spawn is not None:
|
||||
on_spawn(info)
|
||||
else:
|
||||
vm.spawn_pending = False
|
||||
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:
|
||||
return info
|
||||
except BaseException 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)
|
||||
try:
|
||||
self.terminate(vm)
|
||||
except BaseException as cleanup_error:
|
||||
raise FirecrackerError(
|
||||
f"Firecracker startup failed ({type(error).__name__}: {error}); "
|
||||
f"cleanup incomplete for PID {vm.pid}: {cleanup_error}"
|
||||
) from error
|
||||
if isinstance(error, OSError):
|
||||
raise FirecrackerError(f"could not start Firecracker: {error}") from error
|
||||
raise
|
||||
|
||||
def is_alive(self, vm: VmRecord) -> bool:
|
||||
if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None:
|
||||
def adopt_legacy_identity(self, vm: VmRecord) -> bool:
|
||||
"""Validate a v1 process; the caller must lock and persist the adopted boot ID."""
|
||||
|
||||
pid, start_time = vm.pid, vm.process_start_time
|
||||
if (
|
||||
vm.boot_id is not None
|
||||
or type(pid) is not int
|
||||
or not 0 < pid < 2**31
|
||||
or not isinstance(start_time, str)
|
||||
or not start_time.isascii()
|
||||
or not start_time.isdigit()
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
os.kill(vm.pid, 0)
|
||||
except ProcessLookupError:
|
||||
pidfd = _open_pidfd(pid)
|
||||
try:
|
||||
process_stat = self._process_stat(pid)
|
||||
if (
|
||||
process_stat is None
|
||||
or process_stat[1] != start_time
|
||||
or process_stat[0] in {"Z", "X", "x"}
|
||||
):
|
||||
return False
|
||||
|
||||
binary_path = self._settings.firecracker_binary
|
||||
socket_path = self._settings.vm_dir(vm.id) / "firecracker.sock"
|
||||
if (
|
||||
not binary_path.is_absolute()
|
||||
or ".." in binary_path.parts
|
||||
or not vm.id
|
||||
or vm.id in {".", ".."}
|
||||
or Path(vm.id).name != vm.id
|
||||
or vm.socket != str(socket_path)
|
||||
):
|
||||
return False
|
||||
|
||||
# Sticky ancestors are safe only because every existing entry is root-owned.
|
||||
for directory in set(binary_path.parents) | set(socket_path.parents):
|
||||
metadata = directory.lstat()
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_uid != 0
|
||||
or (directory == socket_path.parent and metadata.st_mode & 0o077)
|
||||
or (metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX)
|
||||
):
|
||||
return False
|
||||
binary_stat = binary_path.lstat()
|
||||
socket_stat = socket_path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(binary_stat.st_mode)
|
||||
or binary_stat.st_uid != 0
|
||||
or binary_stat.st_mode & 0o022
|
||||
or not stat.S_ISSOCK(socket_stat.st_mode)
|
||||
or socket_stat.st_uid != 0
|
||||
):
|
||||
return False
|
||||
|
||||
proc_path = Path(f"/proc/{pid}")
|
||||
executable = proc_path / "exe"
|
||||
if executable.readlink() != binary_path or not executable.samefile(binary_path):
|
||||
return False
|
||||
expected_cmdline = b"\0".join(
|
||||
(os.fsencode(binary_path), b"--api-sock", os.fsencode(socket_path), b"")
|
||||
)
|
||||
if (proc_path / "cmdline").read_bytes() != expected_cmdline:
|
||||
return False
|
||||
boot_id = self._boot_id()
|
||||
if boot_id is None or _pidfd_exited(pidfd):
|
||||
return False
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
except (OSError, ValueError, NotImplementedError):
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
return self.process_start_time(vm.pid) == vm.process_start_time
|
||||
if (vm.pid, vm.process_start_time, vm.boot_id) != (pid, start_time, None):
|
||||
return False
|
||||
vm.boot_id = boot_id
|
||||
return True
|
||||
|
||||
def terminate(self, vm: VmRecord) -> None:
|
||||
def observe(self, vm: VmRecord) -> str:
|
||||
"""Distinguish proven exit from missing or unreadable process identity."""
|
||||
|
||||
if vm.pid is not None and vm.pid <= 0:
|
||||
return "unknown"
|
||||
try:
|
||||
self._reap_child(vm.pid)
|
||||
except OSError:
|
||||
return "unknown"
|
||||
boot_id = self._boot_id()
|
||||
if vm.boot_id and boot_id and vm.boot_id != boot_id:
|
||||
return "gone"
|
||||
if vm.pid is None:
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
if vm.pid <= 0:
|
||||
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
|
||||
return "unknown"
|
||||
try:
|
||||
Path(vm.socket).lstat()
|
||||
except FileNotFoundError:
|
||||
return "gone"
|
||||
except OSError:
|
||||
return "unknown"
|
||||
# An unowned socket may belong to a child whose identity was never saved.
|
||||
return "unknown"
|
||||
|
||||
stat = self._process_stat(vm.pid)
|
||||
if stat is None:
|
||||
return "unknown" if self._pid_exists(vm.pid) else "gone"
|
||||
state, start_time = stat
|
||||
has_start_time = (
|
||||
isinstance(vm.process_start_time, str)
|
||||
and vm.process_start_time.isascii()
|
||||
and vm.process_start_time.isdigit()
|
||||
)
|
||||
if has_start_time and vm.process_start_time != start_time:
|
||||
return "gone"
|
||||
if state in {"Z", "X", "x"}:
|
||||
# A zombie thread-group leader can still have live worker threads.
|
||||
try:
|
||||
pidfd = _open_pidfd(vm.pid)
|
||||
try:
|
||||
return "gone" if _pidfd_exited(pidfd) else "unknown"
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
except ProcessLookupError:
|
||||
return "gone"
|
||||
except (OSError, NotImplementedError):
|
||||
return "unknown"
|
||||
if not has_start_time or not vm.boot_id or not boot_id:
|
||||
return "unknown"
|
||||
return "alive"
|
||||
|
||||
def is_alive(self, vm: VmRecord) -> bool:
|
||||
"""Conservative compatibility query: unknown processes may still be alive."""
|
||||
|
||||
return self.observe(vm) != "gone"
|
||||
|
||||
def terminate(self, vm: VmRecord, *, timeout: float | None = None) -> None:
|
||||
"""Wait for exit before unlinking; timeout controls the SIGTERM grace period."""
|
||||
|
||||
if vm.pid is not None and 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
|
||||
grace_timeout = self._settings.terminate_timeout_s if timeout is None else timeout
|
||||
if not math.isfinite(grace_timeout) or grace_timeout < 0:
|
||||
raise FirecrackerError("Firecracker termination timeout must be finite and non-negative")
|
||||
observation = self.observe(vm)
|
||||
if observation == "unknown":
|
||||
raise FirecrackerError(
|
||||
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
|
||||
f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous"
|
||||
)
|
||||
if not self.is_alive(vm):
|
||||
if observation == "gone":
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(vm.pid, signal.SIGTERM)
|
||||
pidfd = _open_pidfd(vm.pid)
|
||||
except ProcessLookupError:
|
||||
self._reap_child(vm.pid)
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
except OSError as error:
|
||||
raise FirecrackerError(f"could not stop Firecracker process {vm.pid}: {error}") from error
|
||||
except (OSError, NotImplementedError) as error:
|
||||
raise FirecrackerError(
|
||||
f"could not safely open a pidfd for 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))
|
||||
try:
|
||||
if not _pidfd_exited(pidfd):
|
||||
# Open the handle BEFORE rechecking identity; never signal by bare PID.
|
||||
observation = self.observe(vm)
|
||||
if observation == "unknown":
|
||||
raise FirecrackerError(
|
||||
f"refusing to stop Firecracker PID {vm.pid}: process identity became ambiguous"
|
||||
)
|
||||
if observation == "alive":
|
||||
try:
|
||||
_signal_pidfd(pidfd, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if not self._wait_for_exit(pidfd, grace_timeout):
|
||||
try:
|
||||
_signal_pidfd(pidfd, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
# Even an immediate forced stop needs time to confirm kernel exit.
|
||||
if not self._wait_for_exit(pidfd, max(1.0, grace_timeout)):
|
||||
raise FirecrackerError(
|
||||
f"could not confirm exit of Firecracker process {vm.pid} after SIGKILL; "
|
||||
"process identity and socket retained"
|
||||
)
|
||||
self._reap_child(vm.pid)
|
||||
self._remove_socket(Path(vm.socket))
|
||||
except (OSError, NotImplementedError) as error:
|
||||
raise FirecrackerError(
|
||||
f"could not safely stop Firecracker process {vm.pid}: {error}"
|
||||
) from error
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
|
||||
def process_start_time(self, pid: int) -> str | None:
|
||||
"""Read Linux proc start time so a reused PID is not mistaken for a VM."""
|
||||
|
||||
stat = self._process_stat(pid)
|
||||
return stat[1] if stat is not None else None
|
||||
|
||||
def host_boot_id(self) -> str | None:
|
||||
"""Return the current host boot identity, or None when it is unreadable."""
|
||||
|
||||
return self._boot_id()
|
||||
|
||||
@staticmethod
|
||||
def _boot_id() -> str | None:
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip() or None
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _process_stat(pid: int) -> tuple[str, str] | None:
|
||||
if pid <= 0:
|
||||
return None
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace")
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
opening_parenthesis = stat.find("(")
|
||||
closing_parenthesis = stat.rfind(")")
|
||||
if closing_parenthesis < 0:
|
||||
if (
|
||||
opening_parenthesis < 0
|
||||
or closing_parenthesis < opening_parenthesis
|
||||
or stat[:opening_parenthesis].strip() != str(pid)
|
||||
):
|
||||
return None
|
||||
fields = stat[closing_parenthesis + 2 :].split()
|
||||
try:
|
||||
return fields[19]
|
||||
except IndexError:
|
||||
fields = stat[closing_parenthesis + 1 :].split()
|
||||
if (
|
||||
len(fields) < 20
|
||||
or fields[0] not in {"R", "S", "D", "Z", "T", "t", "X", "x", "K", "W", "P", "I"}
|
||||
or not fields[19].isascii()
|
||||
or not fields[19].isdigit()
|
||||
):
|
||||
return None
|
||||
return fields[0], fields[19]
|
||||
|
||||
def _wait_for_socket(
|
||||
self,
|
||||
@@ -148,13 +428,32 @@ class FirecrackerProcessManager:
|
||||
log_path: Path,
|
||||
) -> None:
|
||||
deadline = self._monotonic() + self._settings.api_socket_timeout_s
|
||||
while self._monotonic() < deadline:
|
||||
if socket_path.exists():
|
||||
return
|
||||
while True:
|
||||
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")
|
||||
if socket_path.is_socket():
|
||||
return
|
||||
remaining = deadline - self._monotonic()
|
||||
if remaining <= 0:
|
||||
raise FirecrackerError("timed out waiting for Firecracker API socket")
|
||||
self._sleep(min(0.05, remaining))
|
||||
|
||||
def _wait_for_exit(self, pidfd: int, timeout: float) -> bool:
|
||||
deadline = self._monotonic() + timeout
|
||||
while True:
|
||||
if _pidfd_exited(pidfd):
|
||||
return True
|
||||
remaining = deadline - self._monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
self._sleep(min(0.05, remaining))
|
||||
|
||||
def _reap_child(self, pid: int | None) -> None:
|
||||
if pid is None:
|
||||
return
|
||||
process = self._children.get(pid)
|
||||
if process is not None and process.poll() is not None:
|
||||
self._children.pop(pid, None)
|
||||
|
||||
@staticmethod
|
||||
def _remove_socket(socket_path: Path) -> None:
|
||||
@@ -162,9 +461,10 @@ class FirecrackerProcessManager:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
# A stale socket is cleaned on the next launch; never hide a successful stop.
|
||||
pass
|
||||
except OSError as error:
|
||||
raise FirecrackerError(
|
||||
f"could not remove Firecracker socket {socket_path}: {error}"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def _pid_exists(pid: int) -> bool:
|
||||
@@ -172,34 +472,6 @@ class FirecrackerProcessManager:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
except (OSError, OverflowError):
|
||||
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
|
||||
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
"""Private IPv4 service filtering, not host-port forwarding or publishing.
|
||||
|
||||
The lifecycle owner must serialize mutations, keep a live VM's identity immutable,
|
||||
install the policy before starting its guest, and quarantine before teardown.
|
||||
Only bridge/TAP traffic is covered; do not offload it around the bridge hooks.
|
||||
Before making a TAP usable, attach it to an STP-disabled software bridge and set
|
||||
the TAP's bridge_slave group_fwd_mask bit 0x4000. Otherwise Linux can deliver LLDP
|
||||
(and STP) before *any* bridge hook and learn spoofed MACs. Network/lifecycle owns
|
||||
these link settings; this manager only verifies them when the TAP exists.
|
||||
No br_netfilter sysctls, NAT, persistent applied flags, or live conntrack flushes
|
||||
are used. Native bridge conntrack (Linux >= 5.3) and nft JSON support are required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from .config import Settings
|
||||
from .domain import PortRule, VmRecord
|
||||
from .errors import UvmError, ValidationError
|
||||
from .system import CommandResult, CommandRunner
|
||||
|
||||
|
||||
_COMMAND_TIMEOUT_S = 5.0
|
||||
_PRIVATE_NETWORKS = tuple(IPv4Network(value) for value in (
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
))
|
||||
|
||||
|
||||
def _match(left: dict[str, Any], right: Any, op: str = "==") -> dict[str, Any]:
|
||||
return {"match": {"op": op, "left": left, "right": right}}
|
||||
|
||||
|
||||
def _payload(protocol: str, field: str) -> dict[str, Any]:
|
||||
return {"payload": {"protocol": protocol, "field": field}}
|
||||
|
||||
|
||||
def _ct(key: str, direction: str | None = None) -> dict[str, Any]:
|
||||
# nft 1.0.9 parser_json.c/ct.c use the literal keys "ip saddr"/"ip daddr".
|
||||
# Its CT parser ignores the "family" property advertised by the JSON manual.
|
||||
return {"ct": {"key": key, **({"dir": direction} if direction else {})}}
|
||||
|
||||
|
||||
class FirewallManager:
|
||||
"""Own one table per (resolved settings.base, VM id) in the current netns.
|
||||
|
||||
``ports=None`` is legacy/unmanaged, not an empty allowlist. Normal apply
|
||||
refuses it; quarantine works even without a port policy or valid IP/MAC.
|
||||
Errors leave existing rules in place (or the committed replacement on a
|
||||
readback/timeout failure). Callers must not start/release a VM on an error.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner
|
||||
|
||||
def apply(self, vm: VmRecord, *, quarantine: bool = False) -> None:
|
||||
"""Atomically install/replace only this VM's owned table; retain CT state."""
|
||||
expected = self._policy(vm, quarantine=quarantine)
|
||||
if not self._bridge_paths_safe(vm):
|
||||
raise UvmError(
|
||||
"firewall needs an STP-disabled software bridge and TAP bridge_slave "
|
||||
"group_fwd_mask bit 0x4000 (LLDP forwarding) before enforcement"
|
||||
)
|
||||
table = expected[0]["table"]
|
||||
current = self._read_table(table["name"])
|
||||
commands = []
|
||||
if current is not None:
|
||||
commands.append(self._delete_owned(current, table))
|
||||
# Exclusive create plus deletion by handle also protects against a table
|
||||
# being replaced by a foreign owner between inspection and commit.
|
||||
commands.append({"create": expected[0]})
|
||||
commands.extend({"add": item} for item in expected[1:])
|
||||
self._transaction(commands)
|
||||
installed = self._read_table(table["name"])
|
||||
if installed is None or _normalize(installed) != _normalize(expected):
|
||||
raise UvmError("firewall readback differs from the requested policy; keep VM isolated")
|
||||
|
||||
def remove(self, vm: VmRecord) -> None:
|
||||
"""Remove an owned table only after the TAP is gone; absence is harmless."""
|
||||
self._require_offline(vm)
|
||||
table = self._table_identity(vm)
|
||||
current = self._read_table(table["name"])
|
||||
if current is not None:
|
||||
self._transaction([self._delete_owned(current, table)])
|
||||
|
||||
def status(self, vm: VmRecord) -> str:
|
||||
"""Inspect actual hooks, ordered rules, identity and complete port set.
|
||||
|
||||
Missing legacy tables are unmanaged. Foreign or altered tables are
|
||||
drift; failed inspection/invalid metadata is unknown. A legacy VM can
|
||||
report quarantined. This is a ruleset snapshot, not a reachability test.
|
||||
"""
|
||||
try:
|
||||
table = self._table_identity(vm)
|
||||
current = self._read_table(table["name"])
|
||||
if current is None:
|
||||
return "unmanaged" if getattr(vm, "ports", None) is None else "missing"
|
||||
if _normalize(current) == _normalize(self._policy(vm, quarantine=True)):
|
||||
return "quarantined" if self._bridge_paths_safe(vm) else "drift"
|
||||
if getattr(vm, "ports", None) is not None:
|
||||
if _normalize(current) == _normalize(self._policy(vm, quarantine=False)):
|
||||
return "enforced" if self._bridge_paths_safe(vm) else "drift"
|
||||
return "drift"
|
||||
except (UvmError, ValueError, TypeError, KeyError):
|
||||
return "unknown"
|
||||
|
||||
def check_available(self) -> None:
|
||||
"""Check cleanup tooling, kernel and a full non-committing nft policy."""
|
||||
self._run(("conntrack", "--version"))
|
||||
release = self._run(("uname", "-r")).stdout.strip()
|
||||
version = re.match(r"^(\d+)\.(\d+)", release)
|
||||
if version is None or tuple(map(int, version.groups())) < (5, 3):
|
||||
raise UvmError("private ports require Linux >= 5.3 with native bridge conntrack")
|
||||
probe = SimpleNamespace(
|
||||
id=f"probe-{uuid.uuid4().hex}", tap=f"uvm{uuid.uuid4().hex[:11]}",
|
||||
guest_ip="10.0.0.2", gateway="10.0.0.1",
|
||||
network="10.0.0.0/24", mac="02:00:00:00:00:02",
|
||||
ports=(PortRule(443),),
|
||||
)
|
||||
policy = self._policy(probe, quarantine=False) # type: ignore[arg-type]
|
||||
commands = [{"create": policy[0]}, *({"add": item} for item in policy[1:])]
|
||||
try:
|
||||
self._transaction(commands, check_only=True)
|
||||
except UvmError as error:
|
||||
raise UvmError(f"nft bridge/conntrack policy is unavailable: {error}") from error
|
||||
|
||||
def cleanup_connections(self, vm: VmRecord) -> None:
|
||||
"""Offline, IP-scoped CT deletion before address reuse, never for close.
|
||||
|
||||
All four tuple addresses are checked, including NAT reply tuples. A
|
||||
failed deletion is tolerated only if a successful list proves absence.
|
||||
The caller must retain the address reservation until this succeeds.
|
||||
"""
|
||||
self._require_offline(vm)
|
||||
address = self._guest_ip(vm)
|
||||
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
|
||||
selector = ("--family", "ipv4", field, address)
|
||||
self._run(("conntrack", "--delete", *selector), check=False)
|
||||
remaining = self._run(("conntrack", "--list", *selector))
|
||||
if remaining.stdout.strip():
|
||||
raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP")
|
||||
|
||||
def _run(self, command: tuple[str, ...], **kwargs: Any) -> CommandResult:
|
||||
return self._runner.run(
|
||||
command, capture=True, timeout=_COMMAND_TIMEOUT_S, **kwargs,
|
||||
)
|
||||
|
||||
def _transaction(self, commands: list[dict[str, Any]], *, check_only: bool = False) -> None:
|
||||
args = ("nft", "--json", *(("--check",) if check_only else ()), "-f", "-")
|
||||
self._run(args, input_text=json.dumps({"nftables": commands}))
|
||||
|
||||
def _table_identity(self, vm: VmRecord) -> dict[str, Any]:
|
||||
if not isinstance(vm.id, str) or not vm.id:
|
||||
raise ValidationError("firewall requires a nonempty VM ID")
|
||||
identity = json.dumps([str(self._settings.base.resolve()), vm.id]).encode("utf-8")
|
||||
digest = hashlib.sha256(identity).hexdigest()
|
||||
return {"family": "bridge", "name": f"uvm_fw_{digest[:24]}",
|
||||
"comment": f"uvm-private-ports:v1:{digest}"}
|
||||
|
||||
def _tap(self, vm: VmRecord) -> str:
|
||||
if not isinstance(vm.tap, str) or re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}", vm.tap) is None:
|
||||
raise ValidationError("firewall requires an exact, safe TAP name (1-15 characters)")
|
||||
if vm.tap in {self._settings.bridge, getattr(vm, "bridge", None)}:
|
||||
raise ValidationError("firewall TAP must not be the bridge itself")
|
||||
return vm.tap
|
||||
|
||||
def _guest_ip(self, vm: VmRecord) -> str:
|
||||
try:
|
||||
address = IPv4Address(vm.guest_ip)
|
||||
network = IPv4Network(getattr(vm, "network", None) or self._settings.network)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise ValidationError("firewall requires a valid guest IPv4 address/network") from error
|
||||
gateway = str(getattr(vm, "gateway", None) or self._settings.gateway)
|
||||
if (not any(address in private for private in _PRIVATE_NETWORKS)
|
||||
or address not in network
|
||||
or address in {network.network_address, network.broadcast_address}
|
||||
or str(address) == gateway):
|
||||
raise ValidationError("firewall requires a usable private guest IP inside its network")
|
||||
return str(address)
|
||||
|
||||
def _links(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
links = json.loads(self._run(("ip", "-j", "-d", "link", "show")).stdout)
|
||||
if not isinstance(links, list) or any(
|
||||
not isinstance(link, dict) or not isinstance(link.get("ifname"), str)
|
||||
for link in links
|
||||
):
|
||||
raise ValueError("invalid link listing")
|
||||
except (ValueError, TypeError) as error:
|
||||
raise UvmError("cannot inspect network interfaces; retaining firewall/conntrack state") from error
|
||||
return links
|
||||
|
||||
def _bridge_paths_safe(self, vm: VmRecord) -> bool:
|
||||
links = self._links()
|
||||
name = self._tap(vm)
|
||||
taps = [link for link in links if link["ifname"] == name]
|
||||
if not taps:
|
||||
return True # Policy may precede TAP creation; main must prepare it before bringing it up.
|
||||
if len(taps) != 1:
|
||||
return False
|
||||
tap = taps[0]
|
||||
bridges = [link for link in links if tap.get("master") in (link["ifname"], link.get("ifindex"))]
|
||||
if len(bridges) != 1:
|
||||
return False
|
||||
try:
|
||||
port = tap["linkinfo"]
|
||||
bridge = bridges[0]["linkinfo"]
|
||||
mask = int(str(port["info_slave_data"]["group_fwd_mask"]), 0)
|
||||
return (port["info_slave_kind"] == "bridge" and bridge["info_kind"] == "bridge"
|
||||
and bridge["info_data"]["stp_state"] == 0
|
||||
and 0 <= mask <= 65535 and bool(mask & 0x4000))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
def _require_offline(self, vm: VmRecord) -> None:
|
||||
tap = self._tap(vm)
|
||||
if any(link["ifname"] == tap for link in self._links()):
|
||||
raise UvmError(f"TAP {tap} still exists; quarantine it and remove the TAP first")
|
||||
|
||||
def _read_table(self, name: str) -> list[dict[str, Any]] | None:
|
||||
# A successful table inventory, not a localized 'not found' error,
|
||||
# distinguishes missing policy from missing privileges/tools.
|
||||
def objects(command: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(self._run(command).stdout)["nftables"]
|
||||
if not isinstance(data, list) or any(
|
||||
not isinstance(item, dict) or len(item) != 1
|
||||
or not isinstance(next(iter(item.values())), dict) for item in data
|
||||
):
|
||||
raise ValueError("invalid nft object list")
|
||||
return [item for item in data if "metainfo" not in item]
|
||||
except (ValueError, KeyError, TypeError) as error:
|
||||
raise UvmError("could not inspect nft bridge tables") from error
|
||||
|
||||
tables = objects(("nft", "--json", "list", "tables", "bridge"))
|
||||
if any("table" not in item or item["table"].get("family") != "bridge"
|
||||
or not isinstance(item["table"].get("name"), str) for item in tables):
|
||||
raise UvmError("invalid nft bridge table inventory")
|
||||
matches = [item for item in tables if item["table"].get("name") == name]
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) != 1:
|
||||
raise UvmError("ambiguous nft bridge table inventory")
|
||||
data = objects(("nft", "--json", "--numeric-protocol", "list", "table", "bridge", name))
|
||||
headers = [item["table"] for item in data if "table" in item]
|
||||
if len(headers) != 1 or headers[0].get("name") != name or headers[0].get("family") != "bridge":
|
||||
raise UvmError("invalid nft bridge table snapshot")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _delete_owned(current: list[dict[str, Any]], table: dict[str, Any]) -> dict[str, Any]:
|
||||
header = next(item["table"] for item in current if "table" in item)
|
||||
if any(header.get(key) != value for key, value in table.items()):
|
||||
raise UvmError(f"refusing to touch foreign nft table {table['name']}")
|
||||
handle = header.get("handle")
|
||||
if type(handle) is not int or handle <= 0:
|
||||
raise UvmError("owned nft table has no valid handle; refusing unsafe deletion")
|
||||
return {"delete": {"table": {"family": "bridge", "handle": handle}}}
|
||||
|
||||
def _policy(self, vm: VmRecord, *, quarantine: bool) -> list[dict[str, Any]]:
|
||||
ports = getattr(vm, "ports", None)
|
||||
if ports is None and not quarantine:
|
||||
raise ValidationError("legacy VM has no managed port policy; only quarantine is allowed")
|
||||
table = self._table_identity(vm)
|
||||
tap = self._tap(vm)
|
||||
scope = {"family": "bridge", "table": table["name"]}
|
||||
policy: list[dict[str, Any]] = [{"table": table}]
|
||||
|
||||
def chain(name: str, hook: str | None = None, priority: int = 0) -> None:
|
||||
definition = {**scope, "name": name}
|
||||
if hook is not None:
|
||||
definition.update(type="filter", hook=hook, prio=priority, policy="accept")
|
||||
policy.append({"chain": definition})
|
||||
|
||||
def rule(name: str, *expressions: dict[str, Any]) -> None:
|
||||
policy.append({"rule": {**scope, "chain": name, "expr": list(expressions)}})
|
||||
|
||||
for hook in ("prerouting", "input", "forward", "output"):
|
||||
chain(hook, hook, -310 if hook == "prerouting" else 0)
|
||||
if not quarantine:
|
||||
for name in ("identity", "from_vm", "to_vm"):
|
||||
chain(name)
|
||||
for hook, interface, target in (
|
||||
("prerouting", "iifname", "identity"),
|
||||
("input", "iifname", "from_vm"),
|
||||
("forward", "iifname", "from_vm"),
|
||||
("forward", "oifname", "to_vm"),
|
||||
("output", "oifname", "to_vm"),
|
||||
):
|
||||
rule(hook, _match({"meta": {"key": interface}}, tap),
|
||||
{"drop": None} if quarantine else {"jump": {"target": target}})
|
||||
if quarantine:
|
||||
return policy
|
||||
|
||||
address = self._guest_ip(vm)
|
||||
mac = vm.mac.lower() if isinstance(vm.mac, str) else ""
|
||||
if (re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", mac) is None
|
||||
or int(mac[:2], 16) & 1 or mac == "00:00:00:00:00:00"):
|
||||
raise ValidationError("firewall requires a nonzero unicast guest MAC")
|
||||
if not isinstance(ports, tuple):
|
||||
raise ValidationError("managed ports must be a tuple of TCP/HTTP/HTTPS port rules")
|
||||
allowed = set()
|
||||
for port_rule in ports:
|
||||
port = getattr(port_rule, "port", None)
|
||||
if (type(port) is not int or not 1 <= port <= 65535
|
||||
or getattr(port_rule, "protocol", None) not in ("TCP", "HTTP", "HTTPS")):
|
||||
raise ValidationError("private ports must be TCP/HTTP/HTTPS integers from 1 to 65535")
|
||||
allowed.add(port)
|
||||
# json_parse_set_expr() in nft 1.0.9 rejects [], even for an empty named
|
||||
# set. Omit elem, as set_print_json() does when listing an empty set.
|
||||
policy.insert(1, {"set": {**scope, "name": "tcp_ports", "type": "inet_service",
|
||||
"flags": ["constant"],
|
||||
**({"elem": sorted(allowed)} if allowed else {})}})
|
||||
|
||||
drop, success = {"drop": None}, {"return": None}
|
||||
ipv4 = _match(_payload("ether", "type"), "ip")
|
||||
arp = _match(_payload("ether", "type"), "arp")
|
||||
arp_header = [arp, _match(_payload("arp", "htype"), 1),
|
||||
_match(_payload("arp", "ptype"), "ip"),
|
||||
_match(_payload("arp", "hlen"), 6), _match(_payload("arp", "plen"), 4)]
|
||||
# Before conntrack (-200) and FDB learning: no MAC/IP/ARP spoofing,
|
||||
# IPv6, VLAN encapsulation or other EtherTypes from a managed port.
|
||||
rule("identity", _match(_payload("ether", "saddr"), mac, "!="), drop)
|
||||
rule("identity", ipv4, _match(_payload("ip", "saddr"), address), success)
|
||||
rule("identity", *arp_header,
|
||||
_match(_payload("arp", "operation"), {"set": ["request", "reply"]}),
|
||||
_match(_payload("arp", "saddr ether"), mac),
|
||||
_match(_payload("arp", "saddr ip"), address), success)
|
||||
# RFC 5227 address probes may use zero, but only probe this VM's IP.
|
||||
rule("identity", *arp_header, _match(_payload("arp", "operation"), "request"),
|
||||
_match(_payload("arp", "saddr ether"), mac),
|
||||
_match(_payload("arp", "saddr ip"), "0.0.0.0"),
|
||||
_match(_payload("arp", "daddr ip"), address), success)
|
||||
rule("identity", drop)
|
||||
|
||||
# Some link-local bridge frames take input without prerouting. Recheck
|
||||
# identity there too, before its local-finish callback can learn a MAC.
|
||||
rule("from_vm", {"jump": {"target": "identity"}})
|
||||
rule("from_vm", arp, success)
|
||||
rule("to_vm", *arp_header, _match(_payload("arp", "daddr ip"), address),
|
||||
_match(_payload("arp", "operation"), "request"), success)
|
||||
rule("to_vm", *arp_header, _match(_payload("arp", "daddr ip"), address),
|
||||
_match(_payload("arp", "daddr ether"), mac),
|
||||
_match(_payload("arp", "operation"), "reply"), success)
|
||||
|
||||
for name, outbound in (("from_vm", True), ("to_vm", False)):
|
||||
rule(name, _match(_payload("ether", "type"), "ip", "!="), drop)
|
||||
if not outbound:
|
||||
rule(name, ipv4, _match(_payload("ip", "daddr"), address, "!="), drop)
|
||||
rule(name, _match(_payload("ether", "daddr"), mac, "!="), drop)
|
||||
# Loose TCP tracking must not reclassify a server's midstream data
|
||||
# as a newly guest-initiated connection after tracking loss.
|
||||
rule(name, ipv4, _match(_payload("ip", "protocol"), "tcp"),
|
||||
_match(_ct("state"), "new", "in"),
|
||||
_match({"&": [_payload("tcp", "flags"), 23]}, 2, "!="), drop)
|
||||
|
||||
for client in (True, False):
|
||||
original = outbound == client
|
||||
flow = [_match(_ct("direction"), "original" if original else "reply"),
|
||||
_match(_ct("ip saddr" if client else "ip daddr", "original"), address)]
|
||||
states = ["new", "established"] if original else ["established"]
|
||||
state = _match(_ct("state"), states[0] if len(states) == 1 else states, "in")
|
||||
for protocol in (("tcp", "udp", "icmp") if client else ("tcp", "icmp")):
|
||||
parent = [*flow, _match(_ct("protocol"), protocol)]
|
||||
if not client and protocol == "tcp":
|
||||
parent.append(_match(_ct("proto-dst", "original"), "@tcp_ports"))
|
||||
packet = [ipv4, _match(_payload("ip", "protocol"), protocol)]
|
||||
if protocol == "icmp":
|
||||
packet.append(_match(_payload("icmp", "type"),
|
||||
"echo-request" if original else "echo-reply"))
|
||||
rule(name, *packet, *parent, state, success)
|
||||
# Related is restricted to essential ICMP errors and the
|
||||
# same authorized parent tuple, never TCP/UDP helper flows.
|
||||
rule(name, ipv4, _match(_payload("ip", "protocol"), "icmp"),
|
||||
_match(_payload("icmp", "type"), {"set": [
|
||||
"destination-unreachable", "time-exceeded", "parameter-problem",
|
||||
]}), *parent, _match(_ct("state"), "related", "in"), success)
|
||||
rule(name, drop)
|
||||
return policy
|
||||
|
||||
|
||||
def _normalize(objects: list[dict[str, Any]]) -> str:
|
||||
"""Canonical nft JSON, retaining every effective object, rule and verdict.
|
||||
|
||||
Ignore only handles, empty set presentation and nft's redundant protocol
|
||||
dependencies. Rule order is significant; pure conjunctions and sets are not.
|
||||
Unknown expressions/properties remain different rather than being ignored.
|
||||
"""
|
||||
def canonical(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [canonical(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
value = {key: canonical(item) for key, item in value.items()}
|
||||
if (set(value) == {"|"} and isinstance(value["|"], list)
|
||||
and all(type(item) is int for item in value["|"])):
|
||||
result = 0
|
||||
for item in value["|"]:
|
||||
result |= item
|
||||
return result
|
||||
if set(value) == {"set"} and isinstance(value["set"], list):
|
||||
value["set"] = sorted(value["set"], key=lambda item: json.dumps(item, sort_keys=True))
|
||||
return value
|
||||
|
||||
def bitmask(value: Any, names: dict[str, int]) -> Any:
|
||||
if isinstance(value, str):
|
||||
return names.get(value, value)
|
||||
if isinstance(value, dict) and set(value) == {"|"}:
|
||||
value = value["|"]
|
||||
if isinstance(value, list):
|
||||
bits = [bitmask(item, names) for item in value]
|
||||
if all(type(item) is int for item in bits):
|
||||
result = 0
|
||||
for item in bits:
|
||||
result |= item
|
||||
return result
|
||||
return value
|
||||
|
||||
definitions, rules = [], {}
|
||||
for item in objects:
|
||||
kind, raw = next(iter(item.items()))
|
||||
if kind == "metainfo":
|
||||
continue
|
||||
obj = {key: canonical(value) for key, value in raw.items() if key != "handle"}
|
||||
if kind == "set":
|
||||
elements = obj.get("elem", [])
|
||||
if not isinstance(elements, list):
|
||||
elements = [elements]
|
||||
obj["elem"] = sorted(elements, key=lambda value: json.dumps(value, sort_keys=True))
|
||||
if obj.get("flags") == []:
|
||||
del obj["flags"]
|
||||
elif isinstance(obj.get("flags"), str):
|
||||
obj["flags"] = [obj["flags"]]
|
||||
if kind != "rule":
|
||||
definitions.append({kind: obj})
|
||||
continue
|
||||
|
||||
expressions = obj.get("expr", [])
|
||||
if not isinstance(expressions, list) or not isinstance(obj.get("chain"), str):
|
||||
raise UvmError("invalid nft rule snapshot")
|
||||
tcp_flags = {"fin": 1, "syn": 2, "rst": 4, "psh": 8, "ack": 16,
|
||||
"urg": 32, "ecn": 64, "cwr": 128}
|
||||
for expression in expressions:
|
||||
if not isinstance(expression, dict) or not isinstance(expression.get("match", {}), dict):
|
||||
continue
|
||||
match = expression.get("match", {})
|
||||
left = match.get("left")
|
||||
if match and not {"left", "right", "op"}.issubset(match):
|
||||
raise UvmError("invalid nft match snapshot")
|
||||
right = match.get("right")
|
||||
if left in (_payload("ip", "protocol"), _ct("protocol")) and type(right) is int:
|
||||
match["right"] = {1: "icmp", 6: "tcp", 17: "udp"}.get(right, right)
|
||||
if match.get("op") == "in" and (
|
||||
(isinstance(right, str) and right.startswith("@"))
|
||||
or (isinstance(right, dict) and set(right) == {"set"})
|
||||
):
|
||||
match["op"] = "=="
|
||||
if left == _ct("state"):
|
||||
match["right"] = bitmask(match["right"], {
|
||||
"invalid": 1, "established": 2, "related": 4, "new": 8, "untracked": 64,
|
||||
})
|
||||
if (isinstance(left, dict) and isinstance(left.get("&"), list)
|
||||
and len(left["&"]) == 2 and left["&"][0] == _payload("tcp", "flags")):
|
||||
left["&"][1] = bitmask(left["&"][1], tcp_flags)
|
||||
match["right"] = bitmask(match["right"], tcp_flags)
|
||||
|
||||
# nft's replayable JSON omits dependencies implied by named payloads.
|
||||
encoded = json.dumps(expressions, sort_keys=True)
|
||||
has_tcp = '"protocol": "tcp"' in encoded
|
||||
has_icmp = '"protocol": "icmp"' in encoded
|
||||
expressions = [expr for expr in expressions if not (
|
||||
(has_tcp and expr == _match(_payload("ip", "protocol"), "tcp"))
|
||||
or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp"))
|
||||
)]
|
||||
encoded = json.dumps(expressions, sort_keys=True)
|
||||
expressions = [expr for expr in expressions if not (
|
||||
('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp"))
|
||||
or (('"protocol": "ip"' in encoded or has_icmp)
|
||||
and expr == _match(_payload("ether", "type"), "ip"))
|
||||
)]
|
||||
count = len(expressions)
|
||||
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
|
||||
count -= 1
|
||||
if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]):
|
||||
expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
|
||||
obj["expr"] = expressions
|
||||
rules.setdefault(obj.get("chain", ""), []).append(obj)
|
||||
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
|
||||
return json.dumps([definitions, rules], sort_keys=True, separators=(",", ":"))
|
||||
+14
-10
@@ -58,23 +58,14 @@ class ImageStore:
|
||||
self._runner = runner or CommandRunner()
|
||||
|
||||
def installed_assets(self) -> GuestAssets:
|
||||
kernel = self._settings.kernel_image
|
||||
kernel = self.installed_kernel()
|
||||
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,
|
||||
@@ -84,6 +75,19 @@ class ImageStore:
|
||||
)
|
||||
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
||||
|
||||
def installed_kernel(self) -> Path:
|
||||
"""A retained private disk does not depend on the original rootfs template."""
|
||||
kernel = self._settings.kernel_image
|
||||
if not kernel.is_file():
|
||||
raise UvmError("guest kernel missing. Run: sudo uvm install")
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
checksum = self._settings.kernel_sha256
|
||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||
checksum = checksum or manifest.checksums.get("kernel")
|
||||
verify_file(kernel, checksum, "guest kernel", "UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads)
|
||||
return kernel
|
||||
|
||||
def create_vm_disk(self, source: Path, destination: Path) -> Path:
|
||||
"""Copy the template before Firecracker opens it read-write for a VM."""
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ class Installer:
|
||||
"jq",
|
||||
"iproute2",
|
||||
"iptables",
|
||||
"nftables",
|
||||
"conntrack",
|
||||
"e2fsprogs",
|
||||
"util-linux",
|
||||
"ca-certificates",
|
||||
|
||||
+426
-203
@@ -1,43 +1,49 @@
|
||||
"""Ordered VM lifecycle operations and compensation for partial failures."""
|
||||
"""Serialized lifecycle and private-port policy changes with durable intent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import VmRecord, VmSpec, new_vm_id
|
||||
from .errors import TapCreationError, UvmError
|
||||
from .domain import PortRule, VmRecord, VmSpec, new_vm_id
|
||||
from .errors import ConflictError, NotFoundError, UvmError, ValidationError
|
||||
from .firecracker.api import FirecrackerClient
|
||||
from .firecracker.config import build_config, write_config
|
||||
from .firecracker.process import FirecrackerProcessManager
|
||||
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
|
||||
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
|
||||
from .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ListedVm:
|
||||
vm: VmRecord
|
||||
observed_status: str
|
||||
policy_status: str = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationResult:
|
||||
id: str
|
||||
action: str
|
||||
outcome: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class LifecycleService:
|
||||
"""The only module allowed to coordinate multiple VM host resources."""
|
||||
"""All mutating entry points use operation lock -> short state transaction."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
state_store: StateStore,
|
||||
images: ImageStore,
|
||||
network: NetworkManager,
|
||||
process: FirecrackerProcessManager,
|
||||
*,
|
||||
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:
|
||||
@@ -50,235 +56,452 @@ class LifecycleService:
|
||||
self._clock = clock
|
||||
|
||||
def create(self, spec: VmSpec) -> VmRecord:
|
||||
"""Reserve identity first, then create all VM resources in dependency order."""
|
||||
|
||||
require_root()
|
||||
parse_cpu(str(spec.cpu))
|
||||
if type(spec.ram_mib) is not int or spec.ram_mib < 128:
|
||||
raise ValidationError("RAM must be an integer of at least 128 MiB")
|
||||
parse_username(spec.username)
|
||||
parse_password(spec.password)
|
||||
if spec.name is not None:
|
||||
parse_name(spec.name)
|
||||
ports = self._validated_ports(spec.ports)
|
||||
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()
|
||||
self._check_boot()
|
||||
assets = self._images.installed_assets()
|
||||
vm = self._reserve_vm(replace(spec, ports=ports))
|
||||
try:
|
||||
self._network.create_tap(vm.tap)
|
||||
except TapCreationError as error:
|
||||
tap_created = error.tap_created
|
||||
raise
|
||||
else:
|
||||
tap_created = True
|
||||
runtime = self._settings.vm_dir(vm.id)
|
||||
runtime.mkdir(mode=0o700, exist_ok=False)
|
||||
vm.runtime_owned = True
|
||||
self._save(vm)
|
||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
|
||||
self._images.provision_credentials(disk, vm.username, vm.password)
|
||||
vm.disk_ready = True
|
||||
self._save(vm)
|
||||
self._boot(vm, assets.kernel)
|
||||
return vm
|
||||
except BaseException as error:
|
||||
cleanup_error = None
|
||||
try:
|
||||
if vm.operation != "preparing":
|
||||
self._cleanup_runtime(vm)
|
||||
self._remove_runtime(vm)
|
||||
self._remove_record(vm.id)
|
||||
except BaseException as cleanup:
|
||||
cleanup_error = cleanup
|
||||
message = f"failed to create {vm.id}: {error}"
|
||||
if cleanup_error is not None:
|
||||
message += f"; cleanup incomplete: {cleanup_error}"
|
||||
self._record_failure(vm, message)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise UvmError(message) from error
|
||||
|
||||
config = build_config(self._settings, vm, assets.kernel, disk)
|
||||
write_config(Path(vm.config), config)
|
||||
def _check_boot(self) -> None:
|
||||
check_kvm()
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
checksum = self._settings.firecracker_binary_sha256
|
||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||
checksum = checksum or manifest.checksums.get("firecracker")
|
||||
verify_file(self._settings.firecracker_binary, checksum, "Firecracker binary",
|
||||
"UVM_FIRECRACKER_BINARY_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads)
|
||||
self._network.firewall.check_available()
|
||||
|
||||
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)
|
||||
def _boot(self, vm: VmRecord, kernel: Path) -> None:
|
||||
vm.status, vm.operation = "starting", "network"
|
||||
vm.pid = vm.process_start_time = None
|
||||
vm.spawn_pending = False
|
||||
self._save(vm)
|
||||
self._network.ensure_bridge()
|
||||
self._network.prepare_tap(vm)
|
||||
self._save(vm)
|
||||
self._network.firewall.apply(vm)
|
||||
self._network.activate_tap(vm)
|
||||
config = build_config(self._settings, vm, kernel, Path(vm.disk))
|
||||
write_config(Path(vm.config), config)
|
||||
|
||||
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
|
||||
client.configure_and_start(config)
|
||||
# Persist the crash window before spawning, but give start() the already
|
||||
# verified process-free record. A restored pending spawn is never guessed gone.
|
||||
vm.operation = "boot"
|
||||
vm.boot_id = self._process.host_boot_id()
|
||||
self._save(replace(vm, operation="spawning", spawn_pending=True))
|
||||
|
||||
def spawned(info: ProcessInfo) -> None:
|
||||
vm.pid, vm.process_start_time, vm.boot_id = info.pid, info.start_time, info.boot_id
|
||||
vm.spawn_pending = False
|
||||
vm.operation = "configuring"
|
||||
self._save(vm)
|
||||
|
||||
self._process.start(vm, on_spawn=spawned)
|
||||
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
|
||||
client.configure_and_start(config)
|
||||
if self._network.firewall.status(vm) != "enforced":
|
||||
raise UvmError("private-port enforcement could not be confirmed after boot")
|
||||
vm.status, vm.operation, vm.last_error = "running", None, None
|
||||
self._save(vm)
|
||||
|
||||
def start(self, identifier: str) -> VmRecord:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._start_locked(self._load_for_operation(identifier))
|
||||
|
||||
def _start_locked(self, vm: VmRecord) -> VmRecord:
|
||||
self._not_deleting(vm)
|
||||
if vm.ports is None:
|
||||
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
|
||||
observation = self._process.observe(vm)
|
||||
if observation == "unknown":
|
||||
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
|
||||
if observation == "alive":
|
||||
if vm.status != "running" or vm.operation not in {None, "policy"}:
|
||||
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
|
||||
return self._sync_ports_locked(vm)
|
||||
if not vm.disk_ready:
|
||||
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
|
||||
disk = Path(vm.disk)
|
||||
if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
|
||||
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
|
||||
if (vm.gateway != str(self._settings.gateway)
|
||||
or vm.network not in {None, str(self._settings.network)}
|
||||
or vm.bridge not in {None, self._settings.bridge}):
|
||||
raise ConflictError("configured network differs from the VM's retained network")
|
||||
self._check_boot()
|
||||
kernel = self._images.installed_kernel()
|
||||
try:
|
||||
self._cleanup_runtime(vm)
|
||||
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
|
||||
self._boot(vm, kernel)
|
||||
return vm
|
||||
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)
|
||||
message = f"failed to start {vm.id}: {error}"
|
||||
try:
|
||||
self._cleanup_runtime(vm)
|
||||
except BaseException as cleanup:
|
||||
message += f"; cleanup incomplete: {cleanup}"
|
||||
self._record_failure(vm, message)
|
||||
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 stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._stop_locked(self._load_for_operation(identifier), timeout=timeout)
|
||||
|
||||
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
|
||||
deleting = vm.operation == "delete" or vm.status == "terminating"
|
||||
vm.status, vm.operation = "stopping", "delete" if deleting else "stop"
|
||||
self._save(vm)
|
||||
try:
|
||||
self._cleanup_runtime(vm, timeout=timeout)
|
||||
vm.status = "stopped"
|
||||
vm.operation = "delete" if deleting else None
|
||||
vm.last_error = None
|
||||
self._save(vm)
|
||||
return vm
|
||||
except BaseException as error:
|
||||
self._record_failure(vm, f"failed to stop {vm.id}: {error}")
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
vm = self._load_for_operation(identifier)
|
||||
self._not_deleting(vm)
|
||||
self._stop_locked(vm, timeout=timeout)
|
||||
return self._start_locked(vm)
|
||||
|
||||
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._delete_locked(self._load_for_operation(identifier), timeout=timeout)
|
||||
|
||||
def _delete_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
|
||||
vm.status, vm.operation = "terminating", "delete"
|
||||
self._save(vm)
|
||||
try:
|
||||
self._cleanup_runtime(vm, timeout=timeout)
|
||||
self._remove_runtime(vm)
|
||||
self._remove_record(vm.id)
|
||||
return vm
|
||||
except BaseException as error:
|
||||
self._record_failure(vm, f"failed to delete {vm.id}: {error}")
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
def _cleanup_runtime(self, vm: VmRecord, *, timeout: float | None = None) -> None:
|
||||
containment_errors = []
|
||||
try:
|
||||
if self._network.has_tap(vm):
|
||||
try:
|
||||
self._network.firewall.apply(vm, quarantine=True)
|
||||
finally:
|
||||
self._network.deactivate_tap(vm)
|
||||
except UvmError as error:
|
||||
containment_errors.append(str(error))
|
||||
try:
|
||||
self._process.terminate(vm, timeout=timeout)
|
||||
except UvmError as error:
|
||||
detail = f"; containment: {'; '.join(containment_errors)}" if containment_errors else ""
|
||||
raise UvmError(f"process exit unconfirmed: {error}{detail}") from error
|
||||
vm.pid = vm.process_start_time = None
|
||||
vm.spawn_pending = False
|
||||
if vm.operation == "spawning":
|
||||
vm.operation = "boot"
|
||||
self._network.delete_vm_tap(vm)
|
||||
self._network.firewall.cleanup_connections(vm)
|
||||
self._network.firewall.remove(vm)
|
||||
|
||||
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
|
||||
require_root()
|
||||
ports = self._validated_ports(ports)
|
||||
if mode not in {"open", "close", "set"}:
|
||||
raise ValidationError(f"invalid port operation: {mode}")
|
||||
with self._state_store.operation_lock():
|
||||
vm = self._load_for_operation(identifier)
|
||||
self._not_deleting(vm)
|
||||
observation = self._process.observe(vm)
|
||||
if observation == "unknown":
|
||||
raise ConflictError("port changes require a known process identity")
|
||||
if vm.ports is None and (mode != "set" or observation != "gone" or vm.status != "stopped"):
|
||||
raise ConflictError("legacy VM policy must be explicitly set while stopped")
|
||||
if vm.operation not in {None, "policy"}:
|
||||
raise ConflictError(f"finish the pending {vm.operation} operation before editing ports")
|
||||
current = {port.port: port for port in vm.ports or ()}
|
||||
if mode == "set":
|
||||
current = {port.port: port for port in ports}
|
||||
elif mode == "open":
|
||||
for port in ports:
|
||||
current.setdefault(port.port, port)
|
||||
else:
|
||||
for port in ports:
|
||||
current.pop(port.port, None)
|
||||
vm.ports = tuple(current[number] for number in sorted(current))
|
||||
vm.operation = "policy"
|
||||
self._save(vm)
|
||||
return self._sync_ports_locked(vm)
|
||||
|
||||
def sync_ports(self, identifier: str) -> VmRecord:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._sync_ports_locked(self._load_for_operation(identifier))
|
||||
|
||||
def _sync_ports_locked(self, vm: VmRecord) -> VmRecord:
|
||||
self._not_deleting(vm)
|
||||
if vm.ports is None:
|
||||
raise ConflictError("legacy VM has no managed policy; stop it and use ports set")
|
||||
if vm.operation not in {None, "policy"}:
|
||||
raise ConflictError(f"finish the pending {vm.operation} operation before syncing ports")
|
||||
observation = self._process.observe(vm)
|
||||
if observation == "unknown":
|
||||
raise ConflictError("cannot enforce policy for a VM with unknown process identity")
|
||||
vm.operation = "policy"
|
||||
self._save(vm)
|
||||
try:
|
||||
if observation == "alive":
|
||||
if not self._network.has_tap(vm):
|
||||
raise UvmError("running VM has no owned network attachment")
|
||||
self._network.firewall.apply(vm)
|
||||
self._network.activate_tap(vm)
|
||||
vm.status = "running"
|
||||
else:
|
||||
if vm.status != "stopped" or vm.tap_owned:
|
||||
self._cleanup_runtime(vm)
|
||||
vm.status = "stopped"
|
||||
vm.operation, vm.last_error = None, None
|
||||
self._save(vm)
|
||||
return vm
|
||||
except BaseException as error:
|
||||
# Never restore an older, more permissive policy after a failed close.
|
||||
containment_errors = []
|
||||
for contain in (
|
||||
lambda: self._network.deactivate_tap(vm),
|
||||
lambda: self._network.firewall.apply(vm, quarantine=True),
|
||||
):
|
||||
try:
|
||||
contain()
|
||||
except UvmError as containment_error:
|
||||
containment_errors.append(str(containment_error))
|
||||
detail = f"; containment errors: {'; '.join(containment_errors)}" if containment_errors else ""
|
||||
self._record_failure(vm, f"policy pending for {vm.id}: {error}{detail}")
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
def resolve_targets(self, identifiers: Sequence[str], *, all_vms: bool = False) -> list[VmRecord]:
|
||||
if all_vms and identifiers:
|
||||
raise ValidationError("--all cannot be combined with explicit VM targets")
|
||||
state = self._state_store.load()
|
||||
if all_vms:
|
||||
return list(state.vms.values())
|
||||
if not identifiers:
|
||||
raise ValidationError("specify a VM or --all")
|
||||
resolved = [self._state_store.resolve(state, identifier) for identifier in identifiers]
|
||||
return list({vm.id: vm for vm in resolved}.values())
|
||||
|
||||
def run_many(self, action: str, vm_ids: Sequence[str], *, timeout: float | None = None) -> list[OperationResult]:
|
||||
require_root()
|
||||
if action not in {"start", "stop", "restart", "delete", "ports-sync"}:
|
||||
raise ValidationError(f"invalid lifecycle action: {action}")
|
||||
results = []
|
||||
with self._state_store.operation_lock():
|
||||
for index, vm_id in enumerate(vm_ids):
|
||||
try:
|
||||
vm = self._load_for_operation(vm_id, exact_id=True)
|
||||
if action == "start":
|
||||
self._start_locked(vm)
|
||||
elif action == "stop":
|
||||
self._stop_locked(vm, timeout=timeout)
|
||||
elif action == "delete":
|
||||
self._delete_locked(vm, timeout=timeout)
|
||||
elif action == "ports-sync":
|
||||
self._sync_ports_locked(vm)
|
||||
else:
|
||||
self._not_deleting(vm)
|
||||
self._stop_locked(vm, timeout=timeout)
|
||||
self._start_locked(vm)
|
||||
results.append(OperationResult(vm_id, action, "succeeded"))
|
||||
except NotFoundError:
|
||||
results.append(OperationResult(vm_id, action, "skipped", "already absent"))
|
||||
except KeyboardInterrupt:
|
||||
results.append(OperationResult(vm_id, action, "interrupted", "interrupted"))
|
||||
results.extend(OperationResult(remaining, action, "skipped", "not attempted after interruption")
|
||||
for remaining in vm_ids[index + 1:])
|
||||
break
|
||||
except (UvmError, OSError) as error:
|
||||
results.append(OperationResult(vm_id, action, "failed", str(error)))
|
||||
return results
|
||||
|
||||
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
|
||||
return [self._listed(vm) for vm in self._state_store.load().vms.values()]
|
||||
|
||||
def inspect(self, identifier: str) -> ListedVm:
|
||||
return self._listed(self._state_store.find_by_id_or_ip(identifier))
|
||||
|
||||
def _listed(self, vm: VmRecord) -> ListedVm:
|
||||
observation = self._process.observe(vm)
|
||||
status = vm.status
|
||||
if observation == "unknown":
|
||||
status = "unknown"
|
||||
elif observation == "alive" and status == "stopped":
|
||||
status = "running"
|
||||
elif observation == "gone" and status == "running":
|
||||
status = "dead"
|
||||
policy = ("inactive" if observation == "gone" and vm.status == "stopped" and vm.operation is None
|
||||
else self._network.firewall.status(vm))
|
||||
if vm.ports is None and policy == "inactive":
|
||||
policy = "unmanaged"
|
||||
return ListedVm(vm, status, policy)
|
||||
|
||||
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."""
|
||||
def log_path(self, identifier: str) -> Path:
|
||||
vm = self._state_store.find_by_id_or_ip(identifier)
|
||||
self._validate_paths(vm)
|
||||
return Path(vm.log)
|
||||
|
||||
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)
|
||||
def diagnostics(self) -> list[dict]:
|
||||
checks = [("root", require_root), ("kvm", check_kvm),
|
||||
("guest assets", self._images.installed_assets),
|
||||
("private firewall", self._network.firewall.check_available)]
|
||||
results = []
|
||||
for name, check in checks:
|
||||
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
|
||||
check()
|
||||
results.append({"name": name, "ok": True, "message": "available"})
|
||||
except (UvmError, OSError) as error:
|
||||
results.append({"name": name, "ok": False, "message": str(error)})
|
||||
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"):
|
||||
path = shutil.which(command)
|
||||
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
|
||||
return results
|
||||
|
||||
self._remove_vm_if_present(vm.id)
|
||||
def _load_for_operation(self, identifier: str, *, exact_id: bool = False) -> VmRecord:
|
||||
vm = (self._state_store.get(identifier) if exact_id
|
||||
else self._state_store.find_by_id_or_ip(identifier))
|
||||
self._validate_paths(vm)
|
||||
if self._process.adopt_legacy_identity(vm):
|
||||
self._save(vm)
|
||||
return vm
|
||||
|
||||
def _validate_paths(self, vm: VmRecord) -> None:
|
||||
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None:
|
||||
raise ConflictError("invalid persisted VM ID; refusing resource access")
|
||||
directory = self._settings.vm_dir(vm.id).absolute()
|
||||
if directory.is_symlink():
|
||||
raise ConflictError("VM directory must not be a symbolic link")
|
||||
for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"),
|
||||
("log", "firecracker.log"), ("disk", "rootfs.ext4")):
|
||||
path = getattr(vm, field)
|
||||
if field == "disk" and not path:
|
||||
continue # Legacy records may have used a shared template, never delete it.
|
||||
if Path(path).absolute() != directory / filename or Path(path).is_symlink():
|
||||
raise ConflictError(f"unsafe persisted VM {field} path")
|
||||
|
||||
@staticmethod
|
||||
def _not_deleting(vm: VmRecord) -> None:
|
||||
if vm.operation == "delete" or vm.status == "terminating":
|
||||
raise ConflictError(f"deletion is pending for {vm.id}; retry delete instead")
|
||||
|
||||
@staticmethod
|
||||
def _validated_ports(ports: tuple[PortRule, ...]) -> tuple[PortRule, ...]:
|
||||
if not isinstance(ports, tuple) or any(not isinstance(port, PortRule) for port in ports):
|
||||
raise ValidationError("ports must be a tuple of private TCP port rules")
|
||||
return parse_ports([f"{port.port}:{port.protocol}" for port in ports])
|
||||
|
||||
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
|
||||
with self._state_store.transaction() as state:
|
||||
if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()):
|
||||
raise ConflictError(f"VM name already exists: {spec.name}")
|
||||
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:
|
||||
used_taps = {vm.tap for vm in state.vms.values()}
|
||||
while vm_id in state.vms or any(vm.name == vm_id for vm in state.vms.values()) 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)
|
||||
directory = 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,
|
||||
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib,
|
||||
guest_ip=str(guest_ip), gateway=str(self._settings.gateway),
|
||||
network=str(self._settings.network), bridge=self._settings.bridge,
|
||||
tap=self._network.tap_name(vm_id), mac=mac,
|
||||
socket=str(directory / "firecracker.sock"), config=str(directory / "config.json"),
|
||||
log=str(directory / "firecracker.log"), disk=str(directory / "rootfs.ext4"),
|
||||
username=spec.username, password=spec.password, ports=spec.ports,
|
||||
status="starting", operation="preparing", created_at=int(self._clock()), updated_at=int(self._clock()),
|
||||
)
|
||||
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 _remove_runtime(self, vm: VmRecord) -> None:
|
||||
self._validate_paths(vm)
|
||||
directory = self._settings.vm_dir(vm.id)
|
||||
if directory.exists():
|
||||
if not vm.runtime_owned:
|
||||
raise ConflictError(f"refusing to remove unowned VM directory {directory}")
|
||||
if self._process.observe(vm) != "gone":
|
||||
raise ConflictError("refusing to remove disk without confirmed process exit")
|
||||
shutil.rmtree(directory)
|
||||
vm.runtime_owned = False
|
||||
|
||||
def _replace_vm(self, vm: VmRecord) -> None:
|
||||
def _save(self, vm: VmRecord) -> None:
|
||||
vm.updated_at = int(self._clock())
|
||||
with self._state_store.transaction() as state:
|
||||
if vm.id not in state.vms:
|
||||
raise UvmError(f"VM not found: {vm.id}")
|
||||
raise NotFoundError(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 _record_failure(self, vm: VmRecord, message: str) -> None:
|
||||
vm.status, vm.last_error = "failed", message
|
||||
self._save(vm)
|
||||
|
||||
def _remove_vm_if_present(self, vm_id: str) -> None:
|
||||
def _remove_record(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())
|
||||
|
||||
+66
-1
@@ -9,7 +9,8 @@ from ipaddress import IPv4Address
|
||||
|
||||
from .config import Settings
|
||||
from .domain import VmRecord
|
||||
from .errors import TapCreationError, UvmError, ValidationError
|
||||
from .errors import ConflictError, TapCreationError, UvmError, ValidationError
|
||||
from .firewall import FirewallManager
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ class NetworkManager:
|
||||
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner
|
||||
self.firewall = FirewallManager(settings, runner)
|
||||
|
||||
def allocate_ip(
|
||||
self,
|
||||
@@ -95,6 +97,69 @@ class NetworkManager:
|
||||
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")
|
||||
if links[0]["linkinfo"].get("info_data", {}).get("stp_state", 0) != 0:
|
||||
raise UvmError("private ports require an STP-disabled UVM bridge")
|
||||
|
||||
def prepare_tap(self, vm: VmRecord) -> None:
|
||||
"""Create an owned, attached TAP down; policy must precede activation."""
|
||||
if self._link(vm.tap) is not None:
|
||||
raise ConflictError(f"TAP device already exists: {vm.tap}")
|
||||
try:
|
||||
self._runner.run(("ip", "tuntap", "add", "dev", vm.tap, "mode", "tap"), capture=True)
|
||||
vm.tap_owned = True
|
||||
self._runner.run(("ip", "link", "set", "dev", vm.tap, "alias", f"uvm:{vm.id}"), capture=True)
|
||||
self._runner.run(("ip", "link", "set", vm.tap, "master", vm.bridge or self._settings.bridge), capture=True)
|
||||
self._runner.run(("ip", "link", "set", "dev", vm.tap, "type", "bridge_slave",
|
||||
"group_fwd_mask", "16384"), capture=True)
|
||||
except BaseException:
|
||||
if vm.tap_owned:
|
||||
try:
|
||||
self.delete_tap(vm.tap)
|
||||
vm.tap_owned = False
|
||||
except UvmError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def activate_tap(self, vm: VmRecord) -> None:
|
||||
if self._owned_link(vm) is None:
|
||||
raise UvmError(f"TAP device missing: {vm.tap}")
|
||||
self._runner.run(("ip", "link", "set", vm.tap, "up"), capture=True)
|
||||
|
||||
def deactivate_tap(self, vm: VmRecord) -> None:
|
||||
if self._owned_link(vm) is not None:
|
||||
self._runner.run(("ip", "link", "set", vm.tap, "down"), capture=True)
|
||||
|
||||
def delete_vm_tap(self, vm: VmRecord) -> None:
|
||||
if self._owned_link(vm) is not None:
|
||||
self.delete_tap(vm.tap)
|
||||
vm.tap_owned = False
|
||||
|
||||
def has_tap(self, vm: VmRecord) -> bool:
|
||||
return self._owned_link(vm) is not None
|
||||
|
||||
def _link(self, name: str) -> dict | None:
|
||||
result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True)
|
||||
try:
|
||||
links = json.loads(result.stdout)
|
||||
if not isinstance(links, list) or any(not isinstance(link, dict) for link in links):
|
||||
raise ValueError("invalid interface inventory")
|
||||
matches = [link for link in links if link.get("ifname") == name]
|
||||
if len(matches) > 1:
|
||||
raise ValueError("ambiguous interface inventory")
|
||||
return matches[0] if matches else None
|
||||
except (ValueError, TypeError) as error:
|
||||
raise UvmError(f"cannot inspect network interfaces: {error}") from error
|
||||
|
||||
def _owned_link(self, vm: VmRecord) -> dict | None:
|
||||
link = self._link(vm.tap)
|
||||
if link is None:
|
||||
return None
|
||||
info = link.get("linkinfo", {})
|
||||
is_tap = info.get("info_kind") == "tun" and info.get("info_data", {}).get("type") == "tap"
|
||||
legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias")
|
||||
if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy):
|
||||
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
|
||||
return link
|
||||
|
||||
def create_tap(self, name: str) -> None:
|
||||
existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
|
||||
|
||||
+24
-11
@@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, 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 ..domain import PortRule, VmSpec
|
||||
from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username
|
||||
from .dependencies import get_authorized_application
|
||||
|
||||
|
||||
@@ -19,7 +19,11 @@ def list_vms(
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> list[VmResponse]:
|
||||
return [
|
||||
vm_response(listed.vm, observed_status=listed.observed_status)
|
||||
vm_response(
|
||||
listed.vm,
|
||||
observed_status=listed.observed_status,
|
||||
policy_status=listed.policy_status,
|
||||
)
|
||||
for listed in application.lifecycle.list_vms()
|
||||
]
|
||||
|
||||
@@ -29,6 +33,9 @@ def create_vm(
|
||||
request: VmCreateRequest,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports or ()])
|
||||
if not request.no_default_ports and not any(rule.port == 22 for rule in ports):
|
||||
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
|
||||
vm = application.lifecycle.create(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(str(request.cpu)),
|
||||
@@ -36,9 +43,14 @@ def create_vm(
|
||||
guest_ip=request.guest_ip,
|
||||
username=parse_username(request.username),
|
||||
password=parse_password(request.password.get_secret_value()),
|
||||
name=parse_name(request.name) if request.name is not None else None,
|
||||
ports=ports,
|
||||
)
|
||||
)
|
||||
return vm_response(vm)
|
||||
policy_status = "unmanaged" if vm.ports is None else "unknown"
|
||||
if vm.ports is not None and vm.status == "running" and vm.operation is None:
|
||||
policy_status = "enforced"
|
||||
return vm_response(vm, policy_status=policy_status)
|
||||
|
||||
|
||||
@router.post("/{vm_id}/stop", response_model=VmResponse)
|
||||
@@ -46,7 +58,7 @@ def stop_vm(
|
||||
vm_id: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
return vm_response(application.lifecycle.stop(vm_id))
|
||||
return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive")
|
||||
|
||||
|
||||
@router.delete("/{vm_id}", response_model=DestroyResponse)
|
||||
@@ -63,8 +75,9 @@ 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")
|
||||
listed = application.lifecycle.inspect(identifier)
|
||||
return vm_response(
|
||||
listed.vm,
|
||||
observed_status=listed.observed_status,
|
||||
policy_status=listed.policy_status,
|
||||
)
|
||||
|
||||
+6
-7
@@ -8,7 +8,7 @@ import ssl
|
||||
from typing import Any
|
||||
|
||||
from .app import Application, build_application
|
||||
from .errors import ConfigurationError, UvmError, ValidationError
|
||||
from .errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
|
||||
|
||||
|
||||
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
|
||||
@@ -115,13 +115,12 @@ def _validate_server_settings(
|
||||
|
||||
|
||||
def _http_status_for(error: UvmError) -> int:
|
||||
if isinstance(error, NotFoundError):
|
||||
return 404
|
||||
if isinstance(error, ConflictError):
|
||||
return 409
|
||||
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:
|
||||
if "needs root" in str(error):
|
||||
return 403
|
||||
return 500
|
||||
|
||||
+18
-9
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -12,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import State, VmRecord
|
||||
from .errors import StateError, UvmError
|
||||
from .errors import ConflictError, NotFoundError, StateError
|
||||
from .system import ensure_data_directories
|
||||
|
||||
|
||||
@@ -63,16 +64,24 @@ class StateStore:
|
||||
try:
|
||||
return state.vms[vm_id]
|
||||
except KeyError as error:
|
||||
raise UvmError(f"VM not found: {vm_id}") from error
|
||||
raise NotFoundError(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}")
|
||||
return self.resolve(self.load(), identifier)
|
||||
|
||||
@staticmethod
|
||||
def resolve(state: State, identifier: str) -> VmRecord:
|
||||
if re.fullmatch(r"vm-[0-9a-f]{32}", identifier):
|
||||
try:
|
||||
return state.vms[identifier]
|
||||
except KeyError as error:
|
||||
raise NotFoundError(f"VM not found: {identifier}") from error
|
||||
matches = [vm for vm in state.vms.values() if identifier in {vm.id, vm.name, vm.guest_ip}]
|
||||
if len(matches) > 1:
|
||||
raise ConflictError(f"ambiguous VM identifier: {identifier}; use an exact VM ID")
|
||||
if not matches:
|
||||
raise NotFoundError(f"VM not found: {identifier}")
|
||||
return matches[0]
|
||||
|
||||
@contextmanager
|
||||
def _locked(self) -> Iterator[None]:
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -47,7 +48,7 @@ class CommandRunner:
|
||||
check=False,
|
||||
text=True,
|
||||
input=input_text,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stdout=subprocess.PIPE if capture else sys.stderr,
|
||||
stderr=subprocess.PIPE if capture else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .errors import ValidationError
|
||||
from .domain import PortRule
|
||||
|
||||
|
||||
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
|
||||
@@ -81,3 +83,35 @@ def parse_password(value: str) -> str:
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ValidationError("password must not contain control characters")
|
||||
return value
|
||||
|
||||
|
||||
def parse_name(value: str) -> str:
|
||||
if re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None:
|
||||
raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter")
|
||||
if re.fullmatch(r"vm-[0-9a-f]{32}", value):
|
||||
raise ValidationError("VM names must not impersonate generated VM IDs")
|
||||
return value
|
||||
|
||||
|
||||
def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]:
|
||||
ports: dict[int, PortRule] = {}
|
||||
for value in values:
|
||||
for item in value.split(","):
|
||||
match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE)
|
||||
if match is None:
|
||||
raise ValidationError(f"invalid private port: {item!r}; use PORT[:TCP|HTTP|HTTPS]")
|
||||
rule = PortRule(int(match.group(1)), match.group(2) or "TCP")
|
||||
if rule.port in ports and ports[rule.port] != rule:
|
||||
raise ValidationError(f"conflicting labels for TCP port {rule.port}")
|
||||
ports[rule.port] = rule
|
||||
return tuple(ports[number] for number in sorted(ports))
|
||||
|
||||
|
||||
def parse_timeout(value: str) -> float:
|
||||
try:
|
||||
timeout = float(value)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise ValidationError("timeout must be a finite non-negative number of seconds") from error
|
||||
if not math.isfinite(timeout) or timeout < 0:
|
||||
raise ValidationError("timeout must be a finite non-negative number of seconds")
|
||||
return timeout
|
||||
|
||||
Reference in New Issue
Block a user