242 lines
9.4 KiB
Python
242 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
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
|
|
|
|
|
|
def fake_application(*, api_token: str | None = None):
|
|
settings = SimpleNamespace(
|
|
app_name="uvm",
|
|
default_vcpu=1,
|
|
default_ram_mib=512,
|
|
default_ssh_user="root",
|
|
api_token=api_token,
|
|
api_tls_cert=None,
|
|
api_tls_key=None,
|
|
)
|
|
return SimpleNamespace(settings=settings)
|
|
|
|
|
|
class ServerCliTests(unittest.TestCase):
|
|
def test_parser_accepts_the_requested_serve_invocation(self) -> None:
|
|
parser = build_parser(fake_application())
|
|
|
|
args = parser.parse_args(["--serve", "--port", "8123", "--host", "127.0.0.1"])
|
|
|
|
self.assertTrue(args.serve)
|
|
self.assertEqual(args.port, 8123)
|
|
self.assertEqual(args.host, "127.0.0.1")
|
|
self.assertIsNone(args.command)
|
|
|
|
def test_parser_rejects_an_invalid_server_port(self) -> None:
|
|
parser = build_parser(fake_application())
|
|
|
|
with self.assertRaises(SystemExit):
|
|
parser.parse_args(["--serve", "--port", "70000"])
|
|
|
|
def test_create_parser_defaults_guest_credentials_to_root(self) -> None:
|
|
args = build_parser(fake_application()).parse_args(["create"])
|
|
|
|
self.assertEqual(args.username, "root")
|
|
self.assertEqual(args.password, "root")
|
|
|
|
def test_main_delegates_serve_mode_to_the_server_launcher(self) -> None:
|
|
application = fake_application()
|
|
with (
|
|
patch("uvm.cli.build_application", return_value=application),
|
|
patch("uvm.server.run_server") as run_server_mock,
|
|
):
|
|
result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"])
|
|
|
|
self.assertEqual(result, 0)
|
|
run_server_mock.assert_called_once_with(application, host="127.0.0.1", port=8123)
|
|
|
|
def test_server_requires_an_api_token_even_on_loopback(self) -> None:
|
|
with self.assertRaises(UvmError):
|
|
run_server(fake_application(api_token=None), host="127.0.0.1", port=8000)
|
|
|
|
def test_non_loopback_server_requires_tls(self) -> None:
|
|
with self.assertRaises(UvmError):
|
|
run_server(fake_application(api_token="test-token"), host="0.0.0.0", port=8000)
|
|
|
|
def test_factory_requires_an_explicit_host(self) -> None:
|
|
with self.assertRaises(UvmError):
|
|
create_api(fake_application(api_token="test-token"))
|
|
|
|
def test_invalid_tls_material_is_rejected_before_uvicorn_starts(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
certificate = Path(temporary_directory) / "cert.pem"
|
|
key = Path(temporary_directory) / "key.pem"
|
|
certificate.touch()
|
|
key.touch()
|
|
application = fake_application(api_token="test-token")
|
|
application.settings.api_tls_cert = certificate
|
|
application.settings.api_tls_key = key
|
|
|
|
with self.assertRaises(UvmError):
|
|
run_server(application, host="127.0.0.1", port=8000)
|
|
|
|
def test_non_loopback_server_uses_configured_tls(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
certificate = Path(temporary_directory) / "cert.pem"
|
|
key = Path(temporary_directory) / "key.pem"
|
|
certificate.touch()
|
|
key.touch()
|
|
application = fake_application(api_token="test-token")
|
|
application.settings.api_tls_cert = certificate
|
|
application.settings.api_tls_key = key
|
|
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
|
|
|
|
with (
|
|
patch("uvm.server._validate_server_settings"),
|
|
patch("uvm.server.create_api", return_value=object()),
|
|
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
|
|
patch.object(fake_uvicorn, "run") as run_mock,
|
|
):
|
|
run_server(application, host="0.0.0.0", port=8443)
|
|
|
|
run_mock.assert_called_once()
|
|
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
|
|
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
|
|
|
|
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
|
|
for token in ("s\u00e9cret", "contains space", ""):
|
|
with self.subTest(token=token):
|
|
with self.assertRaises(ConfigurationError):
|
|
Settings(api_token=token)
|
|
|
|
|
|
FASTAPI_AVAILABLE = (
|
|
importlib.util.find_spec("fastapi") is not None
|
|
and importlib.util.find_spec("httpx") is not None
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
def list_vms(self) -> list[ListedVm]:
|
|
return [ListedVm(vm=self.vm, observed_status=self.vm.status)]
|
|
|
|
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
|
|
|
|
def stop(self, _vm_id: str):
|
|
self.vm.status = "stopped"
|
|
return self.vm
|
|
|
|
def destroy(self, _vm_id: str):
|
|
return self.vm
|
|
|
|
@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_health_is_available_without_credentials(self) -> None:
|
|
response = self.client.get("/health")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json(), {"status": "ok"})
|
|
|
|
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(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_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})
|
|
|
|
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_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"},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
self.assertEqual(self.lifecycle.last_spec.username, "root")
|
|
self.assertEqual(self.lifecycle.last_spec.password, "root")
|
|
|
|
def test_install_route_uses_the_existing_installer(self) -> None:
|
|
response = self.client.post(
|
|
"/install",
|
|
headers={"X-UVM-Token": "test-token"},
|
|
json={"force": True},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")
|