mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 08:00:10 +00:00
* 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>
319 lines
10 KiB
Python
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
|