mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(deploy): honor BETTER_AUTH_SECRET and DEER_FLOW_INTERNAL_AUTH_TOKEN from .env deploy.sh only checked the shell before reloading a persisted secret or generating a fresh one and exporting it. Compose interpolation lets shell variables outrank --env-file, so a secret written to the repo-root .env -- the surface every deployment doc points at -- was silently replaced by the generated one: sessions were signed with a secret the operator never chose and Gateway workers outside the stack got 401 with the configured token. Leave a .env-provided secret unexported so Compose parses it itself (shell -> .env -> persisted file -> generated), and keep generating when the shell exports an empty value, since Compose would otherwise pass the empty string through. * docs(changelog): reference #5928 in the deploy.sh dotenv secrets entries * fix(deploy): decide whether .env provides a secret with Compose's own parser read_dotenv_value only matches KEY=VALUE and returns the literal text, but Compose also accepts `KEY: VALUE` lines and interpolates `${VAR}` inside values. So `TOKEN: abc` looked absent (and got shadowed by a generated token) while `TOKEN=${UNSET}` looked present (and suppressed generation, leaving the stack with an empty token). Ask Compose for its resolved interpolation environment instead (`docker compose config --environment`, same --env-file, stub project on stdin, project directory docker/) and treat a resolved-empty value as missing. Clients older than Compose 2.28 lack the flag and fall back to the plain reader. Tests cover the canned Compose answers, the fallback, and every dotenv spelling against the real Compose client when installed. * fix(deploy): resolve .env secrets through Compose on every supported client `config --environment` needs Compose 2.28, so 2.24–2.27 clients took the plain KEY=VALUE fallback and kept both defects: `TOKEN: abc` was missed and shadowed, `TOKEN=${UNSET}` counted as present and left the stack with an empty token. Drop the fallback. Render a stub project whose only environment entry is `${KEY}` through `docker compose config` — same --env-file and project directory as the real command — and read the interpolated value back; `""` means empty or unset. This is plain interpolation, available on every Compose v2, and verified identical on 2.24.0, 2.27.0 and 2.32. A failing probe stops the script instead of guessing. The real-Compose tests now run against the docker CLI and any standalone binaries listed in DEER_FLOW_TEST_COMPOSE_BINARIES.
383 lines
17 KiB
Python
383 lines
17 KiB
Python
"""deploy.sh must not shadow secrets the operator wrote to the repo-root .env.
|
|
|
|
Compose interpolates ``${BETTER_AUTH_SECRET}`` / ``${DEER_FLOW_INTERNAL_AUTH_TOKEN}``
|
|
from the shell environment first and ``--env-file`` second. deploy.sh only ever
|
|
looked at the shell before generating (or reloading a persisted) secret and
|
|
exporting it, so a value in ``.env`` -- the surface every deployment doc points
|
|
at -- was silently replaced by the auto-generated one.
|
|
|
|
Whether ``.env`` provides a value is Compose's call, not a ``KEY=VALUE`` grep:
|
|
Compose accepts ``KEY: VALUE`` lines and interpolates ``${VAR}`` inside values.
|
|
The script therefore renders a stub project whose one environment entry is
|
|
``${KEY}`` through ``docker compose config`` and reads the value back -- a
|
|
probe every Compose v2 client can answer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from support.shell import find_script_bash
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# Every test here shells out to bash; on Windows that must be Git Bash --
|
|
# the WSL launcher and Store alias stubs cannot run the repo scripts.
|
|
BASH = find_script_bash()
|
|
pytestmark = pytest.mark.skipif(BASH is None, reason="repo shell-script tests need Git Bash on Windows")
|
|
|
|
SECRETS = ("BETTER_AUTH_SECRET", "DEER_FLOW_INTERNAL_AUTH_TOKEN")
|
|
PERSISTED_FILE = {
|
|
"BETTER_AUTH_SECRET": ".better-auth-secret",
|
|
"DEER_FLOW_INTERNAL_AUTH_TOKEN": ".internal-auth-token",
|
|
}
|
|
GENERATED = re.compile(r"set:[A-Za-z0-9_\-]{32,}")
|
|
|
|
# The fake docker answers `compose ... config` the way Compose renders the
|
|
# script's stub project: it reads the `${KEY}` reference off stdin, looks the
|
|
# key up in a canned resolved environment (what real Compose would compute for
|
|
# the .env under test) and prints the environment entry, `""` when the key is
|
|
# empty or missing -- or fails with FAKE_COMPOSE_CONFIG_RC. Every other
|
|
# invocation stands in for `compose build` and records, per secret, whether
|
|
# the variable reached its environment and with which value: "set:<value>" is
|
|
# what Compose would take from the shell, "" means Compose falls through to
|
|
# --env-file.
|
|
_FAKE_DOCKER = """#!/usr/bin/env sh
|
|
case " $* " in
|
|
*" config "*)
|
|
for arg in "$@"; do printf "%s\\n" "$arg"; done > "$CAPTURE_CONFIG_ARGS"
|
|
cat > "$CAPTURE_CONFIG_STDIN"
|
|
if [ -n "${REAL_DOCKER:-}" ]; then exec "$REAL_DOCKER" "$@" < "$CAPTURE_CONFIG_STDIN"; fi
|
|
if [ "${FAKE_COMPOSE_CONFIG_RC:-0}" != 0 ]; then echo "fake compose: cannot load project" >&2; exit "$FAKE_COMPOSE_CONFIG_RC"; fi
|
|
key="$(sed -n 's/.*\\${\\([A-Za-z_][A-Za-z0-9_]*\\)}.*/\\1/p' "$CAPTURE_CONFIG_STDIN" | head -n 1)"
|
|
value=""
|
|
[ -z "${FAKE_COMPOSE_ENVIRONMENT:-}" ] || value="$(sed -n "s/^${key}=//p" "$FAKE_COMPOSE_ENVIRONMENT" | head -n 1)"
|
|
[ -n "$value" ] || value='""'
|
|
printf 'name: probe\\nservices:\\n probe:\\n environment:\\n DEER_FLOW_PROBE_VALUE: %s\\n image: scratch\\n' "$value"
|
|
exit 0
|
|
;;
|
|
esac
|
|
{
|
|
printf 'BETTER_AUTH_SECRET=%s\\n' "${BETTER_AUTH_SECRET+set:}${BETTER_AUTH_SECRET:-}"
|
|
printf 'DEER_FLOW_INTERNAL_AUTH_TOKEN=%s\\n' "${DEER_FLOW_INTERNAL_AUTH_TOKEN+set:}${DEER_FLOW_INTERNAL_AUTH_TOKEN:-}"
|
|
} > "$CAPTURE_SECRETS"
|
|
for arg in "$@"; do printf "%s\\n" "$arg"; done > "$CAPTURE_DOCKER_ARGS"
|
|
exit 0
|
|
"""
|
|
|
|
|
|
def _worktree(tmp_path: Path) -> Path:
|
|
worktree = tmp_path / "repo"
|
|
shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts")
|
|
shutil.copytree(REPO_ROOT / "docker", worktree / "docker")
|
|
(worktree / "backend").mkdir()
|
|
(worktree / "config.yaml").write_text("database:\n backend: sqlite\n", encoding="utf-8")
|
|
(worktree / "extensions_config.json").write_text('{"mcpServers":{},"skills":{}}\n', encoding="utf-8")
|
|
return worktree
|
|
|
|
|
|
def _run_deploy_build(
|
|
tmp_path: Path,
|
|
worktree: Path,
|
|
*,
|
|
compose_environment: dict[str, str] | None = None,
|
|
compose_config_rc: int = 0,
|
|
real_docker: str | None = None,
|
|
shell_env: dict[str, str] | None = None,
|
|
check: bool = True,
|
|
):
|
|
"""Run ``deploy.sh build`` against the fake docker and return what it observed."""
|
|
capture_secrets = tmp_path / "secrets.txt"
|
|
capture_args = tmp_path / "docker_args.txt"
|
|
capture_config_args = tmp_path / "config_args.txt"
|
|
capture_config_stdin = tmp_path / "config_stdin.yaml"
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir(exist_ok=True)
|
|
docker = bin_dir / "docker"
|
|
docker.write_text(_FAKE_DOCKER, encoding="utf-8")
|
|
docker.chmod(0o755)
|
|
|
|
env = os.environ.copy()
|
|
for key in (*SECRETS, "UV_EXTRAS", "REAL_DOCKER", "FAKE_COMPOSE_ENVIRONMENT", "FAKE_COMPOSE_CONFIG_RC"):
|
|
env.pop(key, None)
|
|
env["DEER_FLOW_HOME"] = str(tmp_path / "deer-flow-home")
|
|
env["CAPTURE_SECRETS"] = str(capture_secrets)
|
|
env["CAPTURE_DOCKER_ARGS"] = str(capture_args)
|
|
env["CAPTURE_CONFIG_ARGS"] = str(capture_config_args)
|
|
env["CAPTURE_CONFIG_STDIN"] = str(capture_config_stdin)
|
|
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
|
if compose_environment is not None:
|
|
canned = tmp_path / "compose_environment.txt"
|
|
canned.write_text("".join(f"{k}={v}\n" for k, v in compose_environment.items()), encoding="utf-8")
|
|
env["FAKE_COMPOSE_ENVIRONMENT"] = str(canned)
|
|
if compose_config_rc:
|
|
env["FAKE_COMPOSE_CONFIG_RC"] = str(compose_config_rc)
|
|
if real_docker:
|
|
env["REAL_DOCKER"] = real_docker
|
|
env.update(shell_env or {})
|
|
|
|
result = subprocess.run(
|
|
[BASH, str(worktree / "scripts" / "deploy.sh"), "build"],
|
|
cwd=worktree,
|
|
env=env,
|
|
check=check,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
observed = {}
|
|
if capture_secrets.exists():
|
|
observed = dict(line.split("=", 1) for line in capture_secrets.read_text(encoding="utf-8").splitlines())
|
|
args = capture_args.read_text(encoding="utf-8").splitlines() if capture_args.exists() else []
|
|
config_args = capture_config_args.read_text(encoding="utf-8").splitlines() if capture_config_args.exists() else []
|
|
config_stdin = capture_config_stdin.read_text(encoding="utf-8") if capture_config_stdin.exists() else ""
|
|
return result, observed, args, config_args, config_stdin, Path(env["DEER_FLOW_HOME"])
|
|
|
|
|
|
def _other(key: str) -> str:
|
|
return next(secret for secret in SECRETS if secret != key)
|
|
|
|
|
|
# ── The script asks Compose, and trusts its answer ──────────────────────────
|
|
|
|
|
|
def test_deploy_asks_compose_to_interpolate_the_secret_like_the_real_project(tmp_path):
|
|
"""The probe renders ``${KEY}`` with the same --env-file and project directory as the real command."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text("BETTER_AUTH_SECRET=from-dotenv\n", encoding="utf-8")
|
|
|
|
_, _, args, config_args, config_stdin, _ = _run_deploy_build(tmp_path, worktree, compose_environment={"BETTER_AUTH_SECRET": "from-dotenv", "DEER_FLOW_INTERNAL_AUTH_TOKEN": "x"})
|
|
|
|
assert config_args[:1] == ["compose"]
|
|
assert "config" in config_args
|
|
assert "--env-file" in config_args
|
|
assert config_args[config_args.index("--env-file") + 1] == args[args.index("--env-file") + 1]
|
|
# The probe resolves the default .env from the same project directory as
|
|
# the real command (the compose file's directory), not from the cwd.
|
|
assert "--project-directory" in config_args
|
|
probe_dir = config_args[config_args.index("--project-directory") + 1]
|
|
assert Path(probe_dir).resolve() == Path(args[args.index("-f") + 1]).resolve().parent
|
|
# The stub project on stdin is what gets interpolated: it must reference
|
|
# the secret and nothing from the real compose file.
|
|
assert config_args[config_args.index("-f") + 1] == "-"
|
|
assert "${DEER_FLOW_INTERNAL_AUTH_TOKEN}" in config_stdin
|
|
assert "docker-compose.yaml" not in config_stdin
|
|
|
|
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
@pytest.mark.parametrize(
|
|
"dotenv_line",
|
|
["{key}=from-dotenv", "{key}: from-dotenv", '{key}="from-${{OTHER}}"'],
|
|
ids=["equals", "colon", "quoted-interpolated"],
|
|
)
|
|
def test_deploy_leaves_a_compose_resolved_secret_for_compose_instead_of_generating_one(tmp_path, key, dotenv_line):
|
|
"""Whatever the dotenv spelling, a value Compose resolves is left to Compose."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text("OTHER=dotenv\n" + dotenv_line.format(key=key) + "\n", encoding="utf-8")
|
|
|
|
result, observed, args, _, _, home = _run_deploy_build(tmp_path, worktree, compose_environment={key: "from-dotenv"})
|
|
|
|
# Compose reads the dotenv itself; an exported copy would outrank it.
|
|
assert "--env-file" in args
|
|
assert observed[key] == "", f"{key} exported into the compose environment: {observed[key]!r}"
|
|
assert not (home / PERSISTED_FILE[key]).exists(), "a persisted secret was generated despite the .env value"
|
|
assert f"{key} loaded from" in result.stdout
|
|
assert ".env" in result.stdout
|
|
|
|
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_deploy_generates_when_compose_resolves_the_dotenv_value_to_empty(tmp_path, key):
|
|
"""``KEY=${UNSET}`` looks set to a grep but is empty to Compose: generate."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{key}=${{UNSET_DEPLOY_SECRET}}\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, home = _run_deploy_build(tmp_path, worktree, compose_environment={key: "", _other(key): "x"})
|
|
|
|
assert GENERATED.fullmatch(observed[key]), observed[key]
|
|
assert (home / PERSISTED_FILE[key]).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_deploy_prefers_dotenv_secret_over_the_persisted_generated_one(tmp_path, key):
|
|
"""An operator-written .env value wins over the file an earlier run generated."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{key}=from-dotenv\n", encoding="utf-8")
|
|
home = tmp_path / "deer-flow-home"
|
|
home.mkdir()
|
|
(home / PERSISTED_FILE[key]).write_text("from-persisted-file\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, _ = _run_deploy_build(tmp_path, worktree, compose_environment={key: "from-dotenv"})
|
|
|
|
assert observed[key] == "", f"the persisted secret shadowed the .env value: {observed[key]!r}"
|
|
|
|
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_deploy_still_generates_and_persists_a_secret_when_dotenv_has_none(tmp_path, key):
|
|
"""Without an operator value the script keeps its generate-once contract."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{_other(key)}=x\nPORT=2026\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, home = _run_deploy_build(tmp_path, worktree, compose_environment={_other(key): "x", "PORT": "2026"})
|
|
|
|
assert GENERATED.fullmatch(observed[key]), observed[key]
|
|
generated = observed[key].removeprefix("set:")
|
|
persisted = home / PERSISTED_FILE[key]
|
|
assert persisted.read_text(encoding="utf-8").strip() == generated
|
|
assert (persisted.stat().st_mode & 0o777) == 0o600 or os.name == "nt"
|
|
|
|
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_deploy_keeps_shell_export_ahead_of_dotenv(tmp_path, key):
|
|
"""An exported shell value keeps compose precedence: it wins over .env untouched."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{key}=from-dotenv\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, home = _run_deploy_build(tmp_path, worktree, compose_environment={key: "from-shell"}, shell_env={key: "from-shell"})
|
|
|
|
assert observed[key] == "set:from-shell"
|
|
assert not (home / PERSISTED_FILE[key]).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_deploy_treats_an_empty_shell_export_as_missing_not_as_dotenv_provided(tmp_path, key):
|
|
"""Compose lets an exported-but-empty shell variable outrank .env.
|
|
|
|
Compose renders that as ``""``; leaving it alone would hand the stack an
|
|
empty secret, so the script must still generate one (and export it).
|
|
"""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{key}=from-dotenv\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, home = _run_deploy_build(tmp_path, worktree, compose_environment={key: ""}, shell_env={key: ""})
|
|
|
|
assert GENERATED.fullmatch(observed[key]), observed[key]
|
|
assert (home / PERSISTED_FILE[key]).exists()
|
|
|
|
|
|
def test_deploy_stops_when_compose_cannot_interpolate_instead_of_guessing(tmp_path):
|
|
"""A failing probe must not silently fall through to a generated, shadowing secret."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text("BETTER_AUTH_SECRET=from-dotenv\n", encoding="utf-8")
|
|
|
|
result, observed, args, config_args, _, home = _run_deploy_build(tmp_path, worktree, compose_config_rc=15, check=False)
|
|
|
|
assert config_args, "the script must have asked Compose"
|
|
assert result.returncode != 0
|
|
assert "could not resolve BETTER_AUTH_SECRET" in result.stderr
|
|
assert "fake compose: cannot load project" in result.stderr
|
|
assert not args, "no build was attempted"
|
|
assert not observed
|
|
assert not (home / PERSISTED_FILE["BETTER_AUTH_SECRET"]).exists()
|
|
|
|
|
|
# ── Against real Compose clients, when installed ────────────────────────────
|
|
|
|
|
|
def _compose_clients() -> list[tuple[str, str]]:
|
|
"""The `docker` CLI plus any standalone binaries named in DEER_FLOW_TEST_COMPOSE_BINARIES."""
|
|
clients: list[tuple[str, str]] = []
|
|
docker = shutil.which("docker")
|
|
if docker and _renders_stub_project([docker, "compose"]):
|
|
clients.append(("docker", docker))
|
|
for binary in filter(None, os.environ.get("DEER_FLOW_TEST_COMPOSE_BINARIES", "").split(os.pathsep)):
|
|
if _renders_stub_project([binary]):
|
|
clients.append((Path(binary).name, binary))
|
|
return clients
|
|
|
|
|
|
def _renders_stub_project(command: list[str]) -> bool:
|
|
try:
|
|
probe = subprocess.run(
|
|
[*command, "-f", "-", "config"],
|
|
input="services:\n probe:\n image: scratch\n",
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
return probe.returncode == 0
|
|
|
|
|
|
COMPOSE_CLIENTS = _compose_clients()
|
|
real_compose = pytest.mark.parametrize(
|
|
"client",
|
|
[pytest.param(path, id=name) for name, path in COMPOSE_CLIENTS] or [pytest.param(None, id="none", marks=pytest.mark.skip(reason="no docker compose client installed"))],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def real_docker(client: str, tmp_path: Path) -> str:
|
|
"""A `docker`-shaped entry point for the client: the CLI itself, or a shim that drops the `compose` word for a standalone binary."""
|
|
if Path(client).name == "docker":
|
|
return client
|
|
shim = tmp_path / "compose-shim" / "docker"
|
|
shim.parent.mkdir()
|
|
shim.write_text(f'#!/usr/bin/env sh\n[ "$1" = compose ] && shift\nexec "{client}" "$@"\n', encoding="utf-8")
|
|
shim.chmod(0o755)
|
|
return str(shim)
|
|
|
|
|
|
def _resolved_by(real_docker: str, dotenv: Path, key: str) -> str:
|
|
rendered = subprocess.run(
|
|
[real_docker, "compose", "--env-file", str(dotenv), "-f", "-", "config"],
|
|
input=f"services:\n probe:\n image: scratch\n environment:\n DEER_FLOW_PROBE_VALUE: ${{{key}}}\n",
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout
|
|
return re.search(r"^\s*DEER_FLOW_PROBE_VALUE: (.*)$", rendered, re.M).group(1)
|
|
|
|
|
|
@real_compose
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
@pytest.mark.parametrize(
|
|
("dotenv_line", "expected"),
|
|
[
|
|
("{key}=from-dotenv", "from-dotenv"),
|
|
("{key}: from-colon", "from-colon"),
|
|
('{key}="from-${{OTHER}}"', "from-dotenv"),
|
|
("{key}=${{UNSET_DEPLOY_SECRET:-defaulted}}", "defaulted"),
|
|
],
|
|
ids=["equals", "colon", "quoted-interpolated", "default-expansion"],
|
|
)
|
|
def test_real_compose_dotenv_forms_are_left_for_compose(tmp_path, real_docker, key, dotenv_line, expected):
|
|
"""Every spelling Compose accepts counts as provided, and Compose sees the operator's value."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text("OTHER=dotenv\n" + dotenv_line.format(key=key) + "\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, home = _run_deploy_build(tmp_path, worktree, real_docker=real_docker)
|
|
|
|
assert observed[key] == "", f"{key} exported into the compose environment: {observed[key]!r}"
|
|
assert not (home / PERSISTED_FILE[key]).exists()
|
|
assert _resolved_by(real_docker, worktree / ".env", key) == expected
|
|
|
|
|
|
@real_compose
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_real_compose_unset_interpolation_in_dotenv_still_gets_a_generated_secret(tmp_path, real_docker, key):
|
|
"""``KEY=${UNSET}`` is empty to Compose, so the script must generate."""
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{key}=${{UNSET_DEPLOY_SECRET}}\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, home = _run_deploy_build(tmp_path, worktree, real_docker=real_docker)
|
|
|
|
assert GENERATED.fullmatch(observed[key]), observed[key]
|
|
assert (home / PERSISTED_FILE[key]).exists()
|
|
|
|
|
|
@real_compose
|
|
@pytest.mark.parametrize("key", SECRETS)
|
|
def test_real_compose_empty_shell_export_still_gets_a_generated_secret(tmp_path, real_docker, key):
|
|
worktree = _worktree(tmp_path)
|
|
(worktree / ".env").write_text(f"{key}=from-dotenv\n", encoding="utf-8")
|
|
|
|
_, observed, _, _, _, _ = _run_deploy_build(tmp_path, worktree, real_docker=real_docker, shell_env={key: ""})
|
|
|
|
assert GENERATED.fullmatch(observed[key]), observed[key]
|