777926487b
initial QEMU slice, backed by imported contracts for majors 6–9. - Implement durable handlers for access/auth, cluster, LXC, storage, HA, firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops - Serve an interactive Web UI with catalog browsing, demo seed controls, and OpenAPI/help surfaces - Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3 - Support in-memory runtime contract Apply (POST /ui/api/contract/apply) so /version and /api2 routes follow the selected major until restart - Expand seed profiles (including demo-cluster), migrations 007–008, TLS gateway config, Compose/Makefile tooling, and compatibility evidence - Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
864 lines
33 KiB
Python
864 lines
33 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")
|
|
CLUSTER_ID = uuid.UUID("dc760c47-d8d7-57e6-9404-f0c6f2395d8f")
|
|
|
|
|
|
def default_node_ops_for_seed(node_name: str) -> dict[str, object]:
|
|
from app.handlers.nodes import default_node_ops
|
|
|
|
ops = default_node_ops()
|
|
# Distinct but deterministic bridge addresses per node name.
|
|
suffix = (stable_id(f"node-ip:{node_name}").int % 200) + 10
|
|
network = ops.get("network")
|
|
if isinstance(network, list):
|
|
for item in network:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if item.get("iface") == "vmbr0":
|
|
item["address"] = f"10.0.0.{suffix}/24"
|
|
elif item.get("iface") == "vmbr1":
|
|
item["address"] = f"10.10.0.{suffix}/24"
|
|
return ops
|
|
|
|
|
|
@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:pve01: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("pve01")
|
|
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 minimal_profile() -> SeedProfile:
|
|
node = _node("pve01")
|
|
resources = (
|
|
_resource(node, "storage", "local", {"content": ["iso", "backup"], "status": "available"}),
|
|
_resource(
|
|
node, "storage", "local-lvm", {"content": ["images", "rootdir"], "status": "available"}
|
|
),
|
|
)
|
|
return SeedProfile("minimal", (node,), resources)
|
|
|
|
|
|
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()
|
|
if name == "minimal":
|
|
return minimal_profile()
|
|
if name == "demo-cluster":
|
|
from app.simulation.demo_cluster import demo_cluster_profile
|
|
|
|
return demo_cluster_profile()
|
|
raise ValueError(f"unknown seed profile: {name}")
|
|
|
|
|
|
def _storage_type(resource: SeedResource) -> str:
|
|
configured = resource.state.get("storage_type")
|
|
if isinstance(configured, str) and configured:
|
|
return configured
|
|
if resource.external_id.startswith("local"):
|
|
if "lvm" in resource.external_id:
|
|
return "lvmthin"
|
|
if "zfs" in resource.external_id:
|
|
return "zfspool"
|
|
return "dir"
|
|
if resource.external_id.startswith("ceph"):
|
|
return "ceph"
|
|
if resource.external_id.startswith("nfs"):
|
|
return "nfs"
|
|
return "dir"
|
|
|
|
|
|
def _storage_capacity(resource: SeedResource) -> tuple[int | None, int | None]:
|
|
total = resource.state.get("total_bytes", resource.state.get("capacity_bytes"))
|
|
used = resource.state.get("used_bytes")
|
|
total_bytes = int(total) if isinstance(total, int) else None
|
|
used_bytes = int(used) if isinstance(used, int) else None
|
|
return total_bytes, used_bytes
|
|
|
|
|
|
async def clear_simulation_state(connection: Connection) -> None:
|
|
"""Remove all mutable simulator state so a seed/reset never fails on leftovers.
|
|
|
|
API-created guests, storages, users, groups, roles, ACL/tokens and custom
|
|
realms must not block "Remove demo data" / reseed. Builtin auth realms
|
|
(`pam`, `pve`, `test`) are kept because principals reference them.
|
|
"""
|
|
for statement in (
|
|
"DELETE FROM task_logs",
|
|
"DELETE FROM task_events",
|
|
"DELETE FROM resource_locks",
|
|
"DELETE FROM tasks",
|
|
"DELETE FROM pool_members",
|
|
"DELETE FROM backups",
|
|
"DELETE FROM snapshots",
|
|
"DELETE FROM storage_contents",
|
|
"DELETE FROM vm_disks",
|
|
"DELETE FROM vm_network_interfaces",
|
|
"DELETE FROM virtual_machines",
|
|
"DELETE FROM containers",
|
|
"DELETE FROM storages",
|
|
"DELETE FROM pools",
|
|
"DELETE FROM resources",
|
|
"DELETE FROM nodes",
|
|
"DELETE FROM openid_pending",
|
|
"DELETE FROM tfa_entries",
|
|
"DELETE FROM group_acl_entries",
|
|
"DELETE FROM identity_group_members",
|
|
"DELETE FROM acl_entries",
|
|
"DELETE FROM api_tokens",
|
|
"DELETE FROM auth_tickets",
|
|
"DELETE FROM identity_groups",
|
|
"DELETE FROM principals",
|
|
"DELETE FROM roles",
|
|
"DELETE FROM realms WHERE name NOT IN ('pam', 'pve', 'test')",
|
|
"DELETE FROM fault_injections",
|
|
"DELETE FROM scenario_rules",
|
|
"DELETE FROM audit_events",
|
|
):
|
|
await connection.execute(statement)
|
|
await connection.execute(
|
|
"""UPDATE clusters
|
|
SET name = 'pve-simulator',
|
|
metadata = '{}'::jsonb,
|
|
updated_at = now()
|
|
WHERE id = $1""",
|
|
CLUSTER_ID,
|
|
)
|
|
|
|
|
|
async def simulation_state_summary(connection: Connection) -> dict[str, object]:
|
|
row = await connection.fetchrow(
|
|
"""SELECT
|
|
c.name AS cluster_name,
|
|
COALESCE(c.metadata->>'profile', 'unknown') AS profile,
|
|
(SELECT count(*)::int FROM nodes) AS nodes,
|
|
(SELECT count(*)::int FROM resources WHERE kind = 'qemu') AS qemu,
|
|
(SELECT count(*)::int FROM resources WHERE kind = 'lxc') AS lxc,
|
|
(SELECT count(*)::int FROM resources WHERE kind = 'ceph-osd') AS ceph_osds,
|
|
(SELECT count(*)::int FROM resources WHERE kind = 'storage') AS storages,
|
|
(SELECT count(*)::int FROM backups) AS backups,
|
|
(SELECT count(*)::int FROM tasks) AS tasks,
|
|
(SELECT count(*)::int FROM task_logs) AS task_logs,
|
|
(SELECT count(*)::int FROM snapshots) AS snapshots,
|
|
(SELECT count(*)::int FROM principals) AS principals,
|
|
COALESCE(
|
|
(SELECT sum(capacity_bytes)::bigint FROM storages WHERE storage_type = 'ceph'),
|
|
0
|
|
) AS ceph_capacity_bytes
|
|
FROM clusters c
|
|
WHERE c.id = $1""",
|
|
CLUSTER_ID,
|
|
)
|
|
if row is None:
|
|
return {"profile": "unknown", "loaded": False}
|
|
payload = dict(row)
|
|
payload["loaded"] = payload["profile"] == "demo-cluster"
|
|
payload["ceph_capacity_pib"] = round((payload.get("ceph_capacity_bytes") or 0) / 1024**5, 2)
|
|
return payload
|
|
|
|
|
|
async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
|
|
async with connection.transaction():
|
|
await clear_simulation_state(connection)
|
|
await connection.execute(
|
|
"""UPDATE clusters
|
|
SET name = $2,
|
|
metadata = $3::jsonb,
|
|
updated_at = now()
|
|
WHERE id = $1""",
|
|
CLUSTER_ID,
|
|
"prod-pve-cluster" if profile.name == "demo-cluster" else "pve-simulator",
|
|
json.dumps(
|
|
{
|
|
"profile": profile.name,
|
|
"nodes": len(profile.nodes),
|
|
"resources": len(profile.resources),
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
)
|
|
await connection.executemany(
|
|
"INSERT INTO nodes(id, name, status, metadata) VALUES($1, $2, $3, $4::jsonb)",
|
|
[
|
|
(
|
|
node.id,
|
|
node.name,
|
|
node.status,
|
|
json.dumps({"ops": default_node_ops_for_seed(node.name)}, sort_keys=True),
|
|
)
|
|
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,
|
|
capacity_bytes, used_bytes, config
|
|
) VALUES($1, $2, $3, $4, $5, $6, $7, $8::jsonb)""",
|
|
[
|
|
(
|
|
resource.id,
|
|
str(CLUSTER_ID),
|
|
resource.external_id,
|
|
_storage_type(resource),
|
|
bool(resource.state.get("shared", False)),
|
|
*_storage_capacity(resource),
|
|
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(
|
|
"DELETE FROM acl_entries WHERE principal_id=$1 AND role_name='PVEAuditor'",
|
|
auditor_id,
|
|
)
|
|
auditor_group_id = await connection.fetchval(
|
|
"""INSERT INTO identity_groups(id, group_id, comment)
|
|
VALUES($1, 'auditors', 'Read-only operators')
|
|
ON CONFLICT (group_id) DO UPDATE SET comment=EXCLUDED.comment
|
|
RETURNING id""",
|
|
stable_id("group:auditors"),
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO identity_group_members(group_id, principal_id)
|
|
VALUES($1, $2) ON CONFLICT DO NOTHING""",
|
|
auditor_group_id,
|
|
auditor_id,
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
|
|
VALUES($1, 'PVEAuditor', '/', true)
|
|
ON CONFLICT (group_id, role_name, path) DO UPDATE
|
|
SET propagate=EXCLUDED.propagate""",
|
|
auditor_group_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"],
|
|
)
|
|
for username, role_name, privileges, acl_path, token_id, token_secret in (
|
|
(
|
|
"operator@pve",
|
|
"PVEVMOperator",
|
|
["VM.Audit", "VM.PowerMgmt"],
|
|
"/vms",
|
|
"operator",
|
|
"operator-secret",
|
|
),
|
|
(
|
|
"storage@pve",
|
|
"PVEStorageUser",
|
|
["Datastore.Audit", "Datastore.AllocateSpace"],
|
|
"/storage",
|
|
"storage",
|
|
"storage-secret",
|
|
),
|
|
):
|
|
principal_id = stable_id(f"principal:{username}")
|
|
await connection.execute(
|
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
|
VALUES($1, $2, $3, 'pve')
|
|
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
|
|
realm_name=EXCLUDED.realm_name""",
|
|
principal_id,
|
|
username,
|
|
hash_secret(f"{username}-password", salt=f"seed:{username}".encode()),
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO roles(name, privileges) VALUES($1, $2)
|
|
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
|
|
role_name,
|
|
privileges,
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
|
|
VALUES($1, $2, $3, true)
|
|
ON CONFLICT (principal_id, role_name, path) DO UPDATE
|
|
SET propagate=EXCLUDED.propagate""",
|
|
principal_id,
|
|
role_name,
|
|
acl_path,
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
|
|
VALUES($1, $2, $3, $4)
|
|
ON CONFLICT (principal_id, token_id) DO UPDATE
|
|
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges,
|
|
privilege_separation=true""",
|
|
principal_id,
|
|
token_id,
|
|
hash_secret(token_secret, salt=f"token:{username}".encode()),
|
|
privileges,
|
|
)
|
|
if profile.name == "demo-cluster":
|
|
await _apply_demo_cluster_extras(connection, profile)
|
|
|
|
|
|
async def _apply_demo_cluster_extras(connection: Connection, profile: SeedProfile) -> None:
|
|
names = {node.id: node.name for node in profile.nodes}
|
|
guests = [resource for resource in profile.resources if resource.kind in {"qemu", "lxc"}]
|
|
|
|
disks: list[tuple[uuid.UUID, uuid.UUID, str, str, int, str]] = []
|
|
for index, resource in enumerate(guests):
|
|
node_name = names[resource.node_id]
|
|
disk_count = 1 + (index % 3)
|
|
for disk_index in range(disk_count):
|
|
device = "rootfs" if resource.kind == "lxc" and disk_index == 0 else f"scsi{disk_index}"
|
|
storage_id = "ceph-prod" if (index + disk_index) % 4 == 0 else f"local-lvm-{node_name}"
|
|
size_bytes = (20 + (index % 9) * 10 + disk_index * 15) * 1024**3
|
|
disks.append(
|
|
(
|
|
stable_id(f"disk:{resource.external_id}:{device}"),
|
|
resource.id,
|
|
device,
|
|
storage_id,
|
|
size_bytes,
|
|
json.dumps({"format": "raw" if disk_index else "qcow2"}, sort_keys=True),
|
|
)
|
|
)
|
|
if disks:
|
|
await connection.executemany(
|
|
"""INSERT INTO vm_disks(id, resource_id, device, storage_id, size_bytes, metadata)
|
|
VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
|
|
disks,
|
|
)
|
|
|
|
interfaces: list[tuple[uuid.UUID, uuid.UUID, str, str]] = []
|
|
for index, resource in enumerate(guests):
|
|
interfaces.append(
|
|
(
|
|
stable_id(f"net:{resource.external_id}:net0"),
|
|
resource.id,
|
|
"net0",
|
|
json.dumps(
|
|
{
|
|
"bridge": "vmbr0",
|
|
"firewall": index % 7 != 0,
|
|
"tag": (index % 12) * 10 or None,
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
)
|
|
)
|
|
if index % 5 == 0:
|
|
interfaces.append(
|
|
(
|
|
stable_id(f"net:{resource.external_id}:net1"),
|
|
resource.id,
|
|
"net1",
|
|
json.dumps({"bridge": "vmbr1", "firewall": True}, sort_keys=True),
|
|
)
|
|
)
|
|
if interfaces:
|
|
await connection.executemany(
|
|
"""INSERT INTO vm_network_interfaces(id, resource_id, device, config)
|
|
VALUES($1, $2, $3, $4::jsonb)""",
|
|
interfaces,
|
|
)
|
|
|
|
snapshots: list[tuple[uuid.UUID, uuid.UUID, str, str | None, str, str]] = []
|
|
for index, resource in enumerate(guests):
|
|
if index % 7 != 0:
|
|
continue
|
|
for snap_index in range(1 + (index % 3)):
|
|
snap_name = f"snap-{snap_index:02d}"
|
|
snapshots.append(
|
|
(
|
|
stable_id(f"snapshot:{resource.external_id}:{snap_name}"),
|
|
resource.id,
|
|
snap_name,
|
|
None if snap_index == 0 else f"snap-{snap_index - 1:02d}",
|
|
f"Automated snapshot #{snap_index}",
|
|
json.dumps({"vmstate": index % 2 == 0}, sort_keys=True),
|
|
)
|
|
)
|
|
if snapshots:
|
|
await connection.executemany(
|
|
"""INSERT INTO snapshots(id, resource_id, name, parent_name, description, state)
|
|
VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
|
|
snapshots,
|
|
)
|
|
|
|
storage_rows = await connection.fetch(
|
|
"""SELECT s.resource_id, s.storage_id, n.name AS node_name
|
|
FROM storages s
|
|
JOIN resources r ON r.id = s.resource_id
|
|
JOIN nodes n ON n.id = r.node_id
|
|
WHERE s.storage_id LIKE 'backup-%' OR s.storage_id IN ('ceph-prod', 'nfs-backup')"""
|
|
)
|
|
storage_by_id = {row["storage_id"]: row["resource_id"] for row in storage_rows}
|
|
storage_by_node = {
|
|
str(row["node_name"]): row["resource_id"]
|
|
for row in storage_rows
|
|
if str(row["storage_id"]).startswith("backup-")
|
|
}
|
|
fallback_backup = storage_by_id.get("nfs-backup") or storage_by_id.get("ceph-prod")
|
|
if fallback_backup is not None:
|
|
backups: list[tuple[uuid.UUID, uuid.UUID | None, uuid.UUID, str, int, str]] = []
|
|
qemu_guests = [resource for resource in guests if resource.kind == "qemu"]
|
|
for index, resource in enumerate(qemu_guests):
|
|
node_name = names[resource.node_id]
|
|
backup_storage = storage_by_node.get(node_name, fallback_backup)
|
|
volume_id = f"backup/vzdump-qemu-{resource.external_id}-2026_07_15-{index:04d}.vma.zst"
|
|
backups.append(
|
|
(
|
|
stable_id(f"backup:{resource.external_id}:{index}"),
|
|
resource.id,
|
|
backup_storage,
|
|
volume_id,
|
|
(8 + (index % 40)) * 1024**3,
|
|
json.dumps(
|
|
{
|
|
"mode": "snapshot" if index % 3 else "suspend",
|
|
"notes-template": "Daily backup",
|
|
"node": node_name,
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
)
|
|
)
|
|
if backups:
|
|
await connection.executemany(
|
|
"""INSERT INTO backups(
|
|
id, resource_id, storage_resource_id, volume_id, size_bytes, metadata
|
|
) VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
|
|
backups,
|
|
)
|
|
|
|
guest_list = sorted(
|
|
guests, key=lambda resource: (names[resource.node_id], resource.external_id)
|
|
)
|
|
extra_tasks: list[tuple[uuid.UUID, str, str, str, str]] = []
|
|
for index in range(251, 321):
|
|
guest = guest_list[(index - 251) % len(guest_list)]
|
|
node_name = names[guest.node_id]
|
|
node = next(node for node in profile.nodes if node.name == node_name)
|
|
task_type = ("vzdump", "qmmigrate", "qmstart", "cephosd")[index % 4]
|
|
status = "running" if index % 17 == 0 else "error" if index % 23 == 0 else "success"
|
|
extra_tasks.append(
|
|
(
|
|
stable_id(f"demo-task-extra:{index}"),
|
|
f"UPID:{node.name}:{index:07X}:{index:07X}:68{index:06X}:"
|
|
f"{task_type}:{guest.external_id}:operator@pve:",
|
|
status,
|
|
json.dumps(
|
|
{"resource_id": guest.external_id, "node": node.name},
|
|
sort_keys=True,
|
|
),
|
|
task_type,
|
|
)
|
|
)
|
|
if extra_tasks:
|
|
await connection.executemany(
|
|
"""INSERT INTO tasks(id, upid, status, payload, task_type, progress, result, error)
|
|
VALUES($1, $2, $3, $4::jsonb, $5,
|
|
CASE WHEN $3 = 'success' THEN 100 WHEN $3 = 'running' THEN 45 ELSE 0 END,
|
|
CASE WHEN $3 = 'success' THEN '{\"seeded\":true}'::jsonb ELSE NULL END,
|
|
CASE WHEN $3 = 'error' THEN 'simulated backup failure' ELSE NULL END)""",
|
|
extra_tasks,
|
|
)
|
|
|
|
task_rows = await connection.fetch(
|
|
"SELECT id, task_type, payload FROM tasks ORDER BY upid LIMIT 180"
|
|
)
|
|
logs: list[tuple[uuid.UUID, str]] = []
|
|
for task in task_rows:
|
|
payload = task["payload"]
|
|
if isinstance(payload, dict):
|
|
resource_id = payload.get("resource_id", "unknown")
|
|
node_label = payload.get("node", "pve01")
|
|
else:
|
|
resource_id = "unknown"
|
|
node_label = "unknown"
|
|
messages: tuple[str, ...] = (
|
|
f"starting task {task['task_type']} on {node_label}",
|
|
f"processing guest {resource_id}",
|
|
f"task {task['task_type']} finished successfully",
|
|
)
|
|
if task["task_type"] == "vzdump":
|
|
messages = (
|
|
f"INFO: starting backup of VM {resource_id} on {node_label}",
|
|
f"INFO: snapshot create VM {resource_id}",
|
|
f"INFO: archive file size: {(8 + hash(str(task['id'])) % 40)}GB",
|
|
"INFO: Backup finished successfully",
|
|
)
|
|
logs.extend((task["id"], message) for message in messages)
|
|
if logs:
|
|
await connection.executemany(
|
|
"INSERT INTO task_logs(task_id, message) VALUES($1, $2)",
|
|
logs,
|
|
)
|
|
|
|
demo_users = (
|
|
("admin@pve", "PVEAdmin", ["/"], ["Sys.Modify", "Sys.Audit", "Datastore.Allocate"]),
|
|
("devops@pve", "PVEAdmin", ["/vms"], ["Sys.Audit", "VM.Allocate", "VM.PowerMgmt"]),
|
|
(
|
|
"backup-operator@pve",
|
|
"PVEDatastoreAdmin",
|
|
["/storage"],
|
|
["Datastore.Allocate", "Datastore.Audit"],
|
|
),
|
|
("ceph-monitor@pve", "PVEAuditor", ["/"], ["Sys.Audit", "Datastore.Audit"]),
|
|
("junior@pve", "PVEAuditor", ["/vms"], ["Sys.Audit", "VM.Audit"]),
|
|
("security@pve", "PVEAuditor", ["/access"], ["Sys.Audit", "User.Modify"]),
|
|
)
|
|
for username, role_name, acl_paths, privileges in demo_users:
|
|
principal_id = stable_id(f"principal:{username}")
|
|
await connection.execute(
|
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
|
VALUES($1, $2, $3, 'pve')
|
|
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
|
|
realm_name=EXCLUDED.realm_name""",
|
|
principal_id,
|
|
username,
|
|
hash_secret(f"{username}-password", salt=f"seed:{username}".encode()),
|
|
)
|
|
await connection.execute(
|
|
"""INSERT INTO roles(name, privileges) VALUES($1, $2)
|
|
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
|
|
role_name,
|
|
privileges,
|
|
)
|
|
for acl_path in acl_paths:
|
|
await connection.execute(
|
|
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
|
|
VALUES($1, $2, $3, true)
|
|
ON CONFLICT (principal_id, role_name, path) DO UPDATE
|
|
SET propagate=EXCLUDED.propagate""",
|
|
principal_id,
|
|
role_name,
|
|
acl_path,
|
|
)
|
|
|
|
|
|
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()
|