deer-flow/backend/tests/test_helm_provisioner_replicas.py
Nan Gao 7ff13bad45
fix(helm): let the sandbox provisioner run with several replicas (#6543)
* fix(helm): let the sandbox provisioner run with several replicas

The chart hard-coded replicas: 1 into the provisioner Deployment, gave it
no rollout strategy and no PodDisruptionBudget, and values.yaml had no
provisioner.replicas key, so a multi-replica gateway deployment still sent
every sandbox create/discover/destroy call through one Pod: while that Pod
restarted or its node drained no sandbox could be created anywhere.

- values.yaml provisioner gains replicas (default 1, unchanged), the
  gateway's surge-then-drain RollingUpdate strategy and
  podDisruptionBudget {enabled: true, minAvailable: 1}.
- provisioner-deployment.yaml renders replicas and strategy from values; at
  1 replica the result equals the Kubernetes default strategy.
- New provisioner-pdb.yaml, rendered only while the provisioner is enabled,
  the budget is enabled and replicas > 1, selecting on the Deployment's
  labels and named <fullname>-provisioner so it never collides with the
  gateway budget. No multiInstance override: nothing else in the chart
  renders differently per provisioner replica count.
- docker/provisioner/app.py is unchanged. It keeps no state between
  requests: the labelled sandbox Pods and Services are the only registry,
  create tolerates the 409 a concurrent creator produces, destroy tolerates
  404, NodePorts come from the API server and there is no background loop.
  The chart README and docker/provisioner/README.md record this, and
  tests/test_helm_provisioner_replicas.py pins it at source level next to
  the values, template and helm-render assertions.

* docs(changelog): link the provisioner replicas entry to #6543

* fix(helm): preserve percentage PodDisruptionBudget values instead of casting them to 0

Review of #6543: both PDB templates rendered minAvailable through
`| default 1 | int`, and sprig's int turns a percentage string such as
"50%" -- a value Kubernetes accepts -- into 0, a budget that permits every
Pod to be evicted. The gateway budget had the same pre-existing cast.

- New shared helper deer-flow.pdbMinAvailable in _helpers.tpl: a YAML or
  --set number renders as an integer, a digit string from --set-string
  becomes that integer, a percentage is preserved quoted, and 0, "0%",
  more than 100%, a non-integer or anything else fails the render with a
  message naming the values key and the enabled switch to use instead.
  Unset still falls back to 1.
- gateway-pdb.yaml and provisioner-pdb.yaml render minAvailable through it.
- values.yaml comments, the chart README multi-replica note and the
  CHANGELOG entry say integer-or-percentage and that zero is rejected.
- tests/test_helm_provisioner_replicas.py pins the helper and the two
  templates at text level and adds render cases for both budgets: "50%"
  with three replicas renders "50%", 2 via --set and --set-string renders
  2, and abc / 0 / 0% / 150% fail naming the key.

* fix(helm): normalize json.Number before the minAvailable string branches

Review of #6543: Helm 3.18.0 loads YAML numbers as json.Number, whose
kind is "string" but which regexMatch and the other string-typed sprig
functions reject at argument validation ("expected string; got
json.Number"). deer-flow.pdbMinAvailable gated its string branch on
kindIs "string", so the unoverridden chart default minAvailable: 1 made
every gateway.replicas=2 / provisioner.replicas=2 render fail on that
release, inside the README's documented 3.8+ support window.

- The helper keeps the explicit float64/int64/int branch and now converts
  everything else with toString into $text before the digit, percentage
  and fail branches; regexMatch, atoi, trimSuffix and quote only ever see
  that plain string. A json.Number 1 renders 1, a json.Number 1.5 still
  fails (the digit regex rejects it) and the hint still prints the raw
  value; bools and other kinds still fail. The helper comment records the
  Helm 3.18.0 reason.
- tests/test_helm_provisioner_replicas.py pins that the string branch
  runs on the toString-normalized variable, that no string-typed function
  is applied to $raw and that kindIs "string" is gone, and adds an
  explicit render case for both budgets with only the replica count set
  (the unoverridden default must render minAvailable: 1), marked as the
  Helm 3.18.0 json.Number regression guard.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-09 18:26:23 +08:00

311 lines
19 KiB
Python

"""Regression tests for the Helm chart's sandbox-provisioner replica count.
The chart hard-coded ``replicas: 1`` into the provisioner Deployment, gave it
no rollout strategy and no PodDisruptionBudget, and ``values.yaml`` had no
``provisioner.replicas`` key, so a multi-replica gateway deployment still sent
every sandbox create/discover/destroy call through one Pod: while that Pod
restarted or its node drained no sandbox could be created. The provisioner
itself keeps no state between requests -- the labelled sandbox Pods and
Services are the only registry, every handler reads them back from the API
server, and create tolerates the 409 a concurrent creator produces -- so the
chart only had to stop pinning it. These tests pin the values keys, the
Deployment wiring, the PodDisruptionBudget guard (enabled and replicas > 1),
the label agreement between the two templates, the operator notes, and the
source-level statelessness the replica claim rests on.
Review of #6543 found that both budgets rendered ``minAvailable`` through
``| int``, which turns a percentage such as ``"50%"`` (a value Kubernetes
accepts) into ``0`` -- a budget that protects nothing -- so the tests also pin
the shared ``deer-flow.pdbMinAvailable`` helper: integers pass through, digit
strings from ``--set-string`` become integers, percentages stay quoted, and
``0`` or anything else fails the render naming the values key.
The ``helm template`` tests skip when helm is not installed; CI's runner has it.
"""
from __future__ import annotations
import ast
import re
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"
VALUES = CHART / "values.yaml"
README = CHART / "README.md"
DEPLOYMENT_TEMPLATE = CHART / "templates" / "provisioner-deployment.yaml"
PDB_TEMPLATE = CHART / "templates" / "provisioner-pdb.yaml"
GATEWAY_PDB_TEMPLATE = CHART / "templates" / "gateway-pdb.yaml"
HELPERS_TEMPLATE = CHART / "templates" / "_helpers.tpl"
PDB_TEMPLATES = {"gateway": GATEWAY_PDB_TEMPLATE, "provisioner": PDB_TEMPLATE}
PROVISIONER_APP = REPO_ROOT / "docker" / "provisioner" / "app.py"
PROVISIONER_README = REPO_ROOT / "docker" / "provisioner" / "README.md"
# A selector block as both templates spell it: the shared selector helper plus
# the component label, so the budget can only ever count provisioner Pods.
SELECTOR_BLOCK = re.compile(r"matchLabels:\n\s+\{\{- include \"deer-flow.selectorLabels\" \. \| nindent 6 \}\}\n\s+app\.kubernetes\.io/component: provisioner\n")
# Methods that mutate a container in place. A module-level container touched by
# one of these from a request handler would be per-Pod state the chart's
# replica claim could not survive.
MUTATING_METHODS = frozenset({"add", "append", "clear", "discard", "extend", "insert", "pop", "popitem", "remove", "setdefault", "update"})
BACKGROUND_WORK = frozenset({"create_task", "ensure_future", "Thread", "Timer", "add_event_handler", "on_event", "repeat_every"})
def _values() -> dict:
return yaml.safe_load(VALUES.read_text(encoding="utf-8"))
def _helm(*args: str) -> subprocess.CompletedProcess[str]:
helm = shutil.which("helm")
if helm is None:
pytest.skip("helm is unavailable")
return subprocess.run([helm, "template", "deer-flow", str(CHART), *args], capture_output=True, text=True)
def _render(*args: str) -> list[dict]:
result = _helm(*args)
assert result.returncode == 0, result.stderr
return [document for document in yaml.safe_load_all(result.stdout) if isinstance(document, dict)]
def _render_chart(*settings: str) -> list[dict]:
return _render(*(arg for setting in settings for arg in ("--set", setting)))
def _render_failure(*args: str) -> str:
"""Return helm's stderr for a render that must be refused."""
result = _helm(*args)
assert result.returncode != 0, "the render must fail instead of producing a budget that protects nothing"
return result.stderr
def _min_available_include(component: str) -> str:
return f'minAvailable: {{{{ include "deer-flow.pdbMinAvailable" (dict "value" $pdb.minAvailable "key" "{component}.podDisruptionBudget.minAvailable") }}}}'
def _find(documents: list[dict], kind: str, name_suffix: str) -> dict | None:
return next((document for document in documents if document.get("kind") == kind and document["metadata"]["name"].endswith(name_suffix)), None)
def _by_kind(documents: list[dict], kind: str, name_suffix: str) -> dict:
document = _find(documents, kind, name_suffix)
assert document is not None, f"no {kind} ending in {name_suffix}"
return document
def _root_name(node: ast.expr) -> str | None:
while isinstance(node, (ast.Subscript, ast.Attribute)):
node = node.value
return node.id if isinstance(node, ast.Name) else None
def _module_level_mutations(tree: ast.Module) -> list[str]:
"""Return every statement that writes into a module-level container from anywhere in the module."""
module_names = {target.id for node in tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name)}
module_names |= {node.target.id for node in tree.body if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)}
findings: list[str] = []
for node in ast.walk(tree):
if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if isinstance(target, ast.Subscript) and _root_name(target) in module_names:
findings.append(f"line {node.lineno}: item assignment into {_root_name(target)}")
elif isinstance(node, ast.Delete):
for target in node.targets:
if isinstance(target, ast.Subscript) and _root_name(target) in module_names:
findings.append(f"line {node.lineno}: del on {_root_name(target)}")
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in MUTATING_METHODS:
if isinstance(node.func.value, ast.Name) and node.func.value.id in module_names:
findings.append(f"line {node.lineno}: {node.func.value.id}.{node.func.attr}()")
return findings
def test_default_values_keep_one_provisioner_replica_with_a_rollout_budget() -> None:
provisioner = _values()["provisioner"]
assert provisioner["enabled"] is True
assert provisioner["replicas"] == 1, "the conservative default is unchanged; operators opt into more"
assert provisioner["strategy"]["type"] == "RollingUpdate"
assert provisioner["strategy"]["rollingUpdate"]["maxSurge"] == 1
assert provisioner["strategy"]["rollingUpdate"]["maxUnavailable"] == 0, "surge-then-drain keeps one Pod answering sandbox calls through a rollout"
assert provisioner["podDisruptionBudget"]["enabled"] is True
assert provisioner["podDisruptionBudget"]["minAvailable"] == 1
def test_deployment_template_takes_replicas_and_strategy_from_values() -> None:
template = DEPLOYMENT_TEMPLATE.read_text(encoding="utf-8")
assert "replicas: {{ .Values.provisioner.replicas }}" in template
assert re.search(r"^\s*replicas:\s*\d+\s*$", template, flags=re.MULTILINE) is None, "a literal replica count ignores provisioner.replicas"
assert re.search(r"\{\{- with \.Values\.provisioner\.strategy \}\}\n\s+strategy:\n\s+\{\{- toYaml \. \| nindent 4 \}\}\n\s+\{\{- end \}\}", template), "the rollout strategy must come from values like the gateway's"
assert SELECTOR_BLOCK.search(template), "the Deployment selector is what the budget has to match"
def test_pdb_template_is_guarded_on_enabled_and_more_than_one_replica() -> None:
template = PDB_TEMPLATE.read_text(encoding="utf-8")
guard = re.search(r"\{\{- if and \.Values\.provisioner\.enabled \$pdb\.enabled \(gt \(int \.Values\.provisioner\.replicas\) 1\) \}\}", template)
assert guard is not None, "render only for an enabled provisioner with an enabled budget and more than one replica"
assert "{{- $pdb := .Values.provisioner.podDisruptionBudget | default dict -}}" in template
assert "apiVersion: policy/v1" in template
assert "kind: PodDisruptionBudget" in template
assert 'name: {{ include "deer-flow.fullname" . }}-provisioner' in template, "must not collide with the gateway budget"
assert _min_available_include("provisioner") in template, "minAvailable goes through the shared helper so a percentage survives"
assert template.count("app.kubernetes.io/component: provisioner") == 2, "once in metadata.labels, once in the selector"
assert SELECTOR_BLOCK.search(template), "the selector must match the Deployment's Pod labels exactly"
def test_provisioner_pdb_mirrors_the_gateway_pdb() -> None:
"""Same shape as the gateway budget so the two stay reviewable side by side."""
gateway = GATEWAY_PDB_TEMPLATE.read_text(encoding="utf-8").replace("gateway", "provisioner")
provisioner = PDB_TEMPLATE.read_text(encoding="utf-8")
for line in ("apiVersion: policy/v1", "kind: PodDisruptionBudget", 'name: {{ include "deer-flow.fullname" . }}-provisioner', _min_available_include("provisioner")):
assert line in gateway and line in provisioner, line
@pytest.mark.parametrize("component", sorted(PDB_TEMPLATES))
def test_both_budgets_render_min_available_through_the_shared_helper(component: str) -> None:
"""``| int`` turns "50%" into 0; Kubernetes accepts an integer or a percentage, so the chart must preserve both."""
template = PDB_TEMPLATES[component].read_text(encoding="utf-8")
assert _min_available_include(component) in template
assert re.search(r"minAvailable:.*\|\s*int\b", template) is None, "int coerces a percentage string to 0"
assert "default 1" not in template, "the fallback lives in the helper so both budgets share it"
def _helper_block() -> str:
helpers = HELPERS_TEMPLATE.read_text(encoding="utf-8")
start = helpers.index('{{- define "deer-flow.pdbMinAvailable" -}}')
end = helpers.find("{{- define ", start + 1)
return helpers[start:] if end == -1 else helpers[start:end]
def test_min_available_helper_preserves_percentages_and_fails_on_zero() -> None:
helper = _helper_block()
assert 'regexMatch "^[0-9]+%$"' in helper, "a percentage is preserved, not cast"
assert 'regexMatch "^[0-9]+$"' in helper, "a digit string from --set-string becomes an integer"
assert "quote" in helper, "the percentage must stay a YAML string"
assert "fail" in helper, "zero and unsupported values refuse to render instead of yielding 0"
assert 'kindIs "invalid"' in helper, "an unset value still falls back to 1"
assert re.search(r"\|\s*int\s*-?\}\}", helper) is None, "no branch may pipe the raw value through int"
def test_min_available_helper_normalizes_named_numbers_before_string_functions() -> None:
"""Helm 3.18.0 loads YAML numbers as ``json.Number``: its kind is "string", so a ``kindIs "string"``
gate lets it through, but ``regexMatch`` and the other string-typed functions reject it at argument
validation -- and the unoverridden default ``minAvailable: 1`` then fails every multi-replica render."""
helper = _helper_block()
assert "$text := toString $raw" in helper, "whatever is not a plain Go number is normalized into a plain string first"
assert 'kindIs "string"' not in helper, "json.Number passes a kind gate without being a plain string"
string_calls = [line for line in helper.splitlines() if re.search(r"\b(regexMatch|atoi|trimSuffix|quote)\b", line)]
assert string_calls, "the digit and percentage branches must still exist"
for line in string_calls:
assert "$raw" not in line, f"string-typed function applied to the raw value: {line.strip()}"
assert "$text" in line, f"string-typed function must take the normalized value: {line.strip()}"
assert re.search(r"printf .*\(got %v\).*\$raw", helper), "the hint still shows the raw value the operator supplied"
def test_operator_notes_document_the_provisioner_replicas() -> None:
values = VALUES.read_text(encoding="utf-8")
readme = README.read_text(encoding="utf-8")
provisioner_readme = PROVISIONER_README.read_text(encoding="utf-8")
for needle in ("provisioner.replicas", "409", "PodDisruptionBudget"):
assert needle in readme, needle
assert "409" in values, "the comment explains why a concurrent create on another replica is harmless"
assert "replica" in provisioner_readme, "the provisioner's own README must say it can run several replicas"
assert "app=deer-flow-sandbox" in provisioner_readme or "labels" in provisioner_readme, "and that the labelled K8s objects are the only registry"
assert "that one provisioner" not in readme, "the health note must not promise a single provisioner Pod"
def test_provisioner_keeps_no_state_between_requests() -> None:
"""The replica claim in values.yaml rests on this; a registry or reaper added here must re-open it."""
source = PROVISIONER_APP.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(PROVISIONER_APP))
globals_declared = {name for node in ast.walk(tree) if isinstance(node, ast.Global) for name in node.names}
assert globals_declared == {"core_v1"}, f"only the K8s client is process-global, found {sorted(globals_declared)}"
assert _module_level_mutations(tree) == [], "a module-level container written at request time is per-Pod state"
background = sorted({node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute) and node.attr in BACKGROUND_WORK})
assert background == [], f"a background loop would run once per replica: {background}"
assert source.count("exc.status != 409") >= 2, "Pod and Service create must both tolerate AlreadyExists from a concurrent creator"
assert 'label_selector="app=deer-flow-sandbox"' in source, "the label is the registry every replica lists from"
@pytest.mark.parametrize("replicas", [1, 2, 3])
def test_rendered_provisioner_follows_the_replica_count(replicas: int) -> None:
values = _values()["provisioner"]
documents = _render_chart(f"provisioner.replicas={replicas}")
deployment = _by_kind(documents, "Deployment", "-provisioner")
assert deployment["spec"]["replicas"] == replicas
assert deployment["spec"]["strategy"] == values["strategy"]
pdb = _find(documents, "PodDisruptionBudget", "-provisioner")
assert (pdb is not None) is (replicas > 1), "a budget of 1 on a single replica would block every node drain"
assert _find(documents, "PodDisruptionBudget", "-gateway") is None, "the gateway stays at one replica in this render"
if pdb is not None:
# Also a Helm 3.18.0 json.Number regression guard for the unoverridden YAML default; the
# explicit gateway + provisioner case is test_rendered_budget_keeps_the_unoverridden_default.
assert pdb["spec"]["minAvailable"] == values["podDisruptionBudget"]["minAvailable"]
assert pdb["spec"]["selector"]["matchLabels"] == deployment["spec"]["selector"]["matchLabels"]
assert pdb["spec"]["selector"]["matchLabels"] == deployment["spec"]["template"]["metadata"]["labels"]
assert pdb["metadata"]["labels"]["app.kubernetes.io/component"] == "provisioner"
assert pdb["metadata"]["namespace"] == deployment["metadata"]["namespace"]
def test_rendered_default_matches_the_previous_single_replica_deployment() -> None:
documents = _render_chart()
deployment = _by_kind(documents, "Deployment", "-provisioner")
assert deployment["spec"]["replicas"] == 1
assert deployment["spec"]["strategy"] == {"type": "RollingUpdate", "rollingUpdate": {"maxSurge": 1, "maxUnavailable": 0}}
assert not any(document.get("kind") == "PodDisruptionBudget" for document in documents)
def test_gateway_and_provisioner_budgets_render_side_by_side() -> None:
documents = _render_chart("gateway.replicas=2", "provisioner.replicas=2")
names = sorted(document["metadata"]["name"] for document in documents if document.get("kind") == "PodDisruptionBudget")
assert len(names) == 2 and names[0].endswith("-gateway") and names[1].endswith("-provisioner")
def test_disabled_provisioner_renders_neither_deployment_nor_budget() -> None:
documents = _render_chart("provisioner.enabled=false", "provisioner.replicas=2")
assert _find(documents, "Deployment", "-provisioner") is None
assert _find(documents, "PodDisruptionBudget", "-provisioner") is None
def test_provisioner_budget_can_be_disabled_independently() -> None:
documents = _render_chart("provisioner.replicas=2", "provisioner.podDisruptionBudget.enabled=false")
assert _by_kind(documents, "Deployment", "-provisioner")["spec"]["replicas"] == 2
assert _find(documents, "PodDisruptionBudget", "-provisioner") is None
@pytest.mark.parametrize("component", sorted(PDB_TEMPLATES))
def test_rendered_budget_keeps_the_unoverridden_default(component: str) -> None:
"""Helm 3.18.0 json.Number regression guard: with only the replica count set, the chart's own
``minAvailable: 1`` must render as the integer 1 -- that release loads YAML numbers as a named
string type that string-typed template functions reject unless the helper normalizes it first."""
documents = _render("--set", f"{component}.replicas=2")
assert _by_kind(documents, "PodDisruptionBudget", f"-{component}")["spec"]["minAvailable"] == 1
@pytest.mark.parametrize("component", sorted(PDB_TEMPLATES))
def test_rendered_budget_preserves_a_percentage(component: str) -> None:
"""With three replicas "50%" must keep two Pods; the old ``int`` coercion rendered 0 and protected none."""
documents = _render("--set", f"{component}.replicas=3", "--set-string", f"{component}.podDisruptionBudget.minAvailable=50%")
assert _by_kind(documents, "PodDisruptionBudget", f"-{component}")["spec"]["minAvailable"] == "50%"
@pytest.mark.parametrize("component", sorted(PDB_TEMPLATES))
@pytest.mark.parametrize("flag", ["--set", "--set-string"])
def test_rendered_budget_keeps_an_integer_whichever_way_it_is_set(component: str, flag: str) -> None:
documents = _render("--set", f"{component}.replicas=3", flag, f"{component}.podDisruptionBudget.minAvailable=2")
assert _by_kind(documents, "PodDisruptionBudget", f"-{component}")["spec"]["minAvailable"] == 2
@pytest.mark.parametrize("component", sorted(PDB_TEMPLATES))
@pytest.mark.parametrize(("flag", "value"), [("--set-string", "abc"), ("--set", "0"), ("--set-string", "0%"), ("--set-string", "150%")])
def test_rendered_budget_refuses_zero_and_unsupported_values(component: str, flag: str, value: str) -> None:
key = f"{component}.podDisruptionBudget.minAvailable"
stderr = _render_failure("--set", f"{component}.replicas=3", flag, f"{key}={value}")
assert key in stderr, "the message must name the values key the operator has to fix"