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:
2026-07-18 04:49:28 +03:00
commit cbd0adca91
218 changed files with 246804 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL infrastructure."""
+16
View File
@@ -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())
+70
View File
@@ -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()
+325
View File
@@ -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
);
+88
View File
@@ -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()
+94
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Typed PostgreSQL repositories for simulation domain state."""
+97
View File
@@ -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)