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:
@@ -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"
|
||||
@@ -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()})
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()},
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user