mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* fix(docker): let the Gateway write extensions_config.json in production AGENTS.md states extensions_config.json may be edited at runtime through the Gateway API, and the Gateway implements that for the MCP enable switch, PUT/PATCH /api/mcp/config and the skill update route. Two properties of the production compose stack made every one of those writes fail: - the file was mounted read-only, and - Docker mounts it as its own mount point, so the temp-file-plus-rename in atomic_write_extensions_config hit EBUSY. Linux refuses rename() over a mount point whether or not the mount is writable, so making the mount read-write alone is not enough. Mount it read-write and fall back to an in-place overwrite on EBUSY only. The fallback is deliberately non-atomic and says so in a warning; it is reached only where the atomic route cannot work, and any other errno still propagates. config.yaml stays read-only: no API writes it. docker-compose-dev.yaml mounts the whole project directory, so the destination is an ordinary file there and this never surfaced in development. * test(docker): parse mount options instead of matching a :ro suffix Docker's short-syntax options segment is comma-separated, so a read-only mount can legally be spelled ":ro,z" or ":z,ro" — common with SELinux relabelling. Matching the raw string for a ":ro" suffix reads those as writable, which silently defeats the guard: the writability assertion would pass on a read-only mount, and the config.yaml assertion would fail on a correctly read-only one. Parse the options segment and test membership instead, and cover the parser with the spellings that broke the suffix check. * fix(config): harden mutable extensions config
76 lines
3.2 KiB
Python
76 lines
3.2 KiB
Python
"""Regression tests for the Helm extensions config write path."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
CHART = REPO_ROOT / "deploy" / "helm" / "deer-flow"
|
|
GATEWAY_TEMPLATE = CHART / "templates" / "gateway-deployment.yaml"
|
|
RUNTIME_CONFIG_PATH = "/app/backend/.deer-flow/extensions-config/extensions_config.json"
|
|
|
|
|
|
def _render_chart(*settings: str) -> list[dict]:
|
|
helm = shutil.which("helm")
|
|
if helm is None:
|
|
pytest.skip("helm is unavailable")
|
|
command = [helm, "template", "deer-flow", str(CHART)]
|
|
for setting in settings:
|
|
command.extend(["--set", setting])
|
|
rendered = subprocess.run(command, check=True, capture_output=True, text=True).stdout
|
|
return [document for document in yaml.safe_load_all(rendered) if isinstance(document, dict)]
|
|
|
|
|
|
def _gateway_deployment(documents: list[dict]) -> dict:
|
|
return next(document for document in documents if document.get("kind") == "Deployment" and document["metadata"]["name"].endswith("-gateway"))
|
|
|
|
|
|
def _named(items: list[dict], name: str) -> dict:
|
|
return next(item for item in items if item["name"] == name)
|
|
|
|
|
|
def test_helm_template_seeds_a_directory_backed_writable_extensions_config() -> None:
|
|
template = GATEWAY_TEMPLATE.read_text(encoding="utf-8")
|
|
assert f"value: {RUNTIME_CONFIG_PATH}" in template
|
|
assert "name: init-extensions" in template
|
|
assert "cp /extensions-seed/extensions_config.json /extensions-runtime/extensions_config.json" in template
|
|
assert "mountPath: /extensions-seed" in template
|
|
assert "mountPath: /app/backend/extensions_config.json" not in template
|
|
assert "subPath: extensions_config.json" not in template
|
|
|
|
|
|
@pytest.mark.parametrize("persistence_enabled", [True, False])
|
|
def test_rendered_helm_extensions_config_is_writable_and_seeded(persistence_enabled: bool) -> None:
|
|
documents = _render_chart(f"persistence.home.enabled={str(persistence_enabled).lower()}")
|
|
deployment = _gateway_deployment(documents)
|
|
pod_spec = deployment["spec"]["template"]["spec"]
|
|
gateway = _named(pod_spec["containers"], "gateway")
|
|
init_extensions = _named(pod_spec["initContainers"], "init-extensions")
|
|
|
|
env = {item["name"]: item for item in gateway["env"]}
|
|
assert env["DEER_FLOW_EXTENSIONS_CONFIG_PATH"]["value"] == RUNTIME_CONFIG_PATH
|
|
|
|
seed_mount = _named(init_extensions["volumeMounts"], "extensions-seed")
|
|
assert seed_mount["mountPath"] == "/extensions-seed"
|
|
assert seed_mount["readOnly"] is True
|
|
runtime_mount = _named(init_extensions["volumeMounts"], "home")
|
|
assert runtime_mount["mountPath"] == "/extensions-runtime"
|
|
assert runtime_mount["subPath"] == "deer-flow/extensions-config"
|
|
|
|
home_mount = _named(gateway["volumeMounts"], "home")
|
|
assert home_mount["mountPath"] == "/app/backend/.deer-flow"
|
|
assert home_mount["subPath"] == "deer-flow"
|
|
assert "readOnly" not in home_mount
|
|
|
|
volumes = {item["name"]: item for item in pod_spec["volumes"]}
|
|
assert volumes["extensions-seed"]["configMap"]["name"].endswith("-extensions")
|
|
if persistence_enabled:
|
|
assert volumes["home"]["persistentVolumeClaim"]["claimName"].endswith("-home")
|
|
else:
|
|
assert volumes["home"]["emptyDir"] == {}
|