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.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user