feat: add deterministic small cluster seed
This commit is contained in:
@@ -76,7 +76,7 @@ api-diff: ## Compare API snapshots
|
|||||||
$(BIN)/proxmox-api-contract diff $(ARGS)
|
$(BIN)/proxmox-api-contract diff $(ARGS)
|
||||||
|
|
||||||
seed: ## Seed simulation data
|
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
|
clean: ## Remove generated local artifacts
|
||||||
rm -rf $(VENV) .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
|
rm -rf $(VENV) .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ make docker-up
|
|||||||
curl http://localhost:8006/health/live
|
curl http://localhost:8006/health/live
|
||||||
curl http://localhost:8006/health/ready
|
curl http://localhost:8006/health/ready
|
||||||
make db-migrate
|
make db-migrate
|
||||||
|
make seed
|
||||||
```
|
```
|
||||||
|
|
||||||
Database migrations are ordered SQL files applied transactionally and recorded
|
Database migrations are ordered SQL files applied transactionally and recorded
|
||||||
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
|
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
|
||||||
applied migration is rejected instead of silently drifting the schema.
|
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:
|
Contract artifacts can be validated and imported into immutable local revisions:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Persistent deterministic simulation services."""
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -7,6 +7,7 @@ import asyncpg # type: ignore[import-untyped]
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.db.migrations import migrate
|
from app.db.migrations import migrate
|
||||||
|
from app.simulation.seed import apply_seed, small_profile
|
||||||
|
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
pytest.mark.integration,
|
pytest.mark.integration,
|
||||||
@@ -34,3 +35,23 @@ async def test_migration_is_repeatable_and_constraints_hold() -> None:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await connection.close()
|
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()
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user