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:
@@ -0,0 +1 @@
|
||||
"""Persistent deterministic simulation services."""
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user