feat: implement durable QEMU snapshots

This commit is contained in:
Sergey Antropoff
2026-07-13 02:08:04 +03:00
parent e1e1f69577
commit 48f7cc39ff
10 changed files with 393 additions and 5 deletions
+16
View File
@@ -110,6 +110,22 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
assert wait_task(proxmox, update_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
snapshots = proxmox.nodes("pve1").qemu("150").snapshot
snapshot_upid = snapshots.post(snapname="baseline", description="before change")
assert wait_task(proxmox, snapshot_upid)["exitstatus"] == "OK"
assert any(item["name"] == "baseline" for item in snapshots.get())
baseline = snapshots("baseline")
assert baseline.get()["description"] == "before change"
assert baseline.config.put(description="stable baseline") is None
assert baseline.config.get()["description"] == "stable baseline"
assert proxmox.nodes("pve1").qemu("150").config.put(name="after-snapshot") is None
rollback_upid = baseline.rollback.post()
assert wait_task(proxmox, rollback_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
snapshot_delete_upid = baseline.delete()
assert wait_task(proxmox, snapshot_delete_upid)["exitstatus"] == "OK"
assert not snapshots.get()
delete_upid = proxmox.nodes("pve1").qemu("150").delete()
assert wait_task(proxmox, delete_upid)["exitstatus"] == "OK"
with pytest.raises(ResourceException) as deleted_vm:
+78 -1
View File
@@ -1,6 +1,7 @@
"""Persistent QEMU CRUD semantic handler tests."""
import uuid
from datetime import UTC, datetime
from typing import Any, cast
import pytest
@@ -32,8 +33,24 @@ class QemuPool:
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"}'}]
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:
@@ -45,9 +62,22 @@ class QemuPool:
"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"
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
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:
@@ -113,10 +143,17 @@ async def test_qemu_create_sync_async_update_and_delete(
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 update_sync and update_async and 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,
@@ -185,3 +222,43 @@ async def test_qemu_crud_conflicts_and_missing_resources(
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"]
+42
View File
@@ -82,6 +82,12 @@ class CrudConnection:
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
if "JOIN virtual_machines" in sql:
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
if "FROM snapshots" in sql:
return {
"state": (
'{"resource_state":{"status":"stopped","name":"old"},"config":{"name":"old"}}'
)
}
return None
async def execute(self, sql: str, *args: object) -> str:
@@ -175,3 +181,39 @@ async def test_qemu_worker_create_update_and_delete_are_persistent() -> None:
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)
async def test_qemu_worker_snapshot_create_rollback_and_delete_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
async def run(operation: str, **payload: object) -> dict[str, object]:
result = await handler(
Task(
uuid.uuid4(),
f"UPID:{operation}",
f"qemu-snapshot-{operation}",
"running",
{"resource_id": str(resource_id), "snapname": "baseline", **payload},
0,
False,
1,
)
)
assert result is not None
return cast(dict[str, object], result)
assert await run("create", description="stable") == {
"snapshot": "baseline",
"operation": "create",
}
assert await run("rollback", start=True) == {
"snapshot": "baseline",
"operation": "rollback",
}
assert await run("delete") == {"snapshot": "baseline", "operation": "delete"}
commands = repository.connection.commands
assert any("INSERT INTO snapshots" in command for command in commands)
assert any("UPDATE virtual_machines" in command for command in commands)
assert any("DELETE FROM snapshots" in command for command in commands)