260 lines
8.8 KiB
Python
260 lines
8.8 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 / "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,
|
|
)
|