diff --git a/coding-agent/helm/templates/configmap-agent-config.yaml b/coding-agent/helm/templates/configmap-agent-config.yaml index 3ebd51757..68dd85ea4 100644 --- a/coding-agent/helm/templates/configmap-agent-config.yaml +++ b/coding-agent/helm/templates/configmap-agent-config.yaml @@ -38,6 +38,9 @@ data: "session_metrics", "shell_session", "skills", + {{- if .Values.kb.enabled }} + "kb", + {{- end }} ] [storage] @@ -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 = [] diff --git a/coding-agent/helm/templates/cronjob-kb-index.yaml b/coding-agent/helm/templates/cronjob-kb-index.yaml new file mode 100644 index 000000000..b4a705fdb --- /dev/null +++ b/coding-agent/helm/templates/cronjob-kb-index.yaml @@ -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 }} diff --git a/coding-agent/helm/values-example.yaml b/coding-agent/helm/values-example.yaml index 64fae6241..ee1103a6c 100644 --- a/coding-agent/helm/values-example.yaml +++ b/coding-agent/helm/values-example.yaml @@ -25,6 +25,13 @@ agent: secretRef: - - + # 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. + # - + # secretEnv: + # - name: OPENAI_API_KEY + # secretName: + # secretKey: api-key config: # Example OpenAI-compatible provider settings. Replace for the target runtime. provider: openai @@ -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: +# repos: +# - name: sre +# corpus: sre +# url: "//sre.git" + observability: enabled: true backend: langfuse @@ -47,6 +75,13 @@ observability: networkPolicy: extraEgress: + # Private deployments should add the git host CIDR needed by kb.index.repos. + - to: + - ipBlock: + cidr: /32 + ports: + - protocol: TCP + port: 443 - to: - ipBlock: cidr: /32 diff --git a/coding-agent/helm/values.yaml b/coding-agent/helm/values.yaml index 823349d1d..6fd3add28 100644 --- a/coding-agent/helm/values.yaml +++ b/coding-agent/helm/values.yaml @@ -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: [] @@ -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: "//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 diff --git a/coding-agent/tests/deploy/test_helm_chart.py b/coding-agent/tests/deploy/test_helm_chart.py index 9c6356e1f..73dfc9ff7 100644 --- a/coding-agent/tests/deploy/test_helm_chart.py +++ b/coding-agent/tests/deploy/test_helm_chart.py @@ -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] @@ -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" @@ -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":"//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 "//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(