Initial commit: VMware vSphere API simulator scaffold.
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
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,60 @@
|
||||
CREATE TABLE contract_snapshots (
|
||||
checksum text PRIMARY KEY,
|
||||
source_version text NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE nodes (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
status text NOT NULL CHECK (status IN ('online', 'offline'))
|
||||
);
|
||||
CREATE TABLE resources (
|
||||
id uuid PRIMARY KEY,
|
||||
node_id uuid NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
|
||||
kind text NOT NULL,
|
||||
external_id text NOT NULL,
|
||||
state jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (kind, external_id)
|
||||
);
|
||||
CREATE INDEX resources_node_id_idx ON resources(node_id);
|
||||
CREATE TABLE principals (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
password_hash text
|
||||
);
|
||||
CREATE TABLE roles (
|
||||
name text PRIMARY KEY,
|
||||
privileges text[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE acl_entries (
|
||||
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE RESTRICT,
|
||||
path text NOT NULL,
|
||||
propagate boolean NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (principal_id, role_name, path)
|
||||
);
|
||||
CREATE TABLE tasks (
|
||||
id uuid PRIMARY KEY,
|
||||
upid text NOT NULL UNIQUE,
|
||||
status text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX tasks_status_created_idx ON tasks(status, created_at);
|
||||
CREATE TABLE scenarios (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
definition jsonb NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true
|
||||
);
|
||||
CREATE TABLE audit_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
principal text,
|
||||
action text NOT NULL,
|
||||
target text,
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE INDEX audit_events_occurred_idx ON audit_events(occurred_at);
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE realms (
|
||||
name text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('pam', 'pve', 'openid', 'ldap'))
|
||||
);
|
||||
INSERT INTO realms(name, kind) VALUES ('pam', 'pam'), ('pve', 'pve');
|
||||
ALTER TABLE principals ADD COLUMN realm_name text REFERENCES realms(name) ON DELETE RESTRICT;
|
||||
CREATE TABLE api_tokens (
|
||||
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||
token_id text NOT NULL,
|
||||
secret_hash text NOT NULL,
|
||||
privileges text[] NOT NULL DEFAULT '{}',
|
||||
expires_at timestamptz,
|
||||
PRIMARY KEY (principal_id, token_id),
|
||||
CHECK (secret_hash LIKE 'scrypt$%')
|
||||
);
|
||||
CREATE INDEX api_tokens_expires_idx ON api_tokens(expires_at) WHERE expires_at IS NOT NULL;
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN task_type text NOT NULL DEFAULT 'generic',
|
||||
ADD COLUMN progress integer NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
|
||||
ADD COLUMN result jsonb,
|
||||
ADD COLUMN error text,
|
||||
ADD COLUMN worker_id text,
|
||||
ADD COLUMN lease_expires_at timestamptz,
|
||||
ADD COLUMN cancel_requested boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN idempotency_key text UNIQUE,
|
||||
ADD COLUMN attempt integer NOT NULL DEFAULT 0,
|
||||
ADD CONSTRAINT tasks_status_check CHECK (status IN ('queued', 'running', 'success', 'error', 'cancelled'));
|
||||
CREATE INDEX tasks_claim_idx ON tasks(status, lease_expires_at, created_at);
|
||||
CREATE TABLE resource_locks (
|
||||
resource_key text PRIMARY KEY,
|
||||
task_id uuid NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
acquired_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE task_logs (
|
||||
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
message text NOT NULL,
|
||||
PRIMARY KEY (task_id, sequence)
|
||||
);
|
||||
CREATE TABLE task_events (
|
||||
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
kind text NOT NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (task_id, sequence)
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
INSERT INTO realms(name, kind) VALUES ('test', 'pve') ON CONFLICT (name) DO NOTHING;
|
||||
ALTER TABLE api_tokens
|
||||
ADD COLUMN comment text,
|
||||
ADD COLUMN privilege_separation boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE group_acl_entries (
|
||||
group_id uuid NOT NULL REFERENCES identity_groups(id) ON DELETE CASCADE,
|
||||
role_name text NOT NULL REFERENCES roles(name) ON DELETE RESTRICT,
|
||||
path text NOT NULL,
|
||||
propagate boolean NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (group_id, role_name, path)
|
||||
);
|
||||
CREATE INDEX identity_group_members_principal_idx
|
||||
ON identity_group_members(principal_id, group_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE realms DROP CONSTRAINT IF EXISTS realms_kind_check;
|
||||
ALTER TABLE realms
|
||||
ADD CONSTRAINT realms_kind_check
|
||||
CHECK (kind IN ('pam', 'pve', 'openid', 'ldap', 'ad'));
|
||||
ALTER TABLE realms
|
||||
ADD COLUMN IF NOT EXISTS config jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
UPDATE realms
|
||||
SET config = config || jsonb_build_object(
|
||||
'comment',
|
||||
CASE name
|
||||
WHEN 'pam' THEN 'Linux PAM standard authentication'
|
||||
WHEN 'pve' THEN 'Proxmox VE authentication server'
|
||||
ELSE COALESCE(config->>'comment', '')
|
||||
END
|
||||
)
|
||||
WHERE name IN ('pam', 'pve')
|
||||
AND COALESCE(config->>'comment', '') = '';
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE tfa_entries (
|
||||
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||
entry_id text NOT NULL,
|
||||
tfa_type text NOT NULL CHECK (tfa_type IN ('totp', 'u2f', 'webauthn', 'recovery', 'yubico')),
|
||||
description text,
|
||||
enable boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
secret text,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (principal_id, entry_id)
|
||||
);
|
||||
CREATE INDEX tfa_entries_principal_idx ON tfa_entries(principal_id);
|
||||
|
||||
ALTER TABLE principals
|
||||
ADD COLUMN IF NOT EXISTS tfa_locked_until timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS totp_locked boolean NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE openid_pending (
|
||||
state text PRIMARY KEY,
|
||||
realm text NOT NULL REFERENCES realms(name) ON DELETE CASCADE,
|
||||
redirect_url text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Native vSphere inventory + sessions (independent of Proxmox resources).
|
||||
|
||||
CREATE TABLE vsphere_sessions (
|
||||
id text PRIMARY KEY,
|
||||
username text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_sessions_expires_idx ON vsphere_sessions (expires_at);
|
||||
|
||||
CREATE TABLE vsphere_objects (
|
||||
moid text PRIMARY KEY,
|
||||
type text NOT NULL,
|
||||
name text NOT NULL,
|
||||
parent_moid text REFERENCES vsphere_objects (moid) ON DELETE SET NULL,
|
||||
props jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_objects_type_idx ON vsphere_objects (type);
|
||||
CREATE INDEX vsphere_objects_parent_idx ON vsphere_objects (parent_moid);
|
||||
CREATE INDEX vsphere_objects_name_idx ON vsphere_objects (name);
|
||||
|
||||
CREATE TABLE vsphere_credentials (
|
||||
username text PRIMARY KEY,
|
||||
password_hash text NOT NULL,
|
||||
roles text[] NOT NULL DEFAULT '{Administrator}'
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Tasks, snapshots, tagging, content library, datastore files, roles.
|
||||
|
||||
CREATE TABLE vsphere_tasks (
|
||||
id text PRIMARY KEY,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED')),
|
||||
service text NOT NULL DEFAULT '',
|
||||
operation text NOT NULL DEFAULT '',
|
||||
result jsonb,
|
||||
error jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_tasks_status_idx ON vsphere_tasks (status);
|
||||
|
||||
CREATE TABLE vsphere_snapshots (
|
||||
id text PRIMARY KEY,
|
||||
vm_moid text NOT NULL REFERENCES vsphere_objects (moid) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
props jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_snapshots_vm_idx ON vsphere_snapshots (vm_moid);
|
||||
|
||||
CREATE TABLE vsphere_tag_categories (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
cardinality text NOT NULL DEFAULT 'MULTIPLE',
|
||||
associable_types text[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE vsphere_tags (
|
||||
id text PRIMARY KEY,
|
||||
category_id text NOT NULL REFERENCES vsphere_tag_categories (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
UNIQUE (category_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE vsphere_tag_associations (
|
||||
tag_id text NOT NULL REFERENCES vsphere_tags (id) ON DELETE CASCADE,
|
||||
object_type text NOT NULL,
|
||||
object_id text NOT NULL,
|
||||
PRIMARY KEY (tag_id, object_type, object_id)
|
||||
);
|
||||
|
||||
CREATE TABLE vsphere_libraries (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
type text NOT NULL DEFAULT 'LOCAL',
|
||||
props jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE vsphere_library_items (
|
||||
id text PRIMARY KEY,
|
||||
library_id text NOT NULL REFERENCES vsphere_libraries (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL DEFAULT 'ovf',
|
||||
description text NOT NULL DEFAULT '',
|
||||
props jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (library_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE vsphere_datastore_files (
|
||||
id bigserial PRIMARY KEY,
|
||||
datastore_moid text NOT NULL REFERENCES vsphere_objects (moid) ON DELETE CASCADE,
|
||||
path text NOT NULL,
|
||||
size bigint NOT NULL DEFAULT 0,
|
||||
type text NOT NULL DEFAULT 'FILE',
|
||||
UNIQUE (datastore_moid, path)
|
||||
);
|
||||
|
||||
CREATE TABLE vsphere_permissions (
|
||||
id bigserial PRIMARY KEY,
|
||||
principal text NOT NULL,
|
||||
role text NOT NULL,
|
||||
entity_moid text,
|
||||
propagate boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_permissions_principal_idx ON vsphere_permissions (principal);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Keyed JSON state for Broadcom Automation API surface (DB-backed stubs).
|
||||
|
||||
CREATE TABLE vsphere_api_state (
|
||||
state_key text PRIMARY KEY,
|
||||
verb text NOT NULL,
|
||||
path_template text NOT NULL,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_api_state_path_idx ON vsphere_api_state (path_template);
|
||||
CREATE INDEX vsphere_api_state_verb_idx ON vsphere_api_state (verb);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Durable content-library transfer sessions and NFC leases (no process memory).
|
||||
|
||||
CREATE TABLE vsphere_transfer_sessions (
|
||||
id text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('download', 'update')),
|
||||
library_item_id text NOT NULL REFERENCES vsphere_library_items (id) ON DELETE CASCADE,
|
||||
state text NOT NULL DEFAULT 'ACTIVE',
|
||||
files jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, kind)
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_transfer_sessions_item_idx ON vsphere_transfer_sessions (library_item_id);
|
||||
CREATE INDEX vsphere_transfer_sessions_kind_idx ON vsphere_transfer_sessions (kind);
|
||||
|
||||
CREATE TABLE vsphere_nfc_leases (
|
||||
id text PRIMARY KEY,
|
||||
vm_moid text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'ready',
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_nfc_leases_vm_idx ON vsphere_nfc_leases (vm_moid);
|
||||
|
||||
-- Keep original seed document so DELETE can restore without Python templates.
|
||||
ALTER TABLE vsphere_api_state
|
||||
ADD COLUMN IF NOT EXISTS seed_payload jsonb;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Durable SOAP PropertyCollector views / page tokens / WaitForUpdates versions.
|
||||
|
||||
CREATE TABLE vsphere_pc_state (
|
||||
kind text NOT NULL CHECK (kind IN ('view', 'token', 'version', 'meta')),
|
||||
key text NOT NULL,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (kind, key)
|
||||
);
|
||||
|
||||
CREATE INDEX vsphere_pc_state_kind_idx ON vsphere_pc_state (kind);
|
||||
|
||||
-- Ephemeral console tickets issued by REST/SOAP.
|
||||
CREATE TABLE vsphere_console_tickets (
|
||||
ticket text PRIMARY KEY,
|
||||
vm_moid text NOT NULL REFERENCES vsphere_objects (moid) ON DELETE CASCADE,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -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