feat: add institutional governance and recovery contracts
This commit is contained in:
@@ -32,6 +32,8 @@ def create_all_tables() -> None:
|
||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||
|
||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
|
||||
class MigrationLockTimeout(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def migration_lock_key(installation_id: str, migration_track: str) -> int:
|
||||
payload = f"govoplan\0{installation_id}\0{migration_track}".encode("utf-8")
|
||||
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big", signed=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def deployment_migration_lock(
|
||||
database_url: str,
|
||||
*,
|
||||
installation_id: str,
|
||||
migration_track: str,
|
||||
timeout_seconds: float = 900.0,
|
||||
poll_seconds: float = 2.0,
|
||||
) -> Iterator[None]:
|
||||
"""Serialize all release migrations before coordination tables are available."""
|
||||
|
||||
if timeout_seconds < 0 or poll_seconds <= 0:
|
||||
raise ValueError("Migration lock timeout must be non-negative and polling positive")
|
||||
if make_url(database_url).get_backend_name() != "postgresql":
|
||||
yield
|
||||
return
|
||||
key = migration_lock_key(installation_id, migration_track)
|
||||
engine = create_engine(database_url)
|
||||
connection = engine.connect()
|
||||
acquired = False
|
||||
try:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
acquired = bool(
|
||||
connection.execute(
|
||||
text("SELECT pg_try_advisory_lock(:key)"),
|
||||
{"key": key},
|
||||
).scalar_one()
|
||||
)
|
||||
if acquired:
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise MigrationLockTimeout(
|
||||
"Timed out waiting for the deployment-wide database migration lock"
|
||||
)
|
||||
time.sleep(min(poll_seconds, max(0.05, deadline - time.monotonic())))
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
connection.execute(
|
||||
text("SELECT pg_advisory_unlock(:key)"),
|
||||
{"key": key},
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
# Closing the physical connection releases session locks.
|
||||
pass
|
||||
connection.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MigrationLockTimeout",
|
||||
"deployment_migration_lock",
|
||||
"migration_lock_key",
|
||||
]
|
||||
@@ -18,6 +18,8 @@ from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
@@ -590,6 +592,51 @@ def database_revision(database_url: str | None = None) -> str | None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def database_migration_heads(
|
||||
database_url: str | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
url = database_url or settings.database_url
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
return tuple(
|
||||
sorted(MigrationContext.configure(connection).get_current_heads())
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def configured_migration_heads(
|
||||
database_url: str | None = None,
|
||||
*,
|
||||
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||
migration_track: str | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
config = alembic_config(
|
||||
database_url=database_url,
|
||||
enabled_modules=enabled_modules,
|
||||
manifest_factories=manifest_factories,
|
||||
migration_track=migration_track,
|
||||
)
|
||||
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
|
||||
|
||||
|
||||
def database_is_at_configured_heads(
|
||||
database_url: str | None = None,
|
||||
*,
|
||||
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||
migration_track: str | None = None,
|
||||
) -> bool:
|
||||
return database_migration_heads(database_url) == configured_migration_heads(
|
||||
database_url,
|
||||
enabled_modules=enabled_modules,
|
||||
manifest_factories=manifest_factories,
|
||||
migration_track=migration_track,
|
||||
)
|
||||
|
||||
|
||||
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
|
||||
try:
|
||||
actual = {column["name"] for column in inspector.get_columns(table_name)}
|
||||
|
||||
Reference in New Issue
Block a user