feat: implement QEMU disk resize and move
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
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())))
|
||||
|
||||
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] = {}
|
||||
parsed: dict[str, Any] = {}
|
||||
for name, definition in definitions.items():
|
||||
if "[n]" in name:
|
||||
continue
|
||||
if name not in supplied:
|
||||
if definition.optional:
|
||||
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)
|
||||
except (TypeError, ValueError) as exc:
|
||||
errors[name] = str(exc)
|
||||
indexed_names: set[str] = set()
|
||||
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:
|
||||
errors[name] = "property is not defined in schema"
|
||||
if errors:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
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]]:
|
||||
tasks = await TaskRepository(_database(request).pool).list_for_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}/migrate", "GET", migrate_preconditions)
|
||||
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/{upid}/status", "GET", task_status)
|
||||
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
|
||||
@@ -473,6 +531,7 @@ async def _create_task(
|
||||
"qemu-snapshot-rollback": "qmrollback",
|
||||
"qemu-clone": "qmclone",
|
||||
"qemu-migrate": "qmigrate",
|
||||
"qemu-move-disk": "qmmove",
|
||||
}[task_type]
|
||||
upid = str(Upid(node, pid, pid, timestamp, worker_type, vmid, str(request.state.principal)))
|
||||
try:
|
||||
@@ -514,3 +573,36 @@ async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
|
||||
if row is None:
|
||||
raise ApiError(404, "snapshot does not exist")
|
||||
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-shutdown": handler,
|
||||
"qemu-migrate": handler,
|
||||
"qemu-move-disk": handler,
|
||||
"qemu-snapshot-create": handler,
|
||||
"qemu-snapshot-delete": handler,
|
||||
"qemu-snapshot-rollback": handler,
|
||||
|
||||
@@ -31,6 +31,8 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
|
||||
)
|
||||
if operation == "migrate":
|
||||
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:
|
||||
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
|
||||
if row is None:
|
||||
@@ -278,5 +280,49 @@ async def _migrate(
|
||||
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]:
|
||||
return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value))
|
||||
|
||||
Reference in New Issue
Block a user