Files
cyclone/backend/tests/test_docker.py
T
Cyclone f7697e58b7 feat(sp23): tests/test_docker.py validates compose config + Dockerfile parse
Eight tests: compose file exists, docker compose config validates, services declare required volumes/restart/healthcheck/ports/depends_on, secrets and volumes tables include the names the operator expects, backend wires CYCLONE_BACKUP_AUTOSTART=1, both Dockerfiles pass . The live bring-up test (compose up + wait for healthy) is gated on DOCKER_TESTS=1 so it skips on bare CI. PyYAML was already a hard dep.
2026-06-23 17:24:46 -06:00

228 lines
7.1 KiB
Python

"""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,
)