CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
Build images / smoke-web (push) Skipped
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 1m46s
The install path milestone 328 wrote produces a web container that exits on boot. entrypoint.sh runs alembic, then app construction raises: MissingCredentialKey: Fernet key file not found at /images/secrets/credential_key.b64. For first-time setup, set CURATOR_BOOTSTRAP_NEW_KEY=1. That variable appeared in no README, no .env.example and no compose file — only in backend/. So a stranger following the documented steps got an app that does not start and an error with no context. Found by the milestone-362 smoke gate on its first real run (#3422). The product behaviour stays exactly as it is. credential_crypto refuses to mint a key because the 2026-06-02 audit found a partial restore — database back, ./images/secrets lost — silently generating a fresh one and producing a healthy-looking instance where every authenticated download failed AUTH_ERROR. Failing fast is right; not saying so is the bug. So: .env.example carries the variable in its own FIRST BOOT ONLY section with the reasoning and an instruction to delete the line afterwards, and README's First run leads with it, because "the app will not start" belongs before "the ML worker downloads weights". Both say to back up ./images/secrets/ alongside the database, which is the part that costs real data if it is learned late. **compose had to change too, and this is the part that would have shipped a second broken instruction.** A variable in `.env` is only used for ${...} interpolation — it does not reach the container unless the service names it. Telling people to set it in .env, without that, would have documented a step that does nothing. Added to the shared app_env anchor, defaulted to empty so the refusal still stands for everyone who has not opted in. Not taken: auto-bootstrapping when the credential table is empty, which would remove the manual step entirely and keep the audit's protection for restores. That is the better product and it is a code change with a predicate that has to be exactly right; this is the smallest correct fix, and #3422 stays open for the other one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
254 lines
11 KiB
YAML
254 lines
11 KiB
YAML
# Base compose stack, and the install path. Uses ${VAR:-default} throughout so
|
|
# the stack boots with zero config — but those defaults are DEV defaults, and
|
|
# two of them (DB_PASSWORD, SECRET_KEY) are published in this file. Copy
|
|
# .env.example to .env and set them before running this anywhere real.
|
|
#
|
|
# To run FabledCurator:
|
|
#
|
|
# docker compose -f docker-compose.yml up -d
|
|
#
|
|
# The -f is load-bearing. Without it Compose auto-merges
|
|
# docker-compose.override.yml, which replaces every image: with a local
|
|
# build: and turns on DEBUG logging — the contributor path. Naming this file
|
|
# explicitly skips the override and pulls the published :latest images.
|
|
#
|
|
# FabledCurator has no authentication. Whatever can reach ${PORT} is an
|
|
# administrator, including over the stored Patreon/SubscribeStar/Pixiv session
|
|
# cookies. Do not publish this port beyond a network you trust — see
|
|
# "Before you expose it" in README.md.
|
|
|
|
# Rolling-deploy safety (Swarm / `docker stack deploy`): update one task at a
|
|
# time, START the new task before stopping the old (zero-downtime via the ingress
|
|
# mesh), and if the new task doesn't reach a healthy state within `monitor`, roll
|
|
# back to the previous image automatically. `monitor` is sized above web's
|
|
# healthcheck start_period so a broken image that never goes healthy is caught.
|
|
# Plain `docker compose up` ignores `deploy:` (it warns + skips), so the dev
|
|
# override is unaffected. Referenced by each long-lived service below.
|
|
x-deploy-policy: &deploy_policy
|
|
update_config:
|
|
order: start-first
|
|
failure_action: rollback
|
|
monitor: 90s
|
|
rollback_config:
|
|
order: start-first
|
|
restart_policy:
|
|
condition: any
|
|
delay: 10s
|
|
|
|
# Worker liveness: ping THIS container's celery node over the broker. Lenient
|
|
# (60s interval, 3 retries, 60s start_period) so a transient broker blip never
|
|
# false-flags a worker into a rollback. `$$HOSTNAME` → `$HOSTNAME` for the shell;
|
|
# celery's default node name is celery@<hostname> (the container id).
|
|
x-celery-healthcheck: &celery_healthcheck
|
|
test: ["CMD-SHELL", "celery -A backend.app.celery_app:celery inspect ping -d celery@$$HOSTNAME --timeout 10 >/dev/null 2>&1"]
|
|
interval: 60s
|
|
timeout: 15s
|
|
retries: 3
|
|
start_period: 60s
|
|
|
|
services:
|
|
redis:
|
|
image: redis:7-alpine
|
|
volumes:
|
|
- redis_data:/data
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
postgres:
|
|
image: pgvector/pgvector:pg16
|
|
environment:
|
|
POSTGRES_USER: ${DB_USER:-fabledcurator}
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-fabledcurator_dev}
|
|
POSTGRES_DB: ${DB_NAME:-fabledcurator}
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
# Docker's default /dev/shm is 64MB; VACUUM (ANALYZE) + parallel queries
|
|
# allocate a POSIX dynamic-shared-memory segment in /dev/shm and fail with
|
|
# "could not resize shared memory segment ... No space left on device"
|
|
# (operator-flagged 2026-06-07, vacuum_analyze on import_task needed 67MB).
|
|
# NOTE: a `shm_size:` key is SILENTLY IGNORED under Docker Swarm
|
|
# (`docker stack deploy` — the prod deployment here), so size /dev/shm via
|
|
# a tmpfs mount instead — honored by both Swarm and plain Compose.
|
|
- type: tmpfs
|
|
target: /dev/shm
|
|
tmpfs:
|
|
size: 536870912 # 512 MB
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fabledcurator}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
web:
|
|
# :latest, NOT :dev — this file IS the install path.
|
|
#
|
|
# `docker compose up -d` merges docker-compose.override.yml, which sets
|
|
# build: for all five app services, and a build: wins over image:. So a
|
|
# contributor never pulls this tag and is unaffected by what it says.
|
|
#
|
|
# The tag is consulted only on `docker compose -f docker-compose.yml up -d`
|
|
# — the documented production path, which skips the override. That is a
|
|
# stranger installing the product, and they must land on the stable channel.
|
|
#
|
|
# :latest is main, which IS production (rule 147). :dev is the rolling
|
|
# bleeding-edge channel we work out of, republished several times a day with
|
|
# no stability promise. This file pinned :dev on all five services until
|
|
# 2026-08-31 (#3270), so the documented install shipped development builds.
|
|
# It went unnoticed because nobody who works on the project takes this path:
|
|
# the operator deploys from a swarm stack file, contributors get the
|
|
# override. Do not "fix" this back to :dev while debugging — use the
|
|
# override, or -f with an explicit tag on the command line.
|
|
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
|
command: ["web"]
|
|
# Graceful shutdown: give the container time to drain in-flight work on a
|
|
# deploy (docker SIGTERMs, then SIGKILLs after this window — default is only
|
|
# 10s, far too short for real jobs). Hypercorn/Celery both warm-shut-down on
|
|
# SIGTERM; per-lane values sized to typical task length. Anything that still
|
|
# outruns the window is re-queued (task_reject_on_worker_lost) and re-driven
|
|
# by the 5-min recovery sweeps, so a kill never corrupts. web = short HTTP
|
|
# requests + the occasional file download.
|
|
stop_grace_period: 30s
|
|
# Liveness for rolling deploys: /api/health is a no-DB 200 (just proves the
|
|
# app booted + serves HTTP after `alembic upgrade head`). start_period covers
|
|
# the migration + boot so a slow start isn't mis-flagged.
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/api/health', timeout=5).status==200 else 1)\""]
|
|
interval: 15s
|
|
timeout: 6s
|
|
retries: 3
|
|
start_period: 40s
|
|
deploy: *deploy_policy
|
|
ports:
|
|
- "${PORT:-8080}:8080"
|
|
environment: &app_env
|
|
DB_USER: ${DB_USER:-fabledcurator}
|
|
DB_PASSWORD: ${DB_PASSWORD:-fabledcurator_dev}
|
|
DB_HOST: postgres
|
|
DB_PORT: "5432"
|
|
DB_NAME: ${DB_NAME:-fabledcurator}
|
|
CELERY_BROKER_URL: redis://redis:6379/0
|
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
|
SECRET_KEY: ${SECRET_KEY:-dev_secret_key_not_for_production_change_me}
|
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
# First boot only. FabledCurator refuses to start until the credential
|
|
# encryption key at /images/secrets/credential_key.b64 exists, and
|
|
# refuses to create one unless told to — auto-creating is
|
|
# indistinguishable from a restore that lost ./images/secrets, where it
|
|
# would mint a key that decrypts nothing and leave an instance that looks
|
|
# healthy while every paywalled download fails.
|
|
#
|
|
# Passed through EXPLICITLY because a variable in `.env` is only used for
|
|
# ${...} interpolation; it does not reach the container unless it is
|
|
# named here. Defaulted to empty so the refusal stands for everyone who
|
|
# has not opted in — the app tests for exactly "1".
|
|
#
|
|
# Set it in .env for one `up`, then remove it. See .env.example.
|
|
CURATOR_BOOTSTRAP_NEW_KEY: ${CURATOR_BOOTSTRAP_NEW_KEY:-}
|
|
volumes:
|
|
- ./images:/images
|
|
- ./import:/import
|
|
# /import is a staging area for scripting a one-off ingest of a library
|
|
# you already have on disk. Drop files in ./import, or bind-mount an
|
|
# existing directory under it as below, then trigger the scan:
|
|
#
|
|
# curl -X POST http://localhost:8080/api/import/trigger
|
|
#
|
|
# Read-only is sufficient — FC copies into /images during the scan. The
|
|
# worker + scheduler services mount the same /import so the scan can run
|
|
# on whichever lane picks it up.
|
|
#
|
|
# Deliberately has no UI. The manual-scan surface was retired 2026-07-02
|
|
# once imports arrived via subscriptions + the extension, and the call
|
|
# not to restore it stands (operator, 2026-09-02): folder ingestion
|
|
# brings complexity the product does not need. The endpoint stays as an
|
|
# unsupported escape hatch; the supported way in is Subscriptions.
|
|
# - /srv/media/my-library:/import/my-library:ro
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
|
|
worker:
|
|
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
|
command: ["worker"]
|
|
# Drain in-flight import/thumbnail/download tasks before SIGKILL on deploy.
|
|
stop_grace_period: 90s
|
|
healthcheck: *celery_healthcheck
|
|
deploy: *deploy_policy
|
|
environment:
|
|
<<: *app_env
|
|
CELERY_QUEUES: default,import,thumbnail,download
|
|
CELERY_CONCURRENCY: "2"
|
|
# /downloads dropped — nothing in the app references it (operator-flagged
|
|
# 2026-06-07: it wasn't mapped in prod and everything worked).
|
|
volumes:
|
|
- ./images:/images
|
|
- ./import:/import
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
|
|
scheduler:
|
|
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
|
command: ["scheduler"]
|
|
# Quick maintenance/scan lane + beat — short tasks, modest drain window.
|
|
stop_grace_period: 60s
|
|
healthcheck: *celery_healthcheck
|
|
deploy: *deploy_policy
|
|
environment:
|
|
<<: *app_env
|
|
CELERY_QUEUES: maintenance,scan
|
|
volumes:
|
|
- ./images:/images
|
|
- ./import:/import
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
|
|
# Dedicated lane for long one-shot maintenance (DB backups, library audits,
|
|
# admin maintenance). Kept off the scheduler's quick `maintenance` lane so a
|
|
# 30-min backup or a multi-chunk audit can never starve the 5-min recovery
|
|
# sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy.
|
|
maintenance-long:
|
|
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
|
command: ["worker"]
|
|
# Longest lane (DB backups, library audits, translation backfill) — give it
|
|
# the most room to finish a chunk gracefully. Chunked + idempotent, so a job
|
|
# that still outruns this resumes cleanly next run rather than corrupting.
|
|
stop_grace_period: 180s
|
|
healthcheck: *celery_healthcheck
|
|
deploy: *deploy_policy
|
|
environment:
|
|
<<: *app_env
|
|
CELERY_QUEUES: maintenance_long
|
|
CELERY_CONCURRENCY: "1"
|
|
# Only /images: backups write to /images/_backups, audits read /images, and
|
|
# the admin tasks (re-extract/cascade-delete/normalize) operate on /images.
|
|
volumes:
|
|
- ./images:/images
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
|
|
ml-worker:
|
|
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:latest
|
|
command: ["ml-worker"]
|
|
# A single GPU inference pass can run tens of seconds — let it finish.
|
|
stop_grace_period: 120s
|
|
healthcheck: *celery_healthcheck
|
|
deploy: *deploy_policy
|
|
environment:
|
|
<<: *app_env
|
|
volumes:
|
|
- ./images:/images:ro
|
|
- ./models:/models
|
|
depends_on:
|
|
postgres: { condition: service_healthy }
|
|
redis: { condition: service_healthy }
|
|
|
|
volumes:
|
|
redis_data:
|
|
postgres_data:
|