fix(migrations): F40 (Astra P1) — Plugin-Migrationen tracken SHA-256-Content-Hash, Drift wird sichtbar
Check Cross-Plugin Imports / check (push) Waiting to run

Vorher: Der Migration-Runner trackte Migrationen nur per DATEINAMEN —
eine nachtraeglich geaenderte, bereits angewandte Migration blieb
unbemerkt (genau die #389-Bugklasse: kaputte Migration wurde gefixt,
Runner skippte still, weil der Dateiname schon getrackt war).

Fix:
- Migration 0148: content_hash-Spalte (SHA-256, 64 Zeichen) in
  plugin_migrations + Index
- PluginMigration-Modell: content_hash-Feld
- run_migration: speichert den Hash des angewandten SQL-Inhalts
- run_all_migrations: vergleicht bei bereits angewandten Migrationen
  den Hash und warnt LAUT bei Abweichung (F40 DRIFT-Warnung mit Plugin,
  Datei, recorded/current-Hash) — Skip bleibt idempotent (kein Deploy-
  Bruch bei legitimen Reparaturen), aber Drift ist ab JETZT sichtbar

Abnahme (Astra): Eine veraenderte angewandte Migration wird erkannt —
erfuellt (Drift-Warnung im Runner-Log; #389 haette so beim naechsten
Start aufgefallen).

Verifikation: Syntax OK, ruff clean, alembic heads = 0148.
This commit is contained in:
Agent Zero
2026-09-18 13:01:25 +02:00
parent 49a9493ca0
commit fd4a1ec4ce
3 changed files with 80 additions and 1 deletions
@@ -0,0 +1,41 @@
"""Add content_hash to plugin_migrations (F40/Astra).
The migration runner previously tracked migrations by FILENAME only —
editing an already-applied migration stayed unnoticed (the #389 bug
class: a broken migration was fixed, but the runner silently skipped
it because the filename was already tracked).
Now every applied migration records the SHA-256 of its SQL content.
On subsequent runs the runner compares hashes and logs a loud warning
when an applied migration was modified (repaired) — the skip stays
idempotent, but drift becomes VISIBLE.
Revision ID: 0148
Revises: 0147
"""
import sqlalchemy as sa
from alembic import op
revision = "0148"
down_revision = "0147"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"plugin_migrations",
sa.Column("content_hash", sa.String(64), nullable=True),
)
op.create_index(
"ix_plugin_migrations_content_hash",
"plugin_migrations",
["content_hash"],
)
def downgrade() -> None:
op.drop_index("ix_plugin_migrations_content_hash", table_name="plugin_migrations")
op.drop_column("plugin_migrations", "content_hash")
+4
View File
@@ -58,3 +58,7 @@ class PluginMigration(Base, TimestampMixin):
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
# F40 (Astra): SHA-256 of the applied SQL content. The runner compares
# this on subsequent runs — a modified already-applied migration
# (repaired) becomes VISIBLE instead of being silently skipped.
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
+35 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.models.plugin import PluginMigration
logger = logging.getLogger(__name__)
class MigrationValidationError(Exception):
"""Raised when a plugin migration creates a table without tenant_id column."""
@@ -124,12 +127,20 @@ class MigrationRunner:
plugin_name=plugin_name,
migration_file=migration_filename,
status="applied",
content_hash=self._hash_sql(sql_content), # F40 (Astra): record applied content
)
db.add(migration_record)
await db.flush()
return migration_record
@staticmethod
def _hash_sql(sql_content: str) -> str:
"""F40: SHA-256 of migration SQL content for drift detection."""
import hashlib
return hashlib.sha256(sql_content.encode("utf-8")).hexdigest()
async def run_all_migrations(
self,
db: AsyncSession,
@@ -150,11 +161,34 @@ class MigrationRunner:
PluginMigration.status == "applied",
)
)
applied_files = {row.migration_file for row in result.scalars().all()}
applied_rows = {row.migration_file: row for row in result.scalars().all()}
applied_files = set(applied_rows.keys())
records: list[PluginMigration] = []
for filename in migration_files:
if filename in applied_files:
# F40 (Astra): hash check — a modified already-applied
# migration must be VISIBLE, not silently skipped. This is
# exactly the #389 bug class: a broken migration was fixed
# on disk, but the runner skipped it by filename forever.
record = applied_rows[filename]
try:
path = self._resolve_migration_path(filename, plugin_name)
current_hash = self._hash_sql(path.read_text(encoding="utf-8"))
except FileNotFoundError:
continue # file gone (plugin removed it) — nothing to compare
if record.content_hash is not None and record.content_hash != current_hash:
logger.warning(
"F40 DRIFT: plugin '%s' migration '%s' was MODIFIED after being "
"applied (recorded=%s current=%s). The runner keeps the skip "
"(idempotent), but the schema may no longer match the file. "
"If the change is intentional, verify the schema and consider "
"a follow-up migration instead of editing an applied one.",
plugin_name,
filename,
(record.content_hash or "")[:12],
current_hash[:12],
)
continue # Already applied — idempotent skip
record = await self.run_migration(db, plugin_name, filename, tenant_id)
records.append(record)