Initial commit: stateful OpenStack API laboratory simulator.

Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm
packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Persistent deterministic simulation services."""
+61
View File
@@ -0,0 +1,61 @@
"""Injectable simulation clocks; task leases deliberately do not use these."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Protocol
class Clock(Protocol):
async def now(self) -> datetime: ...
async def sleep(self, seconds: float) -> None: ...
class RealClock:
async def now(self) -> datetime:
return datetime.now(UTC)
async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds)
class AcceleratedClock:
def __init__(self, scale: float) -> None:
if scale <= 0:
raise ValueError("clock scale must be positive")
self._scale = scale
async def now(self) -> datetime:
return datetime.now(UTC)
async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds / self._scale)
class ManualClock:
def __init__(self, initial: datetime) -> None:
if initial.tzinfo is None:
raise ValueError("manual clock requires timezone-aware time")
self._now = initial
self._condition = asyncio.Condition()
async def now(self) -> datetime:
async with self._condition:
return self._now
async def sleep(self, seconds: float) -> None:
if seconds < 0:
raise ValueError("sleep duration cannot be negative")
async with self._condition:
target = self._now + timedelta(seconds=seconds)
await self._condition.wait_for(lambda: self._now >= target)
async def advance(self, seconds: float) -> datetime:
if seconds < 0:
raise ValueError("clock cannot move backwards")
async with self._condition:
self._now += timedelta(seconds=seconds)
self._condition.notify_all()
return self._now
+370
View File
@@ -0,0 +1,370 @@
"""Enterprise-scale demo cluster profile for realistic emulator workloads."""
from __future__ import annotations
import uuid
from collections import defaultdict
from collections.abc import Sequence
from app.simulation.seed import (
SeedNode,
SeedProfile,
SeedResource,
SeedTask,
_node,
_resource,
stable_id,
)
DEMO_NODE_COUNT = 20
DEMO_QEMU_COUNT = 850
DEMO_LXC_COUNT = 150
DEMO_CEPH_OSD_COUNT = 300
CEPH_TOTAL_BYTES = 5 * 1024**5
QEMU_VMID_START = 100
LXC_VMID_START = 10_000
QEMU_PREFIXES = (
"web",
"api",
"db",
"cache",
"mq",
"batch",
"ml",
"monitor",
"log",
"ci",
"k8s",
"vpn",
"ldap",
"git",
"proxy",
)
LXC_PREFIXES = (
"svc-nginx",
"svc-haproxy",
"svc-dns",
"svc-vault",
"svc-redis",
"mon-agent",
"backup-agent",
"ceph-mgr",
"lb-vip",
"proxy-squid",
"jump-host",
"ntp",
"syslog",
"metrics",
"bastion",
)
TIERS = ("prod", "staging", "dev", "qa", "dr")
POOLS = (
("production", 280),
("staging", 160),
("development", 130),
("qa", 100),
("gpu-workloads", 80),
("legacy", 100),
)
TASK_TYPES = (
"vzdump",
"qmstart",
"qmstop",
"qmmigrate",
"qmreboot",
"qmclone",
"aptupdate",
"startall",
"stopall",
"cephosd",
"pct-start",
"pct-stop",
)
def _even_node_slots(node_count: int, total: int, *, phase: int = 0) -> tuple[int, ...]:
"""Return `total` node indices distributed as evenly as possible."""
if total <= 0:
return ()
base, remainder = divmod(total, node_count)
slots: list[int] = []
for node_index in range(node_count):
slots.extend([node_index] * (base + (1 if node_index < remainder else 0)))
if phase:
phase %= len(slots)
slots = slots[phase:] + slots[:phase]
return tuple(slots)
def _even_sample(resources: Sequence[SeedResource], count: int) -> list[str]:
"""Pick `count` resource IDs spread evenly across the provided sequence."""
if count <= 0 or not resources:
return []
if count >= len(resources):
return [resource.external_id for resource in resources]
step = len(resources) / count
return [resources[int(index * step)].external_id for index in range(count)]
def _guest_name(prefixes: tuple[str, ...], index: int) -> str:
prefix = prefixes[index % len(prefixes)]
tier = TIERS[index % len(TIERS)]
return f"{tier}-{prefix}-{index:04d}"
def _qemu_state(vmid: int, index: int) -> dict[str, object]:
statuses = ("running", "running", "running", "running", "stopped", "paused")
cpus = (1, 2, 2, 4, 4, 8, 8, 16, 32)[index % 9]
memory_mb = (512, 1024, 2048, 4096, 8192, 16_384, 32_768, 65_536)[index % 8]
pool_name = POOLS[index % len(POOLS)][0]
return {
"name": _guest_name(QEMU_PREFIXES, index),
"status": statuses[index % len(statuses)],
"cpus": cpus,
"cores": cpus,
"memory": memory_mb,
"maxmem": memory_mb,
"pool": pool_name,
"tags": f"{TIERS[index % len(TIERS)]};{pool_name}",
"agent": index % 3 != 0,
"template": index % 97 == 0,
"onboot": index % 5 != 0,
"vmid": vmid,
}
def _lxc_state(vmid: int, index: int) -> dict[str, object]:
statuses = ("running", "running", "stopped", "stopped")
memory_mb = (256, 512, 1024, 2048, 4096)[index % 5]
pool_name = POOLS[(index + 2) % len(POOLS)][0]
return {
"name": _guest_name(LXC_PREFIXES, index),
"status": statuses[index % len(statuses)],
"cpus": (1, 1, 2, 2, 4)[index % 5],
"memory": memory_mb,
"maxmem": memory_mb,
"pool": pool_name,
"tags": f"container;{pool_name}",
"unprivileged": index % 4 != 0,
"template": index % 41 == 0,
"vmid": vmid,
}
def _demo_task(index: int, node: SeedNode, task_type: str, resource_id: str) -> SeedTask:
return SeedTask(
stable_id(f"demo-task:{index}:{task_type}:{resource_id}"),
f"UPID:{node.name}:{index:07X}:{index:07X}:67{index:06X}:"
f"{task_type}:{resource_id}:root@pam:",
task_type,
{"resource_id": resource_id, "node": node.name, "seeded": True},
)
def demo_cluster_profile() -> SeedProfile:
nodes = tuple(
_node(f"pve{index:02d}", "offline" if index == 19 else "online")
for index in range(1, DEMO_NODE_COUNT + 1)
)
node_count = len(nodes)
resources: list[SeedResource] = []
qemu_slots = _even_node_slots(node_count, DEMO_QEMU_COUNT, phase=0)
lxc_slots = _even_node_slots(node_count, DEMO_LXC_COUNT, phase=node_count // 2)
osd_slots = _even_node_slots(node_count, DEMO_CEPH_OSD_COUNT, phase=node_count // 4)
qemu_resources: list[SeedResource] = []
for offset, node_index in enumerate(qemu_slots):
vmid = QEMU_VMID_START + offset
resource = _resource(nodes[node_index], "qemu", str(vmid), _qemu_state(vmid, offset))
qemu_resources.append(resource)
resources.append(resource)
lxc_resources: list[SeedResource] = []
for offset, node_index in enumerate(lxc_slots):
vmid = LXC_VMID_START + offset
resource = _resource(nodes[node_index], "lxc", str(vmid), _lxc_state(vmid, offset))
lxc_resources.append(resource)
resources.append(resource)
guests_by_node: dict[uuid.UUID, list[SeedResource]] = defaultdict(list)
for guest in (*qemu_resources, *lxc_resources):
guests_by_node[guest.node_id].append(guest)
for node in nodes:
resources.append(
_resource(
node,
"storage",
f"local-{node.name}",
{
"content": ["iso", "vztmpl", "backup"],
"status": "available",
"storage_type": "dir",
},
)
)
resources.append(
_resource(
node,
"storage",
f"local-lvm-{node.name}",
{
"content": ["images", "rootdir"],
"status": "available",
"storage_type": "lvmthin",
"shared": False,
},
)
)
resources.append(
_resource(
node,
"storage",
f"backup-{node.name}",
{
"content": ["backup"],
"status": "available",
"storage_type": "dir",
"shared": False,
"total_bytes": 4 * 1024**4,
"used_bytes": int(2.2 * 1024**4),
},
)
)
if int(node.name[3:]) % 2 == 0:
resources.append(
_resource(
node,
"storage",
f"local-zfs-{node.name}",
{
"content": ["images", "rootdir"],
"status": "available",
"storage_type": "zfspool",
"shared": False,
},
)
)
used_bytes = int(CEPH_TOTAL_BYTES * 0.62)
resources.append(
_resource(
nodes[0],
"storage",
"ceph-prod",
{
"content": ["images", "rootdir", "backup"],
"shared": True,
"status": "available",
"storage_type": "ceph",
"ceph_pool": "rbd",
"total_bytes": CEPH_TOTAL_BYTES,
"used_bytes": used_bytes,
"osd_count": DEMO_CEPH_OSD_COUNT,
},
)
)
resources.append(
_resource(
nodes[node_count // 2],
"storage",
"nfs-backup",
{
"content": ["backup", "iso"],
"shared": True,
"status": "available",
"storage_type": "nfs",
"total_bytes": 80 * 1024**4,
"used_bytes": 52 * 1024**4,
},
)
)
for osd_index, node_index in enumerate(osd_slots):
node = nodes[node_index]
osd_id = osd_index
weight = round(0.8 + (osd_index % 17) * 0.05, 2)
size_bytes = CEPH_TOTAL_BYTES // DEMO_CEPH_OSD_COUNT
resources.append(
_resource(
node,
"ceph-osd",
f"osd.{osd_id}",
{
"osd_id": osd_id,
"status": "up" if osd_index != 42 else "down",
"in": osd_index != 42,
"weight": weight,
"size_bytes": size_bytes,
"used_bytes": int(size_bytes * (0.55 + (osd_index % 10) * 0.03)),
"device_class": "ssd" if osd_index % 4 else "hdd",
},
)
)
qemu_by_node = [
sorted(guests_by_node[node.id], key=lambda resource: int(resource.external_id))
for node in nodes
]
pool_guest_cursor = 0
for pool_index, (pool_id, member_count) in enumerate(POOLS):
pool_guests: list[SeedResource] = []
per_node, extra = divmod(member_count, node_count)
for node_index, node_guests in enumerate(qemu_by_node):
take = per_node + (1 if node_index < extra else 0)
start = (pool_guest_cursor + node_index) % len(node_guests) if node_guests else 0
for offset in range(take):
if not node_guests:
break
pool_guests.append(node_guests[(start + offset) % len(node_guests)])
pool_guest_cursor += member_count
pool_guests.sort(key=lambda resource: int(resource.external_id))
resources.append(
_resource(
nodes[pool_index % node_count],
"pool",
pool_id,
{
"members": _even_sample(pool_guests, min(40, len(pool_guests))),
"member_count": len(pool_guests),
"comment": f"Simulated {pool_id} pool",
},
)
)
ha_guests = [
qemu_resources[int(index * len(qemu_resources) / min(120, len(qemu_resources)))]
for index in range(min(120, len(qemu_resources)))
]
for ha_index, guest in enumerate(ha_guests):
node = next(node for node in nodes if node.id == guest.node_id)
resources.append(
_resource(
node,
"ha",
f"vm:{guest.external_id}",
{
"state": "started" if ha_index % 5 else "stopped",
"group": "critical-services",
"max_relocate": 2,
"max_restart": 3,
},
)
)
tasks: list[SeedTask] = []
guest_cycle = sorted(
(*qemu_resources, *lxc_resources),
key=lambda resource: (resource.node_id, int(resource.external_id)),
)
for index in range(1, 251):
guest = guest_cycle[(index - 1) % len(guest_cycle)]
node = next(node for node in nodes if node.id == guest.node_id)
task_type = TASK_TYPES[index % len(TASK_TYPES)]
tasks.append(_demo_task(index, node, task_type, guest.external_id))
return SeedProfile("demo-cluster", nodes, tuple(resources), tuple(tasks))
+49
View File
@@ -0,0 +1,49 @@
"""Seeded deterministic fault-rule evaluation."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class FaultContext:
method: str
path: str
principal: str | None = None
node: str | None = None
vmid: str | None = None
call_number: int = 1
@dataclass(frozen=True, slots=True)
class FaultRule:
kind: str
probability: float = 1.0
method: str | None = None
path_prefix: str | None = None
principal: str | None = None
node: str | None = None
vmid: str | None = None
call_number: int | None = None
def __post_init__(self) -> None:
if not 0 <= self.probability <= 1:
raise ValueError("fault probability must be between zero and one")
def matches(rule: FaultRule, context: FaultContext, seed: int) -> bool:
filters = (
(rule.method, context.method),
(rule.principal, context.principal),
(rule.node, context.node),
(rule.vmid, context.vmid),
(rule.call_number, context.call_number),
)
if any(expected is not None and expected != actual for expected, actual in filters):
return False
if rule.path_prefix is not None and not context.path.startswith(rule.path_prefix):
return False
material = f"{seed}:{rule.kind}:{context.method}:{context.path}:{context.call_number}"
sample = int.from_bytes(hashlib.sha256(material.encode()).digest()[:8], "big") / 2**64
return sample < rule.probability
+863
View File
@@ -0,0 +1,863 @@
"""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()
+22
View File
@@ -0,0 +1,22 @@
"""Apply a deterministic simulation seed."""
import asyncio
import json
import os
from app.config import get_settings
from app.simulation.seed import seed_url
async def run() -> None:
state = await seed_url(
get_settings().database_url.get_secret_value(),
os.getenv("SEED_PROFILE", "small"),
large_nodes=int(os.getenv("SEED_LARGE_NODES", "10")),
large_resources=int(os.getenv("SEED_LARGE_RESOURCES", "10000")),
)
print(json.dumps(state, sort_keys=True))
if __name__ == "__main__":
asyncio.run(run())
+67
View File
@@ -0,0 +1,67 @@
"""Explicit virtual-machine state machine."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from app.simulation.clock import Clock
class VmState(StrEnum):
STOPPED = "stopped"
STARTING = "starting"
RUNNING = "running"
PAUSING = "pausing"
PAUSED = "paused"
RESUMING = "resuming"
STOPPING = "stopping"
MIGRATING = "migrating"
SNAPSHOTTING = "snapshotting"
BACKING_UP = "backing_up"
ERROR = "error"
class InvalidTransitionError(ValueError):
pass
TRANSITIONS: dict[tuple[VmState, str], tuple[VmState, VmState]] = {
(VmState.STOPPED, "start"): (VmState.STARTING, VmState.RUNNING),
(VmState.RUNNING, "stop"): (VmState.STOPPING, VmState.STOPPED),
(VmState.RUNNING, "shutdown"): (VmState.STOPPING, VmState.STOPPED),
(VmState.RUNNING, "reboot"): (VmState.STOPPING, VmState.RUNNING),
(VmState.RUNNING, "reset"): (VmState.STOPPING, VmState.RUNNING),
(VmState.RUNNING, "suspend"): (VmState.PAUSING, VmState.PAUSED),
(VmState.RUNNING, "pause"): (VmState.PAUSING, VmState.PAUSED),
(VmState.PAUSED, "resume"): (VmState.RESUMING, VmState.RUNNING),
(VmState.RUNNING, "migrate"): (VmState.MIGRATING, VmState.RUNNING),
(VmState.STOPPED, "migrate"): (VmState.MIGRATING, VmState.STOPPED),
(VmState.RUNNING, "snapshot"): (VmState.SNAPSHOTTING, VmState.RUNNING),
(VmState.STOPPED, "snapshot"): (VmState.SNAPSHOTTING, VmState.STOPPED),
(VmState.RUNNING, "backup"): (VmState.BACKING_UP, VmState.RUNNING),
(VmState.STOPPED, "backup"): (VmState.BACKING_UP, VmState.STOPPED),
}
@dataclass(frozen=True, slots=True)
class Transition:
operation: str
before: VmState
intermediate: VmState
after: VmState
def plan_transition(state: VmState, operation: str) -> Transition:
states = TRANSITIONS.get((state, operation))
if states is None:
raise InvalidTransitionError(f"cannot {operation} VM while it is {state}")
return Transition(operation, state, states[0], states[1])
async def execute_transition(
state: VmState, operation: str, clock: Clock, duration_seconds: float
) -> tuple[VmState, VmState]:
transition = plan_transition(state, operation)
await clock.sleep(duration_seconds)
return transition.intermediate, transition.after