deer-flow/backend/tests/test_deploy_env_file.py
ningkexin96 4f948c7b8d
feat(deploy): select production Compose env files (#6522)
* feat(deploy): select production Compose env files

Use an operator-selected dotenv file consistently for interpolation, container environment, secret probes and build extras. Preserve default selection and shutdown after a profile is removed.

Co-Authored-By: Codex <noreply@openai.com>

* fix(deploy): address production env selection review

Clarify Docker launcher entry points and render real Compose through the launcher to verify both service environments.

Co-Authored-By: Codex <noreply@openai.com>

* test(deploy): cover unreadable env selectors

Reject unreadable explicit selectors before Docker is invoked, using POSIX permission checks and restoring the fixture mode in finally.

Co-Authored-By: Codex <noreply@openai.com>

* test(deploy): scope dotenv assertions to selected secrets

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:58:30 +08:00

222 lines
9.1 KiB
Python

"""Production Compose dotenv selection is explicit and shared by all consumers."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import pytest
import test_deploy_dotenv_secrets as deploy_secret_tests
from test_deploy_dotenv_secrets import BASH, _run_deploy_build, _worktree
from test_deploy_home_writability import _run_deploy
from test_deploy_home_writability import deploy_fixture as deploy_fixture
pytestmark = pytest.mark.skipif(BASH is None, reason="repo shell-script tests need Git Bash on Windows")
@pytest.mark.parametrize("selector_kind", ["relative", "absolute", "spaces"])
def test_deploy_selects_one_env_file_for_build_and_secret_probe(tmp_path, selector_kind):
worktree = _worktree(tmp_path)
(worktree / ".env").write_text("UV_EXTRAS=redis\n", encoding="utf-8")
selected = worktree / ("profiles/stage.env" if selector_kind != "spaces" else "profiles with spaces/stage env")
selected.parent.mkdir()
selected.write_text("UV_EXTRAS=discord\n", encoding="utf-8")
selector = str(selected) if selector_kind == "absolute" else str(selected.relative_to(worktree))
_, _, args, config_args, _, _ = _run_deploy_build(
tmp_path,
worktree,
shell_env={"DEER_FLOW_COMPOSE_ENV_FILE": selector},
compose_environment={"BETTER_AUTH_SECRET": "test-auth", "DEER_FLOW_INTERNAL_AUTH_TOKEN": "test-internal"},
)
assert "--env-file" in args
env_arg = args[args.index("--env-file") + 1]
assert config_args[config_args.index("--env-file") + 1] == env_arg
probe = subprocess.run(
[BASH, "-c", 'test -f "$1" && cmp -s "$1" "$2"', "--", env_arg, str(selected)],
capture_output=True,
check=False,
timeout=10,
)
assert probe.returncode == 0, "Compose and its secret probe must read the selected file"
@pytest.mark.parametrize("invalid_kind", ["missing", "directory", "unreadable"])
def test_deploy_rejects_invalid_explicit_env_file_before_docker(tmp_path, invalid_kind):
worktree = _worktree(tmp_path)
selected = worktree / "stage.env"
if invalid_kind == "directory":
selected.mkdir()
elif invalid_kind == "unreadable":
if os.name != "posix":
pytest.skip("file read permissions require a POSIX filesystem")
if os.geteuid() == 0:
pytest.skip("root can read files despite removed read permissions")
selected.write_text("UV_EXTRAS=discord\n", encoding="utf-8")
original_mode = selected.stat().st_mode if invalid_kind == "unreadable" else None
try:
if original_mode is not None:
selected.chmod(0o000)
if os.access(selected, os.R_OK):
pytest.skip("filesystem does not enforce file read permissions")
result, _, args, config_args, _, _ = _run_deploy_build(
tmp_path,
worktree,
shell_env={"DEER_FLOW_COMPOSE_ENV_FILE": str(selected)},
compose_environment={"BETTER_AUTH_SECRET": "test-auth", "DEER_FLOW_INTERNAL_AUTH_TOKEN": "test-internal"},
check=False,
)
finally:
if original_mode is not None:
selected.chmod(original_mode)
assert result.returncode != 0
assert "DEER_FLOW_COMPOSE_ENV_FILE" in result.stderr
assert not args
assert not config_args
def test_deploy_reads_uv_extras_from_selected_env_file(tmp_path):
worktree = _worktree(tmp_path)
(worktree / ".env").write_text("UV_EXTRAS=redis\n", encoding="utf-8")
selected = worktree / "stage.env"
selected.write_text("UV_EXTRAS=discord\n", encoding="utf-8")
capture = tmp_path / "selected-extras.txt"
docker = tmp_path / "bin" / "docker"
docker.parent.mkdir()
docker.write_text(
'#!/usr/bin/env sh\nprintf "%s" "${UV_EXTRAS:-}" > "$CAPTURE_EXTRAS"\nexit 0\n',
encoding="utf-8",
)
docker.chmod(0o755)
env = os.environ.copy()
env.pop("UV_EXTRAS", None)
env.update(
PATH=f"{docker.parent}{os.pathsep}{env['PATH']}",
DEER_FLOW_COMPOSE_ENV_FILE=str(selected),
DEER_FLOW_HOME=str(tmp_path / "home"),
BETTER_AUTH_SECRET="test-auth",
DEER_FLOW_INTERNAL_AUTH_TOKEN="test-internal",
CAPTURE_EXTRAS=str(capture),
)
result = subprocess.run(
[BASH, str(worktree / "scripts/deploy.sh"), "build"],
cwd=worktree,
env=env,
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert capture.read_text(encoding="utf-8") == "discord"
@pytest.mark.parametrize("use_selector", [False, True, ""], ids=["default", "selected", "empty"])
def test_compose_uses_selected_env_for_gateway_and_provisioner(tmp_path, use_selector):
docker = shutil.which("docker")
if not docker:
pytest.skip("real Docker Compose CLI required to render the production project")
version = subprocess.run([docker, "compose", "version"], capture_output=True, check=False, timeout=15)
if version.returncode:
pytest.skip("Docker Compose plugin unavailable")
worktree = _worktree(tmp_path)
(worktree / ".env").write_text("DEERFLOW_ENV_SELECTION_TEST=default\n", encoding="utf-8")
selected = worktree / "stage.env"
selected.write_text("DEERFLOW_ENV_SELECTION_TEST=stage\n", encoding="utf-8")
env = os.environ.copy()
env.pop("DEER_FLOW_COMPOSE_ENV_FILE", None)
if use_selector == "":
env["DEER_FLOW_COMPOSE_ENV_FILE"] = ""
elif use_selector:
env["DEER_FLOW_COMPOSE_ENV_FILE"] = str(selected)
env.update(
DEER_FLOW_HOME=str(tmp_path / "home"),
DEER_FLOW_CONFIG_PATH=str(worktree / "config.yaml"),
DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(worktree / "extensions_config.json"),
BETTER_AUTH_SECRET="test-auth",
DEER_FLOW_INTERNAL_AUTH_TOKEN="test-internal",
)
result = subprocess.run(
[docker, "compose", "--env-file", str(selected if use_selector else worktree / ".env"), "-f", str(worktree / "docker/docker-compose.yaml"), "config", "--format", "json"],
cwd=worktree,
env=env,
text=True,
capture_output=True,
check=False,
timeout=30,
)
assert result.returncode == 0, result.stderr
config = json.loads(result.stdout)
for service in ("gateway", "provisioner"):
assert config["services"][service]["environment"]["DEERFLOW_ENV_SELECTION_TEST"] == ("stage" if use_selector else "default")
def test_deploy_down_still_works_when_selected_env_file_was_removed(deploy_fixture):
worktree, env, home, capture = deploy_fixture
env["DEER_FLOW_COMPOSE_ENV_FILE"] = str(worktree / "profiles/missing.env")
env.pop("BETTER_AUTH_SECRET")
env.pop("DEER_FLOW_INTERNAL_AUTH_TOKEN")
result = _run_deploy(deploy_fixture, "down")
assert result.returncode == 0, result.stderr
assert capture.read_text(encoding="utf-8").splitlines()[-1] == "down"
assert not list(home.iterdir())
def test_deploy_env_file_reaches_containers_through_real_compose(tmp_path, monkeypatch):
docker = shutil.which("docker")
if not docker:
pytest.skip("real Docker Compose CLI required for the native launcher probe")
version = subprocess.run([docker, "compose", "version"], capture_output=True, check=False, timeout=15)
if version.returncode:
pytest.skip("Docker Compose plugin unavailable")
worktree = _worktree(tmp_path)
(worktree / ".env").write_text("NOT_A_SECRET=default\n", encoding="utf-8")
selected = worktree / "profiles with spaces" / "stage env"
selected.parent.mkdir()
selected.write_text("BETTER_AUTH_SECRET=test-auth\nDEER_FLOW_INTERNAL_AUTH_TOKEN=test-internal\nDEERFLOW_ENV_SELECTION_TEST=stage\n", encoding="utf-8")
rendered = tmp_path / "rendered-project.json"
stub = deploy_secret_tests._FAKE_DOCKER.replace("#!/usr/bin/env sh", "#!/usr/bin/env bash", 1)
prefix, suffix = stub.rsplit("exit 0\n", 1)
stub = (
prefix
+ """args=()
for arg in "$@"; do
[ "$arg" != "build" ] || break
args+=("$arg")
done
"$REAL_DOCKER" "${args[@]}" config --format json > "$CAPTURE_RENDERED_PROJECT"
exit 0
"""
+ suffix
)
monkeypatch.setattr(deploy_secret_tests, "_FAKE_DOCKER", stub)
_, observed, _, _, _, home = _run_deploy_build(
tmp_path,
worktree,
shell_env={"DEER_FLOW_COMPOSE_ENV_FILE": str(selected), "CAPTURE_RENDERED_PROJECT": str(rendered)},
real_docker=docker,
)
project = json.loads(rendered.read_text(encoding="utf-8"))
for service in ("gateway", "provisioner"):
assert project["services"][service]["environment"]["DEERFLOW_ENV_SELECTION_TEST"] == "stage"
assert observed["BETTER_AUTH_SECRET"] == ""
assert observed["DEER_FLOW_INTERNAL_AUTH_TOKEN"] == ""
assert project["services"]["frontend"]["environment"]["BETTER_AUTH_SECRET"] == "test-auth"
assert project["services"]["gateway"]["environment"]["DEER_FLOW_INTERNAL_AUTH_TOKEN"] == "test-internal"
for key in ("BETTER_AUTH_SECRET", "DEER_FLOW_INTERNAL_AUTH_TOKEN"):
assert not (home / deploy_secret_tests.PERSISTED_FILE[key]).exists(), f"Selected dotenv {key} must not be replaced with a generated secret"