From 67f75e54845876e87c369acf4d65c00f49b0fa0c Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Mon, 13 Jul 2026 01:13:26 +0300 Subject: [PATCH] feat: expand domain schema and seed profiles --- Makefile | 2 +- README.md | 22 +- app/db/migrations/004_domain_model.sql | 204 ++++++++++++++++++ app/simulation/seed.py | 282 ++++++++++++++++++++++--- app/simulation/seed_cli.py | 8 +- docs/original-prompt-gap-plan.md | 4 +- tests/integration/test_migrations.py | 15 +- tests/unit/test_migrations.py | 17 ++ tests/unit/test_seed.py | 69 +++--- 9 files changed, 558 insertions(+), 65 deletions(-) create mode 100644 app/db/migrations/004_domain_model.sql diff --git a/Makefile b/Makefile index 2782a03..40af0d1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 3911d65..afe7e81 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/db/migrations/004_domain_model.sql b/app/db/migrations/004_domain_model.sql new file mode 100644 index 0000000..494142f --- /dev/null +++ b/app/db/migrations/004_domain_model.sql @@ -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 +); diff --git a/app/simulation/seed.py b/app/simulation/seed.py index aaedcab..1a3cb1b 100644 --- a/app/simulation/seed.py +++ b/app/simulation/seed.py @@ -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: diff --git a/app/simulation/seed_cli.py b/app/simulation/seed_cli.py index b85797c..a7d3f9a 100644 --- a/app/simulation/seed_cli.py +++ b/app/simulation/seed_cli.py @@ -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)) diff --git a/docs/original-prompt-gap-plan.md b/docs/original-prompt-gap-plan.md index 6e1315d..ddb0c9b 100644 --- a/docs/original-prompt-gap-plan.md +++ b/docs/original-prompt-gap-plan.md @@ -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; diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 7e1f472..fdd215a 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -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() diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index 795de9d..566821a 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -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 diff --git a/tests/unit/test_seed.py b/tests/unit/test_seed.py index 7432317..484b5cf 100644 --- a/tests/unit/test_seed.py +++ b/tests/unit/test_seed.py @@ -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: