Files
proxmox-api-simulator/app/handlers/storage.py
T
Sergey Antropoff 777926487b Add a stateful Proxmox API console and broad handler coverage beyond the
initial QEMU slice, backed by imported contracts for majors 6–9.
- Implement durable handlers for access/auth, cluster, LXC, storage, HA,
  firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops
- Serve an interactive Web UI with catalog browsing, demo seed controls,
  and OpenAPI/help surfaces
- Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3
- Support in-memory runtime contract Apply (POST /ui/api/contract/apply)
  so /version and /api2 routes follow the selected major until restart
- Expand seed profiles (including demo-cluster), migrations 007–008, TLS
  gateway config, Compose/Makefile tooling, and compatibility evidence
- Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
2026-07-16 01:08:01 +03:00

577 lines
25 KiB
Python

"""Storage semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, storage_payload, subdirs, values
from app.simulation.seed import CLUSTER_ID, stable_id
def register_storage_handlers(registry: HandlerRegistry) -> None:
async def _storage_row(request: Request, node: str | None, storage_id: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT s.storage_id, s.storage_type, s.shared, s.capacity_bytes, s.used_bytes,
s.config, n.name AS node_name
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE s.storage_id=$1 AND ($2::text IS NULL OR n.name=$2)""",
storage_id,
node,
)
if row is None:
raise ApiError(404, "storage does not exist")
return row
async def storage_ids(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
rows = await database(_request).pool.fetch(
"SELECT DISTINCT storage_id FROM storages ORDER BY storage_id"
)
return [{"storage": str(row["storage_id"])} for row in rows]
async def storage_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
storage_id = str(payload["storage"])
storage_type = str(payload.get("type") or "dir")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM storages WHERE storage_id=$1)",
storage_id,
)
if exists:
raise ApiError(409, "storage ID already exists")
node = await database(request).pool.fetchrow(
"SELECT id, name FROM nodes ORDER BY name LIMIT 1"
)
if node is None:
raise ApiError(503, "no nodes available")
resource_id = stable_id(f"storage:{storage_id}")
config = {
key: value
for key, value in payload.items()
if key not in {"storage", "type", "nodes", "delete"}
}
if "content" in payload:
config["content"] = [
item.strip() for item in str(payload["content"]).split(",") if item.strip()
]
async with database(request).pool.acquire() as connection:
async with connection.transaction():
await connection.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state, cluster_id)
VALUES($1, $2, 'storage', $3, $4::jsonb, $5)""",
resource_id,
node["id"],
storage_id,
json.dumps({**config, "status": "available"}, sort_keys=True),
CLUSTER_ID,
)
await connection.execute(
"""INSERT INTO storages(
resource_id, cluster_id, storage_id, storage_type, shared, config
) VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
resource_id,
CLUSTER_ID,
storage_id,
storage_type,
bool(payload.get("shared", False)),
json.dumps(config, sort_keys=True),
)
return {"storage": storage_id, "type": storage_type, "config": config}
async def storage_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
storage_id = str(values(inputs)["storage"])
row = await _storage_row(request, None, storage_id)
config = state(row["config"])
return {
"storage": storage_id,
"type": str(row["storage_type"]),
"shared": int(bool(row["shared"])),
**config,
}
async def storage_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
storage_id = str(values(inputs)["storage"])
row = await database(request).pool.fetchrow(
"""SELECT s.resource_id, s.config FROM storages s WHERE s.storage_id=$1""",
storage_id,
)
if row is None:
raise ApiError(404, "storage does not exist")
current = state(row["config"])
provided = values(inputs)
updated = {
**current,
**{
key: value
for key, value in provided.items()
if key not in {"storage", "delete", "digest"}
},
}
await database(request).pool.execute(
"UPDATE storages SET config=$2::jsonb WHERE storage_id=$1",
storage_id,
json.dumps(updated, sort_keys=True),
)
return updated
async def storage_delete(request: Request, inputs: dict[str, Any]) -> None:
storage_id = str(values(inputs)["storage"])
status = await database(request).pool.execute(
"""DELETE FROM resources r USING storages s
WHERE s.resource_id=r.id AND s.storage_id=$1""",
storage_id,
)
if status != "DELETE 1":
raise ApiError(404, "storage does not exist")
async def node_storage_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
rows = await database(request).pool.fetch(
"""SELECT s.storage_id, s.storage_type, s.shared,
s.capacity_bytes, s.used_bytes, s.config
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE n.name=$1 OR s.shared = true
ORDER BY s.storage_id""",
node,
)
return [storage_payload(row) for row in rows]
async def node_storage_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
return subdirs("content", "status", "upload")
async def node_storage_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
row = await _storage_row(request, None, storage_id)
return storage_payload(row)
async def node_storage_content(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
contents = await database(request).pool.fetch(
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
FROM storage_contents WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
resource_id,
)
backups = await database(request).pool.fetch(
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.storage_resource_id=$1
ORDER BY b.created_at DESC""",
resource_id,
)
result: list[dict[str, Any]] = []
for item in contents:
metadata = state(item["metadata"])
result.append(
{
"volid": str(item["volume_id"]),
"content": str(item["content_type"]),
"size": int(item["size_bytes"]),
"format": metadata.get("format", "raw"),
"ctime": int(item["created_at"].timestamp()),
}
)
for item in backups:
metadata = state(item["metadata"])
result.append(
{
"volid": str(item["volume_id"]),
"content": "backup",
"size": int(item["size_bytes"]),
"format": "vma.zst",
"vmid": int(item["vmid"]) if item["vmid"] is not None else None,
"notes": metadata.get("notes-template"),
"ctime": int(item["created_at"].timestamp()),
}
)
return result
async def _content_item(
request: Request, storage_resource_id: object, volume_id: str
) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
storage_resource_id,
volume_id,
)
if row is not None:
metadata = state(row["metadata"])
return {
"volid": str(row["volume_id"]),
"content": str(row["content_type"]),
"size": int(row["size_bytes"]),
"format": metadata.get("format", "raw"),
"ctime": int(row["created_at"].timestamp()),
}
backup = await database(request).pool.fetchrow(
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.storage_resource_id=$1 AND b.volume_id=$2""",
storage_resource_id,
volume_id,
)
if backup is None:
raise ApiError(404, "volume does not exist")
metadata = state(backup["metadata"])
return {
"volid": str(backup["volume_id"]),
"content": "backup",
"size": int(backup["size_bytes"]),
"format": "vma.zst",
"vmid": int(backup["vmid"]) if backup["vmid"] is not None else None,
"notes": metadata.get("notes-template"),
"ctime": int(backup["created_at"].timestamp()),
}
async def node_storage_content_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
volume_id = str(values(inputs)["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
return await _content_item(request, resource_id, volume_id)
async def node_storage_content_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
volume_id = str(values(inputs)["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
status = await database(request).pool.execute(
"DELETE FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2",
resource_id,
volume_id,
)
if status == "DELETE 1":
return
status = await database(request).pool.execute(
"DELETE FROM backups WHERE storage_resource_id=$1 AND volume_id=$2",
resource_id,
volume_id,
)
if status != "DELETE 1":
raise ApiError(404, "volume does not exist")
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
payload = values(inputs)
filename = str(payload.get("filename") or "upload.bin")
content_type = str(payload.get("content") or "iso")
raw_size = payload.get("size") or 0
try:
size = int(raw_size)
except (TypeError, ValueError) as error:
raise ApiError(400, "invalid size") from error
volume_id = f"{storage_id}:{content_type}/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET size_bytes=EXCLUDED.size_bytes,
content_type=EXCLUDED.content_type,
metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
content_type,
size,
json.dumps({"filename": filename, "source": "upload"}, sort_keys=True),
)
return {"uploadid": volume_id, "filename": filename, "size": size, "volid": volume_id}
async def node_storage_prunebackups(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]] | None:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
if request.method == "DELETE":
keep = int(values(inputs).get("keep-last") or values(inputs).get("keep_last") or 1)
await database(request).pool.execute(
"""DELETE FROM backups
WHERE storage_resource_id=$1 AND id IN (
SELECT id FROM backups
WHERE storage_resource_id=$1
ORDER BY created_at DESC
OFFSET $2
)""",
resource_id,
keep,
)
return None
rows = await database(request).pool.fetch(
"""SELECT volume_id, size_bytes, created_at FROM backups
WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
resource_id,
)
return [
{
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"ctime": int(row["created_at"].timestamp()),
}
for row in rows
]
async def content_copy(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
row = await database(request).pool.fetchrow(
"""SELECT volume_id, content_type, size_bytes, metadata
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
)
if row is None:
raise ApiError(404, "volume does not exist")
target = str(payload.get("target") or f"{volume}-copy")
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET size_bytes=EXCLUDED.size_bytes, metadata=EXCLUDED.metadata""",
resource_id,
target,
row["content_type"],
row["size_bytes"],
json.dumps({**state(row["metadata"]), "copied_from": volume}, sort_keys=True),
)
return f"UPID:{node}:copy:{target}"
async def content_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await require_node(request, node)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
row = await database(request).pool.fetchrow(
"""SELECT metadata FROM storage_contents
WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
)
if row is None:
raise ApiError(404, "volume does not exist")
meta = state(row["metadata"])
if "notes" in payload:
meta["notes"] = payload["notes"]
if "protected" in payload:
meta["protected"] = int(bool(payload["protected"]))
await database(request).pool.execute(
"""UPDATE storage_contents SET metadata=$3::jsonb
WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
json.dumps(meta, sort_keys=True),
)
async def download_url(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
filename = str(payload.get("filename") or "download.bin")
content_type = str(payload.get("content") or "iso")
volume_id = f"{storage_id}:{content_type}/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, 0, $4::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
content_type,
json.dumps(
{
"filename": filename,
"url": payload.get("url"),
"source": "download-url",
},
sort_keys=True,
),
)
return f"UPID:{node}:download:{filename}"
async def oci_pull(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
reference = str(payload.get("reference") or "image:latest")
filename = str(payload.get("filename") or reference.replace("/", "_"))
volume_id = f"{storage_id}:import/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, 'import', 0, $3::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
json.dumps({"reference": reference, "source": "oci"}, sort_keys=True),
)
return f"UPID:{node}:oci-pull:{filename}"
async def file_restore_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
filepath = str(payload.get("filepath") or "/")
return [{"filepath": filepath.rstrip("/") + "/etc", "type": "d", "text": "etc"}]
async def file_restore_download(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return {
"download-url": f"/api2/json/nodes/{payload['node']}/storage/"
f"{payload['storage']}/file-restore/download",
"filepath": payload.get("filepath") or "/",
"volume": payload.get("volume"),
}
async def storage_identity(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
row = await _storage_row(request, None, str(payload["storage"]))
return {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"fingerprint": f"sim-{row['storage_id']}",
}
async def import_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await _storage_row(request, None, storage_id)
return {
"type": "qemu",
"source": volume,
"disks": {"scsi0": f"{storage_id}:0/vm-import.raw"},
"net0": "virtio,bridge=vmbr0",
}
async def storage_rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
await _storage_row(request, None, storage_id)
return {"filename": f"pve-storage-{storage_id}.rrd"}
async def storage_rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return [
{"time": 1_700_000_000, "used": 10, "total": 100},
{"time": 1_700_000_060, "used": 12, "total": 100},
]
registry.register("/storage", "GET", storage_ids)
registry.register("/storage", "POST", storage_create)
registry.register("/storage/{storage}", "GET", storage_get)
registry.register("/storage/{storage}", "PUT", storage_update)
registry.register("/storage/{storage}", "DELETE", storage_delete)
registry.register("/nodes/{node}/storage", "GET", node_storage_list)
registry.register("/nodes/{node}/storage/{storage}", "GET", node_storage_index)
registry.register("/nodes/{node}/storage/{storage}/status", "GET", node_storage_status)
registry.register("/nodes/{node}/storage/{storage}/content", "GET", node_storage_content)
registry.register("/nodes/{node}/storage/{storage}/content", "POST", node_storage_upload)
registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "GET", node_storage_content_get
)
registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "DELETE", node_storage_content_delete
)
registry.register("/nodes/{node}/storage/{storage}/upload", "POST", node_storage_upload)
registry.register(
"/nodes/{node}/storage/{storage}/prunebackups", "GET", node_storage_prunebackups
)
registry.register(
"/nodes/{node}/storage/{storage}/prunebackups", "DELETE", node_storage_prunebackups
)
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "POST", content_copy)
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "PUT", content_update)
registry.register("/nodes/{node}/storage/{storage}/download-url", "POST", download_url)
registry.register("/nodes/{node}/storage/{storage}/oci-registry-pull", "POST", oci_pull)
registry.register("/nodes/{node}/storage/{storage}/file-restore/list", "GET", file_restore_list)
registry.register(
"/nodes/{node}/storage/{storage}/file-restore/download", "GET", file_restore_download
)
registry.register("/nodes/{node}/storage/{storage}/identity", "GET", storage_identity)
registry.register("/nodes/{node}/storage/{storage}/import-metadata", "GET", import_metadata)
registry.register("/nodes/{node}/storage/{storage}/rrd", "GET", storage_rrd)
registry.register("/nodes/{node}/storage/{storage}/rrddata", "GET", storage_rrddata)