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