Files
wizarr/scripts/migration_doctor.py
2025-11-17 12:29:42 +01:00

414 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Migration Doctor
CLI helper to diagnose and repair Alembic migration issues for Wizarr.
Typical stuck scenario:
- User updates Wizarr but their database references an Alembic revision
that no longer exists in the repository (e.g., after a squashed release).
- ``flask db upgrade`` fails with "Can't locate revision identified by ..."
This tool inspects the current database state, highlights unknown revisions,
and can stamp the database forward once you confirm the schema is already up
to date. Stamping does **not** run migrations it only updates the
``alembic_version`` table so normal upgrades can proceed again.
"""
from __future__ import annotations
import argparse
import os
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
try:
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
except ModuleNotFoundError as exc: # pragma: no cover - defensive guard
raise SystemExit(
"Alembic is required to run the migration doctor. "
"Install dependencies first (e.g., `uv sync` or `pip install -r requirements`)."
) from exc
from flask import Flask
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.exc import SQLAlchemyError
ROOT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT_DIR))
# Import Wizarr modules after mutating sys.path
from app.config import DevelopmentConfig, ProductionConfig # noqa: E402
from app.extensions import db, migrate # noqa: E402
@dataclass
class RevisionStatus:
revision: str
known: bool
description: str | None
file_path: Path | None
@dataclass
class StatusReport:
db_uri: str
db_path: Path | None
script_location: Path
heads: list[RevisionStatus]
revisions: list[RevisionStatus]
missing_table: bool
issues: list[str]
def build_flask_app(database_uri: str | None = None) -> Flask:
"""Create a lightweight Flask app with SQLAlchemy + Flask-Migrate initialized."""
env = os.getenv("FLASK_ENV", "production").lower()
config_cls: type[ProductionConfig | DevelopmentConfig]
config_cls = DevelopmentConfig if env == "development" else ProductionConfig
app = Flask("wizarr-migration-doctor")
app.config.from_object(config_cls)
if database_uri:
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
db.init_app(app)
migrate.init_app(app, db)
return app
def build_alembic_config(db_uri: str) -> Config:
"""Create Alembic config tied to the Wizarr migrations directory."""
migrations_dir = ROOT_DIR / "migrations"
cfg = Config(str(migrations_dir / "alembic.ini"))
cfg.set_main_option("script_location", str(migrations_dir))
cfg.set_main_option("sqlalchemy.url", db_uri)
return cfg
def build_revision_index(script_dir: ScriptDirectory) -> dict[str, object]:
"""Return mapping of revision id -> Script object."""
return {rev.revision: rev for rev in script_dir.walk_revisions()}
def describe_revision(script_rev) -> tuple[str | None, Path | None]:
"""Extract first-line description and relative path from a Script object."""
if script_rev is None:
return None, None
doc = (script_rev.doc or "").strip()
if doc:
doc = doc.splitlines()[0].strip()
path = Path(script_rev.path)
try:
rel_path = path.resolve().relative_to(ROOT_DIR)
except ValueError:
rel_path = path.resolve()
return doc or None, rel_path
def resolve_db_path(uri: str) -> Path | None:
"""Resolve filesystem path for SQLite URLs for nicer display."""
try:
url = make_url(uri)
except Exception:
return None
if url.drivername.startswith("sqlite") and url.database:
return Path(url.database).expanduser()
return None
def fetch_db_revisions() -> tuple[list[str], bool]:
"""Return (revision_list, missing_table_flag)."""
try:
result = db.session.execute(text("SELECT version_num FROM alembic_version"))
except SQLAlchemyError as exc:
if _is_missing_table_error(exc):
return [], True
raise
revisions = [row[0] for row in result]
return revisions, False
def _is_missing_table_error(exc: SQLAlchemyError) -> bool:
message = str(getattr(exc, "orig", exc)).lower()
return (
"no such table" in message
or "doesn't exist" in message
or "does not exist" in message
)
def collect_status(
db_uri: str, script_dir: ScriptDirectory, revision_index: dict[str, object]
) -> StatusReport:
"""Build status snapshot for printing or automated repair decisions."""
revisions, missing_table = fetch_db_revisions()
revision_status: list[RevisionStatus] = []
issues: list[str] = []
for rev in revisions:
script_rev = revision_index.get(rev)
doc, rel_path = describe_revision(script_rev)
entry = RevisionStatus(
revision=rev,
known=script_rev is not None,
description=doc,
file_path=rel_path,
)
revision_status.append(entry)
unknown = [r.revision for r in revision_status if not r.known]
if unknown:
joined = ", ".join(unknown)
issues.append(
f"Database references unknown revision(s): {joined}. "
"Stamp to a known revision once you confirm the schema is already up to date."
)
if missing_table:
issues.append(
"alembic_version table is missing. New databases should be stamped to base/head before upgrading."
)
elif not revision_status:
issues.append("alembic_version table exists but contains no rows.")
heads = []
for head in script_dir.get_heads():
script_rev = revision_index.get(head)
doc, rel_path = describe_revision(script_rev)
heads.append(
RevisionStatus(
revision=head, known=True, description=doc, file_path=rel_path
)
)
if not missing_table and not unknown and revision_status:
db_revs = {rev.revision for rev in revision_status}
repo_heads = set(script_dir.get_heads())
if db_revs != repo_heads:
issues.append(
"Database is behind repository head(s). Run `uv run flask db upgrade` after resolving blockers."
)
return StatusReport(
db_uri=db_uri,
db_path=resolve_db_path(db_uri),
script_location=Path(script_dir.dir),
heads=heads,
revisions=revision_status,
missing_table=missing_table,
issues=issues,
)
def print_status(report: StatusReport, verbose: bool = False) -> None:
"""Render human-readable report."""
print("🩺 Wizarr Migration Doctor\n")
print(f"Database URI: {report.db_uri}")
if report.db_path:
print(f"Database Path: {report.db_path}")
print(f"Migrations Dir: {report.script_location}")
print()
print("Repository heads:")
if report.heads:
for head in report.heads:
desc = f" {head.description}" if head.description else ""
path = f" ({head.file_path})" if head.file_path else ""
print(f"{head.revision}{desc}{path}")
else:
print(" (no migrations found)")
print()
print("Database revisions:")
if report.missing_table:
print(" ⚠️ alembic_version table not found.")
elif not report.revisions:
print(" ⚠️ alembic_version table is empty.")
else:
for entry in report.revisions:
icon = "" if entry.known else ""
desc = f" {entry.description}" if entry.description else ""
path = f" ({entry.file_path})" if entry.file_path else ""
suffix = "" if entry.known else " <-- missing migration script"
print(f" {icon} {entry.revision}{path}{desc}{suffix}")
print()
if report.issues:
print("Issues detected:")
for issue in report.issues:
print(f"{issue}")
else:
print("No structural issues detected database matches known migrations.")
if verbose:
print("\nTips:")
print(
" • Run `python scripts/migration_doctor.py repair --assume-up-to-date` to stamp unknown revisions."
)
print(
" • Run `uv run flask db upgrade` after stamping to apply missing migrations."
)
def ensure_valid_target(revision: str, revision_index: dict[str, object]) -> None:
if revision in {"head", "heads", "base"}:
return
if revision not in revision_index:
raise SystemExit(
f"Revision '{revision}' is not available in migrations/versions."
)
def confirm(prompt: str, auto_yes: bool) -> bool:
if auto_yes:
return True
answer = input(f"{prompt} [y/N]: ").strip().lower()
return answer in {"y", "yes"}
def run_stamp(
cfg: Config, revision: str, revision_index: dict[str, object], auto_yes: bool
) -> None:
ensure_valid_target(revision, revision_index)
if not confirm(
f"About to stamp database to '{revision}'. This does NOT run migrations continue?",
auto_yes,
):
print("Aborted.")
return
command.stamp(cfg, revision)
print(
f"✅ Database stamped to '{revision}'. You can now run `uv run flask db upgrade` if needed."
)
def run_repair(
cfg: Config,
report: StatusReport,
revision_index: dict[str, object],
target: str,
assume_up_to_date: bool,
auto_yes: bool,
) -> None:
unknown = [r for r in report.revisions if not r.known]
needs_table = report.missing_table or not report.revisions
if not unknown and not needs_table:
print("No unknown revisions detected nothing to repair.")
return
if not assume_up_to_date:
print(
"Refusing to stamp automatically because --assume-up-to-date was not provided.\n"
"Re-run with --assume-up-to-date once you have verified the schema matches the chosen target."
)
return
ensure_valid_target(target, revision_index)
reason = (
"missing alembic_version table" if needs_table else "unknown revision entry"
)
if not confirm(
f"Stamp database ({reason}) to '{target}'? This only updates alembic_version.",
auto_yes,
):
print("Aborted.")
return
command.stamp(cfg, target)
print(
f"✅ Database stamped to '{target}'. Follow up with `uv run flask db upgrade` to apply migrations."
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Diagnose and repair Alembic migration issues for Wizarr."
)
parser.add_argument(
"--database",
dest="database_uri",
help="Override SQLALCHEMY_DATABASE_URI (defaults to config value).",
)
subparsers = parser.add_subparsers(dest="command")
subparsers.required = False
parser.set_defaults(command="status")
status = subparsers.add_parser(
"status", help="Show current migration status (default)."
)
status.add_argument(
"--verbose", action="store_true", help="Print remediation tips."
)
stamp = subparsers.add_parser(
"stamp", help="Force-set database revision without running migrations."
)
stamp.add_argument("revision", help="Revision id, 'head', or 'base'.")
stamp.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation prompt."
)
repair = subparsers.add_parser(
"repair",
help="Stamp away unknown revisions after confirming schema is up to date.",
)
repair.add_argument(
"--target",
default="head",
help="Revision id (or 'head'/'base') to stamp when repairing (default: head).",
)
repair.add_argument(
"--assume-up-to-date",
action="store_true",
help="Confirm that the live database schema already matches the target revision.",
)
repair.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation prompt."
)
return parser
def main(argv: Sequence[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
app = build_flask_app(database_uri=args.database_uri)
cfg = build_alembic_config(app.config["SQLALCHEMY_DATABASE_URI"])
script_dir = ScriptDirectory.from_config(cfg)
revision_index = build_revision_index(script_dir)
with app.app_context():
report = collect_status(
app.config["SQLALCHEMY_DATABASE_URI"], script_dir, revision_index
)
if args.command == "stamp":
run_stamp(cfg, args.revision, revision_index, auto_yes=args.yes)
elif args.command == "repair":
run_repair(
cfg,
report,
revision_index,
target=args.target,
assume_up_to_date=args.assume_up_to_date,
auto_yes=args.yes,
)
else:
print_status(report, verbose=getattr(args, "verbose", False))
if __name__ == "__main__":
main()