feat: expand domain schema and seed profiles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user