3ba5ca0849
After the 8-commit SP23 implementation landed, kicking the tires on
`docker compose build && docker compose up -d` (the gated DOCKER_TESTS=1
live test) surfaced five real bugs that don't show up in unit tests:
1. Backend wheel was built from the stub `__init__.py`, not the real
source. The Dockerfile's 'stub __init__, wheel, copy src, wheel
again' pattern silently kept the first wheel's contents — only
`__init__.py` got re-stubbed. The installed package had an empty
`__init__.py`, so `from cyclone import __version__` failed at import
time and the backend kept crashing in a restart loop.
Fix: single `COPY src/` + single `pip wheel`. Comment explains
why the stub trick is gone for good.
2. Backend binds to 127.0.0.1 (intentional — local-only by design,
see CLAUDE.md). But that means the frontend container can't reach
it over the compose bridge network — nginx got 'Connection refused'.
Fix: `CYCLONE_HOST` env var, defaults to 127.0.0.1 (preserves local
posture for non-Docker runs), set to 0.0.0.0 by the docker-compose
backend service. Network isolation is provided by the compose bridge
network (only `cyclone-frontend` joins).
3. Healthcheck probed `/api/healthz` (404 — the route is `/api/health`).
Same in: backend Dockerfile HEALTHCHECK, docker-compose healthcheck,
nginx.conf doesn't have one (frontend proxies through), RUNBOOK.md,
scripts/post-deploy.sh, scripts/smoke.sh.
Fix: `/api/healthz` → `/api/health` everywhere SP23 owns.
4. The auth matrix in `cyclone.auth.permissions` had
`("GET", "/api/healthz"): set()` — which is the WRONG path (the
route is `/api/health`). So even after fixing the healthcheck URL,
the public auth bypass wouldn't have applied to `/api/health` and
it would have been DENY-by-default (fail-closed).
Fix: matrix entry updated to `/api/health`.
5. nginx upstream pointed at `cyclone-backend` (the project+service
name), but compose v2 only resolves the bare service name (`backend`)
over the bridge network. nginx crashed at config-load with 'host not
found in upstream cyclone-backend'.
Fix: `cyclone-backend:8000` → `backend:8000` in nginx.conf + spec
+ plan.
6. Frontend HEALTHCHECK used `http://localhost:8080/`. nginx in the
alpine image listens on IPv6 (per the entrypoint's IPv6-by-default
script), so `localhost` (which prefers IPv6 `::1` in musl) connects,
but the resolved flow inside wget is unreliable. `127.0.0.1` works.
Fix: HEALTHCHECK uses `http://127.0.0.1:8080/`.
Also moves `frontend/Dockerfile` → `Dockerfile.frontend` and
`frontend/nginx.conf` → `nginx.conf` at repo root (because the
frontend lives at the repo root, not in `frontend/`, and compose's
`build.context: .` needs them at the same root as compose.yml).
The frontend's pre-existing `.dockerignore` was empty/unused, so it's
dropped — the root `.dockerignore` covers it.
Adds `docker-compose.override.yml` for local bring-up testing on a
host without sudo. Production uses `/etc/cyclone/secrets/` directly.
Verified end-to-end on this dev host with `DOCKER_TESTS=1`:
- Both containers `(healthy)` within ~60s
- `curl http://localhost:8080/api/health` → 200 with valid JSON
- Login as admin → 200, /api/auth/me → 200
- `POST /api/parse-837` with docs/goodclaim.x12 → 200, batch created
- Full backend test suite: 1014 passed, 9 skipped (prodfiles gitignored)
229 lines
7.1 KiB
Python
229 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 / "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"):
|
|
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),
|
|
],
|
|
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,
|
|
)
|