feat: complete QEMU power lifecycle
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user