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>
706 lines
29 KiB
Python
706 lines
29 KiB
Python
"""Tests for the two-Gateway local harness (``scripts/dev_multi_instance.{sh,py}``).
|
|
|
|
The shell entry point owns containers and processes; the Python helper owns the
|
|
parts that decide whether the pair can start at all. The key contract pinned
|
|
here: the generated ``config.yaml`` passes the *real* multi-instance startup
|
|
gate (``app.gateway.deps._enforce_postgres_for_multi_worker``) for any base
|
|
config, while leaving the developer's other sections untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from app.gateway.deps import _enforce_postgres_for_multi_worker, _validate_memory_retrieval_index
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.deployment_config import MULTI_INSTANCE_ENV_VAR, multi_instance_declaration
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
HELPER_PATH = REPO_ROOT / "scripts" / "dev_multi_instance.py"
|
|
SHELL_PATH = REPO_ROOT / "scripts" / "dev_multi_instance.sh"
|
|
|
|
_spec = importlib.util.spec_from_file_location("deerflow_dev_multi_instance", HELPER_PATH)
|
|
assert _spec is not None and _spec.loader is not None
|
|
mi = importlib.util.module_from_spec(_spec)
|
|
sys.modules[_spec.name] = mi # dataclasses resolve string annotations through sys.modules
|
|
_spec.loader.exec_module(mi)
|
|
|
|
DATABASE_URL = "postgresql://deerflow:secret@127.0.0.1:55432/deerflow"
|
|
REDIS_URL = "redis://127.0.0.1:56379/0"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_gate_env(monkeypatch):
|
|
"""The gate also reads worker-count and declaration variables from the shell."""
|
|
for name in ("GATEWAY_WORKERS", "WEB_CONCURRENCY", MULTI_INSTANCE_ENV_VAR, "DEER_FLOW_STREAM_BRIDGE_REDIS_URL", mi.RETRIEVAL_INDEX_ENV_VAR):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
SANDBOX_PREFIX = "deer-flow-mi-0123456789-sandbox"
|
|
|
|
|
|
def _overlay(base: dict, **kwargs):
|
|
return mi.build_multi_instance_config(base, database_url=DATABASE_URL, redis_url=REDIS_URL, sandbox_container_prefix=SANDBOX_PREFIX, **kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Overlay semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_overlay_sets_every_multi_instance_prerequisite():
|
|
config, _notes = _overlay({})
|
|
|
|
assert config["deployment"]["multi_instance"] is True
|
|
assert config["database"]["backend"] == "postgres"
|
|
assert config["database"]["postgres_url"] == DATABASE_URL
|
|
assert config["run_events"]["backend"] == "db"
|
|
assert config["run_ownership"]["heartbeat_enabled"] is True
|
|
assert config["stream_bridge"] == {"type": "redis", "redis_url": REDIS_URL}
|
|
# One shared file, a per-process index directory chosen by the environment.
|
|
assert config["memory"]["backend_config"]["retrieval_index_path"] == f"${mi.RETRIEVAL_INDEX_ENV_VAR}"
|
|
|
|
|
|
def test_overlay_preserves_unrelated_base_sections_without_mutating_the_base():
|
|
base = {
|
|
"config_version": 57,
|
|
"models": [{"name": "m1", "use": "langchain_openai:ChatOpenAI", "model": "gpt-x"}],
|
|
"tools": [{"name": "web_search", "use": "deerflow.community.ddg_search.tools:web_search_tool"}],
|
|
"database": {"backend": "sqlite", "sqlite_dir": ".deer-flow/data", "pool_recycle": 120},
|
|
"run_ownership": {"lease_seconds": 15, "grace_seconds": 5, "heartbeat_enabled": False},
|
|
"run_events": {"backend": "memory", "max_trace_content": 2048},
|
|
"memory": {"enabled": True, "backend_config": {"debounce_seconds": 5, "storage_path": ""}},
|
|
"scheduler": {"enabled": False, "multi_instance": False},
|
|
}
|
|
snapshot = yaml.safe_dump(base)
|
|
|
|
config, _notes = _overlay(base)
|
|
|
|
assert yaml.safe_dump(base) == snapshot, "the base mapping must not be mutated"
|
|
assert config["config_version"] == 57
|
|
assert config["models"] == base["models"]
|
|
assert config["tools"] == base["tools"]
|
|
assert config["database"]["pool_recycle"] == 120
|
|
assert config["run_ownership"]["lease_seconds"] == 15
|
|
assert config["run_ownership"]["grace_seconds"] == 5
|
|
assert config["run_events"]["max_trace_content"] == 2048
|
|
assert config["memory"]["backend_config"]["debounce_seconds"] == 5
|
|
# A disabled scheduler is left alone.
|
|
assert config["scheduler"] == {"enabled": False, "multi_instance": False}
|
|
|
|
|
|
def test_overlay_neutralizes_settings_the_gate_refuses():
|
|
base = {
|
|
"tools": [
|
|
{"name": "web_search", "use": "deerflow.community.ddg_search.tools:web_search_tool"},
|
|
{"name": "browser_navigate", "use": "deerflow.community.browser_automation.tools:browser_navigate_tool"},
|
|
{"name": "browser_click", "use": "deerflow.community.browser_automation.tools:browser_click_tool"},
|
|
],
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider", "ownership": {"type": "memory"}},
|
|
"scheduler": {"enabled": True},
|
|
"checkpointer": {"type": "sqlite", "connection_string": "checkpoints.db"},
|
|
"channels": {
|
|
"langgraph_url": "http://localhost:8001/api",
|
|
"session": {"assistant_id": "lead_agent"},
|
|
"telegram": {"enabled": True, "bot_token": "$TELEGRAM_BOT_TOKEN"},
|
|
"slack": {"enabled": False},
|
|
},
|
|
}
|
|
|
|
config, notes = _overlay(base)
|
|
|
|
assert [tool["name"] for tool in config["tools"]] == ["web_search"]
|
|
assert config["sandbox"]["ownership"] == {"type": "redis", "redis_url": REDIS_URL}
|
|
assert config["sandbox"]["use"] == "deerflow.sandbox.local:LocalSandboxProvider"
|
|
assert "container_prefix" not in config["sandbox"], "only the local AIO provider names containers"
|
|
assert config["scheduler"] == {"enabled": True, "multi_instance": True}
|
|
assert "checkpointer" not in config
|
|
assert config["channels"]["telegram"]["enabled"] is False
|
|
assert config["channels"]["telegram"]["bot_token"] == "$TELEGRAM_BOT_TOKEN"
|
|
assert config["channels"]["slack"]["enabled"] is False
|
|
assert config["channels"]["langgraph_url"] == "http://localhost:8001/api"
|
|
joined = "\n".join(notes)
|
|
for fragment in ("browser_navigate", "sandbox.ownership", "scheduler.multi_instance", "checkpointer", "telegram"):
|
|
assert fragment in joined
|
|
|
|
|
|
def test_overlay_keeps_channels_when_asked():
|
|
config, _notes = _overlay({"channels": {"telegram": {"enabled": True}}}, keep_channels=True)
|
|
|
|
assert config["channels"]["telegram"]["enabled"] is True
|
|
|
|
|
|
def test_overlay_drops_a_postgres_checkpointer_pointing_at_the_developers_database():
|
|
# An explicit checkpointer section wins over database.postgres_url for LangGraph
|
|
# checkpoints and the Store, so keeping it would write into the real database.
|
|
base = {"checkpointer": {"type": "postgres", "connection_string": "postgresql://me@db.internal:5432/prod"}}
|
|
|
|
config, notes = _overlay(base)
|
|
|
|
assert "checkpointer" not in config
|
|
assert any("checkpointer" in note for note in notes)
|
|
|
|
|
|
def test_overlay_points_explicit_sandbox_ownership_at_the_harness_redis():
|
|
base = {
|
|
"sandbox": {
|
|
"use": "deerflow.sandbox.local:LocalSandboxProvider",
|
|
"ownership": {"type": "redis", "redis_url": "redis://prod-redis:6379/3", "renewal_interval_seconds": 7.5, "ttl_multiplier": 3},
|
|
}
|
|
}
|
|
|
|
config, notes = _overlay(base)
|
|
|
|
assert config["sandbox"]["ownership"] == {"type": "redis", "redis_url": REDIS_URL, "renewal_interval_seconds": 7.5, "ttl_multiplier": 3}
|
|
assert any("sandbox.ownership" in note for note in notes)
|
|
|
|
|
|
def test_overlay_gives_local_aio_sandboxes_a_harness_container_prefix():
|
|
base = {"sandbox": {"use": "deerflow.community.aio_sandbox:AioSandboxProvider", "container_prefix": "deer-flow-sandbox"}}
|
|
|
|
config, notes = _overlay(base)
|
|
|
|
assert config["sandbox"]["container_prefix"] == SANDBOX_PREFIX
|
|
assert any("container_prefix" in note for note in notes)
|
|
# Omitted prefix (the provider default) is replaced too.
|
|
config, _notes = _overlay({"sandbox": {"use": "deerflow.community.aio_sandbox:AioSandboxProvider"}})
|
|
assert config["sandbox"]["container_prefix"] == SANDBOX_PREFIX
|
|
|
|
|
|
def test_overlay_leaves_provisioner_backed_aio_prefixes_alone():
|
|
base = {"sandbox": {"use": "deerflow.community.aio_sandbox:AioSandboxProvider", "provisioner_url": "http://provisioner:8002"}}
|
|
|
|
config, _notes = _overlay(base)
|
|
|
|
assert "container_prefix" not in config["sandbox"]
|
|
|
|
|
|
def test_sandbox_container_prefix_is_stable_per_state_dir(tmp_path):
|
|
first = mi.sandbox_container_prefix(tmp_path / "a")
|
|
|
|
assert first == mi.sandbox_container_prefix(tmp_path / "a")
|
|
assert first != mi.sandbox_container_prefix(tmp_path / "b")
|
|
assert first.startswith("deer-flow-mi-") and first.endswith("-sandbox")
|
|
assert all(ch.isalnum() or ch in "-_." for ch in first)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"memory",
|
|
[
|
|
{"backend_config": {"storage_path": "/srv/real-memory"}},
|
|
{"storage_path": "/srv/real-memory"}, # legacy pre-abstraction spelling, auto-migrated on load
|
|
],
|
|
)
|
|
def test_overlay_keeps_deermem_data_inside_the_harness_home(memory):
|
|
config, notes = _overlay({"memory": memory})
|
|
|
|
assert "storage_path" not in config["memory"]
|
|
assert "storage_path" not in config["memory"]["backend_config"]
|
|
assert any("storage_path" in note for note in notes)
|
|
|
|
|
|
def test_overlay_keeps_other_data_roots_inside_the_harness():
|
|
base = {
|
|
"blob_storage": {"enabled": True, "backend": "local_fs", "backend_config": {"root": "/mnt/shared/deerflow-blobs"}},
|
|
"database": {"checkpoint_cache": {"type": "redis", "redis_url": "redis://prod-redis:6379/1", "max_entries": 64}},
|
|
}
|
|
|
|
config, notes = _overlay(base)
|
|
|
|
assert "root" not in config["blob_storage"]["backend_config"]
|
|
assert config["database"]["checkpoint_cache"] == {"type": "redis", "redis_url": REDIS_URL, "max_entries": 64}
|
|
joined = "\n".join(notes)
|
|
assert "blob_storage" in joined and "checkpoint_cache" in joined
|
|
|
|
|
|
def test_overlay_leaves_non_deermem_memory_backends_alone_but_says_so():
|
|
config, notes = _overlay({"memory": {"manager_class": "mem0", "backend_config": {"base_url": "https://mem0.example"}}})
|
|
|
|
assert config["memory"]["backend_config"] == {"base_url": "https://mem0.example"}
|
|
assert any("mem0" in note for note in notes)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The generated file against the real loader and startup gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_generated(tmp_path: Path, monkeypatch, base_path: Path, *, index_dir: Path) -> AppConfig:
|
|
out = tmp_path / "state" / "config.yaml"
|
|
extensions = tmp_path / "extensions_config.json"
|
|
extensions.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions))
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "home"))
|
|
monkeypatch.setenv(mi.RETRIEVAL_INDEX_ENV_VAR, str(index_dir))
|
|
mi.render_config(base_path, out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path / "state")
|
|
return AppConfig.from_file(str(out))
|
|
|
|
|
|
def test_generated_config_from_the_example_passes_the_real_startup_gate(tmp_path, monkeypatch, caplog):
|
|
config = _load_generated(tmp_path, monkeypatch, REPO_ROOT / "config.example.yaml", index_dir=tmp_path / "index-a")
|
|
|
|
assert multi_instance_declaration(config) is not None
|
|
_enforce_postgres_for_multi_worker(config) # must not raise SystemExit
|
|
|
|
with caplog.at_level(logging.WARNING, logger="app.gateway.deps"):
|
|
_validate_memory_retrieval_index(config)
|
|
assert "retrieval index" not in caplog.text, "each process must keep its index outside the shared storage_path"
|
|
assert config.memory.backend_config["retrieval_index_path"] == str(tmp_path / "index-a")
|
|
|
|
|
|
def test_generated_config_from_a_hostile_base_passes_the_real_startup_gate(tmp_path, monkeypatch):
|
|
base_path = tmp_path / "base.yaml"
|
|
base_path.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"config_version": 1,
|
|
"models": [],
|
|
"tools": [{"name": "browser_navigate", "use": "deerflow.community.browser_automation.tools:browser_navigate_tool", "group": "web"}],
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider", "ownership": {"type": "memory"}},
|
|
"scheduler": {"enabled": True},
|
|
"database": {"backend": "sqlite", "sqlite_dir": ".deer-flow/data"},
|
|
"run_events": {"backend": "jsonl"},
|
|
"stream_bridge": {"type": "memory"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
|
|
with pytest.raises(SystemExit):
|
|
# Sanity: the base itself is refused under a multi-instance declaration.
|
|
_enforce_postgres_for_multi_worker(AppConfig.from_file(str(base_path)))
|
|
monkeypatch.delenv(MULTI_INSTANCE_ENV_VAR)
|
|
|
|
config = _load_generated(tmp_path, monkeypatch, base_path, index_dir=tmp_path / "index-b")
|
|
|
|
_enforce_postgres_for_multi_worker(config)
|
|
|
|
|
|
def test_each_process_resolves_its_own_retrieval_index(tmp_path, monkeypatch):
|
|
out = tmp_path / "config.yaml"
|
|
mi.render_config(REPO_ROOT / "config.example.yaml", out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path)
|
|
raw = yaml.safe_load(out.read_text(encoding="utf-8"))
|
|
resolved = []
|
|
for name in ("a", "b"):
|
|
monkeypatch.setenv(mi.RETRIEVAL_INDEX_ENV_VAR, str(tmp_path / "index" / name))
|
|
resolved.append(AppConfig.resolve_env_variables(raw)["memory"]["backend_config"]["retrieval_index_path"])
|
|
|
|
assert resolved[0] != resolved[1]
|
|
|
|
|
|
def test_render_config_writes_a_private_file_with_a_generated_header(tmp_path):
|
|
out = tmp_path / "nested" / "config.yaml"
|
|
|
|
notes = mi.render_config(REPO_ROOT / "config.example.yaml", out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path)
|
|
|
|
assert isinstance(notes, list)
|
|
text = out.read_text(encoding="utf-8")
|
|
assert text.startswith("# Generated by scripts/dev_multi_instance.sh")
|
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600, "the base config may carry model API keys"
|
|
assert yaml.safe_load(text)["deployment"]["multi_instance"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared secrets and nginx front
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_secrets_file_is_private_complete_and_stable(tmp_path):
|
|
path = tmp_path / "secrets.env"
|
|
|
|
first = mi.ensure_secrets_file(path)
|
|
second = mi.ensure_secrets_file(path)
|
|
|
|
assert first == second, "an existing secrets file is reused, never regenerated"
|
|
assert set(first) == set(mi.SHARED_SECRET_NAMES)
|
|
assert all(value for value in first.values())
|
|
assert len(base64.urlsafe_b64decode(first["DEER_FLOW_CREDENTIALS_KEY"])) == 32, "must be a raw Fernet key"
|
|
assert first["AUTH_JWT_SECRET"] != first["DEER_FLOW_INTERNAL_AUTH_TOKEN"]
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
# Shell-sourceable KEY=value lines.
|
|
assert sorted(line.split("=", 1)[0] for line in path.read_text(encoding="utf-8").splitlines() if line and not line.startswith("#")) == sorted(mi.SHARED_SECRET_NAMES)
|
|
|
|
|
|
def test_nginx_conf_balances_both_gateways(tmp_path):
|
|
text = mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=tmp_path)
|
|
|
|
assert "listen 127.0.0.1:2027;" in text
|
|
assert "server 127.0.0.1:8001;" in text
|
|
assert "server 127.0.0.1:8011;" in text
|
|
assert "X-DeerFlow-Upstream $upstream_addr" in text
|
|
assert f'"{tmp_path / "run" / "nginx.pid"}"' in text
|
|
|
|
|
|
def test_nginx_conf_quotes_paths_with_whitespace(tmp_path):
|
|
state = tmp_path / "mi state"
|
|
|
|
text = mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=state)
|
|
|
|
assert f'pid "{state / "run" / "nginx.pid"}";' in text
|
|
assert f'access_log "{state / "logs" / "nginx-access.log"}";' in text
|
|
assert f'proxy_temp_path "{state / "nginx" / "temp" / "proxy"}";' in text
|
|
|
|
|
|
def test_nginx_conf_rejects_paths_nginx_would_interpolate(tmp_path):
|
|
with pytest.raises(ValueError):
|
|
mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001], frontend_port=3000, state_dir=tmp_path / "$HOME")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("nginx") is None, reason="nginx is not installed")
|
|
@pytest.mark.parametrize("state_name", ["state", "mi state"])
|
|
def test_nginx_conf_is_accepted_by_nginx(tmp_path, state_name):
|
|
state = tmp_path / state_name
|
|
for sub in ("run", "logs", "nginx/temp"): # the shell creates these before nginx starts
|
|
(state / sub).mkdir(parents=True)
|
|
conf = state / "nginx" / "nginx.conf"
|
|
conf.write_text(mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=state), encoding="utf-8")
|
|
|
|
result = subprocess.run(["nginx", "-t", "-p", str(state / "nginx"), "-c", str(conf)], capture_output=True, text=True, timeout=30)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_sse_events_keeps_ids_and_event_names():
|
|
lines = [
|
|
": comment",
|
|
"id: 1-0",
|
|
"event: metadata",
|
|
'data: {"run_id": "r"}',
|
|
"",
|
|
"event: heartbeat",
|
|
"data: {}",
|
|
"",
|
|
"id: 2-0",
|
|
"event: end",
|
|
"data: null",
|
|
"",
|
|
]
|
|
|
|
events = list(mi.parse_sse_events(lines))
|
|
|
|
assert [(event.id, event.event) for event in events] == [("1-0", "metadata"), (None, "heartbeat"), ("2-0", "end")]
|
|
assert events[0].data == '{"run_id": "r"}'
|
|
|
|
|
|
def test_check_exit_code_reflects_failures_only():
|
|
assert mi.exit_code([mi.CheckResult("a", mi.PASS, ""), mi.CheckResult("b", mi.SKIP, "")]) == 0
|
|
assert mi.exit_code([mi.CheckResult("a", mi.PASS, ""), mi.CheckResult("b", mi.FAIL, "")]) == 1
|
|
|
|
|
|
def _offline_harness(tmp_path, handler, **kwargs):
|
|
import httpx
|
|
|
|
h = mi.Harness(gateways=[("A", "http://a.test"), ("B", "http://b.test")], nginx_url=None, state_dir=tmp_path, with_llm=False, timeout=5.0, **kwargs)
|
|
h.http = httpx.Client(transport=httpx.MockTransport(handler))
|
|
return h
|
|
|
|
|
|
def test_sse_resume_never_starts_a_run_when_model_discovery_failed(tmp_path):
|
|
def refuse(request):
|
|
raise AssertionError(f"unexpected request {request.method} {request.url}")
|
|
|
|
h = _offline_harness(tmp_path, refuse)
|
|
h.thread_id = "t-1"
|
|
h.models = None # the internal-token check failed or was skipped
|
|
|
|
with pytest.raises(mi.CheckSkipped, match="--with-llm"):
|
|
mi._check_sse_resume(h)
|
|
|
|
|
|
def test_sse_read_enforces_its_budget_while_only_heartbeats_arrive(tmp_path):
|
|
import time as _time
|
|
|
|
def heartbeats():
|
|
# A live idle run: comment heartbeats only, for far longer than the budget.
|
|
for _ in range(300):
|
|
_time.sleep(0.01)
|
|
yield b": heartbeat\n\n"
|
|
|
|
import httpx
|
|
|
|
h = _offline_harness(tmp_path, lambda request: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=heartbeats()))
|
|
started = _time.monotonic()
|
|
|
|
with pytest.raises(mi.CheckFailed, match="budget"):
|
|
mi._read_stream(h, "http://a.test", "/api/threads/t/runs/r/join", budget=0.3)
|
|
|
|
assert _time.monotonic() - started < 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shell entry point smoke tests (no Docker, no processes)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_shell_script_parses():
|
|
result = subprocess.run(["bash", "-n", str(SHELL_PATH)], capture_output=True, text=True, timeout=30)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize("flag", ["--help", "help", "-h"])
|
|
def test_shell_script_help_lists_every_command_without_side_effects(tmp_path, flag):
|
|
env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state"), "PATH": os.environ.get("PATH", "")}
|
|
|
|
result = subprocess.run(["bash", str(SHELL_PATH), flag], capture_output=True, text=True, timeout=30, env=env)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
for command in ("up", "down", "status", "logs", "check", "restart"):
|
|
assert f" {command}" in result.stdout
|
|
assert not (tmp_path / "state").exists(), "help must not create harness state"
|
|
|
|
|
|
def test_shell_script_rejects_unknown_commands(tmp_path):
|
|
env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state")}
|
|
|
|
result = subprocess.run(["bash", str(SHELL_PATH), "frobnicate"], capture_output=True, text=True, timeout=30, env=env)
|
|
|
|
assert result.returncode != 0
|
|
assert "Unknown command" in result.stderr
|
|
|
|
|
|
def test_makefile_exposes_harness_targets():
|
|
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
|
|
|
for target in ("dev-multi", "dev-multi-check", "dev-multi-down"):
|
|
assert f"\n{target}:" in makefile
|
|
assert f"make {target} " in makefile, f"`make help` should list {target}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shell lifecycle against stubbed docker / uv / lsof and a fake Gateway
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_DOCKER_STUB = r"""#!/usr/bin/env bash
|
|
dir="$(cd "$(dirname "$0")" && pwd)"
|
|
state="$dir/containers"
|
|
mkdir -p "$state"
|
|
{ printf 'docker'; printf ' %s' "$@"; printf '\n'; } >>"$dir/docker.log"
|
|
cmd="$1"
|
|
shift
|
|
case "$cmd" in
|
|
info) exit 0 ;;
|
|
inspect)
|
|
fmt=""
|
|
if [ "$1" = "-f" ]; then fmt="$2"; shift 2; fi
|
|
[ -d "$state/$1" ] || exit 1
|
|
case "$fmt" in
|
|
*state-dir*) cat "$state/$1/deerflow.harness.state-dir" 2>/dev/null; echo ;;
|
|
*deerflow.harness*) cat "$state/$1/deerflow.harness" 2>/dev/null; echo ;;
|
|
*) echo running ;;
|
|
esac
|
|
;;
|
|
run)
|
|
name=""
|
|
labels=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--name) name="$2"; shift 2 ;;
|
|
--label) labels="$labels$2
|
|
"; shift 2 ;;
|
|
-e | -p) shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
mkdir -p "$state/$name"
|
|
printf '%s' "$labels" | while IFS= read -r kv; do
|
|
[ -n "$kv" ] && printf '%s' "${kv#*=}" >"$state/$name/${kv%%=*}"
|
|
done
|
|
echo "id-$name"
|
|
;;
|
|
exec)
|
|
case "$*" in *redis-cli*) echo PONG ;; esac
|
|
;;
|
|
rm)
|
|
for arg in "$@"; do
|
|
case "$arg" in -*) ;; *) rm -rf "$state/$arg" ;; esac
|
|
done
|
|
;;
|
|
esac
|
|
exit 0
|
|
"""
|
|
|
|
_UV_STUB = r"""#!/usr/bin/env bash
|
|
dir="$(cd "$(dirname "$0")" && pwd)"
|
|
case "$*" in
|
|
*"print(sys.executable)"*) echo "$dir/python" ;;
|
|
esac
|
|
exit 0
|
|
"""
|
|
|
|
_PYTHON_STUB = r"""#!/usr/bin/env bash
|
|
dir="$(cd "$(dirname "$0")" && pwd)"
|
|
if [ "${1:-}" = "-m" ] && [ "${2:-}" = "uvicorn" ]; then
|
|
port=""
|
|
previous=""
|
|
for arg in "$@"; do
|
|
[ "$previous" = "--port" ] && port="$arg"
|
|
previous="$arg"
|
|
done
|
|
exec "$REAL_PYTHON" "$dir/fake_gateway.py" app.gateway.app:app "$port"
|
|
fi
|
|
exec "$REAL_PYTHON" "$@"
|
|
"""
|
|
|
|
_FAKE_GATEWAY = """import sys
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(b"{}")
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
|
|
HTTPServer(("127.0.0.1", int(sys.argv[2])), Handler).serve_forever()
|
|
"""
|
|
|
|
# Ports listed in busy-from-<port> report busy from that call number on; every
|
|
# other query falls through to the real lsof (or "free" when it is missing).
|
|
_LSOF_STUB = r"""#!/usr/bin/env bash
|
|
dir="$(cd "$(dirname "$0")" && pwd)"
|
|
port=""
|
|
for arg in "$@"; do
|
|
case "$arg" in -iTCP:*) port="${arg#-iTCP:}" ;; esac
|
|
done
|
|
count=$(( $(cat "$dir/lsof-count-$port" 2>/dev/null || echo 0) + 1 ))
|
|
echo "$count" >"$dir/lsof-count-$port"
|
|
if [ -f "$dir/busy-from-$port" ] && [ "$count" -ge "$(cat "$dir/busy-from-$port")" ]; then
|
|
echo 99999
|
|
exit 0
|
|
fi
|
|
[ -n "$REAL_LSOF" ] || exit 1
|
|
exec "$REAL_LSOF" "$@"
|
|
"""
|
|
|
|
|
|
def _free_port() -> int:
|
|
import socket
|
|
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture
|
|
def stubbed_harness(tmp_path):
|
|
if os.name == "nt" or shutil.which("bash") is None or shutil.which("curl") is None:
|
|
pytest.skip("POSIX shell lifecycle test")
|
|
stubs = tmp_path / "stubs"
|
|
stubs.mkdir()
|
|
for name, body in (("docker", _DOCKER_STUB), ("uv", _UV_STUB), ("python", _PYTHON_STUB), ("lsof", _LSOF_STUB), ("fake_gateway.py", _FAKE_GATEWAY)):
|
|
path = stubs / name
|
|
path.write_text(body, encoding="utf-8")
|
|
path.chmod(0o755)
|
|
state = tmp_path / "mi state" # whitespace on purpose
|
|
ports = {"a": _free_port(), "b": _free_port(), "pg": _free_port(), "redis": _free_port()}
|
|
env = {key: value for key, value in os.environ.items() if not key.startswith(("DEERFLOW_MI_", "DEER_FLOW_"))}
|
|
env.update(
|
|
PATH=f"{stubs}{os.pathsep}{os.environ.get('PATH', '')}",
|
|
REAL_PYTHON=sys.executable,
|
|
REAL_LSOF=shutil.which("lsof") or "",
|
|
DEERFLOW_MI_STATE_DIR=str(state),
|
|
DEERFLOW_MI_BASE_CONFIG=str(REPO_ROOT / "config.example.yaml"),
|
|
DEERFLOW_MI_GATEWAY_A_PORT=str(ports["a"]),
|
|
DEERFLOW_MI_GATEWAY_B_PORT=str(ports["b"]),
|
|
DEERFLOW_MI_POSTGRES_PORT=str(ports["pg"]),
|
|
DEERFLOW_MI_REDIS_PORT=str(ports["redis"]),
|
|
DEERFLOW_MI_HEALTH_TIMEOUT="20",
|
|
)
|
|
|
|
def run(*args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["bash", str(SHELL_PATH), *args], capture_output=True, text=True, timeout=120, env=env)
|
|
|
|
yield stubs, state, ports, run
|
|
|
|
# Never leak a fake Gateway, whatever the script did.
|
|
for pid_file in (state / "run").glob("*.pid") if (state / "run").is_dir() else []:
|
|
try:
|
|
os.kill(int(pid_file.read_text().strip()), 9)
|
|
except (ValueError, ProcessLookupError):
|
|
pass
|
|
|
|
|
|
def _port_open(port: int) -> bool:
|
|
import socket
|
|
|
|
with socket.socket() as sock:
|
|
sock.settimeout(0.5)
|
|
return sock.connect_ex(("127.0.0.1", port)) == 0
|
|
|
|
|
|
def _container(stubs: Path, name: str, *, state_dir: str) -> Path:
|
|
path = stubs / "containers" / name
|
|
path.mkdir(parents=True)
|
|
(path / "deerflow.harness").write_text("dev-multi-instance", encoding="utf-8")
|
|
(path / "deerflow.harness.state-dir").write_text(state_dir, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def test_up_tears_everything_down_when_b_loses_its_port_after_preflight(stubbed_harness):
|
|
stubs, state, ports, run = stubbed_harness
|
|
(stubs / f"busy-from-{ports['b']}").write_text("2", encoding="utf-8") # free at preflight, taken when B starts
|
|
|
|
result = run("up", "--skip-install", "--no-nginx")
|
|
|
|
assert result.returncode != 0
|
|
assert f"Port {ports['b']} is in use" in result.stderr
|
|
assert "Gateway A ready" in result.stdout, "A must have started before B failed"
|
|
assert not _port_open(ports["a"]), "Gateway A must be stopped when up fails"
|
|
assert "Terminated" not in result.stderr, "the Gateway is not a job of the script shell; killing it must not print job notices"
|
|
assert not list((stubs / "containers").iterdir()), "both containers must be removed"
|
|
removals = [line for line in (stubs / "docker.log").read_text().splitlines() if line.startswith("docker rm")]
|
|
assert removals and all(line.startswith("docker rm -f -v ") for line in removals), removals
|
|
|
|
|
|
def test_up_refuses_containers_owned_by_another_state_dir(stubbed_harness):
|
|
stubs, state, _ports, run = stubbed_harness
|
|
foreign = _container(stubs, "deerflow-mi-postgres", state_dir="/elsewhere/.deer-flow/multi-instance")
|
|
|
|
result = run("up", "--skip-install", "--no-nginx")
|
|
|
|
assert result.returncode != 0
|
|
assert "/elsewhere/.deer-flow/multi-instance" in result.stderr
|
|
assert foreign.is_dir()
|
|
commands = (stubs / "docker.log").read_text()
|
|
assert "docker rm" not in commands and "docker run" not in commands
|
|
|
|
down = run("down")
|
|
|
|
assert foreign.is_dir(), "down must not remove another harness's container either"
|
|
assert "docker rm" not in (stubs / "docker.log").read_text(), down.stdout + down.stderr
|
|
|
|
|
|
def test_up_replaces_its_own_stale_containers_with_their_volumes(stubbed_harness):
|
|
stubs, state, ports, run = stubbed_harness
|
|
_container(stubs, "deerflow-mi-redis", state_dir=str(state))
|
|
(stubs / f"busy-from-{ports['b']}").write_text("2", encoding="utf-8") # stop after A so the test stays short
|
|
|
|
run("up", "--skip-install", "--no-nginx")
|
|
|
|
log = (stubs / "docker.log").read_text().splitlines()
|
|
first_run = next(i for i, line in enumerate(log) if line.startswith("docker run"))
|
|
assert "docker rm -f -v deerflow-mi-redis" in log[:first_run]
|
|
labelled = [line for line in log if line.startswith("docker run")]
|
|
assert all(f"--label deerflow.harness.state-dir={state}" in line for line in labelled), labelled
|