423 lines
15 KiB
Python
423 lines
15 KiB
Python
#!/usr/bin/env python
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_BASELINE_FILE = ROOT / "docs" / "migration-release-baselines.json"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Migration:
|
|
owner: str
|
|
path: Path
|
|
revision: str
|
|
down_revisions: tuple[str, ...]
|
|
branch_labels: tuple[str, ...]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Audit GovOPlaN Alembic migrations against the release baseline policy.",
|
|
)
|
|
parser.add_argument(
|
|
"--workspace-root",
|
|
type=Path,
|
|
default=ROOT.parent,
|
|
help="Directory containing govoplan-* checkouts. Defaults to the parent of govoplan-core.",
|
|
)
|
|
parser.add_argument(
|
|
"--baseline-file",
|
|
type=Path,
|
|
default=DEFAULT_BASELINE_FILE,
|
|
help="JSON file recording released migration heads.",
|
|
)
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="Fail when current heads are not recorded in the latest release baseline.",
|
|
)
|
|
parser.add_argument(
|
|
"--strict-if-baseline",
|
|
action="store_true",
|
|
help="Run in strict mode only after at least one release baseline is recorded.",
|
|
)
|
|
parser.add_argument(
|
|
"--record-release",
|
|
metavar="VERSION",
|
|
help="Record the current reviewed migration heads as a release baseline.",
|
|
)
|
|
parser.add_argument(
|
|
"--replace-release",
|
|
action="store_true",
|
|
help="Replace an existing release entry when used with --record-release.",
|
|
)
|
|
parser.add_argument(
|
|
"--squash-plan",
|
|
action="store_true",
|
|
help="Print the reviewed/manual squash checklist for the current graph.",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
|
args = parser.parse_args()
|
|
|
|
migrations = discover_migrations(args.workspace_root)
|
|
baseline = load_baseline_file(args.baseline_file)
|
|
report = build_report(
|
|
migrations,
|
|
baseline=baseline,
|
|
baseline_file=args.baseline_file,
|
|
workspace_root=args.workspace_root,
|
|
)
|
|
|
|
if args.record_release:
|
|
if report["graph_errors"]:
|
|
print_text_report(report)
|
|
return 1
|
|
baseline = record_release_baseline(
|
|
baseline,
|
|
report,
|
|
release=args.record_release,
|
|
replace=args.replace_release,
|
|
)
|
|
write_baseline_file(args.baseline_file, baseline)
|
|
report = build_report(
|
|
migrations,
|
|
baseline=baseline,
|
|
baseline_file=args.baseline_file,
|
|
workspace_root=args.workspace_root,
|
|
)
|
|
|
|
if args.squash_plan:
|
|
print_squash_plan(report)
|
|
elif args.json:
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
else:
|
|
print_text_report(report)
|
|
|
|
has_graph_errors = bool(report["graph_errors"])
|
|
has_strict_errors = bool(report["strict_errors"])
|
|
strict_required = args.strict or (args.strict_if_baseline and report["release_count"] > 0)
|
|
if has_graph_errors or (strict_required and has_strict_errors):
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def discover_migrations(workspace_root: Path) -> list[Migration]:
|
|
migrations: list[Migration] = []
|
|
for versions_dir in _migration_version_dirs(workspace_root):
|
|
owner = owner_for_versions_dir(versions_dir)
|
|
for path in sorted(versions_dir.glob("*.py")):
|
|
if path.name == "__init__.py":
|
|
continue
|
|
migration = parse_migration_file(owner, path)
|
|
if migration is not None:
|
|
migrations.append(migration)
|
|
return migrations
|
|
|
|
|
|
def _migration_version_dirs(workspace_root: Path) -> list[Path]:
|
|
candidates = [ROOT / "alembic" / "versions"]
|
|
for repo in sorted(workspace_root.glob("govoplan-*")):
|
|
candidates.extend(sorted(repo.glob("src/govoplan_*/backend/migrations/versions")))
|
|
seen: set[Path] = set()
|
|
result: list[Path] = []
|
|
for candidate in candidates:
|
|
resolved = candidate.resolve()
|
|
if resolved in seen or not candidate.is_dir():
|
|
continue
|
|
seen.add(resolved)
|
|
result.append(candidate)
|
|
return result
|
|
|
|
|
|
def owner_for_versions_dir(versions_dir: Path) -> str:
|
|
if (ROOT / "alembic" / "versions").resolve() == versions_dir.resolve():
|
|
return "govoplan-core"
|
|
for parent in versions_dir.parents:
|
|
if parent.name.startswith("govoplan-"):
|
|
return parent.name
|
|
return versions_dir.parent.name
|
|
|
|
|
|
def parse_migration_file(owner: str, path: Path) -> Migration | None:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
values: dict[str, Any] = {}
|
|
for statement in tree.body:
|
|
if isinstance(statement, ast.Assign):
|
|
for target in statement.targets:
|
|
if isinstance(target, ast.Name) and target.id in {"revision", "down_revision", "branch_labels"}:
|
|
values[target.id] = ast.literal_eval(statement.value)
|
|
elif (
|
|
isinstance(statement, ast.AnnAssign)
|
|
and isinstance(statement.target, ast.Name)
|
|
and statement.target.id in {"revision", "down_revision", "branch_labels"}
|
|
and statement.value is not None
|
|
):
|
|
values[statement.target.id] = ast.literal_eval(statement.value)
|
|
revision = values.get("revision")
|
|
if not isinstance(revision, str):
|
|
return None
|
|
return Migration(
|
|
owner=owner,
|
|
path=path,
|
|
revision=revision,
|
|
down_revisions=_normalize_revision_tuple(values.get("down_revision")),
|
|
branch_labels=_normalize_string_tuple(values.get("branch_labels")),
|
|
)
|
|
|
|
|
|
def _normalize_revision_tuple(value: Any) -> tuple[str, ...]:
|
|
if value is None:
|
|
return ()
|
|
if isinstance(value, str):
|
|
return (value,)
|
|
if isinstance(value, (list, tuple, set)):
|
|
return tuple(str(item) for item in value if item is not None)
|
|
return (str(value),)
|
|
|
|
|
|
def _normalize_string_tuple(value: Any) -> tuple[str, ...]:
|
|
if value is None:
|
|
return ()
|
|
if isinstance(value, str):
|
|
return (value,)
|
|
if isinstance(value, (list, tuple, set)):
|
|
return tuple(str(item) for item in value)
|
|
return (str(value),)
|
|
|
|
|
|
def load_baseline_file(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {"version": 1, "releases": [], "_missing": True}
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise SystemExit(f"{path} must contain a JSON object")
|
|
releases = payload.get("releases")
|
|
if not isinstance(releases, list):
|
|
raise SystemExit(f"{path} must contain a releases list")
|
|
return payload
|
|
|
|
|
|
def build_report(
|
|
migrations: list[Migration],
|
|
*,
|
|
baseline: dict[str, Any],
|
|
baseline_file: Path,
|
|
workspace_root: Path,
|
|
) -> dict[str, Any]:
|
|
revisions: dict[str, Migration] = {}
|
|
graph_errors: list[str] = []
|
|
for migration in migrations:
|
|
existing = revisions.get(migration.revision)
|
|
if existing is not None:
|
|
graph_errors.append(
|
|
f"duplicate revision {migration.revision}: {existing.path} and {migration.path}"
|
|
)
|
|
revisions[migration.revision] = migration
|
|
|
|
referenced = {revision for migration in migrations for revision in migration.down_revisions}
|
|
for migration in migrations:
|
|
for down_revision in migration.down_revisions:
|
|
if down_revision not in revisions:
|
|
graph_errors.append(
|
|
f"{migration.revision} references missing down_revision {down_revision} in {migration.path}"
|
|
)
|
|
|
|
current_heads = sorted(revision for revision in revisions if revision not in referenced)
|
|
current_head_entries = [
|
|
{
|
|
"owner": revisions[revision].owner,
|
|
"revision": revision,
|
|
}
|
|
for revision in current_heads
|
|
]
|
|
owner_rows = []
|
|
for owner in sorted({migration.owner for migration in migrations}):
|
|
owner_migrations = [migration for migration in migrations if migration.owner == owner]
|
|
owner_revisions = {migration.revision for migration in owner_migrations}
|
|
owner_referenced = {
|
|
revision
|
|
for migration in owner_migrations
|
|
for revision in migration.down_revisions
|
|
if revision in owner_revisions
|
|
}
|
|
owner_heads = sorted(owner_revisions - owner_referenced)
|
|
owner_rows.append(
|
|
{
|
|
"owner": owner,
|
|
"migrations": len(owner_migrations),
|
|
"heads": owner_heads,
|
|
}
|
|
)
|
|
|
|
releases = baseline.get("releases") or []
|
|
latest_release = releases[-1] if releases else None
|
|
latest_heads = _latest_release_heads(latest_release)
|
|
unrecorded_heads = sorted(set(current_heads) - latest_heads) if latest_release else current_heads
|
|
|
|
strict_errors: list[str] = []
|
|
if baseline.get("_missing"):
|
|
strict_errors.append(f"baseline file is missing: {baseline_file}")
|
|
if not releases:
|
|
strict_errors.append("no released migration baseline has been recorded yet")
|
|
if unrecorded_heads:
|
|
strict_errors.append(
|
|
"current migration heads are not recorded in the latest release baseline: "
|
|
+ ", ".join(unrecorded_heads)
|
|
)
|
|
return {
|
|
"workspace_root": str(workspace_root),
|
|
"baseline_file": str(baseline_file),
|
|
"baseline_file_missing": bool(baseline.get("_missing")),
|
|
"release_count": len(releases),
|
|
"latest_release": latest_release,
|
|
"owners": owner_rows,
|
|
"current_heads": current_heads,
|
|
"current_head_entries": current_head_entries,
|
|
"graph_errors": graph_errors,
|
|
"strict_errors": strict_errors,
|
|
}
|
|
|
|
|
|
def _latest_release_heads(latest_release: Any) -> set[str]:
|
|
if not isinstance(latest_release, dict):
|
|
return set()
|
|
heads = latest_release.get("heads") or []
|
|
graph_heads = latest_release.get("graph_heads") or []
|
|
result: set[str] = set()
|
|
for head_list in (heads, graph_heads):
|
|
if not isinstance(head_list, list):
|
|
continue
|
|
for entry in head_list:
|
|
if isinstance(entry, str):
|
|
result.add(entry)
|
|
elif isinstance(entry, dict) and isinstance(entry.get("revision"), str):
|
|
result.add(entry["revision"])
|
|
owner_heads = latest_release.get("owner_heads") or []
|
|
if isinstance(owner_heads, list):
|
|
for entry in owner_heads:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
revisions = entry.get("revisions") or []
|
|
if isinstance(revisions, str):
|
|
result.add(revisions)
|
|
elif isinstance(revisions, list):
|
|
result.update(revision for revision in revisions if isinstance(revision, str))
|
|
return result
|
|
|
|
|
|
def record_release_baseline(
|
|
baseline: dict[str, Any],
|
|
report: dict[str, Any],
|
|
*,
|
|
release: str,
|
|
replace: bool,
|
|
) -> dict[str, Any]:
|
|
payload = {
|
|
key: value
|
|
for key, value in baseline.items()
|
|
if not key.startswith("_")
|
|
}
|
|
payload.setdefault("version", 1)
|
|
releases = list(payload.get("releases") or [])
|
|
existing_index = next(
|
|
(index for index, item in enumerate(releases) if isinstance(item, dict) and item.get("release") == release),
|
|
None,
|
|
)
|
|
if existing_index is not None and not replace:
|
|
raise SystemExit(f"release {release} already exists in baseline file; pass --replace-release to update it")
|
|
|
|
entry = {
|
|
"release": release,
|
|
"recorded_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
"squash_policy": "reviewed-manual",
|
|
"heads": report["current_head_entries"],
|
|
"owner_heads": [
|
|
{
|
|
"owner": owner["owner"],
|
|
"revisions": owner["heads"],
|
|
}
|
|
for owner in report["owners"]
|
|
],
|
|
}
|
|
if existing_index is None:
|
|
releases.append(entry)
|
|
else:
|
|
releases[existing_index] = entry
|
|
payload["releases"] = releases
|
|
return payload
|
|
|
|
|
|
def write_baseline_file(path: Path, baseline: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(baseline, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
def print_text_report(report: dict[str, Any]) -> None:
|
|
print("Migration release audit")
|
|
print(f" workspace: {report['workspace_root']}")
|
|
print(f" baseline file: {report['baseline_file']}")
|
|
print(f" recorded releases: {report['release_count']}")
|
|
print()
|
|
print("Owners:")
|
|
for owner in report["owners"]:
|
|
heads = ", ".join(owner["heads"]) if owner["heads"] else "-"
|
|
print(f" {owner['owner']}: {owner['migrations']} migration(s), head(s): {heads}")
|
|
print()
|
|
print("Current heads:")
|
|
for entry in report["current_head_entries"]:
|
|
print(f" {entry['owner']}: {entry['revision']}")
|
|
if not report["current_head_entries"]:
|
|
print(" -")
|
|
|
|
if report["graph_errors"]:
|
|
print()
|
|
print("Graph errors:")
|
|
for error in report["graph_errors"]:
|
|
print(f" - {error}")
|
|
|
|
if report["strict_errors"]:
|
|
print()
|
|
print("Release baseline warnings:")
|
|
for error in report["strict_errors"]:
|
|
print(f" - {error}")
|
|
|
|
|
|
def print_squash_plan(report: dict[str, Any]) -> None:
|
|
print("Manual migration squash plan")
|
|
print()
|
|
print("Policy:")
|
|
print(" - Do not rewrite revision IDs that have shipped to real installations.")
|
|
print(" - Squash only unreleased development migrations.")
|
|
print(" - Review generated baseline/upgrade migrations like normal schema code.")
|
|
print(" - Run PostgreSQL migration smoke checks after any squash.")
|
|
print()
|
|
print("Current owner heads:")
|
|
for owner in report["owners"]:
|
|
heads = ", ".join(owner["heads"]) if owner["heads"] else "-"
|
|
print(f" - {owner['owner']}: {heads}")
|
|
print()
|
|
print("Current Alembic graph heads:")
|
|
for entry in report["current_head_entries"]:
|
|
print(f" - {entry['owner']}: {entry['revision']}")
|
|
if not report["current_head_entries"]:
|
|
print(" -")
|
|
print()
|
|
print("Release steps:")
|
|
print(" 1. Decide which migration revisions are unreleased and may be folded.")
|
|
print(" 2. Replace those development chains with reviewed baseline/upgrade migrations.")
|
|
print(" 3. Run scripts/release-migration-audit.py and PostgreSQL release checks.")
|
|
print(" 4. Record the reviewed heads with scripts/release-migration-audit.py --record-release <x.y.z>.")
|
|
print(" 5. Cut the release with scripts/push-release-tag.sh; audit is strict after the first baseline.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|