mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
- test_local_dev_loopback_bind: the stale-render test compared Git Bash's MSYS path (/tmp/...) against a native path; route it through _normalize_rendered_path like its sibling tests. - test_project_documents_repo: TestShelfSnapshot relied on back-to-back inserts getting increasing updated_at, which ties on Windows' ~15.6 ms clock and falls through to the id tie-break. Pin updated_at explicitly. - test_multi_worker_run_ownership: the transient-renewal test compared two immediate lease timestamps with a strict >; start from a deadline inside the lease window, as test_scheduled_goal_handoff already does.
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Regression test for the local (non-Docker) launch bind addresses.
|
|
|
|
``README.md`` documents DeerFlow as deployed by default "in a local trusted
|
|
environment (accessible only via the 127.0.0.1 loopback interface)", and the
|
|
Docker stack publishes only its nginx entry, on ``${BIND_HOST:-127.0.0.1}``
|
|
(``test_compose_default_bind_host.py``). ``make dev`` / ``make start`` instead
|
|
started the Gateway with ``--host 0.0.0.0``, nginx with ``listen 2026`` and the
|
|
frontend on Next's ``0.0.0.0`` default, so on a LAN or VPN every one of those
|
|
ports, including ``/setup`` before the first admin exists, was reachable from
|
|
other machines.
|
|
|
|
Gateway and frontend now always bind loopback; nginx listens on loopback unless
|
|
``BIND_HOST`` opts in, the same variable the Docker stack honors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from support.shell import require_script_bash
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SERVE_SH = REPO_ROOT / "scripts" / "serve.sh"
|
|
NGINX_SH = REPO_ROOT / "scripts" / "nginx.sh"
|
|
NGINX_LOCAL_CONF_SH = REPO_ROOT / "scripts" / "nginx-local-conf.sh"
|
|
NGINX_LOCAL_CONF = REPO_ROOT / "docker" / "nginx" / "nginx.local.conf"
|
|
BACKEND_MAKEFILE = REPO_ROOT / "backend" / "Makefile"
|
|
|
|
|
|
def _listen_directives(conf: str) -> list[str]:
|
|
return re.findall(r"^\s*listen\s+([^;]+);", conf, re.M)
|
|
|
|
|
|
# ── Launch commands ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("path", [SERVE_SH, BACKEND_MAKEFILE], ids=["serve.sh", "backend-Makefile"])
|
|
def test_gateway_launch_commands_bind_loopback(path: Path):
|
|
text = path.read_text(encoding="utf-8")
|
|
launches = [line for line in text.splitlines() if "uvicorn app.gateway.app:app --" in line]
|
|
|
|
assert launches, f"no Gateway launch found in {path.name}"
|
|
for line in launches:
|
|
assert "--host 127.0.0.1" in line, f"{path.name} must start the Gateway on loopback: {line.strip()}"
|
|
|
|
|
|
def test_frontend_launch_commands_bind_loopback():
|
|
"""Every serve.sh frontend mode must pass a loopback --hostname to Next.
|
|
|
|
``frontend/scripts/dev.mjs`` keeps Next's all-interfaces default off Windows
|
|
because the Docker dev frontend container needs it, so serve.sh passes the
|
|
hostname itself.
|
|
"""
|
|
commands = re.findall(r"^\s*FRONTEND_CMD=(.*)$", SERVE_SH.read_text(encoding="utf-8"), re.M)
|
|
|
|
assert len(commands) == 3, f"expected dev, start and preview frontend commands; got {commands}"
|
|
for command in commands:
|
|
assert "--hostname 127.0.0.1" in command, f"frontend command must bind loopback: {command}"
|
|
|
|
|
|
def test_local_nginx_config_listens_on_loopback_only():
|
|
assert _listen_directives(NGINX_LOCAL_CONF.read_text(encoding="utf-8")) == ["127.0.0.1:2026", "[::1]:2026"]
|
|
|
|
|
|
@pytest.mark.parametrize("path", [SERVE_SH, NGINX_SH], ids=["serve.sh", "nginx.sh"])
|
|
def test_nginx_launchers_use_the_bind_host_aware_config(path: Path):
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
assert 'NGINX_CONF="$(bash "$REPO_ROOT/scripts/nginx-local-conf.sh")"' in text
|
|
launches = [line for line in text.splitlines() if "daemon off;" in line]
|
|
assert launches and all("$NGINX_CONF" in line for line in launches), launches
|
|
|
|
|
|
def test_serve_resolves_nginx_config_before_stopping_services():
|
|
"""An invalid BIND_HOST must fail before restart/start tear down a running stack."""
|
|
serve = SERVE_SH.read_text(encoding="utf-8")
|
|
|
|
assert serve.index("scripts/nginx-local-conf.sh") < serve.index('if [ "$ACTION" = "restart" ]; then')
|
|
|
|
|
|
def test_stop_signals_nginx_through_the_tracked_config():
|
|
"""``stop_all`` quits nginx via the tracked config even when it runs the rendered copy.
|
|
|
|
``nginx -s quit`` finds the master through the config's ``pid`` directive, so
|
|
this works because the render keeps that directive (pinned in
|
|
``test_bind_host_renders_a_config_listening_there``). The tracked path stays
|
|
usable for ``--stop`` with an invalid ``BIND_HOST``, which never renders.
|
|
"""
|
|
serve = SERVE_SH.read_text(encoding="utf-8")
|
|
stop_all = serve[serve.index("stop_all() {") : serve.index("\n}\n", serve.index("stop_all() {"))]
|
|
|
|
assert 'nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit' in stop_all
|
|
|
|
|
|
# ── nginx-local-conf.sh ─────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(params=["checkout", "checkout-测试 with spaces"], ids=["ascii", "unicode-spaces"])
|
|
def checkout(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
|
|
"""A minimal checkout: the script resolves the repo root from its own location."""
|
|
tmp_path = tmp_path / request.param
|
|
tmp_path.mkdir()
|
|
(tmp_path / "scripts").mkdir()
|
|
(tmp_path / "docker" / "nginx").mkdir(parents=True)
|
|
shutil.copy2(NGINX_LOCAL_CONF_SH, tmp_path / "scripts" / NGINX_LOCAL_CONF_SH.name)
|
|
shutil.copy2(NGINX_LOCAL_CONF, tmp_path / "docker" / "nginx" / NGINX_LOCAL_CONF.name)
|
|
return tmp_path
|
|
|
|
|
|
def _run(checkout: Path, bind_host: str | None) -> subprocess.CompletedProcess[str]:
|
|
env = {key: value for key, value in os.environ.items() if key != "BIND_HOST"}
|
|
if bind_host is not None:
|
|
env["BIND_HOST"] = bind_host
|
|
return subprocess.run(
|
|
[require_script_bash(), str(checkout / "scripts" / NGINX_LOCAL_CONF_SH.name)],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _normalize_rendered_path(rendered: str) -> str:
|
|
"""Map the script's POSIX rendering back to Windows form on Git Bash hosts.
|
|
|
|
``pwd -P`` inside Git Bash yields the MSYS mount form (``/tmp/...`` for the
|
|
Windows temp dir), which the test's Windows-side ``Path`` can neither
|
|
compare nor read. Resolve cygpath through the selected Git Bash's PATH,
|
|
including when bash.exe lives in Git's wrapper directory.
|
|
"""
|
|
if os.name != "nt" or not rendered.startswith("/"):
|
|
return rendered
|
|
result = subprocess.run(
|
|
[require_script_bash(), "-c", 'cygpath -w "$DEERFLOW_TEST_RENDERED_PATH"'],
|
|
env={**os.environ, "DEERFLOW_TEST_RENDERED_PATH": rendered},
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
pytest.fail(f"Git Bash cygpath failed to normalize {rendered!r} (exit {result.returncode}): {result.stderr.strip()}")
|
|
normalized = result.stdout.strip()
|
|
if not normalized:
|
|
pytest.fail(f"Git Bash cygpath returned an empty path for {rendered!r}")
|
|
return normalized
|
|
|
|
|
|
def _simulate_windows_locale(monkeypatch) -> None:
|
|
native_run = subprocess.run
|
|
|
|
def run_with_windows_locale(*args, **kwargs):
|
|
# Exercise real byte decoding even on UTF-8 POSIX hosts.
|
|
if kwargs.get("text"):
|
|
kwargs.setdefault("encoding", "cp1252")
|
|
return native_run(*args, **kwargs)
|
|
|
|
monkeypatch.setitem(globals(), "subprocess", SimpleNamespace(run=run_with_windows_locale))
|
|
|
|
|
|
@pytest.fixture(params=["bin", "usr/bin"], ids=["wrapper", "usr-bin"])
|
|
def git_bash_layout(tmp_path: Path, monkeypatch, request: pytest.FixtureRequest) -> Path:
|
|
"""Run a POSIX shim with Git for Windows' wrapper and usr/bin layouts."""
|
|
if os.name == "nt":
|
|
pytest.skip("POSIX executable shims; native Windows uses the real renderer tests")
|
|
native_bash = require_script_bash()
|
|
git_root = tmp_path / "Git installation"
|
|
usr_bin = git_root / "usr" / "bin"
|
|
usr_bin.mkdir(parents=True)
|
|
bash = git_root / request.param / "bash.exe"
|
|
bash.parent.mkdir(parents=True, exist_ok=True)
|
|
bash.write_text(f'#!/bin/sh\nexport PATH={shlex.quote(str(usr_bin))}\nexec {shlex.quote(native_bash)} "$@"\n', encoding="utf-8")
|
|
bash.chmod(0o755)
|
|
monkeypatch.setitem(globals(), "require_script_bash", lambda: str(bash))
|
|
monkeypatch.setitem(globals(), "os", SimpleNamespace(name="nt", environ=os.environ))
|
|
return usr_bin
|
|
|
|
|
|
def _write_cygpath_stubs(directory: Path, body: str) -> None:
|
|
# POSIX Bash resolves the bare name; the .exe also models the old lookup.
|
|
for name in ("cygpath", "cygpath.exe"):
|
|
path = directory / name
|
|
path.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
|
|
path.chmod(0o755)
|
|
|
|
|
|
def test_rendered_path_uses_selected_git_bash(git_bash_layout: Path, monkeypatch):
|
|
rendered = "/tmp/测试 user/$(printf changed)/nginx.local.conf"
|
|
native = r"C:\Users\测试 User\AppData\Local\Temp\nginx.local.conf"
|
|
monkeypatch.setenv("EXPECTED_MSYS_PATH", rendered)
|
|
monkeypatch.setenv("EXPECTED_NATIVE_PATH", native)
|
|
_write_cygpath_stubs(
|
|
git_bash_layout,
|
|
'[ "$1" = "-w" ] || exit 2\n[ "$2" = "$EXPECTED_MSYS_PATH" ] || exit 3\nprintf "%s\\n" "$EXPECTED_NATIVE_PATH"',
|
|
)
|
|
_simulate_windows_locale(monkeypatch)
|
|
|
|
assert _normalize_rendered_path(rendered) == native
|
|
|
|
|
|
@pytest.mark.parametrize("behavior", ["missing", "failed", "empty"])
|
|
def test_rendered_path_conversion_fails_loudly(git_bash_layout: Path, behavior: str):
|
|
if behavior == "failed":
|
|
_write_cygpath_stubs(git_bash_layout, 'echo "converter failed" >&2\nexit 5')
|
|
elif behavior == "empty":
|
|
_write_cygpath_stubs(git_bash_layout, "exit 0")
|
|
|
|
with pytest.raises(pytest.fail.Exception, match="cygpath") as failure:
|
|
_normalize_rendered_path("/tmp/nginx.local.conf")
|
|
if behavior == "failed":
|
|
assert "converter failed" in str(failure.value)
|
|
|
|
|
|
def test_renderer_decodes_paths_as_utf8(checkout: Path, monkeypatch):
|
|
_simulate_windows_locale(monkeypatch)
|
|
|
|
result = _run(checkout, None)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert Path(_normalize_rendered_path(result.stdout.strip())) == (checkout / "docker" / "nginx" / "nginx.local.conf").resolve()
|
|
|
|
|
|
@pytest.mark.parametrize("bind_host", [None, "", "127.0.0.1"], ids=["unset", "blank", "loopback"])
|
|
def test_default_bind_host_uses_the_tracked_config(checkout: Path, bind_host: str | None):
|
|
result = _run(checkout, bind_host)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert Path(_normalize_rendered_path(result.stdout.strip())) == (checkout / "docker" / "nginx" / "nginx.local.conf").resolve()
|
|
assert not (checkout / "temp").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("bind_host", [None, "", "127.0.0.1"], ids=["unset", "blank", "loopback"])
|
|
def test_default_bind_host_removes_a_config_rendered_for_an_earlier_bind_host(checkout: Path, bind_host: str | None):
|
|
assert _run(checkout, "0.0.0.0").returncode == 0
|
|
rendered = checkout / "temp" / "nginx.local.conf"
|
|
assert rendered.exists()
|
|
|
|
result = _run(checkout, bind_host)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert Path(_normalize_rendered_path(result.stdout.strip())) == (checkout / "docker" / "nginx" / "nginx.local.conf").resolve()
|
|
assert not rendered.exists(), "a stale render with the old listen lines should not be left behind"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("bind_host", "expected"),
|
|
[
|
|
("0.0.0.0", ["2026", "[::]:2026"]),
|
|
("192.0.2.10", ["192.0.2.10:2026"]),
|
|
("fd00::1", ["[fd00::1]:2026"]),
|
|
("[fd00::1]", ["[fd00::1]:2026"]),
|
|
("::", ["[::]:2026"]),
|
|
("::ffff:192.0.2.10", ["[::ffff:192.0.2.10]:2026"]),
|
|
("2001:db8:0:0:0:0:0:1", ["[2001:db8:0:0:0:0:0:1]:2026"]),
|
|
("localhost", ["localhost:2026"]),
|
|
("devbox.example-corp.internal", ["devbox.example-corp.internal:2026"]),
|
|
],
|
|
)
|
|
def test_bind_host_renders_a_config_listening_there(checkout: Path, bind_host: str, expected: list[str]):
|
|
result = _run(checkout, bind_host)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
rendered = Path(_normalize_rendered_path(result.stdout.strip()))
|
|
assert rendered == (checkout / "temp" / "nginx.local.conf").resolve()
|
|
content = rendered.read_text(encoding="utf-8")
|
|
assert _listen_directives(content) == expected
|
|
|
|
# Only the listen directives change.
|
|
def without_listen(text: str) -> list[str]:
|
|
return [line for line in text.splitlines() if not line.strip().startswith("listen ")]
|
|
|
|
assert without_listen(content) == without_listen(NGINX_LOCAL_CONF.read_text(encoding="utf-8"))
|
|
# serve.sh's stop_all finds the running master through the tracked config's pid file.
|
|
pid_directive = re.compile(r"^\s*pid\s+[^;]+;", re.M)
|
|
assert pid_directive.findall(content) == pid_directive.findall(NGINX_LOCAL_CONF.read_text(encoding="utf-8")) != []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bind_host",
|
|
[
|
|
"0.0.0.0; return 200",
|
|
"a b",
|
|
"$(id)",
|
|
# nginx accepts these and binds an address the operator did not write:
|
|
# "0" resolves to 0.0.0.0, "1.2.3" to 1.2.0.3, "010.0.0.1" to 10.0.0.1.
|
|
"0",
|
|
"1.2.3",
|
|
"010.0.0.1",
|
|
# nginx fails on these at startup ("host not found", "invalid IPv6
|
|
# address"), after serve.sh has already stopped the running stack.
|
|
"256.1.1.1",
|
|
"1.2.3.4.5",
|
|
"-foo",
|
|
"foo-",
|
|
"foo..bar",
|
|
"foo.",
|
|
"host_name",
|
|
"a" * 64,
|
|
"[[fd00::1]]",
|
|
"[fd00::1",
|
|
"fd00::1]",
|
|
"[192.0.2.10]",
|
|
"[localhost]",
|
|
"fd00:::1",
|
|
"fd00::1::2",
|
|
"1:2:3:4:5:6:7",
|
|
"1:2:3:4:5:6:7:8:9",
|
|
"12345::1",
|
|
"fd00::1%eth0",
|
|
"::ffff:192.0.2",
|
|
":1",
|
|
"1:",
|
|
# Every validator must see the whole value, not just its first line:
|
|
# a second line would become extra listen parameters.
|
|
"localhost\nbad",
|
|
"localhost\n",
|
|
"localhost\r",
|
|
"local\thost",
|
|
"192.0.2.10\nbad",
|
|
"fd00::1\nbad",
|
|
"[fd00::1]\nbad",
|
|
],
|
|
)
|
|
def test_bind_host_rejects_values_that_are_not_addresses(checkout: Path, bind_host: str):
|
|
result = _run(checkout, bind_host)
|
|
|
|
assert result.returncode != 0
|
|
assert "BIND_HOST must be an IP address or hostname" in result.stderr
|
|
assert not (checkout / "temp" / "nginx.local.conf").exists()
|
|
|
|
|
|
def test_render_fails_loudly_when_the_listen_directive_drifts(checkout: Path):
|
|
conf = checkout / "docker" / "nginx" / "nginx.local.conf"
|
|
conf.write_text(conf.read_text(encoding="utf-8").replace("listen 127.0.0.1:2026;", "listen 127.0.0.1:2027;"), encoding="utf-8")
|
|
|
|
result = _run(checkout, "0.0.0.0")
|
|
|
|
assert result.returncode != 0
|
|
assert "Could not find the loopback listen directive" in result.stderr
|
|
assert not (checkout / "temp" / "nginx.local.conf").exists()
|