deer-flow/backend/tests/test_helm_extensions_config_writable.py
Aari 236a068e77
fix(docker): let the Gateway write extensions_config.json in production (#4852)
* 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
2026-08-23 15:08:39 +08:00

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"] == {}