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

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

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

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

Address review feedback on #4956.

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

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

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

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

Address the second review round on #4956.

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

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

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

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

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

---------

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

134 lines
5.6 KiB
Python

"""Regression test for the Docker Compose default published bind address.
``README.md`` documents DeerFlow as being deployed by default "in a local
trusted environment (accessible only via the 127.0.0.1 loopback interface)",
but the shipped compose files published the nginx entry as
``"${PORT:-2026}:2026"``, which Docker binds to ``0.0.0.0`` (and ``[::]``). The
shipped artifact therefore did not match its own documented default, and an
operator running it on a LAN or cloud host got a wider surface than the docs
implied without changing anything.
The Gateway itself binds ``0.0.0.0`` inside the container on purpose (nginx has
to reach it over the compose network) and its port is deliberately not
published, so the published nginx port is the whole external surface. This test
pins the loopback default there while keeping it overridable for operators who
intentionally expose the stack behind their own TLS/auth front door.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_PATHS = {
"prod": REPO_ROOT / "docker" / "docker-compose.yaml",
"dev": REPO_ROOT / "docker" / "docker-compose-dev.yaml",
}
EXPECTED_NGINX_PORT_MAPPING = "${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"
def _published_ports(compose_path: Path) -> dict[str, list[str]]:
"""Return {service_name: [port mapping, ...]} for every published port."""
compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
published: dict[str, list[str]] = {}
for service_name, service in (compose.get("services") or {}).items():
ports = service.get("ports") if isinstance(service, dict) else None
if not ports:
continue
published[service_name] = [str(entry) for entry in ports]
return published
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_nginx_entry_defaults_to_loopback(variant: str):
"""With BIND_HOST unset, the entry port must bind 127.0.0.1, not 0.0.0.0."""
published = _published_ports(COMPOSE_PATHS[variant])
assert published.get("nginx") == [EXPECTED_NGINX_PORT_MAPPING], f"{variant} compose must publish nginx as {EXPECTED_NGINX_PORT_MAPPING!r}; got: {published.get('nginx')!r}"
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_no_service_publishes_on_all_interfaces(variant: str):
"""No compose service may publish a port without an explicit bind address.
A bare ``"HOST:CONTAINER"`` mapping binds every interface. Any port added
later must either stay internal to the compose network or opt in to the
same ``BIND_HOST`` default.
"""
offenders: list[str] = []
for service_name, mappings in _published_ports(COMPOSE_PATHS[variant]).items():
for mapping in mappings:
# A bind address is present only when the mapping has three
# colon-separated parts (``ADDR:HOST:CONTAINER``). Variable
# substitutions such as ``${PORT:-2026}`` also contain colons, so
# count separators outside ``${...}`` instead of splitting naively.
if _bind_address(mapping) is None:
offenders.append(f"{service_name}: {mapping}")
assert not offenders, f"{variant} compose publishes ports on all interfaces (add a bind address): {offenders}"
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_bind_address_remains_overridable(variant: str):
"""Operators fronting the stack themselves must be able to widen the bind."""
mapping = _published_ports(COMPOSE_PATHS[variant])["nginx"][0]
assert _bind_address(mapping) == "${BIND_HOST:-127.0.0.1}", f"{variant} compose must keep the bind address overridable via BIND_HOST; got: {mapping!r}"
def test_dev_frontend_allows_default_loopback_origins():
"""The Docker dev frontend must hydrate on its default published hosts."""
compose = yaml.safe_load(COMPOSE_PATHS["dev"].read_text(encoding="utf-8"))
environment = compose["services"]["frontend"]["environment"]
assert "DEER_FLOW_DEV_ALLOWED_ORIGINS=${DEER_FLOW_DEV_ALLOWED_ORIGINS:-127.0.0.1,::1}" in environment
def _bind_address(mapping: str) -> str | None:
"""Return the bind-address segment of a compose port mapping, if any.
Splits on ``:`` at nesting depth zero so ``${PORT:-2026}`` is treated as a
single segment rather than two.
"""
segments: list[str] = []
current: list[str] = []
depth = 0
index = 0
while index < len(mapping):
char = mapping[index]
if mapping.startswith("${", index):
depth += 1
current.append("${")
index += 2
continue
if char == "}" and depth > 0:
depth -= 1
elif char == ":" and depth == 0:
segments.append("".join(current))
current = []
index += 1
continue
current.append(char)
index += 1
segments.append("".join(current))
# ADDR:HOST:CONTAINER -> bound; HOST:CONTAINER or CONTAINER -> unbound.
return segments[0] if len(segments) >= 3 else None
def test_dev_compose_env_files_are_optional():
"""Missing .env files must not fail `docker compose -f docker/docker-compose-dev.yaml`."""
compose = yaml.safe_load(COMPOSE_PATHS["dev"].read_text(encoding="utf-8"))
expected = {
"provisioner": "../.env",
"frontend": "../frontend/.env",
"gateway": "../.env",
}
for service_name, path in expected.items():
entries = compose["services"][service_name]["env_file"]
assert entries == [{"path": path, "required": False}], f"{service_name} env_file must be optional; got: {entries!r}"