Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions coding-agent/helm/templates/configmap-agent-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ data:
"session_metrics",
"shell_session",
"skills",
{{- if .Values.kb.enabled }}
"kb",
{{- end }}
]

[storage]
Expand All @@ -51,6 +54,21 @@ data:
local = {{ printf "%s/local.sqlite3" .Values.persistence.data.mountPath | quote }}
docs = {{ printf "%s/docs" .Values.persistence.data.mountPath | quote }}

{{- if .Values.kb.enabled }}
[kb]
db_path = {{ printf "%s/%s" (.Values.persistence.data.mountPath | trimSuffix "/") (.Values.kb.dbPath | trimPrefix "/") | quote }}
embedding_model = {{ .Values.kb.embeddingModel | quote }}
{{- if .Values.kb.embeddingBaseUrl }}
embedding_base_url = {{ .Values.kb.embeddingBaseUrl | quote }}
{{- end }}
embedding_dim = {{ .Values.kb.embeddingDim }}
{{- if ne .Values.kb.maxDistance nil }}
max_distance = {{ .Values.kb.maxDistance }}
{{- end }}
corpus = {{ .Values.kb.corpus | quote }}
search_corpora = {{ .Values.kb.searchCorpora | toJson }}
{{- end }}

[approval]
policy = {{ .Values.agent.config.approvalPolicy | quote }}
blocked_tools = []
Expand Down
108 changes: 108 additions & 0 deletions coding-agent/helm/templates/cronjob-kb-index.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
{{- if and .Values.kb.enabled .Values.kb.index.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "coding-agent.fullname" . }}-kb-index
labels:
{{- include "coding-agent.labels" . | nindent 4 }}
spec:
schedule: {{ .Values.kb.index.schedule | quote }}
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
metadata:
labels:
{{- include "coding-agent.selectorLabels" . | nindent 12 }}
spec:
serviceAccountName: {{ include "coding-agent.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
enableServiceLinks: {{ .Values.enableServiceLinks }}
restartPolicy: OnFailure
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
containers:
- name: kb-index
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- /bin/sh
- -ec
- |
set -eu
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT

if [ -n "${GIT_USERNAME:-}" ] && [ -n "${GIT_PASSWORD:-}" ]; then
askpass="$workdir/git-askpass.sh"
cat > "$askpass" <<'EOF'
#!/bin/sh
case "$1" in
*Username*) printf '%s\n' "$GIT_USERNAME" ;;
*Password*) printf '%s\n' "$GIT_PASSWORD" ;;
*) printf '\n' ;;
esac
EOF
chmod 700 "$askpass"
export GIT_ASKPASS="$askpass"
fi

{{- range .Values.kb.index.repos }}
git clone --depth 1 --branch {{ $.Values.kb.index.gitRef | quote }} {{ required "kb.index.repos[].url is required" .url | quote }} "$workdir/{{ required "kb.index.repos[].name is required" .name }}"
kb index "$workdir/{{ required "kb.index.repos[].name is required" .name }}" --corpus {{ required "kb.index.repos[].corpus is required" .corpus | quote }}
{{- end }}
{{- if or .Values.agent.secretRef .Values.kb.index.gitSecretName }}
envFrom:
{{- range .Values.agent.secretRef }}
- secretRef:
name: {{ . | quote }}
{{- end }}
{{- if .Values.kb.index.gitSecretName }}
- secretRef:
name: {{ .Values.kb.index.gitSecretName | quote }}
{{- end }}
{{- end }}
{{- if or .Values.agent.extraEnv .Values.agent.secretEnv }}
env:
{{- range .Values.agent.extraEnv }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- range .Values.agent.secretEnv }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName | quote }}
key: {{ .secretKey | quote }}
{{- end }}
{{- end }}
volumeMounts:
# Mount the same rendered agent.toml the Deployment uses so that
# `kb index` reads the [kb] embedding/db_path config from values
# instead of the image-baked default (wrong dim/model/off-PVC path).
- name: agent-config
mountPath: {{ .Values.server.configPath | quote }}
subPath: agent.toml
readOnly: true
# RWO data PVC assumes single-node scheduling. Multi-node clusters
# need RWX storage or node affinity shared with the agent pod.
- name: data-storage
mountPath: {{ .Values.persistence.data.mountPath }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 16 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 16 }}
{{- end }}
volumes:
- name: agent-config
configMap:
name: {{ include "coding-agent.fullname" . }}-agent-config
- name: data-storage
persistentVolumeClaim:
claimName: {{ default (printf "%s-data" (include "coding-agent.fullname" .)) .Values.persistence.data.existingClaim }}
{{- end }}
35 changes: 35 additions & 0 deletions coding-agent/helm/values-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ agent:
secretRef:
- <provider-secret>
- <observability-secret>
# The KB embedding client reads OPENAI_API_KEY. Put that key in a private
# secret and include it here, or wire it with agent.secretEnv below.
# - <kb-embedding-secret>
# secretEnv:
# - name: OPENAI_API_KEY
# secretName: <kb-embedding-secret>
# secretKey: api-key
config:
# Example OpenAI-compatible provider settings. Replace for the target runtime.
provider: openai
Expand All @@ -38,6 +45,27 @@ agent:
memoryLimitMb: null
dockerImage: python:3.11-slim

# kb:
# enabled: true
# dbPath: kb
# embeddingModel: BAAI/bge-m3
# embeddingBaseUrl: https://api.siliconflow.cn/v1
# embeddingDim: 1024
# maxDistance: null
# corpus: notes
# searchCorpora:
# - sre
# - notes
# index:
# enabled: true
# schedule: "0 * * * *"
# gitRef: main
# gitSecretName: <kb-git-secret>
# repos:
# - name: sre
# corpus: sre
# url: "<forgejo-host>/<owner>/sre.git"

observability:
enabled: true
backend: langfuse
Expand All @@ -47,6 +75,13 @@ observability:

networkPolicy:
extraEgress:
# Private deployments should add the git host CIDR needed by kb.index.repos.
- to:
- ipBlock:
cidr: <git-host-cidr>/32
ports:
- protocol: TCP
port: 443
- to:
- ipBlock:
cidr: <observability-host-ip>/32
Expand Down
30 changes: 30 additions & 0 deletions coding-agent/helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ httpRoute:

agent:
dataDir: /var/lib/coding-agent/data
# KB embedding providers such as SiliconFlow must expose their key to pods as
# OPENAI_API_KEY using agent.secretRef or agent.secretEnv. The LLM key remains
# separate and continues to use AGENT_API_KEY or provider-specific variables.
extraEnv: []
secretRef: []
secretEnv: []
Expand All @@ -97,6 +100,33 @@ agent:
memoryLimitMb: null
dockerImage: python:3.11-slim

kb:
enabled: false
dbPath: kb
embeddingModel: BAAI/bge-m3
embeddingBaseUrl: https://api.siliconflow.cn/v1
embeddingDim: 1024
maxDistance: null
corpus: notes
searchCorpora:
- sre
- notes
index:
enabled: false
schedule: "0 * * * *"
gitRef: main
# Real repo URLs come from private values injected at deploy time.
# Example only:
# repos:
# - name: sre
# corpus: sre
# url: "<forgejo-host>/<owner>/sre.git"
repos: []
# Optional k8s secret for git credentials. A secret can provide
# GIT_USERNAME/GIT_PASSWORD for the built-in askpass helper, or other
# git-compatible env such as GIT_ASKPASS.
gitSecretName: ""

observability:
enabled: false
backend: null
Expand Down
108 changes: 108 additions & 0 deletions coding-agent/tests/deploy/test_helm_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def _network_policy(docs: list[dict[str, Any]]) -> dict[str, Any]:
return _object_named(docs, "NetworkPolicy", "coding-agent")


def _cronjob(docs: list[dict[str, Any]], name_suffix: str) -> dict[str, Any]:
return _object_named(docs, "CronJob", name_suffix)


def _objects_of_kind(docs: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
return [doc for doc in docs if doc.get("kind") == kind]

Expand Down Expand Up @@ -147,6 +151,11 @@ def test_helm_values_render(values_file: str | None) -> None:
def test_helm_default_runtime_contract_is_runnable() -> None:
docs = _render()
_assert_storage(docs, "/var/lib/coding-agent/data")
config = _agent_toml(docs)
assert "kb" not in config
assert "kb" not in config["agent"]["plugins"]["enabled"]
assert not _objects_of_kind(docs, "CronJob")

pod_spec = _deployment(docs)["spec"]["template"]["spec"]
assert pod_spec["securityContext"]["fsGroup"] == 10001
assert pod_spec["serviceAccountName"] == "coding-agent-coding-agent"
Expand Down Expand Up @@ -573,6 +582,105 @@ def test_helm_data_mount_override_updates_sqlite_bundle_path() -> None:
_assert_storage(docs, "/data2")


def test_helm_kb_enabled_renders_config_and_plugin() -> None:
docs = _render("--set", "kb.enabled=true")
config = _agent_toml(docs)

assert "kb" in config["agent"]["plugins"]["enabled"]
assert config["kb"] == {
"db_path": "/var/lib/coding-agent/data/kb",
"embedding_model": "BAAI/bge-m3",
"embedding_base_url": "https://api.siliconflow.cn/v1",
"embedding_dim": 1024,
"corpus": "notes",
"search_corpora": ["sre", "notes"],
}


def test_helm_kb_enabled_can_render_optional_max_distance() -> None:
docs = _render("--set", "kb.enabled=true", "--set", "kb.maxDistance=0.42")

assert _agent_toml(docs)["kb"]["max_distance"] == 0.42


def test_helm_kb_index_disabled_does_not_render_cronjob() -> None:
docs = _render("--set", "kb.enabled=true", "--set", "kb.index.enabled=false")

assert not _objects_of_kind(docs, "CronJob")


def test_helm_kb_index_cronjob_renders_pvc_env_and_netpol_labels() -> None:
docs = _render(
"--set",
"kb.enabled=true",
"--set",
"kb.index.enabled=true",
"--set",
"kb.index.gitSecretName=kb-git-creds",
"--set-json",
'agent.secretEnv=[{"name":"OPENAI_API_KEY","secretName":"kb-embedding","secretKey":"api-key"}]',
"--set-json",
'kb.index.repos=[{"name":"sre","corpus":"sre","url":"<forgejo-host>/<owner>/sre.git"}]',
)
job = _cronjob(docs, "-kb-index")
pod_spec = job["spec"]["jobTemplate"]["spec"]["template"]["spec"]
container = pod_spec["containers"][0]

assert job["spec"]["schedule"] == "0 * * * *"
assert job["spec"]["jobTemplate"]["spec"]["template"]["metadata"]["labels"] == {
"app.kubernetes.io/name": "coding-agent",
"app.kubernetes.io/instance": "coding-agent",
}
assert pod_spec["securityContext"]["fsGroup"] == 10001
assert container["securityContext"] == {
"runAsGroup": 10001,
"runAsNonRoot": True,
"runAsUser": 10001,
}
assert container["envFrom"] == [{"secretRef": {"name": "kb-git-creds"}}]
assert _env_var(container, "OPENAI_API_KEY")["valueFrom"]["secretKeyRef"] == {
"name": "kb-embedding",
"key": "api-key",
}
# The CronJob must mount the SAME rendered agent.toml ConfigMap as the
# Deployment, at the same configPath/subPath, so `kb index` reads the [kb]
# embedding/db_path config from values rather than the image-baked default.
deployment_container = _container(docs)
config_mount = next(
m for m in deployment_container["volumeMounts"] if m["name"] == "agent-config"
)
assert config_mount["subPath"] == "agent.toml"
assert container["volumeMounts"] == [
{
"name": "agent-config",
"mountPath": config_mount["mountPath"],
"subPath": "agent.toml",
"readOnly": True,
},
{
"name": "data-storage",
"mountPath": "/var/lib/coding-agent/data",
},
]
assert pod_spec["volumes"] == [
{
"name": "agent-config",
"configMap": {"name": "coding-agent-coding-agent-agent-config"},
},
{
"name": "data-storage",
"persistentVolumeClaim": {
"claimName": "coding-agent-coding-agent-data",
},
},
]
script = "\n".join(container["command"])
assert "git clone --depth 1 --branch \"main\"" in script
assert "<forgejo-host>/<owner>/sre.git" in script
assert "kb index" in script
assert "--corpus \"sre\"" in script


def test_helm_chart_ignores_legacy_sandbox_sidecar_values() -> None:
docs = _render("--set", "sandbox.sidecar.enabled=true")
assert all(
Expand Down
Loading