#!/usr/bin/env python from __future__ import annotations import argparse import base64 import os from pathlib import Path import subprocess import sys META_ROOT = Path(__file__).resolve().parents[2] ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve() SRC = ROOT / "src" DEFAULT_MODULE_SETS: tuple[tuple[str, str], ...] = ( ("core-only", "tenancy,access,admin"), ("files-only", "tenancy,access,admin,files"), ("mail-only", "tenancy,access,admin,mail"), ("campaign-only", "tenancy,access,admin,campaigns"), ("campaign-with-files", "tenancy,access,admin,campaigns,files"), ("campaign-with-mail", "tenancy,access,admin,campaigns,mail"), ("full-product", "tenancy,access,admin,policy,audit,campaigns,files,mail,calendar,docs,ops"), ) def main() -> int: parser = argparse.ArgumentParser( description="Run GovOPlaN migrations and startup smoke checks against a disposable PostgreSQL database.", ) parser.add_argument( "--database-url", default=os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL") or os.environ.get("DATABASE_URL"), help="SQLAlchemy PostgreSQL URL, for example postgresql+psycopg://user:pass@127.0.0.1:55432/govoplan.", ) parser.add_argument( "--module-set", action="append", default=[], metavar="NAME=MODULES", help="Module permutation to check. May be passed multiple times. Defaults to the standard product permutations.", ) parser.add_argument( "--reset-schema", action="store_true", help="DROP and recreate the public schema before every module set. Use only with a disposable database.", ) parser.add_argument("--skip-migrate", action="store_true", help="Skip govoplan_core.commands.init_db.") parser.add_argument("--skip-smoke", action="store_true", help="Skip govoplan_core.devserver --smoke.") parser.add_argument("--python", default=sys.executable, help="Python executable to use. Defaults to the current interpreter.") parser.add_argument("--app-env", default="staging", help="APP_ENV value to pass to subprocesses. Defaults to staging.") args = parser.parse_args() database_url = (args.database_url or "").strip() if not database_url: parser.error("--database-url or GOVOPLAN_POSTGRES_DATABASE_URL is required") if not database_url.startswith("postgresql"): parser.error("This check is PostgreSQL-only. Use a postgresql+psycopg:// or postgresql:// DATABASE_URL.") module_sets = _parse_module_sets(args.module_set) ok = True for name, modules in module_sets: print(f"\n== {name}: {modules} ==") if args.reset_schema: _reset_public_schema(database_url) env = _check_env(database_url=database_url, modules=modules, app_env=args.app_env) if not args.skip_migrate: ok = _run([args.python, "-m", "govoplan_core.commands.init_db", "--database-url", database_url], env=env) and ok if not args.skip_smoke: ok = _run([args.python, "-m", "govoplan_core.devserver", "--smoke", "--no-reload"], env=env) and ok return 0 if ok else 1 def _parse_module_sets(raw: list[str]) -> tuple[tuple[str, str], ...]: if not raw: return DEFAULT_MODULE_SETS parsed: list[tuple[str, str]] = [] for item in raw: name, separator, modules = item.partition("=") if not separator or not name.strip() or not modules.strip(): raise SystemExit(f"Invalid --module-set value {item!r}. Use NAME=module_a,module_b.") parsed.append((name.strip(), modules.strip())) return tuple(parsed) def _check_env(*, database_url: str, modules: str, app_env: str) -> dict[str, str]: env = os.environ.copy() env["APP_ENV"] = app_env env["DATABASE_URL"] = database_url env["ENABLED_MODULES"] = modules env["DEV_AUTO_MIGRATE_ENABLED"] = "false" env["DEV_BOOTSTRAP_ENABLED"] = "false" env["CELERY_ENABLED"] = "false" env.setdefault("MASTER_KEY_B64", base64.urlsafe_b64encode(os.urandom(32)).decode("ascii")) env.setdefault("FILE_STORAGE_BACKEND", "local") env.setdefault("FILE_STORAGE_LOCAL_ROOT", str(ROOT / "runtime" / "postgres-check-files")) env.setdefault("MOCK_MAILBOX_DIR", str(ROOT / "runtime" / "postgres-check-mock-mailbox")) env["PYTHONPATH"] = os.pathsep.join(part for part in (str(SRC), env.get("PYTHONPATH", "")) if part) return env def _run(command: list[str], *, env: dict[str, str]) -> bool: print("+ " + " ".join(command)) result = subprocess.run(command, cwd=ROOT, env=env, check=False) if result.returncode != 0: print(f"Command failed with exit code {result.returncode}: {' '.join(command)}", file=sys.stderr) return False return True def _reset_public_schema(database_url: str) -> None: try: from sqlalchemy import create_engine except ModuleNotFoundError as exc: raise SystemExit("SQLAlchemy is required for --reset-schema. Install govoplan-core requirements first.") from exc engine = create_engine(database_url, isolation_level="AUTOCOMMIT") try: with engine.connect() as connection: connection.exec_driver_sql("DROP SCHEMA IF EXISTS public CASCADE") connection.exec_driver_sql("CREATE SCHEMA public") connection.exec_driver_sql("GRANT ALL ON SCHEMA public TO public") finally: engine.dispose() print("Reset PostgreSQL schema: public") if __name__ == "__main__": raise SystemExit(main())