- Guard maintenance Alembic operations for create_all-only tables on clean DBs - Add guarded verification_runs.fanout_plan_id backfill migration - Improve maintenance banner rendering, chart management, orchestration, and API routes - Expand assistant maintenance tool and edge-case coverage Tests: cd backend && source .venv/bin/activate && python -m pytest -q tests/test_maintenance_api.py tests/test_maintenance_service.py tests/api/test_assistant_tool_maintenance.py tests/api/test_maintenance_routes_edge.py (77 passed)
149 lines
6.8 KiB
Python
149 lines
6.8 KiB
Python
# #region Alembic.LegacyMaintenanceColumns [C:3] [TYPE Module] [SEMANTICS alembic,maintenance,legacy-schema,fanout-approval,idempotent]
|
|
# @ingroup Alembic
|
|
# @BRIEF Idempotent reconciliation for legacy databases: add maintenance_events.auto_end and
|
|
# maintenance_dashboard_banners.original_position_json when absent, and create the
|
|
# fanout_approvals table for persisted PROD fan-out approval (041 hardening), and
|
|
# load_runs.created_by for ownership checks (040 hardening).
|
|
# @LAYER Database
|
|
# @RELATION DEPENDS_ON -> [Models.Maintenance.MaintenanceModels]
|
|
# @RELATION DEPENDS_ON -> [Models.Lineage.FanoutApproval]
|
|
# @RELATION DEPENDS_ON -> [Models.LoadTesting.LoadRun]
|
|
# @INVARIANT Every schema mutation is guarded by an inspector check — running upgrade twice is a
|
|
# no-op on the second run (safe for legacy DBs stamped at head).
|
|
# @RATIONALE Legacy installations may reach alembic head without these columns (tables pre-date
|
|
# the feature migrations); reconciliation must tolerate both present and absent state.
|
|
# @REJECTED Unconditional op.add_column was rejected — it raises on an already-migrated schema
|
|
# and breaks the "legacy DB stamped at head" upgrade path (test_alembic_migrations).
|
|
# @REJECTED ALTER-only recovery was rejected — the fanout approval table must also be created for
|
|
# legacy DBs so PROD fan-out approval can persist.
|
|
|
|
"""add legacy maintenance columns + fanout approvals (idempotent)
|
|
|
|
Revision ID: o1p2q3r4s5t6
|
|
Revises: n1o2p3q4r5s6
|
|
Create Date: 2026-08-06 12:00:00.000000
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from alembic import op
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = "o1p2q3r4s5t6"
|
|
down_revision: str | Sequence[str] | None = "n1o2p3q4r5s6"
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def _column_names(bind, table: str) -> set[str]:
|
|
"""Column names of an existing table; empty set when the table is absent."""
|
|
inspector = sa.inspect(bind)
|
|
if table not in inspector.get_table_names():
|
|
return set()
|
|
return {col["name"] for col in inspector.get_columns(table)}
|
|
|
|
|
|
def _table_exists(bind, table: str) -> bool:
|
|
"""Whether a table currently exists in the database connection."""
|
|
return sa.inspect(bind).has_table(table)
|
|
|
|
|
|
def upgrade() -> None:
|
|
"""Safely add the two legacy maintenance columns and the fanout_approvals table if absent."""
|
|
bind = op.get_bind()
|
|
|
|
# maintenance_events.auto_end — Boolean NOT NULL with server default false (informational
|
|
# unless the scheduler opts in; matches l6m7n8o9p1q2 for legacy tables that missed it).
|
|
# Guard on table existence too: these are create_all()-only tables on a fresh install, and a
|
|
# missing table must be left for ORM schema init, not mistaken for a missing column.
|
|
if _table_exists(bind, "maintenance_events") and "auto_end" not in _column_names(
|
|
bind, "maintenance_events"
|
|
):
|
|
op.add_column(
|
|
"maintenance_events",
|
|
sa.Column("auto_end", sa.Boolean(), nullable=False, server_default=sa.false()),
|
|
)
|
|
|
|
# maintenance_dashboard_banners.original_position_json — nullable Text snapshot restored
|
|
# verbatim on banner removal (matches k5l6m7n8o9p0).
|
|
if _table_exists(bind, "maintenance_dashboard_banners") and "original_position_json" not in _column_names(
|
|
bind, "maintenance_dashboard_banners"
|
|
):
|
|
op.add_column(
|
|
"maintenance_dashboard_banners",
|
|
sa.Column("original_position_json", sa.Text(), nullable=True),
|
|
)
|
|
|
|
# load_runs.created_by — ownership identity for status/stop/compare authorization.
|
|
if _table_exists(bind, "load_runs") and "created_by" not in _column_names(
|
|
bind, "load_runs"
|
|
):
|
|
op.add_column(
|
|
"load_runs",
|
|
sa.Column("created_by", sa.String(), nullable=False, server_default="system"),
|
|
)
|
|
|
|
inspector = sa.inspect(bind)
|
|
if "load_prod_approvals" not in inspector.get_table_names():
|
|
op.create_table(
|
|
"load_prod_approvals",
|
|
sa.Column("id", sa.String(), primary_key=True),
|
|
sa.Column("requester_id", sa.String(), nullable=False),
|
|
sa.Column("approver_id", sa.String(), nullable=False),
|
|
sa.Column("environment_id", sa.String(), nullable=False),
|
|
sa.Column("dashboard_id", sa.Integer(), nullable=False),
|
|
sa.Column("profile_revision", sa.Integer(), nullable=False, server_default="1"),
|
|
sa.Column("effective_cap", sa.Integer(), nullable=False),
|
|
sa.Column("request_estimate", sa.Integer(), nullable=False),
|
|
sa.Column("fingerprint", sa.String(length=64), nullable=False, server_default=""),
|
|
sa.Column("reason", sa.String(), nullable=False),
|
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
sa.Column("consumed_at", sa.DateTime(), nullable=True),
|
|
)
|
|
|
|
# fanout_approvals — persisted PROD fan-out approval (041). FK-free on purpose: legacy
|
|
# schemas may not share the environments FK topology; scope is enforced by the service.
|
|
inspector = sa.inspect(bind)
|
|
if "fanout_approvals" not in inspector.get_table_names():
|
|
op.create_table(
|
|
"fanout_approvals",
|
|
sa.Column("id", sa.String(), primary_key=True),
|
|
sa.Column("environment_id", sa.String(), nullable=False),
|
|
sa.Column("dataset_uuid", sa.String(), nullable=False),
|
|
sa.Column("plan_id", sa.String(), nullable=True),
|
|
sa.Column("reason", sa.Text(), nullable=True),
|
|
sa.Column("approved_by", sa.String(), nullable=False),
|
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
sa.Column("consumed_at", sa.DateTime(), nullable=True),
|
|
)
|
|
op.create_index(
|
|
"ix_fanout_approvals_env_dataset",
|
|
"fanout_approvals",
|
|
["environment_id", "dataset_uuid"],
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""Drop only what this migration could have created; guards mirror upgrade()."""
|
|
bind = op.get_bind()
|
|
|
|
inspector = sa.inspect(bind)
|
|
if "fanout_approvals" in inspector.get_table_names():
|
|
op.drop_index("ix_fanout_approvals_env_dataset", table_name="fanout_approvals")
|
|
op.drop_table("fanout_approvals")
|
|
|
|
if "load_prod_approvals" in inspector.get_table_names():
|
|
op.drop_table("load_prod_approvals")
|
|
|
|
if "created_by" in _column_names(bind, "load_runs"):
|
|
op.drop_column("load_runs", "created_by")
|
|
|
|
if "original_position_json" in _column_names(bind, "maintenance_dashboard_banners"):
|
|
op.drop_column("maintenance_dashboard_banners", "original_position_json")
|
|
|
|
if "auto_end" in _column_names(bind, "maintenance_events"):
|
|
op.drop_column("maintenance_events", "auto_end")
|
|
# #endregion Alembic.LegacyMaintenanceColumns
|