diff --git a/Makefile b/Makefile index cb854c2..2782a03 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ api-diff: ## Compare API snapshots $(BIN)/proxmox-api-contract diff $(ARGS) seed: ## Seed simulation data - @echo "Database seed is scheduled for milestone D2" >&2; exit 2 + $(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 7349905..2d93694 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,14 @@ make docker-up curl http://localhost:8006/health/live curl http://localhost:8006/health/ready make db-migrate +make seed ``` 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. Contract artifacts can be validated and imported into immutable local revisions: diff --git a/app/simulation/__init__.py b/app/simulation/__init__.py new file mode 100644 index 0000000..8026428 --- /dev/null +++ b/app/simulation/__init__.py @@ -0,0 +1 @@ +"""Persistent deterministic simulation services.""" diff --git a/app/simulation/seed.py b/app/simulation/seed.py new file mode 100644 index 0000000..991505a --- /dev/null +++ b/app/simulation/seed.py @@ -0,0 +1,101 @@ +"""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 + +NAMESPACE = uuid.UUID("c9040a72-b391-4a7e-9864-3ae46291a531") + + +@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 SeedProfile: + name: str + nodes: tuple[SeedNode, ...] + resources: tuple[SeedResource, ...] + + def logical_state(self) -> dict[str, object]: + nodes = [{"name": node.name, "status": node.status} 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), + "state": resource.state, + } + for resource in self.resources + ] + return {"profile": self.name, "nodes": nodes, "resources": resources} + + +def stable_id(name: str) -> uuid.UUID: + return uuid.uuid5(NAMESPACE, name) + + +def small_profile() -> SeedProfile: + first = SeedNode(stable_id("node:pve1"), "pve1", "online") + second = SeedNode(stable_id("node:pve2"), "pve2", "online") + resources = ( + SeedResource( + stable_id("qemu:100"), first.id, "qemu", "100", {"name": "demo", "status": "stopped"} + ), + SeedResource( + stable_id("storage:local"), first.id, "storage", "local", {"content": ["iso", "backup"]} + ), + ) + return SeedProfile("small", (first, second), resources) + + +async def apply_seed(connection: Connection, profile: SeedProfile) -> None: + async with connection.transaction(): + 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""", + [(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""", + [ + ( + resource.id, + resource.node_id, + resource.kind, + resource.external_id, + json.dumps(resource.state, sort_keys=True), + ) + for resource in profile.resources + ], + ) + + +async def seed_url(database_url: str) -> dict[str, object]: + connection = await asyncpg.connect(database_url) + try: + profile = small_profile() + await apply_seed(connection, profile) + return profile.logical_state() + finally: + await connection.close() diff --git a/app/simulation/seed_cli.py b/app/simulation/seed_cli.py new file mode 100644 index 0000000..b85797c --- /dev/null +++ b/app/simulation/seed_cli.py @@ -0,0 +1,16 @@ +"""Apply a deterministic simulation seed.""" + +import asyncio +import json + +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()) + print(json.dumps(state, sort_keys=True)) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 02aaffd..7e1f472 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -7,6 +7,7 @@ import asyncpg # type: ignore[import-untyped] import pytest from app.db.migrations import migrate +from app.simulation.seed import apply_seed, small_profile pytestmark = [ pytest.mark.integration, @@ -34,3 +35,23 @@ async def test_migration_is_repeatable_and_constraints_hold() -> None: ) finally: await connection.close() + + +async def test_small_seed_is_idempotent() -> None: + connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"]) + try: + 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 resources WHERE external_id IN ('100', 'local')" + ) + == 2 + ) + finally: + await connection.close() diff --git a/tests/unit/test_seed.py b/tests/unit/test_seed.py new file mode 100644 index 0000000..055018f --- /dev/null +++ b/tests/unit/test_seed.py @@ -0,0 +1,36 @@ +"""Deterministic seed profile tests.""" + +from app.simulation.seed import small_profile, stable_id + + +def test_small_profile_has_stable_logical_state() -> 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": "storage", + "external_id": "local", + "node": "pve1", + "state": {"content": ["iso", "backup"]}, + }, + ], + } + + +def test_stable_ids_are_namespaced_and_repeatable() -> None: + assert stable_id("qemu:100") == stable_id("qemu:100") + assert stable_id("qemu:100") != stable_id("qemu:101")