mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +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>
784 lines
34 KiB
Python
784 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""Helpers for ``scripts/dev_multi_instance.sh``, the two-Gateway local harness.
|
|
|
|
The shell entry point owns containers and processes. This module owns the parts
|
|
that decide whether the pair can start and whether it behaves as one cluster:
|
|
|
|
* ``render-config`` overlays the multi-instance prerequisites on a base
|
|
``config.yaml`` so the result passes the Gateway's multi-instance startup gate
|
|
(``app.gateway.deps._enforce_postgres_for_multi_worker``) and leaves every
|
|
other section of the developer's config alone.
|
|
* ``secrets`` generates the secrets every Gateway process must share.
|
|
* ``nginx-conf`` renders a minimal round-robin nginx front for both Gateways.
|
|
* ``check`` runs the scriptable cross-instance acceptance checks.
|
|
|
|
It needs PyYAML and httpx, so run it with the backend environment::
|
|
|
|
uv run --no-sync --project backend python scripts/dev_multi_instance.py --help
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Each Gateway exports its own value; the shared config.yaml references it so
|
|
# one file serves both processes (the Helm chart does the same with a per-Pod
|
|
# emptyDir mounted at one path).
|
|
RETRIEVAL_INDEX_ENV_VAR = "DEERFLOW_MI_RETRIEVAL_INDEX_PATH"
|
|
|
|
# Every Gateway process must see identical values. DEER_FLOW_CREDENTIALS_KEY is
|
|
# the at-rest credentials key: without one shared value a declared
|
|
# multi-instance pair with channel_connections enabled refuses to start.
|
|
SHARED_SECRET_NAMES = (
|
|
"AUTH_JWT_SECRET",
|
|
"DEER_FLOW_INTERNAL_AUTH_TOKEN",
|
|
"DEER_FLOW_CREDENTIALS_KEY",
|
|
"DEERFLOW_MI_POSTGRES_PASSWORD",
|
|
)
|
|
|
|
GENERATED_HEADER = "# Generated by scripts/dev_multi_instance.sh on every `up`; edit the base config instead.\n# Base config: {base}\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config overlay
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _section(config: dict[str, Any], name: str) -> dict[str, Any]:
|
|
value = config.get(name)
|
|
if not isinstance(value, dict):
|
|
value = {}
|
|
config[name] = value
|
|
return value
|
|
|
|
|
|
def _is_browser_tool(tool: Any) -> bool:
|
|
if not isinstance(tool, Mapping):
|
|
return False
|
|
return str(tool.get("name") or "").startswith("browser_") or "browser_automation" in str(tool.get("use") or "")
|
|
|
|
|
|
def _is_local_aio_sandbox(sandbox: Mapping[str, Any]) -> bool:
|
|
"""AIO sandboxes run as named Docker containers on this host unless a provisioner owns them."""
|
|
return "aio_sandbox" in str(sandbox.get("use") or "") and not sandbox.get("provisioner_url")
|
|
|
|
|
|
def sandbox_container_prefix(state_dir: Path | str) -> str:
|
|
"""Docker name prefix for the local AIO sandboxes of one harness state directory.
|
|
|
|
Stable for a state directory (warm sandboxes survive restarts) and distinct
|
|
from the provider default, so the harness never adopts or reaps the
|
|
sandbox containers of `make dev` or of another harness.
|
|
"""
|
|
digest = hashlib.sha256(os.path.abspath(state_dir).encode("utf-8")).hexdigest()[:10]
|
|
return f"deer-flow-mi-{digest}-sandbox"
|
|
|
|
|
|
def build_multi_instance_config(
|
|
base: Mapping[str, Any] | None,
|
|
*,
|
|
database_url: str,
|
|
redis_url: str,
|
|
sandbox_container_prefix: str,
|
|
keep_channels: bool = False,
|
|
) -> tuple[dict[str, Any], list[str]]:
|
|
"""Return ``(config, notes)``: *base* plus every multi-instance prerequisite.
|
|
|
|
Everything the harness writes must land in its own Postgres, Redis, home
|
|
and containers, never in the developer's own deployment, so every
|
|
persistence endpoint or data root the base config names is redirected.
|
|
``notes`` describes each change beyond the always-applied keys, so the
|
|
harness can tell the developer what differs from their own config.
|
|
"""
|
|
config: dict[str, Any] = copy.deepcopy(dict(base or {}))
|
|
notes: list[str] = []
|
|
|
|
_section(config, "deployment")["multi_instance"] = True
|
|
|
|
database = _section(config, "database")
|
|
database["backend"] = "postgres"
|
|
database["postgres_url"] = database_url
|
|
cache = database.get("checkpoint_cache")
|
|
if isinstance(cache, dict) and (cache.get("type") == "redis" or cache.get("redis_url")):
|
|
cache["redis_url"] = redis_url
|
|
notes.append("pointed database.checkpoint_cache at the harness Redis")
|
|
|
|
_section(config, "run_events")["backend"] = "db"
|
|
_section(config, "run_ownership")["heartbeat_enabled"] = True
|
|
|
|
bridge = _section(config, "stream_bridge")
|
|
bridge["type"] = "redis"
|
|
bridge["redis_url"] = redis_url
|
|
|
|
checkpointer = config.pop("checkpointer", None)
|
|
if checkpointer is not None:
|
|
# An explicit checkpointer section wins over `database` for LangGraph
|
|
# checkpoints and the Store: a sqlite/memory one would split threads per
|
|
# process, a postgres one would write into the developer's database.
|
|
kind = checkpointer.get("type") if isinstance(checkpointer, Mapping) else None
|
|
notes.append(f"removed checkpointer (type {kind!r}); the harness Postgres holds checkpoints and the Store")
|
|
|
|
scheduler = config.get("scheduler")
|
|
if isinstance(scheduler, dict) and scheduler.get("enabled") and not scheduler.get("multi_instance"):
|
|
scheduler["multi_instance"] = True
|
|
notes.append("set scheduler.multi_instance: true (the scheduler is enabled; the gate refuses a per-process scheduler)")
|
|
|
|
tools = config.get("tools")
|
|
if isinstance(tools, list):
|
|
removed = [str(tool.get("name")) for tool in tools if _is_browser_tool(tool)]
|
|
if removed:
|
|
config["tools"] = [tool for tool in tools if not _is_browser_tool(tool)]
|
|
notes.append(f"removed process-local browser tools: {', '.join(removed)}")
|
|
|
|
sandbox = config.get("sandbox")
|
|
if isinstance(sandbox, dict):
|
|
ownership = sandbox.get("ownership")
|
|
if ownership is not None:
|
|
# An explicit section stops ownership from inheriting the bridge's
|
|
# Redis, so its endpoint must be rewritten; timing settings stay.
|
|
ownership = dict(ownership) if isinstance(ownership, Mapping) else {}
|
|
previous = f"type {ownership.get('type', 'memory')!r}" + (f", {ownership['redis_url']}" if ownership.get("redis_url") else "")
|
|
ownership.update(type="redis", redis_url=redis_url)
|
|
sandbox["ownership"] = ownership
|
|
notes.append(f"pointed sandbox.ownership at the harness Redis (was {previous})")
|
|
if _is_local_aio_sandbox(sandbox):
|
|
previous_prefix = sandbox.get("container_prefix") or "deer-flow-sandbox"
|
|
sandbox["container_prefix"] = sandbox_container_prefix
|
|
notes.append(f"set sandbox.container_prefix: {sandbox_container_prefix} (was {previous_prefix!r}) so the harness never adopts or reaps other sandbox containers")
|
|
|
|
channels = config.get("channels")
|
|
if not keep_channels and isinstance(channels, dict):
|
|
disabled = sorted(name for name, value in channels.items() if isinstance(value, dict) and value.get("enabled"))
|
|
for name in disabled:
|
|
channels[name]["enabled"] = False
|
|
if disabled:
|
|
notes.append(f"disabled IM channels {', '.join(disabled)}: both Gateways would connect the same bot (set DEERFLOW_MI_KEEP_CHANNELS=1 to keep them)")
|
|
|
|
memory = _section(config, "memory")
|
|
manager_class = memory.get("manager_class")
|
|
if manager_class in (None, "", "deermem"):
|
|
backend_config = memory.get("backend_config")
|
|
if not isinstance(backend_config, dict):
|
|
backend_config = {}
|
|
memory["backend_config"] = backend_config
|
|
# Empty storage_path means the harness DEER_FLOW_HOME. Both spellings go:
|
|
# a legacy top-level memory.storage_path is migrated into backend_config on load.
|
|
dropped = [f"{label}={value!r}" for label, value in (("memory.storage_path", memory.pop("storage_path", None)), ("memory.backend_config.storage_path", backend_config.pop("storage_path", None))) if value]
|
|
if dropped:
|
|
notes.append(f"dropped {', '.join(dropped)}: DeerMem data stays in the harness home")
|
|
backend_config["retrieval_index_path"] = f"${RETRIEVAL_INDEX_ENV_VAR}"
|
|
else:
|
|
notes.append(f"memory.manager_class={manager_class!r} is not DeerMem: the harness uses that backend as configured, shared with your own deployment")
|
|
|
|
blob_storage = config.get("blob_storage")
|
|
blob_config = blob_storage.get("backend_config") if isinstance(blob_storage, dict) else None
|
|
if isinstance(blob_config, dict) and blob_config.get("root"):
|
|
notes.append(f"dropped blob_storage.backend_config.root={blob_config.pop('root')!r}: blobs stay in the harness home")
|
|
|
|
return config, notes
|
|
|
|
|
|
def _write_private_text(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(f".{path.name}.tmp")
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def render_config(base_path: Path, out_path: Path, *, database_url: str, redis_url: str, state_dir: Path, keep_channels: bool = False) -> list[str]:
|
|
"""Write the generated multi-instance config (mode 0600) and return its notes."""
|
|
import yaml
|
|
|
|
base = yaml.safe_load(Path(base_path).read_text(encoding="utf-8-sig")) or {}
|
|
if not isinstance(base, dict):
|
|
raise SystemExit(f"{base_path} is not a YAML mapping")
|
|
config, notes = build_multi_instance_config(
|
|
base,
|
|
database_url=database_url,
|
|
redis_url=redis_url,
|
|
sandbox_container_prefix=sandbox_container_prefix(state_dir),
|
|
keep_channels=keep_channels,
|
|
)
|
|
body = yaml.safe_dump(config, sort_keys=False, allow_unicode=True, default_flow_style=False)
|
|
_write_private_text(Path(out_path), GENERATED_HEADER.format(base=base_path) + body)
|
|
return notes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared secrets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def generate_shared_secrets() -> dict[str, str]:
|
|
return {
|
|
"AUTH_JWT_SECRET": secrets.token_urlsafe(32),
|
|
"DEER_FLOW_INTERNAL_AUTH_TOKEN": secrets.token_urlsafe(32),
|
|
# A raw urlsafe-base64 32-byte Fernet key, not a passphrase.
|
|
"DEER_FLOW_CREDENTIALS_KEY": base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii"),
|
|
"DEERFLOW_MI_POSTGRES_PASSWORD": secrets.token_hex(16),
|
|
}
|
|
|
|
|
|
def read_env_file(path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
values[key.strip()] = value.strip()
|
|
return values
|
|
|
|
|
|
def ensure_secrets_file(path: Path) -> dict[str, str]:
|
|
"""Create the shell-sourceable secrets file once (``O_EXCL``, 0600) and return it."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
values = generate_shared_secrets()
|
|
try:
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
except FileExistsError:
|
|
return read_env_file(path)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write("# Shared by every Gateway of the dev_multi_instance harness. Do not commit.\n")
|
|
for name in SHARED_SECRET_NAMES:
|
|
fh.write(f"{name}={values[name]}\n")
|
|
return values
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# nginx front
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PROXY_HEADERS = """ proxy_set_header Host $http_host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;"""
|
|
|
|
|
|
def _nginx_path(path: Path) -> str:
|
|
"""Render *path* as an nginx double-quoted string literal.
|
|
|
|
Quoting keeps whitespace and ``;``/``{`` inside one argument. ``$`` cannot be
|
|
escaped (nginx interpolates it in directives such as ``access_log``) and
|
|
control characters cannot be spelled, so both are refused.
|
|
"""
|
|
text = str(path)
|
|
if "$" in text or any(ord(char) < 32 for char in text):
|
|
raise ValueError(f"nginx cannot reference {text!r}: choose a state directory without '$' or control characters")
|
|
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
|
|
|
|
def render_nginx_conf(*, listen_port: int, gateway_ports: Sequence[int], frontend_port: int, state_dir: Path) -> str:
|
|
"""Return a minimal nginx config that round-robins ``/api`` over every Gateway."""
|
|
state_dir = Path(state_dir)
|
|
run_dir, log_dir, temp_dir = state_dir / "run", state_dir / "logs", state_dir / "nginx" / "temp"
|
|
servers = "\n".join(f" server 127.0.0.1:{port};" for port in gateway_ports)
|
|
return f"""# Generated by scripts/dev_multi_instance.sh; regenerated on every `up`.
|
|
worker_processes 1;
|
|
pid {_nginx_path(run_dir / "nginx.pid")};
|
|
error_log {_nginx_path(log_dir / "nginx-error.log")} warn;
|
|
|
|
events {{
|
|
worker_connections 1024;
|
|
}}
|
|
|
|
http {{
|
|
access_log {_nginx_path(log_dir / "nginx-access.log")};
|
|
client_body_temp_path {_nginx_path(temp_dir / "client_body")};
|
|
proxy_temp_path {_nginx_path(temp_dir / "proxy")};
|
|
fastcgi_temp_path {_nginx_path(temp_dir / "fastcgi")};
|
|
uwsgi_temp_path {_nginx_path(temp_dir / "uwsgi")};
|
|
scgi_temp_path {_nginx_path(temp_dir / "scgi")};
|
|
|
|
upstream deerflow_gateways {{
|
|
{servers}
|
|
}}
|
|
|
|
upstream deerflow_frontend {{
|
|
server 127.0.0.1:{frontend_port};
|
|
}}
|
|
|
|
map $http_upgrade $connection_upgrade {{
|
|
default upgrade;
|
|
'' '';
|
|
}}
|
|
|
|
server {{
|
|
listen 127.0.0.1:{listen_port};
|
|
server_name _;
|
|
|
|
# Which Gateway answered: lets `check` and developers see the balancing.
|
|
add_header X-DeerFlow-Upstream $upstream_addr always;
|
|
|
|
proxy_http_version 1.1;
|
|
proxy_buffering off;
|
|
proxy_cache off;
|
|
proxy_request_buffering off;
|
|
client_max_body_size 101M;
|
|
proxy_connect_timeout 10s;
|
|
proxy_send_timeout 600s;
|
|
proxy_read_timeout 600s;
|
|
|
|
location /api/langgraph/ {{
|
|
rewrite ^/api/langgraph/(.*) /api/$1 break;
|
|
{_PROXY_HEADERS}
|
|
proxy_set_header Connection '';
|
|
proxy_pass http://deerflow_gateways;
|
|
}}
|
|
|
|
location /api/ {{
|
|
{_PROXY_HEADERS}
|
|
proxy_set_header Connection '';
|
|
proxy_pass http://deerflow_gateways;
|
|
}}
|
|
|
|
location /health {{
|
|
{_PROXY_HEADERS}
|
|
proxy_pass http://deerflow_gateways;
|
|
}}
|
|
|
|
location / {{
|
|
{_PROXY_HEADERS}
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection $connection_upgrade;
|
|
proxy_pass http://deerflow_frontend;
|
|
}}
|
|
}}
|
|
}}
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-instance checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PASS, FAIL, SKIP = "PASS", "FAIL", "SKIP"
|
|
|
|
|
|
@dataclass
|
|
class CheckResult:
|
|
name: str
|
|
status: str
|
|
detail: str
|
|
|
|
|
|
@dataclass
|
|
class SSEEvent:
|
|
id: str | None
|
|
event: str | None
|
|
data: str
|
|
|
|
|
|
def parse_sse_events(lines: Iterable[str]) -> Iterator[SSEEvent]:
|
|
"""Yield server-sent events from decoded lines (blank line = dispatch)."""
|
|
event_id: str | None = None
|
|
event_name: str | None = None
|
|
data: list[str] = []
|
|
for raw in lines:
|
|
line = raw.rstrip("\r\n")
|
|
if not line:
|
|
if event_name is not None or data or event_id is not None:
|
|
yield SSEEvent(event_id, event_name, "\n".join(data))
|
|
event_id, event_name, data = None, None, []
|
|
continue
|
|
if line.startswith(":"):
|
|
continue
|
|
name, _, value = line.partition(":")
|
|
value = value[1:] if value.startswith(" ") else value
|
|
if name == "id":
|
|
event_id = value
|
|
elif name == "event":
|
|
event_name = value
|
|
elif name == "data":
|
|
data.append(value)
|
|
if event_name is not None or data or event_id is not None:
|
|
yield SSEEvent(event_id, event_name, "\n".join(data))
|
|
|
|
|
|
def exit_code(results: Iterable[CheckResult]) -> int:
|
|
return 1 if any(result.status == FAIL for result in results) else 0
|
|
|
|
|
|
class CheckFailed(Exception):
|
|
"""A check's expectation did not hold; the message is the reported detail."""
|
|
|
|
|
|
class CheckSkipped(Exception):
|
|
"""A check cannot run in this setup; the message says why."""
|
|
|
|
|
|
@dataclass
|
|
class Harness:
|
|
"""State shared by the checks: two Gateways, an optional nginx, one session."""
|
|
|
|
gateways: list[tuple[str, str]]
|
|
nginx_url: str | None
|
|
state_dir: Path
|
|
with_llm: bool
|
|
timeout: float
|
|
internal_token: str = ""
|
|
cookies: dict[str, str] = field(default_factory=dict)
|
|
user: dict[str, Any] = field(default_factory=dict)
|
|
is_admin: bool = False
|
|
thread_id: str | None = None
|
|
models: list[Any] | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
import httpx
|
|
|
|
# Every target is loopback: never route it through an HTTP(S)_PROXY from the shell.
|
|
self.http = httpx.Client(timeout=httpx.Timeout(self.timeout, read=max(self.timeout, 60.0)), follow_redirects=False, trust_env=False)
|
|
|
|
# -- request helpers -----------------------------------------------------
|
|
|
|
def headers(self, *, mutating: bool = False) -> dict[str, str]:
|
|
headers: dict[str, str] = {}
|
|
if self.cookies:
|
|
headers["Cookie"] = "; ".join(f"{name}={value}" for name, value in self.cookies.items())
|
|
if mutating and "csrf_token" in self.cookies:
|
|
headers["X-CSRF-Token"] = self.cookies["csrf_token"]
|
|
return headers
|
|
|
|
def request(self, method: str, base: str, path: str, *, expect: Iterable[int] = (200,), session: bool = True, **kwargs: Any):
|
|
mutating = method.upper() in {"POST", "PUT", "PATCH", "DELETE"}
|
|
headers = {**(self.headers(mutating=mutating) if session else {}), **kwargs.pop("headers", {})}
|
|
response = self.http.request(method, f"{base}{path}", headers=headers, **kwargs)
|
|
self.http.cookies.clear() # cookies travel explicitly, never through the jar
|
|
if response.status_code not in tuple(expect):
|
|
raise CheckFailed(f"{method} {base}{path} -> HTTP {response.status_code}: {response.text[:300]}")
|
|
return response
|
|
|
|
@property
|
|
def a(self) -> str:
|
|
return self.gateways[0][1]
|
|
|
|
@property
|
|
def b(self) -> str:
|
|
return self.gateways[1][1]
|
|
|
|
# -- session ---------------------------------------------------------------
|
|
|
|
def _adopt_session(self, response) -> None:
|
|
for name in ("access_token", "csrf_token"):
|
|
value = response.cookies.get(name)
|
|
if value:
|
|
self.cookies[name] = value
|
|
self.http.cookies.clear()
|
|
if "access_token" not in self.cookies:
|
|
raise CheckFailed(f"{response.request.url} set no access_token cookie")
|
|
|
|
def ensure_session(self) -> str:
|
|
"""Log in as the harness's throwaway user, creating it on first use."""
|
|
creds_path = self.state_dir / "check-user.json"
|
|
if creds_path.is_file():
|
|
creds = json.loads(creds_path.read_text(encoding="utf-8"))
|
|
response = self.request("POST", self.a, "/api/v1/auth/login/local", data={"username": creds["email"], "password": creds["password"]}, expect=(200, 401))
|
|
if response.status_code == 200:
|
|
self._adopt_session(response)
|
|
self.is_admin = bool(creds.get("admin"))
|
|
return f"logged in as {creds['email']} on A"
|
|
creds = {"email": f"mi-check-{secrets.token_hex(4)}@example.com", "password": secrets.token_urlsafe(18)}
|
|
setup = self.request("GET", self.a, "/api/v1/auth/setup-status").json()
|
|
if setup.get("needs_setup"):
|
|
response = self.request("POST", self.a, "/api/v1/auth/initialize", json=creds, expect=(201,))
|
|
creds["admin"] = True
|
|
else:
|
|
response = self.request("POST", self.a, "/api/v1/auth/register", json=creds, expect=(201,))
|
|
creds["admin"] = False
|
|
self._adopt_session(response)
|
|
self.is_admin = creds["admin"]
|
|
_write_private_text(creds_path, json.dumps(creds))
|
|
role = "admin" if self.is_admin else "user"
|
|
return f"created throwaway {role} {creds['email']} on A (credentials: {creds_path})"
|
|
|
|
|
|
def _check_health(h: Harness) -> str:
|
|
seen = []
|
|
for name, base in h.gateways:
|
|
body = h.request("GET", base, "/health/ready").json()
|
|
if body.get("status") != "ready" or body.get("stream_bridge") != "ok":
|
|
raise CheckFailed(f"{name} /health/ready: {body}")
|
|
seen.append(f"{name}: database={body.get('database')} stream_bridge={body.get('stream_bridge')}")
|
|
return "; ".join(seen)
|
|
|
|
|
|
def _check_session(h: Harness) -> str:
|
|
created = h.ensure_session()
|
|
me_a = h.request("GET", h.a, "/api/v1/auth/me").json()
|
|
me_b = h.request("GET", h.b, "/api/v1/auth/me").json()
|
|
if me_a.get("id") != me_b.get("id"):
|
|
raise CheckFailed(f"A sees user {me_a.get('id')}, B sees {me_b.get('id')}")
|
|
h.user = me_a
|
|
return f"{created}; B accepts A's session (shared AUTH_JWT_SECRET)"
|
|
|
|
|
|
def _check_internal_token(h: Harness) -> str:
|
|
if not h.internal_token:
|
|
raise CheckSkipped("no DEER_FLOW_INTERNAL_AUTH_TOKEN in the secrets file")
|
|
bodies = []
|
|
for _name, base in h.gateways:
|
|
# Negative control first: without a session, a wrong token must be refused.
|
|
h.request("GET", base, "/api/models", session=False, headers={"X-DeerFlow-Internal-Token": "not-the-token"}, expect=(401,))
|
|
bodies.append(h.request("GET", base, "/api/models", session=False, headers={"X-DeerFlow-Internal-Token": h.internal_token}).json())
|
|
models = bodies[0].get("models", []) if isinstance(bodies[0], dict) else []
|
|
h.models = models
|
|
return f"both Gateways accept the shared DEER_FLOW_INTERNAL_AUTH_TOKEN ({len(models)} model(s) configured)"
|
|
|
|
|
|
def _require_thread(h: Harness) -> str:
|
|
if h.thread_id is None:
|
|
raise CheckSkipped("no thread (thread check did not pass)")
|
|
return h.thread_id
|
|
|
|
|
|
def _check_thread(h: Harness) -> str:
|
|
created = h.request("POST", h.a, "/api/threads", json={"metadata": {"source": "dev_multi_instance check"}}).json()
|
|
thread_id = created["thread_id"]
|
|
fetched = h.request("GET", h.b, f"/api/threads/{thread_id}").json()
|
|
if fetched.get("thread_id") != thread_id:
|
|
raise CheckFailed(f"B returned {fetched}")
|
|
h.thread_id = thread_id
|
|
return f"thread {thread_id} created on A is readable on B"
|
|
|
|
|
|
def _check_upload(h: Harness) -> str:
|
|
thread_id = _require_thread(h)
|
|
name = f"mi-check-{secrets.token_hex(4)}.txt"
|
|
payload = f"multi-instance upload {secrets.token_hex(16)}\n".encode()
|
|
uploaded = h.request("POST", h.a, f"/api/threads/{thread_id}/uploads", files={"files": (name, payload, "text/plain")}).json()
|
|
info = next((item for item in uploaded.get("files", []) if item.get("filename") == name), None)
|
|
if info is None:
|
|
raise CheckFailed(f"A did not report {name}: {uploaded}")
|
|
listed = h.request("GET", h.b, f"/api/threads/{thread_id}/uploads/list").json()
|
|
if name not in {item.get("filename") for item in listed.get("files", [])}:
|
|
raise CheckFailed(f"B does not list {name}: {listed}")
|
|
body = h.request("GET", h.b, info["artifact_url"]).content
|
|
if body != payload:
|
|
raise CheckFailed(f"B served {len(body)} bytes that differ from the {len(payload)} uploaded on A")
|
|
return f"uploaded on A; B lists it and serves identical bytes via {info['artifact_url']}"
|
|
|
|
|
|
def _check_skill_toggle(h: Harness) -> str:
|
|
if not h.is_admin:
|
|
raise CheckSkipped("the check user is not an admin (an admin already existed); PUT /api/skills is admin-only")
|
|
skills = h.request("GET", h.a, "/api/skills").json().get("skills", [])
|
|
skill = next((s for s in skills if s.get("category") == "public"), None)
|
|
if skill is None:
|
|
raise CheckSkipped("no public skill to toggle")
|
|
name, original = skill["name"], bool(skill.get("enabled", True))
|
|
h.request("PUT", h.a, f"/api/skills/{name}", json={"enabled": not original})
|
|
try:
|
|
started = time.monotonic()
|
|
deadline = started + 30
|
|
while True:
|
|
seen = {s["name"]: s.get("enabled") for s in h.request("GET", h.b, "/api/skills").json().get("skills", [])}
|
|
if seen.get(name) is (not original):
|
|
return f"skill {name!r} toggled on A is {'enabled' if not original else 'disabled'} on B after {time.monotonic() - started:.1f}s (restored)"
|
|
if time.monotonic() > deadline:
|
|
raise CheckFailed(f"B still reports {name!r} enabled={seen.get(name)} 30s after A toggled it")
|
|
time.sleep(1)
|
|
finally:
|
|
h.request("PUT", h.a, f"/api/skills/{name}", json={"enabled": original})
|
|
|
|
|
|
def _check_nginx(h: Harness) -> str:
|
|
if not h.nginx_url:
|
|
raise CheckSkipped("nginx is not running (up --no-nginx, or nginx not installed)")
|
|
expected = {base.split("://", 1)[1] for _name, base in h.gateways}
|
|
upstreams = set()
|
|
for _ in range(2 * len(h.gateways) + 2):
|
|
response = h.request("GET", h.nginx_url, "/health")
|
|
upstreams.add(response.headers.get("x-deerflow-upstream", ""))
|
|
if not expected <= upstreams:
|
|
raise CheckFailed(f"nginx reached {sorted(upstreams)}, expected every Gateway {sorted(expected)}")
|
|
detail = f"round-robin reached {', '.join(sorted(expected))}"
|
|
if h.thread_id:
|
|
for _ in range(len(h.gateways) * 2):
|
|
h.request("GET", h.nginx_url, f"/api/threads/{h.thread_id}")
|
|
detail += f"; thread {h.thread_id} readable through every upstream"
|
|
return detail
|
|
|
|
|
|
def bounded_lines(lines: Iterable[str], *, deadline: float, what: str, clock: Callable[[], float] = time.monotonic) -> Iterator[str]:
|
|
"""Yield raw SSE lines until *deadline*, counting comment heartbeats too.
|
|
|
|
The production stream keeps an idle live run open with ``: heartbeat``
|
|
comments, which the event parser drops and which also reset httpx's read
|
|
timeout, so the budget must be enforced per raw line, not per event.
|
|
"""
|
|
for line in lines:
|
|
if clock() > deadline:
|
|
raise CheckFailed(f"{what} exceeded its time budget without reaching `end`")
|
|
yield line
|
|
|
|
|
|
def _read_stream(h: Harness, base: str, path: str, *, last_event_id: str | None = None, budget: float = 90.0) -> list[SSEEvent]:
|
|
headers = {**h.headers(), "Accept": "text/event-stream"}
|
|
if last_event_id:
|
|
headers["Last-Event-ID"] = last_event_id
|
|
events: list[SSEEvent] = []
|
|
deadline = time.monotonic() + budget
|
|
try:
|
|
with h.http.stream("GET", f"{base}{path}", headers=headers) as response:
|
|
if response.status_code != 200:
|
|
response.read()
|
|
raise CheckFailed(f"GET {base}{path} -> HTTP {response.status_code}: {response.text[:300]}")
|
|
for event in parse_sse_events(bounded_lines(response.iter_lines(), deadline=deadline, what=f"GET {base}{path} ({budget:g}s budget)")):
|
|
events.append(event)
|
|
if event.event == "end":
|
|
break
|
|
finally:
|
|
h.http.cookies.clear()
|
|
return events
|
|
|
|
|
|
def _check_sse_resume(h: Harness) -> str:
|
|
thread_id = _require_thread(h)
|
|
if not h.with_llm:
|
|
# Only a confirmed empty model list makes the run free (it fails before any LLM call).
|
|
if h.models is None:
|
|
raise CheckSkipped("model discovery did not succeed, so a run might call an LLM; rerun with `check --with-llm`")
|
|
if h.models:
|
|
raise CheckSkipped(f"{len(h.models)} model(s) configured: a run would call the LLM; rerun with `check --with-llm`")
|
|
body = {"assistant_id": "lead_agent", "input": {"messages": [{"role": "user", "content": "Reply with the single word: pong"}]}}
|
|
run = h.request("POST", h.a, f"/api/threads/{thread_id}/runs", json=body).json()
|
|
run_id = run["run_id"]
|
|
path = f"/api/threads/{thread_id}/runs/{run_id}/join"
|
|
full = _read_stream(h, h.b, path)
|
|
ids = [event.id for event in full if event.id]
|
|
if not full or full[-1].event != "end":
|
|
raise CheckFailed(f"joining on B did not reach `end`: {[e.event for e in full]}")
|
|
if len(ids) < 2:
|
|
raise CheckSkipped(f"run {run_id} produced only {len(ids)} event id(s); nothing to resume from")
|
|
resumed = _read_stream(h, h.a, path, last_event_id=ids[0])
|
|
resumed_ids = [event.id for event in resumed if event.id]
|
|
if resumed_ids != ids[1:]:
|
|
raise CheckFailed(f"resuming on A after {ids[0]} returned ids {resumed_ids}, expected {ids[1:]}")
|
|
status = h.request("GET", h.b, f"/api/threads/{thread_id}/runs/{run_id}").json().get("status")
|
|
kind = "no-model (fails fast, no LLM call)" if h.models == [] else "LLM"
|
|
return f"{kind} run {run_id} created on A (status {status}); B replayed {len(full)} events; A resumed from Last-Event-ID {ids[0]} with the remaining {len(resumed_ids)}"
|
|
|
|
|
|
CHECKS: list[tuple[str, Callable[[Harness], str]]] = [
|
|
("health/ready on both (stream_bridge ok)", _check_health),
|
|
("session from A accepted by B", _check_session),
|
|
("shared internal auth token", _check_internal_token),
|
|
("thread created on A visible on B", _check_thread),
|
|
("upload on A, list + download on B", _check_upload),
|
|
("skill toggle on A seen by B", _check_skill_toggle),
|
|
("nginx balances both Gateways", _check_nginx),
|
|
("SSE join on B, Last-Event-ID resume on A", _check_sse_resume),
|
|
]
|
|
|
|
MANUAL_CHECKS = (
|
|
"`stop b --kill` while A has an active run: A's run must not turn `error`",
|
|
"cancel a run on B from A: takes effect within about one heartbeat (~10s)",
|
|
"change an MCP filesystem server's args on A: B's local-bash allowlist follows",
|
|
"edit a skill on A: B's system-prompt skills section changes within 30s",
|
|
"IM channel gates (one leader per platform, failover, runtime-config): once channel leader election lands",
|
|
)
|
|
|
|
|
|
def run_checks(h: Harness, *, out=sys.stdout) -> list[CheckResult]:
|
|
results: list[CheckResult] = []
|
|
for name, check in CHECKS:
|
|
try:
|
|
result = CheckResult(name, PASS, check(h))
|
|
except CheckSkipped as exc:
|
|
result = CheckResult(name, SKIP, str(exc))
|
|
except CheckFailed as exc:
|
|
result = CheckResult(name, FAIL, str(exc))
|
|
except Exception as exc: # connection errors, unexpected payloads
|
|
result = CheckResult(name, FAIL, f"{type(exc).__name__}: {exc}")
|
|
results.append(result)
|
|
print(f"[{result.status}] {result.name}: {result.detail}", file=out, flush=True)
|
|
counts = {status: sum(1 for r in results if r.status == status) for status in (PASS, FAIL, SKIP)}
|
|
print(f"\n{counts[PASS]} passed, {counts[FAIL]} failed, {counts[SKIP]} skipped", file=out)
|
|
print('Still manual (backend/docs/CONFIGURATION.md, "Local two-Gateway harness"):', file=out)
|
|
for item in MANUAL_CHECKS:
|
|
print(f" - {item}", file=out)
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _parse_gateway(value: str) -> tuple[str, str]:
|
|
name, sep, url = value.partition("=")
|
|
if not sep or not name or not url:
|
|
raise argparse.ArgumentTypeError("expected NAME=URL, e.g. A=http://127.0.0.1:8001")
|
|
return name, url.rstrip("/")
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
render = sub.add_parser("render-config", help="write the multi-instance config.yaml")
|
|
render.add_argument("--base", required=True, type=Path)
|
|
render.add_argument("--out", required=True, type=Path)
|
|
render.add_argument("--database-url", required=True)
|
|
render.add_argument("--redis-url", required=True)
|
|
render.add_argument("--state-dir", required=True, type=Path)
|
|
render.add_argument("--keep-channels", action="store_true")
|
|
|
|
secrets_cmd = sub.add_parser("secrets", help="create the shared secrets file if missing")
|
|
secrets_cmd.add_argument("--out", required=True, type=Path)
|
|
|
|
nginx = sub.add_parser("nginx-conf", help="write the round-robin nginx config")
|
|
nginx.add_argument("--out", required=True, type=Path)
|
|
nginx.add_argument("--state-dir", required=True, type=Path)
|
|
nginx.add_argument("--listen-port", required=True, type=int)
|
|
nginx.add_argument("--gateway-port", required=True, type=int, action="append")
|
|
nginx.add_argument("--frontend-port", type=int, default=3000)
|
|
|
|
check = sub.add_parser("check", help="run the cross-instance checks")
|
|
check.add_argument("--gateway", required=True, type=_parse_gateway, action="append", help="NAME=URL; pass exactly two")
|
|
check.add_argument("--nginx", default=None)
|
|
check.add_argument("--state-dir", required=True, type=Path)
|
|
check.add_argument("--with-llm", action="store_true", help="allow checks that start a run on a configured model")
|
|
check.add_argument("--timeout", type=float, default=15.0)
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.command == "render-config":
|
|
for note in render_config(args.base, args.out, database_url=args.database_url, redis_url=args.redis_url, state_dir=args.state_dir, keep_channels=args.keep_channels):
|
|
print(f" note: {note}")
|
|
return 0
|
|
if args.command == "secrets":
|
|
ensure_secrets_file(args.out)
|
|
return 0
|
|
if args.command == "nginx-conf":
|
|
_write_private_text(args.out, render_nginx_conf(listen_port=args.listen_port, gateway_ports=args.gateway_port, frontend_port=args.frontend_port, state_dir=args.state_dir))
|
|
return 0
|
|
if args.command == "check":
|
|
if len(args.gateway) != 2:
|
|
parser.error("check needs exactly two --gateway NAME=URL values")
|
|
secrets_path = args.state_dir / "secrets.env"
|
|
token = read_env_file(secrets_path).get("DEER_FLOW_INTERNAL_AUTH_TOKEN", "") if secrets_path.is_file() else ""
|
|
harness = Harness(gateways=args.gateway, nginx_url=(args.nginx or "").rstrip("/") or None, state_dir=args.state_dir, with_llm=args.with_llm, timeout=args.timeout, internal_token=token)
|
|
return exit_code(run_checks(harness))
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|