diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 88c6da1..6815889 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1374,15 +1374,16 @@ jobs: # CID is empty until the app container exists, so this is safe to # arm now and still covers a failure before that point. CID="" + CID_ALL="" cleanup() { rc=$? - if [ -n "$CID" ]; then + for c in $CID $CID_ALL; do # The log ONLY on failure — a boot that never answered must fail # with the reason visible rather than as a bare timeout (rule # 156), while a green run has nothing to say. - [ $rc -eq 0 ] || docker logs "$CID" 2>&1 | tail -40 - docker rm -f "$CID" >/dev/null 2>&1 || true - fi + [ $rc -eq 0 ] || docker logs "$c" 2>&1 | tail -40 + docker rm -f "$c" >/dev/null 2>&1 || true + done docker network rm "$NET" >/dev/null 2>&1 || true # Preserve the real status, which a trap ending on a successful # `docker rm` would otherwise mask. @@ -1511,6 +1512,51 @@ jobs: docker run --rm --network "$NET" "$CANDIDATE" shell -c \ "python3 -c \"import urllib.request; print(urllib.request.urlopen('http://$WEB_IP:8080/api/health', timeout=5).read().decode())\"" + # 4. THE DEFAULT ROLE. Everything above boots `web` explicitly, so + # until now nothing had ever started the shape the single- + # container layout actually runs — supervisord bringing up + # hypercorn plus one celery process per lane. The milestone is + # named for that shape and CI had never once executed it. + # + # Deliberately NO command: this is `docker run ` with + # nothing after it, so it checks the Dockerfile's CMD and the + # entrypoint's default together with the role itself. An adopter + # who writes no `command:` gets exactly this. + # + # `healthcheck_all` is the right assertion and was itself never + # run: it passes only when hypercorn answers AND every lane in + # the table is answering the broker. A web-only check would go + # green with every worker dead, which is the failure mode + # consolidation creates. + # + # Cheap because ml ships at 0 slots and disabled, so nothing + # loads a model — the lane answers `inspect` with its consumers + # cancelled, which is what "healthy" means for a disabled lane. + echo "smoke: the default role boots every lane under supervisord" + CID_ALL=$(docker run -d $ENVOPTS "$CANDIDATE") + lanes_up="" + for i in $(seq 1 60); do + if docker exec "$CID_ALL" python -m backend.app.scripts.healthcheck_all; then + lanes_up=1 + break + fi + if [ "$(docker inspect -f '{{.State.Running}}' "$CID_ALL" 2>/dev/null)" != "true" ]; then + echo "smoke: FAILED — the all-role container exited during boot." >&2 + exit 1 + fi + sleep 3 + done + if [ -z "$lanes_up" ]; then + echo "smoke: FAILED — supervisord came up but the composite" >&2 + echo "smoke: healthcheck never passed: either hypercorn did not" >&2 + echo "smoke: answer or a lane is not consuming. Its log follows." >&2 + exit 1 + fi + echo "smoke: every lane answered" + # Say WHICH processes supervisord is running, so a lane that is + # merely restart-looping is visible rather than inferred. + docker exec "$CID_ALL" supervisorctl -c "${SUPERVISOR_CONF:-/tmp/supervisord.conf}" status || true + echo "smoke: all checks passed against $CANDIDATE, with egress blocked" # Move the channel tags — the whole point of the gate. diff --git a/Dockerfile b/Dockerfile index 1cffc96..df8d1ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -127,4 +127,15 @@ ENV FC_VERSION=${FC_VERSION} EXPOSE 8080 ENTRYPOINT ["./entrypoint.sh"] -CMD ["web"] +# The DEFAULT is the whole application, not one lane of it. +# +# `docker run fabledcurator` with no command starts hypercorn plus every +# worker lane under supervisord — the shape an adopter wants and the shape the +# consolidated stack runs. It was `web`, which meant the single-container +# layout only worked if you knew to ask for it by name, and a compose file +# that forgot `command:` got a web server with nothing processing its queues: +# a gallery that loads, accepts an import, and never finishes one. +# +# The multi-service stack is unaffected — every service there names its role +# explicitly, which is exactly what makes it the multi-service stack. +CMD ["all"] diff --git a/docker-compose.single.yml b/docker-compose.single.yml index c6e0f38..9d34e32 100644 --- a/docker-compose.single.yml +++ b/docker-compose.single.yml @@ -59,10 +59,10 @@ services: fabledcurator: image: git.fabledsword.com/bvandeusen/fabledcurator:latest - # Everything: hypercorn plus one celery process per lane, under - # supervisord, whose config is generated from the application's own lane - # table so the two cannot disagree. - command: ["all"] + # No `command:`. Everything — hypercorn plus one celery process per lane + # under supervisord — is what the image does by default, and supervisord's + # config is generated from the application's own lane table so the two + # cannot disagree. `command: ["all"]` still works and means the same thing. # tini as PID 1, in front of supervisord. supervisord reaps its own # children, but a container's PID 1 also inherits orphans from anywhere # below — celery's prefork pool and gallery-dl's subprocesses both make diff --git a/entrypoint.sh b/entrypoint.sh index f24f007..d7a99b2 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,7 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -ROLE="${1:-web}" +# Defaults to the whole application (see the Dockerfile's CMD). Kept in step +# with that CMD deliberately: they are two doors to the same decision, and a +# disagreement between them would only show up as `docker run --entrypoint` +# behaving differently from `docker run`. +ROLE="${1:-all}" shift || true case "$ROLE" in