feat: expand domain schema and seed profiles

This commit is contained in:
Sergey Antropoff
2026-07-13 01:13:26 +03:00
parent 636f42ce9d
commit 67f75e5484
9 changed files with 558 additions and 65 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ api-diff: ## Compare API snapshots
$(BIN)/proxmox-api-contract diff $(ARGS)
seed: ## Seed simulation data
$(BIN)/python -m app.simulation.seed_cli
SEED_PROFILE="$${PROFILE:-small}" $(BIN)/python -m app.simulation.seed_cli
clean: ## Remove generated local artifacts
rm -rf $(VENV) .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
+20 -2
View File
@@ -68,8 +68,26 @@ response, state, task, error, and permission dimensions.
Database migrations are ordered SQL files applied transactionally and recorded
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
applied migration is rejected instead of silently drifting the schema.
The initial `small` seed is deterministic and idempotent: it creates two nodes,
one stopped QEMU guest, and local storage with stable UUIDv5 identifiers.
Seed profiles are deterministic and replace the previously seeded simulation
state atomically. `small` creates one node, two QEMU guests, one LXC, two
storages, an administrator, and completed task history. `medium` creates three
nodes, 50 QEMU guests, 20 LXC guests, shared/local storage and a pool;
`ha-demo` adds HA state, while `broken-storage` makes one storage unavailable.
`large` uses bounded asyncpg batch operations and is configurable through
`SEED_LARGE_NODES` and `SEED_LARGE_RESOURCES` (10,000 resources by default):
```bash
make seed PROFILE=small
make seed PROFILE=medium
make seed PROFILE=large
make seed PROFILE=ha-demo
make seed PROFILE=broken-storage
```
All stable simulation identifiers use UUIDv5. Migration 004 adds normalized
cluster, QEMU, LXC, storage/content, snapshot, backup, pool, identity,
observation and fault-rule tables while generic resources remain the current
0.1 compatibility boundary.
Contract artifacts can be validated and imported into immutable local revisions:
+204
View File
@@ -0,0 +1,204 @@
CREATE TABLE clusters (
id uuid PRIMARY KEY,
external_id text NOT NULL UNIQUE,
name text NOT NULL,
version integer NOT NULL DEFAULT 1,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO clusters(id, external_id, name)
VALUES ('dc760c47-d8d7-57e6-9404-f0c6f2395d8f', 'default', 'pve-simulator');
ALTER TABLE nodes
ADD COLUMN cluster_id uuid NOT NULL DEFAULT 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f'
REFERENCES clusters(id) ON DELETE CASCADE,
ADD COLUMN version integer NOT NULL DEFAULT 1,
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE resources
ADD COLUMN cluster_id uuid NOT NULL DEFAULT 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f'
REFERENCES clusters(id) ON DELETE CASCADE,
ADD COLUMN version integer NOT NULL DEFAULT 1,
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
CREATE UNIQUE INDEX resources_cluster_vmid_idx
ON resources(cluster_id, external_id) WHERE kind IN ('qemu', 'lxc');
CREATE TABLE virtual_machines (
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
vmid integer NOT NULL CHECK (vmid BETWEEN 100 AND 999999999),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
lock text,
template boolean NOT NULL DEFAULT false,
UNIQUE (cluster_id, vmid)
);
CREATE TABLE containers (
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
vmid integer NOT NULL CHECK (vmid BETWEEN 100 AND 999999999),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
lock text,
template boolean NOT NULL DEFAULT false,
UNIQUE (cluster_id, vmid)
);
CREATE TABLE vm_disks (
id uuid PRIMARY KEY,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
device text NOT NULL,
storage_id text NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (resource_id, device)
);
CREATE TABLE vm_network_interfaces (
id uuid PRIMARY KEY,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
device text NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (resource_id, device)
);
CREATE TABLE storages (
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
storage_id text NOT NULL,
storage_type text NOT NULL,
shared boolean NOT NULL DEFAULT false,
capacity_bytes bigint CHECK (capacity_bytes >= 0),
used_bytes bigint CHECK (used_bytes >= 0),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (cluster_id, storage_id)
);
CREATE TABLE storage_contents (
id uuid PRIMARY KEY,
storage_resource_id uuid NOT NULL REFERENCES storages(resource_id) ON DELETE CASCADE,
volume_id text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (storage_resource_id, volume_id)
);
CREATE TABLE snapshots (
id uuid PRIMARY KEY,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
name text NOT NULL,
parent_name text,
description text,
state jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (resource_id, name)
);
CREATE TABLE backups (
id uuid PRIMARY KEY,
resource_id uuid REFERENCES resources(id) ON DELETE SET NULL,
storage_resource_id uuid NOT NULL REFERENCES storages(resource_id) ON DELETE CASCADE,
volume_id text NOT NULL,
size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (storage_resource_id, volume_id)
);
CREATE TABLE pools (
id uuid PRIMARY KEY,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
pool_id text NOT NULL,
comment text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (cluster_id, pool_id)
);
CREATE TABLE pool_members (
pool_id uuid NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
PRIMARY KEY (pool_id, resource_id)
);
CREATE TABLE identity_groups (
id uuid PRIMARY KEY,
group_id text NOT NULL UNIQUE,
comment text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE identity_group_members (
group_id uuid NOT NULL REFERENCES identity_groups(id) ON DELETE CASCADE,
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, principal_id)
);
CREATE TABLE auth_tickets (
id uuid PRIMARY KEY,
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
ticket_hash text NOT NULL UNIQUE,
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE INDEX auth_tickets_expiry_idx ON auth_tickets(expires_at) WHERE revoked_at IS NULL;
CREATE TABLE contract_paths (
snapshot_checksum text NOT NULL REFERENCES contract_snapshots(checksum) ON DELETE CASCADE,
path text NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (snapshot_checksum, path)
);
CREATE TABLE contract_methods (
snapshot_checksum text NOT NULL,
path text NOT NULL,
verb text NOT NULL,
fingerprint text NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (snapshot_checksum, path, verb),
FOREIGN KEY (snapshot_checksum, path)
REFERENCES contract_paths(snapshot_checksum, path) ON DELETE CASCADE
);
CREATE TABLE contract_parameters (
snapshot_checksum text NOT NULL,
path text NOT NULL,
verb text NOT NULL,
name text NOT NULL,
location text NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (snapshot_checksum, path, verb, name, location),
FOREIGN KEY (snapshot_checksum, path, verb)
REFERENCES contract_methods(snapshot_checksum, path, verb) ON DELETE CASCADE
);
CREATE TABLE contract_schema_fragments (
id uuid PRIMARY KEY,
snapshot_checksum text NOT NULL REFERENCES contract_snapshots(checksum) ON DELETE CASCADE,
fingerprint text NOT NULL,
document jsonb NOT NULL,
UNIQUE (snapshot_checksum, fingerprint)
);
CREATE TABLE observed_contracts (
id uuid PRIMARY KEY,
source_version text NOT NULL,
method_fingerprint text NOT NULL,
observation jsonb NOT NULL,
observed_at timestamptz NOT NULL,
UNIQUE (source_version, method_fingerprint, observed_at)
);
CREATE TABLE scenario_rules (
id uuid PRIMARY KEY,
scenario_id uuid NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
priority integer NOT NULL DEFAULT 0,
matcher jsonb NOT NULL,
action jsonb NOT NULL,
enabled boolean NOT NULL DEFAULT true
);
CREATE INDEX scenario_rules_scenario_priority_idx ON scenario_rules(scenario_id, priority DESC);
CREATE TABLE fault_injections (
id uuid PRIMARY KEY,
scenario_id uuid REFERENCES scenarios(id) ON DELETE CASCADE,
fault_type text NOT NULL,
matcher jsonb NOT NULL,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
active_from timestamptz,
active_until timestamptz,
enabled boolean NOT NULL DEFAULT true
);
+258 -24
View File
@@ -30,63 +30,197 @@ class SeedResource:
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": next(node.name for node in self.nodes if node.id == resource.node_id),
"node": names[resource.node_id],
"state": resource.state,
}
for resource in self.resources
]
return {"profile": self.name, "nodes": nodes, "resources": resources}
tasks = [
{"upid": task.upid, "task_type": task.task_type, "status": "success"}
for task in self.tasks
]
return {"profile": self.name, "nodes": nodes, "resources": resources, "tasks": tasks}
def stable_id(name: str) -> uuid.UUID:
return uuid.uuid5(NAMESPACE, name)
def _string_list(state: dict[str, object], key: str) -> tuple[str, ...]:
value = state.get(key, [])
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise ValueError(f"seed state {key} must be a string list")
return tuple(value)
def _node(name: str, status: str = "online") -> SeedNode:
return SeedNode(stable_id(f"node:{name}"), name, status)
def _resource(
node: SeedNode, kind: str, external_id: str, state: dict[str, object]
) -> SeedResource:
return SeedResource(stable_id(f"{kind}:{external_id}"), node.id, kind, external_id, state)
def _completed_task(index: int, task_type: str, resource_id: str) -> SeedTask:
return SeedTask(
stable_id(f"task:{index}:{task_type}:{resource_id}"),
f"UPID:pve1:0000000{index}:0000000{index}:6500000{index}:"
f"{task_type}:{resource_id}:root@pam:",
task_type,
{"resource_id": resource_id, "seeded": True},
)
def small_profile() -> SeedProfile:
first = SeedNode(stable_id("node:pve1"), "pve1", "online")
second = SeedNode(stable_id("node:pve2"), "pve2", "online")
node = _node("pve1")
resources = (
SeedResource(
stable_id("qemu:100"), first.id, "qemu", "100", {"name": "demo", "status": "stopped"}
),
SeedResource(
stable_id("qemu:101"),
first.id,
"qemu",
"101",
{"name": "worker", "status": "stopped"},
),
SeedResource(
stable_id("storage:local"), first.id, "storage", "local", {"content": ["iso", "backup"]}
_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"}
),
)
return SeedProfile("small", (first, second), resources)
tasks = (_completed_task(1, "qmstart", "100"), _completed_task(2, "qmstop", "100"))
return SeedProfile("small", (node,), resources, tasks)
def medium_profile() -> SeedProfile:
nodes = tuple(_node(f"pve{index}") for index in range(1, 4))
resources: list[SeedResource] = []
for vmid in range(100, 150):
node = nodes[(vmid - 100) % len(nodes)]
resources.append(
_resource(node, "qemu", str(vmid), {"name": f"vm-{vmid}", "status": "stopped"})
)
for vmid in range(200, 220):
node = nodes[(vmid - 200) % len(nodes)]
resources.append(
_resource(node, "lxc", str(vmid), {"name": f"ct-{vmid}", "status": "stopped"})
)
for node in nodes:
resources.append(
_resource(
node,
"storage",
f"local-{node.name}",
{"content": ["images"], "shared": False, "status": "available"},
)
)
resources.append(
_resource(
nodes[0],
"storage",
"shared",
{"content": ["images", "backup"], "shared": True, "status": "available"},
)
)
resources.append(_resource(nodes[0], "pool", "development", {"members": ["100", "101", "200"]}))
tasks = tuple(_completed_task(index, "qmstart", str(99 + index)) for index in range(1, 11))
return SeedProfile("medium", nodes, tuple(resources), tasks)
def large_profile(*, node_count: int = 10, resource_count: int = 10_000) -> SeedProfile:
if node_count < 1 or resource_count < 1:
raise ValueError("large profile counts must be positive")
nodes = tuple(_node(f"pve{index}") for index in range(1, node_count + 1))
resources = tuple(
_resource(
nodes[index % node_count],
"qemu" if index % 4 else "lxc",
str(100 + index),
{"name": f"guest-{100 + index}", "status": "stopped"},
)
for index in range(resource_count)
)
return SeedProfile("large", nodes, resources)
def ha_demo_profile() -> SeedProfile:
profile = medium_profile()
resources = (
*profile.resources,
_resource(profile.nodes[0], "ha", "vm:100", {"state": "started", "group": "primary"}),
)
return SeedProfile("ha-demo", profile.nodes, resources, profile.tasks)
def broken_storage_profile() -> SeedProfile:
profile = small_profile()
resources = tuple(
_resource(
next(node for node in profile.nodes if node.id == resource.node_id),
resource.kind,
resource.external_id,
{**resource.state, "status": "offline", "error": "simulated I/O failure"}
if resource.kind == "storage" and resource.external_id == "local-lvm"
else resource.state,
)
for resource in profile.resources
)
return SeedProfile("broken-storage", profile.nodes, resources, profile.tasks)
def build_profile(
name: str, *, large_nodes: int = 10, large_resources: int = 10_000
) -> SeedProfile:
if name == "small":
return small_profile()
if name == "medium":
return medium_profile()
if name == "large":
return large_profile(node_count=large_nodes, resource_count=large_resources)
if name == "ha-demo":
return ha_demo_profile()
if name == "broken-storage":
return broken_storage_profile()
raise ValueError(f"unknown seed profile: {name}")
async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
async with connection.transaction():
await connection.execute(
"""DELETE FROM task_logs;
DELETE FROM task_events;
DELETE FROM resource_locks;
DELETE FROM tasks;
DELETE FROM pool_members;
DELETE FROM pools;
DELETE FROM resources;
DELETE FROM nodes"""
)
await connection.executemany(
"""INSERT INTO nodes(id, name, status) VALUES($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, status=EXCLUDED.status""",
"INSERT INTO nodes(id, name, status) VALUES($1, $2, $3)",
[(node.id, node.name, node.status) for node in profile.nodes],
)
await connection.executemany(
"""INSERT INTO resources(id, node_id, kind, external_id, state)
VALUES($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (id) DO UPDATE SET node_id=EXCLUDED.node_id,
kind=EXCLUDED.kind, external_id=EXCLUDED.external_id, state=EXCLUDED.state""",
VALUES($1, $2, $3, $4, $5::jsonb)""",
[
(
resource.id,
@@ -98,6 +232,98 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
for resource in profile.resources
],
)
qemu = [resource for resource in profile.resources if resource.kind == "qemu"]
if qemu:
await connection.executemany(
"""INSERT INTO virtual_machines(resource_id, cluster_id, vmid, config)
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
[
(
resource.id,
int(resource.external_id),
json.dumps(resource.state, sort_keys=True),
)
for resource in qemu
],
)
containers = [resource for resource in profile.resources if resource.kind == "lxc"]
if containers:
await connection.executemany(
"""INSERT INTO containers(resource_id, cluster_id, vmid, config)
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
[
(
resource.id,
int(resource.external_id),
json.dumps(resource.state, sort_keys=True),
)
for resource in containers
],
)
storages = [resource for resource in profile.resources if resource.kind == "storage"]
if storages:
await connection.executemany(
"""INSERT INTO storages(
resource_id, cluster_id, storage_id, storage_type, shared, config
) VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3, $4, $5::jsonb)""",
[
(
resource.id,
resource.external_id,
"dir" if resource.external_id.startswith("local") else "nfs",
bool(resource.state.get("shared", False)),
json.dumps(resource.state, sort_keys=True),
)
for resource in storages
],
)
contents = [
(
stable_id(f"content:{resource.external_id}:{content}"),
resource.id,
f"{resource.external_id}:{content}/seeded",
str(content),
)
for resource in storages
for content in _string_list(resource.state, "content")
]
if contents:
await connection.executemany(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type
) VALUES($1, $2, $3, $4)""",
contents,
)
pools = [resource for resource in profile.resources if resource.kind == "pool"]
if pools:
await connection.executemany(
"""INSERT INTO pools(id, cluster_id, pool_id, metadata)
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
[
(resource.id, resource.external_id, json.dumps(resource.state, sort_keys=True))
for resource in pools
],
)
members = [
(pool.id, member.id)
for pool in pools
for external_id in _string_list(pool.state, "members")
for member in profile.resources
if member.external_id == external_id and member.kind in {"qemu", "lxc"}
]
if members:
await connection.executemany(
"INSERT INTO pool_members(pool_id, resource_id) VALUES($1, $2)", members
)
if profile.tasks:
await connection.executemany(
"""INSERT INTO tasks(id, upid, status, payload, task_type, progress, result)
VALUES($1, $2, 'success', $3::jsonb, $4, 100, '{\"seeded\":true}'::jsonb)""",
[
(task.id, task.upid, json.dumps(task.payload, sort_keys=True), task.task_type)
for task in profile.tasks
],
)
await connection.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES($1, 'root@pam', $2, 'pam')
@@ -108,10 +334,18 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
)
async def seed_url(database_url: str) -> dict[str, object]:
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 = small_profile()
profile = build_profile(
profile_name, large_nodes=large_nodes, large_resources=large_resources
)
await apply_seed(connection, profile)
return profile.logical_state()
finally:
+7 -1
View File
@@ -2,13 +2,19 @@
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())
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))
+2 -2
View File
@@ -31,9 +31,9 @@ live admin report renders the same evidence deterministically.
VMID uniqueness.
- [ ] Make migration readiness explicit so workers cannot become permanently
unhealthy before schema creation.
- [ ] Match the required `small` profile (one node, two QEMU, one LXC, two
- [x] Match the required `small` profile (one node, two QEMU, one LXC, two
storages, administrator, completed tasks).
- [ ] Implement deterministic `medium`, configurable batch-insert `large`,
- [x] Implement deterministic `medium`, configurable batch-insert `large`,
`ha-demo`, and `broken-storage` profiles.
Exit: clean migration plus every seed profile has a stable logical snapshot;
+9 -6
View File
@@ -43,15 +43,18 @@ async def test_small_seed_is_idempotent() -> None:
await migrate(connection)
await apply_seed(connection, small_profile())
await apply_seed(connection, small_profile())
assert (
await connection.fetchval("SELECT count(*) FROM nodes WHERE name IN ('pve1', 'pve2')")
== 2
)
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve1'") == 1
assert (
await connection.fetchval(
"SELECT count(*) FROM resources WHERE external_id IN ('100', 'local')"
"""SELECT count(*) FROM resources
WHERE external_id IN ('100', '101', '200', 'local', 'local-lvm')"""
)
== 2
== 5
)
assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2
assert await connection.fetchval("SELECT count(*) FROM containers") == 1
assert await connection.fetchval("SELECT count(*) FROM storages") == 2
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
finally:
await connection.close()
+17
View File
@@ -33,3 +33,20 @@ def test_repository_migration_defines_required_planes() -> None:
assert f"CREATE TABLE {table}" in migration.sql
assert "CREATE TABLE realms" in migrations[1].sql
assert "CREATE TABLE api_tokens" in migrations[1].sql
domain = migrations[3].sql
for table in (
"clusters",
"virtual_machines",
"containers",
"storages",
"storage_contents",
"snapshots",
"backups",
"pools",
"identity_groups",
"contract_paths",
"observed_contracts",
"scenario_rules",
"fault_injections",
):
assert f"CREATE TABLE {table}" in domain
+40 -29
View File
@@ -1,40 +1,51 @@
"""Deterministic seed profile tests."""
from app.simulation.seed import small_profile, stable_id
import pytest
from app.simulation.seed import build_profile, large_profile, small_profile, stable_id
def test_small_profile_has_stable_logical_state() -> None:
def test_small_profile_matches_required_logical_shape() -> None:
first = small_profile()
second = small_profile()
assert first == second
assert first.logical_state() == {
"profile": "small",
"nodes": [
{"name": "pve1", "status": "online"},
{"name": "pve2", "status": "online"},
],
"resources": [
{
"kind": "qemu",
"external_id": "100",
"node": "pve1",
"state": {"name": "demo", "status": "stopped"},
},
{
"kind": "qemu",
"external_id": "101",
"node": "pve1",
"state": {"name": "worker", "status": "stopped"},
},
{
"kind": "storage",
"external_id": "local",
"node": "pve1",
"state": {"content": ["iso", "backup"]},
},
],
}
state = first.logical_state()
assert state == second.logical_state()
assert state["nodes"] == [{"name": "pve1", "status": "online"}]
resources = state["resources"]
assert isinstance(resources, list)
assert [resource["kind"] for resource in resources].count("qemu") == 2
assert [resource["kind"] for resource in resources].count("lxc") == 1
assert [resource["kind"] for resource in resources].count("storage") == 2
tasks = state["tasks"]
assert isinstance(tasks, list)
assert len(tasks) == 2
def test_medium_and_fault_profiles_are_deterministic() -> None:
medium = build_profile("medium")
assert len(medium.nodes) == 3
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
assert build_profile("ha-demo") == build_profile("ha-demo")
broken = build_profile("broken-storage")
assert any(resource.state.get("status") == "offline" for resource in broken.resources)
def test_large_profile_is_configurable_and_stable() -> None:
first = large_profile(node_count=4, resource_count=1_000)
second = large_profile(node_count=4, resource_count=1_000)
assert first == second
assert len(first.nodes) == 4
assert len(first.resources) == 1_000
def test_profile_validation() -> None:
with pytest.raises(ValueError, match="unknown seed profile"):
build_profile("missing")
with pytest.raises(ValueError, match="positive"):
large_profile(node_count=0, resource_count=1)
def test_stable_ids_are_namespaced_and_repeatable() -> None: