diff --git a/apps/api/src/services/backup_restore_alertmanager_ingress.py b/apps/api/src/services/backup_restore_alertmanager_ingress.py index 9a657838d..3d8c17243 100644 --- a/apps/api/src/services/backup_restore_alertmanager_ingress.py +++ b/apps/api/src/services/backup_restore_alertmanager_ingress.py @@ -39,6 +39,7 @@ logger = get_logger("awoooi.backup_restore_alertmanager_ingress") BACKUP_RESTORE_AGENT99_ROUTE_ID = "agent99:backup_health:BackupCheck" BACKUP_RESTORE_DISPATCH_OWNER = "alertmanager_raw_signal" BACKUP_RESTORE_OWNER_SCHEMA_VERSION = "backup_restore_ingress_owner_v1" +APPROVAL_FINGERPRINT_MAX_LENGTH = 64 BACKUP_RESTORE_PROHIBITED_ACTIONS = ( "run_backup", "run_restore", @@ -55,6 +56,15 @@ def _stable_digest(*parts: object) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() +def _approval_storage_fingerprint(source_fingerprint: str) -> str: + """Fit a source identity into approval_records.fingerprint VARCHAR(64).""" + + normalized = str(source_fingerprint or "").strip() + if len(normalized) <= APPROVAL_FINGERPRINT_MAX_LENGTH: + return normalized + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + def build_backup_restore_ingress_owner( *, project_id: str, @@ -233,6 +243,9 @@ async def process_backup_restore_alertmanager_signal( source_event_fingerprint=source_event_fingerprint, source_started_at=source_started_at, ) + approval_storage_fingerprint = _approval_storage_fingerprint( + source_fingerprint + ) approval_service = get_approval_service() owner_approval_id = UUID(str(owner["approval_id"])) approval = await approval_service.get_approval(owner_approval_id) @@ -240,7 +253,7 @@ async def process_backup_restore_alertmanager_signal( legacy_incident_id = "" if approval is None: recent = await approval_service.find_by_fingerprint( - fingerprint=source_fingerprint, + fingerprint=approval_storage_fingerprint, debounce_minutes=30, ) legacy_incident_id = _value(getattr(recent, "incident_id", "")) @@ -253,7 +266,7 @@ async def process_backup_restore_alertmanager_signal( try: approval = await approval_service.create_approval_with_fingerprint( request=request, - fingerprint=source_fingerprint, + fingerprint=approval_storage_fingerprint, ) except Exception: # A concurrent producer may have inserted the deterministic owner. diff --git a/apps/api/tests/integration/test_backup_restore_prod_db_regressions.py b/apps/api/tests/integration/test_backup_restore_prod_db_regressions.py index 927cde3ac..b356584be 100644 --- a/apps/api/tests/integration/test_backup_restore_prod_db_regressions.py +++ b/apps/api/tests/integration/test_backup_restore_prod_db_regressions.py @@ -15,7 +15,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from src.jobs import backup_restore_legacy_backfill_job as backfill -from src.services import platform_operator_service +from src.services import backup_restore_alertmanager_ingress, platform_operator_service def _explicit_local_test_database_url() -> str: @@ -146,6 +146,42 @@ async def test_backfill_run_inserts_satisfy_no_default_cost_column() -> None: await engine.dispose() +@pytest.mark.asyncio +async def test_long_occurrence_fingerprint_fits_real_pg_varchar() -> None: + engine = create_async_engine(_explicit_local_test_database_url(), echo=False) + source_fingerprint = "legacy-backup-occurrence:" + ("a" * 64) + stored_fingerprint = ( + backup_restore_alertmanager_ingress._approval_storage_fingerprint( + source_fingerprint + ) + ) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + try: + await connection.execute(text(""" + CREATE TEMP TABLE approval_records ( + fingerprint VARCHAR(64) NOT NULL + ) ON COMMIT DROP + """)) + await connection.execute( + text(""" + INSERT INTO approval_records (fingerprint) + VALUES (:fingerprint) + """), + {"fingerprint": stored_fingerprint}, + ) + persisted = await connection.scalar( + text("SELECT fingerprint FROM approval_records") + ) + assert persisted == stored_fingerprint + assert len(persisted) == 64 + finally: + await transaction.rollback() + finally: + await engine.dispose() + + @pytest.mark.asyncio async def test_direct_readback_rewrites_ssl_and_keeps_rls_context_in_transaction( monkeypatch: pytest.MonkeyPatch, diff --git a/apps/api/tests/test_backup_restore_alertmanager_ingress.py b/apps/api/tests/test_backup_restore_alertmanager_ingress.py index f63f29c59..c51b03c21 100644 --- a/apps/api/tests/test_backup_restore_alertmanager_ingress.py +++ b/apps/api/tests/test_backup_restore_alertmanager_ingress.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import AsyncMock @@ -46,7 +47,7 @@ def _signal_kwargs() -> dict[str, object]: "lane": "backup_restore_escrow_triage", }, "annotations": {"summary": "readback verifier required"}, - "source_fingerprint": "source-fingerprint", + "source_fingerprint": "legacy-backup-occurrence:" + ("a" * 64), "source_event_fingerprint": "alertmanager-native-fingerprint", "source_started_at": "2026-07-11T10:00:00Z", "alert_category": "backup", @@ -84,6 +85,17 @@ def test_backup_ingress_owner_is_stable_per_source_occurrence() -> None: assert first["read_only"] is True +def test_backup_approval_fingerprint_fits_durable_varchar_boundary() -> None: + short = "source-fingerprint" + long = "legacy-backup-occurrence:" + ("a" * 64) + + assert ingress._approval_storage_fingerprint(short) == short + stored = ingress._approval_storage_fingerprint(long) + assert stored == hashlib.sha256(long.encode("utf-8")).hexdigest() + assert len(stored) == ingress.APPROVAL_FINGERPRINT_MAX_LENGTH + assert ingress._approval_storage_fingerprint(long) == stored + + @pytest.mark.asyncio async def test_alertmanager_type1_backup_queues_durable_owner_before_info_shortcut( monkeypatch: pytest.MonkeyPatch, @@ -167,12 +179,18 @@ class _FakeApprovalService: async def find_by_fingerprint(self, **_kwargs): self.calls.append("legacy_owner_read") + expected = ingress._approval_storage_fingerprint( + str(_signal_kwargs()["source_fingerprint"]) + ) + assert _kwargs["fingerprint"] == expected return None async def create_approval_with_fingerprint(self, *, request, fingerprint): self.calls.append("owner_create") self.created_request = request - assert fingerprint == "source-fingerprint" + assert fingerprint == ingress._approval_storage_fingerprint( + str(_signal_kwargs()["source_fingerprint"]) + ) self.approval = SimpleNamespace( id=UUID(request.metadata["preallocated_approval_id"]), incident_id=request.incident_id, @@ -218,6 +236,7 @@ async def test_backup_ingress_binds_canonical_incident_then_dispatches_and_reads calls.append("agent99_dispatch") assert kwargs["incident_id"] == incident_id assert kwargs["route_id"] == "agent99:backup_health:BackupCheck" + assert kwargs["fingerprint"] == _signal_kwargs()["source_fingerprint"] identity.update({ "incident_id": incident_id, "run_id": "12345678-1234-5678-9234-567812345678",