Files
govoplan/tools/checks/postgres-integration-check.py

130 lines
5.4 KiB
Python

#!/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())