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:
+365
-99
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user