f7697e58b7
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.
228 lines
7.1 KiB
Python
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,
|
|
)
|