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>
218 lines
9.2 KiB
Python
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
|