CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
extension / lint (push) Successful in 18s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m42s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"why isn't the healthcheck built into the image or
base on what command runs if one is passed in. why is it manually declared in
the stack here."*
No good reason. The container is the only thing that knows what it was asked
to run, and every compose file, stack file and README had to restate it:
web -> urllib /api/health
worker -> celery inspect ping -d celery@$HOSTNAME
all -> both, for every lane
Three checks written by hand, once per service, in every file anyone ever
wrote — none of them wrong until a role changed, and all of them silently
wrong after. The same duplication the lane table exists to remove one level
down, and I built it without noticing.
`entrypoint.sh` now records the role it started. The Dockerfile declares ONE
`HEALTHCHECK` that reads it and asks the right question: HTTP for web, a
self-addressed celery ping for a worker lane, both-for-every-lane for `all`,
and nothing for shell/alembic, which are one-shot and have no liveness to
probe. `docker-compose.single.yml` and the consolidated stack declare none.
A service that wants something else can still declare its own; docker prefers
it, so the escape hatch is the default docker behaviour rather than a flag.
Two details that are load-bearing:
* The role is written ONCE, by the outermost invocation. `all` starts the
other roles through this same script under supervisord, and a child
overwriting the container's role would turn the composite check into a
web-only one — silently, and only on the consolidated path. FC_ROLE is
exported so a child sees it set and skips.
* The celery ping is addressed to THIS node, not a bare ping. A bare one is
answered by any worker on the broker, so in a stack with replicas a dead
container would report healthy for as long as a sibling lived — the check
would be measuring the cluster rather than the container it is inside.
`healthcheck_all.py` is deleted; its two probes moved into the dispatcher
rather than being a second copy beside it.
An unrecorded role PASSES. The entrypoint always writes the file, so the only
way to miss it is bypassing the entrypoint — a debugging shape, where a check
that cannot tell what it is looking at must not assert the thing is broken
(snippet #3969). Said on stdout rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
111 lines
4.7 KiB
YAML
111 lines
4.7 KiB
YAML
# FabledCurator in three containers — the install path.
|
|
#
|
|
# docker compose -f docker-compose.single.yml up -d
|
|
#
|
|
# Milestone 422 step 5. FabledCurator runs web and every worker lane inside
|
|
# ONE container, with Postgres and Redis beside it. How much work each lane
|
|
# does is then a dial in the web UI (Settings -> Activity -> Worker lanes),
|
|
# live, with no compose edit and no restart.
|
|
#
|
|
# THE MULTI-SERVICE STACK IS NOT REPLACED. `docker-compose.yml` still runs the
|
|
# five app services separately and is the right shape for a Swarm deployment
|
|
# spread across hosts, where per-service rolling rollback and placement
|
|
# constraints matter. This file is the adopter path: one box, one command.
|
|
#
|
|
# What consolidating costs, stated here rather than discovered later:
|
|
# - Everything shares one host, so there is no spreading work across nodes.
|
|
# - Rollback is all-or-nothing; there is no rolling back `web` alone.
|
|
# - One stop timeout for the whole container, sized to the slowest lane.
|
|
#
|
|
# NOT a cost, recorded so it is not rediscovered and raised again: the
|
|
# multi-service stack mounts /images:ro on ml-worker and one container cannot
|
|
# mount one path two ways. Operator ruled that a non-issue (2026-09-22) — it
|
|
# is the same codebase either way.
|
|
#
|
|
# FabledCurator has no authentication. Whatever can reach ${PORT} is an
|
|
# administrator, including over the stored platform session cookies. Do not
|
|
# publish this port beyond a network you trust — see "Before you expose it"
|
|
# in README.md.
|
|
|
|
services:
|
|
redis:
|
|
image: redis:7-alpine
|
|
volumes:
|
|
- redis_data:/data
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
restart: unless-stopped
|
|
|
|
postgres:
|
|
image: pgvector/pgvector:pg16
|
|
environment:
|
|
POSTGRES_USER: ${DB_USER:-curator}
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
POSTGRES_DB: ${DB_NAME:-curator}
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
# pgvector index builds and the gallery's TABLESAMPLE reads both want more
|
|
# shared memory than docker's 64MB default.
|
|
shm_size: 512m
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-curator} -d ${DB_NAME:-curator}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
restart: unless-stopped
|
|
|
|
fabledcurator:
|
|
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
|
# 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
|
|
# them. Without this they accumulate as zombies for the life of the
|
|
# container.
|
|
init: true
|
|
# Sized to the SLOWEST lane, not the average. maintenance_long runs DB
|
|
# backups, library audits and translation backfill, and gets 180s to
|
|
# finish a chunk; the lanes stop in parallel, so this covers the max
|
|
# rather than their sum. Below this, a routine restart becomes a SIGKILL
|
|
# mid-backup — which is recoverable (the work is chunked and idempotent)
|
|
# but wastes however long it had run.
|
|
stop_grace_period: 200s
|
|
# No healthcheck here either. The image declares one that reads the role
|
|
# it is running, and for this one that means BOTH halves: hypercorn
|
|
# answers AND every lane is answering the broker. A web-only check would
|
|
# report a healthy container while every lane inside it had crashed —
|
|
# the failure mode consolidation creates, since docker can no longer see
|
|
# the lanes as separate services.
|
|
environment:
|
|
DB_USER: ${DB_USER:-curator}
|
|
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
DB_HOST: postgres
|
|
DB_PORT: "5432"
|
|
DB_NAME: ${DB_NAME:-curator}
|
|
CELERY_BROKER_URL: redis://redis:6379/0
|
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
|
SECRET_KEY: ${SECRET_KEY:-change-me-before-you-expose-this}
|
|
EXTENSION_API_KEY: ${EXTENSION_API_KEY:-}
|
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
ports:
|
|
- "${PORT:-8080}:8080"
|
|
volumes:
|
|
- ${IMAGES_DIR:-./images}:/images
|
|
# Read-only. The filesystem scan copies out of here and never writes to
|
|
# it, so a mistake cannot reach the source library.
|
|
- ${IMPORT_DIR:-./import}:/import:ro
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
redis_data:
|
|
postgres_data:
|