fix(helm): default sandbox Services to ClusterIP (#3929)#4190
Conversation
The K8s sandbox provisioner supports both NodePort and ClusterIP via SANDBOX_SERVICE_TYPE (added in bytedance#4016), but the Helm chart never set it, so real-cluster installs inherited the NodePort default. That bound the code-execution sandbox on every node's interfaces - including externally reachable ones on GKE/EKS/AKS - and pinned every sandbox URL to one node IP (SPOF on node reboot/drain/ephemeral-IP). Default the chart to ClusterIP: the provisioner returns a cluster-DNS URL (http://sandbox-<id>-svc.<ns>.svc.cluster.local:8080) so the gateway-> sandbox hop stays inside the cluster network - no node IP, no 30xxx port, no external exposure. The chart always runs the gateway in-cluster, so ClusterIP is always correct there. NodePort remains an opt-in (provisioner.sandboxServiceType: NodePort + nodeHost) for the Docker-Compose/hybrid path where the gateway is not in K8s and cannot resolve .svc.cluster.local; the provisioner code default stays NodePort for that path. - values.yaml: add provisioner.sandboxServiceType ("ClusterIP") - provisioner-deployment.yaml: emit SANDBOX_SERVICE_TYPE; gate the NODE_HOST block on NodePort mode (default "ClusterIP" for upgrade safety) - NOTES.txt + README.md: document ClusterIP default + NodePort opt-in No change to docker/provisioner/app.py (already mode-aware since bytedance#4016) or RBAC (services verbs already cover ClusterIP).
willem-bd
left a comment
There was a problem hiding this comment.
Review summary
Approve, pending the live-cluster smoke (inline #1). A focused, well-reasoned security hardening: the right default flip on the right surface (the sandbox code-execution API was bound on every node's interfaces via NodePort), with a documented NodePort escape hatch for the compose/hybrid path. The provisioner (docker/provisioner/app.py) is unchanged - mode-aware since #4016.
Verified by code reading (commit 008bc91):
- Provisioner consumes
SANDBOX_SERVICE_TYPE(defaultNodePort, validated atapp.py:76-77); ClusterIP mode returnshttp://sandbox-<id>-svc.<K8S_NAMESPACE>.svc.cluster.local:<SANDBOX_CONTAINER_PORT>(app.py:248-249) and creates the Service inK8S_NAMESPACEwithport = target_port = SANDBOX_CONTAINER_PORT(app.py:496-525) - so the DNS URL's namespace/port match. - Chart wiring consistent:
K8S_NAMESPACE=deer-flow.namespace,SANDBOX_CONTAINER_PORT=sandboxPort(8080, already wired),SANDBOX_SERVICE_TYPE=sandboxServiceType. Helpers/values all exist. default "ClusterIP"applied consistently in both thevalue:emit and theif eqguard, so a stale/emptyvalues.yamlcan't produce an empty string the provisioner rejects. Upgrade-safety intent confirmed.- No stray
NODE_HOSTemitters; sandbox Services are provisioner-created (no static chart template). Provisioner test sets the type explicitly per case, so it's unaffected.
Findings: #1 (medium) live-cluster behavior unverified by author and review; #2 (low) no chart-render test for the gating; #4 (nit) no CHANGELOG / upgrade callout for the default flip.
Label note (no code anchor): the PR is labeled area:docs, but it mutates values.yaml and provisioner-deployment.yaml - a runtime default flip, not docs-only. risk:medium is right; area:docs understates it for triage.
🤖 Generated with Claude Code
| # in-cluster gateway and avoids binding the code-execution surface on | ||
| # every node's interfaces. `NodePort` is the Docker-Compose/hybrid escape | ||
| # hatch (used when the gateway is not in K8s); pair with `nodeHost`. | ||
| sandboxServiceType: "ClusterIP" |
There was a problem hiding this comment.
🟡 Live-cluster behavior is unverified - by both the author and this review
This default flip (NodePort -> ClusterIP) is the core of the change, and neither side has confirmed it works in a real cluster: the PR body's own checklist item 4 (create a sandbox, curl from the gateway pod, confirm the 30xxx port is no longer reachable) is explicitly unchecked ("No cluster in this environment"). I couldn't render either (helm not available here).
The wiring is correct by inspection - the provisioner (app.py:248-249) returns http://sandbox-<id>-svc.<K8S_NAMESPACE>.svc.cluster.local:<SANDBOX_CONTAINER_PORT> and creates the Service in K8S_NAMESPACE with port = target_port = SANDBOX_CONTAINER_PORT (app.py:496-525), so the DNS URL's namespace and port match - and the provisioner has been mode-aware since #4016. So confidence is reasonable. But for a default-flip on a code-execution exposure surface, recommend a maintainer run the unchecked live checklist (or at minimum helm template + a kubectl run ... curl smoke) before merge.
| # escape hatch (gateway not in K8s), which needs NODE_HOST below. | ||
| - name: SANDBOX_SERVICE_TYPE | ||
| value: {{ .Values.provisioner.sandboxServiceType | default "ClusterIP" | quote }} | ||
| {{- if eq (default "ClusterIP" .Values.provisioner.sandboxServiceType) "NodePort" }} |
There was a problem hiding this comment.
🟢 No automated chart-render test for this gating
There's no helm template-based test asserting that the chart emits SANDBOX_SERVICE_TYPE=ClusterIP by default, omits NODE_HOST by default, and emits both under --set provisioner.sandboxServiceType=NodePort. The repo has provisioner tests (test_provisioner_request_threading.py) but no chart-render tests, so this relies on the author's manual helm lint / helm template. A small render test would lock in this gating and catch a future regression (e.g., someone re-adding unconditional NODE_HOST). Coverage gap, not a defect.
|
|
||
| Override `provisioner.nodeHost` only if your CNI or network policy blocks | ||
| pod->node-IP traffic: | ||
| ## Sandbox Service type |
There was a problem hiding this comment.
🟢 Consider an upgrade callout / CHANGELOG entry for the default flip
This is a user-facing default change: existing chart installs flip NodePort -> ClusterIP on upgrade. The README documents the new default but doesn't call out the upgrade behavior for operators who may have relied on NodePort reachability (e.g., an external probe hitting the 30xxx port). AGENTS.md's doc-sync policy points at CHANGELOG.md for changes - a one-line entry noting the flip + the sandboxServiceType: NodePort opt-back-in would help. Defensible to ship silently (it's a security improvement), but an explicit note is friendlier.
…t flip (bytedance#3929) Address review on bytedance#4190: - Add scripts/check_chart_sandbox_service.sh: renders the chart for the default (ClusterIP, no NODE_HOST), the NodePort opt-in (both emitted), and NodePort+nodeHost (literal value, not downward API). Locks in the bytedance#3929 gating so a regression (e.g. re-adding an unconditional NODE_HOST, or dropping the `default "ClusterIP"` upgrade-safety fallback) fails CI. Wired into .github/workflows/chart.yaml validate-chart job. (bytedance#2) - CHANGELOG [Unreleased] -> Changed: note the NodePort->ClusterIP default flip on upgrade + the `sandboxServiceType: NodePort` opt-back-in. (bytedance#4) No chart template changes (the gating itself landed in the first commit).
|
Thanks @willem-bd for the thorough review — #2, #4, and the label note are addressed in #2 — chart-render test for the gating ✅
It greps the env-var list item ( #4 — upgrade callout / CHANGELOG ✅ Label note ✅ #1 — live-cluster smoke
I'm running this now and will post the results here; if a maintainer prefers to run it themselves (as you suggested), the steps above are self-contained. 🤖 Generated with Claude Code |
|
#1 - live-cluster smoke: done. Full chain verified end-to-end on a docker-desktop cluster. Setup: the docker-desktop deployment's A/B on the same cluster — same provisioner, only
The new binary creates a ClusterIP Service with no Reachability (curl from an in-cluster pod): So the gateway→sandbox hop stays inside the cluster network — no node IP, no 30xxx port, no external exposure. This matches the ClusterIP case in Dev-env note (not PR-related): the sandbox Pod initially stuck in Test artifacts cleaned up; provisioner env restored to the NodePort code default. 🤖 Generated with Claude Code |
Summary
Closes #3929.
The K8s sandbox provisioner exposes each sandbox Pod behind a per-sandbox Service. #4016 added
SANDBOX_SERVICE_TYPEso the provisioner supports bothNodePortandClusterIP- but the Helm chart never set it, so real-cluster installs inherited theNodePortdefault. That:status.hostIP/provisioner.nodeHost) - a SPOF when that node reboots, drains, or gets a new ephemeral IP.This flips the chart default to
ClusterIP. The provisioner returns a cluster-DNS URL (http://sandbox-<id>-svc.<ns>.svc.cluster.local:8080) so the gateway->sandbox hop stays inside the cluster network - no node IP, no 30xxx port, no external exposure. The chart always runs the gateway in-cluster, so ClusterIP is always correct there.Why only the chart default (not the provisioner code)
The provisioner code default stays
NodePortfor the Docker-Compose/hybrid dev path (docker-compose-dev.yaml,docker-compose.yaml), where the gateway is a compose container - not in K8s - and reaches sandbox Pods on the host's Docker Desktop K8s viahost.docker.internal:NodePort. That gateway uses Docker DNS, not CoreDNS, so.svc.cluster.localdoes not resolve -> NodePort +NODE_HOSTis genuinely required there. The chart path (gateway always in-cluster) is the one that should default to ClusterIP.This is the one deliberate divergence from the issue's literal "drop
NODE_HOST" suggestion;nodeHostis kept as the NodePort-mode opt-in knob.Changes
values.yamlprovisioner.sandboxServiceType: "ClusterIP"(default);nodeHostcomment scoped to NodePort modeprovisioner-deployment.yamlSANDBOX_SERVICE_TYPEfrom the value;NODE_HOSTblock gated on== "NodePort"(default "ClusterIP"for upgrade safety so a stale values file can't send an empty string that the provisioner rejects)NOTES.txt{NODE_HOST}:{NodePort}flow + a nudge back to ClusterIPREADME.mdNo change to
docker/provisioner/app.py(already mode-aware since #4016) or RBAC (the Role already grantsservicesget/list/watch/create/delete, which covers ClusterIP - NodePort needs no extra RBAC either, it's a Service type).Verification
helm lint deploy/helm/deer-flow-> 0 failureshelm templatedefault render ->SANDBOX_SERVICE_TYPE=ClusterIPemitted, noNODE_HOSTenv; NOTES shows the cluster-DNS URL with no exposure warninghelm template --set provisioner.sandboxServiceType=NodePort->SANDBOX_SERVICE_TYPE=NodePort+NODE_HOSTemitted (downward-APIstatus.hostIPwhennodeHostempty; literal IP when set)--set provisioner.nodeHost=192.168.1.10-> rendered as the literal valuehttp://sandbox-<id>-svc.deer-flow.svc.cluster.local:8080;curlfrom the gateway pod -> 200 from/v1/sandbox; 30xxx port no longer reachable on any node. (No cluster in this environment - left for the reviewer/maintainer.)Out of scope
persistence.home.accessMode) multi-node sandbox scheduling blocker - orthogonal to the Service type, called out in NOTES/README.🤖 Generated with Claude Code