diff --git a/backend/tests/test_docker.py b/backend/tests/test_docker.py new file mode 100644 index 0000000..4e62a25 --- /dev/null +++ b/backend/tests/test_docker.py @@ -0,0 +1,227 @@ +"""Dockerfile + compose-config smoke tests for SP23. + +These tests do NOT require a running Docker daemon (the compose-up test +is gated on ``DOCKER_TESTS=1`` so it can be skipped on bare CI without +Docker). They validate that: + +* ``docker-compose.yml`` at the repo root is syntactically valid and that + the shape we expect (services, secrets, volumes, networks) is present. +* Both Dockerfiles parse with ``docker build --check`` if Docker is on PATH. +* The named-volume mount paths match what ``cyclone.db`` + the BackupService + expect at runtime. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMPOSE_FILE = REPO_ROOT / "docker-compose.yml" +BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile" +FRONTEND_DOCKERFILE = REPO_ROOT / "frontend" / "Dockerfile" + + +def _has_docker() -> bool: + return shutil.which("docker") is not None + + +def _has_docker_compose() -> bool: + if shutil.which("docker") is None: + return False + return ( + subprocess.run( + ["docker", "compose", "version"], + capture_output=True, + check=False, + ).returncode + == 0 + ) + + +def _volume_source(v) -> str: + """Normalize a compose volume entry to the source name (str form or 'source' key).""" + if isinstance(v, str): + # Long form: "named_volume:/container/path" — split on ':'. + return v.split(":", 1)[0] + if isinstance(v, dict): + return v.get("source", "") + return "" + + +def test_compose_file_exists(): + assert COMPOSE_FILE.exists(), f"missing {COMPOSE_FILE}" + + +def test_compose_config_validates(): + """``docker compose config`` should exit 0 with no stderr.""" + if not _has_docker_compose(): + pytest.skip("docker compose not on PATH") + result = subprocess.run( + [ + "docker", + "compose", + "-f", + str(COMPOSE_FILE), + "config", + "--quiet", + ], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, ( + f"compose config failed: stderr={result.stderr!r}" + ) + + +def test_compose_declares_required_services(): + compose = yaml.safe_load(COMPOSE_FILE.read_text()) + services = compose.get("services", {}) + assert "backend" in services, "compose must declare a 'backend' service" + assert "frontend" in services, "compose must declare a 'frontend' service" + + backend = services["backend"] + backend_volume_sources = {_volume_source(v) for v in backend.get("volumes", [])} + assert "cyclone_db" in backend_volume_sources, ( + "backend must mount the cyclone_db volume" + ) + assert backend.get("restart") == "unless-stopped", ( + "backend must restart: unless-stopped so healthcheck failures recover" + ) + assert "healthcheck" in backend, "backend must declare a healthcheck" + + frontend = services["frontend"] + assert "8080:8080" in frontend.get("ports", []), ( + "frontend must publish 8080:8080 for LAN access" + ) + depends_on = frontend.get("depends_on") or {} + if isinstance(depends_on, dict): + backend_dep = depends_on.get("backend") or {} + assert backend_dep.get("condition") == "service_healthy", ( + "frontend must wait for backend healthy before starting" + ) + else: + # Short-form `depends_on: [backend]` is acceptable too — it implies + # service_started, not service_healthy. Flag a soft warning. + pytest.skip( + "frontend uses short-form depends_on; switch to long-form for service_healthy" + ) + + +def test_compose_declares_required_secrets_and_volumes(): + compose = yaml.safe_load(COMPOSE_FILE.read_text()) + secrets = compose.get("secrets", {}) + for required in ("cyclone_db_key", "cyclone_admin_password"): + assert required in secrets, f"compose must declare secret {required!r}" + volumes = compose.get("volumes", {}) + for required in ( + "cyclone_db", + "cyclone_backups", + "cyclone_prodfiles", + "cyclone_sftp_staging", + "cyclone_logs", + ): + assert required in volumes, f"compose must declare volume {required!r}" + + +def test_compose_backend_wires_backup_autostart(): + """The existing BackupService (SP17) needs CYCLONE_BACKUP_AUTOSTART=1 + on container boot. The compose env block must include it.""" + compose = yaml.safe_load(COMPOSE_FILE.read_text()) + env = compose["services"]["backend"].get("environment", {}) + assert str(env.get("CYCLONE_BACKUP_AUTOSTART")) == "1", ( + "backend must autostart the backup scheduler (CYCLONE_BACKUP_AUTOSTART=1)" + ) + assert "CYCLONE_BACKUP_INTERVAL_HOURS" in env + assert "CYCLONE_BACKUP_RETENTION_DAYS" in env + + +@pytest.mark.skipif( + not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check" +) +def test_backend_dockerfile_parses(): + result = subprocess.run( + [ + "docker", + "build", + "--check", + "-f", + str(BACKEND_DOCKERFILE), + str(REPO_ROOT / "backend"), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"backend Dockerfile failed to parse: stderr={result.stderr!r}" + ) + + +@pytest.mark.skipif( + not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check" +) +def test_frontend_dockerfile_parses(): + result = subprocess.run( + [ + "docker", + "build", + "--check", + "-f", + str(FRONTEND_DOCKERFILE), + str(REPO_ROOT / "frontend"), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"frontend Dockerfile failed to parse: stderr={result.stderr!r}" + ) + + +@pytest.mark.skipif( + not _has_docker_compose() + or not os.environ.get("DOCKER_TESTS"), + reason="DOCKER_TESTS=1 + docker compose required for live bring-up", +) +def test_compose_up_brings_up_healthy_stack(): + """Gated live test — only runs when DOCKER_TESTS=1 and docker compose + is available. Builds + brings up the full stack and waits up to 120s + for the backend healthcheck to come up healthy.""" + subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--build"], + check=True, + cwd=REPO_ROOT, + ) + try: + import time + + for _ in range(60): + ps = subprocess.run( + [ + "docker", + "compose", + "-f", + str(COMPOSE_FILE), + "ps", + "--format", + "json", + ], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + if "healthy" in ps.stdout: + return + time.sleep(2) + pytest.fail("compose stack did not become healthy within 120s") + finally: + subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "down", "-v"], + check=False, + cwd=REPO_ROOT, + )