mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(helm): wire PROVISIONER_API_KEY through the chart's app Secret The chart enables the sandbox provisioner by default and points config.sandbox.provisioner_url at it, but nothing rendered PROVISIONER_API_KEY. Since the provisioner started requiring the key (#4116) its verify_api_key middleware answers 401 to every /api/* request while the key is empty or mismatched, so a default install could not create a single sandbox. docker-compose was unaffected: it reads the key from .env. - secret-app.yaml generates the key on first install and preserves it across upgrades via lookup, like the other app secrets. - The provisioner and gateway Pods read it from that one Secret. The gateway env is required while provisioner.enabled (the default config references $PROVISIONER_API_KEY and the harness fails on an unset variable) and optional otherwise; existingAppSecret must carry it while the provisioner is enabled. - values.yaml sets sandbox.provisioner_api_key: $PROVISIONER_API_KEY; the README config example, the existingAppSecret key list, the _helpers.tpl comment and NOTES.txt document the key. - backend/tests/test_helm_provisioner_api_key.py pins the values, template source and docs directly and, with helm, the rendered env on both Deployments and the Secret key. * docs(changelog): link the Helm provisioner API key entry to #6365 * fix(helm): render the gateway's PROVISIONER_API_KEY only for the bundled provisioner An explicit env entry wins over envFrom, so emitting the chart-managed gateway env with provisioner.enabled=false overrode the PROVISIONER_API_KEY an operator supplies through `secrets` for an external provisioner with the generated, unrelated value, turning every sandbox request into a 401 on upgrade. The env is now rendered only while the bundled provisioner is enabled (no `optional` flag), leaving the external credential source untouched. Addresses the review on #6365. * fix(helm): keep the default config loadable with the bundled provisioner disabled The default config references $PROVISIONER_API_KEY unconditionally and the harness refuses to start on an unset variable, so with provisioner.enabled=false and no external key the gateway failed at startup instead of booting and only failing sandbox calls at use time, as the chart did before the key existed. The harness resolves an empty variable to "", so the gateway Deployment now injects an empty PROVISIONER_API_KEY in that mode. The entry stays omitted when an external key arrives through `secrets` or `existingSecret`, since an explicit env entry would override the envFrom value. Tests cover the disabled/no-key, disabled/`secrets` and disabled/`existingSecret` renders plus the resolver contract. Addresses the review on #6365. * fix(helm): default PROVISIONER_API_KEY in the gateway start command when the bundled provisioner is off Treating the presence of `existingSecret` as proof that it carries PROVISIONER_API_KEY left the common case of a user-managed provider Secret holding only model keys with an unset variable, so the default config's $PROVISIONER_API_KEY reference still stopped the gateway at startup. The chart cannot see inside that Secret and an explicit env entry would override an envFrom value, so the explicit empty env is replaced by a shell default in the gateway start command, rendered only with provisioner.enabled=false: `export PROVISIONER_API_KEY="${PROVISIONER_API_KEY:-}"` leaves a supplied key untouched and turns an unset variable into "", which the harness resolves to an empty string. Tests run the extracted prefix through sh for both cases and cover the no-key, `secrets` and `existingSecret` renders. Addresses the review on #6365. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
182 lines
9.4 KiB
Python
182 lines
9.4 KiB
Python
"""Regression tests for the Helm chart's provisioner API key.
|
|
|
|
The chart enables the sandbox provisioner by default and points
|
|
``config.sandbox.provisioner_url`` at it, but nothing rendered
|
|
``PROVISIONER_API_KEY``: the provisioner Deployment had no such env, the app
|
|
Secret no such key, and the embedded config no ``provisioner_api_key``. Since
|
|
the provisioner started requiring the key (#4116), its ``verify_api_key``
|
|
middleware answers 401 to every ``/api/*`` request while the key is empty or
|
|
mismatched, so a default install could not create a single sandbox.
|
|
docker-compose was unaffected because it reads the key from ``.env``.
|
|
|
|
These tests pin the generated, upgrade-preserved key in the app Secret, its
|
|
injection into both the gateway and provisioner Pods from that one Secret, the
|
|
``existingAppSecret`` wiring, and the ``$PROVISIONER_API_KEY`` reference in the
|
|
default config.
|
|
|
|
The ``helm template`` tests skip when helm is not installed; CI's runner has it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
CHART = REPO_ROOT / "deploy" / "helm" / "deer-flow"
|
|
VALUES = CHART / "values.yaml"
|
|
APP_SECRET_TEMPLATE = CHART / "templates" / "secret-app.yaml"
|
|
README = CHART / "README.md"
|
|
NOTES = CHART / "templates" / "NOTES.txt"
|
|
GATEWAY_TEMPLATE = CHART / "templates" / "gateway-deployment.yaml"
|
|
|
|
ENV_NAME = "PROVISIONER_API_KEY"
|
|
SHELL_DEFAULT = 'export PROVISIONER_API_KEY="${PROVISIONER_API_KEY:-}" && '
|
|
|
|
|
|
def _values() -> dict:
|
|
return yaml.safe_load(VALUES.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _rendered_config() -> dict:
|
|
return yaml.safe_load(_values()["config"])
|
|
|
|
|
|
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 _by_kind(documents: list[dict], kind: str, name_suffix: str) -> dict:
|
|
return next(document for document in documents if document.get("kind") == kind and document["metadata"]["name"].endswith(name_suffix))
|
|
|
|
|
|
def _container(documents: list[dict], deployment_suffix: str, container_name: str) -> dict:
|
|
containers = _by_kind(documents, "Deployment", deployment_suffix)["spec"]["template"]["spec"]["containers"]
|
|
return next(container for container in containers if container["name"] == container_name)
|
|
|
|
|
|
def _env(container: dict) -> dict[str, dict]:
|
|
return {item["name"]: item for item in container["env"]}
|
|
|
|
|
|
def _secret_ref(container: dict, env_name: str) -> dict:
|
|
return _env(container)[env_name]["valueFrom"]["secretKeyRef"]
|
|
|
|
|
|
def test_default_config_references_the_provisioner_api_key() -> None:
|
|
"""The provisioner is on by default, so the gateway must present the key it was given."""
|
|
values = _values()
|
|
assert values["provisioner"]["enabled"] is True
|
|
sandbox = _rendered_config()["sandbox"]
|
|
assert sandbox["provisioner_url"] == "http://provisioner:8002"
|
|
assert sandbox["provisioner_api_key"] == f"${ENV_NAME}", "the harness resolves $VAR from the gateway env"
|
|
|
|
|
|
def test_app_secret_template_preserves_the_provisioner_api_key_across_upgrades() -> None:
|
|
"""``helm template`` never sees ``lookup`` results, so pin the preservation in the source."""
|
|
template = APP_SECRET_TEMPLATE.read_text(encoding="utf-8")
|
|
assert f'index $prev.data "{ENV_NAME}"' in template, "a rotated key on upgrade would desynchronize the two Pods mid-rollout"
|
|
assert f"{ENV_NAME}: {{{{ $provisionerKey | quote }}}}" in template
|
|
|
|
|
|
def test_docs_mention_the_provisioner_api_key() -> None:
|
|
assert ENV_NAME in README.read_text(encoding="utf-8")
|
|
assert ENV_NAME in NOTES.read_text(encoding="utf-8")
|
|
assert "provisioner_api_key: $PROVISIONER_API_KEY" in README.read_text(encoding="utf-8"), "the README config example replaces the chart default wholesale"
|
|
|
|
|
|
def test_rendered_app_secret_carries_the_provisioner_api_key() -> None:
|
|
secret = _by_kind(_render_chart(), "Secret", "-app")
|
|
assert secret["stringData"][ENV_NAME]
|
|
|
|
|
|
def test_both_pods_read_the_key_from_the_same_app_secret() -> None:
|
|
documents = _render_chart()
|
|
provisioner = _secret_ref(_container(documents, "-provisioner", "provisioner"), ENV_NAME)
|
|
gateway = _secret_ref(_container(documents, "-gateway", "gateway"), ENV_NAME)
|
|
assert provisioner["name"].endswith("-app")
|
|
assert provisioner["key"] == ENV_NAME
|
|
assert provisioner.get("optional", False) is False, "a provisioner without the key rejects every request"
|
|
assert gateway["name"] == provisioner["name"], "the middleware compares the two values byte for byte"
|
|
assert gateway["key"] == ENV_NAME
|
|
assert gateway.get("optional", False) is False, "the default config references $PROVISIONER_API_KEY and the harness fails on an unset variable"
|
|
|
|
|
|
def test_existing_app_secret_is_honored_by_the_provisioner() -> None:
|
|
documents = _render_chart("existingAppSecret=my-app-secret")
|
|
assert not any(document.get("kind") == "Secret" and document["metadata"]["name"].endswith("-app") for document in documents)
|
|
assert _secret_ref(_container(documents, "-provisioner", "provisioner"), ENV_NAME)["name"] == "my-app-secret"
|
|
assert _secret_ref(_container(documents, "-gateway", "gateway"), ENV_NAME)["name"] == "my-app-secret"
|
|
|
|
|
|
def test_harness_needs_the_variable_set_but_accepts_an_empty_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Pins the contract the gateway Deployment relies on when the bundled provisioner is disabled."""
|
|
reference = {"sandbox": {"provisioner_api_key": f"${ENV_NAME}"}}
|
|
monkeypatch.delenv(ENV_NAME, raising=False)
|
|
with pytest.raises(ValueError, match=ENV_NAME):
|
|
AppConfig.resolve_env_variables(reference)
|
|
monkeypatch.setenv(ENV_NAME, "")
|
|
assert AppConfig.resolve_env_variables(reference) == {"sandbox": {"provisioner_api_key": ""}}
|
|
|
|
|
|
@pytest.mark.parametrize("external", [None, "external-provisioner-key"])
|
|
def test_shell_default_sets_the_variable_only_when_nothing_else_did(external: str | None) -> None:
|
|
"""Pins the start-command prefix from the template source: it must leave an envFrom-supplied key
|
|
alone and turn an unset variable into "" (the only value the harness accepts short of a real key)."""
|
|
prefix = re.search(r"\}\}(export PROVISIONER_API_KEY=.*?&& )\{\{ end \}\}cd backend", GATEWAY_TEMPLATE.read_text(encoding="utf-8"))
|
|
assert prefix is not None and prefix.group(1) == SHELL_DEFAULT
|
|
env = {"PATH": "/usr/bin:/bin"} | ({ENV_NAME: external} if external else {})
|
|
result = subprocess.run(["sh", "-c", prefix.group(1) + f"printenv {ENV_NAME}"], env=env, capture_output=True, text=True)
|
|
assert result.returncode == 0, "the variable must be set in every case"
|
|
assert result.stdout == f"{external or ''}\n"
|
|
|
|
|
|
def test_default_start_command_carries_no_shell_default() -> None:
|
|
"""With the bundled provisioner the key is injected and required; the start command stays as it was."""
|
|
gateway = _container(_render_chart(), "-gateway", "gateway")
|
|
assert not " ".join(gateway["args"]).startswith("export")
|
|
|
|
|
|
def test_gateway_key_is_omitted_when_an_external_key_is_supplied() -> None:
|
|
"""An explicit env entry wins over envFrom, so rendering it would override the key an operator
|
|
supplies through ``secrets`` for an external provisioner with the generated, unrelated value."""
|
|
documents = _render_chart("provisioner.enabled=false", "secrets.PROVISIONER_API_KEY=external-provisioner-key")
|
|
assert not any(document.get("kind") == "Deployment" and document["metadata"]["name"].endswith("-provisioner") for document in documents)
|
|
gateway = _container(documents, "-gateway", "gateway")
|
|
assert ENV_NAME not in _env(gateway)
|
|
assert any(source["secretRef"]["name"].endswith("-provider") for source in gateway["envFrom"]), "the external key still arrives through the provider Secret"
|
|
assert _by_kind(documents, "Secret", "-provider")["stringData"][ENV_NAME] == "external-provisioner-key"
|
|
assert " ".join(gateway["args"]).startswith(SHELL_DEFAULT), "the shell default keeps the supplied key"
|
|
|
|
|
|
def test_user_managed_provider_secret_without_the_key_still_boots_the_gateway() -> None:
|
|
"""``existingSecret`` is a generic provider Secret that often holds only model keys; the chart
|
|
cannot see inside it, so it must neither override a key it carries nor leave the variable unset."""
|
|
gateway = _container(_render_chart("provisioner.enabled=false", "existingSecret=my-provider-secret"), "-gateway", "gateway")
|
|
assert ENV_NAME not in _env(gateway)
|
|
assert [source["secretRef"]["name"] for source in gateway["envFrom"]] == ["my-provider-secret"]
|
|
assert " ".join(gateway["args"]).startswith(SHELL_DEFAULT)
|
|
|
|
|
|
def test_disabled_provisioner_without_any_key_still_boots_the_gateway() -> None:
|
|
"""The default config still references $PROVISIONER_API_KEY; an unset variable would stop the gateway
|
|
from starting at all, where the chart used to boot it and only fail sandbox calls at use time."""
|
|
gateway = _container(_render_chart("provisioner.enabled=false"), "-gateway", "gateway")
|
|
assert ENV_NAME not in _env(gateway)
|
|
assert "envFrom" not in gateway
|
|
assert " ".join(gateway["args"]).startswith(SHELL_DEFAULT)
|