393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""Deterministic idempotent simulation seed profiles."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
|
|
import asyncpg # type: ignore[import-untyped]
|
|
from asyncpg import Connection
|
|
|
|
from app.security.auth import hash_secret
|
|
|
|
NAMESPACE = uuid.UUID("c9040a72-b391-4a7e-9864-3ae46291a531")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeedNode:
|
|
id: uuid.UUID
|
|
name: str
|
|
status: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeedResource:
|
|
id: uuid.UUID
|
|
node_id: uuid.UUID
|
|
kind: str
|
|
external_id: str
|
|
state: dict[str, object]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeedTask:
|
|
id: uuid.UUID
|
|
upid: str
|
|
task_type: str
|
|
payload: dict[str, object]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeedProfile:
|
|
name: str
|
|
nodes: tuple[SeedNode, ...]
|
|
resources: tuple[SeedResource, ...]
|
|
tasks: tuple[SeedTask, ...] = ()
|
|
|
|
def logical_state(self) -> dict[str, object]:
|
|
nodes = [{"name": node.name, "status": node.status} for node in self.nodes]
|
|
names = {node.id: node.name for node in self.nodes}
|
|
resources = [
|
|
{
|
|
"kind": resource.kind,
|
|
"external_id": resource.external_id,
|
|
"node": names[resource.node_id],
|
|
"state": resource.state,
|
|
}
|
|
for resource in self.resources
|
|
]
|
|
tasks = [
|
|
{"upid": task.upid, "task_type": task.task_type, "status": "success"}
|
|
for task in self.tasks
|
|
]
|
|
return {"profile": self.name, "nodes": nodes, "resources": resources, "tasks": tasks}
|
|
|
|
|
|
def stable_id(name: str) -> uuid.UUID:
|
|
return uuid.uuid5(NAMESPACE, name)
|
|
|
|
|
|
def _string_list(state: dict[str, object], key: str) -> tuple[str, ...]:
|
|
value = state.get(key, [])
|
|
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
raise ValueError(f"seed state {key} must be a string list")
|
|
return tuple(value)
|
|
|
|
|
|
def _node(name: str, status: str = "online") -> SeedNode:
|
|
return SeedNode(stable_id(f"node:{name}"), name, status)
|
|
|
|
|
|
def _resource(
|
|
node: SeedNode, kind: str, external_id: str, state: dict[str, object]
|
|
) -> SeedResource:
|
|
return SeedResource(stable_id(f"{kind}:{external_id}"), node.id, kind, external_id, state)
|
|
|
|
|
|
def _completed_task(index: int, task_type: str, resource_id: str) -> SeedTask:
|
|
return SeedTask(
|
|
stable_id(f"task:{index}:{task_type}:{resource_id}"),
|
|
f"UPID:pve1:0000000{index}:0000000{index}:6500000{index}:"
|
|
f"{task_type}:{resource_id}:root@pam:",
|
|
task_type,
|
|
{"resource_id": resource_id, "seeded": True},
|
|
)
|
|
|
|
|
|
def small_profile() -> SeedProfile:
|
|
node = _node("pve1")
|
|
resources = (
|
|
_resource(node, "qemu", "100", {"name": "demo", "status": "stopped"}),
|
|
_resource(node, "qemu", "101", {"name": "worker", "status": "stopped"}),
|
|
_resource(node, "lxc", "200", {"name": "service", "status": "stopped"}),
|
|
_resource(node, "storage", "local", {"content": ["iso", "backup"], "status": "available"}),
|
|
_resource(
|
|
node, "storage", "local-lvm", {"content": ["images", "rootdir"], "status": "available"}
|
|
),
|
|
)
|
|
tasks = (_completed_task(1, "qmstart", "100"), _completed_task(2, "qmstop", "100"))
|
|
return SeedProfile("small", (node,), resources, tasks)
|
|
|
|
|
|
def medium_profile() -> SeedProfile:
|
|
nodes = tuple(_node(f"pve{index}") for index in range(1, 4))
|
|
resources: list[SeedResource] = []
|
|
for vmid in range(100, 150):
|
|
node = nodes[(vmid - 100) % len(nodes)]
|
|
resources.append(
|
|
_resource(node, "qemu", str(vmid), {"name": f"vm-{vmid}", "status": "stopped"})
|
|
)
|
|
for vmid in range(200, 220):
|
|
node = nodes[(vmid - 200) % len(nodes)]
|
|
resources.append(
|
|
_resource(node, "lxc", str(vmid), {"name": f"ct-{vmid}", "status": "stopped"})
|
|
)
|
|
for node in nodes:
|
|
resources.append(
|
|
_resource(
|
|
node,
|
|
"storage",
|
|
f"local-{node.name}",
|
|
{"content": ["images"], "shared": False, "status": "available"},
|
|
)
|
|
)
|
|
resources.append(
|
|
_resource(
|
|
nodes[0],
|
|
"storage",
|
|
"shared",
|
|
{"content": ["images", "backup"], "shared": True, "status": "available"},
|
|
)
|
|
)
|
|
resources.append(_resource(nodes[0], "pool", "development", {"members": ["100", "101", "200"]}))
|
|
tasks = tuple(_completed_task(index, "qmstart", str(99 + index)) for index in range(1, 11))
|
|
return SeedProfile("medium", nodes, tuple(resources), tasks)
|
|
|
|
|
|
def large_profile(*, node_count: int = 10, resource_count: int = 10_000) -> SeedProfile:
|
|
if node_count < 1 or resource_count < 1:
|
|
raise ValueError("large profile counts must be positive")
|
|
nodes = tuple(_node(f"pve{index}") for index in range(1, node_count + 1))
|
|
resources = tuple(
|
|
_resource(
|
|
nodes[index % node_count],
|
|
"qemu" if index % 4 else "lxc",
|
|
str(100 + index),
|
|
{"name": f"guest-{100 + index}", "status": "stopped"},
|
|
)
|
|
for index in range(resource_count)
|
|
)
|
|
return SeedProfile("large", nodes, resources)
|
|
|
|
|
|
def ha_demo_profile() -> SeedProfile:
|
|
profile = medium_profile()
|
|
resources = (
|
|
*profile.resources,
|
|
_resource(profile.nodes[0], "ha", "vm:100", {"state": "started", "group": "primary"}),
|
|
)
|
|
return SeedProfile("ha-demo", profile.nodes, resources, profile.tasks)
|
|
|
|
|
|
def broken_storage_profile() -> SeedProfile:
|
|
profile = small_profile()
|
|
resources = tuple(
|
|
_resource(
|
|
next(node for node in profile.nodes if node.id == resource.node_id),
|
|
resource.kind,
|
|
resource.external_id,
|
|
{**resource.state, "status": "offline", "error": "simulated I/O failure"}
|
|
if resource.kind == "storage" and resource.external_id == "local-lvm"
|
|
else resource.state,
|
|
)
|
|
for resource in profile.resources
|
|
)
|
|
return SeedProfile("broken-storage", profile.nodes, resources, profile.tasks)
|
|
|
|
|
|
def build_profile(
|
|
name: str, *, large_nodes: int = 10, large_resources: int = 10_000
|
|
) -> SeedProfile:
|
|
if name == "small":
|
|
return small_profile()
|
|
if name == "medium":
|
|
return medium_profile()
|
|
if name == "large":
|
|
return large_profile(node_count=large_nodes, resource_count=large_resources)
|
|
if name == "ha-demo":
|
|
return ha_demo_profile()
|
|
if name == "broken-storage":
|
|
return broken_storage_profile()
|
|
raise ValueError(f"unknown seed profile: {name}")
|
|
|
|
|
|
async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
|
|
async with connection.transaction():
|
|
await connection.execute(
|
|
"""DELETE FROM task_logs;
|
|
DELETE FROM task_events;
|
|
DELETE FROM resource_locks;
|
|
DELETE FROM tasks;
|
|
DELETE FROM pool_members;
|
|
DELETE FROM pools;
|
|
DELETE FROM resources;
|
|
DELETE FROM nodes"""
|
|
)
|
|
await connection.executemany(
|
|
"INSERT INTO nodes(id, name, status) VALUES($1, $2, $3)",
|
|
[(node.id, node.name, node.status) for node in profile.nodes],
|
|
)
|
|
await connection.executemany(
|
|
"""INSERT INTO resources(id, node_id, kind, external_id, state)
|
|
VALUES($1, $2, $3, $4, $5::jsonb)""",
|
|
[
|
|
(
|
|
resource.id,
|
|
resource.node_id,
|
|
resource.kind,
|
|
resource.external_id,
|
|
json.dumps(resource.state, sort_keys=True),
|
|
)
|
|
for resource in profile.resources
|
|
],
|
|
)
|
|
qemu = [resource for resource in profile.resources if resource.kind == "qemu"]
|
|
if qemu:
|
|
await connection.executemany(
|
|
"""INSERT INTO virtual_machines(resource_id, cluster_id, vmid, config)
|
|
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
|
|
[
|
|
(
|
|
resource.id,
|
|
int(resource.external_id),
|
|
json.dumps(resource.state, sort_keys=True),
|
|
)
|
|
for resource in qemu
|
|
],
|
|
)
|
|
containers = [resource for resource in profile.resources if resource.kind == "lxc"]
|
|
if containers:
|
|
await connection.executemany(
|
|
"""INSERT INTO containers(resource_id, cluster_id, vmid, config)
|
|
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
|
|
[
|
|
(
|
|
resource.id,
|
|
int(resource.external_id),
|
|
json.dumps(resource.state, sort_keys=True),
|
|
)
|
|
for resource in containers
|
|
],
|
|
)
|
|
storages = [resource for resource in profile.resources if resource.kind == "storage"]
|
|
if storages:
|
|
await connection.executemany(
|
|
"""INSERT INTO storages(
|
|
resource_id, cluster_id, storage_id, storage_type, shared, config
|
|
) VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3, $4, $5::jsonb)""",
|
|
[
|
|
(
|
|
resource.id,
|
|
resource.external_id,
|
|
"dir" if resource.external_id.startswith("local") else "nfs",
|
|
bool(resource.state.get("shared", False)),
|
|
json.dumps(resource.state, sort_keys=True),
|
|
)
|
|
for resource in storages
|
|
],
|
|
)
|
|
contents = [
|
|
(
|
|
stable_id(f"content:{resource.external_id}:{content}"),
|
|
resource.id,
|
|
f"{resource.external_id}:{content}/seeded",
|
|
str(content),
|
|
)
|
|
for resource in storages
|
|
for content in _string_list(resource.state, "content")
|
|
]
|
|
if contents:
|
|
await connection.executemany(
|
|
"""INSERT INTO storage_contents(
|
|
id, storage_resource_id, volume_id, content_type
|
|
) VALUES($1, $2, $3, $4)""",
|
|
contents,
|
|
)
|
|
pools = [resource for resource in profile.resources if resource.kind == "pool"]
|
|
if pools:
|
|
await connection.executemany(
|
|
"""INSERT INTO pools(id, cluster_id, pool_id, metadata)
|
|
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
|
|
[
|
|
(resource.id, resource.external_id, json.dumps(resource.state, sort_keys=True))
|
|
for resource in pools
|
|
],
|
|
)
|
|
members = [
|
|
(pool.id, member.id)
|
|
for pool in pools
|
|
for external_id in _string_list(pool.state, "members")
|
|
for member in profile.resources
|
|
if member.external_id == external_id and member.kind in {"qemu", "lxc"}
|
|
]
|
|
if members:
|
|
await connection.executemany(
|
|
"INSERT INTO pool_members(pool_id, resource_id) VALUES($1, $2)", members
|
|
)
|
|
if profile.tasks:
|
|
await connection.executemany(
|
|
"""INSERT INTO tasks(id, upid, status, payload, task_type, progress, result)
|
|
VALUES($1, $2, 'success', $3::jsonb, $4, 100, '{\"seeded\":true}'::jsonb)""",
|
|
[
|
|
(task.id, task.upid, json.dumps(task.payload, sort_keys=True), task.task_type)
|
|
for task in profile.tasks
|
|
],
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
|
VALUES($1, 'root@pam', $2, 'pam')
|
|
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
|
|
realm_name=EXCLUDED.realm_name""",
|
|
stable_id("principal:root@pam"),
|
|
hash_secret("secret", salt=b"pve-simulator-v1"),
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
|
|
VALUES($1, 'automation', $2, $3)
|
|
ON CONFLICT (principal_id, token_id) DO UPDATE
|
|
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges""",
|
|
stable_id("principal:root@pam"),
|
|
hash_secret("automation-secret", salt=b"pve-token-seed-v1"),
|
|
["VM.Audit", "VM.PowerMgmt", "Sys.Audit"],
|
|
)
|
|
auditor_id = stable_id("principal:auditor@pve")
|
|
await connection.execute(
|
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
|
VALUES($1, 'auditor@pve', $2, 'pve')
|
|
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
|
|
realm_name=EXCLUDED.realm_name""",
|
|
auditor_id,
|
|
hash_secret("auditor-secret", salt=b"pve-auditor-v1"),
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO roles(name, privileges)
|
|
VALUES('PVEAuditor', $1)
|
|
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
|
|
["Sys.Audit", "VM.Audit"],
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
|
|
VALUES($1, 'PVEAuditor', '/', true)
|
|
ON CONFLICT (principal_id, role_name, path) DO UPDATE
|
|
SET propagate=EXCLUDED.propagate""",
|
|
auditor_id,
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
|
|
VALUES($1, 'readonly', $2, $3)
|
|
ON CONFLICT (principal_id, token_id) DO UPDATE
|
|
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges""",
|
|
auditor_id,
|
|
hash_secret("readonly-secret", salt=b"pve-readonly-v1"),
|
|
["Sys.Audit", "VM.Audit"],
|
|
)
|
|
|
|
|
|
async def seed_url(
|
|
database_url: str,
|
|
profile_name: str = "small",
|
|
*,
|
|
large_nodes: int = 10,
|
|
large_resources: int = 10_000,
|
|
) -> dict[str, object]:
|
|
connection = await asyncpg.connect(database_url)
|
|
try:
|
|
profile = build_profile(
|
|
profile_name, large_nodes=large_nodes, large_resources=large_resources
|
|
)
|
|
await apply_seed(connection, profile)
|
|
return profile.logical_state()
|
|
finally:
|
|
await connection.close()
|