feat: implement QEMU clone and local migration

This commit is contained in:
Sergey Antropoff
2026-07-13 02:14:00 +03:00
parent 48f7cc39ff
commit 41b76b5472
10 changed files with 279 additions and 4 deletions
+3
View File
@@ -85,6 +85,9 @@ QEMU snapshots are durable PostgreSQL records. Create, delete, and rollback use
UPID tasks and the same per-VM lock as other mutations; rollback restores both UPID tasks and the same per-VM lock as other mutations; rollback restores both
the captured VM configuration and runtime state. Snapshot listing, inspection, the captured VM configuration and runtime state. Snapshot listing, inspection,
and description updates are available through the native Proxmox API paths. and description updates are available through the native Proxmox API paths.
Full QEMU clones copy configuration into a new stopped VM, and local migration
moves a VM between seeded nodes through an explicit migrating state. Both are
durable UPID operations with VMID collision and target-node validation.
Token lifecycle is available at Token lifecycle is available at
`/access/users/{userid}/token[/{tokenid}]`. A generated secret is returned only `/access/users/{userid}/token[/{tokenid}]`. A generated secret is returned only
+68
View File
@@ -322,6 +322,69 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str: async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-rollback", request, inputs) return await snapshot_task("snapshot-rollback", request, inputs)
async def clone(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, newid = str(values["node"]), str(values["vmid"]), str(values["newid"])
source = await _qemu_resource(request, node, vmid)
if await _database(request).pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
newid,
):
raise ApiError(409, "VMID already exists")
target = str(values.get("target") or node)
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=target,
vmid=newid,
task_type="qemu-clone",
payload={
"source_resource_id": str(source["id"]),
"source_vmid": vmid,
"node": target,
"vmid": int(newid),
"name": values.get("name"),
"description": values.get("description"),
"full": bool(values.get("full", False)),
},
)
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
target = str(values["target"])
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
)
if not exists:
raise ApiError(404, "target node does not exist")
return {"local_disks": [], "local_resources": [], "running": False}
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, target = str(values["node"]), str(values["vmid"]), str(values["target"])
resource = await _qemu_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
await migrate_preconditions(request, inputs)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"online": bool(values.get("online", False)),
},
)
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"])
@@ -382,6 +445,9 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
registry.register( registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback "/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
) )
registry.register("/nodes/{node}/qemu/{vmid}/clone", "POST", clone)
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "GET", migrate_preconditions)
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "POST", migrate)
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)
@@ -405,6 +471,8 @@ async def _create_task(
"qemu-snapshot-create": "qmsnapshot", "qemu-snapshot-create": "qmsnapshot",
"qemu-snapshot-delete": "qmdelsnapshot", "qemu-snapshot-delete": "qmdelsnapshot",
"qemu-snapshot-rollback": "qmrollback", "qemu-snapshot-rollback": "qmrollback",
"qemu-clone": "qmclone",
"qemu-migrate": "qmigrate",
}[task_type] }[task_type]
upid = str(Upid(node, pid, pid, timestamp, worker_type, vmid, str(request.state.principal))) upid = str(Upid(node, pid, pid, timestamp, worker_type, vmid, str(request.state.principal)))
try: try:
+2
View File
@@ -44,12 +44,14 @@ def create_app(
repository, repository,
"simulator-worker", "simulator-worker",
{ {
"qemu-clone": handler,
"qemu-create": handler, "qemu-create": handler,
"qemu-delete": handler, "qemu-delete": handler,
"qemu-reboot": handler, "qemu-reboot": handler,
"qemu-reset": handler, "qemu-reset": handler,
"qemu-resume": handler, "qemu-resume": handler,
"qemu-shutdown": handler, "qemu-shutdown": handler,
"qemu-migrate": handler,
"qemu-snapshot-create": handler, "qemu-snapshot-create": handler,
"qemu-snapshot-delete": handler, "qemu-snapshot-delete": handler,
"qemu-snapshot-rollback": handler, "qemu-snapshot-rollback": handler,
+77
View File
@@ -18,6 +18,8 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
operation = task.task_type.removeprefix("qemu-") operation = task.task_type.removeprefix("qemu-")
if operation == "create": if operation == "create":
return await _create(repository, task) return await _create(repository, task)
if operation == "clone":
return await _clone(repository, task)
resource_id = uuid.UUID(str(task.payload["resource_id"])) resource_id = uuid.UUID(str(task.payload["resource_id"]))
if operation == "update": if operation == "update":
return await _update(repository, task, resource_id) return await _update(repository, task, resource_id)
@@ -27,6 +29,8 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
return await _snapshot( return await _snapshot(
repository, task, resource_id, operation.removeprefix("snapshot-") repository, task, resource_id, operation.removeprefix("snapshot-")
) )
if operation == "migrate":
return await _migrate(repository, task, resource_id, clock)
async with repository.pool.acquire() as connection: async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id) row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None: if row is None:
@@ -201,5 +205,78 @@ async def _snapshot(
return {"snapshot": name, "operation": operation} return {"snapshot": name, "operation": operation}
async def _clone(repository: TaskRepository, task: Task) -> dict[str, Any]:
source_id = uuid.UUID(str(task.payload["source_resource_id"]))
target_id = uuid.uuid4()
node, vmid = str(task.payload["node"]), int(task.payload["vmid"])
async with repository.pool.acquire() as connection:
async with connection.transaction():
source = 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""",
source_id,
)
target = await connection.fetchrow(
"SELECT id, cluster_id FROM nodes WHERE name=$1", node
)
if source is None or target is None:
raise ValueError("clone source or target disappeared")
config = _object(source["config"])
if task.payload.get("name") is not None:
config["name"] = task.payload["name"]
state = {**_object(source["state"]), **config, "status": "stopped"}
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)""",
target_id,
target["id"],
target["cluster_id"],
str(vmid),
json.dumps(state),
)
await connection.execute(
"""INSERT INTO virtual_machines(resource_id,cluster_id,vmid,config)
VALUES($1,$2,$3,$4::jsonb)""",
target_id,
target["cluster_id"],
vmid,
json.dumps(config),
)
await repository.append_log(task.id, f"VM cloned to {vmid}")
return {"vmid": vmid, "node": node}
async def _migrate(
repository: TaskRepository, task: Task, resource_id: uuid.UUID, clock: Clock
) -> dict[str, Any]:
target = str(task.payload["target"])
async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None:
raise ValueError("resource disappeared")
state = _object(row["state"])
transition = plan_transition(VmState(str(state["status"])), "migrate")
state["status"] = transition.intermediate
await connection.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1", resource_id, json.dumps(state)
)
await repository.append_log(task.id, f"migration to {target} started")
await clock.sleep(1.0)
async with repository.pool.acquire() as connection:
node = await connection.fetchrow("SELECT id FROM nodes WHERE name=$1", target)
if node is None:
raise ValueError("target node disappeared")
state["status"] = transition.after
await connection.execute(
"""UPDATE resources SET node_id=$2,state=$3::jsonb,version=version+1,
updated_at=now() WHERE id=$1""",
resource_id,
node["id"],
json.dumps(state),
)
await repository.append_log(task.id, f"migration to {target} completed")
return {"node": target, "status": str(transition.after)}
def _object(value: object) -> dict[str, Any]: def _object(value: object) -> dict[str, Any]:
return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value)) return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value))
+2 -2
View File
@@ -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 | 34 | 5.04% | Handler registry and unit/integration tests | | Stateful semantics implemented on current main | 37 | 5.48% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 641 | 94.96% | Default 501 fallback | | Schema-only or explicitly unsupported | 638 | 94.52% | 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`,
+3 -2
View File
@@ -58,8 +58,9 @@ plaintext password, ticket, CSRF token, or token secret reaches storage/logs.
- [x] Create, synchronous/asynchronous update, and delete. - [x] Create, synchronous/asynchronous update, and delete.
- [x] Shutdown, reboot, reset, suspend and resume. - [x] Shutdown, reboot, reset, suspend and resume.
- [x] Snapshots and rollback. - [x] Snapshots and rollback.
- [ ] Clone, local/remote migration, resize and move disk, selected agent - [x] Clone and local migration.
endpoints, pending/status data. - [ ] Remote migration, resize and move 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
JSONB; simulate usage, uptime, PID, IO/network, lock, template, QMP, HA and JSONB; simulate usage, uptime, PID, IO/network, lock, template, QMP, HA and
guest-agent availability. guest-agent availability.
+18
View File
@@ -146,6 +146,24 @@
"verb": "POST", "verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"], "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_qemu_task.py"] "sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_task.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/clone",
"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_qemu_handlers.py", "tests/unit/test_qemu_task.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/migrate",
"verb": "GET",
"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"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/migrate",
"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_qemu_handlers.py", "tests/unit/test_qemu_task.py"]
} }
] ]
} }
+13
View File
@@ -126,6 +126,19 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
assert wait_task(proxmox, snapshot_delete_upid)["exitstatus"] == "OK" assert wait_task(proxmox, snapshot_delete_upid)["exitstatus"] == "OK"
assert not snapshots.get() assert not snapshots.get()
clone_upid = (
proxmox.nodes("pve1").qemu("150").clone.post(newid=151, name="clone-by-proxmoxer", full=1)
)
assert wait_task(proxmox, clone_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve1").qemu("151").config.get()["name"] == "clone-by-proxmoxer"
migration = proxmox.nodes("pve1").qemu("151").migrate
assert migration.get(target="pve2")["local_disks"] == []
migrate_upid = migration.post(target="pve2", online=0)
assert wait_task(proxmox, migrate_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve2").qemu("151").config.get()["name"] == "clone-by-proxmoxer"
clone_delete_upid = proxmox.nodes("pve2").qemu("151").delete()
assert wait_task(proxmox, clone_delete_upid)["exitstatus"] == "OK"
delete_upid = proxmox.nodes("pve1").qemu("150").delete() delete_upid = proxmox.nodes("pve1").qemu("150").delete()
assert wait_task(proxmox, delete_upid)["exitstatus"] == "OK" assert wait_task(proxmox, delete_upid)["exitstatus"] == "OK"
with pytest.raises(ResourceException) as deleted_vm: with pytest.raises(ResourceException) as deleted_vm:
+49
View File
@@ -262,3 +262,52 @@ async def test_qemu_snapshot_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
assert (await rollback(http_request, common)).startswith("UPID:pve1:") assert (await rollback(http_request, common)).startswith("UPID:pve1:")
assert (await delete(http_request, common)).startswith("UPID:pve1:") assert (await delete(http_request, common)).startswith("UPID:pve1:")
assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"] assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"]
async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
tasks: list[dict[str, Any]] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
tasks.append(kwargs)
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
{},
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
assert clone and migrate_get and migrate
clone_upid = await clone(
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
)
assert clone_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-clone"
assert (await migrate_get(http_request, inputs(node="pve1", vmid=150, target="pve2")))[
"local_disks"
] == []
migrate_upid = await migrate(
http_request, inputs(node="pve1", vmid=150, target="pve2", online=False)
)
assert migrate_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-migrate"
with pytest.raises(ApiError) as same_node:
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
assert same_node.value.status_code == 400
+44
View File
@@ -82,6 +82,8 @@ class CrudConnection:
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()} return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
if "JOIN virtual_machines" in sql: if "JOIN virtual_machines" in sql:
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'} return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
if "SELECT state FROM resources" in sql:
return {"state": '{"status":"stopped","name":"old"}'}
if "FROM snapshots" in sql: if "FROM snapshots" in sql:
return { return {
"state": ( "state": (
@@ -217,3 +219,45 @@ async def test_qemu_worker_snapshot_create_rollback_and_delete_are_persistent()
assert any("INSERT INTO snapshots" in command for command in commands) assert any("INSERT INTO snapshots" in command for command in commands)
assert any("UPDATE virtual_machines" in command for command in commands) assert any("UPDATE virtual_machines" in command for command in commands)
assert any("DELETE FROM snapshots" in command for command in commands) assert any("DELETE FROM snapshots" in command for command in commands)
async def test_qemu_worker_clone_and_migrate_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
cloned = await handler(
Task(
uuid.uuid4(),
"UPID:clone",
"qemu-clone",
"running",
{
"source_resource_id": str(resource_id),
"node": "pve1",
"vmid": 151,
"name": "clone",
},
0,
False,
1,
)
)
migrated = await handler(
Task(
uuid.uuid4(),
"UPID:migrate",
"qemu-migrate",
"running",
{"resource_id": str(resource_id), "target": "pve2"},
0,
False,
1,
)
)
assert cloned == {"vmid": 151, "node": "pve1"}
assert migrated == {"node": "pve2", "status": "stopped"}
commands = repository.connection.commands
assert any("INSERT INTO resources" in command for command in commands)
assert any("node_id=$2" in command for command in commands)