feat: implement durable QEMU snapshots
This commit is contained in:
@@ -236,6 +236,92 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
|
||||
async def resume(request: Request, inputs: dict[str, Any]) -> str:
|
||||
return await mutate("resume", request, inputs)
|
||||
|
||||
async def snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
values = _values(inputs)
|
||||
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||
rows = await _database(request).pool.fetch(
|
||||
"""SELECT name, parent_name, description, created_at FROM snapshots
|
||||
WHERE resource_id=$1 ORDER BY created_at, name""",
|
||||
resource["id"],
|
||||
)
|
||||
return [
|
||||
{
|
||||
"name": row["name"],
|
||||
"parent": row["parent_name"],
|
||||
"description": row["description"] or "",
|
||||
"snaptime": int(row["created_at"].timestamp()),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
values = _values(inputs)
|
||||
row = await _snapshot(request, values)
|
||||
state = _state(row["state"])
|
||||
return {
|
||||
"name": row["name"],
|
||||
"parent": row["parent_name"],
|
||||
"description": row["description"] or "",
|
||||
"snaptime": int(row["created_at"].timestamp()),
|
||||
**state,
|
||||
}
|
||||
|
||||
async def snapshot_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
row = await _snapshot(request, _values(inputs))
|
||||
return {"description": row["description"] or "", **_state(row["state"])}
|
||||
|
||||
async def snapshot_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||
values = _values(inputs)
|
||||
row = await _snapshot(request, values)
|
||||
await _database(request).pool.execute(
|
||||
"UPDATE snapshots SET description=$2 WHERE id=$1",
|
||||
row["id"],
|
||||
str(values.get("description", "")),
|
||||
)
|
||||
|
||||
async def snapshot_task(operation: str, request: Request, inputs: dict[str, Any]) -> str:
|
||||
values = _values(inputs)
|
||||
node, vmid, snapname = (
|
||||
str(values["node"]),
|
||||
str(values["vmid"]),
|
||||
str(values["snapname"]),
|
||||
)
|
||||
resource = await _qemu_resource(request, node, vmid)
|
||||
if operation == "snapshot-create":
|
||||
exists = await _database(request).pool.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM snapshots WHERE resource_id=$1 AND name=$2)",
|
||||
resource["id"],
|
||||
snapname,
|
||||
)
|
||||
if exists:
|
||||
raise ApiError(409, "snapshot already exists")
|
||||
else:
|
||||
await _snapshot(request, values)
|
||||
return await _create_task(
|
||||
request,
|
||||
node=node,
|
||||
vmid=vmid,
|
||||
task_type=f"qemu-{operation}",
|
||||
payload={
|
||||
"node": node,
|
||||
"vmid": vmid,
|
||||
"resource_id": str(resource["id"]),
|
||||
"snapname": snapname,
|
||||
"description": str(values.get("description", "")),
|
||||
"vmstate": bool(values.get("vmstate", False)),
|
||||
"start": bool(values.get("start", False)),
|
||||
},
|
||||
)
|
||||
|
||||
async def snapshot_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||
return await snapshot_task("snapshot-create", request, inputs)
|
||||
|
||||
async def snapshot_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||
return await snapshot_task("snapshot-delete", request, inputs)
|
||||
|
||||
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
|
||||
return await snapshot_task("snapshot-rollback", request, inputs)
|
||||
|
||||
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
tasks = await TaskRepository(_database(request).pool).list_for_node(
|
||||
str(_values(inputs)["node"])
|
||||
@@ -283,6 +369,19 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/status/reset", "POST", reset)
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/status/suspend", "POST", suspend)
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/status/resume", "POST", resume)
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "GET", snapshot_list)
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "POST", snapshot_create)
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
|
||||
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
|
||||
registry.register(
|
||||
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
|
||||
)
|
||||
registry.register("/nodes/{node}/tasks", "GET", task_list)
|
||||
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
|
||||
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
|
||||
@@ -303,6 +402,9 @@ async def _create_task(
|
||||
"qemu-create": "qmcreate",
|
||||
"qemu-delete": "qmdestroy",
|
||||
"qemu-update": "qmconfig",
|
||||
"qemu-snapshot-create": "qmsnapshot",
|
||||
"qemu-snapshot-delete": "qmdelsnapshot",
|
||||
"qemu-snapshot-rollback": "qmrollback",
|
||||
}[task_type]
|
||||
upid = str(Upid(node, pid, pid, timestamp, worker_type, vmid, str(request.state.principal)))
|
||||
try:
|
||||
@@ -316,3 +418,31 @@ async def _create_task(
|
||||
except ConflictError as error:
|
||||
raise ApiError(409, str(error)) from error
|
||||
return task.upid
|
||||
|
||||
|
||||
async def _qemu_resource(request: Request, node: str, vmid: str) -> Any:
|
||||
row = await _database(request).pool.fetchrow(
|
||||
"""SELECT r.id, r.state, v.config FROM resources r
|
||||
JOIN nodes n ON n.id=r.node_id
|
||||
JOIN virtual_machines v ON v.resource_id=r.id
|
||||
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
|
||||
node,
|
||||
vmid,
|
||||
)
|
||||
if row is None:
|
||||
raise ApiError(404, "virtual machine does not exist")
|
||||
return row
|
||||
|
||||
|
||||
async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
|
||||
row = await _database(request).pool.fetchrow(
|
||||
"""SELECT s.* FROM snapshots s
|
||||
JOIN resources r ON r.id=s.resource_id JOIN nodes n ON n.id=r.node_id
|
||||
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2 AND s.name=$3""",
|
||||
str(values["node"]),
|
||||
str(values["vmid"]),
|
||||
str(values["snapname"]),
|
||||
)
|
||||
if row is None:
|
||||
raise ApiError(404, "snapshot does not exist")
|
||||
return row
|
||||
|
||||
@@ -50,6 +50,9 @@ def create_app(
|
||||
"qemu-reset": handler,
|
||||
"qemu-resume": handler,
|
||||
"qemu-shutdown": handler,
|
||||
"qemu-snapshot-create": handler,
|
||||
"qemu-snapshot-delete": handler,
|
||||
"qemu-snapshot-rollback": handler,
|
||||
"qemu-start": handler,
|
||||
"qemu-stop": handler,
|
||||
"qemu-suspend": handler,
|
||||
|
||||
@@ -23,6 +23,10 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
|
||||
return await _update(repository, task, resource_id)
|
||||
if operation == "delete":
|
||||
return await _delete(repository, task, resource_id)
|
||||
if operation.startswith("snapshot-"):
|
||||
return await _snapshot(
|
||||
repository, task, resource_id, operation.removeprefix("snapshot-")
|
||||
)
|
||||
async with repository.pool.acquire() as connection:
|
||||
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
|
||||
if row is None:
|
||||
@@ -129,5 +133,73 @@ async def _delete(repository: TaskRepository, task: Task, resource_id: uuid.UUID
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
async def _snapshot(
|
||||
repository: TaskRepository,
|
||||
task: Task,
|
||||
resource_id: uuid.UUID,
|
||||
operation: str,
|
||||
) -> dict[str, Any]:
|
||||
name = str(task.payload["snapname"])
|
||||
async with repository.pool.acquire() as connection:
|
||||
async with connection.transaction():
|
||||
if operation == "create":
|
||||
row = await connection.fetchrow(
|
||||
"""SELECT r.state, v.config FROM resources r
|
||||
JOIN virtual_machines v ON v.resource_id=r.id WHERE r.id=$1""",
|
||||
resource_id,
|
||||
)
|
||||
if row is None:
|
||||
raise ValueError("resource disappeared")
|
||||
captured = {
|
||||
"resource_state": _object(row["state"]),
|
||||
"config": _object(row["config"]),
|
||||
"vmstate": bool(task.payload.get("vmstate", False)),
|
||||
}
|
||||
await connection.execute(
|
||||
"""INSERT INTO snapshots(id, resource_id, name, description, state)
|
||||
VALUES($1, $2, $3, $4, $5::jsonb)""",
|
||||
uuid.uuid4(),
|
||||
resource_id,
|
||||
name,
|
||||
str(task.payload.get("description", "")),
|
||||
json.dumps(captured, sort_keys=True),
|
||||
)
|
||||
elif operation == "delete":
|
||||
status = await connection.execute(
|
||||
"DELETE FROM snapshots WHERE resource_id=$1 AND name=$2",
|
||||
resource_id,
|
||||
name,
|
||||
)
|
||||
if status != "DELETE 1":
|
||||
raise ValueError("snapshot disappeared")
|
||||
elif operation == "rollback":
|
||||
row = await connection.fetchrow(
|
||||
"SELECT state FROM snapshots WHERE resource_id=$1 AND name=$2",
|
||||
resource_id,
|
||||
name,
|
||||
)
|
||||
if row is None:
|
||||
raise ValueError("snapshot disappeared")
|
||||
captured = _object(row["state"])
|
||||
state = dict(cast(Mapping[str, Any], captured["resource_state"]))
|
||||
if bool(task.payload.get("start", False)):
|
||||
state["status"] = "running"
|
||||
await connection.execute(
|
||||
"""UPDATE resources SET state=$2::jsonb, version=version+1,
|
||||
updated_at=now() WHERE id=$1""",
|
||||
resource_id,
|
||||
json.dumps(state, sort_keys=True),
|
||||
)
|
||||
await connection.execute(
|
||||
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
|
||||
resource_id,
|
||||
json.dumps(captured["config"], sort_keys=True),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported snapshot operation: {operation}")
|
||||
await repository.append_log(task.id, f"snapshot {name} {operation} completed")
|
||||
return {"snapshot": name, "operation": operation}
|
||||
|
||||
|
||||
def _object(value: object) -> dict[str, Any]:
|
||||
return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value))
|
||||
|
||||
Reference in New Issue
Block a user