mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
feat(deploy): first-class Helm chart for Kubernetes deployment (#3987)
* feat(helm): add production-ready Helm chart for Kubernetes deployment Adds deploy/helm/deer-flow, a native-Kubernetes translation of the production docker-compose stack, plus CI to publish its images and chart. * ci(release): gate releases on version-source consistency Add a reusable verify-versions workflow invoked by both chart.yaml and container.yaml on v* tags. It runs scripts/verify_versions.sh against the tag and fails the release — skipping all image and chart publishing — when Chart.yaml (version + appVersion), backend/pyproject.toml, or frontend/package.json don't all match the tag. Add scripts/verify_versions.sh (the check, also runnable locally) and scripts/bump_version.sh (bumps all four sources in lockstep, then self-verifies). Document the release flow in RELEASING.md and link it from AGENTS.md. * fix(deploy): address Helm chart review feedback (#3987) Three review items from willem-bd: 1. nginx IPv6 listen strip never matched. The sed pattern required a `;` immediately after `2026`, but the rendered config emits `listen [::]:2026 default_server;` (space + `default_server` before the `;`), so the line was never deleted and nginx crash-looped on pods without IPv6 (`socket() :::2026 failed (97: Address family not supported)`). Drop the trailing `;` from the pattern so it matches. Same latent bug fixed in docker-compose-dev.yaml. 2. Passwords were spliced into DSNs verbatim, so a password containing URL-special chars (@ : / # ? % [ ] space) produced a malformed DSN and a confusing parse error. Add a `deer-flow.urlEscape` helper (replace-based: Sprig lacks urlqueryescape, and regexReplaceAllLiteral treats the replacement as a regex template so `[`/`]`/`?` break it) and apply it to the password in the postgres and redis DSNs. The raw `postgres-password` / `redis-password` keys stay unencoded - they back POSTGRES_PASSWORD / REDIS_PASSWORD, not a URL segment. 3. NODE_HOST defaulted to "gateway", which can never route: the gateway Service is ClusterIP:8001 and knows nothing of a sandbox NodePort, so a user who skips the caveat gets unreachable sandboxes with no error at install time. Default NODE_HOST to the provisioner pod's node IP via the downward API (status.hostIP) - a NodePort is exposed on every node, so <node-IP>:<NodePort> routes from the gateway on most clusters. `provisioner.nodeHost` remains an override for CNIs/policies that block pod->node-IP traffic. Updated NOTES.txt, values.yaml, and the chart README. (#3929 remains the long-term fix - ClusterIP + cluster-DNS URL removes NODE_HOST and the NodePort exposure entirely.) Validated with helm lint, helm template (incl. a special-char password rendering the encoded DSNs), and a sed pattern-match check. * fix(deploy): address round-2 Helm chart review feedback (#3987) Three "Medium" items from willem-bd: 1. No helm lint / helm template gate before publish. A template regression ships as an immutable OCI artifact (GHCR won't overwrite --version), so gate packaging on `helm lint` + `helm template --include-crds` in chart.yaml before `helm package`. (ct lint / helm-unittest deferred.) 2. Action pinning inconsistent + PR body overstates it. SHA-pin actions/checkout (v6.0.3, df4cb1c0) and actions/attest-build-provenance (v2.4.0, e8998f94) across the publishing workflows (chart.yaml, container.yaml, verify-versions.yml), matching the existing docker/* SHA-pin pattern. Resolves the checkout @v4/@v6 mismatch and makes the "SHA-pinned actions" claim accurate. Other pre-existing workflows left untouched (out of scope for this PR). 3. Provisioner RBAC broader than needed. Dropped the unused update/patch verbs and the pods/exec + events rules from the provisioner Role - audited against docker/provisioner/app.py, which only calls get/create/delete on pods and get/list/create/delete on services. Fixed NOTES.txt to accurately describe the grant instead of understating it as "create Pods and Services". The remaining scope concern - verbs apply to all Pods in the namespace, not just sandbox Pods - is still deferred (RBAC can't scope by label; needs a dedicated namespace or admission control), now noted in NOTES.txt and README. Validated with helm lint + helm template (narrowed Role renders with exactly get/list/watch/create/delete). * feat(helm): enable sandbox+web tools out of the box The chart's default config loaded zero agent tools (config.tools empty -> "Total tools loaded: 0"), so a fresh install gave an agent that could do nothing useful. Add tool_groups + tools to the default config block: - web: web_search (ddg), web_fetch (jina), image_search - no API key - file:read: ls, read_file, glob, grep - file:write: write_file, str_replace - bash The file/bash tools run inside the AIO sandbox the chart already configures; the web tools need outbound internet from the gateway pod (swap backends or drop entries for air-gapped clusters - see config.example.yaml). Also bump config_version 15 -> 19 to match config.example.yaml (the chart had drifted behind). NOTES.txt and the README example updated to match. * ci(helm): add chart validation + config_version drift check on PR Extend the chart workflow with a PR-triggered validate-chart job that runs helm lint, helm template --include-crds, and a config_version drift check: it parses config_version from both config.example.yaml and the chart's values.yaml and fails the build (with a ::error:: naming the files to bump) if the chart is behind the example. This catches the kind of drift this PR is fixing - the chart sat at v15 while the example moved to v19 - before it can merge again. verify-versions and publish-chart stay tag-only; publish-chart now needs: [verify-versions, validate-chart]. validate-chart runs on both PRs and tag pushes: the tag arm is required because a job that `needs` a skipped job is itself skipped under the default success() check, so validate-chart must actually run on tag pushes or publish-chart would never fire. * Bump config version to 20
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
name: Publish Helm Chart
|
||||
|
||||
# Publishes the DeerFlow Helm chart as an OCI artifact to GHCR alongside the
|
||||
# container images (see container.yaml). Triggers on the same `v*` tags.
|
||||
#
|
||||
# On pull requests touching the chart or config.example.yaml, `validate-chart`
|
||||
# runs lint + template render + a config_version drift check (the chart's
|
||||
# embedded config_version must not lag config.example.yaml) so a broken or
|
||||
# stale chart fails the PR, not the release.
|
||||
#
|
||||
# Users then install with:
|
||||
# helm install deer-flow oci://ghcr.io/${{ owner }}/deer-flow --version <ver>
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/helm/deer-flow/**"
|
||||
- "config.example.yaml"
|
||||
- ".github/workflows/chart.yaml"
|
||||
|
||||
jobs:
|
||||
validate-chart:
|
||||
# Runs on PRs and release tags: catch a broken render or a stale
|
||||
# config_version before merge / publish. A broken chart published under a
|
||||
# vX.Y.Z tag is an immutable OCI artifact (GHCR won't let you overwrite
|
||||
# --version), so a regression must fail here, not on install.
|
||||
if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
# ubuntu-latest ships with helm 3 preinstalled - no setup-helm action needed.
|
||||
- name: Lint chart
|
||||
run: helm lint deploy/helm/deer-flow
|
||||
- name: Validate templates render
|
||||
run: helm template deer-flow deploy/helm/deer-flow --include-crds >/dev/null
|
||||
|
||||
# The chart's `config:` block embeds a config_version that must not fall
|
||||
# behind config.example.yaml. A stale version is silent in-cluster (the
|
||||
# image ships no example to compare against, so _check_config_version
|
||||
# never warns) but means the chart's config is authored against an older
|
||||
# schema. Bump it in values.yaml and the README example. config_version
|
||||
# gates no runtime behavior - it only drives the outdated-warning - so a
|
||||
# bare version bump needs no field changes.
|
||||
- name: config_version drift check
|
||||
run: |
|
||||
set -eu
|
||||
example=$(grep -E '^config_version:[[:space:]]+[0-9]+' config.example.yaml | head -1 | awk '{print $2}')
|
||||
chart=$(awk '/^config:[[:space:]]*\|/{f=1; next} f && /^[[:space:]]+config_version:[[:space:]]+[0-9]+/ {print $2; exit}' deploy/helm/deer-flow/values.yaml)
|
||||
echo "config.example.yaml config_version=$example"
|
||||
echo "chart values.yaml config_version=$chart"
|
||||
if [ -z "$example" ] || [ -z "$chart" ]; then
|
||||
echo "::error::could not parse config_version from one of the files"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$chart" -lt "$example" ]; then
|
||||
echo "::error::chart config_version ($chart) is behind config.example.yaml ($example). Bump 'config_version' in deploy/helm/deer-flow/values.yaml (and the README example) to $example."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify-versions:
|
||||
# Gate the release: every version source must match the v* tag. A forgotten
|
||||
# bump in Chart.yaml, pyproject.toml, or package.json fails here and skips
|
||||
# the publish. See scripts/verify_versions.sh.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: ./.github/workflows/verify-versions.yml
|
||||
|
||||
publish-chart:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [verify-versions, validate-chart]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Log in to GHCR
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | \
|
||||
helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Package chart
|
||||
run: helm package deploy/helm/deer-flow --destination ./packages
|
||||
|
||||
- name: Push chart to GHCR
|
||||
run: |
|
||||
for pkg in ./packages/*.tgz; do
|
||||
echo "--- pushing $pkg"
|
||||
helm push "$pkg" oci://ghcr.io/${{ github.repository_owner }}
|
||||
done
|
||||
@@ -6,8 +6,14 @@ on:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
verify-versions:
|
||||
# Gate the release: every version source must match the v* tag. A forgotten
|
||||
# bump in Chart.yaml, pyproject.toml, or package.json fails here and skips
|
||||
# all image builds. See scripts/verify_versions.sh.
|
||||
uses: ./.github/workflows/verify-versions.yml
|
||||
|
||||
backend-container:
|
||||
needs: verify-versions
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,7 +25,7 @@ jobs:
|
||||
IMAGE_NAME: ${{ github.repository }}-backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
|
||||
with:
|
||||
@@ -47,13 +53,14 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
frontend-container:
|
||||
needs: verify-versions
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -66,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
|
||||
with:
|
||||
@@ -94,7 +101,55 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
provisioner-container:
|
||||
needs: verify-versions
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}-provisioner
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 #v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=ref,event=branch
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
|
||||
with:
|
||||
context: docker/provisioner
|
||||
file: docker/provisioner/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Verify Versions
|
||||
|
||||
# Reusable workflow: checks that every project version source agrees with the
|
||||
# git tag that triggered the release. Called by chart.yaml and container.yaml
|
||||
# on v* tags so a forgotten version bump blocks the entire release (container
|
||||
# images + chart), not just the chart.
|
||||
#
|
||||
# Sources verified: deploy/helm/deer-flow/Chart.yaml (version + appVersion),
|
||||
# backend/pyproject.toml, frontend/package.json. Logic lives in
|
||||
# scripts/verify_versions.sh so it can also be run locally.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
verify-versions:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Verify all version sources match tag
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
||||
bash scripts/verify_versions.sh "$TAG_VERSION"
|
||||
@@ -113,6 +113,7 @@ Rule of thumb: **root `make` = the full application**; **`backend/Makefile` and
|
||||
`README_ja.md`, `README_fr.md`, `README_ru.md`)
|
||||
- Security policy → **[SECURITY.md](SECURITY.md)**
|
||||
- Changes → **[CHANGELOG.md](CHANGELOG.md)**
|
||||
- Cutting a release → **[RELEASING.md](RELEASING.md)**
|
||||
|
||||
## Cross-Cutting Conventions
|
||||
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# Releasing DeerFlow
|
||||
|
||||
DeerFlow releases are **tag-driven**: pushing a `v*` git tag triggers the
|
||||
publishing workflows. There is no separate release script that bumps versions —
|
||||
the maintainer bumps the version sources, updates the changelog, commits, and
|
||||
tags. The helper scripts below keep the version sources in lockstep, and CI
|
||||
gates the release on them agreeing with the tag.
|
||||
|
||||
## Version sources
|
||||
|
||||
A release version must appear, identically, in four places:
|
||||
|
||||
| File | Field |
|
||||
| -------------------------------------- | -------------------- |
|
||||
| `backend/pyproject.toml` | `version = "X.Y.Z"` |
|
||||
| `frontend/package.json` | `"version": "X.Y.Z"` |
|
||||
| `deploy/helm/deer-flow/Chart.yaml` | `version: X.Y.Z` |
|
||||
| `deploy/helm/deer-flow/Chart.yaml` | `appVersion: "X.Y.Z"`|
|
||||
|
||||
Plus the git tag `vX.Y.Z` itself, which is the canonical release identifier.
|
||||
|
||||
Container images are tagged from the git tag (not from these files), and the
|
||||
Helm chart version is validated against the tag — so if any source lags the
|
||||
tag, the release is blocked (see [Version gate](#version-gate)).
|
||||
|
||||
## Helper scripts
|
||||
|
||||
- `scripts/bump_version.sh <version>` — set all four fields at once, then
|
||||
self-verify. Tolerates a leading `v` (e.g. `v2.2.0`).
|
||||
```bash
|
||||
scripts/bump_version.sh 2.2.0
|
||||
```
|
||||
- `scripts/verify_versions.sh [version]` — check that all sources agree. With
|
||||
no argument it requires mutual equality; with an argument it requires every
|
||||
source to equal it. Exits non-zero on mismatch. Run it locally before tagging
|
||||
to catch drift early:
|
||||
```bash
|
||||
scripts/verify_versions.sh 2.2.0
|
||||
```
|
||||
|
||||
## Release procedure
|
||||
|
||||
1. **Bump the version** across all sources:
|
||||
```bash
|
||||
scripts/bump_version.sh 2.2.0
|
||||
```
|
||||
2. **Update `CHANGELOG.md`**: rename the `## [Unreleased]` section to
|
||||
`## [2.2.0] — YYYY-MM-DD` (note the em dash `—`), and add a link reference
|
||||
at the bottom of the file:
|
||||
```
|
||||
[2.2.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.2.0
|
||||
```
|
||||
Start a fresh `## [Unreleased]` section above it for the next cycle.
|
||||
3. **Commit** the version + changelog changes:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "release: v2.2.0"
|
||||
```
|
||||
4. **Tag and push**:
|
||||
```bash
|
||||
git tag v2.2.0
|
||||
git push origin v2.2.0
|
||||
```
|
||||
Pushing the tag triggers the publishing workflows (below).
|
||||
|
||||
## What CI publishes on a `v*` tag
|
||||
|
||||
- `.github/workflows/container.yaml` — builds and pushes `backend`,
|
||||
`frontend`, and `provisioner` images to `ghcr.io`, tagged with the release
|
||||
version (and `latest` on the default branch).
|
||||
- `.github/workflows/chart.yaml` — packages the Helm chart and pushes it as an
|
||||
OCI artifact to `ghcr.io`. Users install with:
|
||||
```bash
|
||||
helm install deer-flow oci://ghcr.io/<owner>/deer-flow --version 2.2.0
|
||||
```
|
||||
|
||||
## Version gate
|
||||
|
||||
Both publishing workflows call `.github/workflows/verify-versions.yml` as their
|
||||
first job. It runs `scripts/verify_versions.sh` against the tag (minus the
|
||||
`v`). If any of the four version sources doesn't match the tag, the verify job
|
||||
fails and **all** publish jobs are skipped — no images, no chart.
|
||||
|
||||
When it fails, the job annotation names the offending file and suggests the
|
||||
fix:
|
||||
|
||||
```
|
||||
::error::frontend/package.json is '2.1.0' but expected '2.2.0'.
|
||||
Tip: run scripts/bump_version.sh 2.2.0 to align all sources.
|
||||
```
|
||||
|
||||
## Pre-releases (RCs)
|
||||
|
||||
Pre-release tags like `v2.2.0-rc1` are valid `v*` tags and trigger the same
|
||||
workflows. The version sources must equal the full pre-release string
|
||||
(`2.2.0-rc1`) — the gate compares exact strings. Use the same procedure with
|
||||
the rc version:
|
||||
|
||||
```bash
|
||||
scripts/bump_version.sh 2.2.0-rc1
|
||||
# update CHANGELOG, commit, tag v2.2.0-rc1, push
|
||||
```
|
||||
|
||||
## Recovering from a failed gate
|
||||
|
||||
If the gate failed because a source was forgotten:
|
||||
|
||||
1. Run `scripts/bump_version.sh <version>` to align the sources.
|
||||
2. Amend or add a follow-up commit.
|
||||
3. Delete and re-create the tag, then push it:
|
||||
```bash
|
||||
git tag -d v2.2.0
|
||||
git tag v2.2.0
|
||||
git push origin :refs/tags/v2.2.0
|
||||
git push origin v2.2.0
|
||||
```
|
||||
|
||||
Re-pushing the tag re-triggers the workflows. Because the gate blocks **all**
|
||||
artifacts when it fails, nothing was published under the bad tag, so re-tagging
|
||||
is safe — no images or chart were pushed to overwrite.
|
||||
|
||||
## Post-release
|
||||
|
||||
Optionally draft a **GitHub Release** from the tag, pasting the corresponding
|
||||
`CHANGELOG.md` section as the release notes. The changelog link references
|
||||
point at these release URLs.
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v2
|
||||
name: deer-flow
|
||||
description: DeerFlow — LangGraph-based AI super-agent (gateway + frontend + nginx + sandbox provisioner) deployed to Kubernetes.
|
||||
type: application
|
||||
version: 2.1.0
|
||||
appVersion: "2.1.0"
|
||||
keywords:
|
||||
- deerflow
|
||||
- langgraph
|
||||
- ai-agent
|
||||
- llm
|
||||
home: https://github.com/bytedance/deer-flow
|
||||
icon: https://raw.githubusercontent.com/bytedance/deer-flow/main/frontend/public/images/deer.svg
|
||||
maintainers:
|
||||
- name: DeerFlow
|
||||
@@ -0,0 +1,395 @@
|
||||
# DeerFlow Helm Chart
|
||||
|
||||
Deploys the full DeerFlow stack to Kubernetes: **gateway** (backend + embedded
|
||||
LangGraph runtime), **frontend** (Next.js), **nginx** (internal reverse proxy
|
||||
preserving the compose routing), and the **provisioner** (K8s-native sandbox
|
||||
that spawns code-execution Pods on demand).
|
||||
|
||||
This chart translates the production `docker/docker-compose.yaml` into native
|
||||
Kubernetes resources. No existing repo files are modified.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Kubernetes cluster (Docker Desktop K8s, OrbStack, kind, k3d, or a real cluster).
|
||||
- `kubectl` + `helm` 3 installed.
|
||||
- The three DeerFlow images — either the published ones (see "Install the
|
||||
published chart" below) or built locally (see step 1).
|
||||
- An Ingress controller (e.g. ingress-nginx) if you enable `ingress`.
|
||||
|
||||
## Install the published chart (GHCR)
|
||||
|
||||
The chart and all three images are published to GHCR on every `v*` release tag
|
||||
(see `.github/workflows/container.yaml` and `chart.yaml`). Skip the build step
|
||||
and install directly:
|
||||
|
||||
```bash
|
||||
helm install deer-flow oci://ghcr.io/<owner>/deer-flow \
|
||||
--version <version> \
|
||||
-n deer-flow --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
where `<owner>` is the GitHub owner the chart is published from and `<version>`
|
||||
matches the release tag without the leading `v` (tag `v0.1.0` → `--version
|
||||
0.1.0`). Point the chart at the published images:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
registry: ghcr.io/<owner> # owner prefix; images are <owner>/deer-flow-<name>
|
||||
tag: "<version>" # match the release tag (sans leading `v`)
|
||||
pullSecrets:
|
||||
- { name: regcred } # only if the GHCR package is private
|
||||
```
|
||||
|
||||
The chart's `gatewayImage` / `frontendImage` / `provisionerImage` defaults
|
||||
already match the published image names (`deer-flow-backend`,
|
||||
`deer-flow-frontend`, `deer-flow-provisioner`), so only `registry` and `tag`
|
||||
are required. New GHCR packages default to **private** — flip the package to
|
||||
public in its GHCR settings page for unauthenticated pulls, otherwise create a
|
||||
pull secret (step 1) and reference it via `image.pullSecrets`.
|
||||
|
||||
> The OCI chart and the images are versioned independently of the chart's
|
||||
> `appVersion`; always set `image.tag` to the release that matches your chart
|
||||
> `--version` unless you have a reason to pin differently.
|
||||
|
||||
## 1. Build & push images (custom builds only)
|
||||
|
||||
Skip this section if you're using the published chart above. To build the
|
||||
images yourself from the existing Dockerfiles:
|
||||
|
||||
```bash
|
||||
REGISTRY=ghcr.io/yourorg TAG=latest ./deploy/helm/deer-flow/scripts/build-and-push.sh
|
||||
```
|
||||
|
||||
This produces `$REGISTRY/deer-flow-backend`, `$REGISTRY/deer-flow-frontend`,
|
||||
`$REGISTRY/deer-flow-provisioner`. The chart's image-name defaults match these.
|
||||
|
||||
If your registry needs auth, create a pull secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret docker-registry regcred \
|
||||
--docker-server=ghcr.io \
|
||||
--docker-username=youruser \
|
||||
--docker-password=yourtoken \
|
||||
-n deer-flow
|
||||
```
|
||||
|
||||
## 2. Configure values
|
||||
|
||||
Copy and edit `values.yaml` → `my-values.yaml`. At minimum set:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
registry: ghcr.io/yourorg
|
||||
tag: latest
|
||||
pullSecrets:
|
||||
- { name: regcred }
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
host: deer-flow.example.com
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: deer-flow-tls
|
||||
|
||||
secrets:
|
||||
OPENAI_API_KEY: sk-...
|
||||
# add channel tokens, search keys, etc. as needed
|
||||
```
|
||||
|
||||
Provide your model config under `config` (keep secrets as `$VAR` references —
|
||||
they resolve from the `secrets` map):
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
config_version: 20
|
||||
models:
|
||||
- name: gpt-4
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-4
|
||||
api_key: $OPENAI_API_KEY
|
||||
request_timeout: 600.0
|
||||
sandbox:
|
||||
use: deerflow.community.aio_sandbox:AioSandboxProvider
|
||||
provisioner_url: http://provisioner:8002
|
||||
database:
|
||||
backend: postgres
|
||||
postgres_url: $DATABASE_URL
|
||||
checkpointer:
|
||||
type: postgres
|
||||
connection_string: $DATABASE_URL
|
||||
stream_bridge:
|
||||
type: redis # cross-pod SSE; URL from DEER_FLOW_STREAM_BRIDGE_REDIS_URL
|
||||
# Tools MUST be listed explicitly - the agent gets none otherwise
|
||||
# (BUILTIN_TOOLS only adds present_file + ask_clarification). The chart
|
||||
# default in values.yaml enables the sandbox tools + web tools (web_search,
|
||||
# web_fetch, image_search - no API key); when you override `config:`, copy
|
||||
# them in. Full list in values.yaml / config.example.yaml. The web tools need
|
||||
# outbound egress from the gateway pod.
|
||||
tool_groups:
|
||||
- name: web
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
tools:
|
||||
- name: web_search
|
||||
group: web
|
||||
use: deerflow.community.ddg_search.tools:web_search_tool
|
||||
max_results: 5
|
||||
- name: web_fetch
|
||||
group: web
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
timeout: 10
|
||||
- name: image_search
|
||||
group: web
|
||||
use: deerflow.community.image_search.tools:image_search_tool
|
||||
max_results: 5
|
||||
- name: bash
|
||||
group: bash
|
||||
use: deerflow.sandbox.tools:bash_tool
|
||||
# also: ls, read_file, glob, grep, write_file, str_replace (see values.yaml)
|
||||
```
|
||||
|
||||
`$DATABASE_URL` is injected from the postgres Secret (see below). The
|
||||
`checkpointer:` section is required for multi-replica operation — the LangGraph
|
||||
Store (cross-thread memory + thread list) reads it and does not fall back to
|
||||
`database:`. `stream_bridge.type: redis` is the default and routes live SSE
|
||||
events through the bundled redis StatefulSet (or `redis.external`).
|
||||
Because `config:` is a single override blob, a partial `config:` replaces the
|
||||
chart default entirely - keep the `tools:`/`tool_groups:` block (or the agent
|
||||
will have no tools) and the `sandbox:`/`database:`/`checkpointer:`/`stream_bridge:`
|
||||
sections shown above.
|
||||
|
||||
## 3. Install (from a local chart checkout)
|
||||
|
||||
For a custom build or local development, install from the chart directory:
|
||||
|
||||
```bash
|
||||
helm install deer-flow deploy/helm/deer-flow \
|
||||
-n deer-flow --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
## 4. Verify
|
||||
|
||||
```bash
|
||||
kubectl -n deer-flow get pods
|
||||
kubectl -n deer-flow port-forward svc/nginx 2026:2026
|
||||
curl http://localhost:2026/health # gateway health via nginx
|
||||
```
|
||||
|
||||
Hit the Ingress host (map it in `/etc/hosts` for local clusters) to load the UI.
|
||||
|
||||
Provisioner sanity check:
|
||||
|
||||
```bash
|
||||
kubectl -n deer-flow exec deploy/deer-flow-provisioner -- curl -s localhost:8002/health
|
||||
```
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- **PostgreSQL is the default database.** A bundled single-instance postgres
|
||||
StatefulSet (`postgresql.enabled: true`) runs in the namespace and the gateway
|
||||
connects via the in-cluster Service. The DSN is auto-generated into a Secret
|
||||
(key `database-url`) and injected as `DATABASE_URL`; `config.yaml` references
|
||||
it as `$DATABASE_URL` in `database.postgres_url`. Schema is bootstrapped
|
||||
automatically on gateway startup (alembic `create_all` + `stamp head`).
|
||||
For real HA, disable the bundled instance and point at a managed DB:
|
||||
```yaml
|
||||
postgresql:
|
||||
enabled: false
|
||||
external:
|
||||
host: mydb.example.com # or set databaseUrl / existingSecret
|
||||
port: 5432
|
||||
database: deerflow
|
||||
username: deerflow
|
||||
password: changeme
|
||||
```
|
||||
- **Gateway replicas.** Postgres + the Redis stream bridge together make the
|
||||
gateway's *persisted* state (checkpointer + run/thread metadata) and *live
|
||||
stream* path cross-pod-safe. The default is still 1 replica: **do not raise
|
||||
`gateway.replicas` past 1 yet.** Run control — `create_or_reject` dedup,
|
||||
`cancel`, and orphan reconciliation — is still worker-local (in-process
|
||||
`asyncio.Lock` + in-memory `record.task`), tracked by [issue
|
||||
#3948](https://github.com/bytedance/deer-flow/issues/3948). With >1 replica a
|
||||
double-submit can create two runs on one thread (checkpoint corruption), a
|
||||
cancel can land on a non-owner pod (409), and a crashed pod's runs stay
|
||||
`pending`/`running` forever. Stay on 1 replica until that work lands.
|
||||
- **Redis stream bridge.** A bundled single-instance redis StatefulSet
|
||||
(`redis.enabled: true`, `redis:7-alpine`) runs in the namespace and the
|
||||
gateway connects via the in-cluster Service. Per-run SSE events are stored in
|
||||
Redis Streams (PR #3191) so a client connected to any gateway pod receives
|
||||
live events and reconnect resumes from `Last-Event-ID`. The URL is
|
||||
auto-generated into a Secret (key `redis-url`) and injected as
|
||||
`DEER_FLOW_STREAM_BRIDGE_REDIS_URL`; `config.yaml` sets `stream_bridge.type:
|
||||
redis` by default. No-auth by default (ClusterIP isolation, matching compose);
|
||||
set `redis.auth.password` to enable AUTH. For a managed Redis, disable the
|
||||
bundled instance and point at it via `redis.external`.
|
||||
- **Persistence.** A PVC (`<release>-home`) backs `/app/backend/.deer-flow`
|
||||
(sqlite DB, memory, custom agents, per-thread user-data). The gateway mounts
|
||||
it with `subPath: deer-flow` so the layout matches the provisioner's PVC
|
||||
user-data mode. Default `ReadWriteOnce`; use `ReadWriteMany` (NFS) on
|
||||
multi-node clusters so sandbox Pods on other nodes can mount it.
|
||||
- **Provisioner RBAC.** The provisioner gets a ServiceAccount with a namespaced
|
||||
Role (get/list/watch/create/delete on pods + services) and a narrow ClusterRole
|
||||
(namespace get/create). It uses in-cluster service-account creds — no
|
||||
kubeconfig mount. The unused update/patch/pods-exec/events verbs were dropped
|
||||
(audited against `docker/provisioner/app.py`).
|
||||
- **Skills.** Disabled by default (emptyDir at `/app/skills`). Populate via
|
||||
`skills.existingClaim` or `skills.configMap`, or bake skills into a custom
|
||||
gateway image.
|
||||
|
||||
## Security
|
||||
|
||||
### Enforced posture
|
||||
|
||||
All workloads run as **non-root** with **all Linux capabilities dropped**. No
|
||||
container escalates privileges or runs as uid 0.
|
||||
|
||||
| workload | runAsUser | fsGroup | writable-path handling |
|
||||
|---|---|---|---|
|
||||
| gateway | 1000 | 1000 | `.deer-flow` PVC group-writable via fsGroup; `PYTHONDONTWRITEBYTECODE=1` suppresses `.pyc` writes; `UV_CACHE_DIR=/tmp` |
|
||||
| frontend | 1000 (`node`) | 1000 | `emptyDir` at `/app/frontend/.next/cache` (root-owned in the image) |
|
||||
| nginx | 101 (`nginx`) | 101 | command writes the rendered config to `/tmp/nginx.conf` and loads `nginx -c /tmp/nginx.conf` (since `/etc/nginx` is root-owned); `emptyDir` at `/var/cache/nginx` |
|
||||
| provisioner | 1000 | — | no PVC; `PYTHONDONTWRITEBYTECODE=1` |
|
||||
| postgres | 999 (`postgres`) | 999 | official `postgres:16` entrypoint detects non-root and skips the chown/gosu dance; data PVC group-writable via fsGroup |
|
||||
| redis | 999 (`redis`) | 999 | official `redis:7-alpine` entrypoint detects non-root and skips the gosu dance; data PVC group-writable via fsGroup |
|
||||
|
||||
Every container sets:
|
||||
|
||||
- `runAsNonRoot: true`
|
||||
- `allowPrivilegeEscalation: false`
|
||||
- `capabilities.drop: ["ALL"]`
|
||||
- `seccompProfile: { type: RuntimeDefault }`
|
||||
|
||||
All listening ports are >1024 (8001 / 3000 / 2026 / 8002 / 5432), so no
|
||||
`NET_BIND_SERVICE` capability is required.
|
||||
|
||||
**ConfigMap rollout.** ConfigMaps mount via `subPath`, which does **not** receive
|
||||
in-place updates — a `helm upgrade` that changes only a ConfigMap would leave
|
||||
pods on stale config. Each pod template carries a `checksum/*` annotation (SHA256
|
||||
of the rendered ConfigMap): `checksum/config` + `checksum/extensions` on the
|
||||
gateway, `checksum/nginx` on nginx. Any content change alters the pod spec and
|
||||
triggers a rolling restart.
|
||||
|
||||
**Resource defaults.** Every workload ships with modest requests+limits in
|
||||
`values.yaml`; override per workload (`gateway.resources`, `frontend.resources`,
|
||||
`nginx.resources`, `provisioner.resources`, `postgresql.primary.resources`,
|
||||
`redis.primary.resources`).
|
||||
|
||||
### Not yet enforced (deferred hardening)
|
||||
|
||||
These are intentionally **not** set in this chart revision. Each can be added
|
||||
per-workload with testing:
|
||||
|
||||
- **`readOnlyRootFilesystem: true`** — makes the container's root filesystem
|
||||
immutable so a compromised process can't persist changes to the image. Not
|
||||
enabled because it requires auditing every runtime write path and mounting an
|
||||
`emptyDir` over each. Known paths:
|
||||
- gateway / frontend / nginx / provisioner: `/tmp` (uv cache, python tempfiles,
|
||||
the nginx config + pid, node temp) — one `emptyDir` at `/tmp` each.
|
||||
- postgres: `/tmp` **and** `/var/run/postgresql` (the Unix-socket dir).
|
||||
The first four are mechanical. **postgres is the hard case** — the official
|
||||
image writes its socket to `/var/run/postgresql` and isn't designed for a
|
||||
read-only root, so it may need socket-path redirection (`PGHOST`/`unix_socket_directories`).
|
||||
Optionally, add `USER` directives to the `backend/Dockerfile`,
|
||||
`frontend/Dockerfile`, and `docker/provisioner/Dockerfile` so the images are
|
||||
non-root by default (defense in depth — the chart already forces the uid via
|
||||
`securityContext`, so this is not required). A cluster enforcing the
|
||||
`restricted` Pod Security Admission standard would require this setting.
|
||||
- **Provisioner RBAC narrowing.** The Role grants get/list/watch/create/delete
|
||||
on pods and services in the namespace (update/patch/pods-exec/events were
|
||||
dropped as unused). These verbs still apply to *all* Pods in the namespace,
|
||||
not just sandbox Pods — RBAC can't scope by label, so the remaining
|
||||
options are a dedicated sandbox namespace or admission control (OPA/Kyverno).
|
||||
- **`startupProbe`.** Workloads have readiness + liveness probes but no startup
|
||||
probe. The gateway's `livenessProbe.initialDelaySeconds: 30` covers slow starts
|
||||
today; a `startupProbe` would let it take arbitrarily long to initialize
|
||||
without risking a liveness kill during a cold start (e.g. slow model config
|
||||
load).
|
||||
|
||||
None of these affect correctness of the current deployment.
|
||||
|
||||
### Migrating an existing volume to non-root
|
||||
|
||||
`fsGroup` does **not** apply to `subPath` mounts, and it changes group ownership
|
||||
but not file mode — so a PVC written by an earlier **root** run (e.g. a cluster
|
||||
that ran the gateway as root before enabling this hardening, or a backup restore
|
||||
of root-owned files) will keep files like `.jwt_secret` at `0600 root:root`. The
|
||||
non-root gateway (uid 1000) then can't read them and crashes on the first auth
|
||||
request with `RuntimeError: Failed to read JWT secret from .../.jwt_secret`.
|
||||
|
||||
**Fresh installs are unaffected** — uid 1000 creates every file as `1000:1000`.
|
||||
|
||||
To fix an existing root-written PVC, run a one-shot root pod that chowns the
|
||||
volume to the gateway uid (1000), then restart the gateway:
|
||||
|
||||
```bash
|
||||
cat <<'EOF' | kubectl apply -n deer-flow -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: { name: fix-home-perms, namespace: deer-flow }
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: chown
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c"]
|
||||
args: ["chown -R 1000:1000 /home-pvc/deer-flow && chmod -R g+rwX /home-pvc/deer-flow"]
|
||||
volumeMounts:
|
||||
- { name: home, mountPath: /home-pvc }
|
||||
volumes:
|
||||
- name: home
|
||||
persistentVolumeClaim: { claimName: deer-flow-deer-flow-home }
|
||||
EOF
|
||||
kubectl -n deer-flow wait --for=condition=Ready pod/fix-home-perms --timeout=30s
|
||||
kubectl -n deer-flow delete pod fix-home-perms
|
||||
kubectl -n deer-flow rollout restart deploy/deer-flow-deer-flow-gateway
|
||||
```
|
||||
|
||||
(On a single-node cluster the fix pod can mount the RWO PVC concurrently with the
|
||||
gateway; on multi-node, scale the gateway to 0 first.) A durable alternative —
|
||||
an opt-in root `volumePermissions` initContainer that chowns on every start (the
|
||||
Bitnami pattern) — is not yet wired into this chart; it would introduce a root
|
||||
container, so it's left as an operator decision for now.
|
||||
|
||||
## Sandbox NodePort reachability
|
||||
|
||||
The provisioner returns `http://{NODE_HOST}:{NodePort}` to the gateway so the
|
||||
agent can reach its sandbox. In Docker Compose `NODE_HOST=host.docker.internal`;
|
||||
in Kubernetes `NODE_HOST` **defaults to the provisioner pod's node IP** via the
|
||||
[downward API](https://kubernetes.io/docs/concepts/workloads/pods/downward-api/)
|
||||
(`status.hostIP`). Because a NodePort is exposed on every node, the gateway can
|
||||
reach `<node-IP>:<NodePort>` on most clusters without any configuration.
|
||||
|
||||
Override `provisioner.nodeHost` only if your CNI or network policy blocks
|
||||
pod->node-IP traffic:
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide # use INTERNAL-IP or EXTERNAL-IP
|
||||
```
|
||||
|
||||
```yaml
|
||||
provisioner:
|
||||
nodeHost: 192.168.x.x
|
||||
```
|
||||
|
||||
On multi-node clusters, also switch `persistence.home.accessMode` to
|
||||
`ReadWriteMany`.
|
||||
|
||||
## Lint / dry-run
|
||||
|
||||
```bash
|
||||
helm lint deploy/helm/deer-flow
|
||||
helm template deer-flow deploy/helm/deer-flow -n deer-flow -f my-values.yaml | \
|
||||
kubectl apply --dry-run=client -f -
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
helm uninstall deer-flow -n deer-flow
|
||||
# the PVC is NOT deleted by default — remove it manually if desired:
|
||||
kubectl -n deer-flow delete pvc -l app.kubernetes.io/instance=deer-flow
|
||||
```
|
||||
@@ -0,0 +1,133 @@
|
||||
DeerFlow has been deployed.
|
||||
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
|
||||
{{- if not .Values.image.registry }}
|
||||
|
||||
⚠️ WARNING: `image.registry` is empty. The gateway/frontend/provisioner
|
||||
images won't resolve. Set `image.registry` in your values and build+push the
|
||||
three images (see deploy/helm/deer-flow/scripts/build-and-push.sh).
|
||||
{{- end }}
|
||||
|
||||
Get the status:
|
||||
|
||||
kubectl -n {{ include "deer-flow.namespace" . }} get pods
|
||||
|
||||
Quick port-forward to nginx (bypasses Ingress):
|
||||
|
||||
kubectl -n {{ include "deer-flow.namespace" . }} port-forward svc/nginx 2026:2026
|
||||
# then open http://localhost:2026
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
Ingress is enabled for host {{ .Values.ingress.host }}. Ensure your Ingress
|
||||
controller is installed and DNS/hosts maps the host to the controller.
|
||||
{{- if not .Values.ingress.tls.enabled }}
|
||||
TLS is disabled — enable `ingress.tls` and provide a secret (or cert-manager)
|
||||
for HTTPS.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
Provisioner / sandbox notes:
|
||||
|
||||
• The provisioner ServiceAccount has a namespaced Role (get, list, watch,
|
||||
create, delete on Pods + Services incl. pods/log, in this namespace) and a
|
||||
narrow ClusterRole (namespace get + create, cluster-wide). These verbs
|
||||
apply to *all* Pods in the namespace, not just sandbox Pods - scoping to
|
||||
sandbox Pods is deferred hardening (see README "Not yet enforced").
|
||||
• Sandbox Pods are created in namespace {{ include "deer-flow.namespace" . }}.
|
||||
• PVC mode is enabled: sandbox Pods mount the `{{ include "deer-flow.homePVC" . }}`
|
||||
PVC for per-thread user-data.
|
||||
|
||||
⚠️ Sandbox NodePort reachability:
|
||||
The provisioner returns `http://{NODE_HOST}:{NodePort}` to the gateway so
|
||||
the agent can talk to its sandbox. NODE_HOST defaults to the provisioner
|
||||
pod's node IP via the Kubernetes downward API, which routes on most clusters
|
||||
because a NodePort is exposed on every node.
|
||||
|
||||
If your CNI or network policy blocks pod->node-IP traffic, set
|
||||
`provisioner.nodeHost` to an address the gateway can reach that routes to
|
||||
a node IP:
|
||||
|
||||
kubectl get nodes -o wide
|
||||
|
||||
provisioner:
|
||||
nodeHost: <node-InternalIP-or-ExternalIP>
|
||||
|
||||
On multi-node clusters you additionally need `persistence.home.accessMode:
|
||||
ReadWriteMany` (e.g. NFS) so sandbox Pods on other nodes can mount the
|
||||
shared user-data PVC.
|
||||
|
||||
Generated secrets (persisted across upgrades):
|
||||
|
||||
• BETTER_AUTH_SECRET and DEER_FLOW_INTERNAL_AUTH_TOKEN are stored in the
|
||||
Secret `{{ include "deer-flow.appSecret" . }}`.
|
||||
|
||||
Database ({{ if .Values.postgresql.enabled }}bundled postgres{{ else }}external postgres{{ end }}):
|
||||
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
• A postgres StatefulSet (`{{ include "deer-flow.postgresFullname" . }}`)
|
||||
is deployed in this namespace. The gateway connects via the in-cluster
|
||||
Service `{{ include "deer-flow.postgresFullname" . }}:5432`.
|
||||
• DATABASE_URL is in Secret `{{ include "deer-flow.databaseUrlSecret" . }}`
|
||||
(key `database-url`); the auto-generated password is in key
|
||||
`postgres-password`. Both persist across upgrades.
|
||||
• Schema is bootstrapped automatically on gateway startup (alembic
|
||||
create_all + stamp head). No manual migration step needed.
|
||||
• To run real HA, disable this (`postgresql.enabled: false`) and point at a
|
||||
managed DB via `postgresql.external`.
|
||||
{{- else }}
|
||||
• The gateway reads DATABASE_URL from Secret
|
||||
`{{ include "deer-flow.databaseUrlSecret" . }}` (key `database-url`).
|
||||
Ensure that Secret exists and contains your managed-postgres DSN.
|
||||
{{- end }}
|
||||
|
||||
Redis stream bridge ({{ if .Values.redis.enabled }}bundled redis{{ else if (include "deer-flow.redisConfigured" .) }}external redis{{ else }}none{{ end }}):
|
||||
|
||||
{{- if .Values.redis.enabled }}
|
||||
• A redis StatefulSet (`{{ include "deer-flow.redisFullname" . }}`) is deployed
|
||||
in this namespace. The gateway connects via the in-cluster Service
|
||||
`{{ include "deer-flow.redisFullname" . }}:6379`.
|
||||
• DEER_FLOW_STREAM_BRIDGE_REDIS_URL is in Secret
|
||||
`{{ include "deer-flow.redisUrlSecret" . }}` (key `redis-url`).
|
||||
• Per-run SSE events are stored in Redis Streams so a client connected to any
|
||||
gateway pod receives live events; reconnect resumes from Last-Event-ID.
|
||||
• To use a managed Redis, disable this (`redis.enabled: false`) and point at it
|
||||
via `redis.external`.
|
||||
{{- else if (include "deer-flow.redisConfigured" .) }}
|
||||
• The gateway reads DEER_FLOW_STREAM_BRIDGE_REDIS_URL from Secret
|
||||
`{{ include "deer-flow.redisUrlSecret" . }}` (key `redis-url`). Ensure that
|
||||
Secret exists and contains your managed-Redis URL.
|
||||
{{- else }}
|
||||
• No redis configured — the gateway falls back to the in-process memory stream
|
||||
bridge. This is single-pod only: cross-pod SSE delivery and reconnect will
|
||||
not work with `gateway.replicas > 1`.
|
||||
{{- end }}
|
||||
|
||||
{{- if or .Values.secrets .Values.existingSecret }}
|
||||
|
||||
Provider/channel keys are in Secret `{{ include "deer-flow.providerSecret" . }}`
|
||||
and injected into the gateway via envFrom. Reference them from config.yaml as
|
||||
$VAR.
|
||||
{{- else }}
|
||||
|
||||
⚠️ No provider secrets configured. Add at least one model under `config`
|
||||
(e.g. an OpenAI model with `api_key: $OPENAI_API_KEY`) and supply the key
|
||||
under `secrets` in your values, or the agent will have no LLM to call.
|
||||
{{- end }}
|
||||
|
||||
Agent tools: the chart enables the sandbox tools (ls, read_file, glob, grep,
|
||||
write_file, str_replace, bash - run inside the AIO sandbox) and web tools
|
||||
(web_search, web_fetch, image_search - no API key) by default. Add or swap
|
||||
tools under `config` -> `tools:` (see config.example.yaml); the web tools need
|
||||
outbound internet from the gateway pod.
|
||||
|
||||
Pod security:
|
||||
• All pods run non-root: gateway/frontend/provisioner uid 1000, nginx uid 101,
|
||||
postgres uid 999. All Linux capabilities are dropped and privilege escalation
|
||||
is disabled on every container.
|
||||
• ConfigMap changes roll pods automatically (checksum annotations on the
|
||||
gateway and nginx pod templates).
|
||||
• See README's "Security" section for the full posture and the deferred
|
||||
hardening items (readOnlyRootFilesystem, provisioner RBAC narrowing,
|
||||
startupProbe).
|
||||
@@ -0,0 +1,155 @@
|
||||
{{/*
|
||||
Common helpers for the DeerFlow chart.
|
||||
*/}}
|
||||
|
||||
{{- define "deer-flow.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.fullname" -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.labels" -}}
|
||||
helm.sh/chart: {{ include "deer-flow.chart" . }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "deer-flow.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.namespace" -}}
|
||||
{{- default .Release.Namespace .Values.namespace -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.imagePullSecrets" -}}
|
||||
{{- with .Values.image.pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 0 }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Fully-qualified image refs for the three DeerFlow images.
|
||||
When `image.registry` is empty, omit the prefix so the ref is
|
||||
`deer-flow-gateway:latest` (local-image mode, imagePullPolicy: Never). */}}
|
||||
{{- define "deer-flow.gatewayImage" -}}
|
||||
{{- if .Values.image.registry -}}{{- printf "%s/%s:%s" .Values.image.registry .Values.image.gatewayImage .Values.image.tag -}}
|
||||
{{- else -}}{{- printf "%s:%s" .Values.image.gatewayImage .Values.image.tag -}}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.frontendImage" -}}
|
||||
{{- if .Values.image.registry -}}{{- printf "%s/%s:%s" .Values.image.registry .Values.image.frontendImage .Values.image.tag -}}
|
||||
{{- else -}}{{- printf "%s:%s" .Values.image.frontendImage .Values.image.tag -}}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.provisionerImage" -}}
|
||||
{{- if .Values.image.registry -}}{{- printf "%s/%s:%s" .Values.image.registry .Values.image.provisionerImage .Values.image.tag -}}
|
||||
{{- else -}}{{- printf "%s:%s" .Values.image.provisionerImage .Values.image.tag -}}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.nginxImage" -}}
|
||||
{{- printf "%s:%s" .Values.nginx.image.repository .Values.nginx.image.tag -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* PVC name for the .deer-flow home directory. */}}
|
||||
{{- define "deer-flow.homePVC" -}}
|
||||
{{- printf "%s-home" (include "deer-flow.fullname" .) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Name of the Secret holding provider/channel keys. */}}
|
||||
{{- define "deer-flow.providerSecret" -}}
|
||||
{{- if .Values.existingSecret -}}{{- .Values.existingSecret -}}
|
||||
{{- else -}}{{- printf "%s-provider" (include "deer-flow.fullname" .) -}}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Name of the Secret holding generated app secrets (auth token, better-auth). */}}
|
||||
{{- define "deer-flow.appSecret" -}}
|
||||
{{- printf "%s-app" (include "deer-flow.fullname" .) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Name of the postgres StatefulSet/Service. */}}
|
||||
{{- define "deer-flow.postgresFullname" -}}
|
||||
{{- printf "%s-postgres" (include "deer-flow.fullname" .) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Name of the Secret holding DATABASE_URL (and, in bundled mode, the
|
||||
postgres superuser password). Resolution order:
|
||||
1. postgresql.external.existingSecret (user-managed, key=database-url)
|
||||
2. postgresql.existingSecret (user-managed, bundled image)
|
||||
3. chart-managed secret `<release>-postgres`
|
||||
Only #3 is created by this chart; #1/#2 must exist already. */}}
|
||||
{{- define "deer-flow.databaseUrlSecret" -}}
|
||||
{{- if .Values.postgresql.external.existingSecret -}}{{- .Values.postgresql.external.existingSecret -}}
|
||||
{{- else if .Values.postgresql.existingSecret -}}{{- .Values.postgresql.existingSecret -}}
|
||||
{{- else -}}{{- include "deer-flow.postgresFullname" . -}}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Name of the redis StatefulSet/Service. */}}
|
||||
{{- define "deer-flow.redisFullname" -}}
|
||||
{{- printf "%s-redis" (include "deer-flow.fullname" .) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Name of the Secret holding the redis stream-bridge URL (key `redis-url`,
|
||||
plus `redis-password` in bundled mode when a password is set). Resolution:
|
||||
1. redis.external.existingSecret (user-managed, key=redis-url)
|
||||
2. redis.existingSecret (user-managed, bundled image)
|
||||
3. chart-managed secret `<release>-redis`
|
||||
Only #3 is created by this chart; #1/#2 must exist already. */}}
|
||||
{{- define "deer-flow.redisUrlSecret" -}}
|
||||
{{- if .Values.redis.external.existingSecret -}}{{- .Values.redis.external.existingSecret -}}
|
||||
{{- else if .Values.redis.existingSecret -}}{{- .Values.redis.existingSecret -}}
|
||||
{{- else -}}{{- include "deer-flow.redisFullname" . -}}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Whether any redis stream-bridge backend is configured (bundled StatefulSet,
|
||||
external URL, or a user-managed Secret). Drives the env injection in the
|
||||
gateway deployment. */}}
|
||||
{{- define "deer-flow.redisConfigured" -}}
|
||||
{{- or .Values.redis.enabled .Values.redis.external.redisUrl .Values.redis.external.existingSecret .Values.redis.existingSecret -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* SHA256 checksums of the ConfigMaps. Mount these as pod-template
|
||||
annotations: ConfigMaps mounted via subPath do NOT receive live updates,
|
||||
so a `helm upgrade` that only changes a ConfigMap would leave pods on stale
|
||||
config. A checksum annotation makes any content change alter the pod spec,
|
||||
which triggers a rolling restart. */}}
|
||||
{{- define "deer-flow.configChecksum" -}}
|
||||
{{- include (print $.Template.BasePath "/configmap-config.yaml") . | sha256sum -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.extensionsChecksum" -}}
|
||||
{{- include (print $.Template.BasePath "/configmap-extensions.yaml") . | sha256sum -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "deer-flow.nginxChecksum" -}}
|
||||
{{- include (print $.Template.BasePath "/configmap-nginx.yaml") . | sha256sum -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Percent-encode a string for safe interpolation into a URL userinfo
|
||||
(password) segment of a DSN. Sprig lacks urlqueryescape, and
|
||||
regexReplaceAllLiteral treats `replacement` as a regex template so chars
|
||||
like `[`, `]`, `?` break it - so we chain plain `replace` calls instead.
|
||||
`%` is encoded first to avoid double-encoding the percent signs emitted
|
||||
for the other characters. Covers the URL-special chars a managed-DB
|
||||
password might contain (`@ : / # ? % [ ]` and space). */}}
|
||||
{{- define "deer-flow.urlEscape" -}}
|
||||
{{- $s := . -}}
|
||||
{{- $s = replace "%" "%25" $s -}}
|
||||
{{- $s = replace "@" "%40" $s -}}
|
||||
{{- $s = replace ":" "%3A" $s -}}
|
||||
{{- $s = replace "/" "%2F" $s -}}
|
||||
{{- $s = replace "#" "%23" $s -}}
|
||||
{{- $s = replace "?" "%3F" $s -}}
|
||||
{{- $s = replace "[" "%5B" $s -}}
|
||||
{{- $s = replace "]" "%5D" $s -}}
|
||||
{{- $s = replace " " "%20" $s -}}
|
||||
{{- $s -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-config
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
data:
|
||||
config.yaml: |
|
||||
{{ .Values.config | default "" | indent 4 }}
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-extensions
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
data:
|
||||
extensions_config.json: |
|
||||
{{ .Values.extensionsConfig | default "" | indent 4 }}
|
||||
@@ -0,0 +1,225 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-nginx
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
data:
|
||||
nginx.conf: |
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
pid /tmp/nginx.pid;
|
||||
http {
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
# Preserve an upstream proxy's X-Forwarded-Proto so the Gateway sees the
|
||||
# real client scheme when nginx runs behind an Ingress/TLS terminator.
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $scheme;
|
||||
"~*^https" https;
|
||||
}
|
||||
|
||||
# K8s Services resolve at config-parse time (no resolver directive needed).
|
||||
upstream gateway_upstream {
|
||||
server gateway:8001;
|
||||
keepalive 32;
|
||||
}
|
||||
upstream frontend_upstream {
|
||||
server frontend:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
{{- if .Values.provisioner.enabled }}
|
||||
upstream provisioner_upstream {
|
||||
server provisioner:8002;
|
||||
keepalive 16;
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
server {
|
||||
listen 2026 default_server;
|
||||
listen [::]:2026 default_server;
|
||||
server_name _;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# Static liveness endpoint — always 200 if nginx itself is alive.
|
||||
# Decouples the liveness probe from gateway availability so nginx
|
||||
# is not restart-looped while the gateway pulls its image or is
|
||||
# otherwise down. Readiness still proxies /health to the gateway so
|
||||
# traffic is not routed to an nginx that cannot reach it.
|
||||
location = /nginx-health {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok";
|
||||
}
|
||||
|
||||
# LangGraph-compatible API routes (rewrite /api/langgraph/* -> /api/*).
|
||||
location /api/langgraph/ {
|
||||
rewrite ^/api/langgraph/(.*) /api/$1 break;
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header X-Accel-Buffering no;
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
chunked_transfer_encoding on;
|
||||
}
|
||||
|
||||
location /api/models {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /api/memory {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /api/mcp {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /api/skills {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /api/agents {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
# Uploads — large bodies, no request buffering.
|
||||
location ~ ^/api/threads/[^/]+/uploads {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
client_max_body_size 100M;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/api/threads {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /docs {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
# Provisioner (sandbox management). Omitted when the provisioner is
|
||||
# disabled; /api/sandboxes then falls through to the catch-all /api/
|
||||
# below and the gateway answers (404 / "sandbox not configured").
|
||||
{{- if .Values.provisioner.enabled }}
|
||||
location /api/sandboxes {
|
||||
proxy_pass http://provisioner_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
# Catch-all for other /api/ routes (e.g. /api/v1/auth/*).
|
||||
location /api/ {
|
||||
proxy_pass http://gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
# Everything else -> frontend (with WebSocket upgrade for HMR/sockets).
|
||||
location / {
|
||||
proxy_pass http://frontend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-frontend
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
replicas: {{ .Values.frontend.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
{{- include "deer-flow.imagePullSecrets" . | nindent 6 }}
|
||||
securityContext:
|
||||
# Non-root: node:22-alpine ships a `node` user (uid 1000). fsGroup 1000
|
||||
# is harmless here (no PVC) but keeps the pod spec uniform.
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: frontend
|
||||
image: {{ include "deer-flow.frontendImage" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
workingDir: /app/frontend
|
||||
args: ["pnpm", "start"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: DEER_FLOW_INTERNAL_GATEWAY_BASE_URL
|
||||
value: http://gateway:8001
|
||||
- name: BETTER_AUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "deer-flow.appSecret" . }}
|
||||
key: BETTER_AUTH_SECRET
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
{{- with .Values.frontend.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
# Next.js writes runtime cache to .next/cache; the dir is root-owned
|
||||
# in the prod image, so mount an emptyDir to make it writable by 1000.
|
||||
- name: next-cache
|
||||
mountPath: /app/frontend/.next/cache
|
||||
volumes:
|
||||
- name: next-cache
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: frontend
|
||||
@@ -0,0 +1,190 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-gateway
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: gateway
|
||||
spec:
|
||||
replicas: {{ .Values.gateway.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: gateway
|
||||
annotations:
|
||||
# Roll pods when the mounted ConfigMaps change (subPath mounts don't
|
||||
# get live updates, so without this a config-only upgrade is ignored).
|
||||
checksum/config: {{ include "deer-flow.configChecksum" . }}
|
||||
checksum/extensions: {{ include "deer-flow.extensionsChecksum" . }}
|
||||
spec:
|
||||
{{- include "deer-flow.imagePullSecrets" . | nindent 6 }}
|
||||
securityContext:
|
||||
# Non-root: the gateway image has no USER directive and would otherwise
|
||||
# run as root. uid/gid 1000; fsGroup 1000 so the .deer-flow PVC is
|
||||
# group-writable for memory/sqlite/custom-agent state.
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
# initContainer ensures the subPath `deer-flow/` exists on the PV so the
|
||||
# volumeMount succeeds on first boot, and the PVC layout matches what the
|
||||
# provisioner's PVC user-data mode expects (deer-flow/users/.../user-data).
|
||||
{{- if .Values.persistence.home.enabled }}
|
||||
initContainers:
|
||||
- name: init-home
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c", "mkdir -p /home-pvc/deer-flow/data"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: home
|
||||
mountPath: /home-pvc
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: gateway
|
||||
image: {{ include "deer-flow.gatewayImage" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
workingDir: /app
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 1
|
||||
env:
|
||||
- name: CI
|
||||
value: "true"
|
||||
# Running as non-root uid 1000: suppress .pyc writes under the
|
||||
# root-owned /app (avoids PermissionError noise; harmless fallback).
|
||||
- name: PYTHONDONTWRITEBYTECODE
|
||||
value: "1"
|
||||
# uv would cache to ~/.cache (no home dir as 1000); point it at /tmp
|
||||
# (1777). --no-sync means no install/cache writes in practice.
|
||||
- name: UV_CACHE_DIR
|
||||
value: /tmp
|
||||
- name: DEER_FLOW_PROJECT_ROOT
|
||||
value: /app
|
||||
- name: DEER_FLOW_HOME
|
||||
value: /app/backend/.deer-flow
|
||||
- name: DEER_FLOW_CONFIG_PATH
|
||||
value: /app/backend/config.yaml
|
||||
- name: DEER_FLOW_EXTENSIONS_CONFIG_PATH
|
||||
value: /app/backend/extensions_config.json
|
||||
- name: DEER_FLOW_CHANNELS_LANGGRAPH_URL
|
||||
value: http://gateway:8001/api
|
||||
- name: DEER_FLOW_CHANNELS_GATEWAY_URL
|
||||
value: http://gateway:8001
|
||||
- name: DEER_FLOW_HOST_BASE_DIR
|
||||
value: /app/backend/.deer-flow
|
||||
- name: DEER_FLOW_HOST_SKILLS_PATH
|
||||
value: /app/skills
|
||||
- name: GATEWAY_HOST
|
||||
value: 0.0.0.0
|
||||
- name: GATEWAY_PORT
|
||||
value: "8001"
|
||||
- name: DEER_FLOW_INTERNAL_AUTH_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "deer-flow.appSecret" . }}
|
||||
key: DEER_FLOW_INTERNAL_AUTH_TOKEN
|
||||
{{- /* PostgreSQL DSN — only mounted when postgres is configured
|
||||
(bundled StatefulSet, external databaseUrl, or an existing
|
||||
Secret). In sqlite/memory mode the env is omitted and
|
||||
config.yaml's database.postgres_url is never read. */ -}}
|
||||
{{- $pgConfigured := or .Values.postgresql.enabled .Values.postgresql.external.databaseUrl .Values.postgresql.external.existingSecret .Values.postgresql.existingSecret -}}
|
||||
{{- if $pgConfigured }}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "deer-flow.databaseUrlSecret" . }}
|
||||
key: database-url
|
||||
{{- end }}
|
||||
{{- /* Redis stream-bridge URL — only mounted when redis is configured
|
||||
(bundled StatefulSet, external URL, or an existing Secret).
|
||||
Drives cross-pod SSE delivery (PR #3191). redis-py connects via
|
||||
raw TCP so HTTP_PROXY/NO_PROXY do not apply. */ -}}
|
||||
{{- if (include "deer-flow.redisConfigured" .) }}
|
||||
- name: DEER_FLOW_STREAM_BRIDGE_REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "deer-flow.redisUrlSecret" . }}
|
||||
key: redis-url
|
||||
{{- end }}
|
||||
- name: NO_PROXY
|
||||
value: localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner
|
||||
- name: no_proxy
|
||||
value: localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner
|
||||
{{- if or .Values.secrets .Values.existingSecret }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "deer-flow.providerSecret" . }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- containerPort: 8001
|
||||
name: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
{{- with .Values.gateway.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/backend/config.yaml
|
||||
subPath: config.yaml
|
||||
readOnly: true
|
||||
- name: extensions
|
||||
mountPath: /app/backend/extensions_config.json
|
||||
subPath: extensions_config.json
|
||||
readOnly: true
|
||||
- name: skills
|
||||
mountPath: /app/skills
|
||||
readOnly: true
|
||||
{{- if .Values.persistence.home.enabled }}
|
||||
- name: home
|
||||
mountPath: /app/backend/.deer-flow
|
||||
subPath: deer-flow
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ include "deer-flow.fullname" . }}-config
|
||||
- name: extensions
|
||||
configMap:
|
||||
name: {{ include "deer-flow.fullname" . }}-extensions
|
||||
- name: skills
|
||||
{{- if .Values.skills.existingClaim }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.skills.existingClaim }}
|
||||
{{- else if .Values.skills.configMap }}
|
||||
configMap:
|
||||
name: {{ .Values.skills.configMap }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.persistence.home.enabled }}
|
||||
- name: home
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "deer-flow.homePVC" . }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: gateway
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 8001
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: gateway
|
||||
@@ -0,0 +1,37 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- $hosts := .Values.ingress.tls.hosts | default (list .Values.ingress.host) }}
|
||||
{{- toYaml $hosts | nindent 8 }}
|
||||
{{- if .Values.ingress.tls.secretName }}
|
||||
secretName: {{ .Values.ingress.tls.secretName | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: nginx
|
||||
port:
|
||||
number: 2026
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,85 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-nginx
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: nginx
|
||||
spec:
|
||||
replicas: {{ .Values.nginx.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: nginx
|
||||
annotations:
|
||||
# Roll pods when the nginx ConfigMap changes (subPath mount).
|
||||
checksum/nginx: {{ include "deer-flow.nginxChecksum" . }}
|
||||
spec:
|
||||
securityContext:
|
||||
# Non-root: nginx:alpine ships a `nginx` user (uid 101). /etc/nginx and
|
||||
# /var/cache/nginx are root-owned, so the command writes the rendered
|
||||
# config to /tmp and we mount an emptyDir on the cache dir (below).
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
runAsGroup: 101
|
||||
fsGroup: 101
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: nginx
|
||||
image: {{ include "deer-flow.nginxImage" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2026
|
||||
# Strip the IPv6 listen line when IPv6 is unavailable (mirrors compose).
|
||||
# Writes the config to /tmp (1777, writable by uid 101) instead of
|
||||
# /etc/nginx (root-owned), then loads it with `nginx -c`.
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- >-
|
||||
cp /etc/nginx/nginx.conf.template /tmp/nginx.conf &&
|
||||
test -e /proc/net/if_inet6 || sed -i '/^[[:space:]]*listen[[:space:]]\+\[::\]:2026[[:space:]]/d' /tmp/nginx.conf &&
|
||||
nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /nginx-health
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
{{- with .Values.nginx.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: nginx-config
|
||||
mountPath: /etc/nginx/nginx.conf.template
|
||||
subPath: nginx.conf
|
||||
readOnly: true
|
||||
# nginx creates client_body/proxy temp dirs under /var/cache/nginx
|
||||
# at startup; the dir is root-owned, so mount an emptyDir (writable
|
||||
# by fsGroup 101). The pid already lives at /tmp/nginx.pid (config).
|
||||
- name: nginx-cache
|
||||
mountPath: /var/cache/nginx
|
||||
volumes:
|
||||
- name: nginx-config
|
||||
configMap:
|
||||
name: {{ include "deer-flow.fullname" . }}-nginx
|
||||
- name: nginx-cache
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nginx
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: nginx
|
||||
spec:
|
||||
type: {{ .Values.nginx.service.type | default "ClusterIP" }}
|
||||
{{- if and (eq (default "ClusterIP" .Values.nginx.service.type) "LoadBalancer") .Values.nginx.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.nginx.service.loadBalancerIP | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.nginx.service.port | default 2026 }}
|
||||
targetPort: http
|
||||
{{- if and (eq (default "ClusterIP" .Values.nginx.service.type) "NodePort") .Values.nginx.service.nodePort }}
|
||||
nodePort: {{ .Values.nginx.service.nodePort }}
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: nginx
|
||||
@@ -0,0 +1,43 @@
|
||||
{{- /*
|
||||
Postgres Secret — holds DATABASE_URL (key `database-url`) and, in bundled
|
||||
mode, the postgres superuser password (key `postgres-password`).
|
||||
|
||||
Created when the chart owns the credentials:
|
||||
• bundled mode (postgresql.enabled=true) → generates password, builds DSN
|
||||
• external mode with postgresql.external.databaseUrl set → wraps the DSN verbatim
|
||||
NOT created when the user manages the Secret themselves:
|
||||
• postgresql.external.existingSecret OR postgresql.existingSecret → referenced only
|
||||
*/ -}}
|
||||
{{- $userSecret := or .Values.postgresql.external.existingSecret .Values.postgresql.existingSecret -}}
|
||||
{{- $wrapExternal := and (not .Values.postgresql.enabled) .Values.postgresql.external.databaseUrl -}}
|
||||
{{- if and (not $userSecret) (or .Values.postgresql.enabled $wrapExternal) -}}
|
||||
{{- $password := "" -}}
|
||||
{{- $dsn := "" -}}
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
{{- /* bundled: password persists across upgrades via lookup */ -}}
|
||||
{{- $prev := lookup "v1" "Secret" (include "deer-flow.namespace" .) (include "deer-flow.databaseUrlSecret" .) -}}
|
||||
{{- if $prev -}}
|
||||
{{- $password = index $prev.data "postgres-password" | default "" | b64dec -}}
|
||||
{{- end -}}
|
||||
{{- if not $password -}}{{- $password = randAlphaNum 32 -}}{{- end -}}
|
||||
{{- $dsn = printf "postgresql://%s:%s@%s:5432/%s" .Values.postgresql.auth.username (include "deer-flow.urlEscape" $password) (include "deer-flow.postgresFullname" .) .Values.postgresql.auth.database -}}
|
||||
{{- else -}}
|
||||
{{- /* external: user-supplied DSN, verbatim */ -}}
|
||||
{{- $dsn = .Values.postgresql.external.databaseUrl -}}
|
||||
{{- end -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "deer-flow.databaseUrlSecret" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
type: Opaque
|
||||
stringData:
|
||||
database-url: {{ $dsn | quote }}
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
# Superuser password for the bundled postgres StatefulSet (POSTGRES_PASSWORD).
|
||||
postgres-password: {{ $password | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "deer-flow.postgresFullname" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: tcp-postgres
|
||||
port: 5432
|
||||
targetPort: tcp-postgres
|
||||
selector:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,95 @@
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "deer-flow.postgresFullname" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
serviceName: {{ include "deer-flow.postgresFullname" . }}
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
{{- include "deer-flow.imagePullSecrets" . | nindent 6 }}
|
||||
securityContext:
|
||||
# Non-root: postgres:16 ships a `postgres` user (uid 999). The official
|
||||
# entrypoint detects non-root, skips the chown/gosu dance, and runs
|
||||
# initdb as 999. fsGroup 999 makes the data PVC group-writable.
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: postgres
|
||||
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: tcp-postgres
|
||||
containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: {{ .Values.postgresql.auth.username | quote }}
|
||||
- name: POSTGRES_DB
|
||||
value: {{ .Values.postgresql.auth.database | quote }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "deer-flow.databaseUrlSecret" . }}
|
||||
key: postgres-password
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
{{- with .Values.postgresql.primary.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
{{- if not .Values.postgresql.primary.persistence.enabled }}
|
||||
# emptyDir fallback (testing only — data is lost on pod restart).
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.postgresql.primary.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 10 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.postgresql.primary.persistence.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgresql.primary.persistence.size | quote }}
|
||||
{{- with .Values.postgresql.primary.persistence.storageClass }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,94 @@
|
||||
{{- if .Values.provisioner.enabled -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
spec:
|
||||
{{- include "deer-flow.imagePullSecrets" . | nindent 6 }}
|
||||
serviceAccountName: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
securityContext:
|
||||
# Non-root: provisioner image has no USER directive. uid/gid 1000.
|
||||
# No fsGroup — it mounts no PVCs (it references PVC names for the
|
||||
# sandbox Pods it spawns, never mounting them itself).
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: provisioner
|
||||
image: {{ include "deer-flow.provisionerImage" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8002
|
||||
env:
|
||||
# Running as non-root uid 1000: suppress .pyc writes under root-owned /app.
|
||||
- name: PYTHONDONTWRITEBYTECODE
|
||||
value: "1"
|
||||
- name: K8S_NAMESPACE
|
||||
value: {{ include "deer-flow.namespace" . | quote }}
|
||||
- name: SANDBOX_IMAGE
|
||||
value: {{ .Values.provisioner.sandboxImage | quote }}
|
||||
# In-cluster API access via the mounted ServiceAccount token.
|
||||
# K8S_API_SERVER is intentionally unset (do NOT use host.docker.internal).
|
||||
# NODE_HOST: the address the gateway uses to reach sandbox NodePorts.
|
||||
# When `provisioner.nodeHost` is set, use it; otherwise default to this
|
||||
# pod's node IP via the downward API. A NodePort is exposed on every
|
||||
# node, so <node-IP>:<NodePort> routes from the gateway on most clusters.
|
||||
# Override only when pod->node-IP traffic is blocked by the CNI/policy.
|
||||
- name: NODE_HOST
|
||||
{{- if .Values.provisioner.nodeHost }}
|
||||
value: {{ .Values.provisioner.nodeHost | quote }}
|
||||
{{- else }}
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.hostIP
|
||||
{{- end }}
|
||||
# PVC mode — sandbox Pods mount the same PVCs the gateway uses.
|
||||
{{- if .Values.persistence.home.enabled }}
|
||||
- name: USERDATA_PVC_NAME
|
||||
value: {{ include "deer-flow.homePVC" . | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.skills.existingClaim }}
|
||||
- name: SKILLS_PVC_NAME
|
||||
value: {{ .Values.skills.existingClaim | quote }}
|
||||
{{- end }}
|
||||
- name: SANDBOX_CONTAINER_PORT
|
||||
value: {{ .Values.provisioner.sandboxPort | quote }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
{{- with .Values.provisioner.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{- if .Values.provisioner.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
---
|
||||
# Namespaced permissions: manage sandbox Pods + Services in this namespace.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
rules:
|
||||
# Verbs mirror docker/provisioner/app.py, which only calls get/create/delete
|
||||
# on pods and get/list/create/delete on services. update/patch, pods/exec,
|
||||
# and events were unused and dropped (least privilege). These verbs still
|
||||
# apply to *all* Pods in the namespace - scoping to sandbox Pods is deferred
|
||||
# (see README "Not yet enforced").
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "services"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
# Cluster-scoped: read/create Namespaces so the provisioner can ensure the
|
||||
# sandbox namespace exists (mirrors docker/provisioner/README.md:202-206).
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner-ns
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get", "list", "create"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner-ns
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: {{ include "deer-flow.fullname" . }}-provisioner-ns
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.provisioner.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: provisioner
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 8002
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: provisioner
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{- if .Values.persistence.home.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "deer-flow.homePVC" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.home.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.home.size | quote }}
|
||||
{{- with .Values.persistence.home.storageClass }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{- /*
|
||||
Redis Secret — holds the stream-bridge URL (key `redis-url`) and, in bundled
|
||||
mode with a password, the redis AUTH password (key `redis-password`).
|
||||
|
||||
Created when the chart owns the credentials:
|
||||
• bundled mode (redis.enabled=true) → builds DSN from in-cluster Service
|
||||
• external mode with redis.external.redisUrl set → wraps the URL verbatim
|
||||
NOT created when the user manages the Secret themselves:
|
||||
• redis.external.existingSecret OR redis.existingSecret → referenced only
|
||||
|
||||
Auth: empty `redis.auth.password` (default) = no auth, matching the compose
|
||||
deployment (ClusterIP isolation only). Set a password to enable ACL/AUTH;
|
||||
the DSN then becomes redis://:<password>@host:6379/0.
|
||||
*/ -}}
|
||||
{{- $userSecret := or .Values.redis.external.existingSecret .Values.redis.existingSecret -}}
|
||||
{{- $wrapExternal := and (not .Values.redis.enabled) .Values.redis.external.redisUrl -}}
|
||||
{{- if and (not $userSecret) (or .Values.redis.enabled $wrapExternal) -}}
|
||||
{{- $password := .Values.redis.auth.password -}}
|
||||
{{- $dsn := "" -}}
|
||||
{{- if .Values.redis.enabled -}}
|
||||
{{- /* bundled: DSN points at the in-cluster Service */ -}}
|
||||
{{- $auth := ternary (printf ":%s@" (include "deer-flow.urlEscape" $password)) "" (ne $password "") -}}
|
||||
{{- $dsn = printf "redis://%s%s:6379/0" $auth (include "deer-flow.redisFullname" .) -}}
|
||||
{{- else -}}
|
||||
{{- /* external: user-supplied URL, verbatim */ -}}
|
||||
{{- $dsn = .Values.redis.external.redisUrl -}}
|
||||
{{- end -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "deer-flow.redisUrlSecret" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
type: Opaque
|
||||
stringData:
|
||||
redis-url: {{ $dsn | quote }}
|
||||
{{- if and .Values.redis.enabled $password }}
|
||||
# AUTH password for the bundled redis StatefulSet (REDIS_PASSWORD).
|
||||
redis-password: {{ $password | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.redis.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "deer-flow.redisFullname" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: tcp-redis
|
||||
port: 6379
|
||||
targetPort: tcp-redis
|
||||
selector:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,106 @@
|
||||
{{- if .Values.redis.enabled -}}
|
||||
{{- $password := .Values.redis.auth.password -}}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "deer-flow.redisFullname" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
serviceName: {{ include "deer-flow.redisFullname" . }}
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "deer-flow.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
{{- include "deer-flow.imagePullSecrets" . | nindent 6 }}
|
||||
securityContext:
|
||||
# Non-root: redis:7-alpine ships a `redis` user (uid 999). The official
|
||||
# entrypoint detects non-root and skips the gosu dance. fsGroup 999
|
||||
# makes the data PVC group-writable.
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: redis
|
||||
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: tcp-redis
|
||||
containerPort: 6379
|
||||
{{- if $password }}
|
||||
# AUTH password is read at runtime so it never appears in the pod spec
|
||||
# args. `redis-server --requirepass` + `redis-cli -a` both read it
|
||||
# from this env.
|
||||
env:
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "deer-flow.redisUrlSecret" . }}
|
||||
key: redis-password
|
||||
{{- end }}
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- exec redis-server --appendonly yes{{ if $password }} --requirepass "$REDIS_PASSWORD"{{ end }}
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- {{ if $password }}redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping{{ else }}redis-cli ping{{ end }}
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- {{ if $password }}redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping{{ else }}redis-cli ping{{ end }}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
{{- with .Values.redis.primary.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
{{- if not .Values.redis.primary.persistence.enabled }}
|
||||
# emptyDir fallback (testing only — data is lost on pod restart).
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.primary.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 10 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.redis.primary.persistence.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.redis.primary.persistence.size | quote }}
|
||||
{{- with .Values.redis.primary.persistence.storageClass }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{- if not .Values.existingAppSecret -}}
|
||||
{{- $prev := lookup "v1" "Secret" (include "deer-flow.namespace" .) (include "deer-flow.appSecret" .) -}}
|
||||
{{- $betterAuth := "" -}}
|
||||
{{- $internalToken := "" -}}
|
||||
{{- if $prev -}}
|
||||
{{- $betterAuth = index $prev.data "BETTER_AUTH_SECRET" | default "" | b64dec -}}
|
||||
{{- $internalToken = index $prev.data "DEER_FLOW_INTERNAL_AUTH_TOKEN" | default "" | b64dec -}}
|
||||
{{- end -}}
|
||||
{{- if not $betterAuth -}}{{- $betterAuth = randAlphaNum 48 | lower -}}{{- end -}}
|
||||
{{- if not $internalToken -}}{{- $internalToken = randAlphaNum 40 -}}{{- end -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "deer-flow.appSecret" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BETTER_AUTH_SECRET: {{ $betterAuth | quote }}
|
||||
DEER_FLOW_INTERNAL_AUTH_TOKEN: {{ $internalToken | quote }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{- if and (not .Values.existingSecret) .Values.secrets -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "deer-flow.providerSecret" . }}
|
||||
namespace: {{ include "deer-flow.namespace" . }}
|
||||
labels:
|
||||
{{- include "deer-flow.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- range $k, $v := .Values.secrets }}
|
||||
{{ $k }}: {{ $v | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,320 @@
|
||||
# DeerFlow Helm chart values
|
||||
#
|
||||
# Copy to my-values.yaml, edit, and install with:
|
||||
# helm install deer-flow deploy/helm/deer-flow -n deer-flow --create-namespace -f my-values.yaml
|
||||
|
||||
# -- Target namespace (also used as the K8s sandbox namespace for provisioner-spawned Pods).
|
||||
namespace: deer-flow
|
||||
|
||||
# -- Image registry & tag for the three DeerFlow images you build+push.
|
||||
# Required: set `registry` to your registry (e.g. ghcr.io/yourorg).
|
||||
image:
|
||||
registry: "" # REQUIRED, e.g. ghcr.io/yourorg
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
# -- Existing image pull secrets, e.g. [{ name: regcred }]
|
||||
pullSecrets: []
|
||||
|
||||
# Image names match what .github/workflows/container.yaml publishes on GHCR
|
||||
# as ${repository}-<name> (e.g. ghcr.io/<owner>/deer-flow-backend). Set
|
||||
# `registry` to the owner prefix (e.g. ghcr.io/<owner>) and `tag` to consume
|
||||
# the published images.
|
||||
gatewayImage: deer-flow-backend
|
||||
frontendImage: deer-flow-frontend
|
||||
provisionerImage: deer-flow-provisioner
|
||||
|
||||
# -- Gateway (backend) deployment.
|
||||
gateway:
|
||||
replicas: 1 # Safe default. Postgres + the Redis stream bridge are
|
||||
# wired, but multi-replica needs issue #3948's run-control
|
||||
# work (cancel/dedup/reconcile) — see README "Gateway replicas".
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
# -- Frontend (Next.js) deployment.
|
||||
frontend:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
|
||||
# -- nginx reverse-proxy deployment (preserves compose routing).
|
||||
nginx:
|
||||
replicas: 1
|
||||
image:
|
||||
repository: nginx
|
||||
tag: alpine
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
service:
|
||||
# -- Service type fronting nginx. `LoadBalancer` on Docker Desktop / kind /
|
||||
# OrbStack routes to localhost (no Ingress controller needed). `ClusterIP`
|
||||
# pairs with the Ingress resource. `NodePort` exposes on a node port.
|
||||
type: ClusterIP
|
||||
port: 2026
|
||||
loadBalancerIP: ""
|
||||
nodePort: ""
|
||||
|
||||
# -- Sandbox provisioner (K8s-native code execution). Creates sandbox Pods in
|
||||
# this namespace via a ServiceAccount + RBAC.
|
||||
provisioner:
|
||||
enabled: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
# -- AIO-compatible sandbox container image used for sandbox Pods.
|
||||
sandboxImage: "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"
|
||||
# -- Host the gateway uses to reach sandbox NodePorts. Empty (default) falls
|
||||
# back to the provisioner pod's node IP via the Kubernetes downward API,
|
||||
# which routes on most clusters because a NodePort is exposed on every
|
||||
# node. Set explicitly only when pod->node-IP traffic is blocked by your
|
||||
# CNI/network policy. In Docker Compose this is `host.docker.internal`.
|
||||
nodeHost: ""
|
||||
# -- Sandbox container port (must match the sandboxImage's listening port).
|
||||
sandboxPort: 8080
|
||||
|
||||
# -- PostgreSQL database. Bundled mode (default) deploys a single-instance
|
||||
# postgres StatefulSet; set `enabled: false` to use an external managed DB.
|
||||
# The gateway reads DATABASE_URL from the resolved Secret and config.yaml
|
||||
# references it as $DATABASE_URL in database.postgres_url.
|
||||
postgresql:
|
||||
# -- Deploy a bundled postgres StatefulSet. Disable to use an external DB.
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "16"
|
||||
auth:
|
||||
database: deerflow
|
||||
username: deerflow
|
||||
# -- Password auto-generated (32 chars, persisted across upgrades) when
|
||||
# empty. Ignored if existingSecret is set.
|
||||
password: ""
|
||||
# -- Use an existing Secret (key `database-url`, plus `postgres-password`
|
||||
# in bundled mode) instead of generating one. Skips Secret creation.
|
||||
existingSecret: ""
|
||||
primary:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: "" # "" = cluster default
|
||||
accessMode: ReadWriteOnce # RWX (NFS) only needed if postgres itself is HA
|
||||
size: 20Gi
|
||||
# -- External postgres (used when `enabled: false`). Provide either a full
|
||||
# `databaseUrl` (chart wraps it into a Secret for you) or an
|
||||
# `existingSecret` you manage (key `database-url`).
|
||||
external:
|
||||
# -- Full DSN, e.g. postgresql://user:pass@host:5432/deerflow. Chart
|
||||
# writes it into the Secret so it never touches the gateway env directly.
|
||||
databaseUrl: ""
|
||||
# -- Secret you manage (key `database-url`). Use this with External Secrets
|
||||
# / Vault / Sealed Secrets so the DSN never appears in values files.
|
||||
existingSecret: ""
|
||||
|
||||
# -- Redis stream bridge. The gateway stores per-run SSE events in Redis Streams
|
||||
# so live run events reach a client connected to any gateway pod (cross-pod
|
||||
# SSE delivery + reconnect replay, PR #3191). Bundled mode (default) deploys
|
||||
# a single-instance redis StatefulSet; set `enabled: false` to use an external
|
||||
# managed Redis. The gateway reads DEER_FLOW_STREAM_BRIDGE_REDIS_URL from the
|
||||
# resolved Secret; config.yaml's stream_bridge.type is `redis` by default.
|
||||
redis:
|
||||
# -- Deploy a bundled redis StatefulSet. Disable to use an external Redis.
|
||||
enabled: true
|
||||
image:
|
||||
repository: redis
|
||||
tag: "7-alpine"
|
||||
auth:
|
||||
# -- Empty (default) = no auth, matching the compose deployment (ClusterIP
|
||||
# isolation only). Set a password to enable AUTH; the DSN becomes
|
||||
# redis://:<password>@host:6379/0. Ignored if existingSecret is set.
|
||||
password: ""
|
||||
# -- Use an existing Secret (key `redis-url`, plus `redis-password` if auth)
|
||||
# instead of generating one. Skips Secret creation.
|
||||
existingSecret: ""
|
||||
primary:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: "" # "" = cluster default
|
||||
accessMode: ReadWriteOnce # RWX (NFS) only needed if redis itself is HA
|
||||
size: 5Gi
|
||||
# -- External redis (used when `enabled: false`). Provide either a full
|
||||
# `redisUrl` (chart wraps it into a Secret for you) or an `existingSecret`
|
||||
# you manage (key `redis-url`).
|
||||
external:
|
||||
# -- Full URL, e.g. redis://:pass@myredis.example:6379/0. Chart writes it
|
||||
# into the Secret so it never touches the gateway env directly.
|
||||
redisUrl: ""
|
||||
# -- Secret you manage (key `redis-url`). Use with External Secrets / Vault
|
||||
# / Sealed Secrets so the URL never appears in values files.
|
||||
existingSecret: ""
|
||||
|
||||
# -- Persistent volume for runtime state (.deer-flow): sqlite DB, memory,
|
||||
# custom agents, and per-thread user-data. Also mounted (PVC mode) into
|
||||
# provisioner-spawned sandbox Pods.
|
||||
persistence:
|
||||
home:
|
||||
enabled: true
|
||||
storageClass: "" # "" = cluster default
|
||||
accessMode: ReadWriteOnce # Use ReadWriteMany (NFS/etc.) for multi-node
|
||||
size: 10Gi
|
||||
|
||||
# -- Skills library mounted at /app/skills. Default: emptyDir (skills disabled).
|
||||
# Populate via an existing PVC or ConfigMap, or bake skills into a custom
|
||||
# gateway image. Provisioner PVC mode references this same claim when set.
|
||||
skills:
|
||||
enabled: false
|
||||
existingClaim: ""
|
||||
configMap: ""
|
||||
|
||||
# -- Provider/channel/search secrets injected as env vars into the gateway.
|
||||
# Reference them from config.yaml as $VAR. Example:
|
||||
# secrets:
|
||||
# OPENAI_API_KEY: "sk-..."
|
||||
# FEISHU_APP_ID: "cli_xxx"
|
||||
# FEISHU_APP_SECRET: "xxx"
|
||||
# GITHUB_TOKEN: "ghp_xxx"
|
||||
secrets: {}
|
||||
# -- Use an existing Secret instead of creating one from `secrets` above.
|
||||
existingSecret: ""
|
||||
|
||||
# -- Ingress in front of nginx (port 2026). nginx preserves all internal routing.
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
host: "deer-flow.example.com"
|
||||
annotations: {}
|
||||
tls:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
# hosts: [] # defaults to [ingress.host]
|
||||
|
||||
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
||||
# inline literal secret values here. The default enables provisioner sandbox.
|
||||
config: |
|
||||
config_version: 20
|
||||
log_level: info
|
||||
|
||||
models: []
|
||||
# Example (uncomment & set the matching secret in `secrets`):
|
||||
# - name: gpt-4
|
||||
# display_name: GPT-4
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: gpt-4
|
||||
# api_key: $OPENAI_API_KEY
|
||||
# request_timeout: 600.0
|
||||
|
||||
sandbox:
|
||||
use: deerflow.community.aio_sandbox:AioSandboxProvider
|
||||
provisioner_url: http://provisioner:8002
|
||||
image: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
|
||||
port: 8080
|
||||
replicas: 3
|
||||
|
||||
database:
|
||||
# PostgreSQL is the default backend (bundled StatefulSet, or external).
|
||||
# DATABASE_URL is injected from the postgres Secret; $DATABASE_URL is
|
||||
# resolved by the harness before the config is instantiated.
|
||||
backend: postgres
|
||||
postgres_url: $DATABASE_URL
|
||||
|
||||
# The LangGraph Store (cross-thread memory + thread list) reads this legacy
|
||||
# `checkpointer:` section — it does NOT fall back to `database:` the way the
|
||||
# checkpointer does. Point it at the same postgres so the Store is shared
|
||||
# across gateway pods (required for multi-replica operation).
|
||||
checkpointer:
|
||||
type: postgres
|
||||
connection_string: $DATABASE_URL
|
||||
|
||||
# Stream bridge: `redis` (default) stores per-run SSE events in Redis Streams
|
||||
# so live run events reach a client on any gateway pod (cross-pod SSE delivery
|
||||
# + reconnect replay, PR #3191). The URL is read from the
|
||||
# DEER_FLOW_STREAM_BRIDGE_REDIS_URL env var injected from the redis Secret.
|
||||
# Set `type: memory` (and disable the redis chart) for single-pod-only mode.
|
||||
stream_bridge:
|
||||
type: redis
|
||||
|
||||
memory:
|
||||
storage_path: memory.json
|
||||
|
||||
# -- Tools configuration. The agent gets NO tools unless they're listed here
|
||||
# (BUILTIN_TOOLS only adds present_file + ask_clarification). The file/bash
|
||||
# tools run inside the AIO sandbox configured above. The web tools
|
||||
# (web_search, web_fetch, image_search) need no API key but require outbound
|
||||
# internet from the gateway pod - swap backends or remove entries for
|
||||
# air-gapped clusters (see config.example.yaml).
|
||||
tool_groups:
|
||||
- name: web
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
|
||||
tools:
|
||||
- name: web_search
|
||||
group: web
|
||||
use: deerflow.community.ddg_search.tools:web_search_tool
|
||||
max_results: 5
|
||||
- name: web_fetch
|
||||
group: web
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
timeout: 10
|
||||
- name: image_search
|
||||
group: web
|
||||
use: deerflow.community.image_search.tools:image_search_tool
|
||||
max_results: 5
|
||||
- name: ls
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:ls_tool
|
||||
- name: read_file
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:read_file_tool
|
||||
- name: glob
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:glob_tool
|
||||
max_results: 200
|
||||
- name: grep
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:grep_tool
|
||||
max_results: 100
|
||||
- name: write_file
|
||||
group: file:write
|
||||
use: deerflow.sandbox.tools:write_file_tool
|
||||
- name: str_replace
|
||||
group: file:write
|
||||
use: deerflow.sandbox.tools:str_replace_tool
|
||||
- name: bash
|
||||
group: bash
|
||||
use: deerflow.sandbox.tools:bash_tool
|
||||
|
||||
# -- DeerFlow extensions_config.json content (MCP servers + skill state).
|
||||
extensionsConfig: |
|
||||
{"mcpServers":{},"skills":{}}
|
||||
@@ -93,7 +93,7 @@ services:
|
||||
- |
|
||||
set -e
|
||||
cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf
|
||||
test -e /proc/net/if_inet6 || sed -i '/^[[:space:]]*listen[[:space:]]\+\[::\]:2026;/d' /etc/nginx/nginx.conf
|
||||
test -e /proc/net/if_inet6 || sed -i '/^[[:space:]]*listen[[:space:]]\+\[::\]:2026[[:space:]]/d' /etc/nginx/nginx.conf
|
||||
exec nginx -g 'daemon off;'
|
||||
depends_on:
|
||||
- frontend
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump the project version across every version source in lockstep.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/bump_version.sh <version> # e.g. scripts/bump_version.sh 2.2.0
|
||||
#
|
||||
# Updates:
|
||||
# backend/pyproject.toml (version = "...")
|
||||
# frontend/package.json ("version": "...")
|
||||
# deploy/helm/deer-flow/Chart.yaml (version: + appVersion:)
|
||||
#
|
||||
# This does NOT edit CHANGELOG.md or create/push a git tag — keep those manual.
|
||||
# After running, commit and tag v<version> to trigger the release workflows
|
||||
# (container.yaml + chart.yaml), which gate on scripts/verify_versions.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Usage: $0 <version> (e.g. $0 2.2.0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${1#v}" # tolerate a leading "v" (tag form) if passed by mistake
|
||||
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([0-9A-Za-z.+-]+)?$'; then
|
||||
echo "error: '$VERSION' is not a valid SemVer (expected X.Y.Z[, -prerelease])" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PYPROJECT="$ROOT/backend/pyproject.toml"
|
||||
PACKAGE="$ROOT/frontend/package.json"
|
||||
CHART="$ROOT/deploy/helm/deer-flow/Chart.yaml"
|
||||
|
||||
for f in "$PYPROJECT" "$PACKAGE" "$CHART"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "error: expected version file not found: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
python3 - "$PYPROJECT" "$PACKAGE" "$CHART" "$VERSION" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
pyproject, package, chart, version = sys.argv[1:5]
|
||||
|
||||
# backend/pyproject.toml — version = "..."
|
||||
with open(pyproject) as f:
|
||||
src = f.read()
|
||||
new = re.sub(r'(?m)^version\s*=\s*".*?"', f'version = "{version}"', src, count=1)
|
||||
if new == src:
|
||||
sys.exit(f"error: no top-level 'version' field in {pyproject}")
|
||||
with open(pyproject, "w") as f:
|
||||
f.write(new)
|
||||
|
||||
# frontend/package.json — "version": "..." (preserve indentation; minimal diff)
|
||||
with open(package) as f:
|
||||
src = f.read()
|
||||
new = re.sub(
|
||||
r'(?m)^(\s*)"version"\s*:\s*".*?"',
|
||||
lambda m: f'{m.group(1)}"version": "{version}"',
|
||||
src,
|
||||
count=1,
|
||||
)
|
||||
if new == src:
|
||||
sys.exit(f'error: no top-level "version" field in {package}')
|
||||
with open(package, "w") as f:
|
||||
f.write(new)
|
||||
|
||||
# deploy/helm/deer-flow/Chart.yaml — version: X.Y.Z and appVersion: "X.Y.Z"
|
||||
with open(chart) as f:
|
||||
src = f.read()
|
||||
new = re.sub(r'(?m)^version:\s*\S+', f'version: {version}', src, count=1)
|
||||
new = re.sub(r'(?m)^appVersion:\s*".*?"', f'appVersion: "{version}"', new, count=1)
|
||||
if new == src:
|
||||
sys.exit(f"error: could not find version/appVersion in {chart}")
|
||||
with open(chart, "w") as f:
|
||||
f.write(new)
|
||||
PY
|
||||
|
||||
echo "Bumped version to $VERSION in:"
|
||||
echo " backend/pyproject.toml"
|
||||
echo " frontend/package.json"
|
||||
echo " deploy/helm/deer-flow/Chart.yaml (version + appVersion)"
|
||||
echo
|
||||
|
||||
if ! bash "$ROOT/scripts/verify_versions.sh" "$VERSION"; then
|
||||
echo "error: post-bump verification failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. Update CHANGELOG.md"
|
||||
echo " 2. git add -A && git commit -m \"release: v$VERSION\""
|
||||
echo " 3. git tag v$VERSION && git push origin v$VERSION"
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that every project version source agrees.
|
||||
#
|
||||
# Sources checked:
|
||||
# deploy/helm/deer-flow/Chart.yaml — version + appVersion
|
||||
# backend/pyproject.toml — version
|
||||
# frontend/package.json — version
|
||||
#
|
||||
# Usage:
|
||||
# scripts/verify_versions.sh # all sources must be mutually equal
|
||||
# scripts/verify_versions.sh 2.1.0 # all sources must equal 2.1.0
|
||||
#
|
||||
# Exit status is 0 when consistent, 1 otherwise. The release workflows
|
||||
# (.github/workflows/chart.yaml and container.yaml) call this on v* tags — via
|
||||
# the reusable .github/workflows/verify-versions.yml — to gate publishing when
|
||||
# a version source was forgotten.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
CHART="$ROOT/deploy/helm/deer-flow/Chart.yaml"
|
||||
PYPROJECT="$ROOT/backend/pyproject.toml"
|
||||
PACKAGE="$ROOT/frontend/package.json"
|
||||
|
||||
for f in "$CHART" "$PYPROJECT" "$PACKAGE"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "::error::missing version file: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
CHART_VERSION=$(awk '/^version:/ {print $2; exit}' "$CHART")
|
||||
APP_VERSION=$(awk '/^appVersion:/ {gsub(/"/, ""); print $2; exit}' "$CHART")
|
||||
PY_VERSION=$(awk -F'"' '/^version[[:space:]]*=/ {print $2; exit}' "$PYPROJECT")
|
||||
JS_VERSION=$(grep -m1 '"version"' "$PACKAGE" | awk -F'"' '{print $4}')
|
||||
|
||||
printf 'Chart.yaml version: %s\n' "$CHART_VERSION"
|
||||
printf 'Chart.yaml appVersion: %s\n' "$APP_VERSION"
|
||||
printf 'backend/pyproject.toml: %s\n' "$PY_VERSION"
|
||||
printf 'frontend/package.json: %s\n' "$JS_VERSION"
|
||||
|
||||
# mismatch <name> <actual> <expected>: prints a GitHub Actions annotation and
|
||||
# returns 1 when they differ, 0 when equal.
|
||||
mismatch() {
|
||||
if [ "$2" != "$3" ]; then
|
||||
echo "::error::$1 is '$2' but expected '$3'." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
EXPECTED="${1:-}"
|
||||
status=0
|
||||
|
||||
if [ -n "$EXPECTED" ]; then
|
||||
printf 'Expected: %s (from tag v%s)\n\n' "$EXPECTED" "$EXPECTED"
|
||||
mismatch "Chart.yaml version" "$CHART_VERSION" "$EXPECTED" || status=1
|
||||
mismatch "Chart.yaml appVersion" "$APP_VERSION" "$EXPECTED" || status=1
|
||||
mismatch "backend/pyproject.toml" "$PY_VERSION" "$EXPECTED" || status=1
|
||||
mismatch "frontend/package.json" "$JS_VERSION" "$EXPECTED" || status=1
|
||||
else
|
||||
echo
|
||||
mismatch "Chart.yaml appVersion" "$APP_VERSION" "$CHART_VERSION" || status=1
|
||||
mismatch "backend/pyproject.toml" "$PY_VERSION" "$CHART_VERSION" || status=1
|
||||
mismatch "frontend/package.json" "$JS_VERSION" "$CHART_VERSION" || status=1
|
||||
fi
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
if [ -n "$EXPECTED" ]; then
|
||||
echo "Tip: run scripts/bump_version.sh $EXPECTED to align all sources." >&2
|
||||
else
|
||||
echo "Tip: run scripts/bump_version.sh <version> to align all sources." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK — all version sources agree on ${CHART_VERSION}."
|
||||
Reference in New Issue
Block a user