#!/bin/sh # One image, several roles. Called as: entrypoint [args...] # # api uvicorn, behind the `web` nginx container # worker-sync lease-owned account sync supervisor (SPEC §6.3) # worker-jobs arq consumer (interactive + bulk queues) # worker-all both in one process (small installs; halves RAM use) # migrate core Alembic upgrade + per-plugin install; exits 0 when done # reindex rebuild the search index from Postgres # plugins `python -m app.plugins.cli ...` passthrough # shell /bin/sh (debugging) # # Any other argument is exec'd verbatim, so `docker compose run api python -c ...` # still works. set -eu : "${API_HOST:=0.0.0.0}" : "${API_PORT:=8000}" : "${API_WORKERS:=1}" # Plugins whose migrations are applied on every `migrate` run. Space-separated so # an operator can add a third-party plugin without editing the image. : "${ENABLED_PLUGINS:=calendar contacts labels filters}" cmd="${1:-api}" [ "$#" -gt 0 ] && shift || true # Belt and braces on top of the compose healthcheck: a NAS with slow disks can # take a while over initdb, and `migrate` is a one-shot with no restart policy — # if it starts a moment early it fails the whole bring-up. wait_for_db() { python - <<'PYEOF' import os, socket, sys, time from urllib.parse import urlparse url = os.environ.get("DATABASE_URL", "") parsed = urlparse(url.replace("+asyncpg", "")) host = parsed.hostname or "postgres" port = parsed.port or 5432 deadline = time.time() + float(os.environ.get("DB_WAIT_SECONDS", "180")) # Report WHY it is not connecting, not just that it isn't: the first failure # immediately, then every 30s. A silent retry loop that ends in "never accepted" # tells whoever is installing this precisely nothing. last_error = None next_report = 0.0 while time.time() < deadline: try: with socket.create_connection((host, port), timeout=3): print(f"[entrypoint] database accepting connections at {host}:{port}") sys.exit(0) except OSError as exc: last_error = exc if time.time() >= next_report: try: resolved = socket.gethostbyname(host) except OSError as dns_exc: resolved = f"UNRESOLVED ({dns_exc})" print( f"[entrypoint] waiting for {host}:{port} " f"(resolves to {resolved}): {type(exc).__name__}: {exc}", flush=True, ) next_report = time.time() + 30 time.sleep(2) print( f"[entrypoint] ERROR: {host}:{port} never accepted a connection. " f"Last error: {type(last_error).__name__}: {last_error}", file=sys.stderr, ) sys.exit(1) PYEOF } run_migrations() { wait_for_db echo "[entrypoint] core schema: alembic upgrade head" alembic upgrade head for plugin in $ENABLED_PLUGINS; do echo "[entrypoint] plugin schema: $plugin" python -m app.plugins.cli install "$plugin" done echo "[entrypoint] migrations complete" } case "$cmd" in api) # --proxy-headers + --forwarded-allow-ips are what let the app see the # real scheme through nginx/DSM. Without them every request looks like # plain http and the session cookie loses its __Host- prefix and Secure # flag (app/identity/cookies.py:22). The allow-list is the internal # docker network only; this port is never published directly. exec uvicorn app.main:create_app \ --factory \ --host "$API_HOST" \ --port "$API_PORT" \ --workers "$API_WORKERS" \ --proxy-headers \ --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \ --no-server-header \ "$@" ;; worker-sync) WORKER_ROLE=sync exec python -m workers.run "$@" ;; worker-jobs) WORKER_ROLE=jobs exec python -m workers.run "$@" ;; worker-all) WORKER_ROLE=all exec python -m workers.run "$@" ;; migrate) run_migrations ;; reindex) exec python scripts/reindex_search.py "$@" ;; plugins) exec python -m app.plugins.cli "$@" ;; shell) exec /bin/sh "$@" ;; *) exec "$cmd" "$@" ;; esac