"""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 / "Dockerfile.frontend" FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf" 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", "cyclone_sftp_password"): assert required in secrets, f"compose must declare secret {required!r}" # SP26: the SFTP secret must point at the same /etc/cyclone/secrets/ tree # the host operator manages. Catch typos in the file path here so a # rename breaks the test, not a production deploy. assert ( secrets["cyclone_sftp_password"]["file"] == "/etc/cyclone/secrets/sftp_password" ) 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 def test_compose_backend_wires_sftp_password_file_env_var(): """SP26 — the backend must wire CYCLONE_SFTP_PASSWORD_FILE to the mounted Docker secret so the MFT password resolves from /run/secrets/cyclone_sftp_password without an env-var export.""" compose = yaml.safe_load(COMPOSE_FILE.read_text()) backend = compose["services"]["backend"] env = backend.get("environment", {}) assert env.get("CYCLONE_SFTP_PASSWORD_FILE") == "/run/secrets/cyclone_sftp_password", ( "backend must wire CYCLONE_SFTP_PASSWORD_FILE to the Docker secret mount" ) backend_secrets = backend.get("secrets", []) assert any( _volume_source(s) == "cyclone_sftp_password" for s in backend_secrets ), "backend must reference the cyclone_sftp_password Docker secret" @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), ], 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. Uses the override file (docker-compose.override.yml) when present so the test doesn't require sudo to create /etc/cyclone/secrets/.""" # The stack publishes host port 8080. If something else on this host # is already using it (e.g. nocodb on a dev box) the test can't run # here but will run cleanly on a fresh CI worker. Skip with a clear # message rather than failing with a confusing port-bind error. import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: if s.connect_ex(("127.0.0.1", 8080)) == 0: pytest.skip("host port 8080 already bound — rerun on a fresh host") override = REPO_ROOT / "docker-compose.override.yml" cmd_base = ["docker", "compose", "-f", str(COMPOSE_FILE)] if override.exists(): cmd_base.extend(["-f", str(override)]) subprocess.run( cmd_base + ["up", "-d", "--build"], check=True, cwd=REPO_ROOT, ) try: import time for _ in range(60): ps = subprocess.run( cmd_base + ["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( cmd_base + ["down", "-v"], check=False, cwd=REPO_ROOT, )