mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-09 04:20:34 +00:00
* fix(docker): keep local config out of the image build context backend/Dockerfile copies the backend tree wholesale (COPY backend ./backend), and frontend/Dockerfile does the same for frontend/. .dockerignore excluded .env files and runtime data, but not the local configuration: config.yaml, the config.yaml.bak that make config-upgrade writes beside it, extensions_config.json and legacy mcp_config.json. The config resolvers still read these from the legacy backend/ location, so on a checkout that keeps them there every locally built gateway image (dev and prod) carried the model API keys and MCP credentials in its layers, under the file Compose mounts over at runtime. frontend/.env.local, the documented place for BETTER_AUTH_SECRET in local development, was copied into the frontend image the same way, where next build and next start also read it. Exclude those files at the root and in backend/, and .env*.local at any depth. None has tracked content; the build's file list is otherwise unchanged. * docs(changelog): reference #7958 for the Docker build-context fix * fix(docker): keep local HTTPS keys and snapshot credentials out of images frontend/.gitignore also ignores *.pem, the mkcert key pair for local HTTPS, and COPY frontend ./frontend shipped it into the dev and prod frontend images. Exclude frontend/*.pem. The trailing !backend/extensions/sources/** re-inclusion wins over every earlier rule, so a hand-edited extension snapshot could still carry .env files or keys into the gateway image. Exclude, after the re-inclusion, exactly the files the extension manager already refuses to snapshot; a test pins that set to the manager's own _SENSITIVE_FILENAMES and _SENSITIVE_SUFFIXES. The test's matcher now translates patterns as Docker does (** across segments, * within one, character classes, parent-directory matches), so it can model the mid-path ** rules; its predictions agree with BuildKit on every test path. The README and changelog now say that a locally built frontend image no longer inlines NEXT_PUBLIC_* values from frontend/.env.local. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
202 lines
8.3 KiB
Python
202 lines
8.3 KiB
Python
"""Regression test keeping host-local data out of the Docker build context.
|
|
|
|
``backend/Dockerfile`` copies the backend tree wholesale (``COPY backend ./backend``),
|
|
so every path under ``backend/`` that ``.dockerignore`` does not exclude is shipped
|
|
into the image. The runtime directories are written by a *running* DeerFlow, not by
|
|
a build or local deployment:
|
|
|
|
- Exact ``.env`` files hold deployment secrets at the repository root and in
|
|
the backend/frontend projects.
|
|
- ``DEER_FLOW_HOME`` (``backend/.deer-flow`` by default) holds the sqlite database,
|
|
per-user agent definitions and uploads, and ``.jwt_secret``.
|
|
- ``backend/sandbox`` is the local sandbox provider's workspace root, created by
|
|
``backend/Makefile`` and written by agent runs.
|
|
- ``config.yaml`` (and the ``config.yaml.bak`` that ``make config-upgrade`` writes
|
|
beside it), ``extensions_config.json`` and legacy ``mcp_config.json`` hold model
|
|
API keys and MCP credentials. The resolvers still read them from the legacy
|
|
``backend/`` location, where ``COPY backend ./backend`` would ship them.
|
|
- ``.env*.local`` files hold local overrides; ``frontend/.env.local`` is the
|
|
documented place for ``BETTER_AUTH_SECRET``, and ``COPY frontend ./frontend``
|
|
ships it into the image that ``next build`` and ``next start`` read it from.
|
|
``frontend/*.pem`` is the mkcert key pair for local HTTPS.
|
|
- Extension snapshots under ``backend/extensions/sources/`` are re-included
|
|
wholesale, so the files the extension manager refuses to snapshot are excluded
|
|
again after that re-inclusion; a hand-edited snapshot cannot carry them in.
|
|
|
|
Leaving them in the context has two consequences. Anyone who builds an image on a
|
|
host that has run DeerFlow bakes that state — including the JWT secret and the user
|
|
database — into the image. And because the Gateway container creates some of those
|
|
directories as root, the build client eventually cannot read them and the build
|
|
fails outright::
|
|
|
|
target gateway: failed to solve: error from sender:
|
|
open .../.deer-flow/users/<uuid>/integrations/lark-cli: permission denied
|
|
|
|
None of these paths has tracked content, so excluding them costs the build nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
import pytest
|
|
|
|
from deerflow.extensions import manager as extension_manager
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DOCKERIGNORE = REPO_ROOT / ".dockerignore"
|
|
|
|
# Host-local runtime and secret paths that must never enter the build context.
|
|
HOST_LOCAL_PATHS = [
|
|
".env",
|
|
"backend/.env",
|
|
"frontend/.env",
|
|
".deer-flow/integrations/skills/provider/pack/SKILL.md",
|
|
"backend/.deer-flow/data/deerflow.db",
|
|
"backend/.deer-flow/.jwt_secret",
|
|
"backend/.deer-flow/users/some-user/agents/my-agent/config.yaml",
|
|
"backend/sandbox/some-thread/scratch.py",
|
|
"config.yaml",
|
|
"config.yaml.bak",
|
|
"extensions_config.json",
|
|
"mcp_config.json",
|
|
"backend/config.yaml",
|
|
"backend/config.yaml.bak",
|
|
"backend/extensions_config.json",
|
|
"backend/mcp_config.json",
|
|
"frontend/.env.local",
|
|
"frontend/.env.production.local",
|
|
"backend/.env.local",
|
|
"frontend/localhost.pem",
|
|
"frontend/localhost-key.pem",
|
|
"backend/extensions/sources/demo/.env",
|
|
"backend/extensions/sources/demo/.env.local",
|
|
"backend/extensions/sources/demo/src/demo/certs/client.pem",
|
|
]
|
|
|
|
# Paths the build genuinely needs; the exclusions must not swallow them.
|
|
BUILD_INPUT_PATHS = [
|
|
".env.example",
|
|
"frontend/.env.example",
|
|
"backend/pyproject.toml",
|
|
"backend/app/gateway/app.py",
|
|
"backend/packages/harness/deerflow/config/extensions_config.py",
|
|
"backend/packages/harness/deerflow/config/app_config.py",
|
|
"frontend/src/env.js",
|
|
"backend/extensions/sources/demo/pyproject.toml",
|
|
"backend/extensions/sources/demo/README.md",
|
|
"backend/extensions/sources/demo/src/demo/__init__.py",
|
|
"backend/extensions/sources/demo/src/demo/_native.so",
|
|
]
|
|
|
|
# A directory inside an extension snapshot; refused files must stay out at any depth.
|
|
SNAPSHOT_ROOT = "backend/extensions/sources/demo/src/demo"
|
|
|
|
|
|
def _ignore_patterns() -> list[str]:
|
|
lines = DOCKERIGNORE.read_text(encoding="utf-8").splitlines()
|
|
return [line.strip() for line in lines if line.strip() and not line.lstrip().startswith("#")]
|
|
|
|
|
|
def _pattern_regex(pattern: str) -> re.Pattern[str]:
|
|
"""Translate one pattern the way Docker's matcher does.
|
|
|
|
``**`` spans any number of path segments, ``*`` and ``?`` stay within one
|
|
segment, ``[...]`` is a character class, and every pattern is anchored at
|
|
the build-context root.
|
|
"""
|
|
out: list[str] = []
|
|
index = 0
|
|
while index < len(pattern):
|
|
if pattern.startswith("**/", index):
|
|
out.append("(?:.*/)?")
|
|
index += 3
|
|
elif pattern.startswith("**", index):
|
|
out.append(".*")
|
|
index += 2
|
|
elif pattern[index] == "*":
|
|
out.append("[^/]*")
|
|
index += 1
|
|
elif pattern[index] == "?":
|
|
out.append("[^/]")
|
|
index += 1
|
|
elif pattern[index] == "[" and (end := pattern.find("]", index)) != -1:
|
|
body = pattern[index + 1 : end]
|
|
out.append("[" + ("^" + body[1:] if body.startswith(("!", "^")) else body) + "]")
|
|
index = end + 1
|
|
else:
|
|
out.append(re.escape(pattern[index]))
|
|
index += 1
|
|
return re.compile("".join(out) + r"\Z")
|
|
|
|
|
|
def _pattern_matches(pattern: str, path: str) -> bool:
|
|
"""Whether one non-negated pattern matches *path* or one of its parent directories."""
|
|
regex = _pattern_regex(pattern.rstrip("/"))
|
|
parts = PurePosixPath(path).parts
|
|
return any(regex.match("/".join(parts[:depth])) for depth in range(1, len(parts) + 1))
|
|
|
|
|
|
def _is_excluded(patterns: list[str], path: str) -> bool:
|
|
"""Resolve Docker's last-matching-pattern-wins exclusion state."""
|
|
excluded = False
|
|
for raw_pattern in patterns:
|
|
negated = raw_pattern.startswith("!")
|
|
pattern = raw_pattern[1:] if negated else raw_pattern
|
|
if _pattern_matches(pattern, path):
|
|
excluded = not negated
|
|
return excluded
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("patterns", "expected"),
|
|
[
|
|
(["backend/runtime.json", "!backend/runtime.json"], False),
|
|
(["!backend/runtime.json", "backend/runtime.json"], True),
|
|
],
|
|
)
|
|
def test_exclusion_uses_last_matching_pattern(patterns: list[str], expected: bool) -> None:
|
|
assert _is_excluded(patterns, "backend/runtime.json") is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("pattern", "path", "expected"),
|
|
[
|
|
("config.yaml", "config.yaml", True),
|
|
("config.yaml", "backend/config.yaml", False),
|
|
("frontend/*.pem", "frontend/localhost.pem", True),
|
|
("frontend/*.pem", "frontend/certs/localhost.pem", False),
|
|
("**/.env*.local", "frontend/.env.local", True),
|
|
("**/.env*.local", ".env.example", False),
|
|
("backend/extensions/sources/**/.env", "backend/extensions/sources/demo/.env", True),
|
|
("backend/extensions/sources/**/.env", "backend/extensions/sources/demo/a/b/.env", True),
|
|
("**/.deer-flow/", "backend/.deer-flow/data/deerflow.db", True),
|
|
("*.py[cod]", "module.pyc", True),
|
|
("*.py[cod]", "backend/module.pyc", False),
|
|
],
|
|
)
|
|
def test_pattern_matcher_models_docker(pattern: str, path: str, expected: bool) -> None:
|
|
assert _pattern_matches(pattern, path) is expected
|
|
|
|
|
|
@pytest.mark.parametrize("local_path", HOST_LOCAL_PATHS)
|
|
def test_host_local_data_is_excluded_from_build_context(local_path: str) -> None:
|
|
patterns = _ignore_patterns()
|
|
assert _is_excluded(patterns, local_path), f"{local_path} would be copied into the image; add a .dockerignore entry covering it"
|
|
|
|
|
|
@pytest.mark.parametrize("build_input", BUILD_INPUT_PATHS)
|
|
def test_build_inputs_are_still_included(build_input: str) -> None:
|
|
patterns = _ignore_patterns()
|
|
assert not _is_excluded(patterns, build_input), f"{build_input} is needed by the build but is excluded"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[".env", ".env.local", ".env.production", *sorted(extension_manager._SENSITIVE_FILENAMES), *(f"secret{suffix}" for suffix in sorted(extension_manager._SENSITIVE_SUFFIXES))],
|
|
)
|
|
def test_files_the_extension_manager_refuses_stay_out_of_snapshots(name: str) -> None:
|
|
patterns = _ignore_patterns()
|
|
assert _is_excluded(patterns, f"{SNAPSHOT_ROOT}/{name}"), f"extension snapshots are re-included wholesale; exclude {name} after the re-inclusion"
|