On a Synology first run, migrate exited with "postgres:5432 never accepted a connection" after the full 180s — and that was the entire diagnostic output, despite the network being demonstrably fine afterwards (postgres resolved and port 5432 was open from a throwaway container on the same network). The retry loop caught OSError and discarded it. It now prints the first failure immediately and repeats every 30s, with the hostname's resolved address alongside, and includes the last error in the final message. A DNS failure, a refused connection and a silently dropped packet each produce a different line instead of the same useless one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
113 lines
4.1 KiB
Bash
Executable File
113 lines
4.1 KiB
Bash
Executable File
#!/bin/sh
|
|
# One image, several roles. Called as: entrypoint <command> [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
|