deer-flow/backend/tests/test_docker_sandbox_mode_detection.py
yu1128-L badfd7d3f1
fix(docker): make dev provisioner opt-in via compose profile (#6948)
* fix(docker): make dev provisioner opt-in via compose profile

Gate the Kubernetes provisioner behind a Compose profile and have the
wrapper enable it only when config selects provisioner sandbox mode.
This lets the default Docker Compose stack start without kubeconfig.

Fixes #3232

* test(docker): render every compose profile in the env-file regression test

main added backend/tests/test_compose_default_bind_host.py, whose _render helper asserted that the dev provisioner still loads ../.env. `docker compose config` without a profile omits profile-gated services, so that assertion failed once the provisioner became opt-in.

Pass `--profile *` from the helper: the test is about an existing .env reaching every service, so it has to render the services a profile gates rather than only the ones `up` starts by default.

* docs(docker): restore script mode and localize direct Compose guidance

The dev provisioner is now opt-in, but the earlier edit also flipped scripts/docker.sh from 100755 to 100644 even though README.md still documents direct `./scripts/docker.sh` invocation. Restore the executable bit.

Also add the direct-Compose guidance to the translated READMEs so readers of README_zh.md, README_ja.md, and README_fr.md learn that `--profile provisioner` selects the Kubernetes mode and the default stack does not need Kubernetes.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-20 09:10:24 +08:00

1093 lines
40 KiB
Python

"""Regression tests for docker sandbox mode detection logic."""
from __future__ import annotations
import os
import re
import shlex
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
import yaml
from support.shell import find_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / "scripts" / "docker.sh"
BASH_EXECUTABLE = find_script_bash()
# These fixtures are YAML source text, not Python values: quoting must remain
# observable (e.g. null is empty, but "null" is a nonempty string).
PROVISIONER_VALUES = [
pytest.param(None, "aio", id="omitted"),
pytest.param('""', "aio", id="double-quoted-empty"),
pytest.param("''", "aio", id="single-quoted-empty"),
pytest.param("null", "aio", id="null"),
pytest.param("Null", "aio", id="title-case-null"),
pytest.param("NULL", "aio", id="upper-case-null"),
pytest.param("~", "aio", id="tilde"),
pytest.param("", "aio", id="blank"),
pytest.param(" ", "aio", id="whitespace"),
pytest.param("# not configured", "aio", id="comment-only"),
pytest.param('"" # not configured', "aio", id="double-empty-comment"),
pytest.param("'' # not configured", "aio", id="single-empty-comment"),
pytest.param("null # not configured", "aio", id="null-comment"),
pytest.param("~ # not configured", "aio", id="tilde-comment"),
pytest.param("http://provisioner:8002", "provisioner", id="url"),
pytest.param('"http://provisioner:8002" # configured', "provisioner", id="quoted-url-comment"),
pytest.param('"null"', "provisioner", id="quoted-null"),
pytest.param("'null'", "provisioner", id="single-quoted-null"),
pytest.param('"~"', "provisioner", id="double-quoted-tilde"),
pytest.param("'~'", "provisioner", id="quoted-tilde"),
pytest.param('"null" # literal', "provisioner", id="quoted-null-comment"),
pytest.param('" # not a comment"', "provisioner", id="quoted-hash"),
pytest.param('" "', "provisioner", id="quoted-space"),
pytest.param("nullish", "provisioner", id="nonempty-string"),
]
def _sandbox_config(value: str | None, provider: str = "deerflow.community.aio_sandbox:AioSandboxProvider") -> str:
config = f"sandbox:\n use: {provider}\n"
return config if value is None else config + f" provisioner_url: {value}\n"
def _isolated_shell_env(tmp_path: Path) -> dict[str, str]:
# Do not inherit developer credentials, config selectors or shell startup
# hooks. Keep only the platform variables needed to find/run Git Bash.
env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT") if key in os.environ}
env.update(HOME=str(tmp_path), TMPDIR=str(tmp_path), TEMP=str(tmp_path), TMP=str(tmp_path))
return env
def _run_detector(script_name: str, tmp_path: Path, config: str | None) -> str:
"""Execute the real function, never deploy.sh's top-level setup."""
config_path = tmp_path / "config.yaml"
if config is not None:
config_path.write_text(config, encoding="utf-8")
script = (REPO_ROOT / "scripts" / script_name).read_text(encoding="utf-8")
match = re.search(r"^detect_sandbox_mode\(\) \{\n.*?^\}", script, re.M | re.S)
assert match is not None, f"{script_name} has no detector"
command = f"""
set -eu
SCRIPT_DIR={shlex.quote(str(REPO_ROOT / "scripts"))}
REPO_ROOT={shlex.quote(str(REPO_ROOT))}
PROJECT_ROOT={shlex.quote(str(tmp_path))}
DEER_FLOW_CONFIG_PATH={shlex.quote(str(config_path))}
{match.group()}
detect_sandbox_mode
"""
result = subprocess.run([BASH_EXECUTABLE, "-c", command], cwd=tmp_path, env=_isolated_shell_env(tmp_path), capture_output=True, text=True, encoding="utf-8", timeout=10)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"])
@pytest.mark.parametrize(("value", "expected"), PROVISIONER_VALUES)
def test_provisioner_scalar_detection(script_name, value, expected, tmp_path):
assert _run_detector(script_name, tmp_path, _sandbox_config(value)) == expected
@pytest.mark.parametrize(("value", "expected"), PROVISIONER_VALUES)
def test_provisioner_helper_scalar_detection(value, expected, tmp_path):
config_path = tmp_path / "synthetic config.yaml"
config_path.write_text(_sandbox_config(value), encoding="utf-8")
result = subprocess.run(
[BASH_EXECUTABLE, str(REPO_ROOT / "scripts" / "sandbox-mode.sh"), str(config_path)],
cwd=tmp_path,
env=_isolated_shell_env(tmp_path),
capture_output=True,
text=True,
encoding="utf-8",
timeout=10,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == expected
@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"])
@pytest.mark.parametrize(
"config",
[
None,
_sandbox_config("null", "deerflow.sandbox.local:LocalSandboxProvider"),
_sandbox_config("http://provisioner:8002", "deerflow.sandbox.local:LocalSandboxProvider"),
_sandbox_config("http://provisioner:8002", "custom:UnknownProvider"),
],
)
def test_provisioner_detection_local_fallback(script_name, config, tmp_path):
assert _run_detector(script_name, tmp_path, config) == "local"
@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"])
@pytest.mark.parametrize(
"config",
[
_sandbox_config(None) + " # provisioner_url: http://ignored:8002\n",
_sandbox_config(None) + "other:\n provisioner_url: http://ignored:8002\n",
"other:\n provisioner_url: http://ignored:8002\n" + _sandbox_config("null"),
_sandbox_config("'' # empty\t").replace("\n", "\r\n"),
],
ids=["commented-key", "following-section", "preceding-section", "crlf"],
)
def test_provisioner_detection_preserves_section_boundaries(script_name, config, tmp_path):
assert _run_detector(script_name, tmp_path, config) == "aio"
@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"])
@pytest.mark.parametrize(("value", "expected"), [*PROVISIONER_VALUES, pytest.param(None, "local", id="local-provider")])
def test_provisioner_start_selects_services_and_overlay(script_name, value, expected, tmp_path):
"""Run both launchers in a synthetic checkout; all external commands are doubles."""
checkout = tmp_path / "synthetic checkout"
shutil.copytree(REPO_ROOT / "scripts", checkout / "scripts")
docker_dir = checkout / "docker"
docker_dir.mkdir()
for filename in ("docker-compose-dev.yaml", "docker-compose.yaml", "docker-compose.dood.yaml"):
(docker_dir / filename).write_text("services: {}\n", encoding="utf-8")
(checkout / "frontend").mkdir()
for filename in (".env", "frontend/.env"):
(checkout / filename).write_text("# synthetic, no credentials\n", encoding="utf-8")
extensions = checkout / "extensions_config.json"
extensions.write_text("{}\n", encoding="utf-8")
config = _sandbox_config(value, "deerflow.sandbox.local:LocalSandboxProvider") if expected == "local" else _sandbox_config(value)
root_config = checkout / "config.yaml"
selected_config = checkout / "selected config.yaml"
# Dev must keep using PROJECT_ROOT/config.yaml; prod must honor its selector.
decoy = _sandbox_config("http://decoy:8002") if expected != "provisioner" else _sandbox_config(None)
root_config.write_text(config if script_name == "docker.sh" else decoy, encoding="utf-8")
selected_config.write_text(config if script_name == "deploy.sh" else decoy, encoding="utf-8")
capture = tmp_path / "compose-args"
shell_env = tmp_path / "stubs.sh"
shell_env.write_text(
"""docker() {
if [ "$*" = 'compose version --short' ]; then echo '2.24.0'; return; fi
if [ "$*" = info ]; then return 0; fi
if [ "$1" = compose ]; then
printf '%s\\n' "$@" >> "$CAPTURE_COMPOSE_ARGS"
return 0
fi
echo "Unexpected Docker call: $*" >&2; return 97
}
# Exercise the existing Windows socket preflight without requiring a host socket.
uname() { echo MINGW64_NT; }
python() { return 97; }
python3() { return 97; }
uv() { return 97; }
pnpm() { return 97; }
kubectl() { return 97; }
openssl() { return 97; }
""",
encoding="utf-8",
)
env = _isolated_shell_env(tmp_path)
env.update(
BASH_ENV=str(shell_env),
CAPTURE_COMPOSE_ARGS=str(capture),
DEER_FLOW_HOME=str(tmp_path / "runtime"),
DEER_FLOW_CONFIG_PATH=str(selected_config),
DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(extensions),
BETTER_AUTH_SECRET="synthetic-test-secret",
DEER_FLOW_INTERNAL_AUTH_TOKEN="synthetic-test-token",
DEER_FLOW_CREDENTIALS_KEY="synthetic-test-key",
UV_EXTRAS="redis",
)
result = subprocess.run([BASH_EXECUTABLE, str(checkout / "scripts" / script_name), "start"], cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8", timeout=15)
assert result.returncode == 0, result.stdout + result.stderr
args = capture.read_text(encoding="utf-8").splitlines()
assert args.count("up") == 1, args
assert ("provisioner" in args) == (expected == "provisioner"), args
files = [Path(args[index + 1]).name for index, arg in enumerate(args) if arg == "-f"]
base = "docker-compose-dev.yaml" if script_name == "docker.sh" else "docker-compose.yaml"
assert files == [base] + (["docker-compose.dood.yaml"] if expected == "aio" else [])
assert root_config.read_text(encoding="utf-8") == (config if script_name == "docker.sh" else decoy)
assert selected_config.read_text(encoding="utf-8") == (config if script_name == "deploy.sh" else decoy)
assert not any((tmp_path / "runtime").glob(".*secret*"))
assert not (tmp_path / "runtime" / ".internal-auth-token").exists()
assert not (tmp_path / "runtime" / ".credentials_key").exists()
if BASH_EXECUTABLE is None:
pytestmark = pytest.mark.skip(reason="Git 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 test_dev_compose_keeps_provisioner_behind_opt_in_profile():
"""A direct `docker compose up` must not start the Kubernetes provisioner."""
compose_path = REPO_ROOT / "docker" / "docker-compose-dev.yaml"
compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
assert compose["services"]["provisioner"]["profiles"] == ["provisioner"]
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
@pytest.mark.parametrize("args", ["logs --prod", "logs --prod --gateway"])
def test_logs_prod_targets_production_stack(args):
"""`logs --prod` must tail the stack deploy.sh started, not the dev project.
`make up` runs scripts/deploy.sh (project `deer-flow`, docker-compose.yaml)
while the dev default is project `deer-flow-dev`, so `make docker-logs`
after `make up` printed nothing (#5529). The production entry point must
target the same project and interpolate the same .env.
Compose detection is NOT stubbed here: it rebuilds COMPOSE_CMD, and the
appended `--env-file` must survive that rebuild (#5538 review).
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
(tmp_root / "docker-compose.yaml").write_text("services: {}\n", encoding="utf-8")
(tmp_root / ".env").write_text("KEEP=me\n", encoding="utf-8")
marker = tmp_root / "prod_invoke.txt"
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
docker() {{
if [ "$1" = compose ] && [ "$2" = version ]; then
echo '2.41.0'
return 0
fi
if [ "$1" = compose ]; then
printf '%s\\n' "$*" "DEER_FLOW_HOME=${{DEER_FLOW_HOME:-unset}}" > '{marker}'
return 0
fi
command docker "$@"
}}
unset DEER_FLOW_ROOT DEER_FLOW_HOME DEER_FLOW_CONFIG_PATH
unset DEER_FLOW_EXTENSIONS_CONFIG_PATH DEER_FLOW_REPO_ROOT
unset BETTER_AUTH_SECRET DEER_FLOW_INTERNAL_AUTH_TOKEN
{args}
"""
subprocess.check_call([BASH_EXECUTABLE, "-lc", command])
recorded = marker.read_text(encoding="utf-8")
assert "-p deer-flow " in recorded, recorded
assert "-f docker-compose.yaml" in recorded, recorded
assert "--env-file ../.env" in recorded, recorded
assert "logs" in recorded, recorded
# deploy.sh exports interpolation defaults before every compose call;
# `logs --prod` must too, or the volume specs fail to parse.
assert "DEER_FLOW_HOME=" in recorded, recorded
assert "DEER_FLOW_HOME=unset" not in recorded, recorded
def test_logs_without_dev_containers_hints_at_prod_logs():
"""Silent empty output is the #5529 report: point at the production entry point."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
require_compose_version() {{ :; }}
COMPOSE_CMD=true
unset DEER_FLOW_ROOT
logs
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 0, result.stdout + result.stderr
assert "make prod-logs" in result.stdout
def test_aio_dood_socket_preflight_allows_windows_when_docker_reachable():
"""Windows Git Bash without /var/run/docker.sock proceeds when Docker daemon is reachable."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_seed_env_examples(tmp_root)
(tmp_root / "config.yaml").write_text(
"sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n",
encoding="utf-8",
)
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
require_compose_version() {{ :; }}
uname() {{ echo 'MINGW64_NT-10.0'; }}
docker() {{
if [ "$1" = info ]; then
return 0
fi
return 0
}}
DEER_FLOW_DOCKER_SOCKET='/var/run/docker.sock'
COMPOSE_CMD=echo
start
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 0, result.stdout + result.stderr
assert "docker-compose.dood.yaml" in result.stdout
def test_aio_dood_socket_preflight_rejects_missing_socket_on_posix():
"""POSIX hosts without a physical socket file must fail fast."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_seed_env_examples(tmp_root)
(tmp_root / "config.yaml").write_text(
"sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n",
encoding="utf-8",
)
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
require_compose_version() {{ :; }}
uname() {{ echo 'Linux'; }}
DEER_FLOW_DOCKER_SOCKET='/nonexistent/docker.sock'
COMPOSE_CMD=echo
start
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 1, result.stdout + result.stderr
assert "Docker socket not found" in result.stdout
@pytest.mark.parametrize(
("sandbox_provider", "expected_profile"),
[
("deerflow.sandbox.local:LocalSandboxProvider", False),
(
"deerflow.community.aio_sandbox:AioSandboxProvider\n provisioner_url: http://provisioner:8002",
True,
),
],
)
def test_start_toggles_provisioner_profile(sandbox_provider, expected_profile):
"""make docker-start enables the profile only for provisioner mode."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_seed_env_examples(tmp_root)
(tmp_root / "config.yaml").write_text(
f"sandbox:\n use: {sandbox_provider}\n",
encoding="utf-8",
)
marker = tmp_root / "start_invoke.txt"
_run_docker_sh(
tmp_root,
f"""
COMPOSE_CMD=capture_compose
capture_compose() {{ printf '%s\\n' "$*" > '{marker}'; }}
start
""",
)
recorded = marker.read_text(encoding="utf-8")
assert ("--profile provisioner" in recorded) is expected_profile
def test_stop_includes_profile_gated_provisioner():
"""make docker-stop must remove a provisioner started with the profile."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
marker = tmp_root / "stop_invoke.txt"
_run_docker_sh(
tmp_root,
f"""
COMPOSE_CMD=capture_compose
capture_compose() {{ printf '%s\\n' "$*" > '{marker}'; }}
stop
""",
)
assert "--profile provisioner down" in marker.read_text(encoding="utf-8")
def test_aio_dood_socket_preflight_rejects_missing_custom_socket_on_windows():
"""Windows Git Bash must fail if a custom non-existent socket path is specified."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_seed_env_examples(tmp_root)
(tmp_root / "config.yaml").write_text(
"sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n",
encoding="utf-8",
)
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
require_compose_version() {{ :; }}
uname() {{ echo 'MINGW64_NT-10.0'; }}
docker() {{
if [ "$1" = info ]; then
return 0
fi
return 0
}}
DEER_FLOW_DOCKER_SOCKET='/nonexistent/docker.sock'
COMPOSE_CMD=echo
start
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 1, result.stdout + result.stderr
assert "Docker socket not found" in result.stdout
@pytest.mark.skipif(
Path("/var/run/docker.sock").is_socket(),
reason="Host has real /var/run/docker.sock",
)
def test_aio_dood_socket_preflight_rejects_windows_when_docker_unreachable():
"""Windows Git Bash must fail if Docker daemon is not reachable."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_root = Path(tmpdir)
_seed_compose_file(tmp_root)
_seed_env_examples(tmp_root)
(tmp_root / "config.yaml").write_text(
"sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n",
encoding="utf-8",
)
command = f"""
source '{SCRIPT_PATH}'
PROJECT_ROOT='{tmp_root}'
DOCKER_DIR='{tmp_root}'
require_compose_version() {{ :; }}
uname() {{ echo 'MINGW64_NT-10.0'; }}
docker() {{
if [ "$1" = info ]; then
return 1
fi
return 0
}}
DEER_FLOW_DOCKER_SOCKET='/var/run/docker.sock'
COMPOSE_CMD=echo
start
"""
result = subprocess.run(
[BASH_EXECUTABLE, "-lc", command],
capture_output=True,
text=True,
encoding="utf-8",
)
assert result.returncode == 1, result.stdout + result.stderr
assert "Docker socket not found" in result.stdout
def _setup_deploy_worktree(tmp_path: Path) -> Path:
worktree = tmp_path / "repo"
shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts")
shutil.copytree(REPO_ROOT / "docker", worktree / "docker")
(worktree / "backend").mkdir()
(worktree / "config.yaml").write_text(
"sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n",
encoding="utf-8",
)
(worktree / "extensions_config.json").write_text("{}\n", encoding="utf-8")
(worktree / ".env").write_text("TEST=1\n", encoding="utf-8")
return worktree
def test_aio_deploy_socket_preflight_allows_windows_when_docker_reachable(tmp_path):
"""deploy.sh on Windows Git Bash proceeds past preflight when Docker daemon is reachable."""
worktree = _setup_deploy_worktree(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
capture_args = tmp_path / "docker_args.txt"
docker = bin_dir / "docker"
docker.write_text(
f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nfor arg in "$@"; do printf "%s\\n" "$arg"; done > "{capture_args}"\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8")
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env["DEER_FLOW_DOCKER_SOCKET"] = "/var/run/docker.sock"
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "docker-compose.dood.yaml" in capture_args.read_text(encoding="utf-8")
def test_aio_deploy_socket_preflight_rejects_missing_socket_on_posix(tmp_path):
"""deploy.sh on POSIX hosts without a physical socket file fails fast."""
worktree = _setup_deploy_worktree(tmp_path)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'Linux'; }\n", encoding="utf-8")
env = os.environ.copy()
env["BASH_ENV"] = str(bash_env)
env["DEER_FLOW_DOCKER_SOCKET"] = "/nonexistent/docker.sock"
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 1, result.stdout + result.stderr
assert "Docker socket not found" in result.stdout + result.stderr
def test_aio_deploy_socket_preflight_rejects_missing_custom_socket_on_windows(tmp_path):
"""deploy.sh on Windows Git Bash fails if a custom non-existent socket path is specified."""
worktree = _setup_deploy_worktree(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
docker = bin_dir / "docker"
docker.write_text(
'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8")
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env["DEER_FLOW_DOCKER_SOCKET"] = "/nonexistent/docker.sock"
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 1, result.stdout + result.stderr
assert "Docker socket not found" in result.stdout + result.stderr
@pytest.mark.skipif(
Path("/var/run/docker.sock").is_socket(),
reason="Host has real /var/run/docker.sock",
)
def test_aio_deploy_socket_preflight_rejects_windows_when_docker_unreachable(tmp_path):
"""deploy.sh on Windows Git Bash fails if Docker daemon is not reachable."""
worktree = _setup_deploy_worktree(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
docker = bin_dir / "docker"
docker.write_text('#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 1; fi\nexit 0\n', encoding="utf-8")
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8")
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env["DEER_FLOW_DOCKER_SOCKET"] = "/var/run/docker.sock"
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 1, result.stdout + result.stderr
assert "Docker socket not found" in result.stdout + result.stderr
def test_aio_deploy_socket_unsets_default_on_windows(tmp_path):
"""deploy.sh on Windows Git Bash unsets default /var/run/docker.sock before calling Compose."""
worktree = _setup_deploy_worktree(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
capture_socket_env = tmp_path / "docker_socket_env.txt"
docker = bin_dir / "docker"
docker.write_text(
f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8")
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env["DEER_FLOW_DOCKER_SOCKET"] = "/var/run/docker.sock"
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert capture_socket_env.read_text(encoding="utf-8") == ""
def test_aio_deploy_socket_preserves_unset_default_on_windows(tmp_path):
"""deploy.sh does not export default /var/run/docker.sock when unset initially."""
worktree = _setup_deploy_worktree(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
capture_socket_env = tmp_path / "docker_socket_env.txt"
docker = bin_dir / "docker"
docker.write_text(
f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8")
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env.pop("DEER_FLOW_DOCKER_SOCKET", None)
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert capture_socket_env.read_text(encoding="utf-8") == ""
def test_aio_deploy_socket_preserves_custom_socket_on_windows(tmp_path):
"""deploy.sh preserves and exports custom DEER_FLOW_DOCKER_SOCKET on Windows."""
worktree = _setup_deploy_worktree(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
capture_socket_env = tmp_path / "docker_socket_env.txt"
docker = bin_dir / "docker"
docker.write_text(
f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text(
'uname() { echo "MINGW64_NT-10.0"; }\n[() {\n if [[ "$1" == "!" && "$2" == "-S" ]]; then\n return 1\n fi\n builtin [ "$@"\n}\n',
encoding="utf-8",
)
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env["DEER_FLOW_DOCKER_SOCKET"] = "/custom/docker.sock"
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert capture_socket_env.read_text(encoding="utf-8") == "/custom/docker.sock"
def test_aio_deploy_socket_reads_from_dotenv(tmp_path):
"""deploy.sh resolves DEER_FLOW_DOCKER_SOCKET from .env when unset in shell environment."""
worktree = _setup_deploy_worktree(tmp_path)
(worktree / ".env").write_text("DEER_FLOW_DOCKER_SOCKET=/var/run/docker.sock\n", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
capture_socket_env = tmp_path / "docker_socket_env.txt"
docker = bin_dir / "docker"
docker.write_text(
f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
bash_env = tmp_path / "env.sh"
bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8")
env = os.environ.copy()
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASH_ENV"] = str(bash_env)
env.pop("DEER_FLOW_DOCKER_SOCKET", None)
env["BETTER_AUTH_SECRET"] = "test-secret"
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token"
env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key"
env["UV_EXTRAS"] = "redis"
result = subprocess.run(
[BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert capture_socket_env.read_text(encoding="utf-8") == ""