deer-flow/backend/tests/test_docker_sandbox_mode_detection.py
YZJF,YCDG,DJLY,ZZZB 851e76661b
fix(docker): don't abort Docker startup when .env is missing (#4956)
* fix(docker): create compose env files and keep Windows compose paths relative

Windows Docker reports a generic file-not-found when env_file targets are missing, or when compose paths are doubled. Make docker-start copy .env examples and invoke compose with filenames relative to docker/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docker): make dev compose env files optional and repair test fixture

Address review feedback on #4956.

[P1] prepare_compose_env aborted before the mocked COMPOSE_CMD in
test_compose_commands_set_deer_flow_root_before_compose, because the
temp root had no compose file or .env examples. Seed them in the
fixture so the preflight reaches the mock.

[P2] .env is gitignored, so a fresh clone has none and a direct
`docker compose -f docker/docker-compose-dev.yaml up --build` aborts on
Windows before scripts/docker.sh can help. Mark the dev env_file entries
`required: false` so a missing .env is not fatal, and document that
direct Compose must be run from the repository root.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docker): declare Compose 2.24 floor and keep non-start commands read-only

Address the second review round on #4956.

- Document Compose >= 2.24 (CONTRIBUTING, README, compose header) and fail
  early from make docker-start with an actionable message; probe both
  `docker compose` and the hyphenated `docker-compose` binary.
- Document DEER_FLOW_ROOT for direct Compose callers (bash + PowerShell);
  leave the variable without a $PWD fallback because PowerShell/cmd do not
  export it.
- Split prepare_compose_env: compose_preflight is shared and read-only;
  ensure_env_files runs only from start.
- Expand tests for version boundaries, hyphenated fallback, env-file
  creation, and read-only stop/logs/restart behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docker): reuse the probed Compose binary for wrapper operations

The version probe could accept a standalone docker-compose install while
COMPOSE_CMD stayed hardcoded to `docker compose`, so preflight passed and
start/logs/stop/restart then failed. Keep the selected executable in
COMPOSE_BIN (array), refresh COMPOSE_CMD from it in the current shell, and
extend the fallback test through an actual stop invocation.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:48:37 +08:00

319 lines
10 KiB
Python

"""Regression tests for docker sandbox mode detection logic."""
from __future__ import annotations
import subprocess
import tempfile
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / "scripts" / "docker.sh"
BASH_CANDIDATES = [
Path(r"C:\Program Files\Git\bin\bash.exe"),
Path(which("bash")) if which("bash") else None,
]
BASH_EXECUTABLE = next(
(str(path) for path in BASH_CANDIDATES if path is not None and path.exists() and "WindowsApps" not in str(path)),
None,
)
if BASH_EXECUTABLE is None:
pytestmark = pytest.mark.skip(reason="bash is required for docker.sh detection tests")
def _detect_mode_with_config(config_content: str) -> str:
"""Write config content into a temp project root and execute detect_sandbox_mode."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
(tmp_root / "config.yaml").write_text(config_content, encoding="utf-8")
command = f"source '{SCRIPT_PATH}' && PROJECT_ROOT='{tmp_root}' && detect_sandbox_mode"
output = subprocess.check_output(
[BASH_EXECUTABLE, "-lc", command],
text=True,
encoding="utf-8",
).strip()
return output
def test_detect_mode_defaults_to_local_when_config_missing():
"""No config file should default to local mode."""
with tempfile.TemporaryDirectory() as tmpdir:
command = f"source '{SCRIPT_PATH}' && PROJECT_ROOT='{tmpdir}' && detect_sandbox_mode"
output = subprocess.check_output(
[BASH_EXECUTABLE, "-lc", command],
text=True,
encoding="utf-8",
).strip()
assert output == "local"
def test_detect_mode_local_provider():
"""Local sandbox provider should map to local mode."""
config = """
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
""".strip()
assert _detect_mode_with_config(config) == "local"
def test_detect_mode_aio_without_provisioner_url():
"""AIO sandbox without provisioner_url should map to aio mode."""
config = """
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
""".strip()
assert _detect_mode_with_config(config) == "aio"
def test_detect_mode_provisioner_with_url():
"""AIO sandbox with provisioner_url should map to provisioner mode."""
config = """
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
provisioner_url: http://provisioner:8002
""".strip()
assert _detect_mode_with_config(config) == "provisioner"
def test_detect_mode_ignores_commented_provisioner_url():
"""Commented provisioner_url should not activate provisioner mode."""
config = """
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
# provisioner_url: http://provisioner:8002
""".strip()
assert _detect_mode_with_config(config) == "aio"
def test_detect_mode_unknown_provider_falls_back_to_local():
"""Unknown sandbox provider should default to local mode."""
config = """
sandbox:
use: custom.module:UnknownProvider
""".strip()
assert _detect_mode_with_config(config) == "local"
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):
"""Read-only compose commands should resolve mounts from the repository root."""
with tempfile.TemporaryDirectory() as 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