feat: add durable QEMU create update delete

This commit is contained in:
Sergey Antropoff
2026-07-13 01:54:04 +03:00
parent 7721ba87c5
commit 701157f570
15 changed files with 659 additions and 26 deletions
+11 -3
View File
@@ -14,7 +14,7 @@ from app.api.errors import ApiError, ContractValidationError
from app.config import Settings
from app.contracts.model import Method, Schema, Snapshot
from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, authorize, requirement_from_contract
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
@@ -159,13 +159,14 @@ async def _authenticate(
if principal == "root@pam" and token_privileges is None:
return
database = cast(AsyncpgDatabase, request.app.state.database)
await _authorize(database, principal, token_privileges, method, inputs)
await _authorize(database, principal, token_privileges, semantic_path, method, inputs)
async def _authorize(
database: AsyncpgDatabase,
principal: str,
token_privileges: frozenset[str] | None,
semantic_path: str,
method: Method,
inputs: dict[str, Any],
) -> None:
@@ -173,6 +174,8 @@ async def _authorize(
requirement = requirement_from_contract(
method.permissions, {name: str(value) for name, value in values.items()}
)
if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None:
return
rows = await database.pool.fetch(
@@ -201,6 +204,7 @@ async def _authorize(
requirement.privileges,
entries,
token_privileges=token_privileges,
require_all=requirement.require_all,
):
raise ApiError(403, "permission check failed")
@@ -241,7 +245,11 @@ async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
errors[name] = "property is not defined in schema"
if errors:
raise ContractValidationError(dict(sorted(errors.items())))
return {"values": parsed, "path": dict(request.path_params)}
return {
"values": parsed,
"path": dict(request.path_params),
"provided": tuple(sorted(supplied)),
}
def _coerce(value: Any, schema: Schema) -> Any:
+4 -3
View File
@@ -101,6 +101,7 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
async def token_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
provided = frozenset(str(item) for item in inputs.get("provided", values))
userid, tokenid = str(values["userid"]), str(values["tokenid"])
_require_owner(request, userid)
regenerate = bool(values.get("regenerate", False))
@@ -117,9 +118,9 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
extract(epoch from t.expires_at)::bigint AS expire""",
userid,
tokenid,
values.get("comment"),
_expire_value(values),
values.get("privsep"),
values.get("comment") if "comment" in provided else None,
_expire_value(values) if "expire" in provided else None,
values.get("privsep") if "privsep" in provided else None,
hash_secret(secret) if secret is not None else None,
)
if row is None:
+167 -3
View File
@@ -11,6 +11,7 @@ from fastapi import 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.tasks.repository import TaskRepository
from app.tasks.upid import Upid
@@ -43,14 +44,16 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def qemu_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _database(request).pool.fetchrow(
"""SELECT r.state FROM resources r JOIN nodes n ON n.id=r.node_id
"""SELECT 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 {"vmid": int(vmid), **_state(row["state"])}
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
async def qemu_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await qemu_config(request, inputs)
@@ -74,7 +77,17 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
raise ApiError(409, f"cannot {operation} VM while it is {current}")
timestamp = int(await database.pool.fetchval("SELECT extract(epoch from now())::bigint"))
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
upid = str(Upid(node, pid, pid, timestamp, f"qm{operation}", vmid, "root@pam"))
upid = str(
Upid(
node,
pid,
pid,
timestamp,
f"qm{operation}",
vmid,
str(request.state.principal),
)
)
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=f"qemu-{operation}",
@@ -84,6 +97,123 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
)
return task.upid
async def create(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), int(values["vmid"])
database = _database(request)
if not await database.pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", node
):
raise ApiError(404, "node does not exist")
if await database.pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
str(vmid),
):
raise ApiError(409, "VMID already exists")
config = {
key: value
for key, value in values.items()
if key not in {"node", "vmid", "force", "archive", "start"}
}
return await _create_task(
request,
node=node,
vmid=str(vmid),
task_type="qemu-create",
payload={"node": node, "vmid": vmid, "config": config},
)
async def update(request: Request, inputs: dict[str, Any], *, asynchronous: bool) -> str | None:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.version, 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")
control = {"node", "vmid", "digest", "delete", "revert", "skiplock", "background_delay"}
provided = frozenset(str(item) for item in inputs.get("provided", values))
changes = {
key: value for key, value in values.items() if key in provided and key not in control
}
delete = str(values.get("delete", "")) if "delete" in provided else ""
if asynchronous:
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-update",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(row["id"]),
"changes": changes,
"delete": delete,
},
)
state = _state(row["state"])
config = _state(row["config"])
state.update(changes)
config.update(changes)
for key in delete.split(","):
if key:
state.pop(key, None)
config.pop(key, None)
status = await database.pool.execute(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2""",
row["id"],
row["version"],
json.dumps(state, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(409, "configuration changed concurrently")
await database.pool.execute(
"""UPDATE virtual_machines SET config=$2::jsonb
WHERE resource_id=$1""",
row["id"],
json.dumps(config, sort_keys=True),
)
return None
async def update_async(request: Request, inputs: dict[str, Any]) -> str:
result = await update(request, inputs, asynchronous=True)
if not isinstance(result, str):
raise RuntimeError("async QEMU update did not create a task")
return result
async def update_sync(request: Request, inputs: dict[str, Any]) -> None:
await update(request, inputs, asynchronous=False)
async def delete(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_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")
if str(_state(row["state"]).get("status")) != "stopped":
raise ApiError(409, "cannot delete a running virtual machine")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-delete",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
)
async def start(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("start", request, inputs)
@@ -124,10 +254,44 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
]
registry.register("/nodes/{node}/qemu", "GET", qemu_list)
registry.register("/nodes/{node}/qemu", "POST", create)
registry.register("/nodes/{node}/qemu/{vmid}", "DELETE", delete)
registry.register("/nodes/{node}/qemu/{vmid}/config", "GET", qemu_config)
registry.register("/nodes/{node}/qemu/{vmid}/config", "POST", update_async)
registry.register("/nodes/{node}/qemu/{vmid}/config", "PUT", update_sync)
registry.register("/nodes/{node}/qemu/{vmid}/status/current", "GET", qemu_status)
registry.register("/nodes/{node}/qemu/{vmid}/status/start", "POST", start)
registry.register("/nodes/{node}/qemu/{vmid}/status/stop", "POST", stop)
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)
async def _create_task(
request: Request,
*,
node: str,
vmid: str,
task_type: str,
payload: dict[str, Any],
) -> str:
database = _database(request)
timestamp = int(await database.pool.fetchval("SELECT extract(epoch from now())::bigint"))
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
worker_type = {
"qemu-create": "qmcreate",
"qemu-delete": "qmdestroy",
"qemu-update": "qmconfig",
}[task_type]
upid = str(Upid(node, pid, pid, timestamp, worker_type, vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=task_type,
payload=payload,
resource_key=f"qemu:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
+7 -1
View File
@@ -43,7 +43,13 @@ def create_app(
return TaskWorker(
repository,
"simulator-worker",
{"qemu-start": handler, "qemu-stop": handler},
{
"qemu-create": handler,
"qemu-delete": handler,
"qemu-start": handler,
"qemu-stop": handler,
"qemu-update": handler,
},
concurrency=resolved.task_worker_concurrency,
lease_seconds=resolved.task_lease_seconds,
)
+7 -2
View File
@@ -57,17 +57,19 @@ def authorize(
entries: tuple[AclEntry, ...],
*,
token_privileges: frozenset[str] | None = None,
require_all: bool = True,
) -> bool:
privileges = effective_privileges(principal, path, entries)
if token_privileges is not None:
privileges &= token_privileges
return required <= privileges
return required <= privileges if require_all else bool(required & privileges)
@dataclass(frozen=True, slots=True)
class CapabilityRequirement:
path: str
privileges: frozenset[str]
require_all: bool = True
def requirement_from_contract(
@@ -84,4 +86,7 @@ def requirement_from_contract(
raw_privileges = check[2]
if not isinstance(raw_privileges, list):
return None
return CapabilityRequirement(raw_path, frozenset(str(item) for item in raw_privileges))
require_all = not (len(check) >= 4 and check[3] == "any")
return CapabilityRequirement(
raw_path, frozenset(str(item) for item in raw_privileges), require_all
)
+91 -1
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
import uuid
from collections.abc import Mapping
from typing import Any, cast
from app.simulation.clock import Clock
from app.simulation.transitions import VmState, plan_transition
@@ -12,9 +14,15 @@ from app.tasks.worker import TaskHandler
def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
async def execute(task: Task) -> dict[str, str]:
async def execute(task: Task) -> dict[str, Any]:
operation = task.task_type.removeprefix("qemu-")
if operation == "create":
return await _create(repository, task)
resource_id = uuid.UUID(str(task.payload["resource_id"]))
if operation == "update":
return await _update(repository, task, resource_id)
if operation == "delete":
return await _delete(repository, task, resource_id)
async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None:
@@ -41,3 +49,85 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
return {"status": str(transition.after)}
return execute
async def _create(repository: TaskRepository, task: Task) -> dict[str, Any]:
node, vmid = str(task.payload["node"]), int(task.payload["vmid"])
config = dict(task.payload.get("config", {}))
resource_id = uuid.uuid4()
state = {"status": "stopped", **config}
async with repository.pool.acquire() as connection:
async with connection.transaction():
node_row = await connection.fetchrow(
"SELECT id, cluster_id FROM nodes WHERE name=$1", node
)
if node_row is None:
raise ValueError("node disappeared")
await connection.execute(
"""INSERT INTO resources(
id, node_id, cluster_id, kind, external_id, state, metadata
) VALUES($1, $2, $3, 'qemu', $4, $5::jsonb, '{}'::jsonb)""",
resource_id,
node_row["id"],
node_row["cluster_id"],
str(vmid),
json.dumps(state, sort_keys=True),
)
await connection.execute(
"""INSERT INTO virtual_machines(resource_id, cluster_id, vmid, config)
VALUES($1, $2, $3, $4::jsonb)""",
resource_id,
node_row["cluster_id"],
vmid,
json.dumps(config, sort_keys=True),
)
await repository.append_log(task.id, f"VM {vmid} created")
return {"vmid": vmid, "status": "stopped"}
async def _update(repository: TaskRepository, task: Task, resource_id: uuid.UUID) -> dict[str, Any]:
changes = dict(task.payload.get("changes", {}))
delete_keys = tuple(str(task.payload.get("delete", "")).split(","))
async with repository.pool.acquire() as connection:
async with connection.transaction():
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")
state = _object(row["state"])
config = _object(row["config"])
config.update(changes)
for key in delete_keys:
if key:
config.pop(key, None)
state.pop(key, None)
state.update(changes)
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(config, sort_keys=True),
)
await repository.append_log(task.id, "VM configuration updated")
return {"updated": sorted(changes), "deleted": sorted(key for key in delete_keys if key)}
async def _delete(repository: TaskRepository, task: Task, resource_id: uuid.UUID) -> dict[str, Any]:
async with repository.pool.acquire() as connection:
status = await connection.execute("DELETE FROM resources WHERE id=$1", resource_id)
if status != "DELETE 1":
raise ValueError("resource disappeared")
await repository.append_log(task.id, "VM deleted")
return {"deleted": True}
def _object(value: object) -> dict[str, Any]:
return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value))