feat: add durable QEMU create update delete
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
from threading import Event
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from proxmoxer import ProxmoxAPI, ResourceException # type: ignore[import-untyped]
|
||||
@@ -12,6 +13,15 @@ pytestmark = [
|
||||
]
|
||||
|
||||
|
||||
def wait_task(proxmox: Any, upid: str) -> dict[str, object]:
|
||||
for _attempt in range(100):
|
||||
task = proxmox.nodes("pve1").tasks(upid).status.get()
|
||||
if task["status"] == "stopped":
|
||||
return cast(dict[str, object], task)
|
||||
Event().wait(0.05)
|
||||
raise AssertionError("task did not finish")
|
||||
|
||||
|
||||
def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
proxmox = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
@@ -83,6 +93,29 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
storage_api.nodes("pve1").qemu(vmid).config.get()
|
||||
assert hidden.value.status_code == 403
|
||||
|
||||
create_upid = proxmox.nodes("pve1").qemu.post(
|
||||
vmid=150, name="created-by-proxmoxer", cores=2, memory=1024
|
||||
)
|
||||
with pytest.raises(ResourceException) as duplicate_create:
|
||||
proxmox.nodes("pve1").qemu.post(vmid=150, name="duplicate")
|
||||
assert duplicate_create.value.status_code == 409
|
||||
assert wait_task(proxmox, create_upid)["exitstatus"] == "OK"
|
||||
created_config = proxmox.nodes("pve1").qemu("150").config.get()
|
||||
assert created_config["name"] == "created-by-proxmoxer"
|
||||
assert created_config["cores"] == 2
|
||||
|
||||
assert proxmox.nodes("pve1").qemu("150").config.put(name="sync-update", cores=3) is None
|
||||
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "sync-update"
|
||||
update_upid = proxmox.nodes("pve1").qemu("150").config.post(name="async-update", memory=2048)
|
||||
assert wait_task(proxmox, update_upid)["exitstatus"] == "OK"
|
||||
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
|
||||
|
||||
delete_upid = proxmox.nodes("pve1").qemu("150").delete()
|
||||
assert wait_task(proxmox, delete_upid)["exitstatus"] == "OK"
|
||||
with pytest.raises(ResourceException) as deleted_vm:
|
||||
proxmox.nodes("pve1").qemu("150").config.get()
|
||||
assert deleted_vm.value.status_code == 404
|
||||
|
||||
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
|
||||
operator_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
@@ -96,10 +129,5 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
operation = "start" if status["status"] == "stopped" else "stop"
|
||||
endpoint = operator_api.nodes("pve1").qemu("101").status(operation)
|
||||
upid = endpoint.post()
|
||||
for _attempt in range(100):
|
||||
task = operator_api.nodes("pve1").tasks(upid).status.get()
|
||||
if task["status"] == "stopped":
|
||||
break
|
||||
Event().wait(0.05)
|
||||
assert task["status"] == "stopped"
|
||||
task = wait_task(operator_api, upid)
|
||||
assert task["exitstatus"] == "OK"
|
||||
|
||||
@@ -43,3 +43,19 @@ def test_contract_permission_maps_to_capability_requirement() -> None:
|
||||
assert requirement is not None
|
||||
assert requirement.path == "/vms/100"
|
||||
assert requirement.privileges == frozenset({"VM.PowerMgmt"})
|
||||
|
||||
any_permission = Permissions(
|
||||
expression={
|
||||
"check": ["perm", "/vms/{vmid}", ["VM.Config.CPU", "VM.Config.Memory"], "any", 1]
|
||||
}
|
||||
)
|
||||
any_requirement = requirement_from_contract(any_permission, {"vmid": "100"})
|
||||
assert any_requirement is not None
|
||||
assert not any_requirement.require_all
|
||||
assert authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
any_requirement.privileges,
|
||||
(AclEntry("alice@pve", "/vms", frozenset({"VM.Config.CPU"})),),
|
||||
require_all=any_requirement.require_all,
|
||||
)
|
||||
|
||||
@@ -29,7 +29,10 @@ class FakePool:
|
||||
"id": uuid.UUID("00000000-0000-0000-0000-000000000100"),
|
||||
"state": '{"name":"demo","status":"stopped"}',
|
||||
}
|
||||
return {"state": '{"name":"demo","status":"stopped"}'}
|
||||
return {
|
||||
"config": '{"name":"demo"}',
|
||||
"state": '{"name":"demo","status":"stopped"}',
|
||||
}
|
||||
return None
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Persistent QEMU CRUD semantic handler tests."""
|
||||
|
||||
import uuid
|
||||
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
|
||||
raise AssertionError(sql)
|
||||
|
||||
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.id, r.state" in sql:
|
||||
status = "running" if self.running else "stopped"
|
||||
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
|
||||
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")
|
||||
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 update_sync and update_async and delete
|
||||
|
||||
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
|
||||
@@ -61,6 +61,45 @@ class Repository:
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
class Transaction:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class CrudConnection:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[str] = []
|
||||
|
||||
def transaction(self) -> Transaction:
|
||||
return Transaction()
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if "FROM nodes" in sql:
|
||||
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
|
||||
if "JOIN virtual_machines" in sql:
|
||||
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
|
||||
return None
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
self.commands.append(sql)
|
||||
return "DELETE 1" if sql.startswith("DELETE") else "UPDATE 1"
|
||||
|
||||
|
||||
class CrudRepository:
|
||||
def __init__(self) -> None:
|
||||
self.connection = CrudConnection()
|
||||
self.pool = Pool(cast(Connection, self.connection))
|
||||
self.logs: list[str] = []
|
||||
|
||||
async def append_log(self, _task_id: uuid.UUID, message: str) -> None:
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
|
||||
repository = Repository()
|
||||
task = Task(
|
||||
@@ -82,3 +121,57 @@ async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
|
||||
assert '"starting"' in repository.connection.states[0]
|
||||
assert '"running"' in repository.connection.states[1]
|
||||
assert repository.logs == ["VM start started", "VM start completed"]
|
||||
|
||||
|
||||
async def test_qemu_worker_create_update_and_delete_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
created = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:create",
|
||||
"qemu-create",
|
||||
"running",
|
||||
{"node": "pve1", "vmid": 150, "config": {"name": "new"}},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
updated = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:update",
|
||||
"qemu-update",
|
||||
"running",
|
||||
{
|
||||
"resource_id": str(resource_id),
|
||||
"changes": {"name": "changed", "cores": 4},
|
||||
"delete": "unused",
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
deleted = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:delete",
|
||||
"qemu-delete",
|
||||
"running",
|
||||
{"resource_id": str(resource_id)},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
assert created == {"vmid": 150, "status": "stopped"}
|
||||
assert updated == {"updated": ["cores", "name"], "deleted": ["unused"]}
|
||||
assert deleted == {"deleted": True}
|
||||
assert any("INSERT INTO resources" in command for command in repository.connection.commands)
|
||||
assert any("UPDATE virtual_machines" in command for command in repository.connection.commands)
|
||||
assert any("DELETE FROM resources" in command for command in repository.connection.commands)
|
||||
|
||||
Reference in New Issue
Block a user