Files
proxmox-api-simulator/tests/unit/test_qemu_handlers.py
T
Sergey Antropoff 0773f721ea Align sized cluster seeds and GET dumps with PVE wire shapes; restyle DATA panel.
- 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
2026-07-18 08:46:11 +03:00

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"] == {}