mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 00:19:14 +00:00
451 lines
15 KiB
Python
451 lines
15 KiB
Python
import logging
|
|
import os
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.community.aio_sandbox.local_backend import (
|
|
LocalContainerBackend,
|
|
_format_container_command_for_log,
|
|
_format_container_mount,
|
|
_redact_container_command_for_log,
|
|
_resolve_docker_bind_host,
|
|
)
|
|
|
|
|
|
def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths():
|
|
args = _format_container_mount("docker", "D:/deer-flow/backend/.deer-flow/threads", "/mnt/threads", False)
|
|
|
|
assert args == [
|
|
"--mount",
|
|
"type=bind,src=D:/deer-flow/backend/.deer-flow/threads,dst=/mnt/threads",
|
|
]
|
|
|
|
|
|
def test_format_container_mount_marks_docker_readonly_mounts():
|
|
args = _format_container_mount("docker", "/host/path", "/mnt/path", True)
|
|
|
|
assert args == [
|
|
"--mount",
|
|
"type=bind,src=/host/path,dst=/mnt/path,readonly",
|
|
]
|
|
|
|
|
|
def test_format_container_mount_keeps_volume_syntax_for_apple_container():
|
|
args = _format_container_mount("container", "/host/path", "/mnt/path", True)
|
|
|
|
assert args == [
|
|
"-v",
|
|
"/host/path:/mnt/path:ro",
|
|
]
|
|
|
|
|
|
def test_redact_container_command_for_log_redacts_env_values():
|
|
redacted = _redact_container_command_for_log(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"-e",
|
|
"API_KEY=secret-value",
|
|
"--env=TOKEN=token-value",
|
|
"--name",
|
|
"sandbox",
|
|
"image",
|
|
]
|
|
)
|
|
|
|
assert "API_KEY=<redacted>" in redacted
|
|
assert "--env=TOKEN=<redacted>" in redacted
|
|
assert "secret-value" not in " ".join(redacted)
|
|
assert "token-value" not in " ".join(redacted)
|
|
|
|
|
|
def test_redact_container_command_for_log_keeps_inherited_env_names():
|
|
redacted = _redact_container_command_for_log(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"-e",
|
|
"API_KEY",
|
|
"--env=TOKEN",
|
|
"--name",
|
|
"sandbox",
|
|
"image",
|
|
]
|
|
)
|
|
|
|
assert redacted == [
|
|
"docker",
|
|
"run",
|
|
"-e",
|
|
"API_KEY",
|
|
"--env=TOKEN",
|
|
"--name",
|
|
"sandbox",
|
|
"image",
|
|
]
|
|
|
|
|
|
def test_format_container_command_for_log_uses_windows_quoting(monkeypatch):
|
|
monkeypatch.setattr(os, "name", "nt")
|
|
|
|
command = _format_container_command_for_log(["docker", "run", "--name", "sandbox one", "image"])
|
|
|
|
assert command == 'docker run --name "sandbox one" image'
|
|
|
|
|
|
def test_start_container_logs_redacted_env_values(monkeypatch, caplog):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={"API_KEY": "secret-value", "NORMAL": "visible-value"},
|
|
)
|
|
monkeypatch.setattr(backend, "_runtime", "docker")
|
|
|
|
captured_cmd: list[str] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_cmd.extend(cmd)
|
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with caplog.at_level(logging.INFO, logger="deerflow.community.aio_sandbox.local_backend"):
|
|
backend._start_container("sandbox-test", 18080)
|
|
|
|
joined_cmd = " ".join(captured_cmd)
|
|
assert "API_KEY=secret-value" in joined_cmd
|
|
assert "NORMAL=visible-value" in joined_cmd
|
|
|
|
log_output = "\n".join(record.getMessage() for record in caplog.records)
|
|
assert "API_KEY=<redacted>" in log_output
|
|
assert "NORMAL=<redacted>" in log_output
|
|
assert "secret-value" not in log_output
|
|
assert "visible-value" not in log_output
|
|
|
|
|
|
def _capture_start_container_command(monkeypatch, backend: LocalContainerBackend, runtime: str = "docker") -> list[str]:
|
|
monkeypatch.setattr(backend, "_runtime", runtime)
|
|
captured_cmd: list[str] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_cmd.extend(cmd)
|
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._start_container("sandbox-test", 18080)
|
|
return captured_cmd
|
|
|
|
|
|
def test_resolve_docker_bind_host_defaults_loopback_for_localhost(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_HOST", raising=False)
|
|
|
|
assert _resolve_docker_bind_host() == "127.0.0.1"
|
|
|
|
|
|
def test_resolve_docker_bind_host_keeps_dood_compatibility(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
|
|
assert _resolve_docker_bind_host() == "0.0.0.0"
|
|
|
|
|
|
def test_resolve_docker_bind_host_uses_ipv6_loopback_for_ipv6_sandbox_host(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]")
|
|
|
|
assert _resolve_docker_bind_host() == "[::1]"
|
|
|
|
|
|
def test_resolve_docker_bind_host_logs_selected_bind_reason(caplog):
|
|
with caplog.at_level(logging.DEBUG, logger="deerflow.community.aio_sandbox.local_backend"):
|
|
assert _resolve_docker_bind_host(sandbox_host="localhost", bind_host="") == "127.0.0.1"
|
|
|
|
messages = "\n".join(record.getMessage() for record in caplog.records)
|
|
assert "Docker sandbox bind: 127.0.0.1 (loopback default)" in messages
|
|
|
|
|
|
def test_resolve_docker_bind_host_allows_explicit_override(monkeypatch):
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "localhost")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.0.2.10")
|
|
|
|
assert _resolve_docker_bind_host() == "192.0.2.10"
|
|
|
|
|
|
def test_start_container_binds_local_docker_port_to_loopback_by_default(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_HOST", raising=False)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "127.0.0.1:18080:8080"
|
|
|
|
|
|
def test_start_container_keeps_broad_bind_for_dood_sandbox_host(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "0.0.0.0:18080:8080"
|
|
|
|
|
|
def test_start_container_binds_ipv6_sandbox_host_to_ipv6_loopback(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]")
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "[::1]:18080:8080"
|
|
|
|
|
|
def test_start_container_keeps_apple_container_port_format(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "127.0.0.1")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend, runtime="container")
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "18080:8080"
|
|
|
|
|
|
def _backend_for_inspect_tests() -> LocalContainerBackend:
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
backend._runtime = "docker"
|
|
return backend
|
|
|
|
|
|
def test_is_container_running_false_when_container_missing(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Error: No such object: sandbox-missing", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend._is_container_running("sandbox-missing") is False
|
|
|
|
|
|
def test_is_container_running_raises_on_runtime_error(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Cannot connect to the Docker daemon", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(RuntimeError, match="Failed to inspect container sandbox-busy"):
|
|
backend._is_container_running("sandbox-busy")
|
|
|
|
|
|
def test_is_container_running_raises_on_timeout(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(RuntimeError, match="Timed out checking container sandbox-timeout"):
|
|
backend._is_container_running("sandbox-timeout")
|
|
|
|
|
|
def test_discover_returns_none_when_runtime_check_fails(monkeypatch):
|
|
"""A transient daemon error during discovery must fall through to create, not fail acquire."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Cannot connect to the Docker daemon", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend.discover("sandbox-blip") is None
|
|
|
|
|
|
def test_discover_returns_none_when_runtime_check_times_out(monkeypatch):
|
|
"""An inspect timeout during discovery must not propagate out of discover()."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend.discover("sandbox-timeout") is None
|
|
|
|
|
|
def test_reconciliation_discovery_confirms_exact_container_absent(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr(backend, "_reconciliation_backend_namespace", lambda: "docker:daemon-1")
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: False)
|
|
|
|
result = backend.discover_for_reconciliation("missing")
|
|
|
|
assert result.status == "absent"
|
|
assert result.info is None
|
|
assert result.backend_namespace == "docker:daemon-1"
|
|
|
|
|
|
def test_reconciliation_discovery_preserves_runtime_uncertainty(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr(backend, "_reconciliation_backend_namespace", lambda: "docker:daemon-1")
|
|
|
|
def fail(_name):
|
|
raise RuntimeError("daemon unavailable")
|
|
|
|
monkeypatch.setattr(backend, "_is_container_running", fail)
|
|
|
|
result = backend.discover_for_reconciliation("unknown")
|
|
|
|
assert result.status == "unknown"
|
|
assert result.info is None
|
|
|
|
|
|
def test_reconciliation_discovery_returns_exact_running_container(monkeypatch):
|
|
from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo
|
|
|
|
backend = _backend_for_inspect_tests()
|
|
info = SandboxInfo(sandbox_id="existing", sandbox_url="http://localhost:18080")
|
|
monkeypatch.setattr(backend, "_reconciliation_backend_namespace", lambda: "docker:daemon-1")
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(backend, "discover", lambda sandbox_id: info if sandbox_id == "existing" else None)
|
|
monkeypatch.setattr(backend, "_container_incarnation", lambda _name: "container-uid-1")
|
|
|
|
result = backend.discover_for_reconciliation("existing")
|
|
|
|
assert result.status == "found"
|
|
assert result.info is info
|
|
assert result.backend_namespace == "docker:daemon-1"
|
|
assert result.incarnation_id == "container-uid-1"
|
|
|
|
|
|
def test_reconciliation_discovery_fails_closed_without_docker_daemon_identity(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr(backend, "_reconciliation_backend_namespace", lambda: None)
|
|
running = SimpleNamespace(called=False)
|
|
|
|
def should_not_inspect(_name):
|
|
running.called = True
|
|
raise AssertionError("container lookup must not cross an unknown daemon boundary")
|
|
|
|
monkeypatch.setattr(backend, "_is_container_running", should_not_inspect)
|
|
|
|
result = backend.discover_for_reconciliation("same-id")
|
|
|
|
assert result.status == "unknown"
|
|
assert running.called is False
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["", "<no value>"])
|
|
def test_reconciliation_backend_namespace_rejects_missing_docker_daemon_id(monkeypatch, value):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=f"{value}\n"),
|
|
)
|
|
|
|
assert backend._reconciliation_backend_namespace() is None
|
|
|
|
|
|
def test_is_container_running_false_on_apple_container_not_found(monkeypatch):
|
|
"""Apple Container's generic "not found" is trusted when it names the container."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr='Error: not found: "sandbox-apple"', returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend._is_container_running("sandbox-apple") is False
|
|
|
|
|
|
def test_is_container_running_raises_on_unrelated_not_found_error(monkeypatch):
|
|
"""Transient errors whose text contains "not found" must not be misread as a dead container."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Error: credential helper not found in $PATH", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(RuntimeError, match="Failed to inspect container sandbox-busy"):
|
|
backend._is_container_running("sandbox-busy")
|
|
|
|
|
|
def test_stop_container_passes_a_timeout(monkeypatch):
|
|
"""An unbounded `stop` can outlive the teardown lease that guards it.
|
|
|
|
The `del:` marker keeps a peer from re-acquiring the container during the
|
|
stop, but a lease can lapse (a store outage longer than the TTL) while a
|
|
wedged daemon leaves `docker stop` blocked forever — and the stop then lands
|
|
on a container the peer has since been handed. Bounding the call caps that
|
|
exposure independently of the ownership layer.
|
|
"""
|
|
backend = _backend_for_inspect_tests()
|
|
seen = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
seen.update(kwargs)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._stop_container("sandbox-slow")
|
|
|
|
assert seen.get("timeout") == backend._STOP_TIMEOUT_SECONDS
|
|
|
|
|
|
def test_stop_container_propagates_a_timeout_instead_of_reporting_success(monkeypatch):
|
|
"""A timed-out stop must not be swallowed like a failed one.
|
|
|
|
`CalledProcessError` means the runtime answered "I could not stop it"; a
|
|
timeout means we do not know, and the container is probably still running.
|
|
Returning normally would let `_destroy_warm_entry` report a clean stop and
|
|
drop the warm entry, leaking a running container nothing tracks.
|
|
"""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend._stop_container("sandbox-wedged")
|