diff --git a/backend/tests/test_compose_migrations.py b/backend/tests/test_compose_migrations.py new file mode 100644 index 0000000..1b7cabb --- /dev/null +++ b/backend/tests/test_compose_migrations.py @@ -0,0 +1,166 @@ +""" +Static regression tests for the Compose migration gate. + +These tests parse docker-compose.yml (via PyYAML) and assert the structural +invariants that ensure Alembic runs before any application process starts. +No Docker daemon is required — they are pure-Python configuration checks. + +Requirements covered: + CONN-04 — Cloud connections require an up-to-date schema before use + CLOUD-01 — cloud_items/cloud_folder_states tables must exist at runtime +""" +from __future__ import annotations + +import os +import pathlib + +import pytest +import yaml + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +COMPOSE_PATH = REPO_ROOT / "docker-compose.yml" + +APPLICATION_SERVICES = {"backend", "celery-worker", "celery-beat"} + + +@pytest.fixture(scope="module") +def compose() -> dict: + """Load docker-compose.yml once for all tests in this module.""" + with COMPOSE_PATH.open() as fh: + return yaml.safe_load(fh) + + +# --------------------------------------------------------------------------- +# 1. Exactly one service runs "alembic upgrade head" +# --------------------------------------------------------------------------- + +def test_exactly_one_alembic_upgrade_service(compose): + """Exactly one service must run 'alembic upgrade head' — the migrate service.""" + alembic_services = [ + name + for name, svc in compose["services"].items() + if "alembic upgrade head" in " ".join( + svc.get("command", []) + if isinstance(svc.get("command"), list) + else [str(svc.get("command", ""))] + ) + ] + assert len(alembic_services) == 1, ( + f"Expected exactly 1 service running 'alembic upgrade head', " + f"found: {alembic_services}" + ) + assert alembic_services[0] == "migrate", ( + f"Migration service must be named 'migrate', got: {alembic_services[0]}" + ) + + +# --------------------------------------------------------------------------- +# 2. The migrate service uses DATABASE_MIGRATE_URL, not DATABASE_URL +# --------------------------------------------------------------------------- + +def test_migrate_service_uses_migrate_url(compose): + """The migrate service must reference DATABASE_MIGRATE_URL.""" + svc = compose["services"]["migrate"] + env = svc.get("environment", []) + # environment may be a list of "KEY=VALUE" or "KEY" strings, or a dict + if isinstance(env, dict): + keys = set(env.keys()) + else: + keys = {e.split("=")[0] for e in env} + + assert "DATABASE_MIGRATE_URL" in keys, ( + "migrate service must declare DATABASE_MIGRATE_URL in its environment" + ) + assert "DATABASE_URL" not in keys, ( + "migrate service must NOT have DATABASE_URL — it uses the DDL-capable migrate role only" + ) + + +# --------------------------------------------------------------------------- +# 3. Application services must NOT run alembic upgrade head in their command +# --------------------------------------------------------------------------- + +def test_application_services_do_not_run_alembic(compose): + """backend/celery-worker/celery-beat must not run 'alembic upgrade' themselves.""" + for name in APPLICATION_SERVICES: + svc = compose["services"][name] + cmd = svc.get("command", "") + if isinstance(cmd, list): + cmd = " ".join(cmd) + assert "alembic upgrade" not in cmd, ( + f"Service '{name}' must not run 'alembic upgrade' — only the migrate service should" + ) + + +# --------------------------------------------------------------------------- +# 4. All application services depend on migrate with service_completed_successfully +# --------------------------------------------------------------------------- + +def test_application_services_depend_on_migrate(compose): + """backend, celery-worker, and celery-beat must all wait for migrate to complete.""" + for name in APPLICATION_SERVICES: + svc = compose["services"][name] + depends = svc.get("depends_on", {}) + assert "migrate" in depends, ( + f"Service '{name}' is missing 'migrate' in depends_on" + ) + condition = depends["migrate"].get("condition") + assert condition == "service_completed_successfully", ( + f"Service '{name}' depends_on.migrate.condition must be " + f"'service_completed_successfully', got: {condition!r}" + ) + + +# --------------------------------------------------------------------------- +# 5. The migrate service preserves container hardening +# --------------------------------------------------------------------------- + +def test_migrate_service_has_container_hardening(compose): + """migrate service must have read_only, cap_drop, and no-new-privileges.""" + svc = compose["services"]["migrate"] + + assert svc.get("read_only") is True, ( + "migrate service must set read_only: true" + ) + + cap_drop = svc.get("cap_drop", []) + assert "ALL" in cap_drop, ( + "migrate service must cap_drop ALL" + ) + + sec_opts = svc.get("security_opt", []) + assert "no-new-privileges:true" in sec_opts, ( + "migrate service must set no-new-privileges:true" + ) + + +# --------------------------------------------------------------------------- +# 6. The migrate service has restart: "no" (it is one-shot, not a daemon) +# --------------------------------------------------------------------------- + +def test_migrate_service_is_one_shot(compose): + """migrate service must have restart: 'no' — it exits after running migrations.""" + svc = compose["services"]["migrate"] + restart = svc.get("restart", "") + assert restart == "no", ( + f"migrate service restart policy must be 'no' (one-shot), got: {restart!r}" + ) + + +# --------------------------------------------------------------------------- +# 7. The migrate service only depends on postgres (not minio/redis) +# --------------------------------------------------------------------------- + +def test_migrate_service_minimal_dependencies(compose): + """migrate only needs postgres to be healthy — minio/redis are runtime concerns.""" + svc = compose["services"]["migrate"] + depends = svc.get("depends_on", {}) + assert "postgres" in depends, "migrate must depend on postgres" + assert depends["postgres"]["condition"] == "service_healthy", ( + "migrate must wait for postgres to be healthy" + ) + for extra in ("minio", "redis", "minio-init"): + assert extra not in depends, ( + f"migrate should NOT depend on '{extra}' — only postgres is needed for schema migrations" + ) diff --git a/docker-compose.yml b/docker-compose.yml index f8dcc43..5316ca8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,6 +95,29 @@ services: timeout: 3s retries: 5 + migrate: + build: ./backend + # One-shot service: runs "alembic upgrade head" then exits 0. + # All application services depend on this with condition: service_completed_successfully. + # Uses DATABASE_MIGRATE_URL (docuvault_migrate role, DDL-capable) — not DATABASE_URL. + command: alembic upgrade head + environment: + - DATABASE_MIGRATE_URL=${DATABASE_MIGRATE_URL} + - PYTHONDONTWRITEBYTECODE=1 + volumes: + - ./backend:/app + depends_on: + postgres: + condition: service_healthy + read_only: true + tmpfs: + - "/tmp:mode=1777" + cap_drop: + - ALL + security_opt: + - "no-new-privileges:true" + restart: "no" + backend: build: ./backend ports: @@ -135,6 +158,8 @@ services: condition: service_completed_successfully redis: condition: service_healthy + migrate: + condition: service_completed_successfully read_only: true tmpfs: - "/tmp:mode=1777" @@ -174,6 +199,8 @@ services: condition: service_completed_successfully redis: condition: service_healthy + migrate: + condition: service_completed_successfully read_only: true tmpfs: - "/tmp:mode=1777" @@ -205,6 +232,8 @@ services: condition: service_completed_successfully redis: condition: service_healthy + migrate: + condition: service_completed_successfully read_only: true tmpfs: - "/tmp:mode=1777"