Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ Docker provides a consistent, isolated environment with all dependencies pre-con
#### Prerequisites

- Docker Desktop or Docker Engine
- Docker Compose **v2.24 or newer** (check with `docker compose version`). The dev
Compose file marks its `env_file` entries optional using the long-form
`path`/`required` syntax; older clients reject it with
`services.gateway.env_file.0 must be a string`. `make docker-start` verifies the
version and tells you to upgrade — direct `docker compose` callers get that raw
message instead.
- pnpm (for caching optimization)

#### Setup Steps
Expand Down Expand Up @@ -43,6 +49,30 @@ Docker provides a consistent, isolated environment with all dependencies pre-con
```
`make docker-start` reads `config.yaml` and starts `provisioner` only for provisioner/Kubernetes sandbox mode.

Prefer this wrapper over invoking Compose yourself: it checks your Compose
version, creates the missing `.env` files, and exports `DEER_FLOW_ROOT`.

If you do run Compose directly, run it **from the repository root** and set
`DEER_FLOW_ROOT` to the absolute path of your checkout. Compose interpolates
that variable into host-side paths (`DEER_FLOW_HOST_BASE_DIR`,
`THREADS_HOST_PATH`) that the AIO and provisioner sandbox modes bind-mount;
leaving it unset renders them as `/backend/.deer-flow`, so those mounts
silently miss your checkout instead of failing:

```bash
# macOS / Linux
DEER_FLOW_ROOT="$PWD" docker compose -f docker/docker-compose-dev.yaml up --build
```

```powershell
# Windows PowerShell
$env:DEER_FLOW_ROOT = (Get-Location).Path
docker compose -f docker/docker-compose-dev.yaml up --build
```

Do not reuse that `-f` path from inside `docker/` — it resolves to
`docker/docker/docker-compose-dev.yaml` and fails with a file-not-found error.

All services will start with hot-reload enabled:
- Frontend changes are automatically reloaded
- Backend changes trigger automatic restart
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ Use the table below as a practical starting point when choosing how to run DeerF

#### Option 1: Docker (Recommended)

Requires Docker Desktop / Docker Engine and **Docker Compose v2.24+**
(`docker compose version`). Older Compose clients cannot parse the optional
`env_file` syntax in `docker/docker-compose-dev.yaml`.

**Development** (hot-reload, source mounts):

```bash
Expand Down
4 changes: 4 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[

#### 方式一:Docker(推荐)

需要 Docker Desktop / Docker Engine,以及 **Docker Compose v2.24+**
(`docker compose version`)。更旧的 Compose 客户端无法解析
`docker/docker-compose-dev.yaml` 里的可选 `env_file` 语法。

**开发模式**(支持热更新,挂载源码):

```bash
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/test_compose_default_bind_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,16 @@ def _bind_address(mapping: str) -> str | None:

# ADDR:HOST:CONTAINER -> bound; HOST:CONTAINER or CONTAINER -> unbound.
return segments[0] if len(segments) >= 3 else None


def test_dev_compose_env_files_are_optional():
"""Missing .env files must not fail `docker compose -f docker/docker-compose-dev.yaml`."""
compose = yaml.safe_load(COMPOSE_PATHS["dev"].read_text(encoding="utf-8"))
expected = {
"provisioner": "../.env",
"frontend": "../frontend/.env",
"gateway": "../.env",
}
for service_name, path in expected.items():
entries = compose["services"][service_name]["env_file"]
assert entries == [{"path": path, "required": False}], f"{service_name} env_file must be optional; got: {entries!r}"
208 changes: 202 additions & 6 deletions backend/tests/test_docker_sandbox_mode_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,213 @@ def test_detect_mode_unknown_provider_falls_back_to_local():
assert _detect_mode_with_config(config) == "local"


@pytest.mark.parametrize("docker_command", ["logs --gateway", "restart"])
def _seed_compose_file(tmp_root: Path) -> None:
"""Give require_compose_file the file it validates."""
(tmp_root / "docker-compose-dev.yaml").write_text("services: {}\n", encoding="utf-8")


def _seed_env_examples(tmp_root: Path) -> None:
"""Provide the templates ensure_env_files copies from."""
(tmp_root / ".env.example").write_text("# test\n", encoding="utf-8")
frontend = tmp_root / "frontend"
frontend.mkdir(exist_ok=True)
(frontend / ".env.example").write_text("# test\n", encoding="utf-8")


def _run_docker_sh(tmp_root: Path, body: str) -> None:
"""Run docker.sh against a temp checkout, stubbing the real Compose version probe.

Keep SCRIPT_DIR at the real scripts/ directory so stop's cleanup-containers.sh
path still resolves; only PROJECT_ROOT / DOCKER_DIR are redirected.
"""
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
require_compose_version() {{ :; }}
{body}
"""
subprocess.check_call([BASH_EXECUTABLE, "-lc", command])


@pytest.mark.parametrize("docker_command", ["logs --gateway", "stop", "restart"])
def test_compose_commands_set_deer_flow_root_before_compose(docker_command):
"""Log and restart commands should resolve mounts from the repository root."""
"""Read-only compose commands should resolve mounts from the repository root."""
with tempfile.TemporaryDirectory() as tmpdir:
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmpdir}'
DOCKER_DIR='{tmpdir}'
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_run_docker_sh(
tmp_root,
f"""
COMPOSE_CMD=capture_compose
capture_compose() {{ test "${{DEER_FLOW_ROOT:-}}" = "$PROJECT_ROOT"; }}
unset DEER_FLOW_ROOT
{docker_command}
""",
)


@pytest.mark.parametrize("docker_command", ["logs --gateway", "stop", "restart"])
def test_read_only_commands_do_not_create_env_files(docker_command):
"""Only start may write configuration; logs/stop/restart must leave a checkout alone."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_seed_env_examples(tmp_root)

_run_docker_sh(tmp_root, f"COMPOSE_CMD=true\n{docker_command}")

assert not (tmp_root / ".env").exists(), f"{docker_command} created .env"
assert not (tmp_root / "frontend" / ".env").exists(), f"{docker_command} created frontend/.env"


@pytest.mark.parametrize("docker_command", ["logs --gateway", "stop", "restart"])
def test_read_only_commands_run_without_env_examples(docker_command):
"""A missing .env.example must never block stopping or inspecting containers."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)

_run_docker_sh(tmp_root, f"COMPOSE_CMD=true\n{docker_command}")


def test_ensure_env_files_copies_from_examples():
"""start's env-file step should create .env files from their examples when missing."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_env_examples(tmp_root)

command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
ensure_env_files
"""
subprocess.check_call([BASH_EXECUTABLE, "-lc", command])

assert (tmp_root / ".env").is_file()
assert (tmp_root / "frontend" / ".env").is_file()
assert (tmp_root / ".env").read_text(encoding="utf-8") == "# test\n"
assert (tmp_root / "frontend" / ".env").read_text(encoding="utf-8") == "# test\n"


def test_ensure_env_files_leaves_existing_env_untouched():
"""ensure_env_files must not overwrite an already-present .env."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_env_examples(tmp_root)
(tmp_root / ".env").write_text("KEEP=me\n", encoding="utf-8")
frontend = tmp_root / "frontend"
(frontend / ".env").write_text("KEEP=frontend\n", encoding="utf-8")

command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
ensure_env_files
"""
subprocess.check_call([BASH_EXECUTABLE, "-lc", command])

assert (tmp_root / ".env").read_text(encoding="utf-8") == "KEEP=me\n"
assert (frontend / ".env").read_text(encoding="utf-8") == "KEEP=frontend\n"


@pytest.mark.parametrize(
("reported_version", "expected_returncode"),
[
("2.23.3", 1),
("2.5.0", 1),
("2.24.0", 0),
("v2.40.2-desktop.1", 0),
("3.0.1", 0),
("", 0), # undetectable: warn, but do not block
],
)
def test_require_compose_version_enforces_minimum(reported_version, expected_returncode):
"""Old clients get our actionable message instead of a raw Compose parser error."""
# Stub both binaries so an empty plugin probe cannot fall through to the
# real hyphenated docker-compose installed on the developer machine.
command = f"""
source '{SCRIPT_PATH}'
docker() {{ echo '{reported_version}'; }}
docker-compose() {{ echo '{reported_version}'; }}
require_compose_version
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)

assert result.returncode == expected_returncode, result.stdout + result.stderr
if expected_returncode != 0:
assert "too old" in result.stdout
assert "docs.docker.com/compose/install" in result.stdout


def test_require_compose_version_falls_back_to_hyphenated_binary():
"""Plugin missing + docker-compose 2.24: version check passes and stop uses that binary.

Regression for the half-fallback where require_compose_version accepted
docker-compose but COMPOSE_CMD stayed hardcoded to `docker compose`.
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
marker = tmp_root / "hyphenated_invoke.txt"

command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
docker() {{
if [ "$1" = compose ]; then
echo "docker: unknown command" >&2
return 1
fi
command docker "$@"
}}
docker-compose() {{
if [ "$1" = version ]; then
echo '2.24.0'
return 0
fi
# Real wrapper ops (down/logs/...) must hit this binary, not `docker compose`.
printf '%s\n' "$*" > '{marker}'
}}
unset DEER_FLOW_ROOT
stop
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)

assert result.returncode == 0, result.stdout + result.stderr
assert marker.is_file(), "stop never invoked docker-compose for the compose operation"
assert "down" in marker.read_text(encoding="utf-8")


def test_require_compose_version_rejects_old_hyphenated_binary():
"""An old docker-compose binary must still fail the floor check."""
command = f"""
source '{SCRIPT_PATH}'
docker() {{
if [ "$1" = compose ]; then
return 1
fi
command docker "$@"
}}
docker-compose() {{ echo '2.23.3'; }}
require_compose_version
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)

assert result.returncode == 1, result.stdout + result.stderr
assert "too old" in result.stdout
39 changes: 35 additions & 4 deletions docker/docker-compose-dev.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# DeerFlow Development Environment
# Usage: docker-compose -f docker-compose-dev.yaml up --build
#
# Requires Docker Compose >= 2.24. The env_file entries below use the long-form
# path/required syntax so a missing ../.env or ../frontend/.env does not abort
# Compose on Windows ("file not found" / "Le fichier spécifique est
# introuvable"). Clients older than 2.24 do not understand that field and
# reject this file with:
# validating docker-compose-dev.yaml: services.gateway.env_file.0 must be a string
# make docker-start checks the version and says so; direct `docker compose`
# callers get the message above instead.
#
# Supported entry: from the repository root, run `make docker-start`. That
# wrapper checks the Compose version, creates missing .env files, exports
# DEER_FLOW_ROOT, and invokes Compose from this directory with a relative
# filename.
#
# Running Compose yourself works too, but export DEER_FLOW_ROOT as the absolute
# checkout path first (see CONTRIBUTING.md for the Windows form). Unset, the
# DEER_FLOW_HOST_BASE_DIR and THREADS_HOST_PATH values below render as
# /backend/.deer-flow, so the provisioner bind-mounts the wrong host paths:
# DEER_FLOW_ROOT="$PWD" docker compose -f docker/docker-compose-dev.yaml up --build
# The -f path resolves against your shell's directory, not against this file:
# from the repository root use docker/docker-compose-dev.yaml; from inside
# docker/ use the bare docker-compose-dev.yaml, as make docker-start does.
#
# Services:
# - nginx: Reverse proxy (port 2026)
Expand Down Expand Up @@ -57,6 +79,12 @@ services:
# On Docker Desktop/OrbStack, use your actual host paths like /Users/username/...
# Set these in your shell before running docker-compose:
# export DEER_FLOW_ROOT=/absolute/path/to/deer-flow
# Deliberately left without a ${DEER_FLOW_ROOT:-...} fallback. The obvious
# candidate, $PWD, is exported by POSIX shells but not by PowerShell or
# cmd, so the default would resolve to an empty string on Windows — the
# platform this variable exists to get right. A wrong-but-plausible host
# path mounts an empty directory instead of failing, so we require the
# caller to be explicit; make docker-start sets it for you.
- THREADS_HOST_PATH=${DEER_FLOW_ROOT}/backend/.deer-flow/threads
# Per-user data base directory for user-scoped skill mounts
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow
Expand All @@ -74,7 +102,8 @@ services:
# The same value must be set on the gateway side via config.yaml sandbox.provisioner_api_key.
- PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-}
env_file:
- ../.env
- path: ../.env

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Please declare or avoid the new Compose 2.24 minimum. The long-form env_file required field is not understood by older Compose v2 clients, so configurations that previously ran make docker-start now fail while parsing this file, before the wrapper-created env files can help. The prerequisites currently list Docker Desktop or Engine without a Compose version floor, and the wrapper has no version check. Either preserve the previous syntax/support or document Compose >= 2.24 and fail early with an actionable version check, including for direct callers.

required: false
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
Expand Down Expand Up @@ -139,7 +168,8 @@ services:
- CI=true
- DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://gateway:8001
env_file:
- ../frontend/.env
- path: ../frontend/.env
required: false
networks:
- deer-flow-dev
restart: unless-stopped
Expand Down Expand Up @@ -212,7 +242,8 @@ services:
- NO_PROXY=${NO_PROXY:-}${NO_PROXY:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,openviking,host.docker.internal
- no_proxy=${no_proxy:-}${no_proxy:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,openviking,host.docker.internal
env_file:
- ../.env
- path: ../.env
required: false
extra_hosts:
# For Linux: map host.docker.internal to host gateway
- "host.docker.internal:host-gateway"
Expand Down
Loading
Loading