mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(dev): add a two-Gateway multi-instance harness script There was no way to run the deployment.multi_instance topology locally: scripts/serve.sh pins one Gateway to port 8001, the local nginx config has a single upstream, and getting two processes past the multi-instance startup gate needs Postgres, Redis, a shared DEER_FLOW_HOME, shared auth secrets and a per-process DeerMem retrieval index by hand. scripts/dev_multi_instance.sh (make dev-multi / dev-multi-check / dev-multi-down) starts throwaway Postgres and Redis containers on loopback, renders a config from the developer's config.yaml (or config.example.yaml), and runs Gateway A on 8001 and Gateway B on 8011 with one shared home, one copy of extensions_config.json and generated AUTH_JWT_SECRET, DEER_FLOW_INTERNAL_AUTH_TOKEN and DEER_FLOW_CREDENTIALS_KEY values, plus an optional round-robin nginx on 2027. B starts after A answers /health: Alembic migrations are advisory-locked, but the LangGraph checkpointer/store setup() that follows is not. scripts/dev_multi_instance.py holds the testable parts: the config overlay (neutralizing and reporting settings the gate refuses), the secrets file, the nginx config, and `check`, which automates the scriptable cross-instance acceptance checks: readiness with stream_bridge ok, A-issued sessions and the internal token accepted by B, thread/upload/artifact visibility, a skill toggle seen by B, nginx balancing, and SSE join on B with Last-Event-ID resume on A. Tests pin that the rendered config passes the real _enforce_postgres_for_multi_worker gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): link the two-Gateway harness entries to #6613 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dev): keep the multi-instance harness inside its own state Address review findings on the two-Gateway harness (#6613): - Containers carry a deerflow.harness.state-dir label; up/down remove only containers of their own state dir and refuse those of another checkout or DEERFLOW_MI_STATE_DIR instead of force-removing them. - Containers are removed with `docker rm -f -v`, so the anonymous volumes the postgres and redis images declare no longer leak on every up/down. - `up` installs an EXIT trap once it starts containers, so any later failure (including B's port being taken after preflight) tears down A and both containers; start_gateway returns failures instead of exiting the script, and the Gateway is disowned so stopping it prints no job notice. - The overlay removes any checkpointer section (a postgres one would write checkpoints and the Store into the developer's database), points an explicit sandbox.ownership and a Redis checkpoint_cache at the harness Redis (keeping ownership timing), drops DeerMem storage_path (both spellings) and the blob_storage root so data lands in the harness home, gives a local AIO sandbox a state-dir-specific container_prefix, and reports a non-DeerMem memory backend it keeps sharing. - nginx paths are rendered as quoted string literals ('$' and control characters are refused), so a state dir with spaces works. - `check` skips the run-based SSE check when model discovery did not succeed, unless --with-llm, and enforces the SSE time budget per raw line so heartbeat-only traffic cannot hold it open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(scripts): keep the harness guidance under the AGENTS.md soft limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
676 lines
26 KiB
Bash
Executable File
676 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# dev_multi_instance.sh — run two Gateways as one local multi-instance cluster.
|
|
#
|
|
# Starts throwaway Postgres and Redis containers, renders a multi-instance
|
|
# config.yaml from your own config (or config.example.yaml), and runs Gateway A
|
|
# and Gateway B on one shared DEER_FLOW_HOME with shared auth secrets, plus an
|
|
# optional round-robin nginx in front of both. Run with --help for commands.
|
|
#
|
|
# Config rendering, secrets, the nginx config and the checks live in
|
|
# dev_multi_instance.py (unit-tested); this file owns containers and processes.
|
|
# Everything lives under DEERFLOW_MI_STATE_DIR so it never collides with
|
|
# `make dev` state in backend/.deer-flow.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd -P)"
|
|
BACKEND_DIR="$REPO_ROOT/backend"
|
|
HELPER="$REPO_ROOT/scripts/dev_multi_instance.py"
|
|
|
|
STATE_DIR="${DEERFLOW_MI_STATE_DIR:-$REPO_ROOT/.deer-flow/multi-instance}"
|
|
# Gateways run from backend/, so every path handed to them must be absolute.
|
|
case "$STATE_DIR" in
|
|
/*) ;;
|
|
*) STATE_DIR="$PWD/$STATE_DIR" ;;
|
|
esac
|
|
GATEWAY_A_PORT="${DEERFLOW_MI_GATEWAY_A_PORT:-8001}"
|
|
GATEWAY_B_PORT="${DEERFLOW_MI_GATEWAY_B_PORT:-8011}"
|
|
NGINX_PORT="${DEERFLOW_MI_NGINX_PORT:-2027}"
|
|
FRONTEND_PORT="${DEERFLOW_MI_FRONTEND_PORT:-3000}"
|
|
PG_PORT="${DEERFLOW_MI_POSTGRES_PORT:-55432}"
|
|
REDIS_PORT="${DEERFLOW_MI_REDIS_PORT:-56379}"
|
|
PG_CONTAINER="${DEERFLOW_MI_POSTGRES_CONTAINER:-deerflow-mi-postgres}"
|
|
REDIS_CONTAINER="${DEERFLOW_MI_REDIS_CONTAINER:-deerflow-mi-redis}"
|
|
PG_IMAGE="${DEERFLOW_MI_POSTGRES_IMAGE:-postgres:17-alpine}"
|
|
REDIS_IMAGE="${DEERFLOW_MI_REDIS_IMAGE:-redis:7-alpine}"
|
|
HEALTH_TIMEOUT="${DEERFLOW_MI_HEALTH_TIMEOUT:-180}"
|
|
|
|
LABEL_KEY="deerflow.harness"
|
|
LABEL_VALUE="dev-multi-instance"
|
|
# Container names are global, so each container also records the state dir
|
|
# that created it; only that state dir may remove it.
|
|
STATE_LABEL_KEY="deerflow.harness.state-dir"
|
|
STATE_MARKER=".dev-multi-instance"
|
|
RUN_DIR="$STATE_DIR/run"
|
|
LOG_DIR="$STATE_DIR/logs"
|
|
CONFIG_OUT="$STATE_DIR/config.yaml"
|
|
EXTENSIONS_OUT="$STATE_DIR/extensions_config.json"
|
|
SECRETS_FILE="$STATE_DIR/secrets.env"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: scripts/dev_multi_instance.sh <command> [options]
|
|
|
|
Run two DeerFlow Gateways (A and B) on shared Postgres, Redis and
|
|
DEER_FLOW_HOME: the topology that deployment.multi_instance: true targets.
|
|
|
|
Commands:
|
|
up [--skip-install] [--no-nginx] Start containers, render the config, start A, then B, then nginx
|
|
down Stop everything; delete the containers and harness data (logs are kept)
|
|
status Show containers, Gateways and nginx
|
|
logs [a|b|nginx|all] [-f] Print the last 100 log lines (-f follows)
|
|
check [--with-llm] Run the cross-instance checks against the running pair
|
|
stop <a|b> [--kill] Stop one Gateway (SIGTERM; --kill sends SIGKILL to simulate a crash)
|
|
start <a|b> Start a stopped Gateway again
|
|
restart <a|b> stop + start
|
|
help Show this help
|
|
|
|
Defaults:
|
|
Gateway A http://127.0.0.1:$GATEWAY_A_PORT Gateway B http://127.0.0.1:$GATEWAY_B_PORT
|
|
nginx http://localhost:$NGINX_PORT (round-robin /api over A and B; / goes to a frontend on :$FRONTEND_PORT)
|
|
Postgres 127.0.0.1:$PG_PORT ($PG_CONTAINER) Redis 127.0.0.1:$REDIS_PORT ($REDIS_CONTAINER)
|
|
State $STATE_DIR
|
|
|
|
Environment overrides:
|
|
DEERFLOW_MI_BASE_CONFIG base config (default: \$DEER_FLOW_CONFIG_PATH, config.yaml,
|
|
backend/config.yaml, then config.example.yaml)
|
|
DEERFLOW_MI_STATE_DIR state directory
|
|
DEERFLOW_MI_GATEWAY_A_PORT, DEERFLOW_MI_GATEWAY_B_PORT, DEERFLOW_MI_NGINX_PORT,
|
|
DEERFLOW_MI_FRONTEND_PORT, DEERFLOW_MI_POSTGRES_PORT, DEERFLOW_MI_REDIS_PORT
|
|
DEERFLOW_MI_POSTGRES_CONTAINER, DEERFLOW_MI_REDIS_CONTAINER,
|
|
DEERFLOW_MI_POSTGRES_IMAGE, DEERFLOW_MI_REDIS_IMAGE
|
|
DEERFLOW_MI_KEEP_CHANNELS=1 keep IM channels enabled (both Gateways then connect every bot)
|
|
DEERFLOW_MI_HEALTH_TIMEOUT seconds to wait for each Gateway's /health (default 180)
|
|
|
|
'make stop' and 'make dev' reclaim port 8001 and this checkout's Gateways, so
|
|
do not run them while the harness is up.
|
|
EOF
|
|
}
|
|
|
|
die() {
|
|
echo "✗ $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
upper() {
|
|
printf '%s' "$1" | tr '[:lower:]' '[:upper:]'
|
|
}
|
|
|
|
port_in_use() {
|
|
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t >/dev/null 2>&1
|
|
}
|
|
|
|
pid_file() {
|
|
printf '%s/%s.pid' "$RUN_DIR" "$1"
|
|
}
|
|
|
|
# Print the pid recorded for $1 when that process is still the one we started
|
|
# ($2 is a fragment of its command line), so a reused pid is never signalled.
|
|
live_pid() {
|
|
local pid command
|
|
pid="$(cat "$(pid_file "$1")" 2>/dev/null || true)"
|
|
[ -n "$pid" ] || return 1
|
|
kill -0 "$pid" 2>/dev/null || return 1
|
|
command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
|
case "$command" in
|
|
*"$2"*) printf '%s' "$pid" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
gateway_pid() {
|
|
live_pid "gateway-$1" "app.gateway.app:app"
|
|
}
|
|
|
|
nginx_pid() {
|
|
live_pid nginx "nginx"
|
|
}
|
|
|
|
gateway_port() {
|
|
case "$1" in
|
|
a) printf '%s' "$GATEWAY_A_PORT" ;;
|
|
b) printf '%s' "$GATEWAY_B_PORT" ;;
|
|
*) die "Unknown Gateway '$1' (expected a or b)" ;;
|
|
esac
|
|
}
|
|
|
|
# Resolve the backend interpreter once (in this shell, not a subshell) and run
|
|
# it directly: `uv run` would sit between us and uvicorn, so the recorded pid
|
|
# would not be the process that serves (SIGKILL would orphan the server).
|
|
ensure_python() {
|
|
if [ -z "${PYTHON_BIN:-}" ]; then
|
|
PYTHON_BIN="$(uv run --no-sync --project "$BACKEND_DIR" python -c 'import sys; print(sys.executable)')" ||
|
|
die "Cannot run the backend environment. Run 'make install' (or 'cd backend && uv sync') first."
|
|
fi
|
|
}
|
|
|
|
run_helper() {
|
|
ensure_python
|
|
"$PYTHON_BIN" "$HELPER" "$@"
|
|
}
|
|
|
|
load_secrets() {
|
|
[ -f "$SECRETS_FILE" ] || die "Missing $SECRETS_FILE; run 'up' first."
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
. "$SECRETS_FILE"
|
|
set +a
|
|
}
|
|
|
|
# ── State directory ──────────────────────────────────────────────────────────
|
|
|
|
ensure_state_dir() {
|
|
if [ -d "$STATE_DIR" ] && [ ! -f "$STATE_DIR/$STATE_MARKER" ] && [ -n "$(ls -A "$STATE_DIR" 2>/dev/null)" ]; then
|
|
die "$STATE_DIR is not empty and was not created by this harness; set DEERFLOW_MI_STATE_DIR to another directory."
|
|
fi
|
|
mkdir -p "$STATE_DIR" "$RUN_DIR" "$LOG_DIR"
|
|
touch "$STATE_DIR/$STATE_MARKER"
|
|
}
|
|
|
|
# Delete everything the harness created except logs. Guarded by the marker so
|
|
# a mistyped DEERFLOW_MI_STATE_DIR can never point this at someone's data.
|
|
wipe_runtime_data() {
|
|
[ -f "$STATE_DIR/$STATE_MARKER" ] || return 0
|
|
find "$STATE_DIR" -mindepth 1 -maxdepth 1 ! -name logs ! -name "$STATE_MARKER" -exec rm -rf {} +
|
|
}
|
|
|
|
# ── Containers ───────────────────────────────────────────────────────────────
|
|
|
|
require_docker() {
|
|
command -v docker >/dev/null 2>&1 || die "docker is not installed."
|
|
docker info >/dev/null 2>&1 || die "Docker is not running. Start Docker Desktop (or the daemon) and retry."
|
|
}
|
|
|
|
container_exists() {
|
|
docker inspect "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
container_label() {
|
|
docker inspect -f "{{ index .Config.Labels \"$2\" }}" "$1" 2>/dev/null || true
|
|
}
|
|
|
|
# Remove container $1, with its anonymous volumes (both images declare a
|
|
# VOLUME), when this state dir created it. A container from another harness
|
|
# state dir, or from outside the harness, is never touched: returns 1.
|
|
remove_container() {
|
|
local owner
|
|
container_exists "$1" || return 0
|
|
if [ "$(container_label "$1" "$LABEL_KEY")" != "$LABEL_VALUE" ]; then
|
|
echo " ! container $1 was not created by this harness; leaving it alone" >&2
|
|
return 1
|
|
fi
|
|
owner="$(container_label "$1" "$STATE_LABEL_KEY")"
|
|
if [ "$owner" != "$STATE_DIR" ]; then
|
|
echo " ! container $1 belongs to the harness state dir ${owner:-<unrecorded>}; leaving it alone" >&2
|
|
echo " (take that harness down from there, or 'docker rm -f -v $1' if it is stale)" >&2
|
|
return 1
|
|
fi
|
|
docker rm -f -v "$1" >/dev/null
|
|
echo "✓ Removed container $1 and its volumes"
|
|
}
|
|
|
|
wait_until() {
|
|
local timeout=$1 waited=0
|
|
shift
|
|
until "$@" >/dev/null 2>&1; do
|
|
[ "$waited" -lt "$timeout" ] || return 1
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
}
|
|
|
|
redis_pong() {
|
|
[ "$(docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null)" = "PONG" ]
|
|
}
|
|
|
|
start_containers() {
|
|
echo "Starting Postgres ($PG_IMAGE) on 127.0.0.1:$PG_PORT..."
|
|
POSTGRES_PASSWORD="$DEERFLOW_MI_POSTGRES_PASSWORD" docker run -d --name "$PG_CONTAINER" \
|
|
--label "$LABEL_KEY=$LABEL_VALUE" --label "$STATE_LABEL_KEY=$STATE_DIR" \
|
|
-e POSTGRES_USER=deerflow -e POSTGRES_DB=deerflow -e POSTGRES_PASSWORD \
|
|
-p "127.0.0.1:$PG_PORT:5432" "$PG_IMAGE" >/dev/null || return 1
|
|
echo "Starting Redis ($REDIS_IMAGE) on 127.0.0.1:$REDIS_PORT..."
|
|
docker run -d --name "$REDIS_CONTAINER" --label "$LABEL_KEY=$LABEL_VALUE" --label "$STATE_LABEL_KEY=$STATE_DIR" \
|
|
-p "127.0.0.1:$REDIS_PORT:6379" "$REDIS_IMAGE" >/dev/null || return 1
|
|
|
|
# TCP readiness: the image's init-time server listens on the Unix socket only.
|
|
if ! wait_until 90 docker exec "$PG_CONTAINER" pg_isready -h 127.0.0.1 -U deerflow -d deerflow; then
|
|
docker logs --tail 30 "$PG_CONTAINER" >&2 || true
|
|
echo "✗ Postgres did not become ready" >&2
|
|
return 1
|
|
fi
|
|
echo "✓ Postgres ready"
|
|
if ! wait_until 30 redis_pong; then
|
|
docker logs --tail 30 "$REDIS_CONTAINER" >&2 || true
|
|
echo "✗ Redis did not answer PING" >&2
|
|
return 1
|
|
fi
|
|
echo "✓ Redis ready"
|
|
}
|
|
|
|
# ── Gateways and nginx ───────────────────────────────────────────────────────
|
|
|
|
start_gateway() {
|
|
local name=$1 port log pid waited=0
|
|
port="$(gateway_port "$name")"
|
|
log="$LOG_DIR/gateway-$name.log"
|
|
if pid="$(gateway_pid "$name")"; then
|
|
echo "✗ Gateway $(upper "$name") is already running (pid $pid)." >&2
|
|
return 1
|
|
fi
|
|
if port_in_use "$port"; then
|
|
echo "✗ Port $port is in use; free it or set DEERFLOW_MI_GATEWAY_$(upper "$name")_PORT." >&2
|
|
return 1
|
|
fi
|
|
if [ ! -f "$CONFIG_OUT" ]; then
|
|
echo "✗ Missing $CONFIG_OUT; run 'up' first." >&2
|
|
return 1
|
|
fi
|
|
load_secrets
|
|
mkdir -p "$STATE_DIR/index/$name" "$STATE_DIR/home"
|
|
ensure_python
|
|
|
|
echo "Starting Gateway $(upper "$name") on 127.0.0.1:$port..."
|
|
{
|
|
echo ""
|
|
echo "===== $(date '+%Y-%m-%d %H:%M:%S') starting Gateway $(upper "$name") on 127.0.0.1:$port ====="
|
|
} >>"$log"
|
|
(
|
|
cd "$BACKEND_DIR"
|
|
# One worker per Gateway: the pid file must name the process that serves.
|
|
export DEER_FLOW_CONFIG_PATH="$CONFIG_OUT" \
|
|
DEER_FLOW_EXTENSIONS_CONFIG_PATH="$EXTENSIONS_OUT" \
|
|
DEER_FLOW_HOME="$STATE_DIR/home" \
|
|
DEER_FLOW_PROJECT_ROOT="$REPO_ROOT" \
|
|
DEERFLOW_MI_RETRIEVAL_INDEX_PATH="$STATE_DIR/index/$name" \
|
|
DEERFLOW_MI_DATABASE_URL="postgresql://deerflow:$DEERFLOW_MI_POSTGRES_PASSWORD@127.0.0.1:$PG_PORT/deerflow" \
|
|
DEERFLOW_MI_REDIS_URL="redis://127.0.0.1:$REDIS_PORT/0" \
|
|
DEER_FLOW_CHANNELS_LANGGRAPH_URL="http://127.0.0.1:$port/api" \
|
|
DEER_FLOW_CHANNELS_GATEWAY_URL="http://127.0.0.1:$port" \
|
|
GATEWAY_WORKERS=1 WEB_CONCURRENCY=1 PYTHONPATH=. \
|
|
AUTH_JWT_SECRET DEER_FLOW_INTERNAL_AUTH_TOKEN DEER_FLOW_CREDENTIALS_KEY
|
|
exec nohup "$PYTHON_BIN" -m uvicorn app.gateway.app:app --host 127.0.0.1 --port "$port" </dev/null
|
|
) >>"$log" 2>&1 &
|
|
pid=$!
|
|
# The Gateway outlives this script: drop it from the job table so stopping
|
|
# it later (teardown, stop/restart) prints no "Terminated" job notice.
|
|
disown "$pid" 2>/dev/null || true
|
|
echo "$pid" >"$(pid_file "gateway-$name")"
|
|
|
|
while ! curl -fsS --noproxy '*' -o /dev/null --max-time 2 "http://127.0.0.1:$port/health" 2>/dev/null; do
|
|
if ! kill -0 "$pid" 2>/dev/null; then
|
|
echo "✗ Gateway $(upper "$name") exited during startup. Last lines of $log:" >&2
|
|
tail -n 40 "$log" >&2
|
|
return 1
|
|
fi
|
|
if [ "$waited" -ge "$HEALTH_TIMEOUT" ]; then
|
|
echo "✗ Gateway $(upper "$name") did not answer /health within ${HEALTH_TIMEOUT}s. Last lines of $log:" >&2
|
|
tail -n 40 "$log" >&2
|
|
return 1
|
|
fi
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
echo "✓ Gateway $(upper "$name") ready on http://127.0.0.1:$port (pid $pid, ${waited}s)"
|
|
}
|
|
|
|
# stop_process NAME CMD_FRAGMENT SIGNAL
|
|
stop_process() {
|
|
local name=$1 fragment=$2 signal=${3:-TERM} pid waited=0
|
|
if pid="$(live_pid "$name" "$fragment")"; then
|
|
kill "-$signal" "$pid" 2>/dev/null || true
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
if [ "$waited" -ge 30 ]; then
|
|
echo " ! $name (pid $pid) ignored SIG$signal for 30s; sending SIGKILL" >&2
|
|
kill -KILL "$pid" 2>/dev/null || true
|
|
break
|
|
fi
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
echo "✓ Stopped $name (pid $pid, SIG$signal)"
|
|
fi
|
|
rm -f "$(pid_file "$name")"
|
|
}
|
|
|
|
start_nginx() {
|
|
local conf="$STATE_DIR/nginx/nginx.conf" waited=0
|
|
if ! command -v nginx >/dev/null 2>&1; then
|
|
echo " nginx not found; skipping the round-robin front (Gateways are still reachable directly)"
|
|
return 0
|
|
fi
|
|
if port_in_use "$NGINX_PORT"; then
|
|
echo " ! port $NGINX_PORT is in use; skipping nginx (set DEERFLOW_MI_NGINX_PORT)" >&2
|
|
return 0
|
|
fi
|
|
mkdir -p "$STATE_DIR/nginx/temp"
|
|
run_helper nginx-conf --out "$conf" --state-dir "$STATE_DIR" --listen-port "$NGINX_PORT" \
|
|
--gateway-port "$GATEWAY_A_PORT" --gateway-port "$GATEWAY_B_PORT" --frontend-port "$FRONTEND_PORT" || return 1
|
|
echo "Starting nginx on 127.0.0.1:$NGINX_PORT..."
|
|
if ! nginx -e "$LOG_DIR/nginx-error.log" -p "$STATE_DIR/nginx" -c "$conf" >>"$LOG_DIR/nginx.log" 2>&1; then
|
|
tail -n 20 "$LOG_DIR/nginx.log" "$LOG_DIR/nginx-error.log" >&2 2>/dev/null || true
|
|
echo "✗ nginx failed to start" >&2
|
|
return 1
|
|
fi
|
|
until nginx_pid >/dev/null; do
|
|
[ "$waited" -lt 10 ] || { echo "✗ nginx wrote no pid file" >&2; return 1; }
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
echo "✓ nginx ready on http://localhost:$NGINX_PORT (pid $(nginx_pid))"
|
|
}
|
|
|
|
# Stop processes and remove containers; keep config and logs for inspection.
|
|
teardown() {
|
|
stop_process nginx "nginx" QUIT
|
|
stop_process gateway-b "app.gateway.app:app" TERM
|
|
stop_process gateway-a "app.gateway.app:app" TERM
|
|
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
remove_container "$PG_CONTAINER" || true
|
|
remove_container "$REDIS_CONTAINER" || true
|
|
else
|
|
echo " ! Docker is not reachable; containers $PG_CONTAINER / $REDIS_CONTAINER were not removed" >&2
|
|
fi
|
|
}
|
|
|
|
fail_up() {
|
|
echo "✗ $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
# EXIT trap while `up` starts things: any exit before the banner (fail_up, die,
|
|
# set -e, Ctrl+C) stops what was started instead of leaving it running.
|
|
UP_IN_PROGRESS=false
|
|
on_up_exit() {
|
|
local status=$?
|
|
trap - EXIT INT TERM
|
|
if $UP_IN_PROGRESS; then
|
|
echo " Tearing down the partial start; logs stay in $LOG_DIR" >&2
|
|
teardown
|
|
[ "$status" -ne 0 ] || status=1
|
|
fi
|
|
exit "$status"
|
|
}
|
|
|
|
# ── Inputs ───────────────────────────────────────────────────────────────────
|
|
|
|
absolute_path() {
|
|
printf '%s/%s' "$(builtin cd "$(dirname "$1")" >/dev/null 2>&1 && pwd -P)" "$(basename "$1")"
|
|
}
|
|
|
|
resolve_base_config() {
|
|
local candidate
|
|
if [ -n "${DEERFLOW_MI_BASE_CONFIG:-}" ]; then
|
|
[ -f "$DEERFLOW_MI_BASE_CONFIG" ] || die "DEERFLOW_MI_BASE_CONFIG=$DEERFLOW_MI_BASE_CONFIG does not exist."
|
|
BASE_CONFIG="$(absolute_path "$DEERFLOW_MI_BASE_CONFIG")"
|
|
return 0
|
|
fi
|
|
for candidate in "${DEER_FLOW_CONFIG_PATH:-}" "$REPO_ROOT/config.yaml" "$REPO_ROOT/backend/config.yaml" "$REPO_ROOT/config.example.yaml"; do
|
|
[ -n "$candidate" ] && [ -f "$candidate" ] || continue
|
|
candidate="$(absolute_path "$candidate")"
|
|
[ "$candidate" != "$CONFIG_OUT" ] || continue
|
|
BASE_CONFIG="$candidate"
|
|
return 0
|
|
done
|
|
die "No base config found (config.yaml or config.example.yaml)."
|
|
}
|
|
|
|
copy_extensions_config() {
|
|
local candidate
|
|
for candidate in "${DEER_FLOW_EXTENSIONS_CONFIG_PATH:-}" "$REPO_ROOT/extensions_config.json" "$REPO_ROOT/backend/extensions_config.json"; do
|
|
[ -n "$candidate" ] && [ -f "$candidate" ] && [ "$candidate" != "$EXTENSIONS_OUT" ] || continue
|
|
cp "$candidate" "$EXTENSIONS_OUT"
|
|
chmod 600 "$EXTENSIONS_OUT"
|
|
echo " extensions: copied $candidate (Gateway MCP/skill edits go to the copy)"
|
|
return 0
|
|
done
|
|
printf '{\n "mcpServers": {},\n "skills": {}\n}\n' >"$EXTENSIONS_OUT"
|
|
echo " extensions: none found; using an empty extensions_config.json"
|
|
}
|
|
|
|
# Extras the base config needs (models, channels, ...) plus the two this
|
|
# topology always needs. The detector only emits validated extra names.
|
|
uv_extra_flags() {
|
|
local detected="" token previous="" flags="--extra postgres --extra redis"
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
detected="$(DEER_FLOW_CONFIG_PATH="$BASE_CONFIG" python3 "$REPO_ROOT/scripts/detect_uv_extras.py" 2>/dev/null || true)"
|
|
fi
|
|
for token in $detected; do
|
|
if [ "$previous" = "--extra" ] && [ "$token" != postgres ] && [ "$token" != redis ]; then
|
|
flags="$flags --extra $token"
|
|
fi
|
|
previous="$token"
|
|
done
|
|
printf '%s' "$flags"
|
|
}
|
|
|
|
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
|
|
cmd_up() {
|
|
local skip_install=false with_nginx=true arg port extras
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--skip-install) skip_install=true ;;
|
|
--no-nginx) with_nginx=false ;;
|
|
*) die "Unknown option for up: $arg" ;;
|
|
esac
|
|
done
|
|
|
|
require_docker
|
|
command -v uv >/dev/null 2>&1 || die "uv is not installed."
|
|
command -v curl >/dev/null 2>&1 || die "curl is not installed."
|
|
command -v lsof >/dev/null 2>&1 || die "lsof is not installed."
|
|
if gateway_pid a >/dev/null || gateway_pid b >/dev/null || nginx_pid >/dev/null; then
|
|
die "The harness is already running; run 'down' first (or 'status')."
|
|
fi
|
|
|
|
ensure_state_dir
|
|
resolve_base_config
|
|
|
|
# A previous session of this state dir that was not taken down: clear it
|
|
# before the port checks. Containers of any other owner are refused.
|
|
remove_container "$PG_CONTAINER" || die "Container name $PG_CONTAINER is taken; set DEERFLOW_MI_POSTGRES_CONTAINER (and the ports) to run a second harness."
|
|
remove_container "$REDIS_CONTAINER" || die "Container name $REDIS_CONTAINER is taken; set DEERFLOW_MI_REDIS_CONTAINER (and the ports) to run a second harness."
|
|
for port in "$GATEWAY_A_PORT" "$GATEWAY_B_PORT" "$PG_PORT" "$REDIS_PORT"; do
|
|
if port_in_use "$port"; then
|
|
die "Port $port is in use. Stop whatever holds it ('make stop' for make dev) or override the port (see --help)."
|
|
fi
|
|
done
|
|
|
|
wipe_runtime_data
|
|
mkdir -p "$RUN_DIR" "$LOG_DIR" "$STATE_DIR/home" "$STATE_DIR/index"
|
|
: >"$LOG_DIR/gateway-a.log"
|
|
: >"$LOG_DIR/gateway-b.log"
|
|
rm -f "$LOG_DIR"/nginx*.log
|
|
|
|
echo "Base config: $BASE_CONFIG"
|
|
if ! $skip_install; then
|
|
extras="$(uv_extra_flags)"
|
|
echo "Syncing backend dependencies ($extras)..."
|
|
# Intentionally unquoted: splats validated `--extra NAME` pairs.
|
|
# shellcheck disable=SC2086
|
|
(cd "$BACKEND_DIR" && uv sync --locked --quiet --all-packages $extras) || die "Backend dependency sync failed."
|
|
fi
|
|
|
|
run_helper secrets --out "$SECRETS_FILE" || die "Could not create $SECRETS_FILE."
|
|
load_secrets
|
|
echo "Rendering $CONFIG_OUT..."
|
|
local keep_channels=""
|
|
if [ "${DEERFLOW_MI_KEEP_CHANNELS:-}" = "1" ]; then
|
|
keep_channels="--keep-channels"
|
|
fi
|
|
# The URLs stay $VAR references so the file holds no harness secret; each
|
|
# Gateway process exports them (see start_gateway).
|
|
# shellcheck disable=SC2016
|
|
run_helper render-config --base "$BASE_CONFIG" --out "$CONFIG_OUT" --state-dir "$STATE_DIR" \
|
|
--database-url '$DEERFLOW_MI_DATABASE_URL' --redis-url '$DEERFLOW_MI_REDIS_URL' $keep_channels ||
|
|
die "Could not render the multi-instance config."
|
|
copy_extensions_config
|
|
|
|
UP_IN_PROGRESS=true
|
|
trap on_up_exit EXIT
|
|
trap 'echo ""; fail_up "Interrupted"' INT TERM
|
|
start_containers || fail_up "Containers failed to start."
|
|
# Sequential cold start: Alembic migrations are advisory-locked, but the
|
|
# LangGraph checkpointer/store setup() is not, so B starts after A is up.
|
|
start_gateway a || fail_up "Gateway A failed to start."
|
|
start_gateway b || fail_up "Gateway B failed to start."
|
|
if $with_nginx; then
|
|
start_nginx || fail_up "nginx failed to start."
|
|
fi
|
|
UP_IN_PROGRESS=false
|
|
trap - EXIT INT TERM
|
|
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " ✓ Multi-instance harness is up"
|
|
echo "=========================================="
|
|
echo " Gateway A http://127.0.0.1:$GATEWAY_A_PORT (log $LOG_DIR/gateway-a.log)"
|
|
echo " Gateway B http://127.0.0.1:$GATEWAY_B_PORT (log $LOG_DIR/gateway-b.log)"
|
|
if nginx_pid >/dev/null; then
|
|
echo " nginx http://localhost:$NGINX_PORT (round-robin; start the frontend on :$FRONTEND_PORT for the UI)"
|
|
fi
|
|
echo " Postgres 127.0.0.1:$PG_PORT ($PG_CONTAINER, user/db deerflow, password in $SECRETS_FILE)"
|
|
echo " Redis 127.0.0.1:$REDIS_PORT ($REDIS_CONTAINER)"
|
|
echo " Shared home $STATE_DIR/home"
|
|
echo " Config $CONFIG_OUT"
|
|
echo ""
|
|
echo " Next: scripts/dev_multi_instance.sh check"
|
|
echo " scripts/dev_multi_instance.sh stop b --kill (crash B), then: start b"
|
|
echo " scripts/dev_multi_instance.sh down"
|
|
}
|
|
|
|
cmd_down() {
|
|
[ $# -eq 0 ] || die "down takes no options"
|
|
teardown
|
|
wipe_runtime_data
|
|
echo "✓ Harness is down (logs kept in $LOG_DIR)"
|
|
}
|
|
|
|
cmd_status() {
|
|
local name pid port code container
|
|
echo "State dir: $STATE_DIR"
|
|
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
for container in "$PG_CONTAINER" "$REDIS_CONTAINER"; do
|
|
if container_exists "$container"; then
|
|
echo " $container: $(docker inspect -f '{{.State.Status}}' "$container")"
|
|
else
|
|
echo " $container: absent"
|
|
fi
|
|
done
|
|
else
|
|
echo " containers: Docker is not reachable"
|
|
fi
|
|
for name in a b; do
|
|
port="$(gateway_port "$name")"
|
|
if pid="$(gateway_pid "$name")"; then
|
|
code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:$port/health/ready" || true)"
|
|
echo " Gateway $(upper "$name"): running (pid $pid) http://127.0.0.1:$port, /health/ready $code"
|
|
else
|
|
echo " Gateway $(upper "$name"): stopped"
|
|
fi
|
|
done
|
|
if pid="$(nginx_pid)"; then
|
|
echo " nginx: running (pid $pid) http://localhost:$NGINX_PORT"
|
|
else
|
|
echo " nginx: stopped"
|
|
fi
|
|
}
|
|
|
|
cmd_logs() {
|
|
local follow=false arg files=""
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
a | b) files="$files gateway-$arg.log" ;;
|
|
nginx) files="$files nginx-error.log nginx-access.log" ;;
|
|
all) files="$files gateway-a.log gateway-b.log" ;;
|
|
-f | --follow) follow=true ;;
|
|
*) die "Unknown option for logs: $arg" ;;
|
|
esac
|
|
done
|
|
[ -n "$files" ] || files="gateway-a.log gateway-b.log"
|
|
[ -d "$LOG_DIR" ] || die "No logs yet in $LOG_DIR."
|
|
cd "$LOG_DIR"
|
|
if $follow; then
|
|
# shellcheck disable=SC2086
|
|
exec tail -n 100 -F $files
|
|
fi
|
|
for arg in $files; do
|
|
if [ -f "$arg" ]; then
|
|
echo "==> $LOG_DIR/$arg <=="
|
|
tail -n 100 "$arg"
|
|
fi
|
|
done
|
|
}
|
|
|
|
cmd_check() {
|
|
local arg nginx_url=""
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--with-llm) ;;
|
|
*) die "Unknown option for check: $arg" ;;
|
|
esac
|
|
done
|
|
if ! gateway_pid a >/dev/null || ! gateway_pid b >/dev/null; then
|
|
die "Both Gateways must be running; run 'up' (or 'start a|b')."
|
|
fi
|
|
if nginx_pid >/dev/null; then
|
|
nginx_url="http://127.0.0.1:$NGINX_PORT"
|
|
fi
|
|
run_helper check --gateway "A=http://127.0.0.1:$GATEWAY_A_PORT" --gateway "B=http://127.0.0.1:$GATEWAY_B_PORT" \
|
|
${nginx_url:+--nginx "$nginx_url"} --state-dir "$STATE_DIR" "$@"
|
|
}
|
|
|
|
cmd_stop() {
|
|
local name="${1:-}" signal=TERM
|
|
[ -n "$name" ] || die "Usage: stop <a|b> [--kill]"
|
|
gateway_port "$name" >/dev/null
|
|
shift
|
|
case "${1:-}" in
|
|
"") ;;
|
|
--kill) signal=KILL ;;
|
|
*) die "Unknown option for stop: $1" ;;
|
|
esac
|
|
gateway_pid "$name" >/dev/null || die "Gateway $(upper "$name") is not running."
|
|
stop_process "gateway-$name" "app.gateway.app:app" "$signal"
|
|
}
|
|
|
|
cmd_start() {
|
|
local name="${1:-}"
|
|
[ -n "$name" ] && [ $# -eq 1 ] || die "Usage: start <a|b>"
|
|
gateway_port "$name" >/dev/null
|
|
start_gateway "$name"
|
|
}
|
|
|
|
cmd_restart() {
|
|
local name="${1:-}"
|
|
[ -n "$name" ] && [ $# -eq 1 ] || die "Usage: restart <a|b>"
|
|
gateway_port "$name" >/dev/null
|
|
stop_process "gateway-$name" "app.gateway.app:app" TERM
|
|
start_gateway "$name"
|
|
}
|
|
|
|
main() {
|
|
local command="${1:-help}"
|
|
[ $# -eq 0 ] || shift
|
|
case "$command" in
|
|
up) cmd_up "$@" ;;
|
|
down) cmd_down "$@" ;;
|
|
status) cmd_status ;;
|
|
logs) cmd_logs "$@" ;;
|
|
check) cmd_check "$@" ;;
|
|
stop) cmd_stop "$@" ;;
|
|
start) cmd_start "$@" ;;
|
|
restart) cmd_restart "$@" ;;
|
|
help | -h | --help) usage ;;
|
|
*)
|
|
echo "Unknown command: $command" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|