feat(sp23): live-verification fixes from end-to-end bring-up
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)
This commit is contained in:
+19
-13
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Two-stage build:
|
||||
# 1. builder — wheels the package with [sqlcipher] extra into /wheels.
|
||||
# 2. runtime — slim base, non-root user, tini PID 1, curl-based healthcheck.
|
||||
# 2. runtime — slim base, tini PID 1, curl-based healthcheck.
|
||||
#
|
||||
# `sqlcipher` is preferred but the engine falls back to plain SQLite at
|
||||
# runtime if the package isn't actually installed (see cyclone.db) — so a
|
||||
@@ -29,13 +29,11 @@ WORKDIR /build
|
||||
# Copy the build manifest first so this layer caches across source edits.
|
||||
COPY pyproject.toml ./
|
||||
|
||||
# Stub package so `pip wheel` can resolve the `cyclone` project without the
|
||||
# source tree yet — required because pyproject.toml uses setuptools and
|
||||
# references `src/cyclone`.
|
||||
RUN mkdir -p src/cyclone && touch src/cyclone/__init__.py
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
||||
|
||||
# Now copy the real source and rebuild so the wheel reflects the actual code.
|
||||
# Copy the full source tree, then build the wheel once. We deliberately
|
||||
# avoid the "stub __init__.py, build wheel, then rebuild" pattern — it
|
||||
# left stale `__init__.py` content in the wheel because pip wheel reuses
|
||||
# the cached wheel metadata when the name+version matches. See git
|
||||
# history on this file for the long version.
|
||||
COPY src/ ./src/
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
||||
|
||||
@@ -55,12 +53,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone \
|
||||
RUN pip install --no-cache-dir --no-index --find-links /wheels 'cyclone[sqlcipher]' \
|
||||
&& rm -rf /wheels
|
||||
|
||||
COPY --chown=cyclone:cyclone src/ /app/src/
|
||||
|
||||
USER cyclone
|
||||
# NOTE: we deliberately do NOT drop privileges to the `cyclone` user.
|
||||
# Named volumes mount as root inside the container, and chown-ing them
|
||||
# requires CAP_CHOWN (root). The standard hardened pattern is an
|
||||
# entrypoint script that chowns as root then drops to the app user via
|
||||
# gosu/su-exec — adds a dependency + an entrypoint file. For v1 we run
|
||||
# as root inside the container; Docker's user-namespace remapping is
|
||||
# the recommended host-level isolation. The `cyclone` user is created
|
||||
# above and survives only so file ownership in bind mounts stays
|
||||
# consistent. To harden later: install gosu + add an entrypoint script
|
||||
# that does `chown -R cyclone:cyclone /var/lib/cyclone/... && exec gosu
|
||||
# cyclone "$@"`.
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -68,7 +74,7 @@ EXPOSE 8000
|
||||
# effectively a duplicate but the Docker `HEALTHCHECK` directive keeps
|
||||
# `docker ps` honest without needing compose to be running.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD curl -fs http://localhost:8000/api/healthz || exit 1
|
||||
CMD curl -fs http://localhost:8000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["python", "-m", "cyclone", "serve"]
|
||||
|
||||
@@ -40,11 +40,17 @@ def main() -> None:
|
||||
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||
port = os.environ.get("CYCLONE_PORT", "8000")
|
||||
# Local-only by default — see CLAUDE.md. The Docker image
|
||||
# overrides to 0.0.0.0 via compose env so the frontend
|
||||
# container on the compose bridge network can reach the
|
||||
# backend. Network isolation is provided by the bridge
|
||||
# network itself (only cyclone-frontend joins).
|
||||
host = os.environ.get("CYCLONE_HOST", "127.0.0.1")
|
||||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||||
sys.argv = [
|
||||
sys.argv[0],
|
||||
"cyclone.api:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--host", host,
|
||||
"--port", port,
|
||||
]
|
||||
if reload:
|
||||
|
||||
@@ -20,7 +20,7 @@ ADMIN_ONLY = {Role.ADMIN}
|
||||
# Endpoints not in this matrix default to DENY (fail-closed).
|
||||
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
|
||||
# Public paths.
|
||||
("GET", "/api/healthz"): set(),
|
||||
("GET", "/api/health"): set(),
|
||||
("POST", "/api/auth/login"): set(),
|
||||
|
||||
# Auth surface.
|
||||
|
||||
@@ -23,7 +23,8 @@ 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"
|
||||
FRONTEND_DOCKERFILE = REPO_ROOT / "Dockerfile.frontend"
|
||||
FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf"
|
||||
|
||||
|
||||
def _has_docker() -> bool:
|
||||
@@ -173,7 +174,7 @@ def test_frontend_dockerfile_parses():
|
||||
"--check",
|
||||
"-f",
|
||||
str(FRONTEND_DOCKERFILE),
|
||||
str(REPO_ROOT / "frontend"),
|
||||
str(REPO_ROOT),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
Reference in New Issue
Block a user