deer-flow/docker/dev-entrypoint.sh
Nan Gao 063383d995
fix(deploy): wire the multi-replica prerequisites into the Helm chart and compose (#6347)
* fix(deploy): wire the multi-replica prerequisites into the Helm chart and compose

The chart shipped gateway.replicas: 1 with a README citing the long-closed
issue #3948, while its rendered config lacked run_ownership heartbeats and
db run events: raising replicas made every starting Pod write its peers'
lease-less runs off as orphans, and the Gateway's startup gate could not
notice because its worker count only sees one Pod.

Enable heartbeats and db run events in the default config, export
DEER_FLOW_MULTI_INSTANCE from the replica count for the startup gate,
generate and inject a shared AUTH_JWT_SECRET (per-Pod .jwt_secret files
logged users out across replicas), declare a surge-then-drain rollout
strategy, render a PodDisruptionBudget when replicas > 1, and bound
uvicorn's graceful shutdown inside a 90s termination grace period.
docker-compose gets the same uvicorn bound and a 60s stop_grace_period so
the 30s memory flush is no longer SIGKILLed. Docs and comments now list
what actually remains single-instance: IM channels, WeChat QR login and
browser tools.

* fix(deploy): honor existingAppSecret, declare multi-instance explicitly, cover the dev stack's shutdown

Follow-up to the multi-replica prerequisites wiring, addressing review:

- `deer-flow.appSecret` returns `existingAppSecret` when it is set. The
  gateway and frontend kept referencing the generated `<release>-app`
  Secret, which the chart stopped rendering, so every Pod sat in
  CreateContainerConfigError. The value is now declared in values.yaml.
- `gateway.multiInstance` (boolean, default null) overrides the
  replica-derived DEER_FLOW_MULTI_INSTANCE, PodDisruptionBudget and
  AUTH_JWT_SECRET requirement for deployments scaled with kubectl or an
  HPA, which change the replica count without a Helm upgrade.
- AUTH_JWT_SECRET is required, not optional, whenever the gateway is
  multi-instance: concurrently booting Pods otherwise race to write
  different `.jwt_secret` files on the home volume and sign sessions with
  different keys.
- `gateway.uvicornGracefulShutdownSeconds: 0` is honored instead of being
  swallowed by `default 10`.
- docker-compose-dev.yaml and dev-entrypoint.sh get the same uvicorn
  graceful-shutdown bound and `stop_grace_period: 60s`.
- The chart README documents the one-time sign-out when upgrading a
  release that predates AUTH_JWT_SECRET and how to seed the previous key
  into the Secret first.
- Tests read the shutdown budget from the Gateway through a shared helper
  instead of hard-coding 5/5/30, the raw-template string matches give way
  to `helm template` renders, and new renders cover multiInstance,
  existingAppSecret, a disabled PDB and a zero graceful-shutdown bound.

* docs(changelog): link the multi-replica deploy entry to #6347

* fix(deploy): budget every bounded teardown hook and flag the multiInstance PDB window

Review follow-ups on #6347:

- The shutdown budget modeled only channel stop + run drain + memory flush
  (40s). The lifespan also runs the startup trash sweep, the notification
  delivery worker stop, browser-session close and the MCP session-pool
  close, each bounded at _SHUTDOWN_HOOK_TIMEOUT_SECONDS, plus a 1s
  retrieval-warm wait: about 61s worst case. _gateway_shutdown_budget.py
  now reads all of them from the Gateway, a drift guard pins the hook
  count against app.py, and both compose files move stop_grace_period
  from 60s to 90s (Helm's 90s already had room: 5 + 10 + 61). Comments
  and the chart README cite the full arithmetic.
- gateway.multiInstance: true renders the PodDisruptionBudget immediately,
  which blocks drains of the single Pod until the scale-up happens; the
  README, values comment and both deployment guides say so and name
  podDisruptionBudget.enabled: false as the interim escape hatch.
2026-10-06 11:21:51 +08:00

178 lines
7.4 KiB
Bash
Executable File

#!/usr/bin/env sh
#
# DeerFlow gateway dev entrypoint — runs inside the docker-compose-dev gateway
# container. Extracted from docker/docker-compose-dev.yaml's inline `command:`
# (PR #2767, addressing review on Issue #2754).
#
# Responsibilities:
# 1. Resolve `--extra X` flags through scripts/detect_uv_extras.py, using the
# selected config plus explicit UV_EXTRAS and runtime-required backends.
# 2. Validate each extra against [A-Za-z][A-Za-z0-9_-]* so a stray shell
# metacharacter in `.env` cannot reach `uv sync`.
# 3. `uv sync --locked --all-packages` so the declared extension group and
# workspace member extras (deerflow-harness's
# postgres extra in particular) are installed — see PR #2584.
# 4. Self-heal: if the first sync fails, recreate .venv and retry once. The
# retry stays `--locked`, so it repairs a broken .venv but not a stale
# lock; a second failure aborts with recovery instructions rather than
# starting uvicorn against an environment that does not match the lock.
# 5. Hand off to uvicorn with reload, replacing this shell so uvicorn becomes
# PID 1 inside the container.
#
# Anchored at /bin/sh (not bash) since alpine-based base images may not ship
# bash. Uses POSIX-only constructs throughout.
set -e
# `--print-extras` is a dry-run hook: parse + validate UV_EXTRAS, print the
# resulting `--extra X` flags to stdout, and exit. Used by the unit test in
# backend/tests/test_dev_entrypoint.py and useful for ad-hoc debugging.
PRINT_EXTRAS_ONLY=0
if [ "${1:-}" = "--print-extras" ]; then
PRINT_EXTRAS_ONLY=1
fi
# Mirror the legacy command's behavior: redirect both stdout and stderr to the
# host-mounted log file (../logs/gateway.log → /app/logs/gateway.log). Skip
# the redirect under --print-extras so the test runner can capture stdout.
if [ "$PRINT_EXTRAS_ONLY" = "0" ]; then
exec >/app/logs/gateway.log 2>&1
fi
# ── Resolve extras ──────────────────────────────────────────────────────────
EXTRAS_FLAGS=""
EXTRA_NAMES=""
set -f
append_extra() {
extra_name="$1"
case "$extra_name" in
[!A-Za-z]* | *[!A-Za-z0-9_-]*)
echo "[startup] UV_EXTRAS entry '$extra_name' is invalid (must match [A-Za-z][A-Za-z0-9_-]*) — aborting" >&2
exit 1
;;
esac
case " $EXTRA_NAMES " in
*" $extra_name "*) return ;;
esac
EXTRA_NAMES="$EXTRA_NAMES $extra_name"
EXTRAS_FLAGS="$EXTRAS_FLAGS --extra $extra_name"
}
# Validate explicit input before the detector normalizes it. The shared
# detector deliberately drops invalid names with a warning, while container
# startup fails closed so malformed .env input cannot be silently ignored.
if [ -n "${UV_EXTRAS:-}" ]; then
for raw in $(printf '%s' "$UV_EXTRAS" | tr ',' ' '); do
[ -z "$raw" ] || append_extra "$raw"
done
fi
# Docker dev mounts the host checkout at /app/project while
# DEER_FLOW_PROJECT_ROOT points at /app for runtime path translation. Prefer
# both locations, then the checkout-relative path used by direct invocations.
ENTRYPOINT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DETECTOR_PATH=""
for candidate in \
"${DEER_FLOW_PROJECT_ROOT:+$DEER_FLOW_PROJECT_ROOT/scripts/detect_uv_extras.py}" \
/app/project/scripts/detect_uv_extras.py \
"$ENTRYPOINT_DIR/../scripts/detect_uv_extras.py"
do
if [ -n "$candidate" ] && [ -f "$candidate" ]; then
DETECTOR_PATH="$candidate"
break
fi
done
if [ -z "$DETECTOR_PATH" ]; then
echo "[startup] scripts/detect_uv_extras.py is unavailable" >&2
exit 1
fi
if command -v python3 >/dev/null 2>&1; then
DETECTOR_PYTHON=python3
elif command -v python >/dev/null 2>&1; then
DETECTOR_PYTHON=python
else
echo "[startup] Python is required to resolve optional dependencies" >&2
exit 1
fi
if ! DETECTED_FLAGS=$("$DETECTOR_PYTHON" "$DETECTOR_PATH"); then
echo "[startup] detect_uv_extras.py failed" >&2
exit 1
fi
# The detector emits only validated `--extra NAME` pairs. Parse that small
# interface instead of evaluating shell text, and validate again at the final
# shell boundary before any value can reach uv.
set -- $DETECTED_FLAGS
while [ "$#" -gt 0 ]; do
if [ "$1" != "--extra" ] || [ "$#" -lt 2 ]; then
echo "[startup] detect_uv_extras.py returned invalid output" >&2
exit 1
fi
append_extra "$2"
shift 2
done
if [ "$PRINT_EXTRAS_ONLY" = "1" ]; then
# Trim leading space for tidier output, then exit.
printf '%s\n' "${EXTRAS_FLAGS# }"
exit 0
fi
if [ -n "$EXTRAS_FLAGS" ]; then
echo "[startup] uv extras:$EXTRAS_FLAGS"
fi
# Keep runtime-owned files out of uvicorn's reload watcher. Each excluded path
# must exist before uvicorn starts so watchfiles treats it as an excluded
# directory, not as a plain glob pattern — on Python 3.12, globbing an absolute
# pattern raises NotImplementedError and crashes startup (#3459 / #3454). That
# means `sandbox` must be created here too, not just `.deer-flow`.
: "${DEER_FLOW_HOME:=/app/backend/.deer-flow}"
export DEER_FLOW_HOME
mkdir -p "$DEER_FLOW_HOME" /app/backend/.deer-flow /app/backend/sandbox
# ── Sync dependencies (with self-heal) ──────────────────────────────────────
cd /app/backend
# `--all-packages` propagates extras into workspace members (PR #2584).
# docker-compose-dev's default DEER_FLOW_STREAM_BRIDGE_REDIS_URL is translated
# to `--extra redis` by the shared detector, alongside config and UV_EXTRAS.
# `$EXTRAS_FLAGS` intentionally unquoted so each `--extra X` becomes its own arg.
# shellcheck disable=SC2086 # word-splitting is intentional here
if ! uv sync --locked --all-packages $EXTRAS_FLAGS; then
echo "[startup] uv sync failed; recreating .venv and retrying once"
uv venv --clear .venv
# The retry keeps `--locked` on purpose: it repairs a corrupt or partial
# .venv, not a lock that disagrees with pyproject.toml. Startup must never
# silently resolve dependencies, so a second failure is fatal rather than
# something uvicorn limps past and reports later as an import error.
# `set -e` would already stop here; abort explicitly so the operator gets
# the fix instead of a bare uv exit code.
# shellcheck disable=SC2086
if ! uv sync --locked --all-packages $EXTRAS_FLAGS; then
echo "[startup] uv sync --locked failed again after recreating .venv." >&2
echo "[startup] backend/uv.lock does not match backend/pyproject.toml, or a locked artifact is unreachable." >&2
echo "[startup] Run 'make install' on the host to refresh the lock, then restart this container." >&2
exit 1
fi
fi
# ── Hand off to uvicorn ─────────────────────────────────────────────────────
# --timeout-graceful-shutdown bounds how long open SSE connections may delay
# lifespan shutdown (memory flush, run drain) after SIGTERM; the compose
# service's stop_grace_period (90s) covers this bound plus the lifespan's
# worst-case drain work (~61s).
PYTHONPATH=. exec uv run --no-sync uvicorn app.gateway.app:app \
--host 0.0.0.0 --port 8001 \
--timeout-graceful-shutdown 10 \
--reload \
--reload-include='*.yaml' \
--reload-include='.env' \
--reload-exclude=/app/backend/sandbox \
--reload-exclude="$DEER_FLOW_HOME" \
--reload-exclude=/app/backend/.deer-flow