feat: complete QEMU power lifecycle

This commit is contained in:
Sergey Antropoff
2026-07-13 02:00:45 +03:00
parent 701157f570
commit e1e1f69577
9 changed files with 92 additions and 13 deletions
+5
View File
@@ -21,6 +21,11 @@ PUT uses optimistic versioning. Common fields and unknown version-dependent
parameters are retained in JSONB; duplicate VMIDs and overlapping operations
fail with 409.
Power lifecycle now includes start, stop, graceful shutdown, reboot, reset,
suspend, and resume. Every operation is validated by the explicit VM state
machine, exposes intermediate/final state through current status, and runs as a
leased task under the same VM lock.
## Development
Python 3.13 is required.
+25 -4
View File
@@ -12,6 +12,7 @@ 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.simulation.transitions import InvalidTransitionError, VmState, plan_transition
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
@@ -71,10 +72,10 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
if row is None:
raise ApiError(404, "virtual machine does not exist")
current = str(_state(row["state"]).get("status", "stopped"))
if (operation == "start" and current != "stopped") or (
operation == "stop" and current != "running"
):
raise ApiError(409, f"cannot {operation} VM while it is {current}")
try:
plan_transition(VmState(current), operation)
except (InvalidTransitionError, ValueError) as error:
raise ApiError(409, f"cannot {operation} VM while it is {current}") from error
timestamp = int(await database.pool.fetchval("SELECT extract(epoch from now())::bigint"))
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
upid = str(
@@ -220,6 +221,21 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def stop(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("stop", request, inputs)
async def shutdown(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("shutdown", request, inputs)
async def reboot(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reboot", request, inputs)
async def reset(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reset", request, inputs)
async def suspend(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("suspend", request, inputs)
async def resume(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("resume", 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"])
@@ -262,6 +278,11 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
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}/qemu/{vmid}/status/shutdown", "POST", shutdown)
registry.register("/nodes/{node}/qemu/{vmid}/status/reboot", "POST", reboot)
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}/tasks", "GET", task_list)
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
+5
View File
@@ -46,8 +46,13 @@ def create_app(
{
"qemu-create": handler,
"qemu-delete": handler,
"qemu-reboot": handler,
"qemu-reset": handler,
"qemu-resume": handler,
"qemu-shutdown": handler,
"qemu-start": handler,
"qemu-stop": handler,
"qemu-suspend": handler,
"qemu-update": handler,
},
concurrency=resolved.task_worker_concurrency,
+4
View File
@@ -29,6 +29,10 @@ class InvalidTransitionError(ValueError):
TRANSITIONS: dict[tuple[VmState, str], tuple[VmState, VmState]] = {
(VmState.STOPPED, "start"): (VmState.STARTING, VmState.RUNNING),
(VmState.RUNNING, "stop"): (VmState.STOPPING, VmState.STOPPED),
(VmState.RUNNING, "shutdown"): (VmState.STOPPING, VmState.STOPPED),
(VmState.RUNNING, "reboot"): (VmState.STOPPING, VmState.RUNNING),
(VmState.RUNNING, "reset"): (VmState.STOPPING, VmState.RUNNING),
(VmState.RUNNING, "suspend"): (VmState.PAUSING, VmState.PAUSED),
(VmState.RUNNING, "pause"): (VmState.PAUSING, VmState.PAUSED),
(VmState.PAUSED, "resume"): (VmState.RESUMING, VmState.RUNNING),
(VmState.RUNNING, "migrate"): (VmState.MIGRATING, VmState.RUNNING),
+2 -2
View File
@@ -9,8 +9,8 @@ Proxmox compatibility.
| Level | Methods | Contract share | Evidence |
|---|---:|---:|---|
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
| Stateful semantics implemented on current main | 22 | 3.26% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 653 | 96.74% | Default 501 fallback |
| Stateful semantics implemented on current main | 27 | 4.00% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 648 | 96.00% | Default 501 fallback |
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
+1 -1
View File
@@ -56,7 +56,7 @@ plaintext password, ticket, CSRF token, or token secret reaches storage/logs.
## G4 — QEMU 0.2 verticals
- [x] Create, synchronous/asynchronous update, and delete.
- [ ] Shutdown, reboot, reset, suspend and resume.
- [x] Shutdown, reboot, reset, suspend and resume.
- [ ] Snapshots and rollback, clone, local/remote migration, resize and move
disk, selected agent endpoints, pending/status data.
- [ ] Persist normalized CPU/memory/common fields plus unknown PVE parameters in
+30
View File
@@ -74,6 +74,36 @@
"verb": "PUT",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_handlers.py", "tests/unit/test_acl.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/shutdown",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_transitions.py", "tests/unit/test_qemu_task.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/reboot",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_transitions.py", "tests/unit/test_qemu_task.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/reset",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_transitions.py", "tests/unit/test_qemu_task.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/suspend",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_transitions.py", "tests/unit/test_qemu_task.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/resume",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_transitions.py", "tests/unit/test_qemu_task.py"]
}
]
}
+16 -6
View File
@@ -125,9 +125,19 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
verify_ssl=False,
)
status = operator_api.nodes("pve1").qemu("101").status.current.get()
operation = "start" if status["status"] == "stopped" else "stop"
endpoint = operator_api.nodes("pve1").qemu("101").status(operation)
upid = endpoint.post()
task = wait_task(operator_api, upid)
assert task["exitstatus"] == "OK"
status_resource = operator_api.nodes("pve1").qemu("101").status
def run(operation: str, expected: str) -> None:
upid = status_resource(operation).post()
assert wait_task(operator_api, upid)["exitstatus"] == "OK"
assert status_resource.current.get()["status"] == expected
if status_resource.current.get()["status"] == "stopped":
run("start", "running")
run("reboot", "running")
run("reset", "running")
run("suspend", "paused")
run("resume", "running")
run("shutdown", "stopped")
run("start", "running")
run("stop", "stopped")
+4
View File
@@ -13,6 +13,10 @@ from app.simulation.transitions import InvalidTransitionError, VmState, plan_tra
[
(VmState.STOPPED, "start", VmState.RUNNING),
(VmState.RUNNING, "stop", VmState.STOPPED),
(VmState.RUNNING, "shutdown", VmState.STOPPED),
(VmState.RUNNING, "reboot", VmState.RUNNING),
(VmState.RUNNING, "reset", VmState.RUNNING),
(VmState.RUNNING, "suspend", VmState.PAUSED),
(VmState.RUNNING, "pause", VmState.PAUSED),
(VmState.PAUSED, "resume", VmState.RUNNING),
(VmState.RUNNING, "snapshot", VmState.RUNNING),