fix(migrations): F40 (Astra P1) — Plugin-Migrationen tracken SHA-256-Content-Hash, Drift wird sichtbar
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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")
|
||||||
@@ -58,3 +58,7 @@ class PluginMigration(Base, TimestampMixin):
|
|||||||
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
|
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
|
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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|||||||
|
|
||||||
from app.models.plugin import PluginMigration
|
from app.models.plugin import PluginMigration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MigrationValidationError(Exception):
|
class MigrationValidationError(Exception):
|
||||||
"""Raised when a plugin migration creates a table without tenant_id column."""
|
"""Raised when a plugin migration creates a table without tenant_id column."""
|
||||||
@@ -124,12 +127,20 @@ class MigrationRunner:
|
|||||||
plugin_name=plugin_name,
|
plugin_name=plugin_name,
|
||||||
migration_file=migration_filename,
|
migration_file=migration_filename,
|
||||||
status="applied",
|
status="applied",
|
||||||
|
content_hash=self._hash_sql(sql_content), # F40 (Astra): record applied content
|
||||||
)
|
)
|
||||||
db.add(migration_record)
|
db.add(migration_record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
return migration_record
|
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(
|
async def run_all_migrations(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -150,11 +161,34 @@ class MigrationRunner:
|
|||||||
PluginMigration.status == "applied",
|
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] = []
|
records: list[PluginMigration] = []
|
||||||
for filename in migration_files:
|
for filename in migration_files:
|
||||||
if filename in applied_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
|
continue # Already applied — idempotent skip
|
||||||
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
||||||
records.append(record)
|
records.append(record)
|
||||||
|
|||||||
Reference in New Issue
Block a user