diff --git a/README.md b/README.md index ebea553..4e4a9a5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/handlers/qemu.py b/app/handlers/qemu.py index bb1cfa6..3aa316a 100644 --- a/app/handlers/qemu.py +++ b/app/handlers/qemu.py @@ -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) diff --git a/app/main.py b/app/main.py index a06a27b..8619549 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/simulation/transitions.py b/app/simulation/transitions.py index dd687ef..59a213d 100644 --- a/app/simulation/transitions.py +++ b/app/simulation/transitions.py @@ -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), diff --git a/docs/compatibility-0.1.0.md b/docs/compatibility-0.1.0.md index 3fa5927..a93124f 100644 --- a/docs/compatibility-0.1.0.md +++ b/docs/compatibility-0.1.0.md @@ -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`, diff --git a/docs/original-prompt-gap-plan.md b/docs/original-prompt-gap-plan.md index ff9307b..262bac8 100644 --- a/docs/original-prompt-gap-plan.md +++ b/docs/original-prompt-gap-plan.md @@ -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 diff --git a/evidence/pve-9.2.3-0.1.0.json b/evidence/pve-9.2.3-0.1.0.json index b0a7288..978c472 100644 --- a/evidence/pve-9.2.3-0.1.0.json +++ b/evidence/pve-9.2.3-0.1.0.json @@ -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"] } ] } diff --git a/tests/compatibility/test_proxmoxer.py b/tests/compatibility/test_proxmoxer.py index ea93217..72f7779 100644 --- a/tests/compatibility/test_proxmoxer.py +++ b/tests/compatibility/test_proxmoxer.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") diff --git a/tests/unit/test_transitions.py b/tests/unit/test_transitions.py index 3284e65..d0dcace 100644 --- a/tests/unit/test_transitions.py +++ b/tests/unit/test_transitions.py @@ -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),