mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-12 15:09:47 +00:00
* fix(docker): create compose env files and keep Windows compose paths relative Windows Docker reports a generic file-not-found when env_file targets are missing, or when compose paths are doubled. Make docker-start copy .env examples and invoke compose with filenames relative to docker/. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(docker): make dev compose env files optional and repair test fixture Address review feedback on #4956. [P1] prepare_compose_env aborted before the mocked COMPOSE_CMD in test_compose_commands_set_deer_flow_root_before_compose, because the temp root had no compose file or .env examples. Seed them in the fixture so the preflight reaches the mock. [P2] .env is gitignored, so a fresh clone has none and a direct `docker compose -f docker/docker-compose-dev.yaml up --build` aborts on Windows before scripts/docker.sh can help. Mark the dev env_file entries `required: false` so a missing .env is not fatal, and document that direct Compose must be run from the repository root. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(docker): declare Compose 2.24 floor and keep non-start commands read-only Address the second review round on #4956. - Document Compose >= 2.24 (CONTRIBUTING, README, compose header) and fail early from make docker-start with an actionable message; probe both `docker compose` and the hyphenated `docker-compose` binary. - Document DEER_FLOW_ROOT for direct Compose callers (bash + PowerShell); leave the variable without a $PWD fallback because PowerShell/cmd do not export it. - Split prepare_compose_env: compose_preflight is shared and read-only; ensure_env_files runs only from start. - Expand tests for version boundaries, hyphenated fallback, env-file creation, and read-only stop/logs/restart behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(docker): reuse the probed Compose binary for wrapper operations The version probe could accept a standalone docker-compose install while COMPOSE_CMD stayed hardcoded to `docker compose`, so preflight passed and start/logs/stop/restart then failed. Keep the selected executable in COMPOSE_BIN (array), refresh COMPOSE_CMD from it in the current shell, and extend the fallback test through an actual stop invocation. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
5.6 KiB
Python
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}"
|