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
|
parameters are retained in JSONB; duplicate VMIDs and overlapping operations
|
||||||
fail with 409.
|
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
|
## Development
|
||||||
|
|
||||||
Python 3.13 is required.
|
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.api.registry import HandlerRegistry
|
||||||
from app.db.pool import AsyncpgDatabase
|
from app.db.pool import AsyncpgDatabase
|
||||||
from app.db.primitives import ConflictError
|
from app.db.primitives import ConflictError
|
||||||
|
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
|
||||||
from app.tasks.repository import TaskRepository
|
from app.tasks.repository import TaskRepository
|
||||||
from app.tasks.upid import Upid
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
@@ -71,10 +72,10 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "virtual machine does not exist")
|
raise ApiError(404, "virtual machine does not exist")
|
||||||
current = str(_state(row["state"]).get("status", "stopped"))
|
current = str(_state(row["state"]).get("status", "stopped"))
|
||||||
if (operation == "start" and current != "stopped") or (
|
try:
|
||||||
operation == "stop" and current != "running"
|
plan_transition(VmState(current), operation)
|
||||||
):
|
except (InvalidTransitionError, ValueError) as error:
|
||||||
raise ApiError(409, f"cannot {operation} VM while it is {current}")
|
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"))
|
timestamp = int(await database.pool.fetchval("SELECT extract(epoch from now())::bigint"))
|
||||||
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
|
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
|
||||||
upid = str(
|
upid = str(
|
||||||
@@ -220,6 +221,21 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
|
|||||||
async def stop(request: Request, inputs: dict[str, Any]) -> str:
|
async def stop(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
return await mutate("stop", request, inputs)
|
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]]:
|
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
tasks = await TaskRepository(_database(request).pool).list_for_node(
|
tasks = await TaskRepository(_database(request).pool).list_for_node(
|
||||||
str(_values(inputs)["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/current", "GET", qemu_status)
|
||||||
registry.register("/nodes/{node}/qemu/{vmid}/status/start", "POST", start)
|
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/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", "GET", task_list)
|
||||||
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
|
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
|
||||||
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
|
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ def create_app(
|
|||||||
{
|
{
|
||||||
"qemu-create": handler,
|
"qemu-create": handler,
|
||||||
"qemu-delete": handler,
|
"qemu-delete": handler,
|
||||||
|
"qemu-reboot": handler,
|
||||||
|
"qemu-reset": handler,
|
||||||
|
"qemu-resume": handler,
|
||||||
|
"qemu-shutdown": handler,
|
||||||
"qemu-start": handler,
|
"qemu-start": handler,
|
||||||
"qemu-stop": handler,
|
"qemu-stop": handler,
|
||||||
|
"qemu-suspend": handler,
|
||||||
"qemu-update": handler,
|
"qemu-update": handler,
|
||||||
},
|
},
|
||||||
concurrency=resolved.task_worker_concurrency,
|
concurrency=resolved.task_worker_concurrency,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class InvalidTransitionError(ValueError):
|
|||||||
TRANSITIONS: dict[tuple[VmState, str], tuple[VmState, VmState]] = {
|
TRANSITIONS: dict[tuple[VmState, str], tuple[VmState, VmState]] = {
|
||||||
(VmState.STOPPED, "start"): (VmState.STARTING, VmState.RUNNING),
|
(VmState.STOPPED, "start"): (VmState.STARTING, VmState.RUNNING),
|
||||||
(VmState.RUNNING, "stop"): (VmState.STOPPING, VmState.STOPPED),
|
(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.RUNNING, "pause"): (VmState.PAUSING, VmState.PAUSED),
|
||||||
(VmState.PAUSED, "resume"): (VmState.RESUMING, VmState.RUNNING),
|
(VmState.PAUSED, "resume"): (VmState.RESUMING, VmState.RUNNING),
|
||||||
(VmState.RUNNING, "migrate"): (VmState.MIGRATING, VmState.RUNNING),
|
(VmState.RUNNING, "migrate"): (VmState.MIGRATING, VmState.RUNNING),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ Proxmox compatibility.
|
|||||||
| Level | Methods | Contract share | Evidence |
|
| Level | Methods | Contract share | Evidence |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
|
| 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 |
|
| Stateful semantics implemented on current main | 27 | 4.00% | Handler registry and unit/integration tests |
|
||||||
| Schema-only or explicitly unsupported | 653 | 96.74% | Default 501 fallback |
|
| Schema-only or explicitly unsupported | 648 | 96.00% | Default 501 fallback |
|
||||||
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
|
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
|
||||||
|
|
||||||
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
|
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
|
## G4 — QEMU 0.2 verticals
|
||||||
|
|
||||||
- [x] Create, synchronous/asynchronous update, and delete.
|
- [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
|
- [ ] Snapshots and rollback, clone, local/remote migration, resize and move
|
||||||
disk, selected agent endpoints, pending/status data.
|
disk, selected agent endpoints, pending/status data.
|
||||||
- [ ] Persist normalized CPU/memory/common fields plus unknown PVE parameters in
|
- [ ] Persist normalized CPU/memory/common fields plus unknown PVE parameters in
|
||||||
|
|||||||
@@ -74,6 +74,36 @@
|
|||||||
"verb": "PUT",
|
"verb": "PUT",
|
||||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "errors_prohibitions", "permissions"],
|
"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"]
|
"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"),
|
token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
|
||||||
verify_ssl=False,
|
verify_ssl=False,
|
||||||
)
|
)
|
||||||
status = operator_api.nodes("pve1").qemu("101").status.current.get()
|
status_resource = operator_api.nodes("pve1").qemu("101").status
|
||||||
operation = "start" if status["status"] == "stopped" else "stop"
|
|
||||||
endpoint = operator_api.nodes("pve1").qemu("101").status(operation)
|
def run(operation: str, expected: str) -> None:
|
||||||
upid = endpoint.post()
|
upid = status_resource(operation).post()
|
||||||
task = wait_task(operator_api, upid)
|
assert wait_task(operator_api, upid)["exitstatus"] == "OK"
|
||||||
assert task["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.STOPPED, "start", VmState.RUNNING),
|
||||||
(VmState.RUNNING, "stop", VmState.STOPPED),
|
(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.RUNNING, "pause", VmState.PAUSED),
|
||||||
(VmState.PAUSED, "resume", VmState.RUNNING),
|
(VmState.PAUSED, "resume", VmState.RUNNING),
|
||||||
(VmState.RUNNING, "snapshot", VmState.RUNNING),
|
(VmState.RUNNING, "snapshot", VmState.RUNNING),
|
||||||
|
|||||||
Reference in New Issue
Block a user