feat: implement QEMU disk resize and move
This commit is contained in:
@@ -88,6 +88,10 @@ and description updates are available through the native Proxmox API paths.
|
|||||||
Full QEMU clones copy configuration into a new stopped VM, and local migration
|
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
|
moves a VM between seeded nodes through an explicit migrating state. Both are
|
||||||
durable UPID operations with VMID collision and target-node validation.
|
durable UPID operations with VMID collision and target-node validation.
|
||||||
|
Indexed contract fields such as `scsi[n]` accept their concrete Proxmox names
|
||||||
|
(`scsi0`, `scsi1`, and so on). QEMU disk growth is synchronous and rejects
|
||||||
|
shrinking; disk moves are durable tasks that update normalized disk metadata and
|
||||||
|
the preserved VM configuration.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
@@ -226,9 +227,16 @@ async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
|
|||||||
supplied.update(dict(parse_qsl((await request.body()).decode())))
|
supplied.update(dict(parse_qsl((await request.body()).decode())))
|
||||||
|
|
||||||
definitions = {parameter.name: parameter.definition for parameter in method.parameters}
|
definitions = {parameter.name: parameter.definition for parameter in method.parameters}
|
||||||
|
indexed = {
|
||||||
|
re.compile("^" + re.escape(name).replace(r"\[n\]", r"\d+") + "$"): definition
|
||||||
|
for name, definition in definitions.items()
|
||||||
|
if "[n]" in name
|
||||||
|
}
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
parsed: dict[str, Any] = {}
|
parsed: dict[str, Any] = {}
|
||||||
for name, definition in definitions.items():
|
for name, definition in definitions.items():
|
||||||
|
if "[n]" in name:
|
||||||
|
continue
|
||||||
if name not in supplied:
|
if name not in supplied:
|
||||||
if definition.optional:
|
if definition.optional:
|
||||||
if definition.default is not None:
|
if definition.default is not None:
|
||||||
@@ -240,7 +248,18 @@ async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
|
|||||||
parsed[name] = _coerce(supplied[name], definition)
|
parsed[name] = _coerce(supplied[name], definition)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
errors[name] = str(exc)
|
errors[name] = str(exc)
|
||||||
|
indexed_names: set[str] = set()
|
||||||
for name in supplied.keys() - definitions.keys():
|
for name in supplied.keys() - definitions.keys():
|
||||||
|
indexed_definition = next(
|
||||||
|
(candidate for pattern, candidate in indexed.items() if pattern.fullmatch(name)), None
|
||||||
|
)
|
||||||
|
if indexed_definition is not None:
|
||||||
|
indexed_names.add(name)
|
||||||
|
try:
|
||||||
|
parsed[name] = _coerce(supplied[name], indexed_definition)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
errors[name] = str(exc)
|
||||||
|
for name in supplied.keys() - definitions.keys() - indexed_names:
|
||||||
if name not in request.path_params:
|
if name not in request.path_params:
|
||||||
errors[name] = "property is not defined in schema"
|
errors[name] = "property is not defined in schema"
|
||||||
if errors:
|
if errors:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -385,6 +386,61 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def resize(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
config = _state(resource["config"])
|
||||||
|
if disk not in config:
|
||||||
|
raise ApiError(400, f"disk {disk} does not exist")
|
||||||
|
current = _disk_size_bytes(str(config[disk]))
|
||||||
|
size = _resize_bytes(str(values["size"]), current)
|
||||||
|
config[disk] = _replace_disk_size(str(config[disk]), size)
|
||||||
|
status = await _database(request).pool.execute(
|
||||||
|
"""UPDATE virtual_machines SET config=$2::jsonb
|
||||||
|
WHERE resource_id=$1""",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(409, "configuration changed concurrently")
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"""UPDATE resources SET state=state || $2::jsonb,version=version+1,
|
||||||
|
updated_at=now() WHERE id=$1""",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps({disk: config[disk]}, sort_keys=True),
|
||||||
|
)
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"""INSERT INTO vm_disks(id,resource_id,device,storage_id,size_bytes)
|
||||||
|
VALUES(gen_random_uuid(),$1,$2,$3,$4)
|
||||||
|
ON CONFLICT(resource_id,device) DO UPDATE SET size_bytes=EXCLUDED.size_bytes""",
|
||||||
|
resource["id"],
|
||||||
|
disk,
|
||||||
|
str(config[disk]).split(":", 1)[0],
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def move_disk(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
if disk not in _state(resource["config"]):
|
||||||
|
raise ApiError(400, f"disk {disk} does not exist")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="qemu-move-disk",
|
||||||
|
payload={
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"disk": disk,
|
||||||
|
"storage": str(values.get("storage") or "local-lvm"),
|
||||||
|
"target_vmid": int(values.get("target-vmid") or vmid),
|
||||||
|
"target_disk": str(values.get("target-disk") or disk),
|
||||||
|
"delete": bool(values.get("delete", True)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
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"])
|
||||||
@@ -448,6 +504,8 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
|
|||||||
registry.register("/nodes/{node}/qemu/{vmid}/clone", "POST", clone)
|
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", "GET", migrate_preconditions)
|
||||||
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "POST", migrate)
|
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "POST", migrate)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/resize", "PUT", resize)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/move_disk", "POST", move_disk)
|
||||||
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)
|
||||||
@@ -473,6 +531,7 @@ async def _create_task(
|
|||||||
"qemu-snapshot-rollback": "qmrollback",
|
"qemu-snapshot-rollback": "qmrollback",
|
||||||
"qemu-clone": "qmclone",
|
"qemu-clone": "qmclone",
|
||||||
"qemu-migrate": "qmigrate",
|
"qemu-migrate": "qmigrate",
|
||||||
|
"qemu-move-disk": "qmmove",
|
||||||
}[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:
|
||||||
@@ -514,3 +573,36 @@ async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "snapshot does not exist")
|
raise ApiError(404, "snapshot does not exist")
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _size_bytes(value: str) -> int:
|
||||||
|
match = _SIZE_RE.fullmatch(value.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ApiError(400, f"invalid disk size: {value}")
|
||||||
|
units = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
|
||||||
|
return int(match.group("value")) * units[match.group("unit").upper()]
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_size_bytes(value: str) -> int:
|
||||||
|
for part in value.split(","):
|
||||||
|
if part.startswith("size="):
|
||||||
|
return _size_bytes(part.removeprefix("size="))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_bytes(value: str, current: int) -> int:
|
||||||
|
if value.startswith("+"):
|
||||||
|
return current + _size_bytes(value[1:])
|
||||||
|
result = _size_bytes(value)
|
||||||
|
if result < current:
|
||||||
|
raise ApiError(400, "shrinking disks is not supported")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_disk_size(value: str, size: int) -> str:
|
||||||
|
parts = [part for part in value.split(",") if not part.startswith("size=")]
|
||||||
|
parts.append(f"size={size // 2**30}G" if size % 2**30 == 0 else f"size={size}")
|
||||||
|
return ",".join(parts)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def create_app(
|
|||||||
"qemu-resume": handler,
|
"qemu-resume": handler,
|
||||||
"qemu-shutdown": handler,
|
"qemu-shutdown": handler,
|
||||||
"qemu-migrate": handler,
|
"qemu-migrate": handler,
|
||||||
|
"qemu-move-disk": 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,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
|
|||||||
)
|
)
|
||||||
if operation == "migrate":
|
if operation == "migrate":
|
||||||
return await _migrate(repository, task, resource_id, clock)
|
return await _migrate(repository, task, resource_id, clock)
|
||||||
|
if operation == "move-disk":
|
||||||
|
return await _move_disk(repository, task, resource_id)
|
||||||
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:
|
||||||
@@ -278,5 +280,49 @@ async def _migrate(
|
|||||||
return {"node": target, "status": str(transition.after)}
|
return {"node": target, "status": str(transition.after)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _move_disk(
|
||||||
|
repository: TaskRepository, task: Task, resource_id: uuid.UUID
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
disk = str(task.payload["disk"])
|
||||||
|
target_disk = str(task.payload["target_disk"])
|
||||||
|
storage = str(task.payload["storage"])
|
||||||
|
async with repository.pool.acquire() as connection:
|
||||||
|
async with connection.transaction():
|
||||||
|
row = await connection.fetchrow(
|
||||||
|
"SELECT config FROM virtual_machines WHERE resource_id=$1", resource_id
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("resource disappeared")
|
||||||
|
config = _object(row["config"])
|
||||||
|
if disk not in config:
|
||||||
|
raise ValueError("disk disappeared")
|
||||||
|
original = str(config[disk])
|
||||||
|
suffix = original.split(":", 1)[1] if ":" in original else original
|
||||||
|
config[target_disk] = f"{storage}:{suffix}"
|
||||||
|
if bool(task.payload.get("delete", True)) and target_disk != disk:
|
||||||
|
config.pop(disk, None)
|
||||||
|
await connection.execute(
|
||||||
|
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
|
||||||
|
resource_id,
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
await connection.execute(
|
||||||
|
"""UPDATE resources SET state=state || $2::jsonb, version=version+1,
|
||||||
|
updated_at=now() WHERE id=$1""",
|
||||||
|
resource_id,
|
||||||
|
json.dumps({target_disk: config[target_disk]}, sort_keys=True),
|
||||||
|
)
|
||||||
|
await connection.execute(
|
||||||
|
"""UPDATE vm_disks SET device=$2,storage_id=$3
|
||||||
|
WHERE resource_id=$1 AND device=$4""",
|
||||||
|
resource_id,
|
||||||
|
target_disk,
|
||||||
|
storage,
|
||||||
|
disk,
|
||||||
|
)
|
||||||
|
await repository.append_log(task.id, f"disk {disk} moved to {storage}")
|
||||||
|
return {"disk": target_disk, "storage": storage}
|
||||||
|
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
@@ -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 | 37 | 5.48% | Handler registry and unit/integration tests |
|
| Stateful semantics implemented on current main | 39 | 5.78% | Handler registry and unit/integration tests |
|
||||||
| Schema-only or explicitly unsupported | 638 | 94.52% | Default 501 fallback |
|
| Schema-only or explicitly unsupported | 636 | 94.22% | 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`,
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ plaintext password, ticket, CSRF token, or token secret reaches storage/logs.
|
|||||||
- [x] Shutdown, reboot, reset, suspend and resume.
|
- [x] Shutdown, reboot, reset, suspend and resume.
|
||||||
- [x] Snapshots and rollback.
|
- [x] Snapshots and rollback.
|
||||||
- [x] Clone and local migration.
|
- [x] Clone and local migration.
|
||||||
- [ ] Remote migration, resize and move disk, selected agent endpoints,
|
- [x] Resize and move disk.
|
||||||
pending/status data.
|
- [ ] Remote migration, selected agent endpoints and 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.
|
||||||
|
|||||||
@@ -164,6 +164,18 @@
|
|||||||
"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_handlers.py", "tests/unit/test_qemu_task.py"]
|
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_handlers.py", "tests/unit/test_qemu_task.py"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/nodes/{node}/qemu/{vmid}/resize",
|
||||||
|
"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_compatible_io.py", "tests/unit/test_qemu_handlers.py"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/nodes/{node}/qemu/{vmid}/move_disk",
|
||||||
|
"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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
|||||||
assert hidden.value.status_code == 403
|
assert hidden.value.status_code == 403
|
||||||
|
|
||||||
create_upid = proxmox.nodes("pve1").qemu.post(
|
create_upid = proxmox.nodes("pve1").qemu.post(
|
||||||
vmid=150, name="created-by-proxmoxer", cores=2, memory=1024
|
vmid=150,
|
||||||
|
name="created-by-proxmoxer",
|
||||||
|
cores=2,
|
||||||
|
memory=1024,
|
||||||
|
scsi0="local-lvm:vm-150-disk-0,size=8G",
|
||||||
)
|
)
|
||||||
with pytest.raises(ResourceException) as duplicate_create:
|
with pytest.raises(ResourceException) as duplicate_create:
|
||||||
proxmox.nodes("pve1").qemu.post(vmid=150, name="duplicate")
|
proxmox.nodes("pve1").qemu.post(vmid=150, name="duplicate")
|
||||||
@@ -110,6 +114,13 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
|||||||
assert wait_task(proxmox, update_upid)["exitstatus"] == "OK"
|
assert wait_task(proxmox, update_upid)["exitstatus"] == "OK"
|
||||||
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
|
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
|
||||||
|
|
||||||
|
disk_api = proxmox.nodes("pve1").qemu("150")
|
||||||
|
assert disk_api.resize.put(disk="scsi0", size="+2G") is None
|
||||||
|
assert "size=10G" in disk_api.config.get()["scsi0"]
|
||||||
|
move_upid = disk_api.move_disk.post(disk="scsi0", storage="local")
|
||||||
|
assert wait_task(proxmox, move_upid)["exitstatus"] == "OK"
|
||||||
|
assert disk_api.config.get()["scsi0"].startswith("local:")
|
||||||
|
|
||||||
snapshots = proxmox.nodes("pve1").qemu("150").snapshot
|
snapshots = proxmox.nodes("pve1").qemu("150").snapshot
|
||||||
snapshot_upid = snapshots.post(snapname="baseline", description="before change")
|
snapshot_upid = snapshots.post(snapname="baseline", description="before change")
|
||||||
assert wait_task(proxmox, snapshot_upid)["exitstatus"] == "OK"
|
assert wait_task(proxmox, snapshot_upid)["exitstatus"] == "OK"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async def client_for(tmp_path: Path) -> AsyncClient:
|
|||||||
Parameter(name="node", definition=Schema(type="string")),
|
Parameter(name="node", definition=Schema(type="string")),
|
||||||
Parameter(name="count", definition=Schema(type="integer", minimum=1)),
|
Parameter(name="count", definition=Schema(type="integer", minimum=1)),
|
||||||
Parameter(name="force", definition=Schema(type="boolean", optional=True)),
|
Parameter(name="force", definition=Schema(type="boolean", optional=True)),
|
||||||
|
Parameter(name="scsi[n]", definition=Schema(type="string", optional=True)),
|
||||||
),
|
),
|
||||||
returns=Schema(type="null"),
|
returns=Schema(type="null"),
|
||||||
checksum="1" * 64,
|
checksum="1" * 64,
|
||||||
@@ -41,6 +42,8 @@ async def client_for(tmp_path: Path) -> AsyncClient:
|
|||||||
|
|
||||||
async def handler(_request: Request, inputs: dict[str, Any]) -> None:
|
async def handler(_request: Request, inputs: dict[str, Any]) -> None:
|
||||||
assert inputs["values"]["count"] >= 1
|
assert inputs["values"]["count"] >= 1
|
||||||
|
if "scsi0" in inputs["values"]:
|
||||||
|
assert inputs["values"]["scsi0"] == "local:disk,size=8G"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
handlers.register("/nodes/{node}/test", "POST", handler)
|
handlers.register("/nodes/{node}/test", "POST", handler)
|
||||||
@@ -96,3 +99,12 @@ async def test_non_object_json_is_rejected_without_fastapi_body(tmp_path: Path)
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json()["errors"] == {"body": "expected an object"}
|
assert response.json()["errors"] == {"body": "expected an object"}
|
||||||
assert "detail" not in response.json()
|
assert "detail" not in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_indexed_contract_parameter_accepts_concrete_device(tmp_path: Path) -> None:
|
||||||
|
async with await client_for(tmp_path) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api2/json/nodes/pve/test", json={"count": 1, "scsi0": "local:disk,size=8G"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ class QemuPool:
|
|||||||
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
|
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
|
||||||
if "SELECT r.id, r.state" in sql:
|
if "SELECT r.id, r.state" in sql:
|
||||||
status = "running" if self.running else "stopped"
|
status = "running" if self.running else "stopped"
|
||||||
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
|
return {
|
||||||
|
"id": self.resource_id,
|
||||||
|
"state": f'{{"status":"{status}"}}',
|
||||||
|
"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=8G"}',
|
||||||
|
}
|
||||||
if "SELECT r.id, r.state, v.config" in sql:
|
if "SELECT r.id, r.state, v.config" in sql:
|
||||||
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
||||||
if "SELECT s.* FROM snapshots" in sql:
|
if "SELECT s.* FROM snapshots" in sql:
|
||||||
@@ -292,7 +296,9 @@ async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch)
|
|||||||
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
|
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
|
||||||
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
|
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
|
||||||
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
|
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
|
||||||
assert clone and migrate_get and migrate
|
resize = registry.get("/nodes/{node}/qemu/{vmid}/resize", "PUT")
|
||||||
|
move = registry.get("/nodes/{node}/qemu/{vmid}/move_disk", "POST")
|
||||||
|
assert clone and migrate_get and migrate and resize and move
|
||||||
|
|
||||||
clone_upid = await clone(
|
clone_upid = await clone(
|
||||||
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
|
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
|
||||||
@@ -307,6 +313,14 @@ async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch)
|
|||||||
)
|
)
|
||||||
assert migrate_upid.startswith("UPID:pve1:")
|
assert migrate_upid.startswith("UPID:pve1:")
|
||||||
assert tasks[-1]["task_type"] == "qemu-migrate"
|
assert tasks[-1]["task_type"] == "qemu-migrate"
|
||||||
|
assert (
|
||||||
|
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None
|
||||||
|
)
|
||||||
|
move_upid = await move(
|
||||||
|
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
|
||||||
|
)
|
||||||
|
assert move_upid.startswith("UPID:pve1:")
|
||||||
|
assert tasks[-1]["task_type"] == "qemu-move-disk"
|
||||||
|
|
||||||
with pytest.raises(ApiError) as same_node:
|
with pytest.raises(ApiError) as same_node:
|
||||||
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
|
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ class CrudConnection:
|
|||||||
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:
|
if "SELECT state FROM resources" in sql:
|
||||||
return {"state": '{"status":"stopped","name":"old"}'}
|
return {"state": '{"status":"stopped","name":"old"}'}
|
||||||
|
if "SELECT config FROM virtual_machines" in sql:
|
||||||
|
return {"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=10G"}'}
|
||||||
if "FROM snapshots" in sql:
|
if "FROM snapshots" in sql:
|
||||||
return {
|
return {
|
||||||
"state": (
|
"state": (
|
||||||
@@ -255,9 +257,28 @@ async def test_qemu_worker_clone_and_migrate_are_persistent() -> None:
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
moved = await handler(
|
||||||
|
Task(
|
||||||
|
uuid.uuid4(),
|
||||||
|
"UPID:move",
|
||||||
|
"qemu-move-disk",
|
||||||
|
"running",
|
||||||
|
{
|
||||||
|
"resource_id": str(resource_id),
|
||||||
|
"disk": "scsi0",
|
||||||
|
"target_disk": "scsi0",
|
||||||
|
"storage": "local",
|
||||||
|
"delete": True,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
False,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert cloned == {"vmid": 151, "node": "pve1"}
|
assert cloned == {"vmid": 151, "node": "pve1"}
|
||||||
assert migrated == {"node": "pve2", "status": "stopped"}
|
assert migrated == {"node": "pve2", "status": "stopped"}
|
||||||
|
assert moved == {"disk": "scsi0", "storage": "local"}
|
||||||
commands = repository.connection.commands
|
commands = repository.connection.commands
|
||||||
assert any("INSERT INTO resources" in command for command in commands)
|
assert any("INSERT INTO resources" in command for command in commands)
|
||||||
assert any("node_id=$2" in command for command in commands)
|
assert any("node_id=$2" in command for command in commands)
|
||||||
|
|||||||
Reference in New Issue
Block a user