feat: add basic QEMU and task vertical slice
This commit is contained in:
@@ -11,6 +11,7 @@ from app.api.registry import HandlerRegistry
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
from app.security.auth import csrf_token, issue_ticket
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
|
||||
@@ -44,9 +45,19 @@ async def client_for(tmp_path: Path) -> AsyncClient:
|
||||
|
||||
handlers.register("/nodes/{node}/test", "POST", handler)
|
||||
app = create_app(
|
||||
Settings(contract_snapshot=path), lambda _settings: FakeDatabase(True), handlers
|
||||
Settings(contract_snapshot=path),
|
||||
lambda _settings: FakeDatabase(True),
|
||||
handlers,
|
||||
worker_factories=(),
|
||||
)
|
||||
key = Settings().ticket_signing_key.get_secret_value().encode()
|
||||
ticket = issue_ticket("root@pam", key)
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
cookies={"PVEAuthCookie": ticket},
|
||||
headers={"CSRFPreventionToken": csrf_token(ticket, key)},
|
||||
)
|
||||
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
async def test_json_input_and_null_envelope(tmp_path: Path) -> None:
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""First vertical read/login handler tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
from app.security.auth import hash_secret
|
||||
from app.tasks.repository import Task
|
||||
|
||||
|
||||
class FakePool:
|
||||
@@ -20,12 +23,21 @@ class FakePool:
|
||||
}
|
||||
if "FROM nodes" in sql and args[0] == "pve1":
|
||||
return {"name": "pve1", "status": "online"}
|
||||
if "FROM resources r" in sql and args == ("pve1", "100"):
|
||||
if "SELECT r.id" in sql:
|
||||
return {
|
||||
"id": uuid.UUID("00000000-0000-0000-0000-000000000100"),
|
||||
"state": '{"name":"demo","status":"stopped"}',
|
||||
}
|
||||
return {"state": '{"name":"demo","status":"stopped"}'}
|
||||
return None
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM nodes" in sql:
|
||||
return [{"node": "pve1", "status": "online"}]
|
||||
if "r.kind='qemu'" in sql:
|
||||
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}]
|
||||
return [
|
||||
{
|
||||
"type": "qemu",
|
||||
@@ -35,6 +47,9 @@ class FakePool:
|
||||
}
|
||||
]
|
||||
|
||||
async def fetchval(self, sql: str) -> int:
|
||||
return 100 if "pg_backend_pid" in sql else 1_700_000_000
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
pool = FakePool()
|
||||
@@ -82,6 +97,36 @@ def write_snapshot(path: Path) -> None:
|
||||
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
|
||||
),
|
||||
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
|
||||
PathContract(
|
||||
path="/nodes/{node}/qemu",
|
||||
methods=(method("GET", "qemu", (Parameter(name="node", definition=string),)),),
|
||||
),
|
||||
PathContract(
|
||||
path="/nodes/{node}/qemu/{vmid}/config",
|
||||
methods=(
|
||||
method(
|
||||
"GET",
|
||||
"config",
|
||||
(
|
||||
Parameter(name="node", definition=string),
|
||||
Parameter(name="vmid", definition=Schema(type="integer")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PathContract(
|
||||
path="/nodes/{node}/qemu/{vmid}/status/start",
|
||||
methods=(
|
||||
method(
|
||||
"POST",
|
||||
"start",
|
||||
(
|
||||
Parameter(name="node", definition=string),
|
||||
Parameter(name="vmid", definition=Schema(type="integer")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
snapshot = Snapshot(
|
||||
source_version="test",
|
||||
@@ -89,16 +134,39 @@ def write_snapshot(path: Path) -> None:
|
||||
raw_sha256="0" * 64,
|
||||
paths=paths,
|
||||
path_count=len(paths),
|
||||
method_count=5,
|
||||
method_count=sum(len(item.methods) for item in paths),
|
||||
)
|
||||
path.write_bytes(snapshot.canonical_bytes())
|
||||
|
||||
|
||||
async def test_core_login_and_read_endpoints(tmp_path: Path) -> None:
|
||||
async def test_core_login_and_read_endpoints(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: object) -> Task:
|
||||
return Task(
|
||||
uuid.uuid4(),
|
||||
str(kwargs["upid"]),
|
||||
str(kwargs["task_type"]),
|
||||
"queued",
|
||||
{},
|
||||
0,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
write_snapshot(snapshot_path)
|
||||
database = FakeDatabase()
|
||||
app = create_app(Settings(contract_snapshot=snapshot_path), lambda _settings: database)
|
||||
app = create_app(
|
||||
Settings(contract_snapshot=snapshot_path),
|
||||
lambda _settings: database,
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
login = await client.post(
|
||||
@@ -106,10 +174,17 @@ async def test_core_login_and_read_endpoints(tmp_path: Path) -> None:
|
||||
content="username=root%40pam&password=secret",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
csrf = login.json()["data"]["CSRFPreventionToken"]
|
||||
version = await client.get("/api2/json/version")
|
||||
nodes = await client.get("/api2/json/nodes")
|
||||
status = await client.get("/api2/json/nodes/pve1/status")
|
||||
resources = await client.get("/api2/json/cluster/resources")
|
||||
qemu = await client.get("/api2/json/nodes/pve1/qemu")
|
||||
config = await client.get("/api2/json/nodes/pve1/qemu/100/config")
|
||||
start = await client.post(
|
||||
"/api2/json/nodes/pve1/qemu/100/status/start",
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
|
||||
assert login.status_code == 200
|
||||
assert login.json()["data"]["username"] == "root@pam"
|
||||
@@ -118,3 +193,6 @@ async def test_core_login_and_read_endpoints(tmp_path: Path) -> None:
|
||||
assert nodes.json()["data"][0]["node"] == "pve1"
|
||||
assert status.json()["data"]["status"] == "online"
|
||||
assert resources.json()["data"][0]["type"] == "qemu"
|
||||
assert qemu.json()["data"][0]["vmid"] == 100
|
||||
assert config.json()["data"]["name"] == "demo"
|
||||
assert start.json()["data"].startswith("UPID:pve1:")
|
||||
|
||||
@@ -48,6 +48,7 @@ async def request_app(
|
||||
settings,
|
||||
lambda _settings: database,
|
||||
handlers if handlers is not None else HandlerRegistry(),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""QEMU worker transition semantics."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
from app.simulation.clock import Clock
|
||||
from app.tasks.qemu import qemu_handler
|
||||
from app.tasks.repository import Task, TaskRepository
|
||||
|
||||
|
||||
class ImmediateClock:
|
||||
async def now(self) -> datetime:
|
||||
return datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
assert seconds == 1.0
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self) -> None:
|
||||
self.states: list[str] = []
|
||||
|
||||
async def fetchrow(self, sql: str, resource_id: uuid.UUID) -> dict[str, object]:
|
||||
del sql, resource_id
|
||||
return {"state": '{"status":"stopped"}'}
|
||||
|
||||
async def execute(self, sql: str, resource_id: uuid.UUID, state: str) -> str:
|
||||
del sql, resource_id
|
||||
self.states.append(state)
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class Acquire:
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
async def __aenter__(self) -> Connection:
|
||||
return self.connection
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Pool:
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
def acquire(self) -> Acquire:
|
||||
return Acquire(self.connection)
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.connection = Connection()
|
||||
self.pool = Pool(self.connection)
|
||||
self.logs: list[str] = []
|
||||
|
||||
async def append_log(self, task_id: uuid.UUID, message: str) -> None:
|
||||
del task_id
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
|
||||
repository = Repository()
|
||||
task = Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:test",
|
||||
"qemu-start",
|
||||
"running",
|
||||
{"resource_id": str(uuid.uuid4())},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
|
||||
result = await qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))(
|
||||
task
|
||||
)
|
||||
|
||||
assert result == {"status": "running"}
|
||||
assert '"starting"' in repository.connection.states[0]
|
||||
assert '"running"' in repository.connection.states[1]
|
||||
assert repository.logs == ["VM start started", "VM start completed"]
|
||||
@@ -21,6 +21,12 @@ def test_small_profile_has_stable_logical_state() -> None:
|
||||
"node": "pve1",
|
||||
"state": {"name": "demo", "status": "stopped"},
|
||||
},
|
||||
{
|
||||
"kind": "qemu",
|
||||
"external_id": "101",
|
||||
"node": "pve1",
|
||||
"state": {"name": "worker", "status": "stopped"},
|
||||
},
|
||||
{
|
||||
"kind": "storage",
|
||||
"external_id": "local",
|
||||
|
||||
Reference in New Issue
Block a user