0773f721ea
- Scale small/large/big seeds (3×50 / 10×1000 / 20×2000) with proportional backups, snapshots, HA, replication, Ceph capacity, and OSD totals (10 / 100 / 500) plus matching node disks and crush/pg metadata - Enrich handler responses for apt, certificates, qemu/lxc status, storage, SDN, metrics export, and related cluster/node dumps - Flatten nested body_example fields into PARAMS and sync the request body via dotted paths (oVirt-style) - Restyle DATA controls as size cards with full-width Reset to minimal / Refresh stats; unload reloads the minimal cluster
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""Persistent QEMU CRUD semantic handler tests."""
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
|
|
from app.api.errors import ApiError
|
|
from app.api.registry import HandlerRegistry
|
|
from app.db.pool import AsyncpgDatabase
|
|
from app.db.primitives import ConflictError
|
|
from app.handlers.qemu import register_qemu_handlers
|
|
from app.tasks.repository import Task
|
|
|
|
|
|
class QemuPool:
|
|
def __init__(self) -> None:
|
|
self.resource_exists = False
|
|
self.missing = False
|
|
self.running = False
|
|
self.commands: list[str] = []
|
|
self.resource_id = uuid.uuid4()
|
|
|
|
async def fetchval(self, sql: str, *args: object) -> bool | int:
|
|
del args
|
|
if "pg_backend_pid" in sql:
|
|
return 123
|
|
if "extract(epoch" in sql:
|
|
return 1_700_000_000
|
|
if "FROM nodes" in sql:
|
|
return True
|
|
if "FROM resources" in sql:
|
|
return self.resource_exists
|
|
if "FROM snapshots" in sql:
|
|
return False
|
|
raise AssertionError(sql)
|
|
|
|
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
|
del args
|
|
if "FROM resources" in sql:
|
|
return [
|
|
{
|
|
"vmid": 150,
|
|
"state": '{"status":"stopped","name":"vm"}',
|
|
"config": '{"name":"vm","memory":2048,"cores":2}',
|
|
}
|
|
]
|
|
assert "FROM snapshots" in sql
|
|
return [
|
|
{
|
|
"name": "baseline",
|
|
"parent_name": None,
|
|
"description": "stable",
|
|
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
|
}
|
|
]
|
|
|
|
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
|
del args
|
|
if self.missing:
|
|
return None
|
|
if "SELECT r.id, r.version" in sql:
|
|
return {
|
|
"id": self.resource_id,
|
|
"version": 1,
|
|
"state": '{"name":"old","status":"stopped"}',
|
|
"config": '{"name":"old"}',
|
|
}
|
|
if "SELECT r.state, v.config" in sql:
|
|
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
|
|
if "SELECT r.id, r.state" in sql:
|
|
status = "running" if self.running else "stopped"
|
|
agent = {
|
|
"enabled": True,
|
|
"results": {
|
|
"info": {"version": "9.2.0"},
|
|
"get-osinfo": {"machine": "x86_64", "name": "l26"},
|
|
"get-host-name": {"host-name": "vm"},
|
|
"network-get-interfaces": [{"name": "eth0"}],
|
|
"get-time": {"seconds": 1_700_000_000},
|
|
"ping": {},
|
|
},
|
|
}
|
|
return {
|
|
"id": self.resource_id,
|
|
"state": json.dumps({"status": status, "name": "vm", "agent": agent}),
|
|
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
|
|
}
|
|
if "SELECT r.id, r.state, v.config" in sql:
|
|
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
|
if "SELECT s.* FROM snapshots" in sql:
|
|
return {
|
|
"id": uuid.uuid4(),
|
|
"name": "baseline",
|
|
"parent_name": None,
|
|
"description": "stable",
|
|
"state": '{"config":{"name":"old"}}',
|
|
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
|
}
|
|
raise AssertionError(sql)
|
|
|
|
async def execute(self, sql: str, *args: object) -> str:
|
|
del args
|
|
self.commands.append(sql)
|
|
return "UPDATE 1"
|
|
|
|
|
|
class FakeDatabase:
|
|
def __init__(self, pool: QemuPool) -> None:
|
|
self.pool = pool
|
|
|
|
|
|
def request(pool: QemuPool) -> Request:
|
|
app = FastAPI()
|
|
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
|
result = Request(
|
|
{
|
|
"type": "http",
|
|
"app": app,
|
|
"method": "POST",
|
|
"path": "/",
|
|
"headers": [],
|
|
"query_string": b"",
|
|
"server": ("test", 80),
|
|
"client": ("test", 123),
|
|
"scheme": "http",
|
|
}
|
|
)
|
|
result.state.principal = "root@pam"
|
|
return result
|
|
|
|
|
|
def inputs(**values: object) -> dict[str, Any]:
|
|
return {"values": values, "provided": tuple(values)}
|
|
|
|
|
|
async def test_qemu_create_sync_async_update_and_delete(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
created_payloads: list[dict[str, Any]] = []
|
|
|
|
class FakeTaskRepository:
|
|
def __init__(self, pool: object) -> None:
|
|
del pool
|
|
|
|
async def create(self, **kwargs: Any) -> Task:
|
|
created_payloads.append(kwargs)
|
|
return Task(
|
|
uuid.uuid4(),
|
|
str(kwargs["upid"]),
|
|
str(kwargs["task_type"]),
|
|
"queued",
|
|
dict(kwargs["payload"]),
|
|
0,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
|
registry = HandlerRegistry()
|
|
register_qemu_handlers(registry)
|
|
pool = QemuPool()
|
|
http_request = request(pool)
|
|
create = registry.get("/nodes/{node}/qemu", "POST")
|
|
listing = registry.get("/nodes/{node}/qemu", "GET")
|
|
config = registry.get("/nodes/{node}/qemu/{vmid}/config", "GET")
|
|
current = registry.get("/nodes/{node}/qemu/{vmid}/status/current", "GET")
|
|
update_sync = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
|
|
update_async = registry.get("/nodes/{node}/qemu/{vmid}/config", "POST")
|
|
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
|
|
assert create and listing and config and current and update_sync and update_async and delete
|
|
|
|
assert (await listing(http_request, inputs(node="pve1")))[0]["name"] == "vm"
|
|
assert (await config(http_request, inputs(node="pve1", vmid=150)))["name"] == "vm"
|
|
assert (await current(http_request, inputs(node="pve1", vmid=150)))["status"] == "stopped"
|
|
|
|
create_upid = await create(
|
|
http_request,
|
|
inputs(node="pve1", vmid=150, name="new", cores=2),
|
|
)
|
|
assert create_upid.startswith("UPID:pve1:")
|
|
assert created_payloads[-1]["task_type"] == "qemu-create"
|
|
|
|
assert (
|
|
await update_sync(
|
|
http_request,
|
|
inputs(node="pve1", vmid=150, name="sync", delete="unused"),
|
|
)
|
|
is None
|
|
)
|
|
assert len(pool.commands) == 2
|
|
|
|
update_upid = await update_async(
|
|
http_request,
|
|
inputs(node="pve1", vmid=150, memory="2048"),
|
|
)
|
|
assert update_upid.startswith("UPID:pve1:")
|
|
assert created_payloads[-1]["task_type"] == "qemu-update"
|
|
|
|
delete_upid = await delete(http_request, inputs(node="pve1", vmid=150))
|
|
assert delete_upid.startswith("UPID:pve1:")
|
|
assert created_payloads[-1]["task_type"] == "qemu-delete"
|
|
|
|
|
|
async def test_qemu_crud_conflicts_and_missing_resources(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class ConflictingRepository:
|
|
def __init__(self, pool: object) -> None:
|
|
del pool
|
|
|
|
async def create(self, **_kwargs: object) -> Task:
|
|
raise ConflictError("resource is locked")
|
|
|
|
monkeypatch.setattr("app.handlers.qemu.TaskRepository", ConflictingRepository)
|
|
registry = HandlerRegistry()
|
|
register_qemu_handlers(registry)
|
|
pool = QemuPool()
|
|
http_request = request(pool)
|
|
create = registry.get("/nodes/{node}/qemu", "POST")
|
|
update = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
|
|
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
|
|
assert create and update and delete
|
|
|
|
with pytest.raises(ApiError) as locked:
|
|
await create(http_request, inputs(node="pve1", vmid=150))
|
|
assert locked.value.status_code == 409
|
|
|
|
pool.resource_exists = True
|
|
with pytest.raises(ApiError) as duplicate:
|
|
await create(http_request, inputs(node="pve1", vmid=150))
|
|
assert duplicate.value.status_code == 409
|
|
|
|
pool.missing = True
|
|
with pytest.raises(ApiError) as missing:
|
|
await update(http_request, inputs(node="pve1", vmid=150, name="missing"))
|
|
assert missing.value.status_code == 404
|
|
|
|
pool.missing = False
|
|
pool.running = True
|
|
with pytest.raises(ApiError) as running:
|
|
await delete(http_request, inputs(node="pve1", vmid=150))
|
|
assert running.value.status_code == 409
|
|
|
|
|
|
async def test_qemu_snapshot_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
tasks: list[str] = []
|
|
|
|
class FakeTaskRepository:
|
|
def __init__(self, pool: object) -> None:
|
|
del pool
|
|
|
|
async def create(self, **kwargs: Any) -> Task:
|
|
tasks.append(str(kwargs["task_type"]))
|
|
return Task(uuid.uuid4(), str(kwargs["upid"]), tasks[-1], "queued", {}, 0, False, 0)
|
|
|
|
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
|
registry = HandlerRegistry()
|
|
register_qemu_handlers(registry)
|
|
pool = QemuPool()
|
|
http_request = request(pool)
|
|
base = "/nodes/{node}/qemu/{vmid}/snapshot"
|
|
|
|
listing = registry.get(base, "GET")
|
|
create = registry.get(base, "POST")
|
|
get = registry.get(f"{base}/{{snapname}}", "GET")
|
|
delete = registry.get(f"{base}/{{snapname}}", "DELETE")
|
|
config_get = registry.get(f"{base}/{{snapname}}/config", "GET")
|
|
config_put = registry.get(f"{base}/{{snapname}}/config", "PUT")
|
|
rollback = registry.get(f"{base}/{{snapname}}/rollback", "POST")
|
|
assert listing and create and get and delete and config_get and config_put and rollback
|
|
|
|
common = inputs(node="pve1", vmid=150, snapname="baseline")
|
|
assert (await listing(http_request, inputs(node="pve1", vmid=150)))[0]["name"] == "baseline"
|
|
assert (await get(http_request, common))["description"] == "stable"
|
|
assert (await config_get(http_request, common))["config"] == {"name": "old"}
|
|
assert await config_put(http_request, inputs(**common["values"], description="updated")) is None
|
|
assert (
|
|
await create(http_request, inputs(**common["values"], description="stable"))
|
|
).startswith("UPID:pve1:")
|
|
assert (await rollback(http_request, common)).startswith("UPID:pve1:")
|
|
assert (await delete(http_request, common)).startswith("UPID:pve1:")
|
|
assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"]
|
|
|
|
|
|
async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
tasks: list[dict[str, Any]] = []
|
|
|
|
class FakeTaskRepository:
|
|
def __init__(self, pool: object) -> None:
|
|
del pool
|
|
|
|
async def create(self, **kwargs: Any) -> Task:
|
|
tasks.append(kwargs)
|
|
return Task(
|
|
uuid.uuid4(),
|
|
str(kwargs["upid"]),
|
|
str(kwargs["task_type"]),
|
|
"queued",
|
|
{},
|
|
0,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
|
registry = HandlerRegistry()
|
|
register_qemu_handlers(registry)
|
|
pool = QemuPool()
|
|
http_request = request(pool)
|
|
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
|
|
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
|
|
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
|
|
resize = registry.get("/nodes/{node}/qemu/{vmid}/resize", "PUT")
|
|
move = registry.get("/nodes/{node}/qemu/{vmid}/move_disk", "POST")
|
|
assert clone and migrate_get and migrate and resize and move
|
|
|
|
clone_upid = await clone(
|
|
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
|
|
)
|
|
assert clone_upid.startswith("UPID:pve1:")
|
|
assert tasks[-1]["task_type"] == "qemu-clone"
|
|
assert (await migrate_get(http_request, inputs(node="pve1", vmid=150, target="pve2")))[
|
|
"local_disks"
|
|
] == []
|
|
migrate_upid = await migrate(
|
|
http_request, inputs(node="pve1", vmid=150, target="pve2", online=False)
|
|
)
|
|
assert migrate_upid.startswith("UPID:pve1:")
|
|
assert tasks[-1]["task_type"] == "qemu-migrate"
|
|
resize_upid = await resize(
|
|
http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")
|
|
)
|
|
assert resize_upid.startswith("UPID:pve1:")
|
|
assert tasks[-1]["task_type"] == "qemu-resize"
|
|
move_upid = await move(
|
|
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
|
|
)
|
|
assert move_upid.startswith("UPID:pve1:")
|
|
assert tasks[-1]["task_type"] == "qemu-move-disk"
|
|
|
|
with pytest.raises(ApiError) as same_node:
|
|
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
|
|
assert same_node.value.status_code == 400
|
|
|
|
|
|
async def test_qemu_pending_and_agent_handlers() -> None:
|
|
registry = HandlerRegistry()
|
|
register_qemu_handlers(registry)
|
|
pool = QemuPool()
|
|
pool.running = True
|
|
http_request = request(pool)
|
|
values = inputs(node="pve1", vmid=150)
|
|
|
|
pending = registry.get("/nodes/{node}/qemu/{vmid}/pending", "GET")
|
|
routes = {
|
|
"info": "/nodes/{node}/qemu/{vmid}/agent/info",
|
|
"os": "/nodes/{node}/qemu/{vmid}/agent/get-osinfo",
|
|
"host": "/nodes/{node}/qemu/{vmid}/agent/get-host-name",
|
|
"network": "/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
|
|
"time": "/nodes/{node}/qemu/{vmid}/agent/get-time",
|
|
"ping": "/nodes/{node}/qemu/{vmid}/agent/ping",
|
|
}
|
|
handlers = {
|
|
name: registry.get(path, "POST" if name == "ping" else "GET")
|
|
for name, path in routes.items()
|
|
}
|
|
assert pending and all(handlers.values())
|
|
|
|
async def call(name: str) -> dict[str, Any]:
|
|
handler = handlers[name]
|
|
assert handler is not None
|
|
return cast(dict[str, Any], await handler(http_request, values))
|
|
|
|
assert await pending(http_request, values) == []
|
|
assert (await call("info"))["result"]["version"]
|
|
assert (await call("os"))["result"]["machine"] == "x86_64"
|
|
assert (await call("host"))["result"]["host-name"] == "vm"
|
|
assert (await call("network"))["result"][0]["name"] == "eth0"
|
|
assert (await call("time"))["result"]["seconds"] > 0
|
|
assert (await call("ping"))["result"] == {}
|