feat: add institutional governance and recovery contracts

This commit is contained in:
2026-08-01 17:46:54 +02:00
parent b65b48832b
commit 7192d32e65
61 changed files with 12539 additions and 168 deletions
+2
View File
@@ -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)
+77
View File
@@ -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",
]
+47
View File
@@ -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)}