Align sized cluster seeds and GET dumps with PVE wire shapes; restyle DATA panel.

- Scale small/large/big seeds (3×50 / 10×1000 / 20×2000) with proportional
  backups, snapshots, HA, replication, Ceph capacity, and OSD totals
  (10 / 100 / 500) plus matching node disks and crush/pg metadata
- Enrich handler responses for apt, certificates, qemu/lxc status, storage,
  SDN, metrics export, and related cluster/node dumps
- Flatten nested body_example fields into PARAMS and sync the request body
  via dotted paths (oVirt-style)
- Restyle DATA controls as size cards with full-width Reset to minimal /
  Refresh stats; unload reloads the minimal cluster
This commit is contained in:
Sergey Antropoff
2026-07-18 08:46:11 +03:00
parent 48df10b17e
commit 0773f721ea
77 changed files with 4870 additions and 855 deletions
+126
View File
@@ -0,0 +1,126 @@
"""Cluster backup job handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.backup import register_backup_handlers
class BackupPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {
"backup_jobs": {
"backup-daily": {
"id": "backup-daily",
"schedule": "0 2 * * *",
"storage": "local",
"enabled": 1,
"vmid": "100",
}
}
}
self.guests = [
{
"external_id": "100",
"kind": "qemu",
"state": '{"name":"demo"}',
"qemu_config": '{"scsi0":"local-lvm:vm-100-disk-0,size=32G"}',
"lxc_config": None,
},
{
"external_id": "200",
"kind": "lxc",
"state": '{"name":"service"}',
"qemu_config": None,
"lxc_config": '{"rootfs":"local-lvm:vm-200-disk-0,size=8G"}',
},
]
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
if "FROM resources r" in query and "ANY($1" in query:
selected = set(str(item) for item in cast(list[Any], arguments[0]))
return [row for row in self.guests if row["external_id"] in selected]
if "FROM resources r" in query:
return list(self.guests)
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
def _request(pool: BackupPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_backup_jobs_crud_and_shapes() -> None:
registry = HandlerRegistry()
register_backup_handlers(registry)
pool = BackupPool()
http = _request(pool)
listed = await registry.get("/cluster/backup", "GET")(http, {"values": {}})
assert listed[0]["id"] == "backup-daily"
assert listed[0]["schedule"] == "0 2 * * *"
await registry.get("/cluster/backup", "POST")(
http,
{
"values": {
"id": "backup-weekly",
"schedule": "@weekly",
"storage": "local",
"vmid": "200",
}
},
)
assert "backup-weekly" in pool.metadata["backup_jobs"]
info = await registry.get("/cluster/backup-info", "GET")(http, {"values": {}})
assert info == [{"subdir": "not-backed-up"}]
missing = await registry.get("/cluster/backup-info/not-backed-up", "GET")(http, {"values": {}})
assert missing == [] # 100 covered by daily; after weekly both covered? weekly adds 200
# daily covers 100, weekly covers 200 → none missing
assert missing == []
included = await registry.get("/cluster/backup/{id}/included_volumes", "GET")(
http, {"values": {"id": "backup-daily"}}
)
assert included["children"][0]["id"] == 100
assert included["children"][0]["children"][0]["id"] == "scsi0"
await registry.get("/cluster/backup/{id}", "DELETE")(http, {"values": {"id": "backup-weekly"}})
assert "backup-weekly" not in pool.metadata["backup_jobs"]
missing_after = await registry.get("/cluster/backup-info/not-backed-up", "GET")(
http, {"values": {}}
)
assert missing_after[0]["vmid"] == 200
assert missing_after[0]["type"] == "lxc"
+1 -1
View File
@@ -125,7 +125,7 @@ async def test_ceph_pool_and_osd_mutations_persist() -> None:
await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()})
pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()})
assert any(item["pool"] == "vms" for item in pools)
assert any(item.get("pool_name") == "vms" for item in pools)
assert "vms" in pool.cluster_metadata["ceph"]["pools"]
await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()})
+217 -5
View File
@@ -6,8 +6,10 @@ import json
from typing import Any, cast
from uuid import uuid4
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.acme import register_acme_handlers
@@ -34,6 +36,8 @@ class MetaPool:
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return sorted(self.nodes)[0] if self.nodes else None
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
@@ -52,6 +56,16 @@ class MetaPool:
raise AssertionError(query)
class FakeTaskRepository:
def __init__(self, pool: MetaPool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type("Task", (), {"upid": kwargs["upid"]})()
async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any:
@@ -63,7 +77,7 @@ async def call(
def request(pool: MetaPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
http = Request(
{
"type": "http",
"app": app,
@@ -76,15 +90,19 @@ def request(pool: MetaPool) -> Request:
"scheme": "http",
}
)
http.state.principal = "root@pam"
return http
async def test_mapping_acme_config_persist() -> None:
async def test_mapping_acme_config_persist(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_mapping_handlers(registry)
register_acme_handlers(registry)
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.cluster_config.TaskRepository", lambda _pool: repository)
await call(
registry,
@@ -119,20 +137,214 @@ async def test_mapping_acme_config_persist() -> None:
http,
{"values": {"name": "default"}, "provided": frozenset()},
)
assert account["name"] == "default"
assert account["account"]["name"] == "default"
assert account["directory"]
assert "eab-hmac-key" not in account
assert "eab-hmac-key" not in account["account"]
await call(
upid = await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab"}, "provided": frozenset()},
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
assert isinstance(upid, str)
assert upid.startswith("UPID:pve1:")
assert ":clustercreate:" in upid
assert repository.created[0]["task_type"] == "cluster-create"
assert pool.metadata["cluster_config"]["clustername"] == "lab"
assert pool.cluster_name == "lab"
assert pool.metadata["cluster_config"]["corosync_conf"]
assert pool.metadata["cluster_config"]["config_digest"]
totem = await call(
registry, "/cluster/config/totem", "GET", http, {"values": {}, "provided": frozenset()}
)
assert totem["cluster_name"] == "lab"
assert uuid4()
async def test_cluster_config_index_uses_name_links() -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
index = await call(
registry, "/cluster/config", "GET", request(pool), {"values": {}, "provided": frozenset()}
)
assert index == [
{"name": "nodes"},
{"name": "totem"},
{"name": "join"},
{"name": "qdevice"},
{"name": "apiversion"},
]
async def test_cluster_join_info_shape_and_stable_digest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
monkeypatch.setattr(
"app.handlers.cluster_config.TaskRepository",
lambda _pool: FakeTaskRepository(pool),
)
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
first = await call(
registry,
"/cluster/config/join",
"GET",
http,
{"values": {}, "provided": frozenset()},
)
second = await call(
registry,
"/cluster/config/join",
"GET",
http,
{"values": {"node": "pve1"}, "provided": frozenset()},
)
assert set(first) == {"nodelist", "preferred_node", "totem", "config_digest"}
assert first["config_digest"] == second["config_digest"]
assert first["preferred_node"] == "pve1"
defaulted = await call(
registry,
"/cluster/config/join",
"GET",
http,
{
"values": {"node": "current connected node"},
"provided": frozenset(),
},
)
assert defaulted["preferred_node"] == "pve1"
node = first["nodelist"][0]
assert node["name"] == "pve1"
assert node["pve_addr"]
assert node["ring0_addr"]
assert node["pve_fp"].count(":") == 31
assert "password" not in json.dumps(first)
async def test_cluster_join_returns_upid(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.cluster_config.TaskRepository", lambda _pool: repository)
fingerprint = ":".join(["AB"] * 32)
upid = await call(
registry,
"/cluster/config/join",
"POST",
http,
{
"values": {
"hostname": "10.0.0.1",
"fingerprint": fingerprint,
"password": "secret",
},
"provided": frozenset(),
},
)
assert upid.startswith("UPID:")
assert ":clusterjoin:" in upid
assert repository.created[0]["task_type"] == "cluster-join"
join = pool.metadata["cluster_config"]["join_info"]["10.0.0.1"]
assert join["password_set"] is True
assert "password" not in join
async def test_cluster_join_requires_contract_params() -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
with pytest.raises(ApiError, match="hostname"):
await call(
registry,
"/cluster/config/join",
"POST",
http,
{"values": {"password": "x", "fingerprint": "y"}, "provided": frozenset()},
)
async def test_cluster_create_rejects_existing_corosync(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
monkeypatch.setattr(
"app.handlers.cluster_config.TaskRepository",
lambda _pool: FakeTaskRepository(pool),
)
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
with pytest.raises(ApiError, match="cluster config already exists"):
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "other"}, "provided": frozenset()},
)
async def test_cluster_addnode_returns_corosync_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
monkeypatch.setattr(
"app.handlers.cluster_config.TaskRepository",
lambda _pool: FakeTaskRepository(pool),
)
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
result = await call(
registry,
"/cluster/config/nodes/{node}",
"POST",
http,
{
"values": {"node": "pve2", "new_node_ip": "10.0.0.2", "votes": 1},
"provided": frozenset(),
},
)
assert set(result) == {"corosync_authkey", "corosync_conf", "warnings"}
assert "nodelist" in result["corosync_conf"]
assert "pve2" in result["corosync_conf"]
assert result["warnings"] == []
assert "pve2" in pool.nodes
nodes = await call(
registry,
"/cluster/config/nodes",
"GET",
http,
{"values": {}, "provided": frozenset()},
)
assert {item["node"] for item in nodes} == {"pve1", "pve2"}
+68
View File
@@ -88,6 +88,74 @@ def test_method_payload_builds_examples() -> None:
assert payload["resolved_path"] == "/nodes/pve01/qemu"
assert payload["body_example"] == {"vmid": 100, "name": "example"}
assert payload["implemented"] is True
indexed = cast(list[dict[str, Any]], payload["indexed_fields"])
assert indexed[0]["name"] == "scsi0"
assert indexed[0]["template"] == "scsi[n]"
assert indexed[0]["optional"] is True
def test_method_payload_flattens_nested_body_example_into_params() -> None:
method = Method(
verb="POST",
name="create_mapping",
description="Create mapping.",
parameters=(
Parameter(name="id", definition=Schema(type="string")),
Parameter(
name="map",
definition=Schema(
type="array",
items=Schema(type="string"),
),
),
Parameter(name="comment", definition=Schema(type="string", optional=True)),
),
returns=Schema(type="null"),
checksum="c" * 64,
)
snapshot = Snapshot(
source_version="9.2.3",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="d" * 64,
paths=(PathContract(path="/cluster/mapping/dir", methods=(method,)),),
path_count=1,
method_count=1,
)
payload = method_payload(
snapshot,
major=9,
path="/cluster/mapping/dir",
verb="POST",
runtime_version="9.2.3",
implemented_methods=None,
)
assert payload["body_example"] == {"id": "example", "map": ["example"]}
names = [str(item["name"]) for item in cast(list[dict[str, Any]], payload["body_fields"])]
assert "id" in names
assert "map.0" in names
assert "map" not in names
assert "comment" in names
@pytest.mark.asyncio
async def test_method_payload_property_string_examples_from_bundled_contract() -> None:
from app.web import contract_catalog
contract_catalog._SNAPSHOT_CACHE.clear()
snapshot = await contract_catalog.load_snapshot(8, Path("contracts"))
payload = method_payload(
snapshot,
major=8,
path="/cluster/config",
verb="POST",
runtime_version="8.4.5",
implemented_methods=None,
)
assert payload["body_example"] == {"clustername": "example"}
indexed = cast(list[dict[str, Any]], payload["indexed_fields"])
link0 = next(item for item in indexed if item["name"] == "link0")
assert link0["example"] == "192.168.0.1"
assert link0["typetext"] == "[address=]<IP> [,priority=<integer>]"
@pytest.mark.asyncio
+28 -12
View File
@@ -39,20 +39,33 @@ class FakePool:
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM pool_members" in sql or "kind='ha'" in sql:
return []
if "FROM nodes" in sql:
return [{"node": "pve1", "status": "online"}]
return [{"node": "pve1", "name": "pve1", "status": "online"}]
if "r.kind='qemu'" in sql:
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}]
return [
{
"type": "qemu",
"external_id": "100",
"state": '{"status":"stopped"}',
"node": "pve1",
}
]
return [
{
"vmid": 100,
"state": '{"name":"demo","status":"stopped"}',
"config": '{"name":"demo","memory":2048,"cores":2}',
}
]
if "r.kind = ANY" in sql or "r.kind AS type" in sql:
return [
{
"type": "qemu",
"external_id": "100",
"state": '{"status":"stopped","name":"demo"}',
"node": "pve1",
}
]
return []
async def fetchval(self, sql: str) -> int:
async def fetchval(self, sql: str, *args: object) -> object:
del args
if "SELECT name FROM clusters" in sql:
return "pve-simulator"
return 100 if "pg_backend_pid" in sql else 1_700_000_000
@@ -194,10 +207,13 @@ async def test_core_login_and_read_endpoints(
assert login.status_code == 200
assert login.json()["data"]["username"] == "root@pam"
assert "ticket" in login.json()["data"]
assert login.json()["data"]["clustername"] == "pve-simulator"
assert version.json()["data"]["version"] == "test"
assert version.json()["data"]["release"] == "test"
assert nodes.json()["data"][0]["node"] == "pve1"
assert status.json()["data"]["status"] == "online"
assert "uptime" in status.json()["data"]
assert "memory" in status.json()["data"]
assert "cpuinfo" in status.json()["data"]
resource_types = {item["type"] for item in resources.json()["data"]}
assert "qemu" in resource_types
qemu_resources = [item for item in resources.json()["data"] if item["type"] == "qemu"]
+20 -4
View File
@@ -42,6 +42,7 @@ class HandlerPool:
{
"external_id": "osd.0",
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}',
"node": "pve01",
}
]
if "FROM pools" in sql:
@@ -54,7 +55,14 @@ class HandlerPool:
}
]
if "FROM pool_members" in sql:
return [{"external_id": "100"}]
return [
{
"external_id": "100",
"kind": "qemu",
"state": '{"name":"demo","status":"stopped"}',
"node": "pve01",
}
]
if "FROM task_logs" in sql:
return [{"message": "seeded task", "sequence": 1}]
if "FROM tasks" in sql:
@@ -101,6 +109,8 @@ class HandlerPool:
return 150
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql:
return 300
if "COUNT(*) FROM nodes" in sql or "count(*) FROM nodes" in sql:
return 1
if "SELECT resource_id FROM storages" in sql:
return uuid.uuid4()
return False
@@ -139,7 +149,10 @@ async def test_cluster_status_and_nextid() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
status = await _call(registry.get("/cluster/status", "GET"), {})
assert status[0]["name"] == "pve01"
assert status[0]["type"] == "cluster"
assert status[0]["quorate"] is True
assert status[1]["name"] == "pve01"
assert status[1]["online"] is True
nextid = await _call(registry.get("/cluster/nextid", "GET"), {})
assert nextid == 151
@@ -158,7 +171,8 @@ async def test_storage_and_ceph_handlers() -> None:
registry.get("/nodes/{node}/ceph/osd", "GET"),
{"node": "pve01"},
)
assert osds[0]["status"] == "up"
assert osds["root"]["type"] == "root"
assert osds["root"]["children"][0]["children"][0]["status"] == "up"
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
assert ceph_status["osdmap"]["num_osds"] == 1
@@ -169,7 +183,9 @@ async def test_pools_list() -> None:
register_pool_handlers(registry)
pools = await _call(registry.get("/pools", "GET"), {})
assert pools[0]["poolid"] == "production"
assert pools[0]["members"] == ["100"]
assert pools[0]["members"][0]["vmid"] == 100
assert pools[0]["members"][0]["type"] == "qemu"
assert pools[0]["members"][0]["node"] == "pve01"
@pytest.mark.asyncio
+23 -4
View File
@@ -32,7 +32,6 @@ class GapPool:
self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}}
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql and "ORDER BY name" in sql:
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
if "FROM tasks" in sql:
@@ -64,6 +63,17 @@ class GapPool:
}
for group_id, data in self.groups.items()
]
if "FROM identity_group_members" in sql:
userid = str(args[0]) if args else ""
return [
{"group_id": group_id}
for group_id, data in self.groups.items()
if userid in data["users"]
]
if "FROM api_tokens" in sql:
return []
if "FROM resources r JOIN nodes" in sql and "kind='ha'" in sql:
return []
raise AssertionError(sql)
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
@@ -149,6 +159,8 @@ class GapPool:
return json.dumps(self.node_metadata.get(str(args[0]), {}))
if "SELECT name FROM nodes WHERE status" in sql:
return "pve01"
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in sql:
return "pve01"
return False
async def execute(self, sql: str, *args: object) -> str:
@@ -217,7 +229,7 @@ async def test_cluster_index_and_replication_crud() -> None:
)
assert created["id"] == "repl-100"
jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool)
assert jobs[0]["guest"] == "100"
assert jobs[0]["guest"] == 100
fetched = await _call(
registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool
)
@@ -257,10 +269,16 @@ async def test_access_user_and_group_detail() -> None:
@pytest.mark.asyncio
async def test_storage_content_get_and_upload() -> None:
async def test_storage_content_get_and_upload(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
pool = GapPool()
class FakeTaskRepository:
async def create(self, **kwargs: Any) -> Any:
return type("Task", (), {"upid": kwargs["upid"]})()
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: FakeTaskRepository())
item = await _call(
registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"),
{
@@ -276,7 +294,8 @@ async def test_storage_content_get_and_upload() -> None:
{"node": "pve01", "storage": "local-lvm", "filename": "image.iso"},
pool,
)
assert "uploadid" in upload
assert isinstance(upload, str) and upload.startswith("UPID:")
assert any("image.iso" in str(item["volume_id"]) for item in pool.storage_contents)
@pytest.mark.asyncio
+11 -2
View File
@@ -28,8 +28,11 @@ class GapRemainingPool:
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
del arguments
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return "pve01"
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
@@ -68,10 +71,16 @@ def _request(pool: GapRemainingPool, *, method: str = "GET") -> Request:
@pytest.mark.asyncio
async def test_disks_directory_create_persists() -> None:
async def test_disks_directory_create_persists(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_nodes_extra_handlers(registry)
pool = GapRemainingPool()
class FakeTaskRepository:
async def create(self, **kwargs: Any) -> Any:
return type("Task", (), {"upid": kwargs["upid"]})()
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: FakeTaskRepository())
create = registry.get("/nodes/{node}/disks/directory", "POST")
assert create is not None
created = await create(
@@ -81,7 +90,7 @@ async def test_disks_directory_create_persists() -> None:
"provided": frozenset(),
},
)
assert created["name"] == "tank"
assert isinstance(created, str) and created.startswith("UPID:")
ops = pool.node_metadata["pve01"]["ops"]
assert any(item["name"] == "tank" for item in ops["disks"]["directory"])
+13 -2
View File
@@ -37,7 +37,13 @@ class LxcPool:
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM resources" in sql and "kind='lxc'" in sql:
return [{"vmid": 200, "state": '{"status":"stopped","name":"service"}'}]
return [
{
"vmid": 200,
"state": '{"status":"stopped","name":"service"}',
"config": '{"hostname":"service","memory":512,"cores":1}',
}
]
assert "FROM snapshots" in sql
return [
{
@@ -118,7 +124,12 @@ async def test_lxc_list_returns_seeded_containers(registry: HandlerRegistry) ->
handler = registry.get("/nodes/{node}/lxc", "GET")
assert handler is not None
result = await handler(_request(pool), {"values": {"node": "pve1"}})
assert result == [{"vmid": 200, "status": "stopped", "name": "service"}]
assert len(result) == 1
assert result[0]["vmid"] == 200
assert result[0]["status"] == "stopped"
assert result[0]["name"] == "service"
assert result[0]["cpus"] == 1
assert "maxmem" in result[0]
async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None:
+44 -6
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
@@ -17,13 +18,17 @@ class NodePool:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
del arguments
if "SELECT metadata FROM nodes" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
del arguments
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return "pve01"
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
@@ -33,6 +38,16 @@ class NodePool:
raise AssertionError(query)
class FakeTaskRepository:
def __init__(self, pool: NodePool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type("Task", (), {"upid": kwargs["upid"]})()
class FakeDatabase:
def __init__(self, pool: NodePool) -> None:
self.pool = pool
@@ -58,17 +73,29 @@ def request(pool: NodePool, *, method: str = "GET", path: str = "/") -> Request:
return result
async def test_network_and_service_mutations_persist() -> None:
@pytest.fixture
def task_repo(monkeypatch: pytest.MonkeyPatch) -> FakeTaskRepository:
repository = FakeTaskRepository(NodePool())
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: repository)
return repository
async def test_network_and_service_mutations_persist(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_node_ops_handlers(registry)
pool = NodePool()
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: repository)
create = registry.get("/nodes/{node}/network", "POST")
listing = registry.get("/nodes/{node}/network", "GET")
delete = registry.get("/nodes/{node}/network/{iface}", "DELETE")
reload_network = registry.get("/nodes/{node}/network", "PUT")
stop = registry.get("/nodes/{node}/services/{service}/stop", "POST")
state = registry.get("/nodes/{node}/services/{service}/state", "GET")
assert create and listing and delete and stop and state
assert create and listing and delete and reload_network and stop and state
await create(
request(pool, method="POST", path="/api2/json/nodes/pve01/network"),
@@ -90,10 +117,17 @@ async def test_network_and_service_mutations_persist() -> None:
)
assert all(item["iface"] != "vmbr9" for item in items)
await stop(
reload_upid = await reload_network(
request(pool, method="PUT", path="/api2/json/nodes/pve01/network"),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
assert isinstance(reload_upid, str) and reload_upid.startswith("UPID:")
stop_upid = await stop(
request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
)
assert isinstance(stop_upid, str) and stop_upid.startswith("UPID:")
service = await state(
request(pool),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
@@ -102,23 +136,27 @@ async def test_network_and_service_mutations_persist() -> None:
assert "ops" in pool.metadata
async def test_disk_init_and_wipe_persist() -> None:
async def test_disk_init_and_wipe_persist(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_node_ops_handlers(registry)
pool = NodePool()
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: repository)
initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST")
wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT")
listing = registry.get("/nodes/{node}/disks/list", "GET")
assert initgpt and wipe and listing
await initgpt(
init_upid = await initgpt(
request(pool, method="POST"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
)
await wipe(
wipe_upid = await wipe(
request(pool, method="PUT"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
)
assert init_upid.startswith("UPID:")
assert wipe_upid.startswith("UPID:")
disks = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
+11 -3
View File
@@ -41,7 +41,13 @@ class QemuPool:
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM resources" in sql:
return [{"vmid": 150, "state": '{"status":"stopped","name":"vm"}'}]
return [
{
"vmid": 150,
"state": '{"status":"stopped","name":"vm"}',
"config": '{"name":"vm","memory":2048,"cores":2}',
}
]
assert "FROM snapshots" in sql
return [
{
@@ -325,9 +331,11 @@ async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch)
)
assert migrate_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-migrate"
assert (
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None
resize_upid = await resize(
http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")
)
assert resize_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-resize"
move_upid = await move(
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
)
+58 -1
View File
@@ -1,6 +1,11 @@
"""Tests for contract example generation."""
from app.contracts.examples import path_param_example, schema_example
from app.contracts.examples import (
path_param_example,
property_string_example,
schema_example,
wire_param_name,
)
from app.contracts.model import Schema
@@ -9,6 +14,12 @@ def test_path_param_examples_use_known_placeholders() -> None:
assert path_param_example("vmid") == 100
def test_wire_param_name_expands_indexed() -> None:
assert wire_param_name("link[n]") == "link0"
assert wire_param_name("scsi[n]", index=1) == "scsi1"
assert wire_param_name("vmid") == "vmid"
def test_schema_example_prefers_default_and_enum() -> None:
assert schema_example(Schema(type="string", default="custom")) == "custom"
assert schema_example(Schema(type="string", enum=("a", "b"))) == "a"
@@ -23,3 +34,49 @@ def test_schema_example_builds_object_and_array() -> None:
},
)
assert schema_example(schema) == {"count": 2}
def test_schema_example_uses_named_proxmox_formats() -> None:
assert schema_example(Schema(type="string", format="pve-node"), name="node") == "pve01"
assert schema_example(Schema(type="string", format="pve-storage-id")) == "local"
assert schema_example(Schema(type="string", format="ip")) == "192.168.0.1"
# Parameter name hints win over the format token when both apply.
assert schema_example(Schema(type="string", format="pve-node"), name="clustername") == "example"
def test_property_string_example_uses_bare_default_key() -> None:
fmt = {
"address": {
"default_key": 1,
"format": "address",
"type": "string",
},
"priority": {
"default": 0,
"optional": 1,
"type": "integer",
},
}
assert property_string_example(fmt) == "192.168.0.1"
assert schema_example(Schema(type="string", format=fmt), name="link[n]") == "192.168.0.1"
def test_property_string_example_includes_required_keys() -> None:
fmt = {
"enable": {
"default_key": 1,
"default": "1",
"type": "boolean",
},
"burst": {
"default": 5,
"type": "integer",
"minimum": 0,
},
"rate": {
"default": "1/second",
"optional": 1,
"type": "string",
},
}
assert property_string_example(fmt) == "1,burst=5"
+34 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
@@ -17,6 +18,11 @@ class SdnPool:
self.metadata: dict[str, Any] = {}
self.nodes = {"pve1"}
async def fetch(self, query: str, *_arguments: object) -> list[dict[str, Any]]:
if "FROM nodes" in query:
return [{"name": name} for name in sorted(self.nodes)]
raise AssertionError(query)
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
@@ -25,6 +31,8 @@ class SdnPool:
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return sorted(self.nodes)[0]
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
@@ -34,6 +42,16 @@ class SdnPool:
raise AssertionError(query)
class FakeTaskRepository:
def __init__(self, pool: SdnPool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type("Task", (), {"upid": kwargs["upid"]})()
async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any:
@@ -45,7 +63,7 @@ async def call(
def request(pool: SdnPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
http = Request(
{
"type": "http",
"app": app,
@@ -58,13 +76,17 @@ def request(pool: SdnPool) -> Request:
"scheme": "http",
}
)
http.state.principal = "root@pam"
return http
async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
async def test_sdn_zone_vnet_subnet_and_node_views(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_sdn_handlers(registry)
pool = SdnPool()
http = request(pool)
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.cluster_extra.TaskRepository", lambda _pool: repository)
await call(
registry,
@@ -118,11 +140,20 @@ async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
)
assert node_zones[0]["zone"] == "localzone"
assert pool.metadata["sdn"]["pending"] is True
await call(
upid = await call(
registry,
"/cluster/sdn",
"PUT",
http,
{"values": {"release-lock": 1}, "provided": frozenset()},
)
assert isinstance(upid, str) and upid.startswith("UPID:")
assert pool.metadata["sdn"]["pending"] is False
lock_token = await call(
registry,
"/cluster/sdn/lock",
"POST",
http,
{"values": {}, "provided": frozenset()},
)
assert isinstance(lock_token, str) and len(lock_token) >= 8
+105 -10
View File
@@ -5,21 +5,23 @@ from typing import Any, cast
import pytest
from app.simulation.seed import (
big_profile,
build_profile,
clear_simulation_state,
cluster_domain_metadata,
default_node_ops_for_seed,
enrich_guest_state,
enrich_storage_state,
lab_profile,
large_profile,
small_profile,
stable_id,
)
def test_small_profile_matches_required_logical_shape() -> None:
first = small_profile()
second = small_profile()
def test_lab_profile_matches_required_logical_shape() -> None:
first = lab_profile()
second = lab_profile()
assert first == second
state = first.logical_state()
@@ -29,17 +31,54 @@ def test_small_profile_matches_required_logical_shape() -> None:
assert isinstance(resources, list)
assert [resource["kind"] for resource in resources].count("qemu") == 2
assert [resource["kind"] for resource in resources].count("lxc") == 1
assert [resource["kind"] for resource in resources].count("storage") == 2
assert [resource["kind"] for resource in resources].count("storage") == 3
assert [resource["kind"] for resource in resources].count("ceph-osd") == 3
assert [resource["kind"] for resource in resources].count("ha") == 1
tasks = state["tasks"]
assert isinstance(tasks, list)
assert len(tasks) == 2
def test_sized_cluster_profiles() -> None:
small = small_profile()
assert len(small.nodes) == 3
assert sum(resource.kind in {"qemu", "lxc"} for resource in small.resources) == 50
assert sum(resource.kind == "ceph-osd" for resource in small.resources) == 10
assert sum(resource.kind == "ha" for resource in small.resources) == 3
assert len(small.tasks) == 12
large = large_profile()
assert len(large.nodes) == 10
assert sum(resource.kind in {"qemu", "lxc"} for resource in large.resources) == 1_000
assert sum(resource.kind == "ceph-osd" for resource in large.resources) == 100
assert sum(resource.kind == "pool" for resource in large.resources) == 2
assert len(large.tasks) == 50
big = big_profile()
assert len(big.nodes) == 20
assert sum(resource.kind in {"qemu", "lxc"} for resource in big.resources) == 2_000
assert sum(resource.kind == "ceph-osd" for resource in big.resources) == 500
assert sum(resource.kind == "pool" for resource in big.resources) == 3
assert len(big.tasks) == 100
assert build_profile("big") == big
def test_sized_profiles_spread_osds_across_nodes() -> None:
for profile_name, expected in (("small", 10), ("large", 100), ("big", 500)):
profile = build_profile(profile_name)
names = {node.id: node.name for node in profile.nodes}
counts: dict[str, int] = {name: 0 for name in names.values()}
for resource in profile.resources:
if resource.kind == "ceph-osd":
counts[names[resource.node_id]] += 1
assert sum(counts.values()) == expected
assert max(counts.values()) - min(counts.values()) <= 1
def test_medium_and_fault_profiles_are_deterministic() -> None:
medium = build_profile("medium")
assert len(medium.nodes) == 3
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
assert sum(resource.kind in {"qemu", "lxc"} for resource in medium.resources) == 50
assert build_profile("ha-demo") == build_profile("ha-demo")
broken = build_profile("broken-storage")
assert any(resource.state.get("status") == "offline" for resource in broken.resources)
@@ -50,7 +89,10 @@ def test_large_profile_is_configurable_and_stable() -> None:
second = large_profile(node_count=4, resource_count=1_000)
assert first == second
assert len(first.nodes) == 4
assert len(first.resources) == 1_000
guests = [r for r in first.resources if r.kind in {"qemu", "lxc"}]
assert len(guests) == 1_000
assert any(r.kind == "storage" and r.external_id == "local" for r in first.resources)
assert any(r.kind == "ceph-osd" for r in first.resources)
def test_profile_validation() -> None:
@@ -108,7 +150,7 @@ def test_stable_ids_are_namespaced_and_repeatable() -> None:
def test_cluster_domain_metadata_seeds_list_domains() -> None:
meta = cast(dict[str, Any], cluster_domain_metadata(small_profile()))
meta = cast(dict[str, Any], cluster_domain_metadata(lab_profile()))
assert meta["firewall"]["scopes"]["cluster"]["rules"]
assert meta["firewall"]["scopes"]["cluster"]["aliases"]
assert meta["firewall"]["scopes"]["cluster"]["ipset"]
@@ -127,23 +169,36 @@ def test_cluster_domain_metadata_seeds_list_domains() -> None:
assert meta["notifications"]["matchers"]
assert meta["notifications"]["matcher_fields"]
assert meta["acme"]["accounts"]
assert meta["acme"]["accounts"]["letsencrypt"]["tos_url"]
assert meta["acme"]["plugins"]
assert meta["acme"]["directories"]
assert meta["acme"]["challenge_schema"]
assert meta["mapping"]["pci"]
assert meta["mapping"]["usb"]
assert meta["mapping"]["dir"]
assert meta["replication"]
# Single-node lab must not invent ghost peers.
assert meta["replication"] == []
assert "pve02" not in str(meta["sdn"]["controllers"])
assert meta["metrics"]["servers"]
assert meta["ha_groups"]
assert meta["ceph"]["pools"]
assert meta["ceph"]["flags"]
assert "noup" in meta["ceph"]["flags"]
assert meta["ceph"]["flags"]["nobackfill"] == 0
assert meta["ceph"]["health"]["status"] == "HEALTH_OK"
assert meta["ceph"]["version"]
assert len([r for r in lab_profile().resources if r.kind == "ceph-osd"]) == 3
assert any(r.kind == "storage" and r.external_id == "ceph" for r in lab_profile().resources)
local = next(r for r in lab_profile().resources if r.external_id == "local")
assert int(cast(int, local.state["total_bytes"])) > 0
assert lab_profile().tasks[0].payload["node"] == "pve01"
assert meta["qemu_cpu_flags"]
assert meta["metrics"]["export_data"]
assert meta["jobs"]["schedule_analyze_results"]
assert meta["replication"][0]["log"]
ops = cast(dict[str, Any], default_node_ops_for_seed("pve01"))
assert ops["vzdump"]["defaults"]["storage"] == "local"
assert ops["status"]["mem"] > 0
assert ops["status"]["maxmem"] > 0
assert ops["capabilities"]["cpu"]
assert ops["capabilities"]["machines"]
assert ops["hosts"]["data"]
@@ -154,11 +209,21 @@ def test_cluster_domain_metadata_seeds_list_domains() -> None:
assert ops["status"]["uptime"]
assert ops["ip"]
assert meta["cluster_config"]["totem"]
assert meta["cluster_config"]["config_digest"]
assert meta["cluster_config"]["corosync_conf"]
assert meta["cluster_config"]["corosync_authkey"]
first_node = next(iter(meta["cluster_config"]["added_nodes"].values()))
assert first_node["pve_fp"].count(":") == 31
assert first_node["pve_addr"]
assert meta["backup_jobs"]["backup-daily"]["storage"] == "local"
assert meta["quorate"] == 1
guest = cast(
dict[str, Any],
enrich_guest_state({"name": "demo", "status": "stopped"}, kind="qemu", vmid="100"),
)
assert guest["scsi0"].startswith("local-lvm:")
assert guest["ostype"] == "l26"
assert str(guest["net0"]).startswith("virtio=")
assert guest["agent"]["results"]["info"]
assert guest["rrddata"]
assert guest["migrate_preconditions"]
@@ -170,6 +235,36 @@ def test_cluster_domain_metadata_seeds_list_domains() -> None:
assert storage["file_restore"]
assert storage["import_metadata"]
small_meta = cast(dict[str, Any], cluster_domain_metadata(small_profile()))
assert len(small_meta["replication"]) == 2
assert small_meta["replication"][0]["guest"] == 101
assert len(small_meta["backup_jobs"]) == 2
assert len(small_meta["ha_groups"]) == 2
assert small_meta["sdn"]["zones"]["public"]["digest"]
assert small_meta["sdn"]["zones"]["public"]["state"] == "available"
assert len(cast(dict[str, Any], cluster_domain_metadata(large_profile()))["replication"]) == 8
assert len(cast(dict[str, Any], cluster_domain_metadata(large_profile()))["ha_groups"]) == 4
assert len(cast(dict[str, Any], cluster_domain_metadata(big_profile()))["replication"]) == 16
assert len(cast(dict[str, Any], cluster_domain_metadata(big_profile()))["ha_groups"]) == 6
assert len(cast(dict[str, Any], cluster_domain_metadata(big_profile()))["ceph"]["pools"]) == 3
ops = cast(dict[str, Any], default_node_ops_for_seed("pve1", node_index=1, node_count=3))
apt_repos = cast(dict[str, Any], cast(dict[str, Any], ops["apt"])["repositories"])
assert "files" in apt_repos and "standard-repos" in apt_repos
cert = cast(list[dict[str, Any]], cast(dict[str, Any], ops["certificates"])["info"])[0]
assert cert["pem"] and cert["san"] and cert["public-key-bits"] == 4096
def test_sized_profile_artifact_targets() -> None:
from app.simulation.seed import _CLUSTER_SIZE_SPECS
assert _CLUSTER_SIZE_SPECS["small"]["backups"] == 10
assert _CLUSTER_SIZE_SPECS["small"]["snapshots"] == 12
assert _CLUSTER_SIZE_SPECS["large"]["backups"] == 80
assert _CLUSTER_SIZE_SPECS["large"]["snapshots"] == 100
assert _CLUSTER_SIZE_SPECS["big"]["backups"] == 160
assert _CLUSTER_SIZE_SPECS["big"]["snapshots"] == 200
@pytest.mark.asyncio
async def test_clear_simulation_state_wipes_api_created_identity() -> None: