mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
1093 lines
40 KiB
Python
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") == ""
|