deer-flow/backend/tests/test_compose_default_bind_host.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

218 lines
9.2 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
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from support.compose import DOCKER, requires_docker_compose
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
EXPECTED_ENV_FILES = {
"provisioner": "../.env",
"frontend": "../frontend/.env",
"gateway": "../.env",
}
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_compose_env_files_are_optional(variant: str):
"""Missing .env files must not fail either compose file.
Both are gitignored, so a fresh checkout has neither. ``make up`` seeds
config.yaml but not these, and the short ``- ../.env`` form makes Compose
abort with ``env file ... not found`` before building anything.
"""
compose = yaml.safe_load(COMPOSE_PATHS[variant].read_text(encoding="utf-8"))
for service_name, path in EXPECTED_ENV_FILES.items():
entries = compose["services"][service_name]["env_file"]
assert entries == [{"path": path, "required": False}], f"{variant} {service_name} env_file must be optional; got: {entries!r}"
# ── Against the real Compose client, when installed ─────────────────────────
def _render(tmp_path: Path, variant: str) -> subprocess.CompletedProcess[str]:
"""Render a checkout copy of the compose file the way the wrappers invoke it."""
docker_dir = tmp_path / "docker"
shutil.copytree(REPO_ROOT / "docker", docker_dir)
(tmp_path / "frontend").mkdir(exist_ok=True)
env = {key: value for key, value in os.environ.items() if not key.startswith(("DEER_FLOW_", "COMPOSE_"))}
# Values deploy.sh / docker.sh export before calling Compose; without them
# the bind-mount specs render empty and Compose fails for another reason.
env.update(
DEER_FLOW_ROOT=str(tmp_path),
DEER_FLOW_HOME=str(tmp_path / "home"),
DEER_FLOW_REPO_ROOT=str(tmp_path),
DEER_FLOW_CONFIG_PATH=str(tmp_path / "config.yaml"),
DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(tmp_path / "extensions_config.json"),
BETTER_AUTH_SECRET="test-secret",
DEER_FLOW_INTERNAL_AUTH_TOKEN="test-token",
)
return subprocess.run(
# Enable every profile so profile-gated services (the dev provisioner)
# still render: `config` omits them otherwise, and these assertions are
# about a present .env reaching each service, not about what `up`
# starts by default.
[DOCKER, "compose", "--profile", "*", "-p", "deer-flow-env-file-test", "-f", str(docker_dir / COMPOSE_PATHS[variant].name), "config", "--format", "json"],
env=env,
capture_output=True,
text=True,
timeout=120,
check=False,
)
@requires_docker_compose
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_real_compose_renders_a_checkout_without_env_files(tmp_path, variant: str):
result = _render(tmp_path, variant)
assert result.returncode == 0, f"{variant} compose must render without .env files:\n{result.stderr}"
@requires_docker_compose
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_real_compose_still_loads_env_files_that_exist(tmp_path, variant: str):
"""Optional must not mean ignored: a present .env still reaches each service."""
(tmp_path / "frontend").mkdir()
(tmp_path / ".env").write_text("ROOT_ENV_PROBE=from-root-env\n", encoding="utf-8")
(tmp_path / "frontend" / ".env").write_text("FRONTEND_ENV_PROBE=from-frontend-env\n", encoding="utf-8")
result = _render(tmp_path, variant)
assert result.returncode == 0, result.stderr
services = json.loads(result.stdout)["services"]
for service_name, path in EXPECTED_ENV_FILES.items():
key, value = ("FRONTEND_ENV_PROBE", "from-frontend-env") if path.startswith("../frontend/") else ("ROOT_ENV_PROBE", "from-root-env")
assert services[service_name]["environment"].get(key) == value, f"{variant} {service_name} must still load {path}"
@requires_docker_compose
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_real_compose_still_rejects_an_env_file_it_cannot_read(tmp_path, variant: str):
"""`required: false` skips only a missing file; a broken one must stay loud."""
(tmp_path / ".env").mkdir()
result = _render(tmp_path, variant)
assert result.returncode != 0
assert ".env" in result.stderr