Initial release of the oVirt/RHV Engine API simulator.
Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""PostgreSQL infrastructure."""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Apply configured database migrations."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.migrations import migrate_url
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
settings = get_settings()
|
||||
count = await migrate_url(settings.database_url.get_secret_value())
|
||||
print(f"applied {count} migration(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Checksummed asynchronous PostgreSQL migration runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Connection
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Migration:
|
||||
version: int
|
||||
name: str
|
||||
sql: str
|
||||
checksum: str
|
||||
|
||||
|
||||
def load_migrations(root: Path | None = None) -> tuple[Migration, ...]:
|
||||
directory = root or Path(__file__).with_name("migrations")
|
||||
migrations = []
|
||||
for path in sorted(directory.glob("[0-9][0-9][0-9]_*.sql")):
|
||||
version = int(path.name.split("_", 1)[0])
|
||||
sql = path.read_text()
|
||||
migrations.append(
|
||||
Migration(version, path.stem, sql, hashlib.sha256(sql.encode()).hexdigest())
|
||||
)
|
||||
return tuple(migrations)
|
||||
|
||||
|
||||
async def migrate(connection: Connection, migrations: tuple[Migration, ...] | None = None) -> int:
|
||||
await connection.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version integer PRIMARY KEY, name text NOT NULL, checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now())"""
|
||||
)
|
||||
applied = {
|
||||
int(row["version"]): str(row["checksum"])
|
||||
for row in await connection.fetch("SELECT version, checksum FROM schema_migrations")
|
||||
}
|
||||
count = 0
|
||||
for migration in migrations or load_migrations():
|
||||
if migration.version in applied:
|
||||
if applied[migration.version] != migration.checksum:
|
||||
raise MigrationError(f"migration {migration.version} checksum mismatch")
|
||||
continue
|
||||
async with connection.transaction():
|
||||
await connection.execute(migration.sql)
|
||||
await connection.execute(
|
||||
"INSERT INTO schema_migrations(version, name, checksum) VALUES($1, $2, $3)",
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def migrate_url(database_url: str) -> int:
|
||||
connection = await asyncpg.connect(database_url)
|
||||
try:
|
||||
return await migrate(connection)
|
||||
finally:
|
||||
await connection.close()
|
||||
@@ -0,0 +1,325 @@
|
||||
-- oVirt Engine identity, inventory, and generic API object store.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_domains (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_users (
|
||||
id uuid PRIMARY KEY,
|
||||
domain_id uuid NOT NULL REFERENCES ov_domains(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
principal text NOT NULL DEFAULT '',
|
||||
UNIQUE (domain_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_groups (
|
||||
id uuid PRIMARY KEY,
|
||||
domain_id uuid NOT NULL REFERENCES ov_domains(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
UNIQUE (domain_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_roles (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
administrative boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_permissions (
|
||||
id uuid PRIMARY KEY,
|
||||
role_id uuid NOT NULL REFERENCES ov_roles(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES ov_users(id) ON DELETE CASCADE,
|
||||
group_id uuid REFERENCES ov_groups(id) ON DELETE CASCADE,
|
||||
object_type text NOT NULL DEFAULT 'system',
|
||||
object_id uuid,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_tokens (
|
||||
id text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES ov_users(id) ON DELETE CASCADE,
|
||||
scope text NOT NULL DEFAULT 'ovirt-app-api',
|
||||
expires_at timestamptz NOT NULL,
|
||||
issued_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked boolean NOT NULL DEFAULT false
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ov_tokens_user_idx ON ov_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS ov_tokens_expires_idx ON ov_tokens(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_datacenters (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
local boolean NOT NULL DEFAULT false,
|
||||
status text NOT NULL DEFAULT 'up',
|
||||
version_major integer NOT NULL DEFAULT 4,
|
||||
version_minor integer NOT NULL DEFAULT 5,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_clusters (
|
||||
id uuid PRIMARY KEY,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
cpu_type text NOT NULL DEFAULT 'Intel Conroe Family',
|
||||
version_major integer NOT NULL DEFAULT 4,
|
||||
version_minor integer NOT NULL DEFAULT 5,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (datacenter_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_hosts (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL UNIQUE,
|
||||
address text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'up',
|
||||
type text NOT NULL DEFAULT 'rhel',
|
||||
memory bigint NOT NULL DEFAULT 0,
|
||||
cpu_cores integer NOT NULL DEFAULT 8,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_networks (
|
||||
id uuid PRIMARY KEY,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
vlan_id integer,
|
||||
stp boolean NOT NULL DEFAULT false,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (datacenter_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_vnic_profiles (
|
||||
id uuid PRIMARY KEY,
|
||||
network_id uuid NOT NULL REFERENCES ov_networks(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
pass_through boolean NOT NULL DEFAULT false,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (network_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_storage_domains (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
type text NOT NULL DEFAULT 'data',
|
||||
storage_type text NOT NULL DEFAULT 'nfs',
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
available bigint NOT NULL DEFAULT 0,
|
||||
used bigint NOT NULL DEFAULT 0,
|
||||
committed bigint NOT NULL DEFAULT 0,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_storage_domain_attachments (
|
||||
id uuid PRIMARY KEY,
|
||||
storage_domain_id uuid NOT NULL REFERENCES ov_storage_domains(id) ON DELETE CASCADE,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
UNIQUE (storage_domain_id, datacenter_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_storage_connections (
|
||||
id uuid PRIMARY KEY,
|
||||
type text NOT NULL DEFAULT 'nfs',
|
||||
address text NOT NULL DEFAULT '',
|
||||
path text NOT NULL DEFAULT '',
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_templates (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid REFERENCES ov_clusters(id) ON DELETE SET NULL,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
memory bigint NOT NULL DEFAULT 1073741824,
|
||||
cpu_sockets integer NOT NULL DEFAULT 1,
|
||||
cpu_cores integer NOT NULL DEFAULT 1,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_vms (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
|
||||
template_id uuid REFERENCES ov_templates(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'down',
|
||||
memory bigint NOT NULL DEFAULT 1073741824,
|
||||
cpu_sockets integer NOT NULL DEFAULT 1,
|
||||
cpu_cores integer NOT NULL DEFAULT 1,
|
||||
cpu_threads integer NOT NULL DEFAULT 1,
|
||||
os_type text NOT NULL DEFAULT 'other',
|
||||
type text NOT NULL DEFAULT 'server',
|
||||
host_id uuid REFERENCES ov_hosts(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (cluster_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ov_vms_status_idx ON ov_vms(status);
|
||||
CREATE INDEX IF NOT EXISTS ov_vms_name_idx ON ov_vms(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_disks (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
provisioned_size bigint NOT NULL DEFAULT 0,
|
||||
actual_size bigint NOT NULL DEFAULT 0,
|
||||
format text NOT NULL DEFAULT 'cow',
|
||||
sparse boolean NOT NULL DEFAULT true,
|
||||
shareable boolean NOT NULL DEFAULT false,
|
||||
wipe_after_delete boolean NOT NULL DEFAULT false,
|
||||
storage_domain_id uuid REFERENCES ov_storage_domains(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_disk_attachments (
|
||||
id uuid PRIMARY KEY,
|
||||
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
|
||||
disk_id uuid NOT NULL REFERENCES ov_disks(id) ON DELETE CASCADE,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
bootable boolean NOT NULL DEFAULT false,
|
||||
interface text NOT NULL DEFAULT 'virtio_scsi',
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (vm_id, disk_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_nics (
|
||||
id uuid PRIMARY KEY,
|
||||
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
interface text NOT NULL DEFAULT 'virtio',
|
||||
linked boolean NOT NULL DEFAULT true,
|
||||
plugged boolean NOT NULL DEFAULT true,
|
||||
mac_address text NOT NULL DEFAULT '',
|
||||
vnic_profile_id uuid REFERENCES ov_vnic_profiles(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (vm_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_snapshots (
|
||||
id uuid PRIMARY KEY,
|
||||
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
snapshot_type text NOT NULL DEFAULT 'user',
|
||||
persist_memorystate boolean NOT NULL DEFAULT false,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_tags (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_tag_assignments (
|
||||
id uuid PRIMARY KEY,
|
||||
tag_id uuid NOT NULL REFERENCES ov_tags(id) ON DELETE CASCADE,
|
||||
object_type text NOT NULL,
|
||||
object_id uuid NOT NULL,
|
||||
UNIQUE (tag_id, object_type, object_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_affinity_groups (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
enforcing boolean NOT NULL DEFAULT true,
|
||||
positive boolean NOT NULL DEFAULT true,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (cluster_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_quotas (
|
||||
id uuid PRIMARY KEY,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (datacenter_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_bookmarks (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
value text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
code integer NOT NULL DEFAULT 0,
|
||||
severity text NOT NULL DEFAULT 'normal',
|
||||
description text NOT NULL DEFAULT '',
|
||||
time timestamptz NOT NULL DEFAULT now(),
|
||||
user_id uuid REFERENCES ov_users(id) ON DELETE SET NULL,
|
||||
vm_id uuid REFERENCES ov_vms(id) ON DELETE SET NULL,
|
||||
host_id uuid REFERENCES ov_hosts(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_jobs (
|
||||
id uuid PRIMARY KEY,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'started',
|
||||
auto_cleared boolean NOT NULL DEFAULT true,
|
||||
started timestamptz NOT NULL DEFAULT now(),
|
||||
ended timestamptz,
|
||||
owner_id uuid REFERENCES ov_users(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_job_steps (
|
||||
id uuid PRIMARY KEY,
|
||||
job_id uuid NOT NULL REFERENCES ov_jobs(id) ON DELETE CASCADE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'started',
|
||||
type text NOT NULL DEFAULT 'validating',
|
||||
number integer NOT NULL DEFAULT 1,
|
||||
started timestamptz NOT NULL DEFAULT now(),
|
||||
ended timestamptz,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- Generic store for surface-complete collections not given dedicated tables.
|
||||
CREATE TABLE IF NOT EXISTS ov_api_objects (
|
||||
id uuid PRIMARY KEY,
|
||||
collection text NOT NULL,
|
||||
name text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
parent_collection text,
|
||||
parent_id uuid,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ov_api_objects_collection_idx ON ov_api_objects(collection);
|
||||
CREATE INDEX IF NOT EXISTS ov_api_objects_parent_idx ON ov_api_objects(parent_collection, parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_demo_meta (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_runtime_meta (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Small typed asyncpg pool boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Self, cast
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Pool
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.migrations import load_migrations
|
||||
|
||||
LATEST_SCHEMA_VERSION = max(migration.version for migration in load_migrations())
|
||||
|
||||
|
||||
class Database(Protocol):
|
||||
"""Application-facing database lifecycle and health interface."""
|
||||
|
||||
async def connect(self) -> None: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
async def is_ready(self) -> bool: ...
|
||||
|
||||
|
||||
class AsyncpgDatabase:
|
||||
"""Own an asyncpg pool without exposing it as global mutable state."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._pool: Pool | None = None
|
||||
|
||||
@property
|
||||
def pool(self) -> Pool:
|
||||
"""Return the initialized pool to repository factories."""
|
||||
|
||||
if self._pool is None:
|
||||
message = "database pool is not initialized"
|
||||
raise RuntimeError(message)
|
||||
return self._pool
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Create the pool and verify the first connection."""
|
||||
|
||||
if self._pool is not None:
|
||||
return
|
||||
settings = self._settings
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=settings.database_url.get_secret_value(),
|
||||
min_size=settings.db_pool_min_size,
|
||||
max_size=settings.db_pool_max_size,
|
||||
timeout=settings.db_connect_timeout_seconds,
|
||||
command_timeout=settings.db_command_timeout_seconds,
|
||||
)
|
||||
if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons
|
||||
message = "asyncpg did not create a pool"
|
||||
raise RuntimeError(message)
|
||||
self._pool = cast(Pool, pool)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close all pooled connections; repeated close is safe."""
|
||||
|
||||
pool, self._pool = self._pool, None
|
||||
if pool is not None:
|
||||
await pool.close()
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
"""Check connectivity and that all packaged migrations are applied."""
|
||||
|
||||
if self._pool is None:
|
||||
return False
|
||||
try:
|
||||
return bool(
|
||||
await self._pool.fetchval(
|
||||
"""SELECT COALESCE(max(version), 0) >= $1
|
||||
FROM schema_migrations""",
|
||||
LATEST_SCHEMA_VERSION,
|
||||
)
|
||||
)
|
||||
except asyncpg.PostgresError:
|
||||
return False
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
await self.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Typed transactional helpers and stable database error mapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Connection, Pool
|
||||
|
||||
|
||||
class DatabaseOperationError(RuntimeError):
|
||||
"""Safe base error for repository operations."""
|
||||
|
||||
|
||||
class ConflictError(DatabaseOperationError):
|
||||
pass
|
||||
|
||||
|
||||
class ReferenceError(DatabaseOperationError):
|
||||
pass
|
||||
|
||||
|
||||
class TransientDatabaseError(DatabaseOperationError):
|
||||
pass
|
||||
|
||||
|
||||
def map_database_error(error: asyncpg.PostgresError) -> DatabaseOperationError:
|
||||
if isinstance(error, asyncpg.UniqueViolationError):
|
||||
return ConflictError("database uniqueness constraint failed")
|
||||
if isinstance(error, asyncpg.ForeignKeyViolationError):
|
||||
return ReferenceError("database reference constraint failed")
|
||||
if isinstance(
|
||||
error,
|
||||
asyncpg.SerializationError
|
||||
| asyncpg.DeadlockDetectedError
|
||||
| asyncpg.TooManyConnectionsError,
|
||||
):
|
||||
return TransientDatabaseError("transient database failure")
|
||||
return DatabaseOperationError("database operation failed")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(pool: Pool) -> AsyncIterator[Connection]:
|
||||
async with pool.acquire() as connection:
|
||||
try:
|
||||
async with connection.transaction():
|
||||
yield connection
|
||||
except asyncpg.PostgresError as error:
|
||||
raise map_database_error(error) from error
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def savepoint(connection: Connection) -> AsyncIterator[Connection]:
|
||||
try:
|
||||
async with connection.transaction():
|
||||
yield connection
|
||||
except asyncpg.PostgresError as error:
|
||||
raise map_database_error(error) from error
|
||||
|
||||
|
||||
def require_affected(status: str, expected: int = 1) -> None:
|
||||
try:
|
||||
affected = int(status.rsplit(" ", 1)[1])
|
||||
except (IndexError, ValueError) as error:
|
||||
raise DatabaseOperationError(f"unrecognized command status: {status}") from error
|
||||
if affected != expected:
|
||||
raise DatabaseOperationError(f"expected {expected} affected row(s), got {affected}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RetryPolicy:
|
||||
attempts: int = 3
|
||||
base_delay_seconds: float = 0.02
|
||||
|
||||
|
||||
DEFAULT_RETRY_POLICY = RetryPolicy()
|
||||
|
||||
|
||||
async def retry_transient[T](
|
||||
operation: Callable[[], Awaitable[T]], policy: RetryPolicy = DEFAULT_RETRY_POLICY
|
||||
) -> T:
|
||||
if policy.attempts < 1:
|
||||
raise ValueError("retry attempts must be positive")
|
||||
for attempt in range(policy.attempts):
|
||||
try:
|
||||
return await operation()
|
||||
except TransientDatabaseError:
|
||||
if attempt + 1 == policy.attempts:
|
||||
raise
|
||||
await asyncio.sleep(policy.base_delay_seconds * (2**attempt))
|
||||
raise RuntimeError("unreachable retry state")
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed PostgreSQL repositories for simulation domain state."""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Typed resource persistence with explicit optimistic locking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from asyncpg import Pool # type: ignore[import-untyped]
|
||||
|
||||
from app.db.primitives import ConflictError, transaction
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceRecord:
|
||||
id: uuid.UUID
|
||||
cluster_id: uuid.UUID
|
||||
node: str
|
||||
kind: str
|
||||
external_id: str
|
||||
state: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
version: int
|
||||
|
||||
|
||||
def _json_object(value: object) -> dict[str, Any]:
|
||||
if isinstance(value, str):
|
||||
return cast(dict[str, Any], json.loads(value))
|
||||
return dict(cast(Mapping[str, Any], value))
|
||||
|
||||
|
||||
def _record(row: Mapping[str, object]) -> ResourceRecord:
|
||||
return ResourceRecord(
|
||||
id=cast(uuid.UUID, row["id"]),
|
||||
cluster_id=cast(uuid.UUID, row["cluster_id"]),
|
||||
node=str(row["node"]),
|
||||
kind=str(row["kind"]),
|
||||
external_id=str(row["external_id"]),
|
||||
state=_json_object(row["state"]),
|
||||
metadata=_json_object(row["metadata"]),
|
||||
version=int(cast(int, row["version"])),
|
||||
)
|
||||
|
||||
|
||||
class ResourceRepository:
|
||||
def __init__(self, pool: Pool) -> None:
|
||||
self._pool = pool
|
||||
|
||||
async def list(
|
||||
self, *, kind: str | None = None, node: str | None = None
|
||||
) -> list[ResourceRecord]:
|
||||
rows = await self._pool.fetch(
|
||||
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
|
||||
r.state, r.metadata, r.version
|
||||
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||
WHERE ($1::text IS NULL OR r.kind=$1)
|
||||
AND ($2::text IS NULL OR n.name=$2)
|
||||
ORDER BY r.kind, r.external_id""",
|
||||
kind,
|
||||
node,
|
||||
)
|
||||
return [_record(row) for row in rows]
|
||||
|
||||
async def get(self, *, kind: str, external_id: str) -> ResourceRecord | None:
|
||||
row = await self._pool.fetchrow(
|
||||
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
|
||||
r.state, r.metadata, r.version
|
||||
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||
WHERE r.kind=$1 AND r.external_id=$2""",
|
||||
kind,
|
||||
external_id,
|
||||
)
|
||||
return None if row is None else _record(row)
|
||||
|
||||
async def update_state(
|
||||
self,
|
||||
resource_id: uuid.UUID,
|
||||
*,
|
||||
expected_version: int,
|
||||
state: Mapping[str, object],
|
||||
) -> ResourceRecord:
|
||||
async with transaction(self._pool) as connection:
|
||||
row = await connection.fetchrow(
|
||||
"""UPDATE resources SET state=$3::jsonb, version=version+1,
|
||||
updated_at=now() WHERE id=$1 AND version=$2
|
||||
RETURNING id, cluster_id,
|
||||
(SELECT name FROM nodes WHERE id=resources.node_id) AS node,
|
||||
kind, external_id, state, metadata, version""",
|
||||
resource_id,
|
||||
expected_version,
|
||||
json.dumps(dict(state), sort_keys=True),
|
||||
)
|
||||
if row is None:
|
||||
raise ConflictError("resource version conflict or resource missing")
|
||||
return _record(row)
|
||||
Reference in New Issue
Block a user