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:
2026-09-09 17:27:50 +05:30
parent 4ce14c3378
commit d8a25209b9
31 changed files with 9576 additions and 1331 deletions
+421
View File
@@ -0,0 +1,421 @@
"""Opt-in real packet tests, using only disposable user/network namespaces.
Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
Needs Linux, nft, ip, bridge, conntrack, sysctl and permission to create network
namespaces (root, or unprivileged user namespaces). No sudo, installation,
/run/netns mounts, host firewall changes or br_netfilter sysctls are used.
Bridge-connected veths stand in for guest TAPs. A third, routed namespace tests
guest egress without NAT. Ordinary test runs skip without executing commands.
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import select
import shutil
import signal
import socket
import struct
import subprocess
import sys
import time
import unittest
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
class SocketEndpoint:
"""Synchronous socket operations; the kernel completes queued handshakes."""
def __init__(self):
self.sockets = {}
def close(self):
for sock in self.sockets.values():
sock.close()
def request(self, operation, **args):
if operation == "run":
result = subprocess.run(args["command"], check=True, text=True,
capture_output=True, timeout=5)
return result.stdout
if operation in {"listen", "connect", "udp_bind"}:
family = socket.AF_INET6 if ":" in args["ip"] else socket.AF_INET
kind = socket.SOCK_DGRAM if operation == "udp_bind" else socket.SOCK_STREAM
sock = socket.socket(family, kind)
sock.settimeout(1)
if kind == socket.SOCK_STREAM:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if family == socket.AF_INET6:
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
try:
if operation == "connect":
sock.connect((args["ip"], args["port"]))
else:
sock.bind((args["ip"], args["port"]))
if operation == "listen":
sock.listen(16)
except TimeoutError:
sock.close()
return False
except BaseException:
sock.close()
raise
self.sockets[args["name"]] = sock
return True
if operation == "accept":
sock, _ = self.sockets[args["listener"]].accept()
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.settimeout(1)
self.sockets[args["name"]] = sock
return True
if operation in {"send", "udp_send"}:
sock = self.sockets[args["name"]]
data = args["data"].encode("ascii")
if operation == "send":
sock.sendall(data)
else:
sock.sendto(data, (args["ip"], args["port"]))
return True
if operation in {"recv", "udp_recv"}:
sock = self.sockets[args["name"]]
sock.settimeout(0.4 if args.get("blocked") else 2)
data = b""
try:
if operation == "udp_recv":
data, address = sock.recvfrom(4096)
return [data.decode("ascii"), list(address)]
while len(data) < args["size"]:
chunk = sock.recv(args["size"] - len(data))
if not chunk:
break
data += chunk
except TimeoutError:
return data.decode("ascii") if data else None
return data.decode("ascii")
if operation == "ping":
ident = os.getpid() & 0xFFFF
frame = struct.pack("!BBHHH", 8, 0, 0, ident, 1) + b"uvm-private-ports!"
padded = frame + b"\0" * (len(frame) % 2)
checksum = sum(struct.unpack(f"!{len(padded) // 2}H", padded))
checksum = (checksum & 65535) + (checksum >> 16)
checksum = (~((checksum & 65535) + (checksum >> 16))) & 65535
frame = frame[:2] + struct.pack("!H", checksum) + frame[4:]
with socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) as sock:
sock.settimeout(1)
sock.sendto(frame, (args["ip"], 0))
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
try:
data, address = sock.recvfrom(65535)
except TimeoutError:
return False
offset = (data[0] & 15) * 4
if (address[0] == args["ip"] and data[offset] == 0
and data[offset + 4:offset + 8] == frame[4:8]):
return True
return False
if operation == "raw":
with socket.socket(socket.AF_PACKET, socket.SOCK_RAW) as sock:
sock.bind(("eth0", 0))
sock.send(bytes.fromhex(args["frame"]))
return True
raise AssertionError(f"unknown endpoint operation {operation}")
def _require_isolated(parent_fd):
parent, current = os.fstat(parent_fd), os.stat("/proc/self/ns/net")
if (parent.st_dev, parent.st_ino) == (current.st_dev, current.st_ino):
raise RuntimeError("refusing packet/firewall operations in the parent network namespace")
os.close(parent_fd)
class NamespaceEndpoint:
def __enter__(self):
with open("/proc/self/ns/net", "rb") as parent:
self.process = subprocess.Popen(
("unshare", "--net", sys.executable, "-B", str(Path(__file__).resolve()),
"--agent", str(parent.fileno())),
pass_fds=(parent.fileno(),), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
)
try:
assert self._read() == "ready"
except BaseException:
self.__exit__(None, None, None)
raise
return self
def __exit__(self, *_):
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=3)
for pipe in (self.process.stdin, self.process.stdout, self.process.stderr):
pipe.close()
def _read(self):
if not select.select([self.process.stdout], [], [], 8)[0]:
raise AssertionError("namespace endpoint timed out")
line = self.process.stdout.readline()
if not line:
detail = self.process.stderr.read() if self.process.poll() is not None else ""
raise AssertionError(f"namespace endpoint exited: {detail}")
response = json.loads(line)
if "error" in response:
raise AssertionError(response["error"])
return response["result"]
def request(self, operation, **args):
self.process.stdin.write(json.dumps({"operation": operation, **args}) + "\n")
self.process.stdin.flush()
return self._read()
def _agent(parent_fd):
_require_isolated(parent_fd)
endpoint = SocketEndpoint()
try:
print(json.dumps({"result": "ready"}), flush=True)
for line in sys.stdin:
request = json.loads(line)
try:
result = endpoint.request(**request)
response = {"result": result}
except Exception as error:
response = {"error": f"{type(error).__name__}: {error}"}
print(json.dumps(response), flush=True)
finally:
endpoint.close()
def _sandbox(parent_fd):
_require_isolated(parent_fd)
if not __debug__:
raise RuntimeError("packet verification requires assertions; disable PYTHONOPTIMIZE/-O")
# Direct script execution must import the workspace, not an installed uvm.
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord
from uvm.errors import UvmError
from uvm.firewall import FirewallManager
from uvm.system import CommandRunner
runner = CommandRunner(emit=None)
def run(*args):
return runner.run(args, capture=True, timeout=5).stdout
assert [link["ifname"] for link in json.loads(run("ip", "-j", "link", "show"))] == ["lo"]
assert not any("table" in item for item in json.loads(run("nft", "--json", "list", "tables"))["nftables"])
settings = Settings(base=Path("/tmp/uvm-firewall-namespace-test"), bridge="fw-br0",
network=IPv4Network("10.231.73.0/24"), gateway=IPv4Address("10.231.73.1"))
manager = FirewallManager(settings, runner)
manager.check_available()
run("ip", "link", "set", "lo", "up")
run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0")
run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge)
run("ip", "link", "set", settings.bridge, "up")
# This IPv4 sysctl belongs only to the freshly verified network namespace.
run("sysctl", "-w", "net.ipv4.ip_forward=1")
metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway),
socket="", config="", log="", network=str(settings.network), bridge=settings.bridge)
vm = VmRecord(id="integration-vm", tap="fw-tap1", guest_ip="10.231.73.2",
mac="02:fc:00:00:00:01", ports=(PortRule(8080, "HTTP"),), **metadata)
peer_vm = VmRecord(id="integration-peer", tap="fw-tap2", guest_ip="10.231.73.3",
mac="02:fc:00:00:00:02", ports=(PortRule(9090),), **metadata)
with contextlib.ExitStack() as stack:
guest = stack.enter_context(NamespaceEndpoint())
peer = stack.enter_context(NamespaceEndpoint())
outside = stack.enter_context(NamespaceEndpoint())
host = SocketEndpoint()
stack.callback(host.close)
for endpoint, tap, address, mac in (
(guest, vm.tap, vm.guest_ip, vm.mac),
(peer, peer_vm.tap, peer_vm.guest_ip, peer_vm.mac),
(outside, "fw-up", "172.30.73.2", "02:fc:00:00:00:03"),
):
run("ip", "link", "add", tap, "type", "veth", "peer", "name", "fw-end")
run("ip", "link", "set", "fw-end", "netns", str(endpoint.process.pid))
endpoint.request("run", command=["ip", "link", "set", "fw-end", "name", "eth0"])
endpoint.request("run", command=["ip", "link", "set", "lo", "up"])
endpoint.request("run", command=["ip", "link", "set", "eth0", "address", mac])
endpoint.request("run", command=["ip", "addr", "add", f"{address}/24", "dev", "eth0"])
endpoint.request("run", command=["ip", "link", "set", "eth0", "up"])
if endpoint is outside:
run("ip", "addr", "add", "172.30.73.1/24", "dev", tap)
endpoint.request("run", command=["ip", "route", "add", str(settings.network), "via", "172.30.73.1"])
run("ip", "link", "set", tap, "up")
else:
record = vm if endpoint is guest else peer_vm
run("ip", "link", "set", "dev", tap, "alias", f"uvm:{record.id}")
run("ip", "link", "set", tap, "master", settings.bridge)
run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000")
endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)])
manager.apply(vm)
manager.apply(peer_vm)
for tap in (vm.tap, peer_vm.tap):
run("ip", "link", "set", tap, "up")
assert manager.status(vm) == manager.status(peer_vm) == "enforced"
def stream(client, server, target, port, name):
assert client.request("connect", name=name, ip=target, port=port)
assert server.request("accept", listener="listener", name=name)
def transfer(sender, receiver, name, text, *, blocked=False):
assert sender.request("send", name=name, data=text)
received = receiver.request("recv", name=name, size=len(text), blocked=blocked)
assert received == (None if blocked else text), (name, text, received, blocked)
guest.request("listen", name="listener", ip=vm.guest_ip, port=8080)
peer.request("listen", name="listener", ip=peer_vm.guest_ip, port=9090)
host.request("listen", name="listener", ip=str(settings.gateway), port=9000)
outside.request("listen", name="listener", ip="172.30.73.2", port=9000)
stream(host, guest, vm.guest_ip, 8080, "host-service")
stream(peer, guest, vm.guest_ip, 8080, "peer-service")
stream(guest, host, str(settings.gateway), 9000, "host-egress")
stream(guest, outside, "172.30.73.2", 9000, "routed-egress")
stream(guest, peer, peer_vm.guest_ip, 9090, "peer-egress")
pairs = ((host, guest, "host-service"), (peer, guest, "peer-service"),
(guest, host, "host-egress"), (guest, outside, "routed-egress"),
(guest, peer, "peer-egress"))
for client, server, name in pairs:
transfer(client, server, name, "client-before")
transfer(server, client, name, "server-before")
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
def service_ids():
listing = run("conntrack", "--list", "--family", "ipv4", "--proto", "tcp",
"--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id")
return set(re.findall(r"\bid=(\d+)", listing))
before = service_ids()
assert len(before) == 2, before
vm.ports = ()
manager.apply(vm)
assert manager.status(vm) == "enforced"
assert service_ids() == before, "closing a port must preserve connection tracking"
for client, server, name in pairs[:2]:
transfer(client, server, name, "client-after-close", blocked=True)
transfer(server, client, name, "server-after-close", blocked=True)
for client, server, name in pairs[2:]:
transfer(client, server, name, "egress-after-close")
transfer(server, client, name, "reply-after-close")
assert not host.request("connect", name="closed-host", ip=vm.guest_ip, port=8080)
assert not peer.request("connect", name="closed-peer", ip=vm.guest_ip, port=8080)
print("PASS: existing service streams drop BOTH directions, CT IDs and guest egress survive", flush=True)
peer_vm.ports = ()
manager.apply(peer_vm)
transfer(guest, peer, "peer-egress", "peer-client-closed", blocked=True)
transfer(peer, guest, "peer-egress", "peer-server-closed", blocked=True)
transfer(guest, outside, "routed-egress", "other-table-survives")
transfer(outside, guest, "routed-egress", "other-table-reply")
guest.request("udp_bind", name="dns", ip=vm.guest_ip, port=40053)
outside.request("udp_bind", name="dns", ip="172.30.73.2", port=53)
guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query")
query = outside.request("udp_recv", name="dns")
assert query[0] == "dns-query"
outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply")
assert guest.request("udp_recv", name="dns")[0] == "dns-reply"
guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054)
outside.request("udp_send", name="dns", ip=vm.guest_ip, port=40054, data="unsolicited")
assert guest.request("udp_recv", name="unsolicited", blocked=True) is None
for endpoint, target in ((host, vm.guest_ip), (peer, vm.guest_ip),
(guest, str(settings.gateway)), (guest, "172.30.73.2")):
assert endpoint.request("ping", ip=target), (endpoint, target)
print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True)
# Spoofed source MACs must not reach FDB learning, including the local
# link-local path which can skip bridge prerouting entirely.
spoof = bytes.fromhex("02aabbccddee")
arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1) + spoof + socket.inet_aton(vm.guest_ip)
arp += b"\0" * 6 + socket.inet_aton(str(settings.gateway))
for destination in (b"\xff" * 6, bytes.fromhex("0180c200000e"), bytes.fromhex("0180c2000003")):
guest.request("raw", frame=(destination + spoof + b"\x08\x06" + arp).hex())
time.sleep(0.1)
fdb = json.loads(run("bridge", "-j", "fdb", "show", "br", settings.bridge))
assert not any(entry.get("mac") == "02:aa:bb:cc:dd:ee" for entry in fdb)
run("ip", "-6", "addr", "add", "fd42:73::1/64", "dev", settings.bridge, "nodad")
guest.request("run", command=["ip", "-6", "addr", "add", "fd42:73::2/64", "dev", "eth0", "nodad"])
host_mac = json.loads(run("ip", "-j", "link", "show", "dev", settings.bridge))[0]["address"]
run("ip", "-6", "neigh", "replace", "fd42:73::2", "lladdr", vm.mac, "nud", "permanent", "dev", settings.bridge)
guest.request("run", command=["ip", "-6", "neigh", "replace", "fd42:73::1", "lladdr", host_mac,
"nud", "permanent", "dev", "eth0"])
guest.request("listen", name="v6-listener", ip="fd42:73::2", port=8080)
host.request("listen", name="v6-listener", ip="fd42:73::1", port=9000)
assert not host.request("connect", name="v6-inbound", ip="fd42:73::2", port=8080)
assert not guest.request("connect", name="v6-outbound", ip="fd42:73::1", port=9000)
print("PASS: pre-learning MAC identity and IPv6 blocking in both directions", flush=True)
manager.apply(vm, quarantine=True)
assert manager.status(vm) == "quarantined"
transfer(guest, host, "host-egress", "quarantined-out", blocked=True)
transfer(host, guest, "host-egress", "quarantined-in", blocked=True)
try:
manager.remove(vm)
except UvmError:
pass
else:
raise AssertionError("removal must refuse a live port")
run("ip", "neigh", "flush", "dev", settings.bridge)
assert not host.request("ping", ip=vm.guest_ip), "quarantine must also stop ARP"
run("ip", "link", "del", vm.tap)
manager.cleanup_connections(vm)
manager.remove(vm)
assert manager.status(vm) == "missing"
assert manager.status(peer_vm) == "enforced"
print("PASS: quarantine, offline CT cleanup and sibling-safe removal", flush=True)
@unittest.skipUnless(os.environ.get("UVM_FIREWALL_INTEGRATION") == "1",
"opt in with UVM_FIREWALL_INTEGRATION=1; needs isolated network namespaces")
class PrivatePortNamespaceTests(unittest.TestCase):
def test_private_port_packet_paths_and_live_close(self):
if sys.platform != "linux" or shutil.which("unshare") is None:
self.skipTest("Linux and unshare are required")
prefix = ("unshare", "--net") if os.geteuid() == 0 else (
"unshare", "--user", "--map-root-user", "--net",
)
probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5)
if probe.returncode:
self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}")
missing = [name for name in ("ip", "bridge", "nft", "conntrack", "sysctl") if shutil.which(name) is None]
if missing:
self.skipTest(f"integration tools unavailable: {', '.join(missing)}")
with open("/proc/self/ns/net", "rb") as parent:
process = subprocess.Popen(
(*prefix, sys.executable, "-B", str(Path(__file__).resolve()), "--sandbox", str(parent.fileno())),
pass_fds=(parent.fileno(),), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True,
)
try:
stdout, stderr = process.communicate(timeout=120)
except BaseException:
os.killpg(process.pid, signal.SIGKILL)
process.communicate(timeout=5)
raise
self.assertEqual(process.returncode, 0, f"namespace scenario failed:\n{stdout}\n{stderr}")
print(stdout, end="")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--sandbox":
_sandbox(int(sys.argv[2]))
elif len(sys.argv) == 3 and sys.argv[1] == "--agent":
_agent(int(sys.argv[2]))
else:
unittest.main()
+11
View File
@@ -44,6 +44,8 @@ class CliSshTests(unittest.TestCase):
key=None,
user="root",
insecure_host_key=False,
port=22,
remote_command=[],
)
with patch("uvm.cli.os.execvp") as execvp:
@@ -61,6 +63,8 @@ class CliSshTests(unittest.TestCase):
key=None,
user="root",
insecure_host_key=True,
port=22,
remote_command=[],
)
with patch("uvm.cli.os.execvp") as execvp:
@@ -77,6 +81,8 @@ class CliSshTests(unittest.TestCase):
key=None,
user=None,
insecure_host_key=False,
port=22,
remote_command=[],
)
with patch("uvm.cli.os.execvp") as execvp:
@@ -105,6 +111,11 @@ class CliCreateTests(unittest.TestCase):
host_ip=None,
username="admin",
password="custom-secret",
name=None,
ports=[],
no_default_ports=False,
password_stdin=False,
json=False,
)
output = io.StringIO()
+886
View File
@@ -0,0 +1,886 @@
from __future__ import annotations
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, create_autospec, patch
from tests.test_cli import make_vm
from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord
from uvm.errors import UvmError
from uvm.lifecycle import LifecycleService, ListedVm, OperationResult
from uvm.system import CommandRunner
class TtyInput(io.StringIO):
def isatty(self) -> bool:
return True
class CliTestCase(unittest.TestCase):
def setUp(self) -> None:
self.vm = replace(
make_vm(), name="web", ports=(PortRule(22), PortRule(80, "HTTP")),
status="running", disk_ready=True,
)
self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True)
self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced")
self.lifecycle.list_vms.return_value = [self.listed]
self.lifecycle.inspect.return_value = self.listed
self.lifecycle.find_for_ssh.return_value = self.vm
self.lifecycle.resolve_targets.return_value = [self.vm]
self.lifecycle.set_ports.return_value = self.vm
self.lifecycle.run_many.side_effect = lambda action, ids, **_kwargs: [
OperationResult(id=vm_id, action=action, outcome="succeeded", error=None)
for vm_id in ids
]
self.lifecycle.create.side_effect = lambda spec: replace(
self.vm, cpu=spec.cpu, ram_mib=spec.ram_mib, username=spec.username,
password=spec.password, name=spec.name, ports=spec.ports,
)
self.application = SimpleNamespace(
settings=Settings(), lifecycle=self.lifecycle,
state_store=Mock(spec_set=("operation_lock",)),
)
self.application.state_store.operation_lock.side_effect = AssertionError(
"CLI must not take an operation lock"
)
def invoke(self, arguments, *, stdin=None):
stdout, stderr = io.StringIO(), io.StringIO()
with (
patch("uvm.cli.build_application", return_value=self.application) as build,
patch("uvm.cli.sys.stdin", stdin if stdin is not None else io.StringIO()),
redirect_stdout(stdout), redirect_stderr(stderr),
):
try:
result = main(arguments)
except SystemExit as error:
result = error.code
self.build_call = build.call_args
return result, stdout.getvalue(), stderr.getvalue()
class CliParsingTests(CliTestCase):
def test_guest_ip_alias_keeps_the_original_destination(self) -> None:
for option in ("--host-ip", "--guest-ip"):
with self.subTest(option=option):
args = build_parser(self.application).parse_args(["create", option, "10.42.0.8"])
self.assertEqual(args.host_ip, "10.42.0.8")
self.assertEqual(args.password, "root")
self.assertFalse(args.password_stdin)
def test_bulk_selectors_are_exclusive_and_required(self) -> None:
for command in (("start",), ("stop",), ("restart",), ("delete",), ("destroy",), ("ports", "sync")):
with self.subTest(command=command):
parser = build_parser(self.application)
named = parser.parse_args([*command, "web", "10.42.0.3", "--yes", "--json"])
self.assertEqual(named.vms, ["web", "10.42.0.3"])
self.assertFalse(named.all_vms)
all_vms = parser.parse_args([*command, "--all", "--dry-run"])
self.assertTrue(all_vms.all_vms)
self.assertEqual(all_vms.vms, [])
for selector in ([], ["web", "--all"], ["--all", "web"]):
code, _, _ = self.invoke([*command, *selector])
self.assertEqual(code, 2)
self.lifecycle.run_many.assert_not_called()
def test_ports_subcommand_selector_semantics(self) -> None:
parser = build_parser(self.application)
close = parser.parse_args(["ports", "close", "web", "--all"])
self.assertEqual(close.vm, "web")
self.assertTrue(close.all_ports)
listing = parser.parse_args(["ports", "list", "--all"])
self.assertTrue(listing.all_vms)
self.assertIsNone(listing.vm)
for arguments in (
["ports"], ["ports", "list"], ["ports", "list", "web", "--all"],
["ports", "open", "web"], ["ports", "open", "web", "--all"],
["ports", "close", "--all"], ["ports", "close", "web"],
["ports", "close", "web", "--ports", "80", "--all"],
["ports", "set", "web"], ["ports", "set", "web", "--all"],
["ports", "set", "web", "--ports", "80", "--none"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
self.lifecycle.set_ports.assert_not_called()
def test_mutually_exclusive_output_and_password_options(self) -> None:
for arguments in (
["list", "--json", "-q"],
["create", "--password", "root", "--password-stdin"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
self.lifecycle.create.assert_not_called()
def test_no_bare_identifier_guessing_or_public_port_flags(self) -> None:
for arguments in (
["web"], ["vm-test"], ["10.42.0.2"],
["create", "--public-port", "8080"],
["create", "--ports", "127.0.0.1:8080:80"],
["create", "--ports", "8080:80"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
self.lifecycle.create.assert_not_called()
def test_explicit_invalid_ports_fail_before_any_lifecycle_calls(self) -> None:
self.vm.ports = ()
for command in (["create"], ["ports", "open", "web"],
["ports", "close", "web"], ["ports", "set", "web"]):
for invalid in ("", "80/http", "443/HTTPS", "22/TCP", "80:UDP", "80:",
"80:HTTP:TCP", "22,", "80:HTTP,65536:TCP"):
with self.subTest(command=command, invalid=invalid):
code, stdout, _ = self.invoke([
*command, "--ports", "8080:TCP", "--ports", invalid, "--json",
])
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.assertEqual(self.lifecycle.mock_calls, [])
def test_port_help_documents_only_colon_syntax(self) -> None:
for command in (["create"], ["ports", "open"], ["ports", "close"], ["ports", "set"]):
with self.subTest(command=command):
code, stdout, stderr = self.invoke([*command, "--help"])
self.assertEqual(code, 0, stderr)
self.assertIn("PORT[:TYPE]", stdout)
self.assertIn("80:HTTP", stdout)
self.assertNotIn("80/HTTP", stdout)
def test_timeout_help_describes_process_signals_not_graceful_guest_shutdown(self) -> None:
for command in ("stop", "restart", "delete", "destroy"):
with self.subTest(command=command):
code, stdout, stderr = self.invoke([command, "--help"])
self.assertEqual(code, 0, stderr)
self.assertIn("SIGTERM", stdout)
self.assertIn("SIGKILL", stdout)
self.assertIn("does not coordinate guest shutdown", " ".join(stdout.split()))
self.assertNotIn("graceful", stdout.lower())
def test_server_and_ssh_ports_have_independent_defaults(self) -> None:
parser = build_parser(self.application)
self.assertEqual(parser.parse_args(["--serve"]).port, 8000)
self.assertEqual(parser.parse_args(["ssh", "web"]).port, 22)
self.assertEqual(parser.parse_args(["--port", "8123", "ssh", "web"]).port, 22)
for value in ("0", "65536", "tcp", "1.5"):
for command in (["--serve", "--port", value], ["ssh", "web", "--port", value]):
with self.subTest(command=command):
self.assertEqual(self.invoke(command)[0], 2)
def test_server_mode_is_preserved_and_cannot_run_a_command(self) -> None:
with patch("uvm.cli._run_server", return_value=0) as server:
code, _, _ = self.invoke(["--serve", "--host", "127.0.0.1", "--port", "8123"])
self.assertEqual(code, 0)
self.assertEqual(server.call_args.args[1].port, 8123)
self.assertEqual(self.invoke(["--serve", "list"])[0], 2)
self.assertEqual(self.invoke([])[0], 2)
class CliCreateCommandTests(CliTestCase):
def test_create_combines_default_ports_and_stdin_credentials(self) -> None:
code, stdout, stderr = self.invoke([
"create", "--name", "web", "--cpu", "0.5", "--ram", "1G",
"--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin",
"--ports", "80:http,443:https", "--ports", "8080:tcp", "--json",
], stdin=io.StringIO(" stdin-secret \r\n"))
self.assertEqual(code, 0, stderr)
spec = self.lifecycle.create.call_args.args[0]
self.assertEqual(spec.name, "web")
self.assertEqual(spec.cpu, 0.5)
self.assertEqual(spec.ram_mib, 1024)
self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8"))
self.assertEqual(spec.username, "admin")
self.assertEqual(spec.password, " stdin-secret ")
self.assertEqual(spec.ports, (
PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"), PortRule(8080),
))
self.assertEqual(json.loads(stdout)["ports"], [rule.to_dict() for rule in spec.ports])
self.assertNotIn("stdin-secret", stdout + stderr)
self.assertNotIn("password", json.loads(stdout))
self.assertIn("fractional", stderr)
self.lifecycle.run_many.assert_not_called()
def test_create_defaults_to_ssh_but_can_create_an_explicit_empty_policy(self) -> None:
for options, expected in (
([], (PortRule(22),)),
(["--ports", "22,80"], (PortRule(22), PortRule(80))),
(["--no-default-ports"], ()),
(["--no-default-ports", "--ports", "443:https"], (PortRule(443, "HTTPS"),)),
):
with self.subTest(options=options):
code, stdout, stderr = self.invoke(["create", *options, "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(self.lifecycle.create.call_args.args[0].ports, expected)
self.assertNotIn("WARNING", stdout)
self.assertIn("default password", stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "enforced")
def test_create_does_not_claim_enforcement_for_a_legacy_returned_record(self) -> None:
self.lifecycle.create.side_effect = None
self.lifecycle.create.return_value = replace(self.vm, ports=None)
code, stdout, stderr = self.invoke(["create", "--password", "stored-secret", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "unmanaged")
self.assertIsNone(json.loads(stdout)["ports"])
self.assertNotIn("stored-secret", stdout + stderr)
def test_invalid_create_inputs_do_not_call_the_service(self) -> None:
for options in (
["--name", "../bad"], ["--cpu", "nan"], ["--ram", "12"],
["--guest-ip", "invalid"], ["--username", "Invalid"],
["--ports", "80,70000"], ["--ports", "80:http", "--ports", "53:udp"],
["--ports", ""],
):
with self.subTest(options=options):
code, stdout, _ = self.invoke(["create", *options, "--json"])
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.lifecycle.create.assert_not_called()
def test_invalid_stdin_password_is_not_echoed(self) -> None:
for password in ("", "\n", "secret\tcontrol\n", "long-secret-" * 20):
with self.subTest(password=password):
code, stdout, stderr = self.invoke(
["create", "--password-stdin", "--json"], stdin=io.StringIO(password)
)
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.assertNotIn("secret", stderr)
self.lifecycle.create.assert_not_called()
def test_json_uses_a_whitelist_not_persisted_vm_serialization(self) -> None:
with patch.object(VmRecord, "to_dict", side_effect=AssertionError("unsafe serialization")):
for arguments in (
["create", "--password", "stored-secret", "--json"],
["list", "--json"], ["inspect", "web", "--json"],
["ports", "list", "--all", "--json"],
["ports", "open", "web", "--ports", "443", "--json"],
["delete", "--all", "--dry-run", "--json"],
["ports", "set", "web", "--none", "--dry-run", "--json"],
):
with self.subTest(arguments=arguments):
code, stdout, stderr = self.invoke(arguments)
self.assertEqual(code, 0, stderr)
json.loads(stdout)
self.assertNotIn("password", stdout)
self.assertNotIn("stored-secret", stdout + stderr)
def test_application_and_subprocess_progress_use_stderr_for_json(self) -> None:
self.invoke(["list", "--json"])
runner = CommandRunner(emit=self.build_call.kwargs["emit"])
create_vm = self.lifecycle.create.side_effect
def create(spec):
runner.run(["cli-output-probe"])
return create_vm(spec)
def subprocess_run(_command, **kwargs):
self.assertIs(kwargs["stdout"], sys.stderr)
print("child process progress", file=kwargs["stdout"])
return SimpleNamespace(returncode=0, stdout="", stderr="")
self.lifecycle.create.side_effect = create
with patch("uvm.system.subprocess.run", side_effect=subprocess_run) as run:
code, stdout, stderr = self.invoke(["create", "--password", "stored-secret", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["id"], self.vm.id)
self.assertIn("+ cli-output-probe", stderr)
self.assertIn("child process progress", stderr)
self.assertNotIn("stored-secret", stdout + stderr)
run.assert_called_once()
class CliListingTests(CliTestCase):
def test_status_filters_use_observed_status_and_quiet_prints_only_ids(self) -> None:
self.lifecycle.list_vms.return_value = [
ListedVm(vm=self.vm, observed_status="dead", policy_status="missing"),
ListedVm(
vm=replace(self.vm, id="vm-two", status="stopped"),
observed_status="stopped", policy_status="inactive",
),
ListedVm(
vm=replace(self.vm, id="vm-three"), observed_status="running", policy_status="enforced",
),
]
code, stdout, stderr = self.invoke(["list", "--status", "dead", "--status", "stopped", "-q"])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, "vm-test\nvm-two\n")
code, stdout, _ = self.invoke(["list", "--status", "dead", "--json"])
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout)[0]["observed_status"], "dead")
self.assertEqual(json.loads(stdout)[0]["status"], "running")
self.assertEqual(json.loads(stdout)[0]["policy_status"], "missing")
def test_empty_list_formats(self) -> None:
self.lifecycle.list_vms.return_value = []
for options, expected in (([], "No VMs.\n"), (["-q"], ""), (["--json"], "[]\n")):
with self.subTest(options=options):
code, stdout, _ = self.invoke(["list", *options])
self.assertEqual(code, 0)
self.assertEqual(stdout, expected)
def test_inspect_delegates_identifier_and_exposes_observed_state(self) -> None:
self.lifecycle.inspect.return_value = ListedVm(
vm=self.vm, observed_status="dead", policy_status="drift",
)
for options in ([], ["--json"]):
code, stdout, stderr = self.invoke(["inspect", "10.42.0.2", *options])
self.assertEqual(code, 0, stderr)
self.assertIn("dead", stdout)
self.assertIn("drift", stdout)
self.assertNotIn("stored-secret", stdout)
self.lifecycle.inspect.assert_called_with("10.42.0.2")
class CliBulkTests(CliTestCase):
def test_named_lifecycle_actions_use_resolved_ids_and_configured_timeouts(self) -> None:
self.application.settings = replace(Settings(), terminate_timeout_s=7.5)
self.lifecycle.resolve_targets.return_value = [self.vm, replace(self.vm, id="vm-two")]
for action in ("start", "stop", "restart", "delete", "destroy"):
with self.subTest(action=action):
code, _, stderr = self.invoke([action, "web", "10.42.0.3", "-y"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resolve_targets.assert_called_with(["web", "10.42.0.3"], all_vms=False)
self.lifecycle.run_many.assert_called_with(
"delete" if action == "destroy" else action,
("vm-test", "vm-two"), timeout=None if action == "start" else 7.5,
)
def test_explicit_timeouts_are_validated_before_target_resolution(self) -> None:
for action in ("stop", "restart", "delete", "destroy"):
for timeout in ("-1", "nan", "inf", "bad"):
with self.subTest(action=action, timeout=timeout):
code, _, _ = self.invoke([action, "web", "--timeout", timeout, "-y"])
self.assertEqual(code, 2)
self.lifecycle.resolve_targets.assert_not_called()
self.lifecycle.run_many.assert_not_called()
self.assertEqual(self.invoke(["stop", "web", "--timeout", "2.25"])[0], 0)
self.lifecycle.run_many.assert_called_once_with("stop", ("vm-test",), timeout=2.25)
def test_every_delete_and_all_selector_requires_tty_or_yes(self) -> None:
for arguments in (
["delete", "web"], ["destroy", "web"],
["start", "--all"], ["stop", "--all"], ["restart", "--all"],
["delete", "--all"], ["ports", "sync", "--all"],
):
with self.subTest(arguments=arguments):
code, stdout, stderr = self.invoke([*arguments, "--json"], stdin=io.StringIO("yes\n"))
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("--yes", stderr)
self.lifecycle.run_many.assert_not_called()
self.assertEqual(self.invoke(["start", "--all", "-y"])[0], 0)
def test_snapshot_is_taken_before_confirmation_and_never_expanded(self) -> None:
events = []
selected = [self.vm]
def resolve(identifiers, *, all_vms):
self.assertEqual(identifiers, [])
self.assertTrue(all_vms)
events.append("resolve")
return selected
def approve():
events.append("prompt")
selected.append(replace(self.vm, id="vm-new"))
self.vm.id = "vm-renamed-during-prompt"
return "yes\n"
self.lifecycle.resolve_targets.side_effect = resolve
stdin = Mock(isatty=Mock(return_value=True), readline=Mock(side_effect=approve))
code, stdout, stderr = self.invoke(["delete", "--all", "--json"], stdin=stdin)
self.assertEqual(code, 0, stderr)
self.assertEqual(events, ["resolve", "prompt"])
self.lifecycle.resolve_targets.assert_called_once()
self.lifecycle.run_many.assert_called_once_with("delete", ("vm-test",), timeout=5.0)
self.assertIn("vm-test", stderr)
self.assertEqual([result["id"] for result in json.loads(stdout)], ["vm-test"])
def test_declining_confirmation_is_a_noop_and_keeps_stdout_json(self) -> None:
for answer in ("n\n", "\n", "", "not yes\n"):
with self.subTest(answer=answer):
code, stdout, stderr = self.invoke(["delete", "web", "--json"], stdin=TtyInput(answer))
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout), [])
self.assertIn("Cancelled", stderr)
self.lifecycle.run_many.assert_not_called()
def test_dry_run_neither_mutates_nor_prompts(self) -> None:
for action in ("start", "stop", "restart", "delete", "destroy"):
with self.subTest(action=action):
stdin = Mock(isatty=Mock(side_effect=AssertionError("must not prompt")))
code, stdout, stderr = self.invoke([action, "--all", "--dry-run", "--json"], stdin=stdin)
self.assertEqual(code, 0, stderr)
preview = json.loads(stdout)
self.assertTrue(preview["dry_run"])
self.assertEqual(preview["action"], "delete" if action == "destroy" else action)
self.assertEqual([vm["id"] for vm in preview["targets"]], [self.vm.id])
self.lifecycle.run_many.assert_not_called()
def test_empty_target_set_is_success_without_confirmation_or_mutation(self) -> None:
self.lifecycle.resolve_targets.return_value = []
for command in (["delete"], ["start"], ["ports", "sync"]):
with self.subTest(command=command):
code, stdout, stderr = self.invoke([*command, "--all", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout), [])
self.lifecycle.run_many.assert_not_called()
def test_failed_target_resolution_aborts_the_entire_bulk_request(self) -> None:
self.lifecycle.resolve_targets.side_effect = UvmError("VM not found: missing")
code, stdout, stderr = self.invoke(["stop", "web", "missing", "--json"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("missing", stderr)
self.lifecycle.run_many.assert_not_called()
def test_result_outcomes_determine_exit_code_and_preserve_each_result(self) -> None:
for outcomes, expected in (
(("succeeded", "skipped"), 0),
(("succeeded", "failed"), 1),
(("failed", "interrupted", "skipped"), 130),
):
with self.subTest(outcomes=outcomes):
self.lifecycle.run_many.side_effect = None
self.lifecycle.run_many.return_value = [
OperationResult(id=f"vm-{index}", action="stop", outcome=outcome,
error="failure detail" if outcome == "failed" else None)
for index, outcome in enumerate(outcomes)
]
code, stdout, _ = self.invoke(["stop", "web", "--json"])
self.assertEqual(code, expected)
data = json.loads(stdout)
self.assertEqual([result["outcome"] for result in data], list(outcomes))
self.assertTrue(all(set(result) == {"id", "action", "outcome", "error"} for result in data))
def test_keyboard_interrupt_returns_130_without_traceback(self) -> None:
self.lifecycle.run_many.side_effect = KeyboardInterrupt
code, stdout, stderr = self.invoke(["stop", "web"])
self.assertEqual(code, 130)
self.assertEqual(stdout, "")
self.assertIn("interrupted", stderr)
class CliPortsTests(CliTestCase):
def test_list_distinguishes_unmanaged_empty_and_enforced_policies(self) -> None:
self.lifecycle.list_vms.return_value = [
self.listed,
ListedVm(
vm=replace(self.vm, id="vm-legacy", ports=None),
observed_status="running", policy_status="unmanaged",
),
ListedVm(
vm=replace(self.vm, id="vm-empty", ports=()),
observed_status="running", policy_status="enforced",
),
]
code, stdout, stderr = self.invoke(["ports", "list", "--all", "--json"])
self.assertEqual(code, 0, stderr)
data = json.loads(stdout)
self.assertEqual(data[0]["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}])
self.assertIsNone(data[1]["ports"])
self.assertEqual(data[1]["policy_status"], "unmanaged")
self.assertEqual(data[2]["ports"], [])
code, stdout, _ = self.invoke(["ports", "list", "web"])
self.assertEqual(code, 0)
self.assertIn("PRIVATE-IP", stdout)
self.assertIn("22:TCP,80:HTTP", stdout)
self.assertNotIn("22/TCP", stdout)
self.lifecycle.inspect.assert_called_with("web")
def test_open_and_close_call_service_modes_using_resolved_id(self) -> None:
for mode, values, expected in (
("open", "443:https,8080:tcp", (PortRule(443, "HTTPS"), PortRule(8080))),
("close", "80:tcp", (PortRule(80),)),
):
with self.subTest(mode=mode):
code, _, stderr = self.invoke(["ports", mode, "web", "--ports", values, "--json"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_with(self.vm.id, expected, mode=mode)
def test_mutation_json_reports_policy_from_the_successful_returned_state(self) -> None:
self.lifecycle.inspect.return_value = replace(self.listed, policy_status="drift")
for status, policy_status in (("running", "enforced"), ("stopped", "inactive")):
self.lifecycle.set_ports.return_value = replace(self.vm, status=status, operation=None)
for options in (
["open", "web", "--ports", "443:HTTPS"],
["close", "web", "--ports", "80:TCP"],
["set", "web", "--ports", "443:HTTPS", "--yes"],
):
with self.subTest(status=status, options=options):
code, stdout, stderr = self.invoke(["ports", *options, "--json"])
self.assertEqual(code, 0, stderr)
data = json.loads(stdout)
self.assertEqual(data["status"], status)
self.assertEqual(data["policy_status"], policy_status)
self.assertIsNone(data["operation"])
def test_invalid_port_in_later_argument_aborts_before_service_calls(self) -> None:
for mode in ("open", "close", "set"):
with self.subTest(mode=mode):
code, stdout, _ = self.invoke([
"ports", mode, "web", "--ports", "80:http", "--ports", "53:udp", "--json",
])
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.lifecycle.inspect.assert_not_called()
self.lifecycle.set_ports.assert_not_called()
def test_set_close_all_and_removing_ssh_require_confirmation(self) -> None:
for options in (
["set", "web", "--ports", "22,80"], ["set", "web", "--none"],
["close", "web", "--all"], ["close", "web", "--ports", "22"],
):
with self.subTest(options=options):
code, stdout, stderr = self.invoke(["ports", *options, "--json"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("--yes", stderr)
self.lifecycle.set_ports.assert_not_called()
def test_set_and_close_all_replace_policy_without_touching_other_vms(self) -> None:
for options, expected in (
(["close", "web", "--all"], ()),
(["set", "web", "--none"], ()),
(["set", "web", "--ports", "443:https"], (PortRule(443, "HTTPS"),)),
):
with self.subTest(options=options):
code, _, stderr = self.invoke(["ports", *options, "--yes"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_with(self.vm.id, expected, mode="set")
self.lifecycle.resolve_targets.assert_not_called()
self.lifecycle.run_many.assert_not_called()
def test_empty_managed_port_target_is_a_noop_without_confirmation(self) -> None:
for status, policy_status in (("running", "enforced"), ("stopped", "inactive")):
vm = replace(self.vm, ports=(), status=status, operation=None)
self.lifecycle.inspect.return_value = ListedVm(vm, status, policy_status)
for options in (
["close", "web", "--all"], ["close", "web", "--ports", "22:TCP"],
["set", "web", "--none"],
):
for output in ([], ["--json"]):
with self.subTest(status=status, options=options, output=output):
stdin = Mock(isatty=Mock(side_effect=AssertionError("must not prompt")))
code, stdout, stderr = self.invoke(["ports", *options, *output], stdin=stdin)
self.assertEqual(code, 0, stderr)
if output:
self.assertEqual(json.loads(stdout)["ports"], [])
self.assertEqual(json.loads(stdout)["policy_status"], policy_status)
else:
self.assertIn("no port changes", stdout)
self.assertEqual(stderr, "")
self.lifecycle.set_ports.assert_not_called()
self.lifecycle.run_many.assert_not_called()
def test_empty_policy_requires_service_when_unverified_or_pending(self) -> None:
for status, observed, policy, operation in (
("running", "running", "missing", None),
("running", "running", "drift", None),
("running", "running", "failed", None),
("running", "running", "pending", None),
("running", "running", "unknown", None),
("running", "running", "unmanaged", None),
("running", "running", "enforced", "ports-sync"),
("stopped", "stopped", "inactive", "ports-sync"),
("failed", "failed", "enforced", None),
("running", "dead", "enforced", None),
("stopped", "running", "inactive", None),
("stopped", "stopped", "missing", None),
):
vm = replace(self.vm, ports=(), status=status, operation=operation)
self.lifecycle.inspect.return_value = ListedVm(vm, observed, policy)
updated_status = "stopped" if status == observed == "stopped" else "running"
self.lifecycle.set_ports.return_value = replace(vm, status=updated_status, operation=None)
for options, requested, mode in (
(["close", "web", "--all"], (), "set"),
(["close", "web", "--ports", "22:TCP"], (PortRule(22),), "close"),
(["set", "web", "--none"], (), "set"),
):
with self.subTest(state=(status, observed, policy, operation), options=options):
self.lifecycle.set_ports.reset_mock()
code, stdout, stderr = self.invoke(["ports", *options, "--yes", "--json"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_once_with(vm.id, requested, mode=mode)
self.assertEqual(json.loads(stdout)["policy_status"],
"inactive" if updated_status == "stopped" else "enforced")
self.assertIsNone(json.loads(stdout)["operation"])
def test_empty_policy_reconciliation_failure_is_not_reported_as_a_noop(self) -> None:
vm = replace(self.vm, ports=(), status="failed", operation="ports-sync")
self.lifecycle.inspect.return_value = ListedVm(vm, "failed", "drift")
self.lifecycle.set_ports.side_effect = UvmError("policy reconciliation failed")
code, stdout, stderr = self.invoke(["ports", "close", "web", "--all", "--yes", "--json"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("policy reconciliation failed", stderr)
self.lifecycle.set_ports.assert_called_once_with(vm.id, (), mode="set")
def test_confirmation_uses_stable_id_and_decline_does_not_mutate(self) -> None:
code, stdout, stderr = self.invoke(
["ports", "close", "web", "--ports", "22", "--json"], stdin=TtyInput("n\n")
)
self.assertEqual(code, 0)
self.assertIn(self.vm.id, stderr)
self.assertEqual(json.loads(stdout), [])
self.lifecycle.set_ports.assert_not_called()
code, _, _ = self.invoke(
["ports", "close", "web", "--ports", "22"], stdin=TtyInput("YES\n")
)
self.assertEqual(code, 0)
self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (PortRule(22),), mode="close")
def test_port_dry_runs_render_effective_changes_without_mutation(self) -> None:
for options, expected in (
(["open", "web", "--ports", "443:https"],
(PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))),
(["close", "web", "--ports", "80:tcp"], (PortRule(22),)),
(["close", "web", "--ports", "22"], (PortRule(80, "HTTP"),)),
(["close", "web", "--all"], ()),
(["set", "web", "--none"], ()),
(["set", "web", "--ports", "443:https"], (PortRule(443, "HTTPS"),)),
):
with self.subTest(options=options):
code, stdout, stderr = self.invoke(["ports", *options, "--dry-run", "--json"])
self.assertEqual(code, 0, stderr)
data = json.loads(stdout)
self.assertTrue(data["dry_run"])
self.assertEqual(data["changes"]["ports"], {
"before": [rule.to_dict() for rule in self.vm.ports],
"after": [rule.to_dict() for rule in expected],
})
self.lifecycle.set_ports.assert_not_called()
def test_open_preview_preserves_labels_of_existing_ports(self) -> None:
code, stdout, stderr = self.invoke([
"ports", "open", "web", "--ports", "22:HTTPS,80:TCP", "--ports", "443:HTTPS",
"--dry-run", "--json",
])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["changes"]["ports"], {
"before": [PortRule(22).to_dict(), PortRule(80, "HTTP").to_dict()],
"after": [
PortRule(22).to_dict(), PortRule(80, "HTTP").to_dict(), PortRule(443, "HTTPS").to_dict(),
],
})
self.assertEqual(self.vm.ports, (PortRule(22), PortRule(80, "HTTP")))
self.lifecycle.set_ports.assert_not_called()
def test_unmanaged_open_and_close_are_not_silently_treated_as_empty(self) -> None:
self.vm.ports = None
for options in (
["open", "web", "--ports", "80"], ["close", "web", "--ports", "80"],
["close", "web", "--all", "--yes"],
):
for preview in ([], ["--dry-run"]):
with self.subTest(options=options, preview=preview):
code, _, stderr = self.invoke(["ports", *options, *preview])
self.assertEqual(code, 1)
self.assertIn("unmanaged", stderr)
self.lifecycle.set_ports.assert_not_called()
def test_legacy_migration_stopped_requirement_is_owned_by_service(self) -> None:
self.vm.ports = None
self.lifecycle.set_ports.side_effect = UvmError("stop the VM before adopting a port policy")
code, _, stderr = self.invoke(["ports", "set", "web", "--ports", "22", "--yes"])
self.assertEqual(code, 1)
self.assertIn("stop", stderr)
self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (PortRule(22),), mode="set")
def test_setting_an_empty_legacy_policy_is_not_a_noop(self) -> None:
self.vm.ports = None
code, _, stderr = self.invoke(["ports", "set", "web", "--none"])
self.assertEqual(code, 1)
self.assertIn("--yes", stderr)
self.lifecycle.set_ports.assert_not_called()
self.lifecycle.set_ports.return_value = replace(self.vm, ports=(), status="stopped")
code, _, stderr = self.invoke(["ports", "set", "web", "--none", "--yes"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (), mode="set")
def test_sync_uses_bulk_action_and_supports_preview(self) -> None:
code, _, stderr = self.invoke(["ports", "sync", "web", "--json"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resolve_targets.assert_called_with(["web"], all_vms=False)
self.lifecycle.run_many.assert_called_once_with("ports-sync", (self.vm.id,), timeout=None)
self.lifecycle.run_many.reset_mock()
code, stdout, stderr = self.invoke(["ports", "sync", "--all", "--dry-run", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["action"], "ports-sync")
self.lifecycle.run_many.assert_not_called()
self.lifecycle.sync_ports.assert_not_called()
class CliSshCommandTests(CliTestCase):
def test_options_after_vm_are_not_swallowed_by_remote_command(self) -> None:
with patch("uvm.cli.os.execvp") as execute:
code, _, stderr = self.invoke([
"ssh", "web", "--key", "/tmp/key with spaces", "--port", "2222", "--user", "admin",
"--", "printf", "%s", "--user", "--port", "literal argument",
])
self.assertEqual(code, 0, stderr)
self.lifecycle.find_for_ssh.assert_called_once_with("web")
executable, arguments = execute.call_args.args
self.assertEqual(executable, "ssh")
self.assertEqual(arguments[:5], ["ssh", "-p", "2222", "-i", "/tmp/key with spaces"])
self.assertIn("HostKeyAlias=uvm-vm-test", arguments)
self.assertEqual(arguments[-6:], [
"admin@10.42.0.2", "printf", "%s", "--user", "--port", "literal argument",
])
def test_options_before_vm_and_remote_separator_are_supported(self) -> None:
with patch("uvm.cli.os.execvp") as execute:
code, _, stderr = self.invoke([
"ssh", "--port", "2222", "--insecure-host-key", "web", "--", "uname", "-a",
])
self.assertEqual(code, 0, stderr)
arguments = execute.call_args.args[1]
self.assertIn("StrictHostKeyChecking=no", arguments)
self.assertNotIn("HostKeyAlias=uvm-vm-test", arguments)
self.assertEqual(arguments[-3:], ["admin@10.42.0.2", "uname", "-a"])
def test_remote_commands_require_literal_separator_and_unknown_options_fail(self) -> None:
with patch("uvm.cli.os.execvp") as execute:
for arguments in (
["ssh", "web", "uname", "-a"], ["ssh", "web", "--unknown"],
["ssh", "web", "--bad-option", "--", "uname"],
["ssh", "web", "--user", "-bad"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
execute.assert_not_called()
def test_ssh_help_and_option_values_after_separator_belong_to_remote_command(self) -> None:
for remote in (["--help"], ["--port", "not-a-port", "--user", "Not-A-Local-User"], ["--", "-x"]):
with self.subTest(remote=remote), patch("uvm.cli.os.execvp") as execute:
code, _, stderr = self.invoke(["ssh", "web", "--port", "2222", "--", *remote])
self.assertEqual(code, 0, stderr)
arguments = execute.call_args.args[1]
self.assertEqual(arguments[:3], ["ssh", "-p", "2222"])
self.assertEqual(arguments[-len(remote):], remote)
with patch("uvm.cli.os.execvp") as execute:
self.assertEqual(self.invoke(["ssh", "web", "--help"])[0], 0)
execute.assert_not_called()
def test_missing_ssh_executable_is_reported_without_traceback(self) -> None:
with patch("uvm.cli.os.execvp", side_effect=FileNotFoundError("ssh not found")):
code, stdout, stderr = self.invoke(["ssh", "web"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("ssh not found", stderr)
class CliLogTests(CliTestCase):
def test_tail_handles_empty_partial_and_fewer_lines(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "safe.log"
self.lifecycle.log_path.return_value = path
for content, count, expected in (
(b"", 100, ""), (b"one\ntwo\nthree\n", 2, "two\nthree\n"),
(b"one\ntwo\nthree", 2, "two\nthree"), (b"one\n", 100, "one\n"),
(b"one\ntwo\n", 0, ""), (b"\n\n", 1, "\n"),
(b"bad\xff\n", 1, "bad\ufffd\n"),
):
with self.subTest(content=content, count=count):
path.write_bytes(content)
code, stdout, stderr = self.invoke(["logs", "web", "--tail", str(count)])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, expected)
self.lifecycle.log_path.assert_called_with("web")
def test_default_tail_is_100_and_reads_are_bounded_near_eof(self) -> None:
content = b"not in tail\n" * 200000 + b"last line\n" * 100
stream = io.BytesIO(content)
bounded = Mock(wraps=stream)
path = Mock()
path.open.return_value.__enter__ = Mock(return_value=bounded)
path.open.return_value.__exit__ = Mock(return_value=False)
self.lifecycle.log_path.return_value = path
code, stdout, stderr = self.invoke(["logs", "web"])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, "last line\n" * 100)
reads = [call.args[0] for call in bounded.read.call_args_list]
self.assertTrue(all(0 < size <= 65536 for size in reads))
self.assertLess(sum(reads), 131072)
path.open.assert_called_once_with("rb")
def test_multibyte_and_very_long_lines_are_streamed_correctly(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "safe.log"
content = "skip\n" + "x" * 65535 + "\u00e9" + "z" * 70000 + "\n"
path.write_text(content, encoding="utf-8")
self.lifecycle.log_path.return_value = path
code, stdout, stderr = self.invoke(["logs", "web", "--tail", "1"])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, content.removeprefix("skip\n"))
def test_follow_uses_safe_path_without_holding_an_operation_lock(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "safe.log"
path.write_text("old\n", encoding="utf-8")
self.lifecycle.log_path.return_value = path
events = []
def append_then_interrupt(_seconds):
if events:
raise KeyboardInterrupt
with path.open("a", encoding="utf-8") as stream:
stream.write("new\n")
events.append("appended")
with patch("uvm.cli.time.sleep", side_effect=append_then_interrupt):
code, stdout, stderr = self.invoke(["logs", "web", "--tail", "0", "-f"])
self.assertEqual(code, 130)
self.assertEqual(stdout, "new\n")
self.assertIn("interrupted", stderr)
self.lifecycle.log_path.assert_called_once_with("web")
self.application.state_store.operation_lock.assert_not_called()
def test_missing_or_unsafe_log_path_fails_without_opening_record_path(self) -> None:
self.lifecycle.log_path.side_effect = UvmError("unsafe VM log path")
with patch("uvm.cli.Path.open") as open_path:
code, stdout, stderr = self.invoke(["logs", "web"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("unsafe", stderr)
open_path.assert_not_called()
self.lifecycle.log_path.side_effect = None
with tempfile.TemporaryDirectory() as directory:
self.lifecycle.log_path.return_value = Path(directory) / "missing.log"
self.assertEqual(self.invoke(["logs", "web"])[0], 1)
def test_invalid_tail_is_an_argument_error(self) -> None:
for value in ("-1", "1.5", "all"):
with self.subTest(value=value):
self.assertEqual(self.invoke(["logs", "web", "--tail", value])[0], 2)
self.lifecycle.log_path.assert_not_called()
class CliDoctorTests(CliTestCase):
def test_doctor_reports_all_checks_and_fails_if_any_check_fails(self) -> None:
for healthy in (True, False):
with self.subTest(healthy=healthy):
self.lifecycle.diagnostics.return_value = [
{"name": "kvm", "ok": True, "message": "available", "password": "not-output"},
{"name": "firewall", "ok": healthy, "message": "checked"},
]
for options in ([], ["--json"]):
code, stdout, stderr = self.invoke(["doctor", *options])
self.assertEqual(code, 0 if healthy else 1, stderr)
self.assertIn("firewall", stdout)
self.assertNotIn("password", stdout)
self.assertNotIn("not-output", stdout)
if options:
self.assertEqual(len(json.loads(stdout)), 2)
+926
View File
@@ -0,0 +1,926 @@
"""Unprivileged command, drift and rule-semantics tests, not packet verification."""
from __future__ import annotations
import copy
import json
import os
import re
import shutil
import subprocess
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord
from uvm.errors import CommandError, UvmError, ValidationError
from uvm.firewall import FirewallManager, _ct, _normalize
from uvm.system import CommandResult
from uvm.validation import parse_ports
def vm_record(**changes):
fields = dict(id="vm-one", tap="tap-one", guest_ip="10.42.0.2",
mac="02:fc:00:00:00:01", ports=(PortRule(8080),))
fields.update(changes)
return SimpleNamespace(**fields)
class FakeRunner:
"""Model atomic nft transactions and readback without executing commands."""
def __init__(self):
self.calls = []
self.tables = {}
self.links = []
self.handle = 1
self.release = "6.8.0-test"
self.fail_command = None
self.fail_batch = False
self.before_batch = None
self.after_batch = None
self.listing = None
self.ct_delete_code = 0
self.ct_remaining = ""
def run(self, command, *, check=True, capture=False, input_text=None, timeout=None):
args = tuple(command)
self.calls.append((args, dict(check=check, capture=capture,
input_text=input_text, timeout=timeout)))
if args == self.fail_command:
raise CommandError("simulated permission failure or timeout")
stdout, code = "", 0
if args == ("uname", "-r"):
stdout = self.release
elif args == ("ip", "-j", "-d", "link", "show"):
stdout = json.dumps(self.links)
elif args[:2] == ("nft", "--json") and "list" in args:
if self.listing is not None:
stdout = self.listing
else:
if "tables" in args:
objects = [items[0] for items in self.tables.values()]
else:
if args[-1] not in self.tables:
raise CommandError("table disappeared")
objects = self.tables[args[-1]]
stdout = json.dumps({"nftables": [{"metainfo": {"version": "1.0.9"}}, *objects]})
elif args[:2] == ("nft", "--json") and args[-2:] == ("-f", "-"):
commands = json.loads(input_text)["nftables"]
if self.before_batch:
self.before_batch()
self.before_batch = None
if self.fail_batch:
raise CommandError("atomic nft transaction rejected")
candidate = copy.deepcopy(self.tables)
for command in commands:
operation, item = next(iter(command.items()))
kind, obj = next(iter(item.items()))
# Match nft 1.0.9 json_parse_set_expr(), not an echo-only fake.
if kind == "set" and obj.get("elem") == []:
raise CommandError("Invalid set elem expression")
if operation == "delete":
name = next((name for name, items in candidate.items()
if items[0]["table"].get("handle") == obj["handle"]), None)
if name is None:
raise CommandError("table handle disappeared")
del candidate[name]
else:
obj["handle"] = self.handle
self.handle += 1
if kind == "table":
if obj["name"] in candidate:
raise CommandError("exclusive create found an existing table")
candidate[obj["name"]] = [item]
else:
candidate[obj["table"]].append(item)
if "--check" not in args:
self.tables = candidate
if self.after_batch:
self.after_batch()
elif args == ("conntrack", "--version"):
stdout = "conntrack v1.4.8 (conntrack-tools)"
elif args[:2] == ("conntrack", "--delete"):
code = self.ct_delete_code
elif args[:2] == ("conntrack", "--list"):
stdout = self.ct_remaining
else:
raise AssertionError(f"unexpected command: {args}")
if check and code:
raise CommandError("simulated command failure")
return CommandResult(args=args, returncode=code, stdout=stdout)
@property
def batches(self):
return [json.loads(options["input_text"])["nftables"] for args, options in self.calls
if args[0] == "nft" and options["input_text"] is not None and "--check" not in args]
class FirewallManagerTests(unittest.TestCase):
def setUp(self):
self.runner = FakeRunner()
self.manager = FirewallManager(Settings(), self.runner)
self.vm = vm_record()
def policy(self):
return next(iter(self.runner.tables.values()))
def test_apply_is_atomic_bounded_and_contains_no_nat_or_global_mutations(self):
self.manager.apply(self.vm)
self.assertEqual(self.manager.status(self.vm), "enforced")
self.assertEqual(len(self.runner.batches), 1)
self.assertIn("create", self.runner.batches[0][0])
for args, options in self.runner.calls:
if args[0] != "nft":
self.assertEqual(args, ("ip", "-j", "-d", "link", "show"))
self.assertTrue(options["capture"])
self.assertGreater(options["timeout"], 0)
self.assertLessEqual(options["timeout"], 10)
rendered = json.dumps(self.runner.batches)
for forbidden in ("flush", "dnat", "snat", "masquerade", "redirect", "notrack"):
self.assertNotIn(f'"{forbidden}"', rendered)
for item in self.policy():
self.assertEqual(next(iter(item.values()))["family"], "bridge")
def test_update_and_remove_do_not_rebuild_or_delete_siblings(self):
self.manager.apply(self.vm)
sibling = vm_record(id="vm-two", tap="tap-two", guest_ip="10.42.0.3")
self.manager.apply(sibling)
name = next(reversed(self.runner.tables))
saved = copy.deepcopy(self.runner.tables[name])
old_handle = self.policy()[0]["table"]["handle"]
self.vm.ports = ()
self.manager.apply(self.vm)
self.assertEqual(self.runner.batches[-1][0], {
"delete": {"table": {"family": "bridge", "handle": old_handle}},
})
self.assertEqual(self.runner.tables[name], saved)
self.assertEqual(self.manager.status(self.vm), "enforced")
self.manager.remove(self.vm)
self.manager.remove(self.vm)
self.assertEqual(self.runner.tables, {name: saved})
self.assertEqual(self.manager.status(self.vm), "missing")
def test_table_identity_is_stable_but_scoped_to_base_and_vm_id(self):
self.manager.apply(self.vm)
name = next(iter(self.runner.tables))
self.vm.ports = ()
self.manager.apply(self.vm)
self.assertEqual(list(self.runner.tables), [name])
other = FirewallManager(replace(Settings(), base=Path("/var/lib/another-uvm")), self.runner)
other.apply(self.vm)
self.assertEqual(len(self.runner.tables), 2)
self.assertRegex(name, r"^uvm_fw_[0-9a-f]{24}$")
self.assertLess(len(self.policy()[0]["table"]["comment"]), 128)
def test_legacy_is_unmanaged_and_normal_apply_refuses_without_commands(self):
self.vm.ports = None
with self.assertRaises(ValidationError):
self.manager.apply(self.vm)
self.assertEqual(self.runner.calls, [])
self.assertEqual(self.manager.status(self.vm), "unmanaged")
self.vm.guest_ip, self.vm.mac = "damaged", "damaged"
self.manager.apply(self.vm, quarantine=True)
self.assertEqual(self.manager.status(self.vm), "quarantined")
def test_none_does_not_hide_an_existing_managed_table(self):
self.manager.apply(self.vm)
self.vm.ports = None
self.assertEqual(self.manager.status(self.vm), "drift")
def test_protocol_aliases_deduplicate_tcp_ports_and_allow_empty_policy(self):
self.vm.ports = tuple(PortRule(port, protocol) for port, protocol in (
(443, "HTTPS"), (8080, "HTTP"), (443, "TCP"), (22, "TCP"),
))
self.manager.apply(self.vm)
self.assertEqual(next(item["set"]["elem"] for item in self.policy() if "set" in item),
[22, 443, 8080])
self.vm.ports = ()
self.manager.apply(self.vm)
self.assertNotIn("elem", next(item["set"] for item in self.policy() if "set" in item))
def test_real_port_models_and_cli_syntax_reach_the_same_tcp_set(self):
ports = parse_ports(("1:tcp,80:http", "443:HTTPS,65535"))
vm = VmRecord(id="vm-real", cpu=1, ram_mib=128, guest_ip="10.42.0.2",
gateway="10.42.0.1", tap="tap-real", mac=self.vm.mac,
socket="", config="", log="", ports=ports,
network="10.42.0.0/24", bridge="uvm0")
self.manager.apply(vm)
self.assertEqual(self.manager.status(vm), "enforced")
self.assertEqual(next(item["set"]["elem"] for item in self.policy() if "set" in item),
[1, 80, 443, 65535])
for text in ("0", "65536", "-1", "1.5", "80:UDP", "80:HTTP:TCP", "TCP:80", "80:", ":TCP"):
with self.subTest(text=text), self.assertRaises(ValidationError):
parse_ports((text,))
def test_invalid_ports_are_rejected_before_commands(self):
for port, protocol in ((True, "TCP"), (0, "TCP"), (-1, "TCP"), (65536, "TCP"),
(80.0, "TCP"), ("80", "TCP"), (80, "tcp"), (53, "UDP"),
(80, "HTTP:TCP"), (80, None), (80, "HTTP; flush ruleset")):
with self.subTest(port=port, protocol=protocol):
self.vm.ports = (SimpleNamespace(port=port, protocol=protocol),)
with self.assertRaises(ValidationError):
self.manager.apply(self.vm)
self.assertEqual(self.runner.calls, [])
def test_invalid_identity_is_rejected_before_commands(self):
for field, value in (("tap", "tap*"), ("tap", 'x"; drop'), ("tap", "-bad"),
("tap", "a" * 16), ("tap", "uvm0"), ("id", ""),
("mac", "ff:ff:ff:ff:ff:ff"), ("mac", "00:00:00:00:00:00"),
("guest_ip", "10.42.0.1"), ("guest_ip", "10.42.0.255"),
("guest_ip", "10.43.0.2"), ("guest_ip", "127.0.0.1"),
("guest_ip", "8.8.8.8"), ("guest_ip", "::1")):
with self.subTest(field=field, value=value), self.assertRaises(ValidationError):
self.manager.apply(vm_record(**{field: value}))
self.assertEqual(self.runner.calls, [])
def test_persisted_network_can_differ_from_current_settings(self):
vm = vm_record(guest_ip="172.20.1.2", network="172.20.0.0/16", bridge="old-bridge")
self.manager.apply(vm)
self.assertEqual(self.manager.status(vm), "enforced")
def test_live_tap_must_route_lldp_through_early_filter_and_disable_stp(self):
port = {"info_slave_kind": "bridge", "info_slave_data": {"group_fwd_mask": "0x4000"}}
bridge = {"info_kind": "bridge", "info_data": {"stp_state": 0}}
self.runner.links = [{"ifname": self.vm.tap, "master": "uvm0", "linkinfo": port},
{"ifname": "uvm0", "ifindex": 7, "linkinfo": bridge}]
self.manager.apply(self.vm)
self.assertEqual(self.manager.status(self.vm), "enforced")
for stp, mask in ((1, "0x4000"), (0, 0), (0, "unreadable")):
bridge["info_data"]["stp_state"] = stp
port["info_slave_data"]["group_fwd_mask"] = mask
self.assertEqual(self.manager.status(self.vm), "drift")
for quarantine in (False, True):
with self.assertRaisesRegex(UvmError, "LLDP forwarding"):
self.manager.apply(self.vm, quarantine=quarantine)
bridge["info_data"]["stp_state"] = 0
port["info_slave_data"]["group_fwd_mask"] = 0x4008
self.runner.links[0]["master"] = 7 # iproute may use an index instead of a name.
self.manager.apply(self.vm, quarantine=True)
self.assertEqual(self.manager.status(self.vm), "quarantined")
def test_foreign_ownership_is_never_adopted_even_for_quarantine_or_remove(self):
self.manager.apply(self.vm)
self.policy()[0]["table"]["comment"] = "someone else's firewall"
saved = copy.deepcopy(self.runner.tables)
self.assertEqual(self.manager.status(self.vm), "drift")
for operation in (lambda: self.manager.apply(self.vm),
lambda: self.manager.apply(self.vm, quarantine=True),
lambda: self.manager.remove(self.vm)):
with self.assertRaisesRegex(UvmError, "foreign"):
operation()
self.assertEqual(self.runner.tables, saved)
self.assertEqual(len(self.runner.batches), 1)
def test_table_replacement_race_uses_exclusive_create_and_old_handle(self):
self.manager.apply(self.vm)
def replace_table():
self.policy()[0]["table"].update(handle=90000, comment="foreign replacement")
self.runner.before_batch = replace_table
with self.assertRaises(CommandError):
self.manager.apply(self.vm, quarantine=True)
self.assertEqual(self.policy()[0]["table"]["comment"], "foreign replacement")
def test_initial_create_race_does_not_append_to_a_foreign_table(self):
table = self.manager._policy(self.vm, quarantine=True)[0]["table"]
def create_foreign():
self.runner.tables[table["name"]] = [{"table": {**table, "handle": 1000, "comment": "foreign"}}]
self.runner.before_batch = create_foreign
with self.assertRaises(CommandError):
self.manager.apply(self.vm)
self.assertEqual(len(self.policy()), 1)
def test_atomic_failure_keeps_old_policy_and_readback_failure_is_not_success(self):
self.manager.apply(self.vm)
saved = copy.deepcopy(self.runner.tables)
self.runner.fail_batch = True
self.vm.ports = ()
with self.assertRaises(CommandError):
self.manager.apply(self.vm)
self.assertEqual(self.runner.tables, saved)
self.runner.fail_batch = False
self.runner.after_batch = lambda: self.policy().pop()
with self.assertRaisesRegex(UvmError, "readback"):
self.manager.apply(self.vm)
def test_status_does_not_trust_owner_comment_rule_count_or_saved_flags(self):
self.manager.apply(self.vm)
original = copy.deepcopy(self.runner.tables)
self.vm.firewall_applied = True
mutations = (
lambda items: items.pop(),
lambda items: next(item["set"] for item in items if "set" in item)["elem"].append(22),
lambda items: next(item["chain"] for item in items if "chain" in item).update(hook="postrouting"),
lambda items: next(item["chain"] for item in items if "chain" in item).update(prio=1),
lambda items: next(item["chain"] for item in items if "chain" in item).update(policy="drop"),
lambda items: items[0]["table"].update(flags="dormant"),
lambda items: next(item["rule"] for item in items if "rule" in item)["expr"].pop(0),
lambda items: next(item["rule"] for item in items if "rule" in item).update(expr=[{"accept": None}]),
lambda items: items.append({"flowtable": {"family": "bridge", "name": "bypass"}}),
)
for mutate in mutations:
with self.subTest(mutation=mutate):
self.runner.tables = copy.deepcopy(original)
mutate(self.policy())
self.assertEqual(self.manager.status(self.vm), "drift")
self.runner.tables = original
for item in self.policy():
if "rule" in item and item["rule"]["chain"] == "from_vm":
item["rule"]["expr"] = [{"return": None}]
break
self.assertEqual(self.manager.status(self.vm), "drift")
def test_rule_order_is_not_normalized_away(self):
self.manager.apply(self.vm)
positions = [index for index, item in enumerate(self.policy())
if "rule" in item and item["rule"]["chain"] == "from_vm"]
first, last = positions[0], positions[-1]
self.policy()[first], self.policy()[last] = self.policy()[last], self.policy()[first]
self.assertEqual(self.manager.status(self.vm), "drift")
def test_normalizes_nft_json_presentation_not_effective_policy(self):
self.manager.apply(self.vm)
for item in self.policy():
obj = next(iter(item.values()))
obj["handle"] += 100
if "set" in item:
obj["elem"].reverse()
obj["flags"] = "constant"
if "rule" not in item:
continue
exprs = obj["expr"]
protocols = {expr.get("match", {}).get("left", {}).get("payload", {}).get("protocol")
for expr in exprs}
# nft elides EtherType dependencies of ARP/IP payloads, and
# coalesces/sorts adjacent pure payload matches when listing.
has_tcp_payload = '"protocol": "tcp"' in json.dumps(exprs)
if protocols & {"arp", "ip"} and not has_tcp_payload:
exprs[:] = [expr for expr in exprs if expr.get("match", {}).get("left") != {
"payload": {"protocol": "ether", "field": "type"},
}]
for expr in exprs:
match = expr.get("match", {})
if match.get("right") == "@tcp_ports":
match["op"] = "in"
if match.get("left") == {"ct": {"key": "state"}} and isinstance(match["right"], list):
match["right"].reverse()
if "&" in match.get("left", {}):
match["left"]["&"][1] = {"|": [{"|": ["fin", "syn"]}, {"|": ["rst", "ack"]}]}
match["right"] = "syn"
exprs[:-1] = reversed(exprs[:-1])
self.assertEqual(self.manager.status(self.vm), "enforced")
def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self):
self.vm.ports = ()
self.manager.apply(self.vm)
self.assertNotIn("elem", next(item["set"] for item in self.policy() if "set" in item))
self.assertEqual(self.manager.status(self.vm), "enforced")
next(item["set"] for item in self.policy() if "set" in item)["elem"] = []
self.assertEqual(self.manager.status(self.vm), "enforced")
for item in self.policy():
if "rule" in item:
item["rule"]["expr"] = [expr for expr in item["rule"]["expr"]
if expr.get("match", {}).get("left") != {"ct": {"key": "direction"}}]
self.assertEqual(self.manager.status(self.vm), "drift")
def test_missing_tools_permissions_timeouts_and_bad_json_are_unknown_not_missing(self):
self.runner.fail_command = ("nft", "--json", "list", "tables", "bridge")
self.assertEqual(self.manager.status(self.vm), "unknown")
with self.assertRaises(CommandError):
self.manager.apply(self.vm)
self.runner.fail_command = None
for listing in ("", "not json", "{}", '{"nftables": null}',
'{"nftables": [null]}', '{"nftables": [{"table": {}}]}'):
with self.subTest(listing=listing):
self.runner.listing = listing
self.assertEqual(self.manager.status(self.vm), "unknown")
with self.assertRaises(UvmError):
self.manager.apply(self.vm)
self.assertEqual(self.runner.batches, [])
def test_remove_and_cleanup_refuse_a_live_tap_even_when_quarantined(self):
self.manager.apply(self.vm, quarantine=True)
self.runner.links = [{"ifname": self.vm.tap, "master": "uvm0", "linkinfo": {
"info_slave_kind": "bridge", "info_slave_data": {"group_fwd_mask": 0x4000},
}}, {"ifname": "uvm0", "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}}}]
for operation in (self.manager.remove, self.manager.cleanup_connections):
with self.assertRaisesRegex(UvmError, "still exists"):
operation(self.vm)
self.assertEqual(self.manager.status(self.vm), "quarantined")
self.assertFalse(any(args[0] == "conntrack" for args, _ in self.runner.calls))
def test_offline_check_errors_do_not_release_policy(self):
self.manager.apply(self.vm, quarantine=True)
self.runner.fail_command = ("ip", "-j", "-d", "link", "show")
with self.assertRaises(CommandError):
self.manager.remove(self.vm)
self.runner.fail_command = None
self.runner.links = [{}]
with self.assertRaises(UvmError):
self.manager.cleanup_connections(self.vm)
self.assertEqual(self.manager.status(self.vm), "unknown")
def test_offline_conntrack_cleanup_is_scoped_to_all_four_tuple_addresses(self):
self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too.
self.manager.cleanup_connections(self.vm)
for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
for command in ("--delete", "--list"):
self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip),
[args for args, _ in self.runner.calls])
self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls))
self.runner.ct_remaining = "tcp 6 100 ESTABLISHED ..."
with self.assertRaisesRegex(UvmError, "do not reuse"):
self.manager.cleanup_connections(self.vm)
self.runner.ct_remaining = ""
self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
with self.assertRaises(CommandError):
self.manager.cleanup_connections(self.vm)
def test_availability_checks_kernel_and_full_policy_without_committing(self):
self.manager.check_available()
self.assertEqual(self.runner.tables, {})
self.assertEqual(self.runner.calls[0][0], ("conntrack", "--version"))
self.assertTrue(self.runner.calls[0][1]["check"])
self.assertEqual(self.runner.calls[0][1]["timeout"], 5)
batch = next((args, options) for args, options in self.runner.calls if "--check" in args)
self.assertIn('"ct"', batch[1]["input_text"])
self.assertIn('"hook": "prerouting"', batch[1]["input_text"])
for release in ("5.2.99", "4.19.0", "not-linux"):
self.runner.release = release
with self.assertRaisesRegex(UvmError, ">= 5.3"):
self.manager.check_available()
self.runner.release = "5.3.0"
self.runner.fail_batch = True
with self.assertRaisesRegex(UvmError, "unavailable"):
self.manager.check_available()
def test_missing_cleanup_utility_fails_preflight_before_nft(self):
self.runner.fail_command = ("conntrack", "--version")
with self.assertRaises(CommandError):
self.manager.check_available()
self.assertEqual([args for args, _ in self.runner.calls], [("conntrack", "--version")])
def test_status_rejects_changed_ct_keys_masks_verdicts_and_quarantine_hooks(self):
self.manager.apply(self.vm)
original = copy.deepcopy(self.runner.tables)
for alteration in ("tuple-key", "ct-mask", "tcp-mask", "return", "extra-set-property"):
self.runner.tables = copy.deepcopy(original)
rules = [item["rule"] for item in self.policy() if "rule" in item]
expressions = [expr for rule in rules for expr in rule["expr"]]
if alteration == "tuple-key":
match = next(expr["match"] for expr in expressions
if expr.get("match", {}).get("left") == {"ct": {"key": "ip saddr", "dir": "original"}})
match["left"] = {"ct": {"key": "saddr", "family": "ip", "dir": "original"}}
elif alteration == "ct-mask":
next(expr["match"] for expr in expressions
if expr.get("match", {}).get("left") == {"ct": {"key": "state"}})["right"] = "established"
elif alteration == "tcp-mask":
next(expr["match"] for expr in expressions
if "&" in expr.get("match", {}).get("left", {}))["left"]["&"][1] = 2
elif alteration == "return":
verdict = next(expr for expr in expressions if "return" in expr)
verdict.clear()
verdict["accept"] = None
else:
next(item["set"] for item in self.policy() if "set" in item)["timeout"] = 30
with self.subTest(alteration=alteration):
self.assertEqual(self.manager.status(self.vm), "drift")
self.runner.tables = original
self.manager.apply(self.vm, quarantine=True)
self.policy().pop()
self.assertEqual(self.manager.status(self.vm), "drift")
@unittest.skipUnless(os.path.exists("/proc/self/ns/net"), "Linux network namespace handles required")
def test_integration_harness_refuses_to_run_in_the_parent_namespace(self):
from tests.integration.test_private_ports import _sandbox
with open("/proc/self/ns/net", "rb") as parent:
with self.assertRaisesRegex(RuntimeError, "parent network namespace"):
_sandbox(parent.fileno())
class NftDumpFixtureTests(unittest.TestCase):
"""Literal upstream JSON fixtures, not output synthesized by FakeRunner.
Sources below are from nftables tag v1.0.9:
https://git.netfilter.org/nftables/tree/tests?h=v1.0.9
https://github.com/1g4-mirror/nftables/tree/v1.0.9/tests
tests/py/nft-test.py lists with numeric-protocol enabled. Files ending in
.json.output are its recorded expected nft dumps, not packet tests run here.
"""
@staticmethod
def normalize_rule(text):
return _normalize([{"rule": {"family": "bridge", "table": "fixture", "chain": "input",
"expr": json.loads(text)}}])
def test_explicit_ct_address_keys_match_upstream_wire_format(self):
# tests/py/inet/ct.t.json, "ct original ip daddr 1.2.3.4 accept".
# There is no .json.output override for this example: dump == input.
dumped = json.loads('''[
{"match": {"left": {"ct": {"dir": "original", "key": "ip daddr"}},
"op": "==", "right": "1.2.3.4"}},
{"accept": null}
]''')
self.assertEqual(_ct("ip daddr", "original"), dumped[0]["match"]["left"])
# ct_templates[NFT_CT_SRC_IP] / [NFT_CT_DST_IP] in src/ct.c; the
# v1.0.9 json_parse_ct_expr() looks up these keys and ignores family.
self.assertEqual(_ct("ip saddr", "original"), {"ct": {"key": "ip saddr", "dir": "original"}})
policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False)
addresses = [expr["match"]["left"]["ct"] for item in policy if "rule" in item
for expr in item["rule"]["expr"] if "ct" in expr.get("match", {}).get("left", {})
and "addr" in expr["match"]["left"]["ct"]["key"]]
self.assertEqual({ct["key"] for ct in addresses}, {"ip saddr", "ip daddr"})
self.assertTrue(all(ct["dir"] == "original" and "family" not in ct for ct in addresses))
def test_upstream_ct_list_and_set_dumps_preserve_distinct_operators(self):
# tests/py/any/ct.t.json and ct.t.json.output, ct state list vs set.
pairs = (
('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in",
"right": ["new", "established", "related", "untracked"]}}]''',
'''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in",
"right": ["established", "related", "new", "untracked"]}}]'''),
('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in",
"right": {"set": ["new", "established", "related", "untracked"]}}}]''',
'''[{"match": {"left": {"ct": {"key": "state"}}, "op": "==",
"right": {"set": ["established", "related", "new", "untracked"]}}}]'''),
('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": 8}}]''',
'''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}}]'''),
)
for source, dumped in pairs:
with self.subTest(source=source):
self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped))
# Equality against the ORed mask is NOT the list's any-bit test.
self.assertNotEqual(self.normalize_rule(pairs[0][1]),
self.normalize_rule(pairs[0][1].replace('"op": "in"', '"op": "=="')))
def test_upstream_bridge_icmp_numeric_protocol_dump(self):
# tests/py/bridge/icmpX.t.json and icmpX.t.json.output.
source = '''[
{"match": {"left": {"payload": {"field": "protocol", "protocol": "ip"}},
"op": "==", "right": "icmp"}},
{"match": {"left": {"payload": {"field": "type", "protocol": "icmp"}},
"op": "==", "right": "echo-request"}}
]'''
dumped = '''[
{"match": {"left": {"payload": {"field": "protocol", "protocol": "ip"}},
"op": "==", "right": 1}},
{"match": {"left": {"payload": {"field": "type", "protocol": "icmp"}},
"op": "==", "right": "echo-request"}}
]'''
self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped))
self.assertNotEqual(self.normalize_rule(source), self.normalize_rule(dumped.replace('"right": 1', '"right": 6')))
def test_upstream_arp_payload_field_names_and_reordering(self):
# tests/py/arp/arp.t.json and arp.t.json.output:
# arp daddr ether fe:ed:00:c0:ff:ee arp saddr ip 192.168.1.1
source = '''[
{"match": {"left": {"payload": {"field": "daddr ether", "protocol": "arp"}},
"op": "==", "right": "fe:ed:00:c0:ff:ee"}},
{"match": {"left": {"payload": {"field": "saddr ip", "protocol": "arp"}},
"op": "==", "right": "192.168.1.1"}}
]'''
dumped = '''[
{"match": {"left": {"payload": {"field": "saddr ip", "protocol": "arp"}},
"op": "==", "right": "192.168.1.1"}},
{"match": {"left": {"payload": {"field": "daddr ether", "protocol": "arp"}},
"op": "==", "right": "fe:ed:00:c0:ff:ee"}}
]'''
self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped))
self.assertNotEqual(self.normalize_rule(source), self.normalize_rule(dumped.replace("saddr ip", "daddr ip")))
policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False)
fields = {expr["match"]["left"]["payload"]["field"] for item in policy if "rule" in item
for expr in item["rule"]["expr"]
if expr.get("match", {}).get("left", {}).get("payload", {}).get("protocol") == "arp"}
self.assertEqual(fields, {"htype", "ptype", "hlen", "plen", "operation",
"saddr ip", "saddr ether", "daddr ip", "daddr ether"})
def test_upstream_tcp_flag_dump_does_not_hide_mask_changes(self):
# tests/py/inet/tcp.t.json.output, tcp flags & (syn|fin) == (syn|fin).
dumped = '''[{"match": {
"left": {"&": [{"payload": {"field": "flags", "protocol": "tcp"}}, {"|": ["fin", "syn"]}]},
"op": "==", "right": {"|": ["fin", "syn"]}
}}]'''
numeric = '''[{"match": {
"left": {"&": [{"payload": {"field": "flags", "protocol": "tcp"}}, 3]},
"op": "==", "right": 3
}}]'''
self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric))
self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]')))
def test_upstream_full_dump_ignores_only_handles_and_metainfo(self):
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
dumped = json.loads('''{"nftables": [
{"metainfo": {"version": "1.0.5", "release_name": "Lester Gooch #4", "json_schema_version": 1}},
{"table": {"family": "inet", "name": "t", "handle": 9}},
{"counter": {"family": "inet", "name": "mycounter", "table": "t", "handle": 1,
"comment": "my comment in counter", "packets": 0, "bytes": 0}}
]}''')["nftables"]
expected = [
{"table": {"family": "inet", "name": "t"}},
{"counter": {"family": "inet", "name": "mycounter", "table": "t",
"comment": "my comment in counter", "packets": 0, "bytes": 0}},
]
self.assertEqual(_normalize(dumped), _normalize(expected))
dumped[1]["table"]["flags"] = "dormant"
self.assertNotEqual(_normalize(dumped), _normalize(expected))
@unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks")
class NftJsonParserTests(unittest.TestCase):
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
A deliberately invalid final command must be the first command rejected.
This proves all earlier commands parsed even when cache initialization lacks
CAP_NET_ADMIN. --check and the invalid command both prevent any commit.
"""
@staticmethod
def parse(commands):
commands = [*commands, {"add": {"uvm_schema_sentinel": None}}]
result = subprocess.run(
("nft", "--check", "--json", "-f", "-"),
input=json.dumps({"nftables": commands}), text=True, capture_output=True,
timeout=5, env={**os.environ, "LC_ALL": "C"},
)
indices = [int(index) for index in re.findall(r"Parsing command array at index (\d+) failed", result.stderr)]
return result, indices
@classmethod
def setUpClass(cls):
result, indices = cls.parse([])
if indices != [0]:
raise unittest.SkipTest(f"nft parser diagnostics unavailable before netlink: {result.stderr.strip()}")
def test_all_generated_transactions_reach_only_the_final_invalid_command(self):
runner = FakeRunner()
manager = FirewallManager(Settings(), runner)
vm = vm_record(ports=(PortRule(1), PortRule(443, "HTTPS"), PortRule(65535)))
manager.apply(vm)
vm.ports = ()
manager.apply(vm)
vm.ports = None
manager.apply(vm, quarantine=True)
manager.remove(vm)
manager.check_available()
transactions = [json.loads(options["input_text"])["nftables"]
for args, options in runner.calls if args[0] == "nft" and options["input_text"]]
for index, commands in enumerate(transactions):
with self.subTest(transaction=index):
result, rejected = self.parse(commands)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(rejected, [len(commands)], result.stderr)
def test_parser_negative_controls_fail_before_the_sentinel(self):
bad_commands = (
{"add": {"set": {"family": "bridge", "table": "probe", "name": "ports",
"type": "inet_service", "elem": []}}},
{"delete": {"table": {"family": "bridge", "handle": "not-an-integer"}}},
{"add": {"chain": {"family": "bridge", "table": "probe", "name": "input",
"type": "filter", "hook": "not-a-hook", "prio": 0}}},
{"add": {"rule": {"family": "bridge", "table": "probe", "chain": "input", "expr": [
{"match": {"left": {"ct": {"key": "state", "dir": "original"}}, "op": "in", "right": "new"}},
]}}},
{"add": {"rule": {"family": "bridge", "table": "probe", "chain": "input", "expr": [
{"match": {"left": {"payload": {"protocol": "arp", "field": "saddr"}}, "op": "==", "right": "10.42.0.2"}},
]}}},
)
for command in bad_commands:
with self.subTest(command=command):
result, rejected = self.parse([command])
self.assertNotEqual(result.returncode, 0)
self.assertEqual(rejected, [0], result.stderr)
class RulesetModel:
"""Evaluate the emitted pure match/jump/return/drop subset, not a kernel.
Conntrack metadata is supplied by each test. This catches policy/direction
mistakes but deliberately does not pretend to test native bridge tracking.
"""
def __init__(self, tables):
self.tables = tables
def permits(self, packet, *hooks):
for hook in hooks:
for objects in self.tables:
chains = {item["chain"]["name"]: item["chain"] for item in objects if "chain" in item}
rules = {}
sets = {item["set"]["name"]: item["set"].get("elem", []) for item in objects if "set" in item}
for item in objects:
if "rule" in item:
rules.setdefault(item["rule"]["chain"], []).append(item["rule"]["expr"])
def value(expr):
if isinstance(expr, str) and expr.startswith("@"):
return sets[expr[1:]]
if not isinstance(expr, dict):
return expr
if "meta" in expr:
return packet.get(expr["meta"]["key"])
if "payload" in expr:
payload = expr["payload"]
return packet.get((payload["protocol"], payload["field"]))
if "ct" in expr:
ct = expr["ct"]
return packet.get(("ct", ct["key"], ct.get("dir")))
if "set" in expr:
return expr["set"]
if "&" in expr:
left, right = map(value, expr["&"])
return None if left is None else left & right
raise AssertionError(f"unmodeled expression {expr}")
def evaluate(name):
for expressions in rules.get(name, []):
for expr in expressions:
if "match" in expr:
match = expr["match"]
left, right = value(match["left"]), value(match["right"])
if left is None:
break
same = left in right if isinstance(right, list) else left == right
if not (not same if match["op"] == "!=" else same):
break
elif "jump" in expr:
verdict = evaluate(expr["jump"]["target"])
if verdict != "return":
return verdict
elif "return" in expr:
return "return"
elif "drop" in expr:
return "drop"
elif "accept" in expr:
return "accept"
else:
raise AssertionError(f"unmodeled statement {expr}")
return "return"
for name, chain in chains.items():
if chain.get("hook") == hook and evaluate(name) == "drop":
return False
return True
def packet(src, dst, *, protocol="tcp", reply=False, state="established", dport=8080, flags=16):
original_src, original_dst = (dst, src) if reply else (src, dst)
return {
"iifname": src.tap, "oifname": dst.tap,
("ether", "type"): "ip", ("ether", "saddr"): src.mac, ("ether", "daddr"): dst.mac,
("ip", "saddr"): src.guest_ip, ("ip", "daddr"): dst.guest_ip, ("ip", "protocol"): protocol,
("tcp", "flags"): flags, ("ct", "direction", None): "reply" if reply else "original",
("ct", "ip saddr", "original"): original_src.guest_ip,
("ct", "ip daddr", "original"): original_dst.guest_ip,
("ct", "proto-dst", "original"): dport, ("ct", "protocol", None): protocol,
("ct", "state", None): state, ("icmp", "type"): "echo-reply" if reply else "echo-request",
}
class FirewallPolicyTests(unittest.TestCase):
def setUp(self):
self.manager = FirewallManager(Settings(), FakeRunner())
self.guest = vm_record()
self.peer = vm_record(id="vm-two", tap="tap-two", guest_ip="10.42.0.3",
mac="02:fc:00:00:00:02", ports=())
self.host = vm_record(tap="", guest_ip="10.42.0.1", mac="02:00:00:00:00:01")
def model(self, *, quarantine=False, peer=True, reverse=False):
tables = [self.manager._policy(self.guest, quarantine=quarantine)]
if peer:
tables.append(self.manager._policy(self.peer, quarantine=False))
return RulesetModel(list(reversed(tables)) if reverse else tables)
def test_all_hooks_and_return_verdicts_are_present_with_early_identity(self):
policy = self.manager._policy(self.guest, quarantine=False)
chains = [item["chain"] for item in policy if "chain" in item and "hook" in item["chain"]]
self.assertEqual({chain["hook"] for chain in chains}, {"prerouting", "input", "forward", "output"})
self.assertLess(next(chain["prio"] for chain in chains if chain["hook"] == "prerouting"), -200)
verdicts = [expr for item in policy if "rule" in item for expr in item["rule"]["expr"]]
self.assertIn({"return": None}, verdicts)
self.assertNotIn({"accept": None}, verdicts)
def test_host_and_peer_service_connections_close_in_both_directions(self):
for client, incoming, outgoing in ((self.host, ("output",), ("prerouting", "input")),
(self.peer, ("prerouting", "forward"), ("prerouting", "forward"))):
for reverse in (False, True):
with self.subTest(client=client.guest_ip, reverse=reverse):
self.guest.ports = (SimpleNamespace(port=8080, protocol="TCP"),)
traffic = packet(client, self.guest)
response = packet(self.guest, client, reply=True)
self.assertTrue(self.model(reverse=reverse).permits(traffic, *incoming))
self.assertTrue(self.model(reverse=reverse).permits(response, *outgoing))
self.guest.ports = ()
self.assertFalse(self.model(reverse=reverse).permits(traffic, *incoming))
self.assertFalse(self.model(reverse=reverse).permits(response, *outgoing))
def test_closed_policy_preserves_guest_egress_tcp_udp_and_icmp_replies(self):
self.guest.ports = ()
for protocol in ("tcp", "udp", "icmp"):
with self.subTest(protocol=protocol):
request = packet(self.guest, self.host, protocol=protocol, state="new", flags=2)
response = packet(self.host, self.guest, protocol=protocol, reply=True)
self.assertTrue(self.model().permits(request, "prerouting", "input"))
self.assertTrue(self.model().permits(response, "output"))
response[("ct", "direction", None)] = "original"
self.assertFalse(self.model().permits(response, "output"))
def test_peer_egress_never_skips_the_destination_policy(self):
for reverse in (False, True):
traffic = packet(self.guest, self.peer, state="new", flags=2)
self.assertFalse(self.model(reverse=reverse).permits(traffic, "prerouting", "forward"))
self.peer.ports = (SimpleNamespace(port=8080, protocol="TCP"),)
self.assertTrue(self.model(reverse=reverse).permits(traffic, "prerouting", "forward"))
self.peer.ports = ()
def test_new_tcp_requires_syn_for_inbound_and_egress_tracking_loss(self):
for flags in (0, 16, 18, 24, 1, 4, 3):
with self.subTest(flags=flags):
ingress = packet(self.host, self.guest, state="new", flags=flags)
lost_server_flow = packet(self.guest, self.host, state="new", flags=flags)
self.assertFalse(self.model().permits(ingress, "output"))
self.assertFalse(self.model().permits(lost_server_flow, "prerouting", "input"))
for flags in (2, 2 | 64 | 128):
self.assertTrue(self.model().permits(packet(self.host, self.guest, state="new", flags=flags), "output"))
for state in ("invalid", "untracked", "related"):
self.assertFalse(self.model().permits(packet(self.host, self.guest, state=state), "output"))
def test_current_original_destination_port_and_address_are_required(self):
for key, value in ((("ct", "proto-dst", "original"), 22),
(("ct", "ip daddr", "original"), "10.42.0.99"),
(("ct", "protocol", None), "udp")):
for reply in (False, True):
traffic = packet(self.guest, self.host, reply=True) if reply else packet(self.host, self.guest)
traffic[key] = value
self.assertFalse(self.model().permits(traffic, *(('prerouting', 'input') if reply else ('output',))))
def test_inbound_udp_and_non_tcp_transports_are_not_opened(self):
for protocol in ("udp", "sctp", "gre"):
for state in ("new", "established", "related"):
self.assertFalse(self.model().permits(packet(self.host, self.guest, protocol=protocol, state=state), "output"))
def test_required_icmp_is_authorized_by_parent_tuple_not_blanket_related(self):
self.guest.ports = ()
self.assertTrue(self.model().permits(packet(self.host, self.guest, protocol="icmp", state="new"), "output"))
for protocol in ("tcp", "udp", "icmp"):
traffic = packet(self.host, self.guest, protocol=protocol, state="related", reply=True)
traffic[("ip", "protocol")] = "icmp"
for icmp in ("destination-unreachable", "time-exceeded", "parameter-problem"):
traffic[("icmp", "type")] = icmp
self.assertTrue(self.model().permits(traffic, "output"))
traffic[("icmp", "type")] = "redirect"
self.assertFalse(self.model().permits(traffic, "output"))
service_error = packet(self.host, self.guest, state="related")
service_error.update({("ip", "protocol"): "icmp", ("icmp", "type"): "destination-unreachable"})
self.assertFalse(self.model().permits(service_error, "output"))
def test_identity_is_enforced_before_learning_and_ipv6_and_vlan_are_dropped(self):
for key, value in ((("ether", "saddr"), self.peer.mac),
(("ip", "saddr"), self.peer.guest_ip),
(("ether", "type"), "ip6"), (("ether", "type"), "vlan"),
(("ether", "type"), "8021ad")):
traffic = packet(self.guest, self.host)
traffic[key] = value
self.assertFalse(self.model().permits(traffic, "prerouting"))
for ethertype in ("ip6", "vlan", "8021ad"):
traffic = packet(self.host, self.guest)
traffic[("ether", "type")] = ethertype
self.assertFalse(self.model().permits(traffic, "output"))
traffic = packet(self.host, self.guest)
traffic[("ip", "daddr")] = self.peer.guest_ip
self.assertFalse(self.model().permits(traffic, "output"))
def test_arp_identity_and_address_probes_are_narrow(self):
traffic = packet(self.guest, self.host)
traffic.update({("ether", "type"): "arp", ("arp", "htype"): 1,
("arp", "ptype"): "ip", ("arp", "hlen"): 6, ("arp", "plen"): 4,
("arp", "operation"): "request", ("arp", "saddr ether"): self.guest.mac,
("arp", "saddr ip"): self.guest.guest_ip, ("arp", "daddr ip"): self.host.guest_ip})
self.assertTrue(self.model().permits(traffic, "prerouting", "input"))
for key, bad in ((("arp", "saddr ip"), self.peer.guest_ip),
(("arp", "saddr ether"), self.peer.mac), (("arp", "hlen"), 5),
(("arp", "operation"), "rreply")):
self.assertFalse(self.model().permits({**traffic, key: bad}, "prerouting"))
self.assertFalse(self.model().permits({**traffic, key: bad}, "input"))
traffic[("arp", "saddr ip")] = "0.0.0.0"
self.assertFalse(self.model().permits(traffic, "prerouting"))
traffic[("arp", "daddr ip")] = self.guest.guest_ip
self.assertTrue(self.model().permits(traffic, "prerouting"))
def test_quarantine_drops_every_path_but_unrelated_traffic_is_untouched(self):
for protocol in ("tcp", "udp", "icmp"):
for endpoint, hook in ((self.host, "input"), (self.peer, "forward")):
self.assertFalse(self.model(quarantine=True).permits(packet(self.guest, endpoint, protocol=protocol), "prerouting", hook))
self.assertFalse(self.model(quarantine=True).permits(packet(endpoint, self.guest, protocol=protocol), "output" if hook == "input" else "forward"))
other = vm_record(tap="unmanaged", guest_ip="10.42.0.99")
self.assertTrue(self.model(quarantine=True, peer=False).permits(packet(other, self.host), "prerouting", "input"))
if __name__ == "__main__":
unittest.main()
+570 -65
View File
@@ -2,109 +2,325 @@ from __future__ import annotations
import tempfile
import unittest
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import Mock, patch
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import VmSpec
from uvm.errors import FirecrackerError, UvmError
from uvm.domain import State, VmRecord, VmSpec
from uvm.errors import ConflictError, FirecrackerError, UvmError, ValidationError
from uvm.firecracker.process import ProcessInfo
from uvm.images import ImageStore
from uvm.lifecycle import LifecycleService
from uvm.network import NetworkManager
from uvm.state import StateStore
class TrackedStateStore(StateStore):
def __init__(self, settings: Settings) -> None:
super().__init__(settings)
self.operation_active = False
self.transaction_active = False
self.operation_locks = 0
@contextmanager
def operation_lock(self) -> Iterator[None]:
if self.operation_active or self.transaction_active:
raise AssertionError("nested or inverted operation lock")
self.operation_locks += 1
with super().operation_lock():
self.operation_active = True
try:
yield
finally:
self.operation_active = False
@contextmanager
def transaction(self) -> Iterator[State]:
if self.transaction_active:
raise AssertionError("nested state transaction")
self.transaction_active = True
try:
with super().transaction() as state:
yield state
finally:
self.transaction_active = False
class FakeHost:
"""Record boundary calls and inject one-shot failures before their effects."""
def __init__(self, store: TrackedStateStore) -> None:
self.store = store
self.events: list[tuple[str, str | None]] = []
self.records: dict[tuple[str, str], VmRecord] = {}
self.failures: dict[tuple[str, str | None], BaseException] = {}
def call(self, action: str, vm_id: str | None = None, *, readonly: bool = False) -> None:
if self.store.transaction_active:
raise AssertionError(f"state transaction held during {action}")
if not readonly and not self.store.operation_active:
raise AssertionError(f"operation lock missing during {action}")
self.events.append((action, vm_id))
if vm_id is not None:
vm = self.store.load().vms.get(vm_id)
if vm is not None:
self.records[action, vm_id] = vm
error = self.failures.pop((action, vm_id), None)
if error is None:
error = self.failures.pop((action, None), None)
if error is not None:
raise error
def actions(self, vm_id: str) -> list[str]:
return [action for action, target in self.events if target == vm_id]
class FakeFirewall:
def __init__(self, host: FakeHost, network: FakeNetwork) -> None:
self.host = host
self.network = network
self.tables: dict[str, tuple[VmRecord, bool]] = {}
def check_available(self) -> None:
self.host.call("firewall.check_available", readonly=True)
def apply(self, vm: VmRecord, *, quarantine: bool = False) -> None:
self.host.call("firewall.quarantine" if quarantine else "firewall.apply", vm.id)
if vm.ports is None and not quarantine:
raise ValidationError("legacy VM has no managed port policy")
self.tables[vm.id] = (replace(vm), quarantine)
self.host.call("firewall.readback", vm.id)
def status(self, vm: VmRecord) -> str:
self.host.call("firewall.status", vm.id, readonly=True)
if vm.id not in self.tables:
return "unmanaged" if vm.ports is None else "missing"
installed, quarantine = self.tables[vm.id]
if installed.tap != vm.tap:
return "drift"
if quarantine:
return "quarantined"
if vm.ports is None:
return "drift"
same_identity = installed.guest_ip == vm.guest_ip and installed.mac == vm.mac
same_ports = {rule.port for rule in installed.ports} == {rule.port for rule in vm.ports}
return "enforced" if same_identity and same_ports else "drift"
def cleanup_connections(self, vm: VmRecord) -> None:
self.host.call("firewall.cleanup_connections", vm.id)
if vm.tap in self.network.taps:
raise UvmError("cannot clean connections while TAP exists")
def remove(self, vm: VmRecord) -> None:
self.host.call("firewall.remove", vm.id)
if vm.tap in self.network.taps:
raise UvmError("cannot remove guard while TAP exists")
self.tables.pop(vm.id, None)
class FakeNetwork:
def __init__(self) -> None:
def __init__(self, settings: Settings, host: FakeHost) -> None:
self._settings = settings
self.host = host
self.taps: dict[str, str] = {}
self.active_taps: set[str] = set()
self.created_taps: list[str] = []
self.deleted_taps: list[str] = []
self.firewall = FakeFirewall(host, self)
def allocate_ip(self, _vms, requested: IPv4Address | None) -> IPv4Address:
return requested or IPv4Address("10.42.0.2")
def allocate_ip(self, vms: Iterable[VmRecord], requested: IPv4Address | None) -> IPv4Address:
return NetworkManager.allocate_ip(self, vms, requested) # type: ignore[arg-type]
@staticmethod
def mac_for(index: int) -> str:
return f"02:fc:00:00:00:{index:02x}"
@staticmethod
def tap_name(vm_id: str) -> str:
return f"uvm-{vm_id[-8:]}"[:15]
mac_for = staticmethod(NetworkManager.mac_for)
tap_name = staticmethod(NetworkManager.tap_name)
def ensure_bridge(self) -> None:
return None
self.host.call("network.ensure_bridge")
def create_tap(self, name: str) -> None:
self.created_taps.append(name)
def prepare_tap(self, vm: VmRecord) -> None:
self.host.call("network.prepare_tap", vm.id)
if vm.tap in self.taps:
raise ConflictError(f"TAP device already exists: {vm.tap}")
self.taps[vm.tap] = vm.id
self.created_taps.append(vm.tap)
vm.tap_owned = True
def delete_tap(self, name: str) -> None:
self.deleted_taps.append(name)
def has_tap(self, vm: VmRecord) -> bool:
self.host.call("network.has_tap", vm.id, readonly=True)
owner = self.taps.get(vm.tap)
if owner is not None and owner != vm.id:
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
return owner is not None
def activate_tap(self, vm: VmRecord) -> None:
self.host.call("network.activate_tap", vm.id)
if not self.has_tap(vm):
raise UvmError(f"TAP device missing: {vm.tap}")
self.active_taps.add(vm.tap)
def deactivate_tap(self, vm: VmRecord) -> None:
self.host.call("network.deactivate_tap", vm.id)
if self.has_tap(vm):
self.active_taps.discard(vm.tap)
def delete_vm_tap(self, vm: VmRecord) -> None:
self.host.call("network.delete_vm_tap", vm.id)
if self.has_tap(vm):
del self.taps[vm.tap]
self.active_taps.discard(vm.tap)
self.deleted_taps.append(vm.tap)
vm.tap_owned = False
class FakeProcess:
def __init__(self) -> None:
self.started = False
self.terminated: list[int | None] = []
self.alive = True
def __init__(self, host: FakeHost) -> None:
self.host = host
self.boot_id = "test-host-boot"
self.next_pid = 12345
self.running: dict[str, ProcessInfo] = {}
self.unknown: set[str] = set()
self.started: list[ProcessInfo] = []
self.terminated: list[tuple[str, int | None, float | None]] = []
def start(self, _vm) -> ProcessInfo:
self.started = True
return ProcessInfo(pid=12345, start_time="42")
def host_boot_id(self) -> str:
return self.boot_id
def is_alive(self, _vm) -> bool:
return self.alive
def observe(self, vm: VmRecord) -> str:
self.host.call("process.observe", vm.id, readonly=True)
if vm.pid is not None and vm.pid <= 0:
return "unknown"
if vm.boot_id and vm.boot_id != self.boot_id:
return "gone"
if vm.id in self.unknown:
return "unknown"
if vm.pid is None:
if (vm.spawn_pending or vm.operation == "spawning"
or vm.process_start_time is not None or Path(vm.socket).exists()):
return "unknown"
return "gone"
info = self.running.get(vm.id)
if info is None or info.pid != vm.pid:
return "gone"
if vm.process_start_time is not None and info.start_time != vm.process_start_time:
return "gone"
if vm.process_start_time is None or vm.boot_id is None:
return "unknown"
return "alive"
def terminate(self, vm) -> None:
self.terminated.append(vm.pid)
self.alive = False
def adopt_legacy_identity(self, vm: VmRecord) -> bool:
self.host.call("process.adopt_legacy_identity", vm.id)
info = self.running.get(vm.id)
if (vm.boot_id is None and info is not None and vm.id not in self.unknown
and (vm.pid, vm.process_start_time) == (info.pid, info.start_time)):
vm.boot_id = info.boot_id
return True
return False
def start(self, vm: VmRecord, *, on_spawn: Callable[[ProcessInfo], None] | None = None) -> ProcessInfo:
self.host.call("process.start", vm.id)
if self.observe(vm) != "gone":
raise FirecrackerError("refusing to replace an existing or ambiguous process")
vm.operation = "spawning"
vm.spawn_pending = True
vm.boot_id = self.host_boot_id()
# A failure here models an interrupt inside Popen, before identity is known.
self.host.call("process.spawn", vm.id)
info = ProcessInfo(self.next_pid, str(self.next_pid * 10), self.boot_id)
self.next_pid += 1
self.running[vm.id] = info
self.started.append(info)
vm.pid, vm.process_start_time = info.pid, info.start_time
if on_spawn is not None:
on_spawn(info)
else:
vm.spawn_pending = False
Path(vm.socket).touch()
with Path(vm.log).open("ab") as log:
log.write(f"boot {info.pid}\n".encode("ascii"))
self.host.call("process.ready", vm.id)
return info
def terminate(self, vm: VmRecord, *, timeout: float | None = None) -> None:
self.terminated.append((vm.id, vm.pid, timeout))
self.host.call("process.terminate", vm.id)
observation = self.observe(vm)
if observation == "unknown":
raise FirecrackerError("process identity is ambiguous")
if observation == "alive":
del self.running[vm.id]
Path(vm.socket).unlink(missing_ok=True)
self.host.call("process.gone", vm.id)
class FakeClient:
def __init__(self, should_fail: bool = False) -> None:
self.should_fail = should_fail
self.config = None
def __init__(self, host: FakeHost, vm_id: str) -> None:
self.host = host
self.vm_id = vm_id
self.config: dict | None = None
def configure_and_start(self, config) -> None:
def configure_and_start(self, config: dict) -> None:
self.host.call("client.configure", self.vm_id)
self.config = config
if self.should_fail:
raise FirecrackerError("simulated API failure")
class LifecycleTests(unittest.TestCase):
class LifecycleTestCase(unittest.TestCase):
def setUp(self) -> None:
self._temporary_directory = tempfile.TemporaryDirectory()
base = Path(self._temporary_directory.name) / "uvm"
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
base = Path(temporary.name) / "uvm"
self.settings = replace(Settings(), base=base, allow_unverified_downloads=True)
self.settings.bin_dir.mkdir(parents=True)
self.settings.firecracker_binary.touch()
self.settings.images_dir.mkdir(parents=True)
self.settings.kernel_image.write_bytes(b"kernel")
self.settings.rootfs_image.write_bytes(b"rootfs")
self.store = StateStore(self.settings)
self.store = TrackedStateStore(self.settings)
self.host = FakeHost(self.store)
self.images = ImageStore(self.settings)
self.images.provision_credentials = Mock() # type: ignore[method-assign]
self.network = FakeNetwork()
self.process = FakeProcess()
self.client = FakeClient()
self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
self.copy_disk = self.enterContext(patch.object(
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
))
self.network = FakeNetwork(self.settings, self.host)
self.process = FakeProcess(self.host)
self.clients: dict[str, FakeClient] = {}
self.service = LifecycleService(
self.settings,
self.store,
self.images,
self.network, # type: ignore[arg-type]
self.process, # type: ignore[arg-type]
client_factory=lambda _socket, _timeout: self.client, # type: ignore[arg-type]
client_factory=self.make_client, # type: ignore[arg-type]
)
self._root_patch = patch("uvm.lifecycle.require_root")
self._kvm_patch = patch("uvm.lifecycle.check_kvm")
self._root_patch.start()
self._kvm_patch.start()
self.require_root = self.enterContext(patch("uvm.lifecycle.require_root"))
self.check_kvm = self.enterContext(patch("uvm.lifecycle.check_kvm"))
for target in ("uvm.system.CommandRunner.run", "subprocess.run", "subprocess.Popen", "os.kill"):
self.enterContext(patch(target, side_effect=AssertionError(f"unexpected host operation: {target}")))
def tearDown(self) -> None:
self._kvm_patch.stop()
self._root_patch.stop()
self._temporary_directory.cleanup()
def make_client(self, socket: Path, _timeout: float) -> FakeClient:
client = FakeClient(self.host, socket.parent.name)
self.clients[client.vm_id] = client
return client
def create_vm(self, **kwargs) -> VmRecord:
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
def persist(self, vm: VmRecord, **changes) -> VmRecord:
with self.store.transaction() as state:
updated = replace(state.vms[vm.id], **changes)
state.vms[vm.id] = updated
return updated
def assert_call_order(self, vm: VmRecord, *expected: str) -> None:
actual = [action for action in self.host.actions(vm.id) if action in expected]
self.assertEqual(actual, list(expected))
class LifecycleTests(LifecycleTestCase):
def test_create_uses_a_private_disk_and_persists_running_state(self) -> None:
vm = self.service.create(VmSpec(cpu=1, ram_mib=512))
@@ -115,8 +331,14 @@ class LifecycleTests(unittest.TestCase):
self.assertEqual(vm.password, "root")
self.assertEqual(self.store.load().vms[vm.id].pid, 12345)
self.assertEqual(self.network.created_taps, [vm.tap])
self.assertEqual(self.client.config["drives"][0]["path_on_host"], vm.disk)
self.images.provision_credentials.assert_called_once_with(
self.assertEqual(self.clients[vm.id].config["drives"][0]["path_on_host"], vm.disk)
self.assertTrue(vm.runtime_owned)
self.assertTrue(vm.tap_owned)
self.assertTrue(vm.disk_ready)
self.assertFalse(vm.spawn_pending)
self.assertEqual(self.process.observe(vm), "alive")
self.assertEqual(self.network.firewall.status(vm), "enforced")
self.provision.assert_called_once_with(
Path(vm.disk),
"root",
"root",
@@ -130,27 +352,30 @@ class LifecycleTests(unittest.TestCase):
persisted = self.store.load().vms[vm.id]
self.assertEqual(persisted.username, "admin")
self.assertEqual(persisted.password, "secret-value")
self.images.provision_credentials.assert_called_once_with(
self.provision.assert_called_once_with(
Path(vm.disk),
"admin",
"secret-value",
)
def test_create_rolls_back_when_firecracker_configuration_fails(self) -> None:
self.client.should_fail = True
self.host.failures["client.configure", None] = FirecrackerError("simulated API failure")
with self.assertRaises(FirecrackerError):
with self.assertRaisesRegex(UvmError, r"failed to create vm-.*: simulated API failure") as raised:
self.service.create(VmSpec(cpu=1, ram_mib=512))
self.assertIsInstance(raised.exception.__cause__, FirecrackerError)
self.assertEqual(self.store.load().vms, {})
self.assertEqual(len(self.network.deleted_taps), 1)
self.assertEqual(self.process.terminated, [12345])
self.assertEqual([pid for _, pid, _ in self.process.terminated], [12345])
self.assertEqual(self.network.firewall.tables, {})
self.assertEqual(self.process.running, {})
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_create_rolls_back_before_network_setup_when_provisioning_fails(self) -> None:
self.images.provision_credentials.side_effect = UvmError("invalid guest image")
self.provision.side_effect = UvmError("invalid guest image")
with self.assertRaises(UvmError):
with self.assertRaisesRegex(UvmError, r"failed to create vm-.*: invalid guest image"):
self.service.create(VmSpec(cpu=1, ram_mib=512))
self.assertEqual(self.store.load().vms, {})
@@ -159,10 +384,7 @@ class LifecycleTests(unittest.TestCase):
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_create_rolls_back_when_interrupted(self) -> None:
def interrupting_create_tap(_name: str) -> None:
raise KeyboardInterrupt
self.network.create_tap = interrupting_create_tap
self.host.failures["network.prepare_tap", None] = KeyboardInterrupt()
with self.assertRaises(KeyboardInterrupt):
self.service.create(VmSpec(cpu=1, ram_mib=512))
@@ -183,3 +405,286 @@ class LifecycleTests(unittest.TestCase):
self.assertEqual(destroyed.id, vm.id)
self.assertFalse(self.settings.vm_dir(vm.id).exists())
self.assertEqual(self.store.load().vms, {})
def test_stop_edit_disk_start_preserves_guest_data_credentials_and_identity(self) -> None:
vm = self.create_vm(name="database", username="admin", password="original-secret")
original = replace(vm)
disk = Path(vm.disk)
inode = disk.stat().st_ino
stopped = self.service.stop(vm.name, timeout=0.25)
self.assertEqual(self.process.terminated[-1], (vm.id, vm.pid, 0.25))
self.assertEqual(stopped.status, "stopped")
self.assertNotIn(vm.tap, self.network.taps)
self.assertNotIn(vm.id, self.network.firewall.tables)
disk.write_bytes(b"guest-edited-data\npassword-hash\noriginal-ssh-host-keys\n")
edited = disk.read_bytes()
started = self.service.start(vm.guest_ip)
for field in ("id", "name", "guest_ip", "mac", "tap", "disk", "username", "password", "ports", "created_at"):
self.assertEqual(getattr(started, field), getattr(original, field), field)
self.assertNotEqual(started.pid, original.pid)
self.assertNotEqual(started.process_start_time, original.process_start_time)
self.assertEqual(started.boot_id, self.process.host_boot_id())
self.assertEqual(disk.stat().st_ino, inode)
self.assertEqual(disk.read_bytes(), edited)
self.assertEqual(self.settings.rootfs_image.read_bytes(), b"rootfs")
self.provision.assert_called_once_with(disk, "admin", "original-secret")
self.copy_disk.assert_called_once()
self.assertEqual(self.store.get(vm.id).to_dict(), started.to_dict())
def test_start_uses_private_disk_when_rootfs_template_has_been_removed(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
self.settings.rootfs_image.unlink()
with patch.object(self.images, "installed_assets", side_effect=AssertionError("start needs only the kernel")):
started = self.service.start(vm.id)
self.assertEqual(started.status, "running")
self.assertEqual(Path(started.disk).read_bytes(), b"rootfs")
self.assertEqual(self.provision.call_count, 1)
self.assertEqual(self.copy_disk.call_count, 1)
def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None:
vm = self.create_vm()
self.assert_call_order(
vm, "network.prepare_tap", "firewall.apply", "firewall.readback",
"network.activate_tap", "process.start", "process.spawn", "process.ready",
"client.configure", "firewall.status",
)
policy_record = self.host.records["firewall.apply", vm.id]
self.assertTrue(policy_record.disk_ready)
self.assertTrue(policy_record.tap_owned)
pending = self.host.records["process.start", vm.id]
self.assertTrue(pending.spawn_pending)
self.assertEqual(pending.operation, "spawning")
self.assertIsNone(pending.pid)
spawned = self.host.records["process.ready", vm.id]
self.assertEqual((spawned.pid, spawned.process_start_time, spawned.boot_id),
(vm.pid, vm.process_start_time, vm.boot_id))
self.assertFalse(spawned.spawn_pending)
self.assertEqual(spawned.operation, "configuring")
self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid)
def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None:
self.host.failures["firewall.apply", None] = UvmError("policy unavailable")
with self.assertRaisesRegex(UvmError, "policy unavailable"):
self.create_vm()
self.assertFalse(any(action in {"network.activate_tap", "process.start", "client.configure"}
for action, _ in self.host.events))
self.assertEqual(self.store.load().vms, {})
self.assertEqual(self.network.taps, {})
self.assertEqual(self.network.firewall.tables, {})
def test_unavailable_firewall_fails_preflight_before_reserving_or_copying(self) -> None:
self.host.failures["firewall.check_available", None] = UvmError("bridge filtering unavailable")
with self.assertRaisesRegex(UvmError, "bridge filtering unavailable"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertEqual(self.host.events, [("firewall.check_available", None)])
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
self.provision.assert_not_called()
self.copy_disk.assert_not_called()
def test_create_cleans_up_if_policy_readback_is_not_enforced_after_boot(self) -> None:
with patch.object(self.network.firewall, "status", return_value="drift"):
with self.assertRaisesRegex(UvmError, "enforcement could not be confirmed after boot"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertEqual(self.process.running, {})
self.assertEqual(self.network.taps, {})
self.assertEqual(self.network.firewall.tables, {})
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_start_rejects_missing_or_unprovisioned_disk_without_network_mutation(self) -> None:
for problem in ("missing", "unprovisioned"):
with self.subTest(problem=problem):
vm = self.create_vm()
self.service.stop(vm.id)
if problem == "missing":
Path(vm.disk).unlink()
else:
self.persist(vm, disk_ready=False)
before = self.settings.state_path.read_bytes()
self.host.events.clear()
with self.assertRaisesRegex(UvmError, "disk"):
self.service.start(vm.id)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"})
def test_stop_quarantines_and_confirms_exit_before_releasing_network(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.stop(vm.id, timeout=0)
self.assert_call_order(
vm, "firewall.quarantine", "network.deactivate_tap", "process.terminate",
"process.gone", "network.delete_vm_tap", "firewall.cleanup_connections", "firewall.remove",
)
pending = self.host.records["process.terminate", vm.id]
self.assertEqual((pending.status, pending.operation), ("stopping", "stop"))
self.assertEqual(self.process.terminated[-1], (vm.id, vm.pid, 0))
def test_failed_process_cleanup_keeps_disk_tap_guard_identity_and_reservation(self) -> None:
for action in ("stop", "destroy", "restart"):
with self.subTest(action=action):
vm = self.create_vm(name=f"failed-{action}")
disk = Path(vm.disk)
self.host.events.clear()
self.host.failures["process.terminate", vm.id] = FirecrackerError("SIGKILL exit unconfirmed")
with self.assertRaisesRegex(UvmError, "process exit unconfirmed.*SIGKILL"):
getattr(self.service, action)(vm.id, timeout=0.1)
retained = self.store.get(vm.id)
self.assertEqual(retained.status, "failed")
self.assertEqual((retained.pid, retained.process_start_time), (vm.pid, vm.process_start_time))
self.assertEqual(retained.guest_ip, vm.guest_ip)
self.assertEqual(disk.read_bytes(), b"rootfs")
self.assertTrue(retained.runtime_owned)
self.assertTrue(retained.tap_owned)
self.assertEqual(self.network.taps[vm.tap], vm.id)
self.assertNotIn(vm.tap, self.network.active_taps)
self.assertEqual(self.network.firewall.status(retained), "quarantined")
self.assertEqual(self.process.observe(retained), "alive")
self.assertFalse(set(self.host.actions(vm.id)) & {
"network.delete_vm_tap", "firewall.cleanup_connections", "firewall.remove", "process.start",
})
def test_create_retains_resources_when_rollback_cannot_confirm_exit(self) -> None:
self.host.failures["client.configure", None] = FirecrackerError("API failed")
self.host.failures["process.terminate", None] = FirecrackerError("still alive")
with self.assertRaisesRegex(UvmError, "failed to create.*API failed; cleanup incomplete.*still alive"):
self.create_vm()
vm, = self.store.load().vms.values()
self.assertEqual(vm.status, "failed")
self.assertEqual(self.process.observe(vm), "alive")
self.assertTrue(Path(vm.disk).exists())
self.assertTrue(vm.runtime_owned)
self.assertTrue(vm.tap_owned)
self.assertEqual(self.network.taps[vm.tap], vm.id)
self.assertEqual(self.network.firewall.status(vm), "quarantined")
self.assertNotIn("network.delete_vm_tap", self.host.actions(vm.id))
self.assertNotIn("firewall.remove", self.host.actions(vm.id))
def test_uncertain_spawn_interrupt_retains_resources_and_survives_stop_retry(self) -> None:
for interrupt in (KeyboardInterrupt(), SystemExit(143)):
with self.subTest(interrupt=type(interrupt).__name__):
self.host.failures["process.spawn", None] = interrupt
previous = set(self.store.load().vms)
with self.assertRaises(type(interrupt)):
self.create_vm()
vm, = [vm for vm in self.store.load().vms.values() if vm.id not in previous]
self.assertEqual(self.process.observe(vm), "unknown")
self.assertTrue(vm.spawn_pending or vm.operation == "spawning")
self.assertIsNone(vm.pid)
self.assertEqual(vm.status, "failed")
self.assertTrue(Path(vm.disk).exists())
self.assertEqual(self.network.taps[vm.tap], vm.id)
self.assertEqual(self.network.firewall.status(vm), "quarantined")
self.assertNotIn(vm.tap, self.network.active_taps)
for action in ("stop", "destroy"):
with self.assertRaisesRegex(UvmError, "process exit unconfirmed"):
getattr(self.service, action)(vm.id)
retained = self.store.get(vm.id)
self.assertEqual(self.process.observe(retained), "unknown")
self.assertTrue(Path(vm.disk).exists())
self.assertIn(vm.tap, self.network.taps)
self.assertNotIn("network.delete_vm_tap", self.host.actions(vm.id))
self.assertNotIn("firewall.remove", self.host.actions(vm.id))
def test_directory_reservation_collision_never_deletes_existing_contents(self) -> None:
directory = self.settings.vm_dir("vm-existing")
directory.mkdir(parents=True)
disk = directory / "rootfs.ext4"
disk.write_bytes(b"not-owned-by-this-create")
marker = directory / "operator-data"
marker.write_bytes(b"keep")
before = {path.name: (path.stat().st_ino, path.read_bytes()) for path in directory.iterdir()}
with patch("uvm.lifecycle.new_vm_id", return_value="vm-existing"):
with self.assertRaisesRegex(UvmError, "failed to create vm-existing"):
self.create_vm()
self.assertEqual({path.name: (path.stat().st_ino, path.read_bytes()) for path in directory.iterdir()}, before)
self.provision.assert_not_called()
self.copy_disk.assert_not_called()
self.assertEqual(self.network.created_taps, [])
self.assertEqual(self.process.started, [])
retained = self.store.load().vms.get("vm-existing")
if retained is not None:
self.assertFalse(retained.runtime_owned)
self.assertFalse(retained.disk_ready)
def test_stale_stopping_and_deleting_records_can_finish_cleanup(self) -> None:
for action, status, operation in (("stop", "stopping", "stop"), ("destroy", "terminating", "delete")):
with self.subTest(action=action):
vm = self.create_vm()
self.persist(vm, status=status, operation=operation)
del self.process.running[vm.id]
self.host.events.clear()
result = getattr(self.service, action)(vm.id)
self.assertEqual(result.id, vm.id)
self.assertNotIn(vm.tap, self.network.taps)
self.assertNotIn(vm.id, self.network.firewall.tables)
self.assertFalse(Path(vm.socket).exists())
self.assertNotIn("process.start", self.host.actions(vm.id))
if action == "stop":
retained = self.store.get(vm.id)
self.assertEqual((retained.status, retained.operation), ("stopped", None))
self.assertTrue(Path(vm.disk).exists())
else:
self.assertNotIn(vm.id, self.store.load().vms)
self.assertFalse(self.settings.vm_dir(vm.id).exists())
def test_failed_delete_intent_cannot_be_resurrected_by_stop_or_start(self) -> None:
vm = self.create_vm()
self.host.failures["firewall.cleanup_connections", vm.id] = UvmError("conntrack remains")
with self.assertRaisesRegex(UvmError, "failed to delete.*conntrack remains"):
self.service.destroy(vm.id)
retained = self.store.get(vm.id)
self.assertEqual((retained.status, retained.operation), ("failed", "delete"))
self.assertTrue(Path(vm.disk).exists())
self.assertIn(vm.id, self.network.firewall.tables)
for action in ("start", "restart", "sync_ports"):
with self.subTest(action=action), self.assertRaisesRegex(ConflictError, "deletion is pending"):
getattr(self.service, action)(vm.id)
with self.assertRaisesRegex(ConflictError, "deletion is pending"):
self.service.set_ports(vm.id, ())
stopped = self.service.stop(vm.id)
self.assertEqual((stopped.status, stopped.operation), ("stopped", "delete"))
with self.assertRaisesRegex(ConflictError, "deletion is pending"):
self.service.start(vm.id)
self.service.destroy(vm.id)
self.assertNotIn(vm.id, self.store.load().vms)
self.assertFalse(self.settings.vm_dir(vm.id).exists())
self.assertEqual(len(self.process.started), 1)
def test_failed_start_retains_edited_disk_and_retries_without_provisioning(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
Path(vm.disk).write_bytes(b"retained edits and host keys")
self.host.failures["client.configure", vm.id] = FirecrackerError("API retry needed")
with self.assertRaisesRegex(UvmError, "failed to start.*API retry needed"):
self.service.start(vm.id)
failed = self.store.get(vm.id)
self.assertEqual(failed.status, "failed")
self.assertIsNone(failed.pid)
self.assertTrue(failed.disk_ready)
self.assertTrue(failed.runtime_owned)
self.assertNotIn(vm.tap, self.network.taps)
started = self.service.start(vm.id)
self.assertEqual(started.status, "running")
self.assertEqual(Path(vm.disk).read_bytes(), b"retained edits and host keys")
self.assertEqual(len({info.pid for info in self.process.started}), 3)
self.assertEqual(self.provision.call_count, 1)
self.assertEqual(self.copy_disk.call_count, 1)
def test_restart_uses_one_lock_and_replaces_only_target_process(self) -> None:
vm = self.create_vm()
other = self.create_vm()
other_record = self.store.get(other.id).to_dict()
before_locks = self.store.operation_locks
self.host.events.clear()
restarted = self.service.restart(vm.id, timeout=0.75)
self.assertEqual(self.store.operation_locks - before_locks, 1)
self.assertNotEqual(restarted.pid, vm.pid)
self.assertEqual(self.process.terminated[0], (vm.id, vm.pid, 0.75))
self.assertEqual(self.store.get(other.id).to_dict(), other_record)
self.assertEqual(self.process.observe(other), "alive")
self.assertEqual(self.network.firewall.status(other), "enforced")
self.assertEqual(self.provision.call_count, 2)
+493
View File
@@ -0,0 +1,493 @@
from __future__ import annotations
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import patch
from tests.test_lifecycle import LifecycleTestCase
from uvm.domain import PortRule
from uvm.errors import ConflictError, FirecrackerError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import LifecycleService, OperationResult
from uvm.state import StateStore
class LifecycleLookupTests(LifecycleTestCase):
def test_vm_resources_are_distinct_and_aliases_resolve_to_the_same_record(self) -> None:
first = self.create_vm(name="web")
second = self.create_vm(name="database", guest_ip=IPv4Address("10.42.0.9"))
third = self.create_vm()
for field in ("id", "guest_ip", "mac", "tap", "pid", "disk"):
self.assertEqual(len({getattr(vm, field) for vm in (first, second, third)}), 3, field)
self.assertEqual((first.guest_ip, second.guest_ip, third.guest_ip),
("10.42.0.2", "10.42.0.9", "10.42.0.3"))
for identifier in (second.id, second.name, second.guest_ip):
with self.subTest(identifier=identifier):
self.assertEqual(self.service.inspect(identifier).vm.id, second.id)
self.assertEqual(self.service.find_for_ssh(identifier).id, second.id)
self.assertEqual(self.service.log_path(identifier), Path(second.log))
selected = self.service.resolve_targets([
second.name, first.guest_ip, second.id, first.name, third.id, second.guest_ip,
])
self.assertEqual([vm.id for vm in selected], [second.id, first.id, third.id])
self.service.stop(second.name)
self.assertEqual(self.process.observe(self.store.get(second.id)), "gone")
self.assertEqual(self.process.observe(first), "alive")
self.assertEqual(self.process.observe(third), "alive")
self.assertEqual(self.network.firewall.status(first), "enforced")
self.assertEqual(self.network.firewall.status(third), "enforced")
def test_name_and_stopped_ip_collisions_do_not_reserve_or_provision_again(self) -> None:
vm = self.create_vm(name="reserved")
self.service.stop(vm.id)
before = self.settings.state_path.read_bytes()
for kwargs, message in (
({"name": vm.name}, "VM name already exists"),
({"name": vm.id}, "VM name already exists"),
({"guest_ip": IPv4Address(vm.guest_ip)}, "IP already allocated"),
):
with self.subTest(kwargs=kwargs), self.assertRaisesRegex(UvmError, message):
self.create_vm(**kwargs)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.provision.call_count, 1)
self.assertEqual(self.copy_disk.call_count, 1)
self.assertEqual(len(self.process.started), 1)
def test_generated_id_retries_id_name_and_tap_collisions(self) -> None:
with patch("uvm.lifecycle.new_vm_id", return_value="vm-first-same1234567"):
first = self.create_vm(name="reserved-name")
collision = "vm-other-same1234567"
self.assertEqual(self.network.tap_name(collision), first.tap)
with patch("uvm.lifecycle.new_vm_id", side_effect=[
first.id, first.name, collision, "vm-new-unique12345",
]) as generate:
second = self.create_vm()
self.assertEqual(generate.call_count, 4)
self.assertEqual(second.id, "vm-new-unique12345")
self.assertNotEqual(second.tap, first.tap)
self.assertNotEqual(second.guest_ip, first.guest_ip)
self.assertEqual(self.network.taps, {first.tap: first.id, second.tap: second.id})
def test_ambiguous_persisted_name_ip_or_id_never_selects_an_arbitrary_vm(self) -> None:
first = self.create_vm(name="first")
second = self.create_vm(name="second")
for identifier in (first.guest_ip, first.id, first.name):
with self.subTest(identifier=identifier):
self.persist(second, name=identifier)
before = self.settings.state_path.read_bytes()
self.host.events.clear()
for method in (self.service.inspect, self.service.find_for_ssh,
self.service.log_path, self.service.stop):
with self.assertRaisesRegex(ConflictError, "ambiguous VM identifier"):
method(identifier)
with self.assertRaisesRegex(ConflictError, "ambiguous VM identifier"):
self.service.resolve_targets([identifier])
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, [])
def test_target_resolution_rejects_invalid_or_missing_targets_without_mutation(self) -> None:
vm = self.create_vm(name="web")
before = self.settings.state_path.read_bytes()
before_locks = self.store.operation_locks
self.host.events.clear()
with self.assertRaises(ValidationError):
self.service.resolve_targets([])
with self.assertRaises(ValidationError):
self.service.resolve_targets([vm.id], all_vms=True)
with self.assertRaises(NotFoundError):
self.service.resolve_targets([vm.name, "missing"])
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.store.operation_locks, before_locks)
self.assertEqual(self.host.events, [])
class LifecyclePortTests(LifecycleTestCase):
def test_stopped_port_edits_and_sync_are_metadata_only(self) -> None:
vm = self.create_vm()
stopped = self.service.stop(vm.id)
self.assertFalse(stopped.tap_owned)
for mode, ports, expected in (
("open", (PortRule(443, "HTTPS"), PortRule(80, "HTTP")),
(PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))),
("close", (PortRule(80),), (PortRule(22), PortRule(443, "HTTPS"))),
("set", (), ()),
):
with self.subTest(mode=mode):
self.host.events.clear()
updated = self.service.set_ports(vm.id, ports, mode=mode)
self.assertEqual(updated.ports, expected)
self.assertEqual((updated.status, updated.operation, updated.pid), ("stopped", None, None))
self.assertFalse(updated.tap_owned)
self.assertEqual(self.store.get(vm.id).ports, expected)
self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"})
self.host.events.clear()
self.service.sync_ports(vm.id)
self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"})
self.assertEqual(self.network.firewall.tables, {})
self.assertEqual(len(self.process.started), 1)
self.assertEqual(self.provision.call_count, 1)
def test_live_port_modes_persist_sorted_rules_without_flushing_connections(self) -> None:
vm = self.create_vm(ports=(PortRule(22), PortRule(80, "HTTP")))
other = self.create_vm(ports=(PortRule(443, "HTTPS"),))
before_other = self.store.get(other.id).to_dict()
for mode, ports, expected in (
("open", (PortRule(443, "HTTPS"), PortRule(80)),
(PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))),
("close", (PortRule(80), PortRule(9000)), (PortRule(22), PortRule(443, "HTTPS"))),
("set", (), ()),
):
with self.subTest(mode=mode):
self.host.events.clear()
updated = self.service.set_ports(vm.id, ports, mode=mode)
self.assertEqual(updated.ports, expected)
self.assertEqual(updated.pid, vm.pid)
self.assertEqual(updated.status, "running")
self.assertIsNone(updated.operation)
pending = self.host.records["firewall.apply", vm.id]
self.assertEqual(pending.ports, expected)
self.assertEqual(pending.operation, "policy")
self.assert_call_order(vm, "firewall.apply", "network.activate_tap")
self.assertFalse(set(self.host.actions(vm.id)) & {
"firewall.cleanup_connections", "firewall.remove", "network.delete_vm_tap",
"process.terminate", "process.start", "client.configure",
})
self.assertEqual(self.network.firewall.status(updated), "enforced")
self.assertEqual(self.store.get(other.id).to_dict(), before_other)
self.assertEqual(self.network.firewall.status(other), "enforced")
self.assertIn(other.tap, self.network.active_taps)
def test_failed_live_close_persists_pending_policy_quarantines_and_never_reopens_old_ports(self) -> None:
for failure in ("firewall.apply", "firewall.readback", "network.activate_tap"):
with self.subTest(failure=failure):
vm = self.create_vm(ports=(PortRule(22), PortRule(80, "HTTP")))
self.host.events.clear()
self.host.failures[failure, vm.id] = UvmError("policy update failed")
with self.assertRaisesRegex(UvmError, "policy pending.*policy update failed"):
self.service.set_ports(vm.id, (PortRule(80),), mode="close")
pending = self.store.get(vm.id)
self.assertEqual((pending.status, pending.operation), ("failed", "policy"))
self.assertEqual(pending.ports, (PortRule(22),))
self.assertEqual(pending.pid, vm.pid)
self.assertIn("policy update failed", pending.last_error)
self.assertEqual(self.host.records["firewall.apply", vm.id].ports, pending.ports)
self.assertEqual(self.host.records["firewall.quarantine", vm.id].ports, pending.ports)
self.assertEqual(self.network.firewall.status(pending), "quarantined")
self.assertNotIn(vm.tap, self.network.active_taps)
self.assertFalse(set(self.host.actions(vm.id)) & {
"process.terminate", "firewall.cleanup_connections", "firewall.remove",
})
self.host.events.clear()
synced = self.service.sync_ports(vm.id)
self.assertEqual(synced.ports, (PortRule(22),))
self.assertEqual((synced.status, synced.operation, synced.last_error), ("running", None, None))
self.assertEqual(self.network.firewall.status(synced), "enforced")
self.assertIn(vm.tap, self.network.active_taps)
self.assert_call_order(vm, "firewall.apply", "network.activate_tap")
def test_failed_close_still_quarantines_when_tap_deactivation_fails(self) -> None:
vm = self.create_vm(ports=(PortRule(22), PortRule(80, "HTTP")))
self.host.events.clear()
self.host.failures["firewall.apply", vm.id] = UvmError("policy rejected")
self.host.failures["network.deactivate_tap", vm.id] = UvmError("link-down failed")
with self.assertRaisesRegex(UvmError, "policy pending"):
self.service.set_ports(vm.id, (PortRule(80),), mode="close")
pending = self.store.get(vm.id)
self.assertEqual(pending.ports, (PortRule(22),))
self.assertEqual(pending.operation, "policy")
self.assertEqual(self.network.firewall.status(pending), "quarantined")
self.assertIn("firewall.quarantine", self.host.actions(vm.id))
def test_failed_close_keeps_tap_down_if_quarantine_also_fails(self) -> None:
vm = self.create_vm(ports=(PortRule(22), PortRule(80)))
self.host.events.clear()
self.host.failures["firewall.apply", vm.id] = UvmError("policy rejected")
self.host.failures["firewall.quarantine", vm.id] = UvmError("quarantine rejected")
with self.assertRaisesRegex(UvmError, "policy pending"):
self.service.set_ports(vm.id, (PortRule(80),), mode="close")
pending = self.store.get(vm.id)
self.assertEqual(pending.ports, (PortRule(22),))
self.assertEqual(pending.operation, "policy")
self.assertNotIn(vm.tap, self.network.active_taps)
self.assertIn(vm.id, self.network.firewall.tables)
self.assertNotIn("network.activate_tap", self.host.actions(vm.id))
self.assertNotIn("firewall.remove", self.host.actions(vm.id))
def test_interrupted_close_retains_new_intent_and_quarantines(self) -> None:
for interrupt in (KeyboardInterrupt(), SystemExit(143)):
with self.subTest(interrupt=type(interrupt).__name__):
vm = self.create_vm(ports=(PortRule(22), PortRule(80)))
self.host.failures["firewall.apply", vm.id] = interrupt
with self.assertRaises(type(interrupt)):
self.service.set_ports(vm.id, (PortRule(80),), mode="close")
pending = self.store.get(vm.id)
self.assertEqual((pending.ports, pending.operation), ((PortRule(22),), "policy"))
self.assertNotIn(vm.tap, self.network.active_taps)
self.assertEqual(self.network.firewall.status(pending), "quarantined")
def test_legacy_policy_requires_explicit_set_while_stopped(self) -> None:
vm = self.create_vm()
self.persist(vm, ports=None)
before = self.settings.state_path.read_bytes()
for mode in ("open", "close", "set"):
with self.subTest(mode=mode), self.assertRaisesRegex(ConflictError, "legacy VM policy"):
self.service.set_ports(vm.id, (PortRule(22),), mode=mode)
with self.assertRaisesRegex(ConflictError, "legacy VM has no port policy"):
self.service.start(vm.id)
with self.assertRaisesRegex(ConflictError, "legacy VM has no managed policy"):
self.service.sync_ports(vm.id)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.service.stop(vm.id)
self.assertEqual(self.service.inspect(vm.id).policy_status, "unmanaged")
for mode in ("open", "close"):
with self.subTest(mode=mode), self.assertRaises(ConflictError):
self.service.set_ports(vm.id, (), mode=mode)
migrated = self.service.set_ports(vm.id, (), mode="set")
self.assertEqual(migrated.ports, ())
self.assertEqual(self.service.inspect(vm.id).policy_status, "inactive")
self.assertEqual(self.service.start(vm.id).status, "running")
def test_invalid_port_requests_fail_before_lock_state_or_host_mutation(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
before_locks = self.store.operation_locks
self.host.events.clear()
for ports, mode in (
([PortRule(22)], "set"), (("22",), "set"),
((PortRule(80, "HTTP"), PortRule(80, "TCP")), "set"),
((PortRule(22),), "replace"),
):
with self.subTest(ports=ports, mode=mode), self.assertRaises(ValidationError):
self.service.set_ports(vm.id, ports, mode=mode)
self.assertEqual(self.store.operation_locks, before_locks)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, [])
def test_start_of_running_vm_repairs_policy_without_reboot_or_reprovision(self) -> None:
vm = self.create_vm()
del self.network.firewall.tables[vm.id]
self.host.events.clear()
started = self.service.start(vm.id)
self.assertEqual(started.pid, vm.pid)
self.assertEqual(self.network.firewall.status(started), "enforced")
self.assertNotIn("process.start", self.host.actions(vm.id))
self.assertNotIn("process.terminate", self.host.actions(vm.id))
self.assertNotIn("client.configure", self.host.actions(vm.id))
self.assertEqual(self.provision.call_count, 1)
self.assertEqual(self.copy_disk.call_count, 1)
class LifecycleBulkTests(LifecycleTestCase):
def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None:
untouched = self.create_vm()
untouched_record = self.store.get(untouched.id).to_dict()
for action in ("start", "stop", "restart", "delete", "ports-sync"):
with self.subTest(action=action):
first, second = self.create_vm(), self.create_vm()
ids = (first.id, second.id)
if action == "start":
for vm_id in ids:
self.service.stop(vm_id)
before_locks = self.store.operation_locks
self.host.events.clear()
self.process.terminated.clear()
results = self.service.run_many(action, ids, timeout=0.5)
self.assertEqual(results, [OperationResult(vm_id, action, "succeeded") for vm_id in ids])
self.assertEqual(self.store.operation_locks - before_locks, 1)
self.assertFalse(self.store.operation_active)
self.assertFalse(self.store.transaction_active)
self.assertEqual(self.store.get(untouched.id).to_dict(), untouched_record)
self.assertEqual(self.host.actions(untouched.id), [])
if action in {"stop", "restart", "delete"}:
self.assertEqual([(vm_id, timeout) for vm_id, _, timeout in self.process.terminated
if timeout is not None], [(vm_id, 0.5) for vm_id in ids])
for vm in (first, second):
if action == "delete":
self.assertNotIn(vm.id, self.store.load().vms)
self.assertFalse(self.settings.vm_dir(vm.id).exists())
else:
current = self.store.get(vm.id)
self.assertEqual(current.status, "stopped" if action == "stop" else "running")
if action in {"start", "restart"}:
self.assertNotEqual(current.pid, vm.pid)
def test_partial_failure_reports_each_target_and_continues(self) -> None:
first, failed, last = self.create_vm(), self.create_vm(), self.create_vm()
self.host.failures["process.terminate", failed.id] = FirecrackerError("exit unconfirmed")
results = self.service.run_many("stop", (first.id, failed.id, last.id), timeout=0)
self.assertEqual([result.id for result in results], [first.id, failed.id, last.id])
self.assertEqual([result.outcome for result in results], ["succeeded", "failed", "succeeded"])
self.assertIn(failed.id, results[1].error)
self.assertIn("exit unconfirmed", results[1].error)
self.assertIsNone(results[0].error)
self.assertIsNone(results[2].error)
self.assertEqual(self.store.get(first.id).status, "stopped")
self.assertEqual(self.store.get(last.id).status, "stopped")
retained = self.store.get(failed.id)
self.assertEqual(retained.pid, failed.pid)
self.assertTrue(Path(failed.disk).exists())
self.assertIn(failed.tap, self.network.taps)
self.assertEqual(self.network.firewall.status(retained), "quarantined")
retry = self.service.run_many("stop", (failed.id,))
self.assertEqual(retry, [OperationResult(failed.id, "stop", "succeeded")])
def test_fixed_snapshot_excludes_later_vms_and_reused_name_or_ip(self) -> None:
removed = self.create_vm(name="web")
retained = self.create_vm(name="database")
selected = tuple(vm.id for vm in self.service.resolve_targets([], all_vms=True))
self.service.destroy(removed.id)
replacement = self.create_vm(name=removed.name, guest_ip=IPv4Address(removed.guest_ip))
later = self.create_vm(name="later")
before = {vm.id: self.store.get(vm.id).to_dict() for vm in (replacement, later)}
results = self.service.run_many("stop", selected)
self.assertEqual({result.id: result.outcome for result in results},
{removed.id: "skipped", retained.id: "succeeded"})
for vm in (replacement, later):
self.assertEqual(self.store.get(vm.id).to_dict(), before[vm.id])
self.assertEqual(self.process.observe(vm), "alive")
self.assertEqual(self.store.get(retained.id).status, "stopped")
def test_bulk_exact_ids_do_not_retarget_a_new_vm_named_after_a_deleted_id(self) -> None:
original = self.create_vm(name="original")
selected = tuple(vm.id for vm in self.service.resolve_targets([original.name]))
self.service.destroy(original.id)
replacement = self.create_vm(name=original.id)
before = self.store.get(replacement.id).to_dict()
self.host.events.clear()
results = self.service.run_many("delete", selected)
self.assertEqual(results, [OperationResult(original.id, "delete", "skipped", "already absent")])
self.assertEqual(self.store.get(replacement.id).to_dict(), before)
self.assertEqual(self.host.actions(replacement.id), [])
self.assertTrue(Path(replacement.disk).exists())
def test_interrupt_preserves_current_resources_and_skips_remaining_targets(self) -> None:
first, interrupted, last = self.create_vm(), self.create_vm(), self.create_vm()
last_record = self.store.get(last.id).to_dict()
self.host.events.clear()
self.host.failures["process.terminate", interrupted.id] = KeyboardInterrupt()
results = self.service.run_many("stop", (first.id, interrupted.id, last.id))
self.assertEqual(results, [
OperationResult(first.id, "stop", "succeeded"),
OperationResult(interrupted.id, "stop", "interrupted", "interrupted"),
OperationResult(last.id, "stop", "skipped", "not attempted after interruption"),
])
self.assertEqual(self.store.get(first.id).status, "stopped")
pending = self.store.get(interrupted.id)
self.assertEqual((pending.status, pending.operation), ("failed", "stop"))
self.assertEqual(pending.pid, interrupted.pid)
self.assertTrue(Path(interrupted.disk).exists())
self.assertIn(interrupted.tap, self.network.taps)
self.assertEqual(self.network.firewall.status(pending), "quarantined")
self.assertEqual(self.store.get(last.id).to_dict(), last_record)
self.assertEqual(self.host.actions(last.id), [])
self.assertFalse(self.store.operation_active)
self.assertEqual(self.service.stop(last.id).status, "stopped")
def test_empty_bulk_requests_have_no_vm_or_host_effects(self) -> None:
self.assertEqual(self.service.resolve_targets([], all_vms=True), [])
for action in ("start", "stop", "restart", "delete", "ports-sync"):
with self.subTest(action=action):
self.assertEqual(self.service.run_many(action, ()), [])
self.assertEqual(self.store.load().vms, {})
self.assertFalse(self.settings.state_path.exists())
self.assertEqual(self.host.events, [])
self.provision.assert_not_called()
self.copy_disk.assert_not_called()
def test_invalid_bulk_action_fails_before_lock_or_mutation(self) -> None:
with self.assertRaisesRegex(ValidationError, "invalid lifecycle action"):
self.service.run_many("create", ("vm-missing",))
self.assertEqual(self.store.operation_locks, 0)
self.assertEqual(self.host.events, [])
class LifecycleInspectionTests(LifecycleTestCase):
def test_unknown_pid_inspection_is_read_only_and_never_adopts_or_cleans_up(self) -> None:
vm = self.create_vm(name="legacy-identity")
self.persist(vm, boot_id=None)
before = self.settings.state_path.read_bytes()
before_files = {path: (path.stat().st_ino, path.read_bytes())
for path in self.settings.vm_dir(vm.id).iterdir()}
before_locks = self.store.operation_locks
self.host.events.clear()
self.require_root.reset_mock()
self.check_kvm.reset_mock()
with patch.object(self.store, "transaction", side_effect=AssertionError("read must not persist")):
inspected = self.service.inspect(vm.name)
listed, = self.service.list_vms()
self.assertEqual(self.service.find_for_ssh(vm.guest_ip).id, vm.id)
self.assertEqual(self.service.log_path(vm.id), Path(vm.log))
self.assertEqual([item.id for item in self.service.resolve_targets([vm.name])], [vm.id])
self.assertEqual(inspected.observed_status, "unknown")
self.assertEqual(listed.observed_status, "unknown")
self.assertEqual(inspected.vm.status, "running")
self.assertIsNone(inspected.vm.boot_id)
self.assertEqual(self.store.operation_locks, before_locks)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual({path: (path.stat().st_ino, path.read_bytes())
for path in self.settings.vm_dir(vm.id).iterdir()}, before_files)
self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "firewall.status"})
self.require_root.assert_not_called()
self.check_kvm.assert_not_called()
def test_mutation_adopts_legacy_identity_under_lock_before_termination(self) -> None:
vm = self.create_vm()
self.persist(vm, boot_id=None)
self.host.events.clear()
stopped = self.service.stop(vm.id)
self.assertEqual(stopped.status, "stopped")
self.assertEqual(self.host.records["process.terminate", vm.id].boot_id, self.process.host_boot_id())
self.assert_call_order(vm, "process.adopt_legacy_identity", "process.terminate")
def test_unknown_identity_blocks_start_and_port_edits_without_mutation(self) -> None:
vm = self.create_vm()
self.process.unknown.add(vm.id)
before = self.settings.state_path.read_bytes()
self.host.events.clear()
for method in (self.service.start, self.service.sync_ports):
with self.assertRaisesRegex(ConflictError, "unknown"):
method(vm.id)
with self.assertRaisesRegex(ConflictError, "known process identity"):
self.service.set_ports(vm.id, ())
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"})
def test_observed_dead_or_running_status_never_rewrites_desired_state(self) -> None:
dead = self.create_vm()
alive = self.create_vm()
del self.process.running[dead.id]
self.persist(alive, status="stopped")
before = self.settings.state_path.read_bytes()
observations = {item.vm.id: item for item in self.service.list_vms()}
self.assertEqual(observations[dead.id].observed_status, "dead")
self.assertEqual(observations[dead.id].vm.status, "running")
self.assertEqual(observations[alive.id].observed_status, "running")
self.assertEqual(observations[alive.id].vm.status, "stopped")
self.assertEqual(self.settings.state_path.read_bytes(), before)
def test_empty_inspection_does_not_create_an_uninstalled_registry(self) -> None:
settings = replace(self.settings, base=self.settings.base / "not-installed")
service = LifecycleService(settings, StateStore(settings), self.images, self.network, self.process)
self.assertEqual(service.list_vms(), [])
self.assertEqual(service.resolve_targets([], all_vms=True), [])
for method in (service.inspect, service.find_for_ssh, service.log_path):
with self.assertRaises(NotFoundError):
method("missing")
self.assertFalse(settings.base.exists())
self.assertEqual(self.host.events, [])
def test_diagnostics_collects_failures_without_host_commands_or_lifecycle_effects(self) -> None:
self.check_kvm.side_effect = UvmError("KVM unavailable")
self.host.failures["firewall.check_available", None] = UvmError("nft unavailable")
with patch("uvm.lifecycle.shutil.which", return_value=None):
checks = {item["name"]: item for item in self.service.diagnostics()}
self.assertTrue(checks["root"]["ok"])
self.assertTrue(checks["guest assets"]["ok"])
self.assertFalse(checks["kvm"]["ok"])
self.assertEqual(checks["kvm"]["message"], "KVM unavailable")
self.assertFalse(checks["private firewall"]["ok"])
self.assertEqual(checks["private firewall"]["message"], "nft unavailable")
self.assertFalse(checks["nft"]["ok"])
self.assertEqual(self.store.operation_locks, 0)
self.assertFalse(self.settings.state_path.exists())
self.assertEqual(self.host.events, [("firewall.check_available", None)])
+355 -33
View File
@@ -1,35 +1,78 @@
from __future__ import annotations
import json
import unittest
from copy import deepcopy
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network
from uvm.config import Settings
from uvm.domain import VmRecord
from uvm.errors import UvmError, ValidationError
from uvm.domain import PortRule, VmRecord
from uvm.errors import CommandError, ConflictError, UvmError, ValidationError
from uvm.network import NetworkManager
from uvm.system import CommandResult
LINK_INVENTORY = ("ip", "-j", "-d", "link", "show")
class FakeRunner:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
self.existing_links: set[str] = set()
self.link_details: dict[str, str] = {}
self.links: dict[str, dict] = {}
self.inventory: str | None = None
self.failures: dict[tuple[str, ...], BaseException] = {}
def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
del check, capture, timeout
del capture, timeout
args = tuple(str(part) for part in command)
self.calls.append(args)
if args[:3] == ("ip", "link", "show") and args[3] in self.existing_links:
return CommandResult(args=args, returncode=0)
if args[:6] == ("ip", "-j", "-d", "link", "show", "dev"):
return CommandResult(args=args, returncode=0, stdout=self.link_details[args[6]])
if args[:4] == ("ip", "link", "show", "uvm0"):
return CommandResult(args=args, returncode=1)
if failure := self.failures.get(args):
if check or not isinstance(failure, CommandError):
raise failure
return CommandResult(args=args, returncode=1, stderr=str(failure))
if args[:5] == LINK_INVENTORY:
links = list(self.links.values()) if args == LINK_INVENTORY else [self.links[args[6]]]
stdout = self.inventory if self.inventory is not None else json.dumps(links)
return CommandResult(args=args, returncode=0, stdout=stdout)
if args[:3] == ("ip", "link", "show"):
return CommandResult(args=args, returncode=0 if args[3] in self.links else 1)
if args[:4] == ("ip", "tuntap", "add", "dev"):
if args[4] in self.links:
raise CommandError("interface already exists")
self.links[args[4]] = {
"ifname": args[4], "flags": [],
"linkinfo": {"info_kind": "tun", "info_data": {"type": "tap"}},
}
elif args[:3] == ("ip", "link", "add"):
self.links[args[3]] = {
"ifname": args[3], "flags": [],
"linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}},
}
elif args[:3] == ("ip", "link", "set"):
name, *options = args[4:] if args[3] == "dev" else args[3:]
link = self.links[name]
if options[0] == "alias":
link["ifalias"] = options[1]
elif options[0] == "master":
link["master"] = options[1]
elif options in (["up"], ["down"]):
link["flags"] = ["UP"] if options == ["up"] else []
elif options[:3] == ["type", "bridge_slave", "group_fwd_mask"]:
link["linkinfo"]["info_slave_kind"] = "bridge"
link["linkinfo"]["info_slave_data"] = {"group_fwd_mask": int(options[3])}
else:
raise AssertionError(f"unexpected link command: {args}")
elif args[:3] == ("ip", "link", "del"):
self.links.pop(args[3], None)
elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"):
pass
elif args[0] == "iptables":
return CommandResult(args=args, returncode=1 if "-C" in args else 0)
elif args != ("ip", "route", "show", "default"):
raise AssertionError(f"unexpected host command: {args}")
if args == ("ip", "route", "show", "default"):
return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n")
if len(args) > 3 and args[0] == "iptables" and "-C" in args:
return CommandResult(args=args, returncode=1)
return CommandResult(args=args, returncode=0)
@@ -37,13 +80,8 @@ class NetworkManagerTests(unittest.TestCase):
def setUp(self) -> None:
self.runner = FakeRunner()
self.network = NetworkManager(Settings(), runner=self.runner) # type: ignore[arg-type]
def test_allocates_first_available_guest_address(self) -> None:
self.assertEqual(self.network.allocate_ip([], None), IPv4Address("10.42.0.2"))
def test_rejects_gateway_and_used_requested_addresses(self) -> None:
vm = VmRecord(
id="vm-test",
self.vm = VmRecord(
id="vm-0123456789abcdef0123456789abcdef",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
@@ -53,11 +91,23 @@ class NetworkManagerTests(unittest.TestCase):
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
ports=(PortRule(22),),
bridge="uvm0",
)
self.tap_link = {
"ifname": self.vm.tap, "ifalias": f"uvm:{self.vm.id}", "flags": [],
"master": "uvm0",
"linkinfo": {"info_kind": "tun", "info_data": {"type": "tap"}},
}
def test_allocates_first_available_guest_address(self) -> None:
self.assertEqual(self.network.allocate_ip([], None), IPv4Address("10.42.0.2"))
def test_rejects_gateway_and_used_requested_addresses(self) -> None:
with self.assertRaises(ValidationError):
self.network.allocate_ip([vm], IPv4Address("10.42.0.1"))
self.network.allocate_ip([self.vm], IPv4Address("10.42.0.1"))
with self.assertRaises(ValidationError):
self.network.allocate_ip([vm], IPv4Address("10.42.0.2"))
self.network.allocate_ip([self.vm], IPv4Address("10.42.0.2"))
with self.assertRaises(ValidationError):
self.network.allocate_ip([], IPv4Address("10.42.0.0"))
with self.assertRaises(ValidationError):
@@ -130,19 +180,21 @@ class NetworkManagerTests(unittest.TestCase):
)
def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
self.runner.existing_links.add("uvm-existing")
with self.assertRaises(UvmError):
self.network.create_tap("uvm-existing")
self.assertNotIn(
("ip", "tuntap", "add", "dev", "uvm-existing", "mode", "tap"),
self.runner.calls,
)
for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"):
for owned in (False, True):
with self.subTest(alias=alias, owned=owned):
link = {**deepcopy(self.tap_link), "ifalias": alias}
self.runner.links = {self.vm.tap: link}
self.runner.calls.clear()
self.vm.tap_owned = owned
with self.assertRaisesRegex(ConflictError, "already exists"):
self.network.prepare_tap(self.vm)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertEqual(self.runner.links, {self.vm.tap: link})
self.assertIs(self.vm.tap_owned, owned)
def test_refuses_to_reconfigure_an_existing_non_bridge_interface(self) -> None:
self.runner.existing_links.add("uvm0")
self.runner.link_details["uvm0"] = '[{"linkinfo": {"info_kind": "dummy"}}]'
self.runner.links["uvm0"] = {"ifname": "uvm0", "linkinfo": {"info_kind": "dummy"}}
with self.assertRaises(UvmError):
self.network.ensure_bridge()
@@ -151,3 +203,273 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
self.runner.calls,
)
def test_existing_stp_disabled_bridge_is_reused(self) -> None:
self.runner.links["uvm0"] = {
"ifname": "uvm0", "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}},
}
self.network.ensure_bridge()
self.assertNotIn(("ip", "link", "add", "uvm0", "type", "bridge"), self.runner.calls)
self.assertIn(("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"), self.runner.calls)
def test_stp_enabled_bridge_is_rejected_before_any_mutation(self) -> None:
for stp_state in (1, 2):
with self.subTest(stp_state=stp_state):
self.runner.links["uvm0"] = {
"ifname": "uvm0",
"linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": stp_state}},
}
self.runner.calls.clear()
with self.assertRaisesRegex(UvmError, "STP-disabled"):
self.network.ensure_bridge()
self.assertEqual(self.runner.calls, [
("ip", "link", "show", "uvm0"), (*LINK_INVENTORY, "dev", "uvm0"),
])
def test_invalid_bridge_inventory_is_rejected_before_any_mutation(self) -> None:
self.runner.links["uvm0"] = {"ifname": "uvm0"}
for inventory in ("invalid-json", "null", "{}", "[]", "[{}]"):
with self.subTest(inventory=inventory):
self.runner.inventory = inventory
self.runner.calls.clear()
with self.assertRaises(UvmError):
self.network.ensure_bridge()
self.assertEqual(self.runner.calls, [
("ip", "link", "show", "uvm0"), (*LINK_INVENTORY, "dev", "uvm0"),
])
def test_prepare_tap_sets_owner_bridge_and_lldp_mask_without_activating_it(self) -> None:
unrelated = {"ifname": f"{self.vm.tap}-other", "linkinfo": {"info_kind": "dummy"}}
for bridge in (None, "uvm-retained"):
with self.subTest(bridge=bridge):
self.runner.links = {unrelated["ifname"]: deepcopy(unrelated)}
self.runner.calls.clear()
vm = replace(self.vm, bridge=bridge, tap_owned=False)
self.network.prepare_tap(vm)
link = self.runner.links[vm.tap]
self.assertIs(vm.tap_owned, True)
self.assertEqual(link["ifalias"], f"uvm:{vm.id}")
self.assertEqual(link["master"], bridge or "uvm0")
self.assertEqual(link["linkinfo"]["info_kind"], "tun")
self.assertEqual(link["linkinfo"]["info_data"]["type"], "tap")
self.assertEqual(link["linkinfo"]["info_slave_data"]["group_fwd_mask"], 16384)
self.assertNotIn("UP", link["flags"])
self.assertFalse(any(call[-1] == "up" for call in self.runner.calls))
self.assertEqual(self.runner.links[unrelated["ifname"]], unrelated)
self.assertEqual(self.runner.calls[0], LINK_INVENTORY)
commands = (
("ip", "tuntap", "add", "dev", vm.tap, "mode", "tap"),
("ip", "link", "set", "dev", vm.tap, "alias", f"uvm:{vm.id}"),
("ip", "link", "set", vm.tap, "master", bridge or "uvm0"),
("ip", "link", "set", "dev", vm.tap, "type", "bridge_slave", "group_fwd_mask", "16384"),
)
positions = [self.runner.calls.index(command) for command in commands]
self.assertEqual(positions, sorted(positions))
def test_owned_tap_can_be_activated_deactivated_and_deleted(self) -> None:
unrelated = {"ifname": "eth0", "flags": ["UP"], "linkinfo": {"info_kind": "veth"}}
for owned in (False, True):
with self.subTest(owned=owned):
vm = replace(self.vm, tap_owned=owned)
self.runner.links = {"eth0": deepcopy(unrelated), vm.tap: deepcopy(self.tap_link)}
self.runner.calls.clear()
self.assertTrue(self.network.has_tap(vm))
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.network.activate_tap(vm)
self.assertIn("UP", self.runner.links[vm.tap]["flags"])
self.network.deactivate_tap(vm)
self.assertNotIn("UP", self.runner.links[vm.tap]["flags"])
self.assertIs(vm.tap_owned, owned)
self.network.delete_vm_tap(vm)
self.assertNotIn(vm.tap, self.runner.links)
self.assertIs(vm.tap_owned, False)
self.assertFalse(self.network.has_tap(vm))
self.assertEqual(self.runner.links, {"eth0": unrelated})
def test_missing_tap_cannot_be_activated_and_cleanup_is_idempotent(self) -> None:
self.vm.tap_owned = True
with self.assertRaisesRegex(UvmError, "TAP device missing"):
self.network.activate_tap(self.vm)
self.assertIs(self.vm.tap_owned, True)
self.network.deactivate_tap(self.vm)
self.assertFalse(self.network.has_tap(self.vm))
self.network.delete_vm_tap(self.vm)
self.network.delete_vm_tap(self.vm)
self.assertIs(self.vm.tap_owned, False)
self.assertTrue(all(call == LINK_INVENTORY for call in self.runner.calls))
def test_named_foreign_taps_are_never_activated_deactivated_deleted_or_claimed(self) -> None:
cases = (
((PortRule(22),), True, None), ((), True, ""), ((), False, None),
(None, False, None), (None, True, "uvm:another-vm"),
((), True, "uvm:another-vm"), ((), True, self.vm.id),
((), True, f"uvm:{self.vm.id}-suffix"),
)
for ports, owned, alias in cases:
for operation in ("activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"):
with self.subTest(ports=ports, owned=owned, alias=alias, operation=operation):
vm = replace(self.vm, ports=ports, tap_owned=owned)
link = {**deepcopy(self.tap_link), "ifalias": alias}
self.runner.links = {vm.tap: link}
before = deepcopy(self.runner.links)
self.runner.calls.clear()
with self.assertRaisesRegex(ConflictError, "unowned TAP"):
getattr(self.network, operation)(vm)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertEqual(self.runner.links, before)
self.assertIs(vm.tap_owned, owned)
def test_owned_legacy_unaliased_records_can_only_manage_real_tun_taps(self) -> None:
for alias in (None, ""):
with self.subTest(alias=alias):
vm = replace(self.vm, ports=None, tap_owned=True)
link = deepcopy(self.tap_link)
if alias is None:
link.pop("ifalias")
else:
link["ifalias"] = alias
self.runner.links = {vm.tap: link}
self.assertTrue(self.network.has_tap(vm))
self.network.activate_tap(vm)
self.assertIn("UP", link["flags"])
self.network.deactivate_tap(vm)
self.assertNotIn("UP", link["flags"])
self.network.delete_vm_tap(vm)
self.assertIs(vm.tap_owned, False)
self.assertNotIn(vm.tap, self.runner.links)
def test_ownership_markers_do_not_authorize_non_tap_interfaces(self) -> None:
for info in (
{}, {"info_kind": "dummy"}, {"info_kind": "bridge"}, {"info_kind": "veth"},
{"info_kind": "tap"}, {"info_kind": "tun"},
{"info_kind": "tun", "info_data": {"type": "tun"}},
):
for ports, alias in ((None, None), ((), f"uvm:{self.vm.id}")):
for operation in ("activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"):
with self.subTest(info=info, ports=ports, operation=operation):
vm = replace(self.vm, ports=ports, tap_owned=True)
link = {"ifname": vm.tap, "ifalias": alias, "linkinfo": info}
self.runner.links = {vm.tap: link}
self.runner.calls.clear()
with self.assertRaises(ConflictError):
getattr(self.network, operation)(vm)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertIs(vm.tap_owned, True)
def test_invalid_or_ambiguous_inventory_fails_closed(self) -> None:
for inventory in (
"invalid-json", "null", "{}", "[null]", "[[]]", "[1]",
json.dumps([self.tap_link, self.tap_link]),
):
for operation in ("prepare_tap", "activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"):
with self.subTest(inventory=inventory, operation=operation):
self.runner.inventory = inventory
self.runner.calls.clear()
self.vm.tap_owned = True
with self.assertRaisesRegex(UvmError, "cannot inspect network interfaces"):
getattr(self.network, operation)(self.vm)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertIs(self.vm.tap_owned, True)
def test_invalid_interface_names_cannot_authorize_creation_or_cleanup(self) -> None:
for link in ({}, {"ifname": None}, {"ifname": 123}, {"ifname": ""}):
for operation, owned in (("prepare_tap", False), ("delete_vm_tap", True)):
with self.subTest(link=link, operation=operation):
self.runner.inventory = json.dumps([link])
self.runner.links.clear()
self.runner.calls.clear()
self.vm.tap_owned = owned
with self.assertRaises(UvmError):
getattr(self.network, operation)(self.vm)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertIs(self.vm.tap_owned, owned)
def test_malformed_tap_metadata_is_a_controlled_error_without_mutation(self) -> None:
for info in (
None, [], {"info_kind": "tun", "info_data": None},
{"info_kind": "tun", "info_data": []},
):
for operation in ("activate_tap", "delete_vm_tap"):
with self.subTest(info=info, operation=operation):
self.runner.links = {self.vm.tap: {**self.tap_link, "linkinfo": info}}
self.runner.calls.clear()
self.vm.tap_owned = True
with self.assertRaises(UvmError):
getattr(self.network, operation)(self.vm)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertIs(self.vm.tap_owned, True)
def test_inventory_command_failure_never_mutates_a_link_or_ownership(self) -> None:
failure = CommandError("inventory unavailable")
self.runner.failures[LINK_INVENTORY] = failure
for operation in ("prepare_tap", "activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"):
with self.subTest(operation=operation):
self.runner.calls.clear()
self.vm.tap_owned = True
with self.assertRaises(CommandError) as raised:
getattr(self.network, operation)(self.vm)
self.assertIs(raised.exception, failure)
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
self.assertIs(self.vm.tap_owned, True)
def test_prepare_failure_cleans_created_taps_or_retains_the_persistable_owned_flag(self) -> None:
stages = (
("ip", "tuntap", "add", "dev", self.vm.tap, "mode", "tap"),
("ip", "link", "set", "dev", self.vm.tap, "alias", f"uvm:{self.vm.id}"),
("ip", "link", "set", self.vm.tap, "master", "uvm0"),
("ip", "link", "set", "dev", self.vm.tap, "type", "bridge_slave", "group_fwd_mask", "16384"),
)
delete = ("ip", "link", "del", self.vm.tap)
for index, stage in enumerate(stages):
for cleanup_fails in (False, True):
with self.subTest(stage=stage, cleanup_fails=cleanup_fails):
vm = replace(self.vm, tap_owned=False)
self.runner.links.clear()
self.runner.calls.clear()
failure = CommandError("preparation failed")
self.runner.failures = {stage: failure}
if cleanup_fails:
self.runner.failures[delete] = CommandError("deletion failed")
with self.assertRaises(CommandError) as raised:
self.network.prepare_tap(vm)
retained = index > 0 and cleanup_fails
self.assertIs(raised.exception, failure)
self.assertIs(vm.tap_owned, retained)
restored = VmRecord.from_dict(json.loads(json.dumps(vm.to_dict())))
self.assertIs(restored.tap_owned, retained)
self.assertEqual(vm.tap in self.runner.links, retained)
self.assertEqual(delete in self.runner.calls, index > 0)
self.assertFalse(any(call[-1] == "up" for call in self.runner.calls))
def test_interrupted_preparation_cleans_up_and_reraises_the_interrupt(self) -> None:
stage = ("ip", "link", "set", self.vm.tap, "master", "uvm0")
for failure in (KeyboardInterrupt(), SystemExit(2)):
with self.subTest(failure=type(failure).__name__):
self.runner.calls.clear()
self.runner.failures = {stage: failure}
with self.assertRaises(type(failure)) as raised:
self.network.prepare_tap(self.vm)
self.assertIs(raised.exception, failure)
self.assertNotIn(self.vm.tap, self.runner.links)
self.assertIs(self.vm.tap_owned, False)
self.assertIn(("ip", "link", "del", self.vm.tap), self.runner.calls)
def test_failed_deletion_keeps_ownership_until_a_successful_retry(self) -> None:
self.vm.tap_owned = True
self.runner.links[self.vm.tap] = deepcopy(self.tap_link)
delete = ("ip", "link", "del", self.vm.tap)
self.runner.failures[delete] = CommandError("device busy")
with self.assertRaisesRegex(UvmError, "could not delete TAP.*device busy"):
self.network.delete_vm_tap(self.vm)
self.assertIn(self.vm.tap, self.runner.links)
self.assertIs(self.vm.tap_owned, True)
self.assertIs(VmRecord.from_dict(self.vm.to_dict()).tap_owned, True)
self.runner.failures.clear()
self.network.delete_vm_tap(self.vm)
self.assertNotIn(self.vm.tap, self.runner.links)
self.assertIs(self.vm.tap_owned, False)
+1252 -39
View File
File diff suppressed because it is too large Load Diff
+365 -99
View File
@@ -11,8 +11,10 @@ from unittest.mock import patch
from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.errors import ConfigurationError, UvmError
from uvm.server import create_api, run_server
from uvm.domain import PortRule, VmRecord, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import ListedVm
from uvm.server import _http_status_for, create_api, run_server
def fake_application(*, api_token: str | None = None):
@@ -117,6 +119,20 @@ class ServerCliTests(unittest.TestCase):
Settings(api_token=token)
class ServerErrorTests(unittest.TestCase):
def test_domain_errors_map_to_http_status_codes(self) -> None:
for error, expected in (
(NotFoundError("unknown guest"), 404),
(ConflictError("ambiguous name"), 409),
(ValidationError("invalid name"), 422),
(ConfigurationError("invalid settings"), 422),
(UvmError("this operation needs root"), 403),
(UvmError("host operation failed"), 500),
):
with self.subTest(error=error):
self.assertEqual(_http_status_for(error), expected)
FASTAPI_AVAILABLE = (
importlib.util.find_spec("fastapi") is not None
and importlib.util.find_spec("httpx") is not None
@@ -125,117 +141,367 @@ FASTAPI_AVAILABLE = (
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
from uvm.domain import VmRecord
from uvm.lifecycle import ListedVm
from uvm.server import create_api
class FakeLifecycle:
def __init__(self) -> None:
self.last_spec = None
self.vm = VmRecord(
id="vm-test",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
status="running",
class FakeLifecycle:
def __init__(self) -> None:
self.last_spec: VmSpec | None = None
self.observed_status = "running"
self.policy_status = "enforced"
self.vm = VmRecord(
id="vm-test",
name="test-vm",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
password="guest-secret",
status="running",
ports=(PortRule(22),),
operation=None,
disk_ready=True,
boot_id="test-boot",
)
def list_vms(self) -> list[ListedVm]:
return [ListedVm(
vm=self.vm,
observed_status=self.observed_status,
policy_status=self.policy_status,
)]
def inspect(self, identifier: str) -> ListedVm:
if identifier not in {self.vm.id, self.vm.guest_ip, self.vm.name}:
raise NotFoundError(f"VM not found: {identifier}")
return ListedVm(
vm=self.vm,
observed_status=self.observed_status,
policy_status=self.policy_status,
)
def create(self, spec: VmSpec) -> VmRecord:
self.last_spec = spec
self.vm.cpu = spec.cpu
self.vm.ram_mib = spec.ram_mib
if spec.guest_ip is not None:
self.vm.guest_ip = str(spec.guest_ip)
self.vm.username = spec.username
self.vm.password = spec.password
self.vm.name = spec.name
self.vm.ports = spec.ports
self.vm.status = "running"
self.vm.operation = None
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.vm.status = "stopped"
self.vm.pid = None
self.vm.operation = None
self.observed_status = "stopped"
self.policy_status = "inactive"
return self.vm
def destroy(self, identifier: str) -> VmRecord:
self.inspect(identifier)
return self.vm
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests")
class ApiRouterTests(unittest.TestCase):
def setUp(self) -> None:
application = fake_application(api_token="test-token")
self.lifecycle = FakeLifecycle()
application.lifecycle = self.lifecycle
application.installer = SimpleNamespace(
install=lambda **_kwargs: (
Path("/tmp/firecracker"),
SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
)
)
application.state_store = SimpleNamespace(initialize=lambda: None)
self.headers = {"X-UVM-Token": "test-token"}
self.client = TestClient(create_api(application, host="127.0.0.1"))
self.addCleanup(self.client.close)
def list_vms(self) -> list[ListedVm]:
return [ListedVm(vm=self.vm, observed_status=self.vm.status)]
def test_health_is_available_without_credentials(self) -> None:
response = self.client.get("/health")
def create(self, spec):
self.last_spec = spec
self.vm.username = spec.username
self.vm.password = spec.password
self.vm.status = "running"
return self.vm
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
def stop(self, _vm_id: str):
self.vm.status = "stopped"
return self.vm
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
for method, path in (
("GET", "/vms"),
("POST", "/vms"),
("GET", "/vms/test-vm"),
("POST", "/vms/test-vm/stop"),
("DELETE", "/vms/test-vm"),
("POST", "/install"),
):
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
with self.subTest(method=method, path=path, headers=headers):
response = self.client.request(method, path, headers=headers, json={})
self.assertEqual(response.status_code, 401)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(self.lifecycle.vm.status, "running")
def destroy(self, _vm_id: str):
return self.vm
authorized = self.client.get("/vms", headers=self.headers)
self.assertEqual(authorized.status_code, 200)
self.assertEqual(authorized.json()[0]["id"], "vm-test")
self.assertNotIn("password", authorized.json()[0])
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI is not installed")
class ApiRouterTests(unittest.TestCase):
def setUp(self) -> None:
application = fake_application(api_token="test-token")
self.lifecycle = FakeLifecycle()
application.lifecycle = self.lifecycle
application.installer = SimpleNamespace(
install=lambda **_kwargs: (
Path("/tmp/firecracker"),
SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
)
)
application.state_store = SimpleNamespace(initialize=lambda: None)
self.client = TestClient(create_api(application, host="127.0.0.1"))
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
created = self.client.post(
"/vms",
headers=self.headers,
json={
"cpu": 1,
"ram": "512",
"username": "root",
"password": "api-secret",
},
)
detail = self.client.get("/vms/vm-test", headers=self.headers)
stopped = self.client.post("/vms/vm-test/stop", headers=self.headers)
destroyed = self.client.delete("/vms/vm-test", headers=self.headers)
invalid = self.client.post("/vms", headers=self.headers, json={"forse": True})
def test_health_is_available_without_credentials(self) -> None:
response = self.client.get("/health")
self.assertEqual(created.status_code, 201)
self.assertEqual(created.json()["username"], "root")
self.assertEqual(self.lifecycle.last_spec.password, "api-secret")
for response in (created, detail, stopped, destroyed):
self.assertNotIn("password", response.json())
self.assertNotIn("api-secret", response.text)
self.assertEqual(detail.status_code, 200)
self.assertEqual(stopped.json()["status"], "stopped")
self.assertEqual(stopped.json()["policy_status"], "inactive")
self.assertEqual(destroyed.json(), {"id": "vm-test", "status": "terminated"})
self.assertEqual(invalid.status_code, 422)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
def test_vm_create_defaults_guest_credentials_to_root(self) -> None:
response = self.client.post(
"/vms", headers=self.headers, json={"cpu": 1, "ram": "512"},
)
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
unauthorized = self.client.get("/vms")
authorized = self.client.get("/vms", headers={"X-UVM-Token": "test-token"})
self.assertEqual(response.status_code, 201)
self.assertEqual(self.lifecycle.last_spec.username, "root")
self.assertEqual(self.lifecycle.last_spec.password, "root")
self.assertIsNone(self.lifecycle.last_spec.name)
self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),))
self.assertEqual(unauthorized.status_code, 401)
self.assertEqual(authorized.status_code, 200)
self.assertEqual(authorized.json()[0]["id"], "vm-test")
self.assertNotIn("password", authorized.json()[0])
def test_create_accepts_valid_names(self) -> None:
for name in (None, "a", "web-1", "a" * 63):
with self.subTest(name=name):
response = self.client.post("/vms", headers=self.headers, json={"name": name})
self.assertEqual(response.status_code, 201)
self.assertEqual(self.lifecycle.last_spec.name, name)
self.assertEqual(response.json()["name"], name)
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
headers = {"X-UVM-Token": "test-token"}
created = self.client.post(
"/vms",
headers=headers,
json={
"cpu": 1,
"ram": "512",
"username": "root",
"password": "api-secret",
},
)
detail = self.client.get("/vms/vm-test", headers=headers)
stopped = self.client.post("/vms/vm-test/stop", headers=headers)
destroyed = self.client.delete("/vms/vm-test", headers=headers)
invalid = self.client.post("/vms", headers=headers, json={"forse": True})
def test_create_rejects_invalid_names_before_lifecycle_calls(self) -> None:
for name in ("", "-web", "9web", "Web", "web_name", "web.name", "two words", "a" * 64, 1, True):
with self.subTest(name=name):
response = self.client.post("/vms", headers=self.headers, json={"name": name})
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(created.status_code, 201)
self.assertEqual(created.json()["username"], "root")
self.assertNotIn("password", created.json())
self.assertEqual(self.lifecycle.last_spec.password, "api-secret")
self.assertEqual(detail.status_code, 200)
self.assertEqual(stopped.json()["status"], "stopped")
self.assertEqual(destroyed.json()["status"], "terminated")
self.assertEqual(invalid.status_code, 422)
def test_create_normalizes_private_ports_and_default_ssh(self) -> None:
cases = (
({}, (PortRule(22),)),
({"ports": None}, (PortRule(22),)),
({"ports": []}, (PortRule(22),)),
(
{"ports": [{"port": 443, "protocol": "HTTPS"}, {"port": 80, "protocol": "HTTP"}]},
(PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS")),
),
(
{"ports": [{"port": 80}, {"port": 22}, {"port": 80, "protocol": "TCP"}]},
(PortRule(22), PortRule(80)),
),
({"ports": [{"port": 22, "protocol": "HTTP"}]}, (PortRule(22, "HTTP"),)),
({"ports": [{"port": 65535}, {"port": 1}]}, (PortRule(1), PortRule(22), PortRule(65535))),
({"no_default_ports": True}, ()),
({"no_default_ports": True, "ports": None}, ()),
({"no_default_ports": True, "ports": []}, ()),
(
{"no_default_ports": True, "ports": [{"port": 443, "protocol": "HTTPS"}]},
(PortRule(443, "HTTPS"),),
),
({"no_default_ports": True, "ports": [{"port": 22}]}, (PortRule(22),)),
)
for payload, expected in cases:
with self.subTest(payload=payload):
response = self.client.post("/vms", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(self.lifecycle.last_spec.ports, expected)
self.assertEqual(response.json()["ports"], [rule.to_dict() for rule in expected])
self.assertEqual(response.json()["policy_status"], "enforced")
def test_vm_create_defaults_guest_credentials_to_root(self) -> None:
response = self.client.post(
"/vms",
headers={"X-UVM-Token": "test-token"},
json={"cpu": 1, "ram": "512"},
)
def test_create_rejects_invalid_ports_before_lifecycle_calls(self) -> None:
invalid_rules = [
{"port": value}
for value in (0, -1, 65536, True, False, 22.0, 22.5, "22", None, {}, [])
] + [
{"port": 80, "protocol": value}
for value in ("UDP", "http", "HTTP ", "", None, True, 1)
] + [
{},
{"protocol": "TCP"},
{"port": 80, "public_port": 8080},
{"port": 80, "host_port": 8080},
]
payloads = [{"ports": [rule]} for rule in invalid_rules] + [
{"ports": value} for value in ("22:TCP", {}, [22], ["22:TCP"], [None])
] + [
{"ports": [{"port": 80}, {"port": 80, "protocol": "HTTP"}]},
{"ports": [{"port": 443, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]},
{"public_ports": [80]},
] + [
{"no_default_ports": value} for value in (None, 0, 1, "true", "false")
]
for payload in payloads:
with self.subTest(payload=payload):
response = self.client.post("/vms", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(response.status_code, 201)
self.assertEqual(self.lifecycle.last_spec.username, "root")
self.assertEqual(self.lifecycle.last_spec.password, "root")
def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None:
expected_fields = {
"id", "name", "cpu", "ram_mib", "guest_ip", "gateway", "mac", "username",
"status", "observed_status", "ports", "policy_status", "operation", "pid",
"created_at", "updated_at", "last_error",
}
self.lifecycle.vm.operation = "ports"
self.lifecycle.observed_status = "dead"
for ports, policy_status in (
(None, "unmanaged"),
((), "enforced"),
((PortRule(443, "HTTPS"),), "missing"),
((PortRule(22),), "unknown"),
((PortRule(22),), "drift"),
):
with self.subTest(ports=ports, policy_status=policy_status):
self.lifecycle.vm.ports = ports
self.lifecycle.policy_status = policy_status
listed = self.client.get("/vms", headers=self.headers)
detail = self.client.get("/vms/test-vm", headers=self.headers)
self.assertEqual(listed.status_code, 200)
self.assertEqual(detail.status_code, 200)
self.assertIsInstance(listed.json(), list)
self.assertEqual(listed.json(), [detail.json()])
body = detail.json()
self.assertEqual(set(body), expected_fields)
self.assertEqual(body["name"], "test-vm")
self.assertEqual(body["status"], "running")
self.assertEqual(body["observed_status"], "dead")
self.assertEqual(body["policy_status"], policy_status)
self.assertEqual(body["operation"], "ports")
self.assertEqual(body["ports"], None if ports is None else [p.to_dict() for p in ports])
self.assertNotIn("guest-secret", detail.text)
def test_install_route_uses_the_existing_installer(self) -> None:
response = self.client.post(
"/install",
headers={"X-UVM-Token": "test-token"},
json={"force": True},
)
def test_get_delegates_id_ip_and_name_resolution_to_inspect(self) -> None:
with (
patch.object(self.lifecycle, "inspect", wraps=self.lifecycle.inspect) as inspect,
patch.object(self.lifecycle, "list_vms", side_effect=AssertionError("must use inspect")),
):
for identifier in ("vm-test", "10.42.0.2", "test-vm"):
with self.subTest(identifier=identifier):
response = self.client.get(f"/vms/{identifier}", headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["id"], "vm-test")
inspect.assert_called_with(identifier)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")
def test_stop_and_destroy_pass_identifiers_to_lifecycle(self) -> None:
with (
patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop,
patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy,
):
for identifier in ("vm-test", "10.42.0.2", "test-vm"):
with self.subTest(identifier=identifier):
stopped = self.client.post(f"/vms/{identifier}/stop", headers=self.headers)
self.assertEqual(stopped.status_code, 200)
self.assertEqual(stopped.json()["id"], "vm-test")
self.assertEqual(stopped.json()["policy_status"], "inactive")
self.assertEqual(stopped.json()["ports"], [{"port": 22, "protocol": "TCP"}])
stop.assert_called_with(identifier)
destroyed = self.client.delete(f"/vms/{identifier}", headers=self.headers)
self.assertEqual(destroyed.status_code, 200)
self.assertEqual(destroyed.json()["id"], "vm-test")
destroy.assert_called_with(identifier)
def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None:
for ports, status, operation, policy_status in (
(None, "running", None, "unmanaged"),
((), "running", None, "enforced"),
((PortRule(22),), "failed", None, "unknown"),
((PortRule(22),), "starting", "create", "unknown"),
((PortRule(22),), "running", "ports", "unknown"),
):
with self.subTest(ports=ports, status=status, operation=operation):
self.lifecycle.vm.ports = ports
self.lifecycle.vm.status = status
self.lifecycle.vm.operation = operation
with patch.object(self.lifecycle, "create", return_value=self.lifecycle.vm):
response = self.client.post("/vms", headers=self.headers, json={})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["policy_status"], policy_status)
def test_lifecycle_errors_use_typed_status_codes(self) -> None:
for method, path, operation in (
("GET", "/vms", "list_vms"),
("GET", "/vms/test-vm", "inspect"),
("POST", "/vms", "create"),
("POST", "/vms/test-vm/stop", "stop"),
("DELETE", "/vms/test-vm", "destroy"),
):
for error, expected in (
(NotFoundError("unknown guest"), 404),
(ConflictError("ambiguous name"), 409),
(ValidationError("invalid request"), 422),
(ConfigurationError("invalid settings"), 422),
(UvmError("this operation needs root"), 403),
(UvmError("host operation failed"), 500),
):
with self.subTest(operation=operation, error=error):
with patch.object(self.lifecycle, operation, side_effect=error):
response = self.client.request(method, path, headers=self.headers, json={})
self.assertEqual(response.status_code, expected)
self.assertEqual(response.json(), {"error": {"message": str(error)}})
def test_missing_vm_returns_not_found(self) -> None:
response = self.client.get("/vms/missing", headers=self.headers)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
def test_api_schema_keeps_only_existing_operations(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertEqual(
{(path, method) for path, methods in schema["paths"].items() for method in methods},
{
("/health", "get"),
("/install", "post"),
("/vms", "get"),
("/vms", "post"),
("/vms/{identifier}", "get"),
("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"),
},
)
self.assertEqual(
set(schema["components"]["schemas"]["PortRuleModel"]["properties"]),
{"port", "protocol"},
)
def test_install_route_uses_the_existing_installer(self) -> None:
response = self.client.post("/install", headers=self.headers, json={"force": True})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")
+227 -18
View File
@@ -9,8 +9,8 @@ from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import VmRecord
from uvm.errors import StateError
from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmSpec, new_vm_id
from uvm.errors import ConflictError, NotFoundError, StateError
from uvm.state import StateStore
@@ -61,26 +61,103 @@ class StateStoreTests(unittest.TestCase):
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
self.base.mkdir(parents=True)
legacy = {"vms": {"vm-test": make_vm().to_dict()}}
legacy["vms"]["vm-test"].pop("disk")
legacy["vms"]["vm-test"].pop("username")
legacy["vms"]["vm-test"].pop("password")
legacy["vms"]["vm-test"].pop("updated_at")
self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8")
for version in (None, 1):
with self.subTest(version=version):
raw_vm = make_vm().to_dict()
for field in (
"disk", "username", "password", "updated_at", "name", "ports",
"disk_ready", "runtime_owned", "tap_owned", "operation", "boot_id",
"spawn_pending", "network", "bridge",
):
raw_vm.pop(field)
legacy = {"vms": {"vm-test": raw_vm}}
if version is not None:
legacy["schema_version"] = version
self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8")
loaded = self.store.load()
self.assertEqual(loaded.vms["vm-test"].disk, "")
self.assertEqual(loaded.vms["vm-test"].username, "root")
self.assertIsNone(loaded.vms["vm-test"].password)
self.assertEqual(loaded.next_mac_index, 2)
loaded = self.store.load()
vm = loaded.vms["vm-test"]
self.assertEqual(loaded.schema_version, 2)
self.assertEqual(vm.disk, "")
self.assertEqual(vm.username, "root")
self.assertIsNone(vm.password)
self.assertIsNone(vm.name)
self.assertIsNone(vm.ports)
self.assertIsNone(vm.network)
self.assertIsNone(vm.bridge)
self.assertFalse(vm.disk_ready)
self.assertFalse(vm.runtime_owned)
self.assertFalse(vm.tap_owned)
self.assertFalse(vm.spawn_pending)
self.assertEqual(vm.updated_at, vm.created_at)
self.assertEqual(loaded.next_mac_index, 2)
self.assertEqual(
json.loads(self.settings.state_path.read_text(encoding="utf-8")), legacy
)
with self.store.transaction() as state:
state.vms["vm-test"].status = "stopped"
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(persisted["schema_version"], 2)
self.assertEqual(persisted["next_mac_index"], 2)
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
def test_round_trip_preserves_policy_ownership_and_recovery_fields(self) -> None:
for ports in (None, (), (PortRule(22), PortRule(8080, "http"))):
with self.subTest(ports=ports):
vm = replace(
make_vm(), name="app-server", ports=ports, disk_ready=True,
runtime_owned=True, tap_owned=True, operation="configuring",
boot_id="host-boot-id", spawn_pending=True, pid=123,
process_start_time="456789", network="10.42.0.0/24", bridge="uvm0",
last_error="interrupted boot",
)
with self.store.transaction() as state:
state.vms[vm.id] = vm
loaded = self.store.load()
self.assertEqual(loaded.schema_version, 2)
self.assertEqual(loaded.vms[vm.id], vm)
raw = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(raw["schema_version"], 2)
self.assertEqual(
raw["vms"][vm.id]["ports"],
None if ports is None else [port.to_dict() for port in ports],
)
for field in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
self.assertIs(raw["vms"][vm.id][field], True)
def test_lookup_requires_full_case_sensitive_ids_names_or_ips(self) -> None:
vm = replace(make_vm("vm-0123456789abcdef0123456789abcdef"), name="app-server")
with self.store.transaction() as state:
state.vms["vm-test"].status = "stopped"
state.vms[vm.id] = vm
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(persisted["schema_version"], 1)
self.assertEqual(persisted["next_mac_index"], 2)
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
for identifier in (vm.id, vm.name, vm.guest_ip):
with self.subTest(identifier=identifier):
self.assertEqual(self.store.find_by_id_or_ip(identifier), vm)
self.assertEqual(self.store.get(vm.id), vm)
for identifier in (
"", vm.id[:15], vm.id.upper(), "app", "APP-SERVER", " app-server ", "10.42.0",
):
with self.subTest(identifier=identifier), self.assertRaises(NotFoundError):
self.store.find_by_id_or_ip(identifier)
for identifier in (vm.name, vm.guest_ip):
with self.subTest(get_identifier=identifier), self.assertRaises(NotFoundError):
self.store.get(identifier)
def test_load_rejects_a_record_whose_id_does_not_match_its_key(self) -> None:
self.base.mkdir(parents=True)
for version in (0, 1, 2):
with self.subTest(version=version):
raw = {
"schema_version": version,
"vms": {"vm-wrong-key": make_vm("vm-record-id").to_dict()},
}
self.settings.state_path.write_text(json.dumps(raw), encoding="utf-8")
with self.assertRaisesRegex(StateError, "ID.*registry key"):
self.store.load()
def test_initialize_protects_registry_credentials(self) -> None:
self.base.mkdir(parents=True)
@@ -110,3 +187,135 @@ class StateStoreTests(unittest.TestCase):
self.assertRaisesRegex(StateError, "Run this command with sudo"),
):
self.store.load()
class DomainStateTests(unittest.TestCase):
def test_new_specs_default_to_ssh_without_overriding_an_explicit_empty_policy(self) -> None:
self.assertEqual(VmSpec(cpu=1, ram_mib=512).ports, (PortRule(22),))
self.assertEqual(VmSpec(cpu=1, ram_mib=512, ports=()).ports, ())
self.assertIsNone(make_vm().ports)
def test_port_rule_serialization_normalizes_labels_and_defaults_to_tcp(self) -> None:
for port, protocol in ((1, "tcp"), (8080, "hTtP"), (65535, "https")):
with self.subTest(port=port, protocol=protocol):
rule = PortRule.from_dict({"port": port, "protocol": protocol})
self.assertEqual(rule.to_dict(), {"port": port, "protocol": protocol.upper()})
self.assertEqual(PortRule.from_dict(rule.to_dict()), rule)
self.assertEqual(PortRule.from_dict({"port": 22}), PortRule(22, "TCP"))
def test_port_rule_deserialization_rejects_invalid_types_fields_and_values(self) -> None:
invalid = (
None, True, 22, "22", [], (), {}, {"protocol": "TCP"},
{"port": True}, {"port": False}, {"port": None}, {"port": "22"},
{"port": 22.0}, {"port": 0}, {"port": 65536},
{"port": 22, "protocol": None}, {"port": 22, "protocol": True},
{"port": 22, "protocol": "UDP"}, {"port": 22, "protocol": " TCP"},
{"port": 22, "type": "TCP"}, {"port": 22, "protocol": "TCP", "extra": 1},
)
for value in invalid:
with self.subTest(value=value), self.assertRaises(StateError):
PortRule.from_dict(value)
def test_persisted_ports_must_be_a_json_list_or_legacy_null(self) -> None:
for ports in (True, False, 22, "22", {}, (), (PortRule(22),)):
with self.subTest(ports=ports), self.assertRaisesRegex(StateError, "ports"):
VmRecord.from_dict({**make_vm().to_dict(), "ports": ports})
for ports in ([22], [None], ["22:TCP"], [{"port": True}]):
with self.subTest(ports=ports), self.assertRaises(StateError):
VmRecord.from_dict({**make_vm().to_dict(), "ports": ports})
def test_persisted_duplicate_and_conflicting_tcp_rules_are_rejected(self) -> None:
for labels in (("TCP", "TCP"), ("tcp", "TCP"), ("TCP", "HTTP"), ("HTTP", "HTTPS")):
with self.subTest(labels=labels):
raw = make_vm().to_dict()
raw["ports"] = [{"port": 80, "protocol": label} for label in labels]
with self.assertRaisesRegex(StateError, "duplicate TCP port"):
VmRecord.from_dict(raw)
def test_ownership_and_spawn_pending_fields_require_real_booleans(self) -> None:
for field in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
for value in (None, 0, 1, 0.0, 1.0, "false", "true", [], {}):
with self.subTest(field=field, value=value):
raw = {**make_vm().to_dict(), field: value}
with self.assertRaisesRegex(StateError, f"{field} must be a boolean"):
VmRecord.from_dict(raw)
def test_explicit_recovery_flags_override_legacy_inference(self) -> None:
fields = ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending")
for flag in (False, True):
for operation in (None, "spawning", "configuring"):
with self.subTest(flag=flag, operation=operation):
raw = replace(make_vm(), status="running", pid=123, operation=operation).to_dict()
raw.update(dict.fromkeys(fields, flag))
vm = VmRecord.from_dict(raw)
for field in fields:
self.assertIs(getattr(vm, field), flag)
self.assertIs(vm.to_dict()[field], flag)
def test_legacy_ownership_is_inferred_from_disk_status_and_pid(self) -> None:
cases = (
("running", "/tmp/rootfs.ext4", 123, (True, True, True)),
("stopped", "/tmp/rootfs.ext4", None, (True, True, False)),
("error", "/tmp/rootfs.ext4", 123, (False, True, True)),
("starting", "/tmp/rootfs.ext4", None, (False, True, False)),
("running", "", 123, (False, False, True)),
("unknown", "", None, (False, False, False)),
)
for status, disk, pid, expected in cases:
with self.subTest(status=status, disk=disk, pid=pid):
raw = replace(make_vm(), status=status, disk=disk, pid=pid).to_dict()
for field in ("disk_ready", "runtime_owned", "tap_owned", "ports"):
raw.pop(field)
vm = VmRecord.from_dict(raw)
self.assertEqual((vm.disk_ready, vm.runtime_owned, vm.tap_owned), expected)
self.assertIsNone(vm.ports)
def test_missing_spawn_pending_recovers_only_a_legacy_spawning_operation(self) -> None:
for operation in (None, "spawning", "configuring", "stopping", "policy"):
with self.subTest(operation=operation):
raw = replace(make_vm(), operation=operation).to_dict()
raw.pop("spawn_pending")
vm = VmRecord.from_dict(raw)
self.assertIs(vm.spawn_pending, operation == "spawning")
self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm)
def test_new_state_uses_schema_two_and_rejects_future_versions(self) -> None:
self.assertEqual(State().schema_version, 2)
with self.assertRaisesRegex(StateError, "newer than"):
State.from_dict({"schema_version": STATE_SCHEMA_VERSION + 1, "vms": {}})
def test_generated_ids_have_the_reserved_uuid_format(self) -> None:
first, second = new_vm_id(), new_vm_id()
self.assertNotEqual(first, second)
for vm_id in (first, second):
self.assertRegex(vm_id, r"\Avm-[0-9a-f]{32}\Z")
def test_exact_generated_id_wins_over_a_legacy_name_collision(self) -> None:
vm = make_vm("vm-" + "a" * 32)
other = replace(make_vm("vm-" + "b" * 32), name=vm.id, guest_ip="10.42.0.3")
state = State(vms={other.id: other, vm.id: vm})
self.assertIs(StateStore.resolve(state, vm.id), vm)
del state.vms[vm.id]
with self.assertRaises(NotFoundError):
StateStore.resolve(state, vm.id)
def test_ambiguous_names_ips_and_legacy_ids_require_an_exact_id(self) -> None:
cases = (
("vm-" + "a" * 32, "app", "app", "10.42.0.3", "app"),
("vm-" + "a" * 32, "app", "other", "10.42.0.2", "10.42.0.2"),
("vm-" + "a" * 32, "app", "10.42.0.2", "10.42.0.3", "10.42.0.2"),
("vm-legacy", "app", "vm-legacy", "10.42.0.3", "vm-legacy"),
)
for vm_id, name, other_name, other_ip, identifier in cases:
with self.subTest(identifier=identifier, other_name=other_name):
vm = replace(make_vm(vm_id), name=name)
other = replace(make_vm("vm-" + "b" * 32), name=other_name, guest_ip=other_ip)
for records in ((vm, other), (other, vm)):
state = State(vms={record.id: record for record in records})
with self.assertRaisesRegex(ConflictError, "ambiguous.*exact VM ID"):
StateStore.resolve(state, identifier)
self.assertIs(StateStore.resolve(state, other.id), other)
def test_multiple_matching_fields_on_one_vm_are_not_ambiguous(self) -> None:
vm = replace(make_vm("vm-legacy"), name="vm-legacy")
self.assertIs(StateStore.resolve(State(vms={vm.id: vm}), "vm-legacy"), vm)
+69 -16
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import io
import subprocess
import unittest
from types import SimpleNamespace
from unittest.mock import patch
@@ -9,9 +11,41 @@ from uvm.system import CommandRunner
class CommandRunnerTests(unittest.TestCase):
def test_uncaptured_child_stdout_is_redirected_to_current_stderr(self) -> None:
stderr = io.StringIO()
completed = SimpleNamespace(returncode=0, stdout=None, stderr=None)
with (
patch("uvm.system.sys.stderr", stderr),
patch("uvm.system.subprocess.run", return_value=completed) as run,
):
result = CommandRunner(emit=None).run(("host-tool", "--check"), timeout=2.5)
run.assert_called_once_with(
("host-tool", "--check"), check=False, text=True, input=None,
stdout=stderr, stderr=None, timeout=2.5,
)
self.assertEqual(result.args, ("host-tool", "--check"))
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertEqual(result.stderr, "")
def test_captured_stdout_and_stderr_still_use_separate_pipes(self) -> None:
completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="diagnostic\n")
with patch("uvm.system.subprocess.run", return_value=completed) as run:
result = CommandRunner(emit=None).run(("host-tool",), capture=True)
self.assertEqual(run.call_args.kwargs["stdout"], subprocess.PIPE)
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.PIPE)
self.assertEqual(result.stdout, "result\n")
self.assertEqual(result.stderr, "diagnostic\n")
def test_passes_sensitive_input_over_stdin_without_logging_it(self) -> None:
emitted: list[str] = []
completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="")
completed = SimpleNamespace(
returncode=0, stdout="result secret-value\n", stderr="warning secret-value\n",
)
with patch("uvm.system.subprocess.run", return_value=completed) as run:
result = CommandRunner(emit=emitted.append).run(
@@ -21,26 +55,45 @@ class CommandRunnerTests(unittest.TestCase):
sensitive=True,
)
self.assertEqual(result.stdout, "result\n")
self.assertEqual(result.stdout, "result secret-value\n")
self.assertEqual(result.stderr, "warning secret-value\n")
self.assertEqual(run.call_args.kwargs["stdout"], subprocess.PIPE)
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.PIPE)
self.assertEqual(run.call_args.kwargs["input"], "secret-value\n")
self.assertNotIn("secret-value", emitted[0])
self.assertEqual(emitted, ["+ credential-tool --stdin"])
def test_redacts_sensitive_command_output_from_errors(self) -> None:
for stdout, stderr in (
("failure mentioning secret-value", ""),
("", "failure mentioning secret-value"),
("secret-value", "failure mentioning secret-value"),
):
with self.subTest(stdout=stdout, stderr=stderr):
completed = SimpleNamespace(returncode=1, stdout=stdout, stderr=stderr)
emitted: list[str] = []
with (
patch("uvm.system.subprocess.run", return_value=completed),
self.assertRaises(CommandError) as raised,
):
CommandRunner(emit=emitted.append).run(
("credential-tool", "--stdin"), capture=True,
input_text="secret-value\n", sensitive=True,
)
self.assertNotIn("secret-value", str(raised.exception))
self.assertEqual(emitted, ["+ credential-tool --stdin"])
def test_unchecked_sensitive_failure_preserves_captured_output_for_the_caller(self) -> None:
completed = SimpleNamespace(
returncode=1,
stdout="",
stderr="failure mentioning secret-value",
returncode=1, stdout="secret-value\n", stderr="failure mentioning secret-value",
)
with (
patch("uvm.system.subprocess.run", return_value=completed),
self.assertRaises(CommandError) as raised,
):
CommandRunner(emit=None).run(
("credential-tool", "--stdin"),
capture=True,
input_text="secret-value\n",
sensitive=True,
with patch("uvm.system.subprocess.run", return_value=completed):
result = CommandRunner(emit=None).run(
("credential-tool", "--stdin"), check=False, capture=True,
input_text="secret-value\n", sensitive=True,
)
self.assertNotIn("secret-value", str(raised.exception))
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, completed.stdout)
self.assertEqual(result.stderr, completed.stderr)
+105 -1
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
import unittest
from uvm.domain import PortRule
from uvm.errors import ValidationError
from uvm.validation import parse_cpu, parse_password, parse_ram, parse_username
from uvm.validation import (
parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_timeout, parse_username,
)
class ValidationTests(unittest.TestCase):
@@ -43,3 +46,104 @@ class ValidationTests(unittest.TestCase):
with self.subTest(value=value):
with self.assertRaises(ValidationError):
parse_password(value)
def test_names_accept_full_lowercase_names_up_to_63_characters(self) -> None:
for name in ("a", "app-server", "worker2", "a" * 63, "vm-test", "vm-" + "g" * 32):
with self.subTest(name=name):
self.assertEqual(parse_name(name), name)
def test_names_reject_invalid_format_case_and_length(self) -> None:
for name in (
"", "App", "APP", "9app", "-app", "app_name", "app.name", "app/name",
" app", "app ", "app\n", "a" * 64, "caf\u00e9", "app;id",
):
with self.subTest(name=name), self.assertRaises(ValidationError):
parse_name(name)
def test_names_cannot_impersonate_a_generated_vm_id(self) -> None:
for suffix in ("0" * 32, "0123456789abcdef" * 2, "f" * 32):
with self.subTest(suffix=suffix):
with self.assertRaisesRegex(ValidationError, "impersonate generated VM IDs"):
parse_name("vm-" + suffix)
for suffix in ("f" * 31, "f" * 33):
self.assertEqual(parse_name("vm-" + suffix), "vm-" + suffix)
def test_ports_accept_repeated_comma_lists_whitespace_and_case_insensitive_labels(self) -> None:
values = ("443:https,22", " 80:HtTp ,\t8080:tcp ", "65535, 1")
expected = (
PortRule(1), PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"),
PortRule(8080), PortRule(65535),
)
self.assertEqual(parse_ports(values), expected)
self.assertEqual(parse_ports(list(values)), expected)
def test_bare_ports_are_tcp_and_empty_sequences_do_not_add_defaults(self) -> None:
self.assertEqual(parse_ports(("80,443",)), (PortRule(80, "TCP"), PortRule(443, "TCP")))
self.assertEqual(parse_ports([]), ())
self.assertEqual(parse_ports(()), ())
def test_identical_port_labels_are_deduplicated_across_options_and_comma_lists(self) -> None:
self.assertEqual(
parse_ports(("22", "22:tcp", "80:HTTP,80:hTtP", "443:HTTPS", "443:https")),
(PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS")),
)
def test_conflicting_tcp_service_labels_are_rejected(self) -> None:
for values in (
("80", "80:HTTP"), ("80:HTTP,80:TCP",),
("80:HTTP", "80:HTTPS"), ("80:https,80:http",),
):
with self.subTest(values=values):
with self.assertRaisesRegex(ValidationError, "conflicting labels for TCP port 80"):
parse_ports(values)
def test_ports_require_strict_port_colon_label_syntax(self) -> None:
for value in (
"", ",", "22,", ",22", "22,,80", "22, ,80", "TCP", "HTTP", "HTTPS",
"TCP:80", "80/tcp", ":HTTP", "80:", "80::HTTP", "80 :HTTP", "80: HTTP",
"80:HTTP S", "80:UDP", "80-81", "80.0", "+80", "0x50", "22 80", "80;id",
):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_ports((value,))
def test_parsed_ports_reject_out_of_range_numbers_and_non_integer_text(self) -> None:
for value in ("-1", "0", "65536", "999999999999", "True", "False", "None", "1.5"):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_ports((value,))
def test_ports_reject_scalar_strings_instead_of_splitting_them_into_digits(self) -> None:
for values in ("22", "", b"22"):
with self.subTest(values=values), self.assertRaises(ValidationError):
parse_ports(values)
def test_ports_require_a_sequence_not_a_scalar_mapping_or_set(self) -> None:
for values in (None, True, 22, {"22": "HTTP"}, {"22"}):
with self.subTest(values=values), self.assertRaises(ValidationError):
parse_ports(values)
def test_ports_require_string_sequence_members(self) -> None:
for value in (None, True, False, 22, 22.0, b"22", [], {}, PortRule(22)):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_ports(("80:HTTP", value))
def test_port_rules_require_real_integers_within_tcp_port_bounds(self) -> None:
self.assertEqual(PortRule(1).protocol, "TCP")
self.assertEqual(PortRule(65535, "https").protocol, "HTTPS")
for port in (None, True, False, 0, -1, 65536, 22.0, "22", b"22", [], {}):
with self.subTest(port=port), self.assertRaises(ValidationError):
PortRule(port)
def test_port_rule_labels_are_normalized_but_not_coerced_or_trimmed(self) -> None:
self.assertEqual(PortRule(8080, "hTtP"), PortRule(8080, "HTTP"))
for protocol in (None, True, 1, "", "UDP", "SCTP", " TCP", "TCP ", "tcp\n", [], {}):
with self.subTest(protocol=protocol), self.assertRaises(ValidationError):
PortRule(22, protocol)
def test_timeout_accepts_zero_and_fractional_seconds(self) -> None:
self.assertEqual(parse_timeout("0"), 0)
self.assertEqual(parse_timeout("0.5"), 0.5)
def test_timeout_rejects_negative_non_finite_or_invalid_values(self) -> None:
for value in ("-1", "nan", "inf", "-inf", "", "seconds", None):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_timeout(value)