Implement PostgreSQL production store path
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Add the PostgreSQL backend, migration and stopped-write transfer tools, lease-aware deployment manifests, tenancy declarations, and shared conformance coverage. Persist grouping mutations in durable stores and separate process liveness from database readiness.
This commit is contained in:
parent
2063470ac8
commit
749461b97b
30 changed files with 2364 additions and 71 deletions
|
|
@ -4,7 +4,7 @@ import hashlib
|
|||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||
from fastapi import FastAPI, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -204,16 +204,36 @@ def create_app(
|
|||
},
|
||||
)
|
||||
|
||||
@app.get("/live")
|
||||
async def live() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "tenant-engine", "version": __version__}
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "tenant-engine", "version": __version__}
|
||||
ping = getattr(store, "ping", None)
|
||||
try:
|
||||
if ping is not None:
|
||||
ping()
|
||||
except StoreUnavailableError as exc:
|
||||
raise HTTPException(status_code=503, detail="tenant_store_unavailable") from exc
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "tenant-engine",
|
||||
"version": __version__,
|
||||
"store_backend": str(getattr(store, "backend_name", "custom")),
|
||||
}
|
||||
|
||||
# -- Authoritative tenant record read (TEN-WP-0005) -------------------
|
||||
# user-engine's platform operator UI reads this before offering an edit,
|
||||
# and echoes the returned ETag back as If-Match on the mutation.
|
||||
|
||||
@app.get("/tenants/{tenant_id}")
|
||||
async def get_tenant(tenant_id: str, response: Response) -> dict:
|
||||
async def get_tenant(
|
||||
tenant_id: str,
|
||||
response: Response,
|
||||
actor: str = Query(min_length=1),
|
||||
) -> dict:
|
||||
authorizer.authorize(action="tenant.read", tenant_id=tenant_id, actor=actor)
|
||||
try:
|
||||
tenant = store.get_tenant(tenant_id)
|
||||
except TenantNotFoundError as exc:
|
||||
|
|
@ -228,7 +248,8 @@ def create_app(
|
|||
# -- Cache-read API (key-cape, at token issuance) --------------------
|
||||
|
||||
@app.get("/tenants/{tenant_id}/roles")
|
||||
async def cache_read_roles(tenant_id: str) -> dict:
|
||||
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
|
||||
authorizer.authorize(action="tenant.role.read", tenant_id=tenant_id, actor=actor)
|
||||
return _read_roles(store, tenant_id)
|
||||
|
||||
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
|
||||
|
|
@ -239,7 +260,8 @@ def create_app(
|
|||
# authorizing a privileged action -- not payload shape or error handling.
|
||||
|
||||
@app.get("/tenants/{tenant_id}/roles/live")
|
||||
async def live_lookup_roles(tenant_id: str) -> dict:
|
||||
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
|
||||
authorizer.authorize(action="tenant.role.read.live", tenant_id=tenant_id, actor=actor)
|
||||
return _read_roles(store, tenant_id)
|
||||
|
||||
# -- Write API (grant/revoke/plan mutation) ---------------------------
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_reque
|
|||
# resource/action vocabulary exactly -- the two repos coordinate on these
|
||||
# strings, neither invents its own.
|
||||
_RESOURCE_TYPES: dict[str, str] = {
|
||||
"tenant.read": "tenant",
|
||||
"tenant.create": "tenant",
|
||||
"tenant.role.grant": "role-grant",
|
||||
"tenant.role.revoke": "role-grant",
|
||||
"tenant.role.read": "role-grant",
|
||||
"tenant.role.read.live": "role-grant",
|
||||
"tenant.plan.assign": "plan-assignment",
|
||||
# TEN-WP-0005: lifecycle actions are distinct so policy can separate a
|
||||
# metadata edit from a retirement.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class Settings:
|
|||
host: str
|
||||
port: int
|
||||
database_path: str | None = None
|
||||
database_url_file: str | None = None
|
||||
flex_auth_token_file: str | None = None
|
||||
|
||||
@classmethod
|
||||
|
|
@ -21,5 +22,6 @@ class Settings:
|
|||
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
|
||||
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
|
||||
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None,
|
||||
flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,14 +4,29 @@ import uvicorn
|
|||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.config import Settings
|
||||
from tenant_engine.postgres_store import PostgresTenantStore
|
||||
from tenant_engine.sqlite_store import SQLiteTenantStore
|
||||
from tenant_engine.store import TenantStore
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = Settings.from_env()
|
||||
store = SQLiteTenantStore(settings.database_path) if settings.database_path else None
|
||||
store = _build_store(settings)
|
||||
uvicorn.run(create_app(settings=settings, store=store), host=settings.host, port=settings.port)
|
||||
|
||||
|
||||
def _build_store(settings: Settings) -> TenantStore | None:
|
||||
if settings.database_path and settings.database_url_file:
|
||||
raise RuntimeError(
|
||||
"refusing ambiguous store configuration: set only one of "
|
||||
"TENANT_ENGINE_DATABASE_PATH or TENANT_ENGINE_DATABASE_URL_FILE"
|
||||
)
|
||||
if settings.database_url_file:
|
||||
return PostgresTenantStore(settings.database_url_file)
|
||||
if settings.database_path:
|
||||
return SQLiteTenantStore(settings.database_path)
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
38
src/tenant_engine/migrate.py
Normal file
38
src/tenant_engine/migrate.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run an out-of-band tenant-engine migration")
|
||||
parser.add_argument(
|
||||
"--url-file",
|
||||
default=os.getenv("TENANT_ENGINE_MIGRATION_DATABASE_URL_FILE", ""),
|
||||
help="file containing the leased migration database URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--migration",
|
||||
default="/app/migrations/postgres/0001_tenant_store.sql",
|
||||
help="SQL migration file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.url_file:
|
||||
parser.error("--url-file or TENANT_ENGINE_MIGRATION_DATABASE_URL_FILE is required")
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError as exc: # pragma: no cover - deployment guard
|
||||
raise RuntimeError("install tenant-engine[postgres] to run migrations") from exc
|
||||
|
||||
dsn = Path(args.url_file).read_text(encoding="utf-8").strip()
|
||||
if not dsn:
|
||||
raise RuntimeError("migration database URL file is empty")
|
||||
sql = Path(args.migration).read_text(encoding="utf-8")
|
||||
with psycopg.connect(dsn, autocommit=True) as connection:
|
||||
connection.execute(sql)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
541
src/tenant_engine/postgres_store.py
Normal file
541
src/tenant_engine/postgres_store.py
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
try: # Optional runtime dependency; SQLite/development installs stay small.
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import ConnectionPool, PoolTimeout
|
||||
except ImportError: # pragma: no cover - exercised by the deployment guard
|
||||
psycopg = None
|
||||
ConnectionPool = None
|
||||
PoolTimeout = None
|
||||
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
RoleGrant,
|
||||
Tenant,
|
||||
TenantLifecycle,
|
||||
TenantRetiredError,
|
||||
)
|
||||
from tenant_engine.guardrail import (
|
||||
DEFAULT_REGISTRY,
|
||||
GuardrailChange,
|
||||
LimitValue,
|
||||
dump_limit,
|
||||
load_limit,
|
||||
)
|
||||
from tenant_engine.store import (
|
||||
DomainEvent,
|
||||
GrantNotFoundError,
|
||||
IdempotencyConflictError,
|
||||
StoreUnavailableError,
|
||||
TenantAlreadyExistsError,
|
||||
TenantNotFoundError,
|
||||
VersionConflictError,
|
||||
guard_guardrail_write,
|
||||
)
|
||||
|
||||
|
||||
class RotatingConnectionPool:
|
||||
"""A bounded pool whose DSN is read from a projected file per checkout.
|
||||
|
||||
OpenBao can replace the file atomically when a database lease rotates.
|
||||
The next checkout swaps pools, while max_lifetime also bounds how long a
|
||||
still-valid connection can retain an old credential.
|
||||
"""
|
||||
|
||||
def __init__(self, dsn_file: str, *, min_size: int = 1, max_size: int = 4) -> None:
|
||||
if ConnectionPool is None:
|
||||
raise RuntimeError("install tenant-engine[postgres] for PostgreSQL support")
|
||||
self._dsn_file = Path(dsn_file)
|
||||
self._min_size = min_size
|
||||
self._max_size = max_size
|
||||
self._lock = RLock()
|
||||
self._dsn = ""
|
||||
self._pool = None
|
||||
|
||||
@contextmanager
|
||||
def connection(self) -> Iterator[Any]:
|
||||
try:
|
||||
dsn = self._dsn_file.read_text(encoding="utf-8").strip()
|
||||
if not dsn:
|
||||
raise OSError("database URL file is empty")
|
||||
old_pool = None
|
||||
with self._lock:
|
||||
if self._pool is None or dsn != self._dsn:
|
||||
old_pool = self._pool
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=self._min_size,
|
||||
max_size=self._max_size,
|
||||
timeout=5,
|
||||
max_lifetime=300,
|
||||
kwargs={"row_factory": dict_row},
|
||||
open=True,
|
||||
)
|
||||
self._dsn = dsn
|
||||
pool = self._pool
|
||||
if old_pool is not None:
|
||||
old_pool.close()
|
||||
with pool.connection() as connection:
|
||||
yield connection
|
||||
except OSError as exc:
|
||||
raise StoreUnavailableError("database credential unavailable") from exc
|
||||
except (psycopg.Error, PoolTimeout) as exc:
|
||||
raise StoreUnavailableError("PostgreSQL unavailable") from exc
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._pool is not None:
|
||||
self._pool.close()
|
||||
self._pool = None
|
||||
|
||||
|
||||
class PostgresTenantStore:
|
||||
"""PostgreSQL implementation of the complete TenantStore protocol."""
|
||||
|
||||
backend_name = "postgresql"
|
||||
|
||||
def __init__(self, dsn_file: str, *, min_pool_size: int = 1, max_pool_size: int = 4) -> None:
|
||||
self._pool = RotatingConnectionPool(
|
||||
dsn_file, min_size=min_pool_size, max_size=max_pool_size
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
def ping(self) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
try:
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
conn.execute(
|
||||
"""INSERT INTO tenants
|
||||
(tenant_id, identifier, grouping_name, display_name, contact_email,
|
||||
lifecycle, version, created_at, updated_at, retired_at, reactivated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
tenant.tenant_id,
|
||||
tenant.identifier,
|
||||
tenant.grouping,
|
||||
tenant.display_name,
|
||||
tenant.contact_email,
|
||||
tenant.lifecycle.value,
|
||||
tenant.version,
|
||||
tenant.created_at,
|
||||
tenant.updated_at,
|
||||
tenant.retired_at,
|
||||
tenant.reactivated_at,
|
||||
),
|
||||
)
|
||||
self._emit(
|
||||
conn,
|
||||
"tenant_created",
|
||||
tenant.tenant_id,
|
||||
{"identifier": tenant.identifier, "grouping": tenant.grouping},
|
||||
)
|
||||
except StoreUnavailableError as exc:
|
||||
if isinstance(exc.__cause__, UniqueViolation):
|
||||
raise TenantAlreadyExistsError(tenant.identifier) from exc
|
||||
raise
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM tenants WHERE tenant_id = %s OR identifier = %s",
|
||||
(tenant_id, tenant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TenantNotFoundError(tenant_id)
|
||||
return _tenant(row)
|
||||
|
||||
def mutate_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
mutate: Callable[[Tenant], Tenant],
|
||||
event_type: str,
|
||||
evidence: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
) -> tuple[Tenant, bool]:
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
row = self._locked_tenant(conn, tenant_id)
|
||||
receipt = conn.execute(
|
||||
"""SELECT request_fingerprint, result FROM idempotency_receipts
|
||||
WHERE tenant_id = %s AND idempotency_key = %s""",
|
||||
(row["tenant_id"], idempotency_key),
|
||||
).fetchone()
|
||||
if receipt is not None:
|
||||
if receipt["request_fingerprint"] != request_fingerprint:
|
||||
raise IdempotencyConflictError(idempotency_key)
|
||||
return _tenant(receipt["result"]), True
|
||||
|
||||
current = _tenant(row)
|
||||
if current.version != expected_version:
|
||||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||
updated = mutate(current)
|
||||
conn.execute(
|
||||
"""UPDATE tenants SET grouping_name = %s, display_name = %s,
|
||||
contact_email = %s, lifecycle = %s, version = %s, updated_at = %s,
|
||||
retired_at = %s, reactivated_at = %s WHERE tenant_id = %s""",
|
||||
(
|
||||
updated.grouping,
|
||||
updated.display_name,
|
||||
updated.contact_email,
|
||||
updated.lifecycle.value,
|
||||
updated.version,
|
||||
updated.updated_at,
|
||||
updated.retired_at,
|
||||
updated.reactivated_at,
|
||||
updated.tenant_id,
|
||||
),
|
||||
)
|
||||
self._record_receipt(
|
||||
conn, updated, idempotency_key, request_fingerprint
|
||||
)
|
||||
self._emit(
|
||||
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
|
||||
)
|
||||
return updated, False
|
||||
|
||||
def grant_role(self, grant: RoleGrant) -> None:
|
||||
tenant = self.get_tenant(grant.tenant_id)
|
||||
self._require_active(tenant, "grant a role")
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
conn.execute(
|
||||
"""INSERT INTO grants
|
||||
(grant_id, tenant_id, role, grant_reason, plan_id, granted_by,
|
||||
granted_at, correlation_id, revoked_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (grant_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id, role = EXCLUDED.role,
|
||||
grant_reason = EXCLUDED.grant_reason, plan_id = EXCLUDED.plan_id,
|
||||
granted_by = EXCLUDED.granted_by, granted_at = EXCLUDED.granted_at,
|
||||
correlation_id = EXCLUDED.correlation_id,
|
||||
revoked_at = EXCLUDED.revoked_at""",
|
||||
(
|
||||
grant.grant_id,
|
||||
tenant.tenant_id,
|
||||
grant.role.value,
|
||||
grant.grant_reason,
|
||||
grant.plan_id,
|
||||
grant.granted_by,
|
||||
grant.granted_at,
|
||||
grant.correlation_id,
|
||||
grant.revoked_at,
|
||||
),
|
||||
)
|
||||
self._emit(
|
||||
conn,
|
||||
"role_granted",
|
||||
tenant.tenant_id,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"role": grant.role.value,
|
||||
"grant_reason": grant.grant_reason,
|
||||
"correlation_id": grant.correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
row = conn.execute(
|
||||
"SELECT * FROM grants WHERE tenant_id = %s AND grant_id = %s FOR UPDATE",
|
||||
(tenant.tenant_id, grant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise GrantNotFoundError(grant_id)
|
||||
grant = _grant(row).revoke(at=at)
|
||||
conn.execute("UPDATE grants SET revoked_at = %s WHERE grant_id = %s", (at, grant_id))
|
||||
self._emit(
|
||||
conn,
|
||||
"role_revoked",
|
||||
tenant.tenant_id,
|
||||
{"grant_id": grant_id, "role": grant.role.value},
|
||||
)
|
||||
return grant
|
||||
|
||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT role FROM grants WHERE tenant_id = %s AND revoked_at IS NULL",
|
||||
(tenant.tenant_id,),
|
||||
).fetchall()
|
||||
return frozenset(CapabilityRole(row["role"]) for row in rows)
|
||||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
||||
tenant = self.get_tenant(assignment.tenant_id)
|
||||
self._require_active(tenant, "assign a plan")
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
conn.execute(
|
||||
"""INSERT INTO plans (tenant_id, plan_id, assigned_at) VALUES (%s, %s, %s)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
plan_id = EXCLUDED.plan_id, assigned_at = EXCLUDED.assigned_at""",
|
||||
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
|
||||
)
|
||||
self._emit(
|
||||
conn, "plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}
|
||||
)
|
||||
|
||||
def events(self) -> list[DomainEvent]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute("SELECT * FROM events ORDER BY seq").fetchall()
|
||||
return [
|
||||
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM guardrail_overrides WHERE tenant_id = %s", (tenant.tenant_id,)
|
||||
).fetchall()
|
||||
return {row["limit_key"]: load_limit(row) for row in rows}
|
||||
|
||||
def guardrail_changes(self, tenant_id: str) -> list[GuardrailChange]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM guardrail_changes WHERE tenant_id = %s
|
||||
ORDER BY changed_at, change_id""",
|
||||
(tenant.tenant_id,),
|
||||
).fetchall()
|
||||
return [_change(row) for row in rows]
|
||||
|
||||
def set_guardrail_override(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_version: int,
|
||||
limit_key: str,
|
||||
value: LimitValue | None,
|
||||
change_id: str,
|
||||
changed_by: str,
|
||||
reason: str,
|
||||
correlation_id: str,
|
||||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
at: datetime,
|
||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||
DEFAULT_REGISTRY.get(limit_key)
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
row = self._locked_tenant(conn, tenant_id)
|
||||
receipt = conn.execute(
|
||||
"""SELECT request_fingerprint, result FROM idempotency_receipts
|
||||
WHERE tenant_id = %s AND idempotency_key = %s""",
|
||||
(row["tenant_id"], idempotency_key),
|
||||
).fetchone()
|
||||
if receipt is not None:
|
||||
if receipt["request_fingerprint"] != request_fingerprint:
|
||||
raise IdempotencyConflictError(idempotency_key)
|
||||
prior = conn.execute(
|
||||
"SELECT * FROM guardrail_changes WHERE change_id = %s", (change_id,)
|
||||
).fetchone()
|
||||
return _tenant(receipt["result"]), _change(prior) if prior else None, True
|
||||
|
||||
current = _tenant(row)
|
||||
if current.version != expected_version:
|
||||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||
override_rows = conn.execute(
|
||||
"SELECT * FROM guardrail_overrides WHERE tenant_id = %s",
|
||||
(current.tenant_id,),
|
||||
).fetchall()
|
||||
overrides = {item["limit_key"]: load_limit(item) for item in override_rows}
|
||||
guard_guardrail_write(
|
||||
tenant=current, overrides=overrides, limit_key=limit_key, value=value
|
||||
)
|
||||
change = GuardrailChange(
|
||||
change_id=change_id,
|
||||
tenant_id=current.tenant_id,
|
||||
limit_key=limit_key,
|
||||
previous=overrides.get(limit_key),
|
||||
current=value,
|
||||
changed_by=changed_by,
|
||||
reason=reason,
|
||||
correlation_id=correlation_id,
|
||||
changed_at=at,
|
||||
)
|
||||
if value is None:
|
||||
conn.execute(
|
||||
"DELETE FROM guardrail_overrides WHERE tenant_id = %s AND limit_key = %s",
|
||||
(current.tenant_id, limit_key),
|
||||
)
|
||||
else:
|
||||
dumped = dump_limit(value)
|
||||
conn.execute(
|
||||
"""INSERT INTO guardrail_overrides
|
||||
(tenant_id, limit_key, kind, amount, currency, period)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (tenant_id, limit_key) DO UPDATE SET
|
||||
kind = EXCLUDED.kind, amount = EXCLUDED.amount,
|
||||
currency = EXCLUDED.currency, period = EXCLUDED.period""",
|
||||
(
|
||||
current.tenant_id,
|
||||
limit_key,
|
||||
dumped["kind"],
|
||||
dumped["amount"],
|
||||
dumped["currency"],
|
||||
dumped["period"],
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO guardrail_changes
|
||||
(change_id, tenant_id, limit_key, previous, current, changed_by,
|
||||
reason, correlation_id, changed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
change.change_id,
|
||||
change.tenant_id,
|
||||
change.limit_key,
|
||||
Jsonb(dump_limit(change.previous)) if change.previous else None,
|
||||
Jsonb(dump_limit(change.current)) if change.current else None,
|
||||
change.changed_by,
|
||||
change.reason,
|
||||
change.correlation_id,
|
||||
change.changed_at,
|
||||
),
|
||||
)
|
||||
updated = replace(current, version=current.version + 1, updated_at=at)
|
||||
conn.execute(
|
||||
"UPDATE tenants SET version = %s, updated_at = %s WHERE tenant_id = %s",
|
||||
(updated.version, updated.updated_at, updated.tenant_id),
|
||||
)
|
||||
self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
|
||||
self._emit(
|
||||
conn,
|
||||
"guardrail_changed",
|
||||
updated.tenant_id,
|
||||
{
|
||||
"limit_key": limit_key,
|
||||
"change_id": change_id,
|
||||
"cleared": value is None,
|
||||
"correlation_id": correlation_id,
|
||||
"version": updated.version,
|
||||
},
|
||||
)
|
||||
return updated, change, False
|
||||
|
||||
@staticmethod
|
||||
def _require_active(tenant: Tenant, what: str) -> None:
|
||||
if tenant.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
||||
|
||||
@staticmethod
|
||||
def _locked_tenant(conn: Any, tenant_id: str) -> Mapping[str, Any]:
|
||||
row = conn.execute(
|
||||
"""SELECT * FROM tenants WHERE tenant_id = %s OR identifier = %s
|
||||
FOR UPDATE""",
|
||||
(tenant_id, tenant_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TenantNotFoundError(tenant_id)
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _record_receipt(
|
||||
conn: Any, tenant: Tenant, idempotency_key: str, request_fingerprint: str
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO idempotency_receipts
|
||||
(tenant_id, idempotency_key, request_fingerprint, result, recorded_at)
|
||||
VALUES (%s, %s, %s, %s, %s)""",
|
||||
(
|
||||
tenant.tenant_id,
|
||||
idempotency_key,
|
||||
request_fingerprint,
|
||||
Jsonb(_row(tenant)),
|
||||
datetime.now(UTC),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)",
|
||||
(event_type, tenant_id, datetime.now(UTC), Jsonb(payload)),
|
||||
)
|
||||
|
||||
|
||||
def _tenant(row: Mapping[str, Any]) -> Tenant:
|
||||
return Tenant(
|
||||
tenant_id=row["tenant_id"],
|
||||
identifier=row["identifier"],
|
||||
grouping=row["grouping_name"],
|
||||
display_name=row["display_name"],
|
||||
contact_email=row["contact_email"],
|
||||
lifecycle=TenantLifecycle(row["lifecycle"]),
|
||||
version=int(row["version"]),
|
||||
created_at=_dt(row["created_at"]),
|
||||
updated_at=_dt(row["updated_at"]),
|
||||
retired_at=_dt(row["retired_at"]),
|
||||
reactivated_at=_dt(row["reactivated_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _row(tenant: Tenant) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"identifier": tenant.identifier,
|
||||
"grouping_name": tenant.grouping,
|
||||
"display_name": tenant.display_name,
|
||||
"contact_email": tenant.contact_email,
|
||||
"lifecycle": tenant.lifecycle.value,
|
||||
"version": tenant.version,
|
||||
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
|
||||
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
|
||||
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
|
||||
"reactivated_at": (
|
||||
tenant.reactivated_at.isoformat() if tenant.reactivated_at else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _dt(value: datetime | str | None) -> datetime | None:
|
||||
if isinstance(value, datetime) or value is None:
|
||||
return value
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def _grant(row: Mapping[str, Any]) -> RoleGrant:
|
||||
return RoleGrant(
|
||||
row["grant_id"],
|
||||
row["tenant_id"],
|
||||
CapabilityRole(row["role"]),
|
||||
row["grant_reason"],
|
||||
row["plan_id"],
|
||||
row["granted_by"],
|
||||
_dt(row["granted_at"]),
|
||||
row["correlation_id"],
|
||||
_dt(row["revoked_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _change(row: Mapping[str, Any]) -> GuardrailChange:
|
||||
return GuardrailChange(
|
||||
change_id=row["change_id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
limit_key=row["limit_key"],
|
||||
previous=load_limit(row["previous"]) if row["previous"] else None,
|
||||
current=load_limit(row["current"]) if row["current"] else None,
|
||||
changed_by=row["changed_by"],
|
||||
reason=row["reason"],
|
||||
correlation_id=row["correlation_id"],
|
||||
changed_at=_dt(row["changed_at"]),
|
||||
)
|
||||
|
|
@ -37,6 +37,8 @@ from tenant_engine.store import (
|
|||
class SQLiteTenantStore:
|
||||
"""Durable single-node tenant store used by the Kubernetes runtime."""
|
||||
|
||||
backend_name = "sqlite"
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self._db = sqlite3.connect(path, check_same_thread=False)
|
||||
self._db.row_factory = sqlite3.Row
|
||||
|
|
@ -104,6 +106,10 @@ class SQLiteTenantStore:
|
|||
if column not in existing:
|
||||
self._db.execute(f"ALTER TABLE tenants ADD COLUMN {column} {spec}")
|
||||
|
||||
def ping(self) -> None:
|
||||
with self._lock:
|
||||
self._db.execute("SELECT 1").fetchone()
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
with self._lock, self._db:
|
||||
try:
|
||||
|
|
@ -179,12 +185,14 @@ class SQLiteTenantStore:
|
|||
|
||||
updated = mutate(current)
|
||||
self._db.execute(
|
||||
"""UPDATE tenants SET display_name = ?, contact_email = ?, lifecycle = ?,
|
||||
version = ?, updated_at = ?, retired_at = ?, reactivated_at = ?
|
||||
"""UPDATE tenants SET grouping_name = ?, display_name = ?,
|
||||
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
|
||||
retired_at = ?, reactivated_at = ?
|
||||
WHERE tenant_id = ?""",
|
||||
(updated.display_name, updated.contact_email, updated.lifecycle.value,
|
||||
updated.version, _iso(updated.updated_at), _iso(updated.retired_at),
|
||||
_iso(updated.reactivated_at), updated.tenant_id),
|
||||
(updated.grouping, updated.display_name, updated.contact_email,
|
||||
updated.lifecycle.value, updated.version, _iso(updated.updated_at),
|
||||
_iso(updated.retired_at), _iso(updated.reactivated_at),
|
||||
updated.tenant_id),
|
||||
)
|
||||
self._db.execute(
|
||||
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ def guard_guardrail_write(
|
|||
|
||||
|
||||
class InMemoryTenantStore:
|
||||
backend_name = "memory"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tenants: dict[str, Tenant] = {}
|
||||
self._by_identifier: dict[str, str] = {}
|
||||
|
|
|
|||
262
src/tenant_engine/transfer.py
Normal file
262
src/tenant_engine/transfer.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TABLES: dict[str, tuple[str, ...]] = {
|
||||
"tenants": (
|
||||
"tenant_id",
|
||||
"identifier",
|
||||
"grouping_name",
|
||||
"display_name",
|
||||
"contact_email",
|
||||
"lifecycle",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"retired_at",
|
||||
"reactivated_at",
|
||||
),
|
||||
"grants": (
|
||||
"grant_id",
|
||||
"tenant_id",
|
||||
"role",
|
||||
"grant_reason",
|
||||
"plan_id",
|
||||
"granted_by",
|
||||
"granted_at",
|
||||
"correlation_id",
|
||||
"revoked_at",
|
||||
),
|
||||
"plans": ("tenant_id", "plan_id", "assigned_at"),
|
||||
"events": ("seq", "event_type", "tenant_id", "at", "payload"),
|
||||
"idempotency_receipts": (
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
"request_fingerprint",
|
||||
"result",
|
||||
"recorded_at",
|
||||
),
|
||||
"guardrail_overrides": (
|
||||
"tenant_id",
|
||||
"limit_key",
|
||||
"kind",
|
||||
"amount",
|
||||
"currency",
|
||||
"period",
|
||||
),
|
||||
"guardrail_changes": (
|
||||
"change_id",
|
||||
"tenant_id",
|
||||
"limit_key",
|
||||
"previous",
|
||||
"current",
|
||||
"changed_by",
|
||||
"reason",
|
||||
"correlation_id",
|
||||
"changed_at",
|
||||
),
|
||||
}
|
||||
|
||||
PRIMARY_KEYS = {
|
||||
"tenants": ("tenant_id",),
|
||||
"grants": ("grant_id",),
|
||||
"plans": ("tenant_id",),
|
||||
"events": ("seq",),
|
||||
"idempotency_receipts": ("tenant_id", "idempotency_key"),
|
||||
"guardrail_overrides": ("tenant_id", "limit_key"),
|
||||
"guardrail_changes": ("change_id",),
|
||||
}
|
||||
|
||||
JSON_COLUMNS = {
|
||||
"events": {"payload"},
|
||||
"idempotency_receipts": {"result"},
|
||||
"guardrail_changes": {"previous", "current"},
|
||||
}
|
||||
|
||||
TIMESTAMP_COLUMNS = {
|
||||
"tenants": {"created_at", "updated_at", "retired_at", "reactivated_at"},
|
||||
"grants": {"granted_at", "revoked_at"},
|
||||
"plans": {"assigned_at"},
|
||||
"events": {"at"},
|
||||
"idempotency_receipts": {"recorded_at"},
|
||||
"guardrail_changes": {"changed_at"},
|
||||
}
|
||||
|
||||
|
||||
def _source_rows(connection: sqlite3.Connection, table: str) -> list[dict[str, Any]]:
|
||||
columns = TABLES[table]
|
||||
order = ", ".join(PRIMARY_KEYS[table])
|
||||
selected = ", ".join(columns)
|
||||
return [
|
||||
dict(row) for row in connection.execute(f"SELECT {selected} FROM {table} ORDER BY {order}")
|
||||
]
|
||||
|
||||
|
||||
def _target_rows(connection: Any, table: str) -> list[dict[str, Any]]:
|
||||
columns = TABLES[table]
|
||||
order = ", ".join(PRIMARY_KEYS[table])
|
||||
selected = ", ".join(columns)
|
||||
return list(connection.execute(f"SELECT {selected} FROM {table} ORDER BY {order}").fetchall())
|
||||
|
||||
|
||||
def _normalise(table: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
output: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item: dict[str, Any] = {}
|
||||
for column in TABLES[table]:
|
||||
value = row[column]
|
||||
if value is not None and column in JSON_COLUMNS.get(table, set()):
|
||||
value = json.loads(value) if isinstance(value, str) else value
|
||||
if value is not None and column in TIMESTAMP_COLUMNS.get(table, set()):
|
||||
parsed = datetime.fromisoformat(value) if isinstance(value, str) else value
|
||||
value = parsed.isoformat()
|
||||
item[column] = value
|
||||
output.append(item)
|
||||
return output
|
||||
|
||||
|
||||
def _digest(rows: list[dict[str, Any]]) -> str:
|
||||
encoded = json.dumps(rows, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse_expectation(value: str) -> tuple[str, str, int]:
|
||||
tenant_id, separator, expected = value.rpartition("=")
|
||||
lifecycle, state_separator, version = expected.rpartition(":")
|
||||
if not separator or not state_separator or not tenant_id or not lifecycle:
|
||||
raise ValueError("expected TENANT_ID=LIFECYCLE:VERSION")
|
||||
try:
|
||||
parsed_version = int(version)
|
||||
except ValueError as exc:
|
||||
raise ValueError("expected TENANT_ID=LIFECYCLE:VERSION") from exc
|
||||
return tenant_id, lifecycle, parsed_version
|
||||
|
||||
|
||||
def _transfer(source: sqlite3.Connection, target: Any) -> None:
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
nonempty = {
|
||||
table: target.execute(f"SELECT count(*) AS count FROM {table}").fetchone()["count"]
|
||||
for table in TABLES
|
||||
}
|
||||
occupied = {table: count for table, count in nonempty.items() if count}
|
||||
if occupied:
|
||||
raise RuntimeError(
|
||||
f"target is not empty: {occupied}; use --verify-only or a fresh migrated database"
|
||||
)
|
||||
|
||||
for table in TABLES:
|
||||
columns = TABLES[table]
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
override = " OVERRIDING SYSTEM VALUE" if table == "events" else ""
|
||||
statement = f"INSERT INTO {table} ({', '.join(columns)}){override} VALUES ({placeholders})"
|
||||
for row in _source_rows(source, table):
|
||||
values: list[Any] = []
|
||||
for column in columns:
|
||||
value = row[column]
|
||||
if value is not None and column in JSON_COLUMNS.get(table, set()):
|
||||
value = Jsonb(json.loads(value))
|
||||
values.append(value)
|
||||
target.execute(statement, values)
|
||||
|
||||
target.execute(
|
||||
"""SELECT setval(
|
||||
pg_get_serial_sequence('events', 'seq'),
|
||||
COALESCE((SELECT max(seq) FROM events), 1),
|
||||
EXISTS (SELECT 1 FROM events)
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
def _verify(
|
||||
source: sqlite3.Connection,
|
||||
target: Any,
|
||||
expectations: list[tuple[str, str, int]],
|
||||
) -> dict[str, Any]:
|
||||
tables: dict[str, dict[str, Any]] = {}
|
||||
for table in TABLES:
|
||||
source_rows = _normalise(table, _source_rows(source, table))
|
||||
target_rows = _normalise(table, _target_rows(target, table))
|
||||
if source_rows != target_rows:
|
||||
raise RuntimeError(f"verification mismatch in {table}")
|
||||
tables[table] = {"rows": len(source_rows), "sha256": _digest(source_rows)}
|
||||
|
||||
tenants = {
|
||||
row["tenant_id"]: row for row in _normalise("tenants", _source_rows(source, "tenants"))
|
||||
}
|
||||
checked: list[dict[str, Any]] = []
|
||||
for tenant_id, lifecycle, version in expectations:
|
||||
row = tenants.get(tenant_id)
|
||||
if row is None:
|
||||
raise RuntimeError(f"expected tenant {tenant_id!r} is absent")
|
||||
if row["lifecycle"] != lifecycle or row["version"] != version:
|
||||
raise RuntimeError(
|
||||
f"tenant {tenant_id!r} is {row['lifecycle']} v{row['version']}, "
|
||||
f"expected {lifecycle} v{version}"
|
||||
)
|
||||
checked.append({"tenant_id": tenant_id, "lifecycle": lifecycle, "version": version})
|
||||
|
||||
return {
|
||||
"result": "verified",
|
||||
"scope": "all tenant-engine tables and rows",
|
||||
"tables": tables,
|
||||
"expected_tenants": checked,
|
||||
"note": "Digests prove this copy only; retain the stopped SQLite volume through soak.",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Copy and verify a stopped tenant-engine SQLite store in PostgreSQL"
|
||||
)
|
||||
parser.add_argument("--sqlite", required=True, help="path to the stopped SQLite database")
|
||||
parser.add_argument("--url-file", required=True, help="file containing the migration-role URL")
|
||||
parser.add_argument("--verify-only", action="store_true")
|
||||
parser.add_argument(
|
||||
"--expect-tenant",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="TENANT_ID=LIFECYCLE:VERSION",
|
||||
)
|
||||
parser.add_argument("--evidence-output", help="optional non-secret JSON evidence path")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
except ImportError as exc: # pragma: no cover - deployment guard
|
||||
raise RuntimeError("install tenant-engine[postgres] to transfer data") from exc
|
||||
|
||||
expectations = [_parse_expectation(value) for value in args.expect_tenant]
|
||||
sqlite_path = Path(args.sqlite).resolve()
|
||||
if not sqlite_path.is_file():
|
||||
raise RuntimeError(f"SQLite database does not exist: {sqlite_path}")
|
||||
dsn = Path(args.url_file).read_text(encoding="utf-8").strip()
|
||||
if not dsn:
|
||||
raise RuntimeError("migration database URL file is empty")
|
||||
|
||||
source = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||
source.row_factory = sqlite3.Row
|
||||
try:
|
||||
with psycopg.connect(dsn, row_factory=dict_row) as target:
|
||||
with target.transaction():
|
||||
if not args.verify_only:
|
||||
_transfer(source, target)
|
||||
evidence = _verify(source, target, expectations)
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
rendered = json.dumps(evidence, indent=2, sort_keys=True) + "\n"
|
||||
if args.evidence_output:
|
||||
Path(args.evidence_output).write_text(rendered, encoding="utf-8")
|
||||
print(rendered, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue