From 74c167680ab5482d76a4eb26b6ab119687b99460 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 3 Nov 2026 23:05:25 +0800 Subject: [PATCH 01/38] feat(helm): add optional HugeGraph RAG component --- helm/hugegraph/Chart.yaml | 6 +- .../docs/images/rag-helm-architecture.svg | 47 +++ helm/hugegraph/docs/rag.md | 117 ++++++++ helm/hugegraph/templates/_rag.tpl | 164 +++++++++++ helm/hugegraph/templates/rag-deployment.yaml | 210 ++++++++++++++ helm/hugegraph/templates/rag-ingress.yaml | 59 ++++ .../templates/rag-networkpolicy.yaml | 82 ++++++ helm/hugegraph/templates/rag-pvc.yaml | 44 +++ helm/hugegraph/templates/rag-service.yaml | 48 ++++ .../templates/rag-serviceaccount.yaml | 36 +++ helm/hugegraph/values-cluster.yaml | 7 + helm/hugegraph/values-rag.yaml | 115 ++++++++ helm/hugegraph/values-single.yaml | 5 + helm/test_helm_rag.py | 267 ++++++++++++++++++ 14 files changed, 1205 insertions(+), 2 deletions(-) create mode 100644 helm/hugegraph/docs/images/rag-helm-architecture.svg create mode 100644 helm/hugegraph/docs/rag.md create mode 100644 helm/hugegraph/templates/_rag.tpl create mode 100644 helm/hugegraph/templates/rag-deployment.yaml create mode 100644 helm/hugegraph/templates/rag-ingress.yaml create mode 100644 helm/hugegraph/templates/rag-networkpolicy.yaml create mode 100644 helm/hugegraph/templates/rag-pvc.yaml create mode 100644 helm/hugegraph/templates/rag-service.yaml create mode 100644 helm/hugegraph/templates/rag-serviceaccount.yaml create mode 100644 helm/hugegraph/values-rag.yaml create mode 100644 helm/test_helm_rag.py diff --git a/helm/hugegraph/Chart.yaml b/helm/hugegraph/Chart.yaml index 1a835b045b..29791c6308 100644 --- a/helm/hugegraph/Chart.yaml +++ b/helm/hugegraph/Chart.yaml @@ -17,21 +17,23 @@ apiVersion: v2 name: hugegraph-helm -description: Helm chart for Apache HugeGraph HStore cluster with optional SeaTunnel and Vermeer +description: Helm chart for Apache HugeGraph HStore with optional RAG, SeaTunnel and Vermeer components type: application -version: 0.4.0 +version: 0.5.0 # TODO: set appVersion to "1.8.0" when the release images are published. appVersion: "latest" kubeVersion: ">=1.23.0-0" keywords: - hugegraph - graph + - graphrag - hstore - raft home: https://hugegraph.apache.org/ icon: https://hugegraph.apache.org/img/logo.svg sources: - https://github.com/apache/hugegraph + - https://github.com/apache/hugegraph-ai - https://github.com/apache/seatunnel - https://github.com/apache/hugegraph-computer/tree/master/vermeer maintainers: diff --git a/helm/hugegraph/docs/images/rag-helm-architecture.svg b/helm/hugegraph/docs/images/rag-helm-architecture.svg new file mode 100644 index 0000000000..714fa5e107 --- /dev/null +++ b/helm/hugegraph/docs/images/rag-helm-architecture.svg @@ -0,0 +1,47 @@ + + HugeGraph Helm optional RAG integration + One chart manages HugeGraph core services and an optional RAG application for single-node and cluster presets. + + + + + + + One Helm chart, optional GraphRAG runtime + Default remains unchanged · enable with rag.enabled=true · same template for 1+1+1 and 3+3+3 + + + + + + hugegraph-helm release + + RAG OFF by default + + + + + + + + PD1 or 3 replicasmetadata + placement + Store1 or 3 replicasHStore data plane + Server Servicetopology-neutral DNSauth Secret reused safely + RAG0..N replicasimage: hugegraph/rag + + + + + + + + + Single preset1 PD + 1 Store + 1 Server + Cluster preset3 PD + 3 Store + 3 Server + Flexible boundariesService · Ingress · PVC · NetPol + + + Docker and docker-compose in hugegraph-ai remain unchanged; Kubernetes ownership moves to the main chart. + diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md new file mode 100644 index 0000000000..29d9a35f22 --- /dev/null +++ b/helm/hugegraph/docs/rag.md @@ -0,0 +1,117 @@ +# Optional HugeGraph RAG deployment + +The main HugeGraph chart owns the Kubernetes topology for the whole system. +`rag.enabled=true` adds the `hugegraph/rag` application from +[hugegraph-ai](https://github.com/apache/hugegraph-ai) without changing the +PD, Store, Server or Hubble topology. The component remains disabled by +default, and the Docker image and Docker Compose workflows in hugegraph-ai +remain independent. + +![RAG Helm integration](images/rag-helm-architecture.svg) + +## Enable it + +The same RAG template works with both presets because it connects through the +release-scoped Server Service rather than enumerating Server Pods. The optional +`values-rag.yaml` overlay is the complete, reviewable switch; `rag.enabled=false` +removes every RAG object again. + +```bash +# Single PD + Store + Server, plus one RAG Pod +helm upgrade --install hugegraph ./helm/hugegraph \ + -n hugegraph --create-namespace \ + -f helm/hugegraph/values-single.yaml \ + -f helm/hugegraph/values-rag.yaml + +# 3 + 3 + 3 HugeGraph cluster, plus the preset's RAG replica count +helm upgrade --install hugegraph ./helm/hugegraph \ + -n hugegraph --create-namespace \ + -f helm/hugegraph/values-cluster.yaml \ + -f helm/hugegraph/values-rag.yaml +``` + +With `rag.hugegraph.autoConfigure=true` and an empty `rag.hugegraph.url`, the +chart injects: + +- `GRAPH_URL` as the same-release Server client Service in the application's native `host:port` form; +- `GRAPH_NAME`, `GRAPH_SPACE` and `GRAPH_USER` from chart values; +- `GRAPH_PWD` from the same Server administrator Secret when Server auth is + enabled; and +- a writable `HUGEGRAPH_LLM_ENV_PATH` under `/tmp`. + +Any entry in `rag.env` with the same name replaces its automatic counterpart. +Set `rag.hugegraph.autoConfigure=false` to manage the complete application +environment yourself. + +## Model provider credentials + +Keep provider credentials out of values files. Create a Secret and expose it +with `envFrom`: + +```bash +kubectl -n hugegraph create secret generic rag-model \ + --from-literal=OPENAI_API_KEY='replace-me' \ + --from-literal=OPENAI_EMBEDDING_API_KEY='replace-me' + +cat > /tmp/rag-values.yaml <<'YAML' +rag: + enabled: true + envFrom: + - secretRef: + name: rag-model +YAML + +helm upgrade --install hugegraph ./helm/hugegraph \ + -n hugegraph --create-namespace -f /tmp/rag-values.yaml +``` + +The application supports other model providers through the same native +`env`/`envFrom` mechanism. The chart does not copy credentials into a +ConfigMap or a generated Secret. + +## Use another HugeGraph deployment + +Set an explicit URL and credential Secret. An external URL disables automatic +reuse of this release's Server password. + +```yaml +rag: + enabled: true + hugegraph: + autoConfigure: true + url: http://hugegraph-server.graphs.svc:8080 + graphName: hugegraph + graphSpace: DEFAULT + username: admin + passwordSecret: + name: external-hugegraph-admin + key: password +``` + +## Exposure and strict mode + +In `mode=easy`, the RAG Service inherits `NodePort`. In `mode=strict`, it +inherits `ClusterIP` and a NetworkPolicy isolates the Pod. Strict mode permits +DNS and the same-release Server automatically, but does not silently open +access to public model APIs or an Ingress controller. Add the required peers +to `rag.networkPolicy.extraIngress` and provider destinations to +`rag.networkPolicy.extraEgress` using native Kubernetes NetworkPolicy rules. + +An explicit `rag.service.type` always wins over the mode default. Ingress is +independent and remains disabled until `rag.ingress.enabled=true`. Before exposing +the demo UI outside a trusted network, review the application login settings and +model-provider spend controls; the chart does not invent a second RAG credential +system. + +## Persistence and replicas + +Persistence is off by default, so the image's packaged prompt and resource +files are not hidden by an empty volume. When enabled, an init container seeds +the claim from the selected image before the main container mounts it at +`rag.persistence.mountPath`. A generated claim is retained on uninstall. + +One generated `ReadWriteOnce` claim is valid only for one RAG replica. For +multiple replicas, either keep persistence disabled, provide an existing +shared claim, or use a StorageClass that supports `ReadWriteMany` and include +that mode in `rag.persistence.accessModes`. Model and graph credentials remain +Kubernetes Secrets regardless of persistence. diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl new file mode 100644 index 0000000000..83df898ec5 --- /dev/null +++ b/helm/hugegraph/templates/_rag.tpl @@ -0,0 +1,164 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.name" -}} +{{- printf "%s-rag" (include "hugegraph.fullname" . | trunc 59 | trimSuffix "-") -}} +{{- end }} + +{{- define "hugegraph.rag.dataName" -}} +{{- printf "%s-data" (include "hugegraph.rag.name" . | trunc 58 | trimSuffix "-") -}} +{{- end }} + + +{{- define "hugegraph.rag.replicas" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- if hasKey $rag "replicas" -}}{{- get $rag "replicas" -}}{{- else -}}1{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.port" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- if hasKey $rag "port" -}}{{- get $rag "port" -}}{{- else -}}8001{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.image" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $image := deepCopy (get $rag "image" | default dict) -}} +{{- $registry := trimSuffix "/" (get (.Values.global | default dict) "imageRegistry" | default "") -}} +{{- $repository := get $image "repository" | default "hugegraph/rag" -}} +{{- $_ := set $image "repository" (include "hugegraph.registryImage" (dict "reference" $repository "registry" $registry)) -}} +{{- include "hugegraph.image" (dict "image" $image "appVersion" .Chart.AppVersion) -}} +{{- end }} + +{{- define "hugegraph.rag.serviceType" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $service := get $rag "service" | default dict -}} +{{- if and (hasKey $service "type") (ne (get $service "type" | toString) "") (ne (get $service "type" | toString) "") -}} +{{- get $service "type" -}} +{{- else if eq (.Values.mode | default "easy") "strict" -}} +ClusterIP +{{- else -}} +NodePort +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.graphUrl" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $graph := get $rag "hugegraph" | default dict -}} +{{- $url := trim (get $graph "url" | default "") -}} +{{- if $url -}} +{{- $url -}} +{{- else -}} +{{- include "hugegraph.server.clientUrl" . | trimPrefix "http://" | trimPrefix "https://" -}} +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.graphSpace" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $graph := get $rag "hugegraph" | default dict -}} +{{- get $graph "graphSpace" | default .Values.server.graphSpace -}} +{{- end }} + +{{- define "hugegraph.rag.passwordSecretName" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $graph := get $rag "hugegraph" | default dict -}} +{{- $password := get $graph "passwordSecret" | default dict -}} +{{- $explicit := get $password "name" | default "" -}} +{{- if $explicit -}} +{{- $explicit -}} +{{- else if and (not (trim (get $graph "url" | default ""))) (get (.Values.server.auth | default dict) "enabled" | default false) -}} +{{- include "hugegraph.server.authSecretName" . -}} +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.passwordSecretKey" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $graph := get $rag "hugegraph" | default dict -}} +{{- $password := get $graph "passwordSecret" | default dict -}} +{{- $explicit := get $password "key" | default "" -}} +{{- if $explicit -}} +{{- $explicit -}} +{{- else if and (not (trim (get $graph "url" | default ""))) (get (.Values.server.auth | default dict) "enabled" | default false) -}} +{{- include "hugegraph.server.authSecretKey" . -}} +{{- else -}} +password +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.claimName" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- get $persistence "existingClaim" | default (include "hugegraph.rag.dataName" .) -}} +{{- end }} + +{{- define "hugegraph.rag.validate" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- if get $rag "enabled" | default false -}} +{{- $replicas := include "hugegraph.rag.replicas" . | int -}} +{{- if or (lt $replicas 1) (gt $replicas 100) -}} +{{- fail "rag.replicas must be between 1 and 100" -}} +{{- end -}} +{{- $port := include "hugegraph.rag.port" . | int -}} +{{- if or (lt $port 1) (gt $port 65535) -}} +{{- fail "rag.port must be between 1 and 65535" -}} +{{- end -}} +{{- $image := get $rag "image" | default dict -}} +{{- if not (trim (get $image "repository" | default "")) -}} +{{- fail "rag.image.repository must not be empty" -}} +{{- end -}} +{{- $service := get $rag "service" | default dict -}} +{{- $serviceType := include "hugegraph.rag.serviceType" . -}} +{{- if not (has $serviceType (list "ClusterIP" "NodePort" "LoadBalancer")) -}} +{{- fail "rag.service.type must be ClusterIP, NodePort or LoadBalancer" -}} +{{- end -}} +{{- $nodePort := int (get $service "nodePort" | default 0) -}} +{{- if and (ne $nodePort 0) (or (lt $nodePort 1) (gt $nodePort 65535)) -}} +{{- fail "rag.service.nodePort must be 0/unset or between 1 and 65535" -}} +{{- end -}} +{{- if and (ne $nodePort 0) (eq $serviceType "ClusterIP") -}} +{{- fail "rag.service.nodePort requires service.type NodePort or LoadBalancer" -}} +{{- end -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- if get $persistence "enabled" | default false -}} +{{- if not (trim (get $persistence "mountPath" | default "")) -}} +{{- fail "rag.persistence.mountPath must not be empty when persistence is enabled" -}} +{{- end -}} +{{- if and (not (get $persistence "existingClaim" | default "")) (not (get $persistence "size" | default "")) -}} +{{- fail "rag.persistence.size must not be empty when the chart creates the claim" -}} +{{- end -}} +{{- $accessModes := get $persistence "accessModes" | default (list "ReadWriteOnce") -}} +{{- if and (gt $replicas 1) (not (get $persistence "existingClaim" | default "")) (not (has "ReadWriteMany" $accessModes)) -}} +{{- fail "rag.persistence.accessModes must include ReadWriteMany when the chart creates one claim for multiple RAG replicas; otherwise use an existing shared claim or keep rag.replicas=1" -}} +{{- end -}} +{{- end -}} +{{- $podLabels := get $rag "podLabels" | default dict -}} +{{- range $reserved := list "app.kubernetes.io/name" "app.kubernetes.io/instance" "app.kubernetes.io/component" -}} +{{- if hasKey $podLabels $reserved -}} +{{- fail (printf "rag.podLabels must not set %s because the chart owns workload selectors" $reserved) -}} +{{- end -}} +{{- end -}} +{{- range $key, $_ := get $rag "podAnnotations" | default dict -}} +{{- if hasPrefix "checksum/" $key -}} +{{- fail (printf "rag.podAnnotations must not set %s because the chart owns checksum annotations" $key) -}} +{{- end -}} +{{- end -}} +{{- range $index, $entry := get $rag "env" | default list -}} +{{- if not (get $entry "name" | default "") -}} +{{- fail (printf "rag.env[%d].name must not be empty" $index) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml new file mode 100644 index 0000000000..884a6d3bee --- /dev/null +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -0,0 +1,210 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ include "hugegraph.resolveValues" . }} +{{- $rag := get .Values "rag" | default dict }} +{{- if get $rag "enabled" | default false }} +{{- include "hugegraph.rag.validate" . }} +{{- $graph := get $rag "hugegraph" | default dict }} +{{- $autoConfigure := true }} +{{- if hasKey $graph "autoConfigure" }}{{- $autoConfigure = get $graph "autoConfigure" }}{{- end }} +{{- $persistence := get $rag "persistence" | default dict }} +{{- $persistenceEnabled := get $persistence "enabled" | default false }} +{{- $envNames := dict }} +{{- range get $rag "env" | default list }} +{{- $_ := set $envNames (.name | toString) true }} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "hugegraph.rag.name" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag +spec: + replicas: {{ include "hugegraph.rag.replicas" . }} + strategy: + type: {{ ternary "Recreate" "RollingUpdate" (and $persistenceEnabled (eq (include "hugegraph.rag.replicas" . | int) 1)) }} + selector: + matchLabels: + {{- include "hugegraph.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: rag + template: + metadata: + labels: + {{- include "hugegraph.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: rag + {{- with get $rag "podLabels" }}{{ toYaml . | nindent 8 }}{{- end }} + {{- with get $rag "podAnnotations" }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- $serviceAccount := get $rag "serviceAccount" | default dict }} + {{- $serviceAccountCreate := true }} + {{- if hasKey $serviceAccount "create" }}{{- $serviceAccountCreate = get $serviceAccount "create" }}{{- end }} + automountServiceAccountToken: {{ get $serviceAccount "automountServiceAccountToken" | default false }} + {{- if get $serviceAccount "name" | default "" }} + serviceAccountName: {{ get $serviceAccount "name" }} + {{- else if $serviceAccountCreate }} + serviceAccountName: {{ include "hugegraph.rag.name" . }} + {{- else }} + serviceAccountName: default + {{- end }} + {{- if hasKey $rag "terminationGracePeriodSeconds" }} + terminationGracePeriodSeconds: {{ get $rag "terminationGracePeriodSeconds" }} + {{- end }} + {{- with get $rag "priorityClassName" }} + priorityClassName: {{ . | quote }} + {{- end }} + {{- with get $rag "nodeSelector" }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with get $rag "tolerations" }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with get $rag "topologySpreadConstraints" }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with get $rag "affinity" }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with get $rag "podSecurityContext" }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if $persistenceEnabled }} + initContainers: + - name: seed-resources + image: {{ include "hugegraph.rag.image" . | quote }} + imagePullPolicy: {{ get (get $rag "image" | default dict) "pullPolicy" | default "Always" }} + command: ["/bin/sh", "-ec"] + args: + - | + cp -a -n /home/work/hugegraph-llm/src/hugegraph_llm/resources/. /data/ + touch /data/.hugegraph-rag-seeded + {{- with get $rag "securityContext" }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml (get $rag "initResources" | default dict) | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- end }} + containers: + - name: rag + image: {{ include "hugegraph.rag.image" . | quote }} + imagePullPolicy: {{ get (get $rag "image" | default dict) "pullPolicy" | default "Always" }} + {{- with get $rag "securityContext" }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ include "hugegraph.rag.port" . }} + protocol: TCP + env: + {{- if and $autoConfigure (not (hasKey $envNames "GRAPH_URL")) }} + - name: GRAPH_URL + value: {{ include "hugegraph.rag.graphUrl" . | quote }} + {{- end }} + {{- if and $autoConfigure (not (hasKey $envNames "GRAPH_NAME")) }} + - name: GRAPH_NAME + value: {{ get $graph "graphName" | default "hugegraph" | quote }} + {{- end }} + {{- if and $autoConfigure (not (hasKey $envNames "GRAPH_SPACE")) }} + - name: GRAPH_SPACE + value: {{ include "hugegraph.rag.graphSpace" . | quote }} + {{- end }} + {{- if and $autoConfigure (not (hasKey $envNames "GRAPH_USER")) }} + - name: GRAPH_USER + value: {{ get $graph "username" | default "admin" | quote }} + {{- end }} + {{- $passwordSecret := include "hugegraph.rag.passwordSecretName" . }} + {{- if and $autoConfigure $passwordSecret (not (hasKey $envNames "GRAPH_PWD")) }} + - name: GRAPH_PWD + valueFrom: + secretKeyRef: + name: {{ $passwordSecret | quote }} + key: {{ include "hugegraph.rag.passwordSecretKey" . | quote }} + {{- end }} + {{- if not (hasKey $envNames "HUGEGRAPH_LLM_ENV_PATH") }} + - name: HUGEGRAPH_LLM_ENV_PATH + value: /tmp/hugegraph-llm.env + {{- end }} + {{- with get $rag "env" }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with get $rag "envFrom" }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml (get $rag "resources" | default dict) | nindent 12 }} + {{- if or $persistenceEnabled (get $rag "extraVolumeMounts") }} + volumeMounts: + {{- if $persistenceEnabled }} + - name: data + mountPath: {{ get $persistence "mountPath" | default "/home/work/hugegraph-llm/src/hugegraph_llm/resources" | quote }} + {{- end }} + {{- with get $rag "extraVolumeMounts" }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + startupProbe: + httpGet: + path: {{ get (get (get $rag "probes" | default dict) "startup" | default dict) "path" | default "/" | quote }} + port: http + failureThreshold: {{ get (get (get $rag "probes" | default dict) "startup" | default dict) "failureThreshold" | default 60 }} + periodSeconds: {{ get (get (get $rag "probes" | default dict) "startup" | default dict) "periodSeconds" | default 5 }} + {{- include "hugegraph.probeTuning" (get (get (get $rag "probes" | default dict) "startup" | default dict)) | nindent 12 }} + readinessProbe: + httpGet: + path: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "path" | default "/" | quote }} + port: http + failureThreshold: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "failureThreshold" | default 3 }} + periodSeconds: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "periodSeconds" | default 10 }} + {{- include "hugegraph.probeTuning" (get (get (get $rag "probes" | default dict) "readiness" | default dict)) | nindent 12 }} + livenessProbe: + httpGet: + path: {{ get (get (get $rag "probes" | default dict) "liveness" | default dict) "path" | default "/" | quote }} + port: http + failureThreshold: {{ get (get (get $rag "probes" | default dict) "liveness" | default dict) "failureThreshold" | default 3 }} + periodSeconds: {{ get (get (get $rag "probes" | default dict) "liveness" | default dict) "periodSeconds" | default 20 }} + {{- include "hugegraph.probeTuning" (get (get (get $rag "probes" | default dict) "liveness" | default dict)) | nindent 12 }} + {{- if or $persistenceEnabled (get $rag "extraVolumes") }} + volumes: + {{- if $persistenceEnabled }} + - name: data + persistentVolumeClaim: + claimName: {{ include "hugegraph.rag.claimName" . }} + {{- end }} + {{- with get $rag "extraVolumes" }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/hugegraph/templates/rag-ingress.yaml b/helm/hugegraph/templates/rag-ingress.yaml new file mode 100644 index 0000000000..4c7f4fbeb6 --- /dev/null +++ b/helm/hugegraph/templates/rag-ingress.yaml @@ -0,0 +1,59 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ include "hugegraph.resolveValues" . }} +{{- $rag := get .Values "rag" | default dict }} +{{- $ingress := get $rag "ingress" | default dict }} +{{- if and (get $rag "enabled" | default false) (get $ingress "enabled" | default false) }} +{{- include "hugegraph.rag.validate" . }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "hugegraph.rag.name" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag + {{- with get $ingress "annotations" }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with get $ingress "className" }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- with get $ingress "tls" }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range (get $ingress "hosts" | default (list (dict "paths" (list (dict "path" "/" "pathType" "Prefix"))))) }} + - {{- with .host }} + host: {{ . | quote }} + {{- end }} + http: + paths: + {{- range (.paths | default (list (dict "path" "/" "pathType" "Prefix"))) }} + - path: {{ .path | quote }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ include "hugegraph.rag.name" $ }} + port: + number: {{ include "hugegraph.rag.port" $ }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/hugegraph/templates/rag-networkpolicy.yaml b/helm/hugegraph/templates/rag-networkpolicy.yaml new file mode 100644 index 0000000000..aedd9a59fb --- /dev/null +++ b/helm/hugegraph/templates/rag-networkpolicy.yaml @@ -0,0 +1,82 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ include "hugegraph.resolveValues" . }} +{{- $rag := get .Values "rag" | default dict }} +{{- $networkPolicy := get .Values "networkPolicy" | default dict }} +{{- if and (get $rag "enabled" | default false) (get $networkPolicy "enabled" | default false) }} +{{- include "hugegraph.rag.validate" . }} +{{- $graph := get $rag "hugegraph" | default dict }} +{{- $autoConfigure := true }} +{{- if hasKey $graph "autoConfigure" }}{{- $autoConfigure = get $graph "autoConfigure" }}{{- end }} +{{- $internalServer := and $autoConfigure (not (trim (get $graph "url" | default ""))) }} +{{- $ragPolicy := get $rag "networkPolicy" | default dict }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "hugegraph.rag.name" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag +spec: + podSelector: + matchLabels: + {{- include "hugegraph.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: rag + policyTypes: + - Ingress + - Egress + {{- with get $ragPolicy "extraIngress" }} + ingress: + {{- toYaml . | nindent 4 }} + {{- end }} + egress: + {{- if $internalServer }} + - to: + {{- include "hugegraph.netpol.peer" (dict "root" . "component" "server") | nindent 8 }} + ports: + {{- include "hugegraph.netpol.ports" (list .Values.server.port) | nindent 8 }} + {{- end }} + {{- include "hugegraph.netpol.dns" . | nindent 4 }} + {{- with get $ragPolicy "extraEgress" }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- if $internalServer }} +--- +# Core Server policy is intentionally kept independent from optional RAG. +# Kubernetes unions ingress allows from all policies selecting the same Pods. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "hugegraph.rag.name" . }}-to-server + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag +spec: + podSelector: + matchLabels: + {{- include "hugegraph.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: server + policyTypes: + - Ingress + ingress: + - from: + {{- include "hugegraph.netpol.peer" (dict "root" . "component" "rag") | nindent 8 }} + ports: + {{- include "hugegraph.netpol.ports" (list .Values.server.port) | nindent 8 }} +{{- end }} +{{- end }} diff --git a/helm/hugegraph/templates/rag-pvc.yaml b/helm/hugegraph/templates/rag-pvc.yaml new file mode 100644 index 0000000000..6f51eb7db5 --- /dev/null +++ b/helm/hugegraph/templates/rag-pvc.yaml @@ -0,0 +1,44 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ include "hugegraph.resolveValues" . }} +{{- $rag := get .Values "rag" | default dict }} +{{- $persistence := get $rag "persistence" | default dict }} +{{- if and (get $rag "enabled" | default false) (get $persistence "enabled" | default false) (not (get $persistence "existingClaim" | default "")) }} +{{- include "hugegraph.rag.validate" . }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "hugegraph.rag.dataName" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + {{- toYaml (get $persistence "accessModes" | default (list "ReadWriteOnce")) | nindent 4 }} + {{- $storageClass := get $persistence "storageClassName" | default "" }} + {{- if eq $storageClass "-" }} + storageClassName: "" + {{- else if $storageClass }} + storageClassName: {{ $storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ get $persistence "size" | default "10Gi" }} +{{- end }} diff --git a/helm/hugegraph/templates/rag-service.yaml b/helm/hugegraph/templates/rag-service.yaml new file mode 100644 index 0000000000..a9cd780028 --- /dev/null +++ b/helm/hugegraph/templates/rag-service.yaml @@ -0,0 +1,48 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ include "hugegraph.resolveValues" . }} +{{- $rag := get .Values "rag" | default dict }} +{{- if get $rag "enabled" | default false }} +{{- include "hugegraph.rag.validate" . }} +{{- $service := get $rag "service" | default dict }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "hugegraph.rag.name" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag + {{- with get $service "annotations" }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ include "hugegraph.rag.serviceType" . }} + selector: + {{- include "hugegraph.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: rag + ports: + - name: http + port: {{ include "hugegraph.rag.port" . }} + targetPort: http + protocol: TCP + {{- $nodePort := int (get $service "nodePort" | default 0) }} + {{- if ne $nodePort 0 }} + nodePort: {{ $nodePort }} + {{- end }} +{{- end }} diff --git a/helm/hugegraph/templates/rag-serviceaccount.yaml b/helm/hugegraph/templates/rag-serviceaccount.yaml new file mode 100644 index 0000000000..438752c547 --- /dev/null +++ b/helm/hugegraph/templates/rag-serviceaccount.yaml @@ -0,0 +1,36 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ include "hugegraph.resolveValues" . }} +{{- $rag := get .Values "rag" | default dict }} +{{- $serviceAccount := get $rag "serviceAccount" | default dict }} +{{- $create := true }} +{{- if hasKey $serviceAccount "create" }}{{- $create = get $serviceAccount "create" }}{{- end }} +{{- if and (get $rag "enabled" | default false) $create (not (get $serviceAccount "name" | default "")) }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "hugegraph.rag.name" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag + {{- with get $serviceAccount "annotations" }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ get $serviceAccount "automountServiceAccountToken" | default false }} +{{- end }} diff --git a/helm/hugegraph/values-cluster.yaml b/helm/hugegraph/values-cluster.yaml index 1a9f0014d4..f61707be98 100644 --- a/helm/hugegraph/values-cluster.yaml +++ b/helm/hugegraph/values-cluster.yaml @@ -132,6 +132,13 @@ server: # Network isolation is selected by mode or an explicit enabled override. +# Optional RAG application follows this preset only when explicitly enabled. +# Two stateless Pods are a reasonable cluster starting point; configure shared +# ReadWriteMany storage before enabling persistence with more than one replica. +rag: + enabled: false + replicas: 2 + # Optional computation follows this preset only when explicitly enabled. vermeer: enabled: false diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml new file mode 100644 index 0000000000..65e1637134 --- /dev/null +++ b/helm/hugegraph/values-rag.yaml @@ -0,0 +1,115 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Optional HugeGraph RAG overlay. Layer it after values-single.yaml or +# values-cluster.yaml; topology presets retain their own RAG replica count. +rag: + enabled: true + image: + repository: hugegraph/rag + tag: latest + digest: '' + pullPolicy: Always + port: 8001 + hugegraph: + autoConfigure: true + url: '' + graphName: hugegraph + graphSpace: '' + username: admin + passwordSecret: + name: '' + key: '' + resources: {} + initResources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + podSecurityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: '' + podAnnotations: {} + podLabels: {} + env: [] + envFrom: [] + terminationGracePeriodSeconds: 30 + serviceAccount: + create: true + name: '' + annotations: {} + automountServiceAccountToken: false + service: + type: null + nodePort: null + annotations: {} + ingress: + enabled: false + className: '' + annotations: {} + hosts: + - host: rag.local + paths: + - path: / + pathType: Prefix + tls: [] + persistence: + enabled: false + existingClaim: '' + size: 10Gi + storageClassName: '' + accessModes: + - ReadWriteOnce + mountPath: /home/work/hugegraph-llm/src/hugegraph_llm/resources + extraVolumes: [] + extraVolumeMounts: [] + probes: + startup: + path: / + failureThreshold: 60 + periodSeconds: 5 + timeoutSeconds: 3 + readiness: + path: / + failureThreshold: 3 + periodSeconds: 10 + timeoutSeconds: 3 + liveness: + path: / + failureThreshold: 3 + periodSeconds: 20 + timeoutSeconds: 3 + networkPolicy: + extraIngress: [] + extraEgress: [] diff --git a/helm/hugegraph/values-single.yaml b/helm/hugegraph/values-single.yaml index 0b8f78b81f..2c9a4e1fa9 100644 --- a/helm/hugegraph/values-single.yaml +++ b/helm/hugegraph/values-single.yaml @@ -45,6 +45,11 @@ server: token: autoGenerate: true +# Optional RAG application follows this preset only when explicitly enabled. +rag: + enabled: false + replicas: 1 + # Optional computation follows this preset only when explicitly enabled. vermeer: enabled: false diff --git a/helm/test_helm_rag.py b/helm/test_helm_rag.py new file mode 100644 index 0000000000..07417c9563 --- /dev/null +++ b/helm/test_helm_rag.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import yaml + + +CHART = Path(__file__).resolve().parent / "hugegraph" +HELM_TEMPLATE_TIMEOUT_SECONDS = 120 + + +def component_resources(documents, component="rag", kind=None): + return [ + document + for document in documents + if (kind is None or document["kind"] == kind) + and document["metadata"].get("labels", {}).get("app.kubernetes.io/component") == component + ] + + +def pod(workload): + return workload["spec"]["template"] + + +def env_by_name(container): + return {entry["name"]: entry for entry in container.get("env", [])} + + +class RagSourceContractTest(unittest.TestCase): + def test_defaults_are_opt_in_and_match_the_image_contract(self): + values = yaml.safe_load((CHART / "values-rag.yaml").read_text(encoding="utf-8")) + rag = values["rag"] + self.assertTrue(rag["enabled"]) + self.assertEqual("hugegraph/rag", rag["image"]["repository"]) + self.assertEqual(8001, rag["port"]) + self.assertEqual(8001, rag["service"].get("port", rag["port"])) + self.assertEqual( + "/home/work/hugegraph-llm/src/hugegraph_llm/resources", + rag["persistence"]["mountPath"], + ) + + def test_single_and_cluster_presets_do_not_enable_rag_implicitly(self): + single = yaml.safe_load((CHART / "values-single.yaml").read_text(encoding="utf-8")) + cluster = yaml.safe_load((CHART / "values-cluster.yaml").read_text(encoding="utf-8")) + self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) + self.assertEqual({"enabled": False, "replicas": 2}, cluster["rag"]) + + def test_templates_use_named_port_and_release_server_service(self): + deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") + service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") + helpers = (CHART / "templates" / "_rag.tpl").read_text(encoding="utf-8") + self.assertIn('containerPort: {{ include "hugegraph.rag.port" . }}', deployment) + self.assertIn("targetPort: http", service) + self.assertIn('include "hugegraph.server.clientUrl"', helpers) + self.assertIn('trimPrefix "http://"', helpers) + + +class RagChartTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if shutil.which("helm") is None: + raise unittest.SkipTest("Helm is required for rendered chart regression tests") + + def render(self, rag=None, preset=None, mode=None, **values): + if rag is not None: + values["rag"] = {"enabled": True, **rag} + if mode is not None: + values["mode"] = mode + with tempfile.TemporaryDirectory(prefix="rag-values-") as directory: + configuration = Path(directory) / "values.yaml" + configuration.write_text(yaml.safe_dump(values), encoding="utf-8") + command = ["helm", "template", "fixture", str(CHART), "--namespace", "graphs"] + if preset is not None: + command.extend(["-f", str(CHART / preset)]) + if rag is not None: + command.extend(["-f", str(CHART / "values-rag.yaml")]) + command.extend(["-f", str(configuration)]) + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=HELM_TEMPLATE_TIMEOUT_SECONDS, + check=False, + ) + + def documents(self, rag=None, preset=None, mode=None, **values): + result = self.render(rag, preset=preset, mode=mode, **values) + self.assertEqual(0, result.returncode, result.stderr) + return [document for document in yaml.safe_load_all(result.stdout) if document] + + def deployment(self, documents): + deployment, = component_resources(documents, kind="Deployment") + return deployment + + def test_disabled_adds_no_resources(self): + documents = self.documents() + self.assertEqual([], component_resources(documents)) + self.assertFalse(any("-rag" in document["metadata"]["name"] for document in documents)) + + def test_default_enable_uses_one_consistent_port_and_internal_server(self): + documents = self.documents({}) + deployment = self.deployment(documents) + container, = pod(deployment)["spec"]["containers"] + service, = component_resources(documents, kind="Service") + self.assertEqual(8001, container["ports"][0]["containerPort"]) + self.assertEqual("http", service["spec"]["ports"][0]["targetPort"]) + self.assertEqual(8001, service["spec"]["ports"][0]["port"]) + environment = env_by_name(container) + self.assertEqual( + "fixture-hugegraph-server.graphs.svc:8080", + environment["GRAPH_URL"]["value"], + ) + self.assertEqual("hugegraph", environment["GRAPH_NAME"]["value"]) + self.assertEqual("DEFAULT", environment["GRAPH_SPACE"]["value"]) + self.assertEqual("admin", environment["GRAPH_USER"]["value"]) + self.assertEqual("fixture-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + self.assertEqual("password", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) + self.assertEqual("/tmp/hugegraph-llm.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual("hugegraph/rag:latest", container["image"]) + + def test_single_and_cluster_presets_share_the_same_graph_contract(self): + cases = (("values-single.yaml", 1), ("values-cluster.yaml", 2)) + for preset, expected_replicas in cases: + with self.subTest(preset=preset): + documents = self.documents({}, preset=preset) + deployment = self.deployment(documents) + container, = pod(deployment)["spec"]["containers"] + self.assertEqual(expected_replicas, deployment["spec"]["replicas"]) + environment = env_by_name(container) + self.assertEqual( + "fixture-hugegraph-server.graphs.svc:8080", + environment["GRAPH_URL"]["value"], + ) + + def test_mode_controls_only_the_default_service_exposure(self): + expected = {"easy": "NodePort", "strict": "ClusterIP"} + for mode, service_type in expected.items(): + with self.subTest(mode=mode): + documents = self.documents({}, mode=mode) + service, = component_resources(documents, kind="Service") + self.assertEqual(service_type, service["spec"]["type"]) + documents = self.documents({"service": {"type": "ClusterIP"}}, mode="easy") + service, = component_resources(documents, kind="Service") + self.assertEqual("ClusterIP", service["spec"]["type"]) + + def test_user_environment_replaces_automatic_entries(self): + documents = self.documents({ + "env": [ + {"name": "GRAPH_URL", "value": "http://external:8080"}, + {"name": "GRAPH_PWD", "valueFrom": {"secretKeyRef": {"name": "external", "key": "pwd"}}}, + ] + }) + container, = pod(self.deployment(documents))["spec"]["containers"] + entries = [entry for entry in container["env"] if entry["name"] in ("GRAPH_URL", "GRAPH_PWD")] + self.assertEqual(2, len(entries)) + environment = env_by_name(container) + self.assertEqual("http://external:8080", environment["GRAPH_URL"]["value"]) + self.assertEqual("external", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + + def test_external_server_uses_only_explicit_password_secret(self): + documents = self.documents({"hugegraph": { + "autoConfigure": True, + "url": "http://remote.graphs.svc:8080", + "passwordSecret": {"name": "remote-admin", "key": "credential"}, + }}) + container, = pod(self.deployment(documents))["spec"]["containers"] + environment = env_by_name(container) + self.assertEqual("http://remote.graphs.svc:8080", environment["GRAPH_URL"]["value"]) + self.assertEqual("remote-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + self.assertEqual("credential", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) + + documents = self.documents({"hugegraph": { + "autoConfigure": True, + "url": "http://remote.graphs.svc:8080", + }}) + container, = pod(self.deployment(documents))["spec"]["containers"] + self.assertNotIn("GRAPH_PWD", env_by_name(container)) + + def test_persistence_seeds_the_resource_directory(self): + documents = self.documents({"persistence": {"enabled": True}}) + deployment = self.deployment(documents) + pvc, = component_resources(documents, kind="PersistentVolumeClaim") + self.assertEqual("fixture-hugegraph-rag-data", pvc["metadata"]["name"]) + self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) + init, = pod(deployment)["spec"]["initContainers"] + self.assertIn("cp -a", init["args"][0]) + container, = pod(deployment)["spec"]["containers"] + mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] + self.assertEqual( + "/home/work/hugegraph-llm/src/hugegraph_llm/resources", + mount["mountPath"], + ) + + def test_multiple_generated_persistent_replicas_require_rwx(self): + result = self.render({"replicas": 2, "persistence": {"enabled": True}}) + self.assertNotEqual(0, result.returncode) + self.assertIn("ReadWriteMany", result.stderr) + documents = self.documents({ + "replicas": 2, + "persistence": {"enabled": True, "accessModes": ["ReadWriteMany"]}, + }) + self.assertEqual(2, self.deployment(documents)["spec"]["replicas"]) + + def test_strict_network_policy_allows_internal_server_only(self): + documents = self.documents({}, mode="strict") + policies = component_resources(documents, kind="NetworkPolicy") + self.assertEqual(2, len(policies)) + rag_policy = next(policy for policy in policies if policy["spec"]["podSelector"]["matchLabels"]["app.kubernetes.io/component"] == "rag") + server_allow = next(policy for policy in policies if policy is not rag_policy) + self.assertEqual("server", server_allow["spec"]["podSelector"]["matchLabels"]["app.kubernetes.io/component"]) + self.assertTrue(any(rule.get("to") for rule in rag_policy["spec"]["egress"])) + + documents = self.documents({"hugegraph": { + "autoConfigure": True, + "url": "http://external:8080", + }}, mode="strict") + policy, = component_resources(documents, kind="NetworkPolicy") + self.assertFalse(any( + peer.get("podSelector", {}).get("matchLabels", {}).get("app.kubernetes.io/component") == "server" + for rule in policy["spec"]["egress"] + for peer in rule.get("to", []) + )) + + def test_global_registry_and_digest_render(self): + digest = "sha256:" + "a" * 64 + documents = self.documents( + {"image": {"repository": "hugegraph/rag", "tag": "ignored", "digest": digest}}, + **{"global": {"imageRegistry": "registry.example.com"}}, + ) + deployment = self.deployment(documents) + expected = f"registry.example.com/hugegraph/rag@{digest}" + self.assertEqual(expected, pod(deployment)["spec"]["containers"][0]["image"]) + + def test_invalid_service_contracts_fail_before_render(self): + invalid = ( + {"service": {"type": "ExternalName"}}, + {"service": {"type": "ClusterIP", "nodePort": 31001}}, + {"port": 0}, + {"replicas": 0}, + ) + for values in invalid: + with self.subTest(values=values): + self.assertNotEqual(0, self.render(values).returncode) + + +if __name__ == "__main__": + unittest.main() From 4b10f1f72311b0bc41823149189dadb717c9f61d Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 3 Nov 2026 23:09:07 +0800 Subject: [PATCH 02/38] test(helm): cover optional RAG rendering --- helm/hugegraph/tests/rag_component_test.yaml | 155 +++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 helm/hugegraph/tests/rag_component_test.yaml diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml new file mode 100644 index 0000000000..1668a9ebbe --- /dev/null +++ b/helm/hugegraph/tests/rag_component_test.yaml @@ -0,0 +1,155 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +suite: Optional RAG component +tests: + - it: renders no RAG deployment while the component is disabled + template: rag-deployment.yaml + asserts: + - hasDocuments: + count: 0 + + - it: connects an enabled RAG pod to the same-release Server service + template: rag-deployment.yaml + set: + rag.enabled: true + asserts: + - isKind: + of: Deployment + - equal: + path: spec.replicas + value: 1 + - equal: + path: spec.template.spec.containers[0].image + value: hugegraph/rag:latest + - equal: + path: spec.template.spec.containers[0].ports[0].containerPort + value: 8001 + - contains: + path: spec.template.spec.containers[0].env + content: + name: GRAPH_URL + value: RELEASE-NAME-hugegraph-server.NAMESPACE.svc:8080 + - contains: + path: spec.template.spec.containers[0].env + content: + name: GRAPH_PWD + valueFrom: + secretKeyRef: + name: RELEASE-NAME-admin + key: password + - contains: + path: spec.template.spec.containers[0].env + content: + name: HUGEGRAPH_LLM_ENV_PATH + value: /tmp/hugegraph-llm.env + + - it: lets an explicit graph endpoint and Secret replace release wiring + template: rag-deployment.yaml + set: + rag.enabled: true + rag.hugegraph.url: http://remote.graphs.svc:8080 + rag.hugegraph.passwordSecret.name: remote-admin + rag.hugegraph.passwordSecret.key: credential + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: GRAPH_URL + value: http://remote.graphs.svc:8080 + - contains: + path: spec.template.spec.containers[0].env + content: + name: GRAPH_PWD + valueFrom: + secretKeyRef: + name: remote-admin + key: credential + + - it: uses the topology preset replica count without changing the template + template: rag-deployment.yaml + set: + rag.enabled: true + rag.replicas: 2 + asserts: + - equal: + path: spec.replicas + value: 2 + - equal: + path: spec.strategy.type + value: RollingUpdate + + - it: seeds image resources before mounting persistent storage + template: rag-deployment.yaml + set: + rag.enabled: true + rag.persistence.enabled: true + asserts: + - equal: + path: spec.strategy.type + value: Recreate + - equal: + path: spec.template.spec.initContainers[0].name + value: seed-resources + - matchRegex: + path: spec.template.spec.initContainers[0].args[0] + pattern: "cp -a -n" + - equal: + path: spec.template.spec.containers[0].volumeMounts[0].mountPath + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources + + - it: defaults the RAG service to NodePort in easy mode + template: rag-service.yaml + set: + rag.enabled: true + asserts: + - equal: + path: spec.type + value: NodePort + - equal: + path: spec.ports[0].port + value: 8001 + - equal: + path: spec.ports[0].targetPort + value: http + - notExists: + path: spec.ports[0].nodePort + + - it: defaults the RAG service to ClusterIP in strict mode + template: rag-service.yaml + set: + mode: strict + rag.enabled: true + asserts: + - equal: + path: spec.type + value: ClusterIP + + - it: creates a retained claim only when persistence is enabled + template: rag-pvc.yaml + set: + rag.enabled: true + rag.persistence.enabled: true + asserts: + - isKind: + of: PersistentVolumeClaim + - equal: + path: metadata.annotations["helm.sh/resource-policy"] + value: keep + - equal: + path: spec.resources.requests.storage + value: 10Gi From c00c00f3725a833014f20c10d3101e555dee5467 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 01:24:46 +0800 Subject: [PATCH 03/38] fix(helm): align RAG runtime and security defaults --- helm/hugegraph/Chart.yaml | 2 +- .../docs/images/rag-helm-architecture.svg | 16 +++++ helm/hugegraph/docs/rag.md | 60 ++++++++++++++----- helm/hugegraph/templates/_rag.tpl | 15 +---- helm/hugegraph/templates/rag-deployment.yaml | 9 +++ helm/hugegraph/tests/rag_component_test.yaml | 46 +++++++++++++- helm/hugegraph/values-rag.yaml | 4 +- helm/test_helm_package.py | 12 +++- helm/test_helm_rag.py | 51 ++++++++++++---- 9 files changed, 169 insertions(+), 46 deletions(-) diff --git a/helm/hugegraph/Chart.yaml b/helm/hugegraph/Chart.yaml index 29791c6308..2b4460b29f 100644 --- a/helm/hugegraph/Chart.yaml +++ b/helm/hugegraph/Chart.yaml @@ -19,7 +19,7 @@ apiVersion: v2 name: hugegraph-helm description: Helm chart for Apache HugeGraph HStore with optional RAG, SeaTunnel and Vermeer components type: application -version: 0.5.0 +version: 0.4.0 # TODO: set appVersion to "1.8.0" when the release images are published. appVersion: "latest" kubeVersion: ">=1.23.0-0" diff --git a/helm/hugegraph/docs/images/rag-helm-architecture.svg b/helm/hugegraph/docs/images/rag-helm-architecture.svg index 714fa5e107..1f91d4d538 100644 --- a/helm/hugegraph/docs/images/rag-helm-architecture.svg +++ b/helm/hugegraph/docs/images/rag-helm-architecture.svg @@ -1,3 +1,19 @@ + HugeGraph Helm optional RAG integration One chart manages HugeGraph core services and an optional RAG application for single-node and cluster presets. diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 29d9a35f22..c7653d76aa 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -13,8 +13,10 @@ remain independent. The same RAG template works with both presets because it connects through the release-scoped Server Service rather than enumerating Server Pods. The optional -`values-rag.yaml` overlay is the complete, reviewable switch; `rag.enabled=false` -removes every RAG object again. +`values-rag.yaml` overlay is the complete, reviewable profile; `rag.enabled=false` +removes every RAG object again. A bare `--set rag.enabled=true` also renders with +safe template defaults, while the overlay adds the recommended non-root and +resource settings. ```bash # Single PD + Store + Server, plus one RAG Pod @@ -39,6 +41,10 @@ chart injects: enabled; and - a writable `HUGEGRAPH_LLM_ENV_PATH` under `/tmp`. +The chart starts the application's native module explicitly and passes +`rag.port` to its `--port` argument. The container port, Service and probes use +the same resolved value. + Any entry in `rag.env` with the same name replaces its automatic counterpart. Set `rag.hugegraph.autoConfigure=false` to manage the complete application environment yourself. @@ -88,20 +94,46 @@ rag: key: password ``` -## Exposure and strict mode +## Exposure and authentication -In `mode=easy`, the RAG Service inherits `NodePort`. In `mode=strict`, it -inherits `ClusterIP` and a NetworkPolicy isolates the Pod. Strict mode permits -DNS and the same-release Server automatically, but does not silently open -access to public model APIs or an Ingress controller. Add the required peers -to `rag.networkPolicy.extraIngress` and provider destinations to -`rag.networkPolicy.extraEgress` using native Kubernetes NetworkPolicy rules. +The RAG Service defaults to `ClusterIP` in both `easy` and `strict` modes. This +is intentionally stricter than the browser-oriented Server and Hubble defaults: +the current RAG application starts with login disabled and warns that binding to +a non-loopback address does not provide unified authentication for every HTTP +API route. + +An explicit `rag.service.type` always wins. Ingress remains disabled until +`rag.ingress.enabled=true`. Treat NodePort, LoadBalancer and Ingress as reviewed +external-exposure choices. Put the application behind an authenticated reverse +proxy or another trusted network boundary and apply model-provider spend limits. +The application's native login can still be enabled for its own UI and guarded +write paths, but it is not a substitute for an authenticated ingress protecting +all routes: + +```bash +kubectl -n hugegraph create secret generic rag-login \ + --from-literal=USER_TOKEN='replace-user-token' \ + --from-literal=ADMIN_TOKEN='replace-admin-token' +``` + +```yaml +rag: + enabled: true + env: + - name: ENABLE_LOGIN + value: "True" + envFrom: + - secretRef: + name: rag-login + service: + type: NodePort +``` -An explicit `rag.service.type` always wins over the mode default. Ingress is -independent and remains disabled until `rag.ingress.enabled=true`. Before exposing -the demo UI outside a trusted network, review the application login settings and -model-provider spend controls; the chart does not invent a second RAG credential -system. +In `mode=strict`, a NetworkPolicy isolates the Pod. Strict mode permits DNS and +the same-release Server automatically, but does not silently open access to +public model APIs or an Ingress controller. Add the required peers to +`rag.networkPolicy.extraIngress` and provider destinations to +`rag.networkPolicy.extraEgress` using native Kubernetes NetworkPolicy rules. ## Persistence and replicas diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index 83df898ec5..bc8b708e49 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -23,7 +23,6 @@ {{- printf "%s-data" (include "hugegraph.rag.name" . | trunc 58 | trimSuffix "-") -}} {{- end }} - {{- define "hugegraph.rag.replicas" -}} {{- $rag := get .Values "rag" | default dict -}} {{- if hasKey $rag "replicas" -}}{{- get $rag "replicas" -}}{{- else -}}1{{- end -}} @@ -48,10 +47,8 @@ {{- $service := get $rag "service" | default dict -}} {{- if and (hasKey $service "type") (ne (get $service "type" | toString) "") (ne (get $service "type" | toString) "") -}} {{- get $service "type" -}} -{{- else if eq (.Values.mode | default "easy") "strict" -}} -ClusterIP {{- else -}} -NodePort +ClusterIP {{- end -}} {{- end }} @@ -115,10 +112,6 @@ password {{- if or (lt $port 1) (gt $port 65535) -}} {{- fail "rag.port must be between 1 and 65535" -}} {{- end -}} -{{- $image := get $rag "image" | default dict -}} -{{- if not (trim (get $image "repository" | default "")) -}} -{{- fail "rag.image.repository must not be empty" -}} -{{- end -}} {{- $service := get $rag "service" | default dict -}} {{- $serviceType := include "hugegraph.rag.serviceType" . -}} {{- if not (has $serviceType (list "ClusterIP" "NodePort" "LoadBalancer")) -}} @@ -133,12 +126,6 @@ password {{- end -}} {{- $persistence := get $rag "persistence" | default dict -}} {{- if get $persistence "enabled" | default false -}} -{{- if not (trim (get $persistence "mountPath" | default "")) -}} -{{- fail "rag.persistence.mountPath must not be empty when persistence is enabled" -}} -{{- end -}} -{{- if and (not (get $persistence "existingClaim" | default "")) (not (get $persistence "size" | default "")) -}} -{{- fail "rag.persistence.size must not be empty when the chart creates the claim" -}} -{{- end -}} {{- $accessModes := get $persistence "accessModes" | default (list "ReadWriteOnce") -}} {{- if and (gt $replicas 1) (not (get $persistence "existingClaim" | default "")) (not (has "ReadWriteMany" $accessModes)) -}} {{- fail "rag.persistence.accessModes must include ReadWriteMany when the chart creates one claim for multiple RAG replicas; otherwise use an existing shared claim or keep rag.replicas=1" -}} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 884a6d3bee..017ec32339 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -119,6 +119,15 @@ spec: - name: rag image: {{ include "hugegraph.rag.image" . | quote }} imagePullPolicy: {{ get (get $rag "image" | default dict) "pullPolicy" | default "Always" }} + command: + - python + - -m + - hugegraph_llm.demo.rag_demo.app + args: + - --host + - 0.0.0.0 + - --port + - {{ include "hugegraph.rag.port" . | quote }} {{- with get $rag "securityContext" }} securityContext: {{- toYaml . | nindent 12 }} diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml index 1668a9ebbe..0d1ba0cce5 100644 --- a/helm/hugegraph/tests/rag_component_test.yaml +++ b/helm/hugegraph/tests/rag_component_test.yaml @@ -36,6 +36,19 @@ tests: - equal: path: spec.template.spec.containers[0].image value: hugegraph/rag:latest + - equal: + path: spec.template.spec.containers[0].command + value: + - python + - -m + - hugegraph_llm.demo.rag_demo.app + - equal: + path: spec.template.spec.containers[0].args + value: + - --host + - 0.0.0.0 + - --port + - "8001" - equal: path: spec.template.spec.containers[0].ports[0].containerPort value: 8001 @@ -58,6 +71,19 @@ tests: name: HUGEGRAPH_LLM_ENV_PATH value: /tmp/hugegraph-llm.env + - it: passes a custom port to the process and Pod contract + template: rag-deployment.yaml + set: + rag.enabled: true + rag.port: 9001 + asserts: + - equal: + path: spec.template.spec.containers[0].args[3] + value: "9001" + - equal: + path: spec.template.spec.containers[0].ports[0].containerPort + value: 9001 + - it: lets an explicit graph endpoint and Secret replace release wiring template: rag-deployment.yaml set: @@ -112,14 +138,14 @@ tests: path: spec.template.spec.containers[0].volumeMounts[0].mountPath value: /home/work/hugegraph-llm/src/hugegraph_llm/resources - - it: defaults the RAG service to NodePort in easy mode + - it: keeps the RAG service internal by default in easy mode template: rag-service.yaml set: rag.enabled: true asserts: - equal: path: spec.type - value: NodePort + value: ClusterIP - equal: path: spec.ports[0].port value: 8001 @@ -129,7 +155,7 @@ tests: - notExists: path: spec.ports[0].nodePort - - it: defaults the RAG service to ClusterIP in strict mode + - it: keeps the RAG service internal by default in strict mode template: rag-service.yaml set: mode: strict @@ -139,6 +165,20 @@ tests: path: spec.type value: ClusterIP + - it: permits an explicit NodePort exposure + template: rag-service.yaml + set: + rag.enabled: true + rag.service.type: NodePort + rag.service.nodePort: 31001 + asserts: + - equal: + path: spec.type + value: NodePort + - equal: + path: spec.ports[0].nodePort + value: 31001 + - it: creates a retained claim only when persistence is enabled template: rag-pvc.yaml set: diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml index 65e1637134..a758c21e56 100644 --- a/helm/hugegraph/values-rag.yaml +++ b/helm/hugegraph/values-rag.yaml @@ -70,8 +70,10 @@ rag: name: '' annotations: {} automountServiceAccountToken: false + # RAG stays internal by default because the application login is optional. + # Select NodePort/LoadBalancer or enable Ingress only with reviewed auth. service: - type: null + type: ClusterIP nodePort: null annotations: {} ingress: diff --git a/helm/test_helm_package.py b/helm/test_helm_package.py index a9c480a947..202ca04f13 100644 --- a/helm/test_helm_package.py +++ b/helm/test_helm_package.py @@ -78,7 +78,8 @@ def test_no_contributor_or_binary_documentation_files(self): def test_required_configuration_is_preserved(self): required = ("Chart.yaml", "README.md", "values.yaml", "values.schema.json", "values-single.yaml", "values-cluster.yaml", "values-strict.yaml", - "docs/operations.md", "docs/distribution.md", "docs/vermeer.md", "examples/README.md") + "values-rag.yaml", "docs/operations.md", "docs/distribution.md", + "docs/vermeer.md", "docs/rag.md", "examples/README.md") for name in required: with self.subTest(file=name): self.assertIn(name, self.files.keys()) @@ -144,5 +145,14 @@ def test_packaged_optional_vermeer_images_include_native_token_helper(self): self.assertIn("/go/bin/vermeer-token", rendered) +def load_tests(loader, tests, _pattern): + """Run the optional RAG contract in the shared packaged-chart CI entrypoint.""" + from test_helm_rag import RagChartTest, RagSourceContractTest + + tests.addTests(loader.loadTestsFromTestCase(RagSourceContractTest)) + tests.addTests(loader.loadTestsFromTestCase(RagChartTest)) + return tests + + if __name__ == "__main__": unittest.main() diff --git a/helm/test_helm_rag.py b/helm/test_helm_rag.py index 07417c9563..872520381f 100644 --- a/helm/test_helm_rag.py +++ b/helm/test_helm_rag.py @@ -53,7 +53,7 @@ def test_defaults_are_opt_in_and_match_the_image_contract(self): self.assertTrue(rag["enabled"]) self.assertEqual("hugegraph/rag", rag["image"]["repository"]) self.assertEqual(8001, rag["port"]) - self.assertEqual(8001, rag["service"].get("port", rag["port"])) + self.assertEqual("ClusterIP", rag["service"]["type"]) self.assertEqual( "/home/work/hugegraph-llm/src/hugegraph_llm/resources", rag["persistence"]["mountPath"], @@ -65,11 +65,13 @@ def test_single_and_cluster_presets_do_not_enable_rag_implicitly(self): self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) self.assertEqual({"enabled": False, "replicas": 2}, cluster["rag"]) - def test_templates_use_named_port_and_release_server_service(self): + def test_templates_use_one_process_port_and_release_server_service(self): deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") helpers = (CHART / "templates" / "_rag.tpl").read_text(encoding="utf-8") + self.assertIn("hugegraph_llm.demo.rag_demo.app", deployment) self.assertIn('containerPort: {{ include "hugegraph.rag.port" . }}', deployment) + self.assertIn('- {{ include "hugegraph.rag.port" . | quote }}', deployment) self.assertIn("targetPort: http", service) self.assertIn('include "hugegraph.server.clientUrl"', helpers) self.assertIn('trimPrefix "http://"', helpers) @@ -81,7 +83,7 @@ def setUpClass(cls): if shutil.which("helm") is None: raise unittest.SkipTest("Helm is required for rendered chart regression tests") - def render(self, rag=None, preset=None, mode=None, **values): + def render(self, rag=None, preset=None, mode=None, use_profile=True, **values): if rag is not None: values["rag"] = {"enabled": True, **rag} if mode is not None: @@ -92,7 +94,7 @@ def render(self, rag=None, preset=None, mode=None, **values): command = ["helm", "template", "fixture", str(CHART), "--namespace", "graphs"] if preset is not None: command.extend(["-f", str(CHART / preset)]) - if rag is not None: + if rag is not None and use_profile: command.extend(["-f", str(CHART / "values-rag.yaml")]) command.extend(["-f", str(configuration)]) return subprocess.run( @@ -103,8 +105,8 @@ def render(self, rag=None, preset=None, mode=None, **values): check=False, ) - def documents(self, rag=None, preset=None, mode=None, **values): - result = self.render(rag, preset=preset, mode=mode, **values) + def documents(self, rag=None, preset=None, mode=None, use_profile=True, **values): + result = self.render(rag, preset=preset, mode=mode, use_profile=use_profile, **values) self.assertEqual(0, result.returncode, result.stderr) return [document for document in yaml.safe_load_all(result.stdout) if document] @@ -117,14 +119,28 @@ def test_disabled_adds_no_resources(self): self.assertEqual([], component_resources(documents)) self.assertFalse(any("-rag" in document["metadata"]["name"] for document in documents)) + def test_bare_enable_uses_consistent_template_defaults(self): + documents = self.documents({}, use_profile=False) + deployment = self.deployment(documents) + container, = pod(deployment)["spec"]["containers"] + service, = component_resources(documents, kind="Service") + self.assertEqual("hugegraph/rag:latest", container["image"]) + self.assertEqual(8001, container["ports"][0]["containerPort"]) + self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) + self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) + self.assertEqual("ClusterIP", service["spec"]["type"]) + def test_default_enable_uses_one_consistent_port_and_internal_server(self): documents = self.documents({}) deployment = self.deployment(documents) container, = pod(deployment)["spec"]["containers"] service, = component_resources(documents, kind="Service") self.assertEqual(8001, container["ports"][0]["containerPort"]) + self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) + self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) self.assertEqual("http", service["spec"]["ports"][0]["targetPort"]) self.assertEqual(8001, service["spec"]["ports"][0]["port"]) + self.assertEqual("ClusterIP", service["spec"]["type"]) environment = env_by_name(container) self.assertEqual( "fixture-hugegraph-server.graphs.svc:8080", @@ -138,6 +154,17 @@ def test_default_enable_uses_one_consistent_port_and_internal_server(self): self.assertEqual("/tmp/hugegraph-llm.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual("hugegraph/rag:latest", container["image"]) + def test_custom_port_reaches_process_pod_service_and_probes(self): + documents = self.documents({"port": 9001}) + deployment = self.deployment(documents) + container, = pod(deployment)["spec"]["containers"] + service, = component_resources(documents, kind="Service") + self.assertEqual("9001", container["args"][3]) + self.assertEqual(9001, container["ports"][0]["containerPort"]) + self.assertEqual(9001, service["spec"]["ports"][0]["port"]) + for name in ("startupProbe", "readinessProbe", "livenessProbe"): + self.assertEqual("http", container[name]["httpGet"]["port"]) + def test_single_and_cluster_presets_share_the_same_graph_contract(self): cases = (("values-single.yaml", 1), ("values-cluster.yaml", 2)) for preset, expected_replicas in cases: @@ -152,16 +179,16 @@ def test_single_and_cluster_presets_share_the_same_graph_contract(self): environment["GRAPH_URL"]["value"], ) - def test_mode_controls_only_the_default_service_exposure(self): - expected = {"easy": "NodePort", "strict": "ClusterIP"} - for mode, service_type in expected.items(): + def test_service_stays_internal_unless_exposure_is_explicit(self): + for mode in ("easy", "strict"): with self.subTest(mode=mode): documents = self.documents({}, mode=mode) service, = component_resources(documents, kind="Service") - self.assertEqual(service_type, service["spec"]["type"]) - documents = self.documents({"service": {"type": "ClusterIP"}}, mode="easy") + self.assertEqual("ClusterIP", service["spec"]["type"]) + documents = self.documents({"service": {"type": "NodePort", "nodePort": 31001}}, mode="easy") service, = component_resources(documents, kind="Service") - self.assertEqual("ClusterIP", service["spec"]["type"]) + self.assertEqual("NodePort", service["spec"]["type"]) + self.assertEqual(31001, service["spec"]["ports"][0]["nodePort"]) def test_user_environment_replaces_automatic_entries(self): documents = self.documents({ From b3c15a1f6758aef11225ee9f12e2da5c31fab899 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 02:26:40 +0800 Subject: [PATCH 04/38] fix(helm): persist and harden RAG configuration --- helm/hugegraph/docs/rag.md | 248 +++++++++++++----- helm/hugegraph/templates/_rag.tpl | 138 +++++++++- helm/hugegraph/templates/rag-deployment.yaml | 57 ++-- .../templates/rag-networkpolicy.yaml | 6 +- helm/hugegraph/templates/rag-pvc.yaml | 3 +- helm/hugegraph/tests/rag_component_test.yaml | 85 ++++-- helm/hugegraph/values-cluster.yaml | 9 +- helm/hugegraph/values-rag.yaml | 48 ++-- helm/test_helm_package.py | 7 +- helm/test_helm_rag.py | 188 +++++++++---- 10 files changed, 578 insertions(+), 211 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index c7653d76aa..e30463b916 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -3,52 +3,91 @@ The main HugeGraph chart owns the Kubernetes topology for the whole system. `rag.enabled=true` adds the `hugegraph/rag` application from [hugegraph-ai](https://github.com/apache/hugegraph-ai) without changing the -PD, Store, Server or Hubble topology. The component remains disabled by -default, and the Docker image and Docker Compose workflows in hugegraph-ai -remain independent. +PD, Store, Server or Hubble topology. RAG remains disabled by default, and the +Docker image and Docker Compose workflows in hugegraph-ai remain independent. ![RAG Helm integration](images/rag-helm-architecture.svg) -## Enable it +## Minimal configuration -The same RAG template works with both presets because it connects through the -release-scoped Server Service rather than enumerating Server Pods. The optional -`values-rag.yaml` overlay is the complete, reviewable profile; `rag.enabled=false` -removes every RAG object again. A bare `--set rag.enabled=true` also renders with -safe template defaults, while the overlay adds the recommended non-root and -resource settings. +For a RAG Pod connected to the HugeGraph Server in the **same Helm release**, the +chart deliberately keeps the required values small: + +1. Enable RAG with `rag.enabled=true` or the supplied `values-rag.yaml` overlay. +2. Provide the selected model provider credentials and model settings, normally + through a Kubernetes Secret or the RAG interface. +3. Set `rag.persistence.storageClassName` or `existingClaim` only when the + cluster has no suitable default StorageClass. + +Do **not** repeat the Server Pod IP, REST port, graph name, graph space or Server +administrator Secret for the normal same-release deployment. The chart derives +them from the Server Service and existing Server values. RAG uses a single +persistent replica by default in both topology presets so the ordinary +`ReadWriteOnce` claim is sufficient. ```bash -# Single PD + Store + Server, plus one RAG Pod +# Single PD + Store + Server, plus one persistent RAG Pod helm upgrade --install hugegraph ./helm/hugegraph \ -n hugegraph --create-namespace \ -f helm/hugegraph/values-single.yaml \ -f helm/hugegraph/values-rag.yaml -# 3 + 3 + 3 HugeGraph cluster, plus the preset's RAG replica count +# 3 + 3 + 3 HugeGraph cluster, plus one persistent RAG Pod helm upgrade --install hugegraph ./helm/hugegraph \ -n hugegraph --create-namespace \ -f helm/hugegraph/values-cluster.yaml \ -f helm/hugegraph/values-rag.yaml ``` +A bare `--set rag.enabled=true` has the same safe non-root, internal-Service and +persistent-storage defaults. The overlay keeps the commonly reviewed knobs in +one file, but no second copy of the Server connection is required. + +## Automatic HugeGraph wiring + With `rag.hugegraph.autoConfigure=true` and an empty `rag.hugegraph.url`, the chart injects: -- `GRAPH_URL` as the same-release Server client Service in the application's native `host:port` form; -- `GRAPH_NAME`, `GRAPH_SPACE` and `GRAPH_USER` from chart values; +- `GRAPH_URL` as the same-release Server client Service in the application's + native `host:port` form; +- `GRAPH_NAME=hugegraph`, unless explicitly overridden; +- `GRAPH_SPACE` from `server.graphSpace`; +- `GRAPH_USER=admin`, unless explicitly overridden; and - `GRAPH_PWD` from the same Server administrator Secret when Server auth is - enabled; and -- a writable `HUGEGRAPH_LLM_ENV_PATH` under `/tmp`. + enabled. -The chart starts the application's native module explicitly and passes -`rag.port` to its `--port` argument. The container port, Service and probes use -the same resolved value. +The chart also passes `rag.port` to the native RAG process. The process, +container port, Service and probes therefore use one resolved port value. Any entry in `rag.env` with the same name replaces its automatic counterpart. -Set `rag.hugegraph.autoConfigure=false` to manage the complete application +When `rag.env` explicitly supplies `GRAPH_URL`, the chart does **not** attach the +same-release administrator password to that endpoint. Supply an explicit +`rag.hugegraph.passwordSecret` or `GRAPH_PWD` for the selected endpoint. Set +`rag.hugegraph.autoConfigure=false` to manage the complete application environment yourself. +The automatic user is convenient for a local or evaluation installation, but it +inherits administrator privileges. Production deployments should create a +dedicated least-privilege HugeGraph user and Secret, then override only the user +and password source while keeping the release-derived Server address: + +```yaml +rag: + enabled: true + hugegraph: + autoConfigure: true + username: rag-service + passwordSecret: + name: rag-hugegraph-credentials + key: password +``` + +When the chart-managed Server administrator Secret changes during a Helm +upgrade, the RAG pod template checksum changes and RAG rolls out with the new +credential. For an external Secret, update the Secret and run `helm upgrade` so +its resource version is re-evaluated; changing the Secret alone does not restart +an existing Pod whose environment was already populated. + ## Model provider credentials Keep provider credentials out of values files. Create a Secret and expose it @@ -58,27 +97,130 @@ with `envFrom`: kubectl -n hugegraph create secret generic rag-model \ --from-literal=OPENAI_API_KEY='replace-me' \ --from-literal=OPENAI_EMBEDDING_API_KEY='replace-me' +``` -cat > /tmp/rag-values.yaml <<'YAML' +```yaml rag: enabled: true envFrom: - secretRef: name: rag-model -YAML +``` -helm upgrade --install hugegraph ./helm/hugegraph \ - -n hugegraph --create-namespace -f /tmp/rag-values.yaml +The application supports other providers through the same native `env` and +`envFrom` mechanisms. The chart does not copy provider credentials into a +ConfigMap or generate a second credential system. The application can persist +selected model and graph settings in its `.env` file, so treat the RAG PVC as +sensitive data as well: restrict access, include it in the appropriate backup +policy, and use storage encryption where required. + +## Persistent and externally editable configuration + +Persistence defaults to enabled after RAG is enabled. One claim stores the +application's writable resource tree at: + +```text +/home/work/hugegraph-llm/src/hugegraph_llm/resources +``` + +The chart points the application's writable configuration to locations on that +same claim: + +```text +.env model and graph runtime settings +/demo/config_prompt.yaml editable prompts and graph schema examples +``` + +The effective environment variables are therefore: + +```text +HUGEGRAPH_LLM_ENV_PATH=/.env +HUGEGRAPH_LLM_PROMPT_CONFIG_PATH=/demo/config_prompt.yaml +``` + +A restart or image replacement reuses these files. The init container copies +only files that are missing from the claim (`cp -n`); it never overwrites a file +already changed through the UI or by an operator. Consequently, an image upgrade +also does not replace existing prompt/config files with newer packaged defaults. +Back up the claim and update selected files manually when adopting those new +defaults. + +The files can be inspected or replaced without rebuilding the image. The +following example locates the RAG Pod by labels instead of assuming a generated +name: + +```bash +NS=hugegraph +RELEASE=hugegraph +MOUNT=/home/work/hugegraph-llm/src/hugegraph_llm/resources +POD=$(kubectl -n "$NS" get pod \ + -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=rag" \ + -o jsonpath='{.items[0].metadata.name}') + +kubectl -n "$NS" exec "$POD" -- ls -la "$MOUNT" +kubectl -n "$NS" exec -it "$POD" -- sh +``` + +For GitOps or an externally managed configuration lifecycle, provision a PVC, +seed or mount it with the desired files, and point the chart at it: + +```yaml +rag: + enabled: true + persistence: + enabled: true + existingClaim: managed-rag-config +``` + +Overriding `HUGEGRAPH_LLM_ENV_PATH`, `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` or +`rag.persistence.mountPath` is supported, but an override outside the claim is +intentionally ephemeral unless another persistent volume is mounted there. + +## Persistence lifecycle and replicas + +A chart-generated RAG PVC has `helm.sh/resource-policy: keep`. Disabling RAG or +uninstalling the release removes the RAG workload but retains that PVC and its +data. Delete it manually only after the data is no longer required: + +```bash +kubectl -n hugegraph delete pvc hugegraph-rag-data ``` -The application supports other model providers through the same native -`env`/`envFrom` mechanism. The chart does not copy credentials into a -ConfigMap or a generated Secret. +The exact generated name follows the Helm release name; inspect it before +deleting with `kubectl -n hugegraph get pvc`. + +One generated `ReadWriteOnce` claim supports one RAG replica. To run multiple +replicas, use a StorageClass and claim that support `ReadWriteMany`, either with +an existing shared claim or by setting `rag.persistence.accessModes` accordingly. +A bound PVC cannot be converted from RWO to RWX in place by `helm upgrade`. +Create a new shared claim and migrate the files before setting +`rag.persistence.existingClaim`, or back up and recreate the generated claim. + +```yaml +rag: + replicas: 2 + persistence: + enabled: true + existingClaim: shared-rag-resources +``` + +Persistence can be disabled explicitly for disposable tests: + +```yaml +rag: + enabled: true + persistence: + enabled: false +``` + +In that mode the generated `.env` and changes inside the container filesystem +are lost when the Pod is replaced. ## Use another HugeGraph deployment -Set an explicit URL and credential Secret. An external URL disables automatic -reuse of this release's Server password. +Set an explicit URL and credential Secret for an external Server. A custom +Secret defaults to the key `password` when `key` is omitted; specifying the key +is clearer and recommended. ```yaml rag: @@ -88,9 +230,9 @@ rag: url: http://hugegraph-server.graphs.svc:8080 graphName: hugegraph graphSpace: DEFAULT - username: admin + username: rag-service passwordSecret: - name: external-hugegraph-admin + name: external-hugegraph-credentials key: password ``` @@ -98,9 +240,8 @@ rag: The RAG Service defaults to `ClusterIP` in both `easy` and `strict` modes. This is intentionally stricter than the browser-oriented Server and Hubble defaults: -the current RAG application starts with login disabled and warns that binding to -a non-loopback address does not provide unified authentication for every HTTP -API route. +the current RAG application login does not provide unified authentication for +every HTTP API route. An explicit `rag.service.type` always wins. Ingress remains disabled until `rag.ingress.enabled=true`. Treat NodePort, LoadBalancer and Ingress as reviewed @@ -108,26 +249,7 @@ external-exposure choices. Put the application behind an authenticated reverse proxy or another trusted network boundary and apply model-provider spend limits. The application's native login can still be enabled for its own UI and guarded write paths, but it is not a substitute for an authenticated ingress protecting -all routes: - -```bash -kubectl -n hugegraph create secret generic rag-login \ - --from-literal=USER_TOKEN='replace-user-token' \ - --from-literal=ADMIN_TOKEN='replace-admin-token' -``` - -```yaml -rag: - enabled: true - env: - - name: ENABLE_LOGIN - value: "True" - envFrom: - - secretRef: - name: rag-login - service: - type: NodePort -``` +all routes. In `mode=strict`, a NetworkPolicy isolates the Pod. Strict mode permits DNS and the same-release Server automatically, but does not silently open access to @@ -135,15 +257,9 @@ public model APIs or an Ingress controller. Add the required peers to `rag.networkPolicy.extraIngress` and provider destinations to `rag.networkPolicy.extraEgress` using native Kubernetes NetworkPolicy rules. -## Persistence and replicas - -Persistence is off by default, so the image's packaged prompt and resource -files are not hidden by an empty volume. When enabled, an init container seeds -the claim from the selected image before the main container mounts it at -`rag.persistence.mountPath`. A generated claim is retained on uninstall. - -One generated `ReadWriteOnce` claim is valid only for one RAG replica. For -multiple replicas, either keep persistence disabled, provide an existing -shared claim, or use a StorageClass that supports `ReadWriteMany` and include -that mode in `rag.persistence.accessModes`. Model and graph credentials remain -Kubernetes Secrets regardless of persistence. +The templates enforce non-root UID/GID/fsGroup `1000`, dropped Linux +capabilities, disabled privilege escalation and RuntimeDefault seccomp even for +a bare enable. The small default requests/limits in `values-rag.yaml` apply only +to the resource-seeding init container. The main RAG container deliberately +keeps `resources: {}` because model, embedding and index workloads require +installation-specific sizing. diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index bc8b708e49..2762ff2171 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -73,7 +73,7 @@ ClusterIP {{- $rag := get .Values "rag" | default dict -}} {{- $graph := get $rag "hugegraph" | default dict -}} {{- $password := get $graph "passwordSecret" | default dict -}} -{{- $explicit := get $password "name" | default "" -}} +{{- $explicit := trim (get $password "name" | default "") -}} {{- if $explicit -}} {{- $explicit -}} {{- else if and (not (trim (get $graph "url" | default ""))) (get (.Values.server.auth | default dict) "enabled" | default false) -}} @@ -85,9 +85,16 @@ ClusterIP {{- $rag := get .Values "rag" | default dict -}} {{- $graph := get $rag "hugegraph" | default dict -}} {{- $password := get $graph "passwordSecret" | default dict -}} -{{- $explicit := get $password "key" | default "" -}} -{{- if $explicit -}} -{{- $explicit -}} +{{- $explicitName := trim (get $password "name" | default "") -}} +{{- $explicitKey := trim (get $password "key" | default "") -}} +{{- if $explicitKey -}} +{{- $explicitKey -}} +{{- else if $explicitName -}} +{{- if eq $explicitName (include "hugegraph.server.authSecretName" .) -}} +{{- include "hugegraph.server.authSecretKey" . -}} +{{- else -}} +password +{{- end -}} {{- else if and (not (trim (get $graph "url" | default ""))) (get (.Values.server.auth | default dict) "enabled" | default false) -}} {{- include "hugegraph.server.authSecretKey" . -}} {{- else -}} @@ -95,20 +102,90 @@ password {{- end -}} {{- end }} +{{- define "hugegraph.rag.persistenceEnabled" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- if hasKey $persistence "enabled" -}}{{- get $persistence "enabled" -}}{{- else -}}true{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.mountPath" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- get $persistence "mountPath" | default "/home/work/hugegraph-llm/src/hugegraph_llm/resources" -}} +{{- end }} + +{{- define "hugegraph.rag.envPath" -}} +{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} +{{- printf "%s/.env" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} +{{- else -}} +/tmp/hugegraph-llm.env +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.promptConfigPath" -}} +{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} +{{- printf "%s/demo/config_prompt.yaml" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} +{{- else -}} +/home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml +{{- end -}} +{{- end }} + {{- define "hugegraph.rag.claimName" -}} {{- $rag := get .Values "rag" | default dict -}} {{- $persistence := get $rag "persistence" | default dict -}} {{- get $persistence "existingClaim" | default (include "hugegraph.rag.dataName" .) -}} {{- end }} +{{- define "hugegraph.rag.podSecurityContext" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 1000 + "runAsGroup" 1000 + "fsGroup" 1000 + "fsGroupChangePolicy" "OnRootMismatch" + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "podSecurityContext" | default dict)) -}} +{{- range $field := list "runAsUser" "runAsGroup" "fsGroup" -}} +{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} +{{- end -}} +{{- toYaml $context -}} +{{- end }} + +{{- define "hugegraph.rag.securityContext" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 1000 + "runAsGroup" 1000 + "allowPrivilegeEscalation" false + "capabilities" (dict "drop" (list "ALL")) + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "securityContext" | default dict)) -}} +{{- range $field := list "runAsUser" "runAsGroup" -}} +{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} +{{- end -}} +{{- toYaml $context -}} +{{- end }} + {{- define "hugegraph.rag.validate" -}} {{- $rag := get .Values "rag" | default dict -}} {{- if get $rag "enabled" | default false -}} -{{- $replicas := include "hugegraph.rag.replicas" . | int -}} +{{- $rawReplicas := "1" -}} +{{- if hasKey $rag "replicas" -}}{{- $rawReplicas = get $rag "replicas" | toString -}}{{- end -}} +{{- if not (regexMatch "^[0-9]+$" $rawReplicas) -}} +{{- fail "rag.replicas must be an integer between 1 and 100" -}} +{{- end -}} +{{- $replicas := int $rawReplicas -}} {{- if or (lt $replicas 1) (gt $replicas 100) -}} {{- fail "rag.replicas must be between 1 and 100" -}} {{- end -}} -{{- $port := include "hugegraph.rag.port" . | int -}} +{{- $rawPort := "8001" -}} +{{- if hasKey $rag "port" -}}{{- $rawPort = get $rag "port" | toString -}}{{- end -}} +{{- if not (regexMatch "^[0-9]+$" $rawPort) -}} +{{- fail "rag.port must be an integer between 1 and 65535" -}} +{{- end -}} +{{- $port := int $rawPort -}} {{- if or (lt $port 1) (gt $port 65535) -}} {{- fail "rag.port must be between 1 and 65535" -}} {{- end -}} @@ -117,20 +194,57 @@ password {{- if not (has $serviceType (list "ClusterIP" "NodePort" "LoadBalancer")) -}} {{- fail "rag.service.type must be ClusterIP, NodePort or LoadBalancer" -}} {{- end -}} -{{- $nodePort := int (get $service "nodePort" | default 0) -}} +{{- $rawNodePort := get $service "nodePort" | default 0 | toString -}} +{{- if and (ne $rawNodePort "0") (not (regexMatch "^[0-9]+$" $rawNodePort)) -}} +{{- fail "rag.service.nodePort must be an integer" -}} +{{- end -}} +{{- $nodePort := int $rawNodePort -}} {{- if and (ne $nodePort 0) (or (lt $nodePort 1) (gt $nodePort 65535)) -}} {{- fail "rag.service.nodePort must be 0/unset or between 1 and 65535" -}} {{- end -}} {{- if and (ne $nodePort 0) (eq $serviceType "ClusterIP") -}} {{- fail "rag.service.nodePort requires service.type NodePort or LoadBalancer" -}} {{- end -}} +{{- $graph := get $rag "hugegraph" | default dict -}} +{{- $password := get $graph "passwordSecret" | default dict -}} +{{- if and (trim (get $password "key" | default "")) (not (trim (get $password "name" | default ""))) -}} +{{- fail "rag.hugegraph.passwordSecret.key requires passwordSecret.name" -}} +{{- end -}} {{- $persistence := get $rag "persistence" | default dict -}} -{{- if get $persistence "enabled" | default false -}} +{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} +{{- $mountPath := include "hugegraph.rag.mountPath" . -}} +{{- if or (not $mountPath) (not (hasPrefix "/" $mountPath)) -}} +{{- fail "rag.persistence.mountPath must be an absolute path" -}} +{{- end -}} {{- $accessModes := get $persistence "accessModes" | default (list "ReadWriteOnce") -}} {{- if and (gt $replicas 1) (not (get $persistence "existingClaim" | default "")) (not (has "ReadWriteMany" $accessModes)) -}} {{- fail "rag.persistence.accessModes must include ReadWriteMany when the chart creates one claim for multiple RAG replicas; otherwise use an existing shared claim or keep rag.replicas=1" -}} {{- end -}} {{- end -}} +{{- $podContext := get $rag "podSecurityContext" | default dict -}} +{{- $containerContext := get $rag "securityContext" | default dict -}} +{{- range $scope, $context := dict "rag.podSecurityContext" $podContext "rag.securityContext" $containerContext -}} +{{- if and (hasKey $context "runAsNonRoot") (not (get $context "runAsNonRoot")) -}} +{{- fail (printf "%s.runAsNonRoot must stay true for the HugeGraph RAG image" $scope) -}} +{{- end -}} +{{- range $field := list "runAsUser" "runAsGroup" -}} +{{- if hasKey $context $field -}} +{{- $raw := get $context $field | toString -}} +{{- if not (regexMatch "^[1-9][0-9]*$" $raw) -}} +{{- fail (printf "%s.%s must be a positive integer" $scope $field) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if hasKey $podContext "fsGroup" -}} +{{- $rawFsGroup := get $podContext "fsGroup" | toString -}} +{{- if not (regexMatch "^[1-9][0-9]*$" $rawFsGroup) -}} +{{- fail "rag.podSecurityContext.fsGroup must be a positive integer" -}} +{{- end -}} +{{- end -}} +{{- if and (hasKey $containerContext "allowPrivilegeEscalation") (get $containerContext "allowPrivilegeEscalation") -}} +{{- fail "rag.securityContext.allowPrivilegeEscalation must stay false" -}} +{{- end -}} {{- $podLabels := get $rag "podLabels" | default dict -}} {{- range $reserved := list "app.kubernetes.io/name" "app.kubernetes.io/instance" "app.kubernetes.io/component" -}} {{- if hasKey $podLabels $reserved -}} @@ -142,10 +256,16 @@ password {{- fail (printf "rag.podAnnotations must not set %s because the chart owns checksum annotations" $key) -}} {{- end -}} {{- end -}} +{{- $seenEnv := dict -}} {{- range $index, $entry := get $rag "env" | default list -}} -{{- if not (get $entry "name" | default "") -}} +{{- $name := get $entry "name" | default "" | toString -}} +{{- if not $name -}} {{- fail (printf "rag.env[%d].name must not be empty" $index) -}} {{- end -}} +{{- if hasKey $seenEnv $name -}} +{{- fail (printf "rag.env contains duplicate name %s" $name) -}} +{{- end -}} +{{- $_ := set $seenEnv $name true -}} {{- end -}} {{- end -}} {{- end }} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 017ec32339..556de2ea67 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -22,12 +22,15 @@ {{- $graph := get $rag "hugegraph" | default dict }} {{- $autoConfigure := true }} {{- if hasKey $graph "autoConfigure" }}{{- $autoConfigure = get $graph "autoConfigure" }}{{- end }} -{{- $persistence := get $rag "persistence" | default dict }} -{{- $persistenceEnabled := get $persistence "enabled" | default false }} +{{- $persistenceEnabled := eq (include "hugegraph.rag.persistenceEnabled" .) "true" }} {{- $envNames := dict }} {{- range get $rag "env" | default list }} {{- $_ := set $envNames (.name | toString) true }} {{- end }} +{{- $password := get $graph "passwordSecret" | default dict }} +{{- $explicitPasswordSecret := trim (get $password "name" | default "") }} +{{- $passwordSecret := include "hugegraph.rag.passwordSecretName" . }} +{{- $injectPassword := and $autoConfigure $passwordSecret (not (hasKey $envNames "GRAPH_PWD")) (or $explicitPasswordSecret (not (hasKey $envNames "GRAPH_URL"))) }} apiVersion: apps/v1 kind: Deployment metadata: @@ -36,7 +39,7 @@ metadata: {{- include "hugegraph.labels" . | nindent 4 }} app.kubernetes.io/component: rag spec: - replicas: {{ include "hugegraph.rag.replicas" . }} + replicas: {{ include "hugegraph.rag.replicas" . | int }} strategy: type: {{ ternary "Recreate" "RollingUpdate" (and $persistenceEnabled (eq (include "hugegraph.rag.replicas" . | int) 1)) }} selector: @@ -49,9 +52,17 @@ spec: {{- include "hugegraph.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: rag {{- with get $rag "podLabels" }}{{ toYaml . | nindent 8 }}{{- end }} - {{- with get $rag "podAnnotations" }} + {{- if or $injectPassword (get $rag "podAnnotations") }} annotations: - {{- toYaml . | nindent 8 }} + {{- if $injectPassword }} + {{- if $explicitPasswordSecret }} + {{- $live := lookup "v1" "Secret" .Release.Namespace $passwordSecret | default dict }} + checksum/hugegraph-auth: {{ printf "%s|%s|%s" $passwordSecret (include "hugegraph.rag.passwordSecretKey" .) (dig "metadata" "resourceVersion" "" $live) | sha256sum | quote }} + {{- else }} + checksum/hugegraph-auth: {{ include "hugegraph.server.authChecksum" . | quote }} + {{- end }} + {{- end }} + {{- with get $rag "podAnnotations" }}{{ toYaml . | nindent 8 }}{{- end }} {{- end }} spec: {{- $serviceAccount := get $rag "serviceAccount" | default dict }} @@ -87,10 +98,8 @@ spec: affinity: {{- toYaml . | nindent 8 }} {{- end }} - {{- with get $rag "podSecurityContext" }} securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} + {{- include "hugegraph.rag.podSecurityContext" . | nindent 8 }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} @@ -103,12 +112,11 @@ spec: command: ["/bin/sh", "-ec"] args: - | + # Preserve operator and UI edits. Image upgrades add only files + # that are still missing from the persistent resource directory. cp -a -n /home/work/hugegraph-llm/src/hugegraph_llm/resources/. /data/ - touch /data/.hugegraph-rag-seeded - {{- with get $rag "securityContext" }} securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} + {{- include "hugegraph.rag.securityContext" . | nindent 12 }} resources: {{- toYaml (get $rag "initResources" | default dict) | nindent 12 }} volumeMounts: @@ -127,14 +135,12 @@ spec: - --host - 0.0.0.0 - --port - - {{ include "hugegraph.rag.port" . | quote }} - {{- with get $rag "securityContext" }} + - {{ include "hugegraph.rag.port" . | int | quote }} securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} + {{- include "hugegraph.rag.securityContext" . | nindent 12 }} ports: - name: http - containerPort: {{ include "hugegraph.rag.port" . }} + containerPort: {{ include "hugegraph.rag.port" . | int }} protocol: TCP env: {{- if and $autoConfigure (not (hasKey $envNames "GRAPH_URL")) }} @@ -153,8 +159,7 @@ spec: - name: GRAPH_USER value: {{ get $graph "username" | default "admin" | quote }} {{- end }} - {{- $passwordSecret := include "hugegraph.rag.passwordSecretName" . }} - {{- if and $autoConfigure $passwordSecret (not (hasKey $envNames "GRAPH_PWD")) }} + {{- if $injectPassword }} - name: GRAPH_PWD valueFrom: secretKeyRef: @@ -163,7 +168,11 @@ spec: {{- end }} {{- if not (hasKey $envNames "HUGEGRAPH_LLM_ENV_PATH") }} - name: HUGEGRAPH_LLM_ENV_PATH - value: /tmp/hugegraph-llm.env + value: {{ include "hugegraph.rag.envPath" . | quote }} + {{- end }} + {{- if not (hasKey $envNames "HUGEGRAPH_LLM_PROMPT_CONFIG_PATH") }} + - name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH + value: {{ include "hugegraph.rag.promptConfigPath" . | quote }} {{- end }} {{- with get $rag "env" }} {{- toYaml . | nindent 12 }} @@ -178,7 +187,7 @@ spec: volumeMounts: {{- if $persistenceEnabled }} - name: data - mountPath: {{ get $persistence "mountPath" | default "/home/work/hugegraph-llm/src/hugegraph_llm/resources" | quote }} + mountPath: {{ include "hugegraph.rag.mountPath" . | quote }} {{- end }} {{- with get $rag "extraVolumeMounts" }} {{- toYaml . | nindent 12 }} @@ -190,21 +199,21 @@ spec: port: http failureThreshold: {{ get (get (get $rag "probes" | default dict) "startup" | default dict) "failureThreshold" | default 60 }} periodSeconds: {{ get (get (get $rag "probes" | default dict) "startup" | default dict) "periodSeconds" | default 5 }} - {{- include "hugegraph.probeTuning" (get (get (get $rag "probes" | default dict) "startup" | default dict)) | nindent 12 }} + {{- include "hugegraph.probeTuning" (get (get $rag "probes" | default dict) "startup" | default dict) | nindent 12 }} readinessProbe: httpGet: path: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "path" | default "/" | quote }} port: http failureThreshold: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "failureThreshold" | default 3 }} periodSeconds: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "periodSeconds" | default 10 }} - {{- include "hugegraph.probeTuning" (get (get (get $rag "probes" | default dict) "readiness" | default dict)) | nindent 12 }} + {{- include "hugegraph.probeTuning" (get (get $rag "probes" | default dict) "readiness" | default dict) | nindent 12 }} livenessProbe: httpGet: path: {{ get (get (get $rag "probes" | default dict) "liveness" | default dict) "path" | default "/" | quote }} port: http failureThreshold: {{ get (get (get $rag "probes" | default dict) "liveness" | default dict) "failureThreshold" | default 3 }} periodSeconds: {{ get (get (get $rag "probes" | default dict) "liveness" | default dict) "periodSeconds" | default 20 }} - {{- include "hugegraph.probeTuning" (get (get (get $rag "probes" | default dict) "liveness" | default dict)) | nindent 12 }} + {{- include "hugegraph.probeTuning" (get (get $rag "probes" | default dict) "liveness" | default dict) | nindent 12 }} {{- if or $persistenceEnabled (get $rag "extraVolumes") }} volumes: {{- if $persistenceEnabled }} diff --git a/helm/hugegraph/templates/rag-networkpolicy.yaml b/helm/hugegraph/templates/rag-networkpolicy.yaml index aedd9a59fb..af0e2df77f 100644 --- a/helm/hugegraph/templates/rag-networkpolicy.yaml +++ b/helm/hugegraph/templates/rag-networkpolicy.yaml @@ -23,7 +23,11 @@ {{- $graph := get $rag "hugegraph" | default dict }} {{- $autoConfigure := true }} {{- if hasKey $graph "autoConfigure" }}{{- $autoConfigure = get $graph "autoConfigure" }}{{- end }} -{{- $internalServer := and $autoConfigure (not (trim (get $graph "url" | default ""))) }} +{{- $envNames := dict }} +{{- range get $rag "env" | default list }} +{{- $_ := set $envNames (.name | toString) true }} +{{- end }} +{{- $internalServer := and $autoConfigure (not (trim (get $graph "url" | default ""))) (not (hasKey $envNames "GRAPH_URL")) }} {{- $ragPolicy := get $rag "networkPolicy" | default dict }} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy diff --git a/helm/hugegraph/templates/rag-pvc.yaml b/helm/hugegraph/templates/rag-pvc.yaml index 6f51eb7db5..0472c1fba4 100644 --- a/helm/hugegraph/templates/rag-pvc.yaml +++ b/helm/hugegraph/templates/rag-pvc.yaml @@ -18,7 +18,8 @@ {{ include "hugegraph.resolveValues" . }} {{- $rag := get .Values "rag" | default dict }} {{- $persistence := get $rag "persistence" | default dict }} -{{- if and (get $rag "enabled" | default false) (get $persistence "enabled" | default false) (not (get $persistence "existingClaim" | default "")) }} +{{- $persistenceEnabled := eq (include "hugegraph.rag.persistenceEnabled" .) "true" }} +{{- if and (get $rag "enabled" | default false) $persistenceEnabled (not (get $persistence "existingClaim" | default "")) }} {{- include "hugegraph.rag.validate" . }} apiVersion: v1 kind: PersistentVolumeClaim diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml index 0d1ba0cce5..84bbe69ef6 100644 --- a/helm/hugegraph/tests/rag_component_test.yaml +++ b/helm/hugegraph/tests/rag_component_test.yaml @@ -23,7 +23,7 @@ tests: - hasDocuments: count: 0 - - it: connects an enabled RAG pod to the same-release Server service + - it: connects a persistent non-root RAG pod to the same-release Server template: rag-deployment.yaml set: rag.enabled: true @@ -33,6 +33,24 @@ tests: - equal: path: spec.replicas value: 1 + - equal: + path: spec.strategy.type + value: Recreate + - equal: + path: spec.template.spec.securityContext.runAsUser + value: 1000 + - equal: + path: spec.template.spec.securityContext.runAsGroup + value: 1000 + - equal: + path: spec.template.spec.securityContext.fsGroup + value: 1000 + - equal: + path: spec.template.spec.initContainers[0].name + value: seed-resources + - matchRegex: + path: spec.template.spec.initContainers[0].args[0] + pattern: "cp -a -n" - equal: path: spec.template.spec.containers[0].image value: hugegraph/rag:latest @@ -52,6 +70,9 @@ tests: - equal: path: spec.template.spec.containers[0].ports[0].containerPort value: 8001 + - equal: + path: spec.template.spec.containers[0].volumeMounts[0].mountPath + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources - contains: path: spec.template.spec.containers[0].env content: @@ -69,7 +90,12 @@ tests: path: spec.template.spec.containers[0].env content: name: HUGEGRAPH_LLM_ENV_PATH - value: /tmp/hugegraph-llm.env + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/.env + - contains: + path: spec.template.spec.containers[0].env + content: + name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml - it: passes a custom port to the process and Pod contract template: rag-deployment.yaml @@ -106,37 +132,37 @@ tests: name: remote-admin key: credential - - it: uses the topology preset replica count without changing the template + - it: defaults a custom password Secret to the password key template: rag-deployment.yaml set: rag.enabled: true - rag.replicas: 2 + rag.hugegraph.passwordSecret.name: rag-credentials asserts: - - equal: - path: spec.replicas - value: 2 - - equal: - path: spec.strategy.type - value: RollingUpdate + - contains: + path: spec.template.spec.containers[0].env + content: + name: GRAPH_PWD + valueFrom: + secretKeyRef: + name: rag-credentials + key: password - - it: seeds image resources before mounting persistent storage + - it: supports an explicitly disposable RAG pod template: rag-deployment.yaml set: rag.enabled: true - rag.persistence.enabled: true + rag.persistence.enabled: false asserts: - equal: path: spec.strategy.type - value: Recreate - - equal: - path: spec.template.spec.initContainers[0].name - value: seed-resources - - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: "cp -a -n" - - equal: - path: spec.template.spec.containers[0].volumeMounts[0].mountPath - value: /home/work/hugegraph-llm/src/hugegraph_llm/resources + value: RollingUpdate + - notExists: + path: spec.template.spec.initContainers + - contains: + path: spec.template.spec.containers[0].env + content: + name: HUGEGRAPH_LLM_ENV_PATH + value: /tmp/hugegraph-llm.env - it: keeps the RAG service internal by default in easy mode template: rag-service.yaml @@ -179,11 +205,10 @@ tests: path: spec.ports[0].nodePort value: 31001 - - it: creates a retained claim only when persistence is enabled + - it: creates a retained claim by default after RAG is enabled template: rag-pvc.yaml set: rag.enabled: true - rag.persistence.enabled: true asserts: - isKind: of: PersistentVolumeClaim @@ -193,3 +218,15 @@ tests: - equal: path: spec.resources.requests.storage value: 10Gi + - equal: + path: spec.accessModes[0] + value: ReadWriteOnce + + - it: creates no claim when persistence is explicitly disabled + template: rag-pvc.yaml + set: + rag.enabled: true + rag.persistence.enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/helm/hugegraph/values-cluster.yaml b/helm/hugegraph/values-cluster.yaml index f61707be98..54de702ec1 100644 --- a/helm/hugegraph/values-cluster.yaml +++ b/helm/hugegraph/values-cluster.yaml @@ -132,12 +132,13 @@ server: # Network isolation is selected by mode or an explicit enabled override. -# Optional RAG application follows this preset only when explicitly enabled. -# Two stateless Pods are a reasonable cluster starting point; configure shared -# ReadWriteMany storage before enabling persistence with more than one replica. +# Optional RAG follows this preset only when explicitly enabled. Start with one +# persistent replica so the default ReadWriteOnce claim works on ordinary +# StorageClasses. Scale beyond one only after selecting a shared ReadWriteMany +# claim with rag.persistence.existingClaim or accessModes. rag: enabled: false - replicas: 2 + replicas: 1 # Optional computation follows this preset only when explicitly enabled. vermeer: diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml index a758c21e56..a008b0e7e9 100644 --- a/helm/hugegraph/values-rag.yaml +++ b/helm/hugegraph/values-rag.yaml @@ -16,7 +16,8 @@ # # Optional HugeGraph RAG overlay. Layer it after values-single.yaml or -# values-cluster.yaml; topology presets retain their own RAG replica count. +# values-cluster.yaml. The same-release Server address, port, graph identity +# and administrator Secret are derived by the chart and need no duplication. rag: enabled: true image: @@ -25,15 +26,16 @@ rag: digest: '' pullPolicy: Always port: 8001 + + # Keep this true for a Server deployed by the same Helm release. Configure + # url/graphName/graphSpace/username/passwordSecret only for an external or + # deliberately separate HugeGraph deployment; see docs/rag.md. hugegraph: autoConfigure: true - url: '' - graphName: hugegraph - graphSpace: '' - username: admin - passwordSecret: - name: '' - key: '' + + # The main RAG process has no universal resource profile because model, + # embedding and indexing workloads vary widely. Size it for the selected + # providers and dataset. resources: {} initResources: requests: @@ -42,19 +44,9 @@ rag: limits: cpu: 100m memory: 64Mi - podSecurityContext: - fsGroup: 1000 - fsGroupChangePolicy: OnRootMismatch - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault + + # Safe non-root UID/GID/fsGroup 1000 and RuntimeDefault seccomp are built + # into the templates, including bare --set rag.enabled=true installs. nodeSelector: {} tolerations: [] affinity: {} @@ -65,13 +57,15 @@ rag: env: [] envFrom: [] terminationGracePeriodSeconds: 30 + serviceAccount: create: true name: '' annotations: {} automountServiceAccountToken: false - # RAG stays internal by default because the application login is optional. - # Select NodePort/LoadBalancer or enable Ingress only with reviewed auth. + + # RAG stays internal by default because the application login does not + # protect every HTTP route. Expose it only behind a reviewed auth boundary. service: type: ClusterIP nodePort: null @@ -86,14 +80,20 @@ rag: - path: / pathType: Prefix tls: [] + + # Persistence defaults on when RAG is enabled. The claim stores the editable + # resource tree, generated .env model/graph settings and prompt YAML. The + # init container adds missing files from a new image but never overwrites + # files already changed on the claim. persistence: - enabled: false + enabled: true existingClaim: '' size: 10Gi storageClassName: '' accessModes: - ReadWriteOnce mountPath: /home/work/hugegraph-llm/src/hugegraph_llm/resources + extraVolumes: [] extraVolumeMounts: [] probes: diff --git a/helm/test_helm_package.py b/helm/test_helm_package.py index 202ca04f13..6e4831cfa2 100644 --- a/helm/test_helm_package.py +++ b/helm/test_helm_package.py @@ -146,8 +146,11 @@ def test_packaged_optional_vermeer_images_include_native_token_helper(self): def load_tests(loader, tests, _pattern): - """Run the optional RAG contract in the shared packaged-chart CI entrypoint.""" - from test_helm_rag import RagChartTest, RagSourceContractTest + """Run the optional RAG contract from discover and module-style entry points.""" + try: + from test_helm_rag import RagChartTest, RagSourceContractTest + except ModuleNotFoundError: + from helm.test_helm_rag import RagChartTest, RagSourceContractTest tests.addTests(loader.loadTestsFromTestCase(RagSourceContractTest)) tests.addTests(loader.loadTestsFromTestCase(RagChartTest)) diff --git a/helm/test_helm_rag.py b/helm/test_helm_rag.py index 872520381f..632f04f085 100644 --- a/helm/test_helm_rag.py +++ b/helm/test_helm_rag.py @@ -27,6 +27,7 @@ CHART = Path(__file__).resolve().parent / "hugegraph" HELM_TEMPLATE_TIMEOUT_SECONDS = 120 +RAG_MOUNT = "/home/work/hugegraph-llm/src/hugegraph_llm/resources" def component_resources(documents, component="rag", kind=None): @@ -47,34 +48,37 @@ def env_by_name(container): class RagSourceContractTest(unittest.TestCase): - def test_defaults_are_opt_in_and_match_the_image_contract(self): + def test_profile_is_internal_persistent_and_matches_the_image_contract(self): values = yaml.safe_load((CHART / "values-rag.yaml").read_text(encoding="utf-8")) rag = values["rag"] self.assertTrue(rag["enabled"]) + self.assertNotIn("replicas", rag, "topology presets own the optional replica count") self.assertEqual("hugegraph/rag", rag["image"]["repository"]) self.assertEqual(8001, rag["port"]) + self.assertEqual({"autoConfigure": True}, rag["hugegraph"]) self.assertEqual("ClusterIP", rag["service"]["type"]) - self.assertEqual( - "/home/work/hugegraph-llm/src/hugegraph_llm/resources", - rag["persistence"]["mountPath"], - ) + self.assertTrue(rag["persistence"]["enabled"]) + self.assertEqual(RAG_MOUNT, rag["persistence"]["mountPath"]) - def test_single_and_cluster_presets_do_not_enable_rag_implicitly(self): + def test_single_and_cluster_presets_keep_one_persistent_rag_replica(self): single = yaml.safe_load((CHART / "values-single.yaml").read_text(encoding="utf-8")) cluster = yaml.safe_load((CHART / "values-cluster.yaml").read_text(encoding="utf-8")) self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) - self.assertEqual({"enabled": False, "replicas": 2}, cluster["rag"]) + self.assertEqual({"enabled": False, "replicas": 1}, cluster["rag"]) - def test_templates_use_one_process_port_and_release_server_service(self): + def test_templates_use_one_process_port_and_persistent_native_paths(self): deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") helpers = (CHART / "templates" / "_rag.tpl").read_text(encoding="utf-8") self.assertIn("hugegraph_llm.demo.rag_demo.app", deployment) - self.assertIn('containerPort: {{ include "hugegraph.rag.port" . }}', deployment) - self.assertIn('- {{ include "hugegraph.rag.port" . | quote }}', deployment) + self.assertIn('containerPort: {{ include "hugegraph.rag.port" . | int }}', deployment) + self.assertIn('include "hugegraph.rag.port" . | int | quote', deployment) self.assertIn("targetPort: http", service) self.assertIn('include "hugegraph.server.clientUrl"', helpers) self.assertIn('trimPrefix "http://"', helpers) + self.assertIn("HUGEGRAPH_LLM_ENV_PATH", deployment) + self.assertIn("HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", deployment) + self.assertNotIn("touch /data/.hugegraph-rag-seeded", deployment) class RagChartTest(unittest.TestCase): @@ -114,50 +118,65 @@ def deployment(self, documents): deployment, = component_resources(documents, kind="Deployment") return deployment + def container(self, documents): + container, = pod(self.deployment(documents))["spec"]["containers"] + return container + def test_disabled_adds_no_resources(self): documents = self.documents() self.assertEqual([], component_resources(documents)) self.assertFalse(any("-rag" in document["metadata"]["name"] for document in documents)) - def test_bare_enable_uses_consistent_template_defaults(self): + def test_bare_enable_has_safe_persistent_defaults(self): documents = self.documents({}, use_profile=False) deployment = self.deployment(documents) - container, = pod(deployment)["spec"]["containers"] + container = self.container(documents) service, = component_resources(documents, kind="Service") + pvc, = component_resources(documents, kind="PersistentVolumeClaim") + self.assertEqual("hugegraph/rag:latest", container["image"]) + self.assertEqual(1, deployment["spec"]["replicas"]) + self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) self.assertEqual(8001, container["ports"][0]["containerPort"]) self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) self.assertEqual("ClusterIP", service["spec"]["type"]) + self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) + + pod_security = pod(deployment)["spec"]["securityContext"] + self.assertTrue(pod_security["runAsNonRoot"]) + self.assertEqual(1000, pod_security["runAsUser"]) + self.assertEqual(1000, pod_security["runAsGroup"]) + self.assertEqual(1000, pod_security["fsGroup"]) + self.assertFalse(container["securityContext"]["allowPrivilegeEscalation"]) + self.assertEqual(["ALL"], container["securityContext"]["capabilities"]["drop"]) + + environment = env_by_name(container) + self.assertEqual(f"{RAG_MOUNT}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{RAG_MOUNT}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) - def test_default_enable_uses_one_consistent_port_and_internal_server(self): + def test_default_profile_uses_internal_server_contract(self): documents = self.documents({}) deployment = self.deployment(documents) - container, = pod(deployment)["spec"]["containers"] + container = self.container(documents) service, = component_resources(documents, kind="Service") - self.assertEqual(8001, container["ports"][0]["containerPort"]) - self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) - self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) - self.assertEqual("http", service["spec"]["ports"][0]["targetPort"]) - self.assertEqual(8001, service["spec"]["ports"][0]["port"]) - self.assertEqual("ClusterIP", service["spec"]["type"]) environment = env_by_name(container) - self.assertEqual( - "fixture-hugegraph-server.graphs.svc:8080", - environment["GRAPH_URL"]["value"], - ) + + self.assertEqual("fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"]) self.assertEqual("hugegraph", environment["GRAPH_NAME"]["value"]) self.assertEqual("DEFAULT", environment["GRAPH_SPACE"]["value"]) self.assertEqual("admin", environment["GRAPH_USER"]["value"]) self.assertEqual("fixture-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) self.assertEqual("password", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) - self.assertEqual("/tmp/hugegraph-llm.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual("hugegraph/rag:latest", container["image"]) + self.assertIn("checksum/hugegraph-auth", pod(deployment)["metadata"]["annotations"]) + self.assertEqual("ClusterIP", service["spec"]["type"]) def test_custom_port_reaches_process_pod_service_and_probes(self): documents = self.documents({"port": 9001}) - deployment = self.deployment(documents) - container, = pod(deployment)["spec"]["containers"] + container = self.container(documents) service, = component_resources(documents, kind="Service") self.assertEqual("9001", container["args"][3]) self.assertEqual(9001, container["ports"][0]["containerPort"]) @@ -166,14 +185,12 @@ def test_custom_port_reaches_process_pod_service_and_probes(self): self.assertEqual("http", container[name]["httpGet"]["port"]) def test_single_and_cluster_presets_share_the_same_graph_contract(self): - cases = (("values-single.yaml", 1), ("values-cluster.yaml", 2)) - for preset, expected_replicas in cases: + for preset in ("values-single.yaml", "values-cluster.yaml"): with self.subTest(preset=preset): documents = self.documents({}, preset=preset) deployment = self.deployment(documents) - container, = pod(deployment)["spec"]["containers"] - self.assertEqual(expected_replicas, deployment["spec"]["replicas"]) - environment = env_by_name(container) + environment = env_by_name(self.container(documents)) + self.assertEqual(1, deployment["spec"]["replicas"]) self.assertEqual( "fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"], @@ -193,49 +210,96 @@ def test_service_stays_internal_unless_exposure_is_explicit(self): def test_user_environment_replaces_automatic_entries(self): documents = self.documents({ "env": [ - {"name": "GRAPH_URL", "value": "http://external:8080"}, + {"name": "GRAPH_URL", "value": "external:8080"}, {"name": "GRAPH_PWD", "valueFrom": {"secretKeyRef": {"name": "external", "key": "pwd"}}}, ] }) - container, = pod(self.deployment(documents))["spec"]["containers"] - entries = [entry for entry in container["env"] if entry["name"] in ("GRAPH_URL", "GRAPH_PWD")] - self.assertEqual(2, len(entries)) - environment = env_by_name(container) - self.assertEqual("http://external:8080", environment["GRAPH_URL"]["value"]) + environment = env_by_name(self.container(documents)) + self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) self.assertEqual("external", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + def test_external_graph_url_in_env_never_inherits_internal_admin_password(self): + documents = self.documents({"env": [{"name": "GRAPH_URL", "value": "external:8080"}]}) + environment = env_by_name(self.container(documents)) + self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) + self.assertNotIn("GRAPH_PWD", environment) + self.assertNotIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"].get("annotations", {})) + def test_external_server_uses_only_explicit_password_secret(self): documents = self.documents({"hugegraph": { "autoConfigure": True, "url": "http://remote.graphs.svc:8080", "passwordSecret": {"name": "remote-admin", "key": "credential"}, }}) - container, = pod(self.deployment(documents))["spec"]["containers"] - environment = env_by_name(container) + environment = env_by_name(self.container(documents)) self.assertEqual("http://remote.graphs.svc:8080", environment["GRAPH_URL"]["value"]) self.assertEqual("remote-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) self.assertEqual("credential", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) + self.assertIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"]["annotations"]) documents = self.documents({"hugegraph": { "autoConfigure": True, "url": "http://remote.graphs.svc:8080", }}) - container, = pod(self.deployment(documents))["spec"]["containers"] - self.assertNotIn("GRAPH_PWD", env_by_name(container)) + self.assertNotIn("GRAPH_PWD", env_by_name(self.container(documents))) + + def test_custom_password_secret_defaults_to_password_key(self): + documents = self.documents({"hugegraph": { + "passwordSecret": {"name": "rag-credentials"}, + }}) + password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] + self.assertEqual("rag-credentials", password["name"]) + self.assertEqual("password", password["key"]) + + def test_internal_server_secret_keeps_its_configured_key(self): + documents = self.documents({}, server={"auth": {"admin": {"key": "admin-password"}}}) + password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] + self.assertEqual("admin-password", password["key"]) - def test_persistence_seeds_the_resource_directory(self): + def test_persistence_seeds_and_reuses_all_writable_configuration(self): documents = self.documents({"persistence": {"enabled": True}}) deployment = self.deployment(documents) pvc, = component_resources(documents, kind="PersistentVolumeClaim") self.assertEqual("fixture-hugegraph-rag-data", pvc["metadata"]["name"]) self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) init, = pod(deployment)["spec"]["initContainers"] - self.assertIn("cp -a", init["args"][0]) - container, = pod(deployment)["spec"]["containers"] + self.assertIn("cp -a -n", init["args"][0]) + self.assertNotIn(".hugegraph-rag-seeded", init["args"][0]) + container = self.container(documents) mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] + self.assertEqual(RAG_MOUNT, mount["mountPath"]) + environment = env_by_name(container) + self.assertEqual(f"{RAG_MOUNT}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual( - "/home/work/hugegraph-llm/src/hugegraph_llm/resources", - mount["mountPath"], + f"{RAG_MOUNT}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) + + def test_custom_mount_path_updates_both_native_config_paths(self): + mount_path = "/var/lib/hugegraph-rag" + documents = self.documents({"persistence": {"mountPath": mount_path}}) + container = self.container(documents) + environment = env_by_name(container) + self.assertEqual(f"{mount_path}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{mount_path}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) + mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] + self.assertEqual(mount_path, mount["mountPath"]) + + def test_persistence_can_be_disabled_for_disposable_runs(self): + documents = self.documents({"persistence": {"enabled": False}}) + deployment = self.deployment(documents) + self.assertEqual([], component_resources(documents, kind="PersistentVolumeClaim")) + self.assertNotIn("initContainers", pod(deployment)["spec"]) + container = self.container(documents) + self.assertFalse(any(item["name"] == "data" for item in container.get("volumeMounts", []))) + environment = env_by_name(container) + self.assertEqual("/tmp/hugegraph-llm.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{RAG_MOUNT}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], ) def test_multiple_generated_persistent_replicas_require_rwx(self): @@ -274,20 +338,32 @@ def test_global_registry_and_digest_render(self): {"image": {"repository": "hugegraph/rag", "tag": "ignored", "digest": digest}}, **{"global": {"imageRegistry": "registry.example.com"}}, ) - deployment = self.deployment(documents) expected = f"registry.example.com/hugegraph/rag@{digest}" + deployment = self.deployment(documents) self.assertEqual(expected, pod(deployment)["spec"]["containers"][0]["image"]) + self.assertEqual(expected, pod(deployment)["spec"]["initContainers"][0]["image"]) - def test_invalid_service_contracts_fail_before_render(self): + def test_invalid_contracts_fail_before_render(self): invalid = ( - {"service": {"type": "ExternalName"}}, - {"service": {"type": "ClusterIP", "nodePort": 31001}}, - {"port": 0}, - {"replicas": 0}, + ({"service": {"type": "ExternalName"}}, "service.type"), + ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), + ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), + ({"port": 0}, "rag.port"), + ({"port": 8001.5}, "rag.port"), + ({"replicas": 0}, "rag.replicas"), + ({"replicas": 1.5}, "rag.replicas"), + ({"podSecurityContext": {"runAsUser": 0}}, "runAsUser"), + ({"podSecurityContext": {"fsGroup": 0}}, "fsGroup"), + ({"securityContext": {"allowPrivilegeEscalation": True}}, "allowPrivilegeEscalation"), + ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), + ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), + ({"env": [{"name": "GRAPH_URL", "value": "a"}, {"name": "GRAPH_URL", "value": "b"}]}, "duplicate"), ) - for values in invalid: + for values, expected in invalid: with self.subTest(values=values): - self.assertNotEqual(0, self.render(values).returncode) + result = self.render(values) + self.assertNotEqual(0, result.returncode) + self.assertIn(expected, result.stderr) if __name__ == "__main__": From fed18218c313e04e0071ef225bebaeb953cdd3db Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 03:46:20 +0800 Subject: [PATCH 05/38] test(helm): avoid duplicate and eager RAG test loading --- helm/test_helm_package.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/helm/test_helm_package.py b/helm/test_helm_package.py index 6e4831cfa2..5f05ce34b2 100644 --- a/helm/test_helm_package.py +++ b/helm/test_helm_package.py @@ -145,11 +145,16 @@ def test_packaged_optional_vermeer_images_include_native_token_helper(self): self.assertIn("/go/bin/vermeer-token", rendered) -def load_tests(loader, tests, _pattern): - """Run the optional RAG contract from discover and module-style entry points.""" +def load_tests(loader, tests, pattern): + """Load the RAG suite only for the narrow packaged-chart CI invocation.""" + if pattern != "test_helm_package.py": + return tests + try: from test_helm_rag import RagChartTest, RagSourceContractTest - except ModuleNotFoundError: + except ModuleNotFoundError as error: + if error.name != "test_helm_rag": + raise from helm.test_helm_rag import RagChartTest, RagSourceContractTest tests.addTests(loader.loadTestsFromTestCase(RagSourceContractTest)) From d2476cb8e0c0da407f3c44dd171757b4ccfb26c9 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 03:55:30 +0800 Subject: [PATCH 06/38] fix(helm): harden RAG runtime state Keep generated runtime configuration ephemeral so rotated Kubernetes Secrets remain authoritative after restarts. Retain prompts and indexes on the PVC, enforce the application's single-active replica contract, reject unsafe security overrides and unknown RAG fields, and strengthen Helm regressions and documentation. --- .../docs/images/rag-helm-architecture.svg | 8 +- helm/hugegraph/docs/rag.md | 126 +++--- helm/hugegraph/templates/_rag.tpl | 143 +------ helm/hugegraph/templates/_rag_security.tpl | 50 +++ helm/hugegraph/templates/_rag_validate.tpl | 26 ++ .../templates/_rag_validate_fields.tpl | 86 ++++ .../templates/_rag_validate_metadata.tpl | 42 ++ .../templates/_rag_validate_runtime.tpl | 66 ++++ .../templates/_rag_validate_security.tpl | 98 +++++ helm/hugegraph/templates/rag-deployment.yaml | 4 +- helm/hugegraph/tests/rag_component_test.yaml | 20 +- helm/hugegraph/values-rag.yaml | 14 +- helm/rag_tests/__init__.py | 5 + helm/rag_tests/basic.py | 87 ++++ helm/rag_tests/source_contract.py | 71 ++++ helm/rag_tests/state.py | 137 +++++++ helm/rag_tests/support.py | 91 +++++ helm/rag_tests/validation.py | 50 +++ helm/rag_tests/wiring.py | 94 +++++ helm/test_helm_rag.py | 370 ++---------------- 20 files changed, 1032 insertions(+), 556 deletions(-) create mode 100644 helm/hugegraph/templates/_rag_security.tpl create mode 100644 helm/hugegraph/templates/_rag_validate.tpl create mode 100644 helm/hugegraph/templates/_rag_validate_fields.tpl create mode 100644 helm/hugegraph/templates/_rag_validate_metadata.tpl create mode 100644 helm/hugegraph/templates/_rag_validate_runtime.tpl create mode 100644 helm/hugegraph/templates/_rag_validate_security.tpl create mode 100644 helm/rag_tests/__init__.py create mode 100644 helm/rag_tests/basic.py create mode 100644 helm/rag_tests/source_contract.py create mode 100644 helm/rag_tests/state.py create mode 100644 helm/rag_tests/support.py create mode 100644 helm/rag_tests/validation.py create mode 100644 helm/rag_tests/wiring.py diff --git a/helm/hugegraph/docs/images/rag-helm-architecture.svg b/helm/hugegraph/docs/images/rag-helm-architecture.svg index 1f91d4d538..ac6706fe41 100644 --- a/helm/hugegraph/docs/images/rag-helm-architecture.svg +++ b/helm/hugegraph/docs/images/rag-helm-architecture.svg @@ -16,7 +16,7 @@ --> HugeGraph Helm optional RAG integration - One chart manages HugeGraph core services and an optional RAG application for single-node and cluster presets. + One chart manages HugeGraph core services and one optional single-active RAG application for single-node and cluster presets. @@ -45,7 +45,7 @@ PD1 or 3 replicasmetadata + placement Store1 or 3 replicasHStore data plane Server Servicetopology-neutral DNSauth Secret reused safely - RAG0..N replicasimage: hugegraph/rag + RAG0 or 1 replicaRecreate · ephemeral .env @@ -56,8 +56,8 @@ Single preset1 PD + 1 Store + 1 Server Cluster preset3 PD + 3 Store + 3 Server - Flexible boundariesService · Ingress · PVC · NetPol + Separated stateSecret env · retained PVC - Docker and docker-compose in hugegraph-ai remain unchanged; Kubernetes ownership moves to the main chart. + Runtime credentials remain ephemeral; prompts, indexes and resources remain durable on the PVC. diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index e30463b916..d50cb777a6 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -14,16 +14,16 @@ For a RAG Pod connected to the HugeGraph Server in the **same Helm release**, th chart deliberately keeps the required values small: 1. Enable RAG with `rag.enabled=true` or the supplied `values-rag.yaml` overlay. -2. Provide the selected model provider credentials and model settings, normally - through a Kubernetes Secret or the RAG interface. +2. Provide the selected model-provider credentials and model settings, normally + through a Kubernetes Secret. 3. Set `rag.persistence.storageClassName` or `existingClaim` only when the cluster has no suitable default StorageClass. Do **not** repeat the Server Pod IP, REST port, graph name, graph space or Server administrator Secret for the normal same-release deployment. The chart derives -them from the Server Service and existing Server values. RAG uses a single -persistent replica by default in both topology presets so the ordinary -`ReadWriteOnce` claim is sufficient. +them from the Server Service and existing Server values. RAG is deliberately +single-replica in both topology presets, so the ordinary `ReadWriteOnce` claim is +sufficient. ```bash # Single PD + Store + Server, plus one persistent RAG Pod @@ -39,9 +39,10 @@ helm upgrade --install hugegraph ./helm/hugegraph \ -f helm/hugegraph/values-rag.yaml ``` -A bare `--set rag.enabled=true` has the same safe non-root, internal-Service and -persistent-storage defaults. The overlay keeps the commonly reviewed knobs in -one file, but no second copy of the Server connection is required. +A bare `--set rag.enabled=true` has the same non-root, internal-Service, +single-replica and persistent-resource defaults. The overlay keeps the commonly +reviewed knobs in one file, but no second copy of the Server connection is +required. ## Automatic HugeGraph wiring @@ -83,12 +84,12 @@ rag: ``` When the chart-managed Server administrator Secret changes during a Helm -upgrade, the RAG pod template checksum changes and RAG rolls out with the new -credential. For an external Secret, update the Secret and run `helm upgrade` so -its resource version is re-evaluated; changing the Secret alone does not restart -an existing Pod whose environment was already populated. +upgrade, the RAG pod-template checksum changes and RAG rolls out with the new +credential. For an external password Secret, update the Secret and run +`helm upgrade` so its resource version is re-evaluated; changing the Secret alone +does not restart a Pod whose environment was already populated. -## Model provider credentials +## Model-provider credentials Keep provider credentials out of values files. Create a Secret and expose it with `envFrom`: @@ -109,12 +110,17 @@ rag: The application supports other providers through the same native `env` and `envFrom` mechanisms. The chart does not copy provider credentials into a -ConfigMap or generate a second credential system. The application can persist -selected model and graph settings in its `.env` file, so treat the RAG PVC as -sensitive data as well: restrict access, include it in the appropriate backup -policy, and use storage encryption where required. +ConfigMap or generate a second credential system. Kubernetes injects Secret +values into a container only when the Pod starts, so restart or roll out RAG +after changing a provider Secret. -## Persistent and externally editable configuration +The generated application `.env` is intentionally placed at +`/tmp/hugegraph-llm.env`, outside the persistent claim. HugeGraph AI loads an +existing `.env` into the process environment before reading its settings; if +that file were retained, stale graph or model credentials could override newly +rotated Kubernetes Secrets after a restart. + +## Persistent and externally editable resources Persistence defaults to enabled after RAG is enabled. One claim stores the application's writable resource tree at: @@ -123,31 +129,32 @@ application's writable resource tree at: /home/work/hugegraph-llm/src/hugegraph_llm/resources ``` -The chart points the application's writable configuration to locations on that -same claim: +The chart separates durable resources from runtime environment settings: ```text -.env model and graph runtime settings -/demo/config_prompt.yaml editable prompts and graph schema examples +/tmp/hugegraph-llm.env ephemeral model/graph settings +/demo/config_prompt.yaml durable prompts and schema examples +/... durable indexes and other resources ``` The effective environment variables are therefore: ```text -HUGEGRAPH_LLM_ENV_PATH=/.env +HUGEGRAPH_LLM_ENV_PATH=/tmp/hugegraph-llm.env HUGEGRAPH_LLM_PROMPT_CONFIG_PATH=/demo/config_prompt.yaml ``` -A restart or image replacement reuses these files. The init container copies -only files that are missing from the claim (`cp -n`); it never overwrites a file -already changed through the UI or by an operator. Consequently, an image upgrade -also does not replace existing prompt/config files with newer packaged defaults. -Back up the claim and update selected files manually when adopting those new -defaults. +A restart or image replacement reuses the persistent resource tree and prompt +configuration, while model and graph settings are reconstructed from the Pod +environment and Kubernetes Secrets. The init container copies only files that +are missing from the claim (`cp -n`); it never overwrites a file already changed +through the UI or by an operator. Consequently, an image upgrade also does not +replace existing prompt/config files with newer packaged defaults. Back up the +claim and update selected files manually when adopting those new defaults. -The files can be inspected or replaced without rebuilding the image. The -following example locates the RAG Pod by labels instead of assuming a generated -name: +The durable files can be inspected or replaced without rebuilding the image. +The following example locates the RAG Pod by labels instead of assuming a +generated name: ```bash NS=hugegraph @@ -161,22 +168,24 @@ kubectl -n "$NS" exec "$POD" -- ls -la "$MOUNT" kubectl -n "$NS" exec -it "$POD" -- sh ``` -For GitOps or an externally managed configuration lifecycle, provision a PVC, -seed or mount it with the desired files, and point the chart at it: +For GitOps or an externally managed resource lifecycle, provision a PVC, seed or +mount it with the desired files, and point the chart at it: ```yaml rag: enabled: true persistence: enabled: true - existingClaim: managed-rag-config + existingClaim: managed-rag-resources ``` -Overriding `HUGEGRAPH_LLM_ENV_PATH`, `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` or -`rag.persistence.mountPath` is supported, but an override outside the claim is -intentionally ephemeral unless another persistent volume is mounted there. +`rag.persistence.mountPath` and `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` can be +overridden when another volume layout is required. Overriding +`HUGEGRAPH_LLM_ENV_PATH` to a persistent path is possible through `rag.env`, but +is not recommended: the retained file becomes a higher-priority configuration +source and can mask rotated Secret values. -## Persistence lifecycle and replicas +## Persistence lifecycle and single-replica contract A chart-generated RAG PVC has `helm.sh/resource-policy: keep`. Disabling RAG or uninstalling the release removes the RAG workload but retains that PVC and its @@ -189,20 +198,14 @@ kubectl -n hugegraph delete pvc hugegraph-rag-data The exact generated name follows the Helm release name; inspect it before deleting with `kubectl -n hugegraph get pvc`. -One generated `ReadWriteOnce` claim supports one RAG replica. To run multiple -replicas, use a StorageClass and claim that support `ReadWriteMany`, either with -an existing shared claim or by setting `rag.persistence.accessModes` accordingly. -A bound PVC cannot be converted from RWO to RWX in place by `helm upgrade`. -Create a new shared claim and migrate the files before setting -`rag.persistence.existingClaim`, or back up and recreate the generated claim. - -```yaml -rag: - replicas: 2 - persistence: - enabled: true - existingClaim: shared-rag-resources -``` +The current HugeGraph RAG application keeps mutable settings in process and +writes prompt/resource files without cross-Pod coordination. An RWX volume would +only make the files mountable by multiple Pods; it would not synchronize their +in-memory settings or serialize writes. The chart therefore requires +`rag.replicas=1` and always uses the `Recreate` deployment strategy so an update +never leaves two active RAG Pods. Multi-replica support should be enabled only +after the application provides an external, concurrency-safe configuration +store and cross-Pod reload semantics. Persistence can be disabled explicitly for disposable tests: @@ -213,8 +216,8 @@ rag: enabled: false ``` -In that mode the generated `.env` and changes inside the container filesystem -are lost when the Pod is replaced. +In that mode prompt, index and other filesystem changes are lost when the Pod is +replaced. Runtime `.env` settings are ephemeral in both modes by design. ## Use another HugeGraph deployment @@ -257,9 +260,10 @@ public model APIs or an Ingress controller. Add the required peers to `rag.networkPolicy.extraIngress` and provider destinations to `rag.networkPolicy.extraEgress` using native Kubernetes NetworkPolicy rules. -The templates enforce non-root UID/GID/fsGroup `1000`, dropped Linux -capabilities, disabled privilege escalation and RuntimeDefault seccomp even for -a bare enable. The small default requests/limits in `values-rag.yaml` apply only -to the resource-seeding init container. The main RAG container deliberately -keeps `resources: {}` because model, embedding and index workloads require -installation-specific sizing. +The templates enforce non-root execution, `privileged=false`, no added Linux +capabilities, `allowPrivilegeEscalation=false`, RuntimeDefault seccomp and a +disabled ServiceAccount token. UID/GID/fsGroup default to `1000`; explicit +values must remain positive non-root IDs. The small default requests/limits in +`values-rag.yaml` apply only to the resource-seeding init container. The main RAG +container deliberately keeps `resources: {}` because model, embedding and index +workloads require installation-specific sizing. diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index 2762ff2171..03b9c847db 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -114,12 +114,14 @@ password {{- get $persistence "mountPath" | default "/home/work/hugegraph-llm/src/hugegraph_llm/resources" -}} {{- end }} +{{/* +Keep the generated .env outside the persistent resource tree. HugeGraph AI +loads an existing .env into os.environ before Pydantic reads the container +environment, so persisting it would let stale graph/model credentials override +rotated Kubernetes Secrets after a restart. +*/}} {{- define "hugegraph.rag.envPath" -}} -{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} -{{- printf "%s/.env" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} -{{- else -}} /tmp/hugegraph-llm.env -{{- end -}} {{- end }} {{- define "hugegraph.rag.promptConfigPath" -}} @@ -136,136 +138,3 @@ password {{- get $persistence "existingClaim" | default (include "hugegraph.rag.dataName" .) -}} {{- end }} -{{- define "hugegraph.rag.podSecurityContext" -}} -{{- $rag := get .Values "rag" | default dict -}} -{{- $defaults := dict - "runAsNonRoot" true - "runAsUser" 1000 - "runAsGroup" 1000 - "fsGroup" 1000 - "fsGroupChangePolicy" "OnRootMismatch" - "seccompProfile" (dict "type" "RuntimeDefault") -}} -{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "podSecurityContext" | default dict)) -}} -{{- range $field := list "runAsUser" "runAsGroup" "fsGroup" -}} -{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} -{{- end -}} -{{- toYaml $context -}} -{{- end }} - -{{- define "hugegraph.rag.securityContext" -}} -{{- $rag := get .Values "rag" | default dict -}} -{{- $defaults := dict - "runAsNonRoot" true - "runAsUser" 1000 - "runAsGroup" 1000 - "allowPrivilegeEscalation" false - "capabilities" (dict "drop" (list "ALL")) - "seccompProfile" (dict "type" "RuntimeDefault") -}} -{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "securityContext" | default dict)) -}} -{{- range $field := list "runAsUser" "runAsGroup" -}} -{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} -{{- end -}} -{{- toYaml $context -}} -{{- end }} - -{{- define "hugegraph.rag.validate" -}} -{{- $rag := get .Values "rag" | default dict -}} -{{- if get $rag "enabled" | default false -}} -{{- $rawReplicas := "1" -}} -{{- if hasKey $rag "replicas" -}}{{- $rawReplicas = get $rag "replicas" | toString -}}{{- end -}} -{{- if not (regexMatch "^[0-9]+$" $rawReplicas) -}} -{{- fail "rag.replicas must be an integer between 1 and 100" -}} -{{- end -}} -{{- $replicas := int $rawReplicas -}} -{{- if or (lt $replicas 1) (gt $replicas 100) -}} -{{- fail "rag.replicas must be between 1 and 100" -}} -{{- end -}} -{{- $rawPort := "8001" -}} -{{- if hasKey $rag "port" -}}{{- $rawPort = get $rag "port" | toString -}}{{- end -}} -{{- if not (regexMatch "^[0-9]+$" $rawPort) -}} -{{- fail "rag.port must be an integer between 1 and 65535" -}} -{{- end -}} -{{- $port := int $rawPort -}} -{{- if or (lt $port 1) (gt $port 65535) -}} -{{- fail "rag.port must be between 1 and 65535" -}} -{{- end -}} -{{- $service := get $rag "service" | default dict -}} -{{- $serviceType := include "hugegraph.rag.serviceType" . -}} -{{- if not (has $serviceType (list "ClusterIP" "NodePort" "LoadBalancer")) -}} -{{- fail "rag.service.type must be ClusterIP, NodePort or LoadBalancer" -}} -{{- end -}} -{{- $rawNodePort := get $service "nodePort" | default 0 | toString -}} -{{- if and (ne $rawNodePort "0") (not (regexMatch "^[0-9]+$" $rawNodePort)) -}} -{{- fail "rag.service.nodePort must be an integer" -}} -{{- end -}} -{{- $nodePort := int $rawNodePort -}} -{{- if and (ne $nodePort 0) (or (lt $nodePort 1) (gt $nodePort 65535)) -}} -{{- fail "rag.service.nodePort must be 0/unset or between 1 and 65535" -}} -{{- end -}} -{{- if and (ne $nodePort 0) (eq $serviceType "ClusterIP") -}} -{{- fail "rag.service.nodePort requires service.type NodePort or LoadBalancer" -}} -{{- end -}} -{{- $graph := get $rag "hugegraph" | default dict -}} -{{- $password := get $graph "passwordSecret" | default dict -}} -{{- if and (trim (get $password "key" | default "")) (not (trim (get $password "name" | default ""))) -}} -{{- fail "rag.hugegraph.passwordSecret.key requires passwordSecret.name" -}} -{{- end -}} -{{- $persistence := get $rag "persistence" | default dict -}} -{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} -{{- $mountPath := include "hugegraph.rag.mountPath" . -}} -{{- if or (not $mountPath) (not (hasPrefix "/" $mountPath)) -}} -{{- fail "rag.persistence.mountPath must be an absolute path" -}} -{{- end -}} -{{- $accessModes := get $persistence "accessModes" | default (list "ReadWriteOnce") -}} -{{- if and (gt $replicas 1) (not (get $persistence "existingClaim" | default "")) (not (has "ReadWriteMany" $accessModes)) -}} -{{- fail "rag.persistence.accessModes must include ReadWriteMany when the chart creates one claim for multiple RAG replicas; otherwise use an existing shared claim or keep rag.replicas=1" -}} -{{- end -}} -{{- end -}} -{{- $podContext := get $rag "podSecurityContext" | default dict -}} -{{- $containerContext := get $rag "securityContext" | default dict -}} -{{- range $scope, $context := dict "rag.podSecurityContext" $podContext "rag.securityContext" $containerContext -}} -{{- if and (hasKey $context "runAsNonRoot") (not (get $context "runAsNonRoot")) -}} -{{- fail (printf "%s.runAsNonRoot must stay true for the HugeGraph RAG image" $scope) -}} -{{- end -}} -{{- range $field := list "runAsUser" "runAsGroup" -}} -{{- if hasKey $context $field -}} -{{- $raw := get $context $field | toString -}} -{{- if not (regexMatch "^[1-9][0-9]*$" $raw) -}} -{{- fail (printf "%s.%s must be a positive integer" $scope $field) -}} -{{- end -}} -{{- end -}} -{{- end -}} -{{- end -}} -{{- if hasKey $podContext "fsGroup" -}} -{{- $rawFsGroup := get $podContext "fsGroup" | toString -}} -{{- if not (regexMatch "^[1-9][0-9]*$" $rawFsGroup) -}} -{{- fail "rag.podSecurityContext.fsGroup must be a positive integer" -}} -{{- end -}} -{{- end -}} -{{- if and (hasKey $containerContext "allowPrivilegeEscalation") (get $containerContext "allowPrivilegeEscalation") -}} -{{- fail "rag.securityContext.allowPrivilegeEscalation must stay false" -}} -{{- end -}} -{{- $podLabels := get $rag "podLabels" | default dict -}} -{{- range $reserved := list "app.kubernetes.io/name" "app.kubernetes.io/instance" "app.kubernetes.io/component" -}} -{{- if hasKey $podLabels $reserved -}} -{{- fail (printf "rag.podLabels must not set %s because the chart owns workload selectors" $reserved) -}} -{{- end -}} -{{- end -}} -{{- range $key, $_ := get $rag "podAnnotations" | default dict -}} -{{- if hasPrefix "checksum/" $key -}} -{{- fail (printf "rag.podAnnotations must not set %s because the chart owns checksum annotations" $key) -}} -{{- end -}} -{{- end -}} -{{- $seenEnv := dict -}} -{{- range $index, $entry := get $rag "env" | default list -}} -{{- $name := get $entry "name" | default "" | toString -}} -{{- if not $name -}} -{{- fail (printf "rag.env[%d].name must not be empty" $index) -}} -{{- end -}} -{{- if hasKey $seenEnv $name -}} -{{- fail (printf "rag.env contains duplicate name %s" $name) -}} -{{- end -}} -{{- $_ := set $seenEnv $name true -}} -{{- end -}} -{{- end -}} -{{- end }} diff --git a/helm/hugegraph/templates/_rag_security.tpl b/helm/hugegraph/templates/_rag_security.tpl new file mode 100644 index 0000000000..fd46ccf1c2 --- /dev/null +++ b/helm/hugegraph/templates/_rag_security.tpl @@ -0,0 +1,50 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.podSecurityContext" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 1000 + "runAsGroup" 1000 + "fsGroup" 1000 + "fsGroupChangePolicy" "OnRootMismatch" + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "podSecurityContext" | default dict)) -}} +{{- range $field := list "runAsUser" "runAsGroup" "fsGroup" -}} +{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} +{{- end -}} +{{- toYaml $context -}} +{{- end }} + +{{- define "hugegraph.rag.securityContext" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 1000 + "runAsGroup" 1000 + "privileged" false + "allowPrivilegeEscalation" false + "capabilities" (dict "drop" (list "ALL")) + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "securityContext" | default dict)) -}} +{{- range $field := list "runAsUser" "runAsGroup" -}} +{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} +{{- end -}} +{{- toYaml $context -}} +{{- end }} + diff --git a/helm/hugegraph/templates/_rag_validate.tpl b/helm/hugegraph/templates/_rag_validate.tpl new file mode 100644 index 0000000000..13952d327f --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate.tpl @@ -0,0 +1,26 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.validate" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- if get $rag "enabled" | default false -}} +{{- include "hugegraph.rag.validate.fields" . -}} +{{- include "hugegraph.rag.validate.runtime" . -}} +{{- include "hugegraph.rag.validate.security" . -}} +{{- include "hugegraph.rag.validate.metadata" . -}} +{{- end -}} +{{- end }} diff --git a/helm/hugegraph/templates/_rag_validate_fields.tpl b/helm/hugegraph/templates/_rag_validate_fields.tpl new file mode 100644 index 0000000000..7a57903923 --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_fields.tpl @@ -0,0 +1,86 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.validate.fields" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $allowedRagKeys := list "enabled" "replicas" "image" "port" "hugegraph" "resources" "initResources" "podSecurityContext" "securityContext" "nodeSelector" "tolerations" "affinity" "topologySpreadConstraints" "priorityClassName" "podAnnotations" "podLabels" "env" "envFrom" "terminationGracePeriodSeconds" "serviceAccount" "service" "ingress" "persistence" "extraVolumes" "extraVolumeMounts" "probes" "networkPolicy" -}} +{{- range $key, $_ := $rag -}} +{{- if not (has $key $allowedRagKeys) -}} +{{- fail (printf "rag contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $image := get $rag "image" | default dict -}} +{{- range $key, $_ := $image -}} +{{- if not (has $key (list "repository" "tag" "digest" "pullPolicy")) -}} +{{- fail (printf "rag.image contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $graphKeys := list "autoConfigure" "url" "graphName" "graphSpace" "username" "passwordSecret" -}} +{{- $graphForKeys := get $rag "hugegraph" | default dict -}} +{{- range $key, $_ := $graphForKeys -}} +{{- if not (has $key $graphKeys) -}} +{{- fail (printf "rag.hugegraph contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $passwordForKeys := get $graphForKeys "passwordSecret" | default dict -}} +{{- range $key, $_ := $passwordForKeys -}} +{{- if not (has $key (list "name" "key")) -}} +{{- fail (printf "rag.hugegraph.passwordSecret contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $serviceForKeys := get $rag "service" | default dict -}} +{{- range $key, $_ := $serviceForKeys -}} +{{- if not (has $key (list "type" "nodePort" "annotations")) -}} +{{- fail (printf "rag.service contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $serviceAccountForKeys := get $rag "serviceAccount" | default dict -}} +{{- range $key, $_ := $serviceAccountForKeys -}} +{{- if not (has $key (list "create" "name" "annotations" "automountServiceAccountToken")) -}} +{{- fail (printf "rag.serviceAccount contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $persistenceForKeys := get $rag "persistence" | default dict -}} +{{- range $key, $_ := $persistenceForKeys -}} +{{- if not (has $key (list "enabled" "existingClaim" "size" "storageClassName" "accessModes" "mountPath")) -}} +{{- fail (printf "rag.persistence contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $ingressForKeys := get $rag "ingress" | default dict -}} +{{- range $key, $_ := $ingressForKeys -}} +{{- if not (has $key (list "enabled" "className" "annotations" "hosts" "tls")) -}} +{{- fail (printf "rag.ingress contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $networkPolicyForKeys := get $rag "networkPolicy" | default dict -}} +{{- range $key, $_ := $networkPolicyForKeys -}} +{{- if not (has $key (list "extraIngress" "extraEgress")) -}} +{{- fail (printf "rag.networkPolicy contains unsupported field %s" $key) -}} +{{- end -}} +{{- end -}} +{{- $probesForKeys := get $rag "probes" | default dict -}} +{{- range $key, $probe := $probesForKeys -}} +{{- if not (has $key (list "startup" "readiness" "liveness")) -}} +{{- fail (printf "rag.probes contains unsupported field %s" $key) -}} +{{- end -}} +{{- range $probeKey, $_ := $probe -}} +{{- if not (has $probeKey (list "path" "failureThreshold" "periodSeconds" "timeoutSeconds" "initialDelaySeconds" "successThreshold")) -}} +{{- fail (printf "rag.probes.%s contains unsupported field %s" $key $probeKey) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/helm/hugegraph/templates/_rag_validate_metadata.tpl b/helm/hugegraph/templates/_rag_validate_metadata.tpl new file mode 100644 index 0000000000..e2057ca6ed --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_metadata.tpl @@ -0,0 +1,42 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.validate.metadata" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $podLabels := get $rag "podLabels" | default dict -}} +{{- range $reserved := list "app.kubernetes.io/name" "app.kubernetes.io/instance" "app.kubernetes.io/component" -}} +{{- if hasKey $podLabels $reserved -}} +{{- fail (printf "rag.podLabels must not set %s because the chart owns workload selectors" $reserved) -}} +{{- end -}} +{{- end -}} +{{- range $key, $_ := get $rag "podAnnotations" | default dict -}} +{{- if hasPrefix "checksum/" $key -}} +{{- fail (printf "rag.podAnnotations must not set %s because the chart owns checksum annotations" $key) -}} +{{- end -}} +{{- end -}} +{{- $seenEnv := dict -}} +{{- range $index, $entry := get $rag "env" | default list -}} +{{- $name := get $entry "name" | default "" | toString -}} +{{- if not $name -}} +{{- fail (printf "rag.env[%d].name must not be empty" $index) -}} +{{- end -}} +{{- if hasKey $seenEnv $name -}} +{{- fail (printf "rag.env contains duplicate name %s" $name) -}} +{{- end -}} +{{- $_ := set $seenEnv $name true -}} +{{- end -}} +{{- end }} diff --git a/helm/hugegraph/templates/_rag_validate_runtime.tpl b/helm/hugegraph/templates/_rag_validate_runtime.tpl new file mode 100644 index 0000000000..ff4cb7fd6d --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_runtime.tpl @@ -0,0 +1,66 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.validate.runtime" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $rawReplicas := "1" -}} +{{- if hasKey $rag "replicas" -}}{{- $rawReplicas = get $rag "replicas" | toString -}}{{- end -}} +{{- if not (regexMatch "^[0-9]+$" $rawReplicas) -}} +{{- fail "rag.replicas must be the integer 1" -}} +{{- end -}} +{{- $replicas := int $rawReplicas -}} +{{- if ne $replicas 1 -}} +{{- fail "rag.replicas must be 1: the current HugeGraph RAG runtime keeps mutable configuration in one process and one writable resource tree, so multi-replica deployment is not supported" -}} +{{- end -}} +{{- $rawPort := "8001" -}} +{{- if hasKey $rag "port" -}}{{- $rawPort = get $rag "port" | toString -}}{{- end -}} +{{- if not (regexMatch "^[0-9]+$" $rawPort) -}} +{{- fail "rag.port must be an integer between 1 and 65535" -}} +{{- end -}} +{{- $port := int $rawPort -}} +{{- if or (lt $port 1) (gt $port 65535) -}} +{{- fail "rag.port must be between 1 and 65535" -}} +{{- end -}} +{{- $service := get $rag "service" | default dict -}} +{{- $serviceType := include "hugegraph.rag.serviceType" . -}} +{{- if not (has $serviceType (list "ClusterIP" "NodePort" "LoadBalancer")) -}} +{{- fail "rag.service.type must be ClusterIP, NodePort or LoadBalancer" -}} +{{- end -}} +{{- $rawNodePort := get $service "nodePort" | default 0 | toString -}} +{{- if and (ne $rawNodePort "0") (not (regexMatch "^[0-9]+$" $rawNodePort)) -}} +{{- fail "rag.service.nodePort must be an integer" -}} +{{- end -}} +{{- $nodePort := int $rawNodePort -}} +{{- if and (ne $nodePort 0) (or (lt $nodePort 1) (gt $nodePort 65535)) -}} +{{- fail "rag.service.nodePort must be 0/unset or between 1 and 65535" -}} +{{- end -}} +{{- if and (ne $nodePort 0) (eq $serviceType "ClusterIP") -}} +{{- fail "rag.service.nodePort requires service.type NodePort or LoadBalancer" -}} +{{- end -}} +{{- $graph := get $rag "hugegraph" | default dict -}} +{{- $password := get $graph "passwordSecret" | default dict -}} +{{- if and (trim (get $password "key" | default "")) (not (trim (get $password "name" | default ""))) -}} +{{- fail "rag.hugegraph.passwordSecret.key requires passwordSecret.name" -}} +{{- end -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} +{{- $mountPath := include "hugegraph.rag.mountPath" . -}} +{{- if or (not $mountPath) (not (hasPrefix "/" $mountPath)) -}} +{{- fail "rag.persistence.mountPath must be an absolute path" -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/helm/hugegraph/templates/_rag_validate_security.tpl b/helm/hugegraph/templates/_rag_validate_security.tpl new file mode 100644 index 0000000000..2bf76e2257 --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_security.tpl @@ -0,0 +1,98 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- define "hugegraph.rag.validate.security" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $podContext := get $rag "podSecurityContext" | default dict -}} +{{- $containerContext := get $rag "securityContext" | default dict -}} +{{- range $scope, $context := dict "rag.podSecurityContext" $podContext "rag.securityContext" $containerContext -}} +{{- if hasKey $context "runAsNonRoot" -}} +{{- if not (kindIs "bool" (get $context "runAsNonRoot")) -}} +{{- fail (printf "%s.runAsNonRoot must be a boolean" $scope) -}} +{{- end -}} +{{- if not (get $context "runAsNonRoot") -}} +{{- fail (printf "%s.runAsNonRoot must stay true for the HugeGraph RAG image" $scope) -}} +{{- end -}} +{{- end -}} +{{- range $field := list "runAsUser" "runAsGroup" -}} +{{- if hasKey $context $field -}} +{{- $raw := get $context $field | toString -}} +{{- if not (regexMatch "^[1-9][0-9]*$" $raw) -}} +{{- fail (printf "%s.%s must be a positive integer" $scope $field) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if hasKey $context "seccompProfile" -}} +{{- $seccomp := get $context "seccompProfile" -}} +{{- if not (kindIs "map" $seccomp) -}} +{{- fail (printf "%s.seccompProfile must be an object" $scope) -}} +{{- end -}} +{{- if ne (get $seccomp "type" | default "" | toString) "RuntimeDefault" -}} +{{- fail (printf "%s.seccompProfile.type must stay RuntimeDefault" $scope) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if hasKey $podContext "fsGroup" -}} +{{- $rawFsGroup := get $podContext "fsGroup" | toString -}} +{{- if not (regexMatch "^[1-9][0-9]*$" $rawFsGroup) -}} +{{- fail "rag.podSecurityContext.fsGroup must be a positive integer" -}} +{{- end -}} +{{- end -}} +{{- range $field := list "privileged" "allowPrivilegeEscalation" -}} +{{- if hasKey $containerContext $field -}} +{{- if not (kindIs "bool" (get $containerContext $field)) -}} +{{- fail (printf "rag.securityContext.%s must be a boolean" $field) -}} +{{- end -}} +{{- if get $containerContext $field -}} +{{- fail (printf "rag.securityContext.%s must stay false" $field) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if hasKey $containerContext "capabilities" -}} +{{- $capabilities := get $containerContext "capabilities" -}} +{{- if not (kindIs "map" $capabilities) -}} +{{- fail "rag.securityContext.capabilities must be an object" -}} +{{- end -}} +{{- if hasKey $capabilities "add" -}} +{{- $added := get $capabilities "add" -}} +{{- if not (kindIs "slice" $added) -}} +{{- fail "rag.securityContext.capabilities.add must be an array" -}} +{{- end -}} +{{- if gt (len $added) 0 -}} +{{- fail "rag.securityContext.capabilities.add must stay empty" -}} +{{- end -}} +{{- end -}} +{{- if hasKey $capabilities "drop" -}} +{{- $dropped := get $capabilities "drop" -}} +{{- if not (kindIs "slice" $dropped) -}} +{{- fail "rag.securityContext.capabilities.drop must be an array" -}} +{{- end -}} +{{- if not (has "ALL" $dropped) -}} +{{- fail "rag.securityContext.capabilities.drop must include ALL" -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- $serviceAccount := get $rag "serviceAccount" | default dict -}} +{{- if hasKey $serviceAccount "automountServiceAccountToken" -}} +{{- if not (kindIs "bool" (get $serviceAccount "automountServiceAccountToken")) -}} +{{- fail "rag.serviceAccount.automountServiceAccountToken must be a boolean" -}} +{{- end -}} +{{- if get $serviceAccount "automountServiceAccountToken" -}} +{{- fail "rag.serviceAccount.automountServiceAccountToken must stay false" -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 556de2ea67..d14b9d78c3 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -40,8 +40,10 @@ metadata: app.kubernetes.io/component: rag spec: replicas: {{ include "hugegraph.rag.replicas" . | int }} + # The application keeps mutable runtime configuration in-process. Recreate + # preserves the chart's single-active-pod contract even when persistence is off. strategy: - type: {{ ternary "Recreate" "RollingUpdate" (and $persistenceEnabled (eq (include "hugegraph.rag.replicas" . | int) 1)) }} + type: Recreate selector: matchLabels: {{- include "hugegraph.selectorLabels" . | nindent 6 }} diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml index 84bbe69ef6..1244d30f9c 100644 --- a/helm/hugegraph/tests/rag_component_test.yaml +++ b/helm/hugegraph/tests/rag_component_test.yaml @@ -23,7 +23,7 @@ tests: - hasDocuments: count: 0 - - it: connects a persistent non-root RAG pod to the same-release Server + - it: connects one persistent non-root RAG pod to the same-release Server template: rag-deployment.yaml set: rag.enabled: true @@ -45,6 +45,12 @@ tests: - equal: path: spec.template.spec.securityContext.fsGroup value: 1000 + - equal: + path: spec.template.spec.securityContext.seccompProfile.type + value: RuntimeDefault + - equal: + path: spec.template.spec.automountServiceAccountToken + value: false - equal: path: spec.template.spec.initContainers[0].name value: seed-resources @@ -54,6 +60,12 @@ tests: - equal: path: spec.template.spec.containers[0].image value: hugegraph/rag:latest + - equal: + path: spec.template.spec.containers[0].securityContext.privileged + value: false + - equal: + path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation + value: false - equal: path: spec.template.spec.containers[0].command value: @@ -90,7 +102,7 @@ tests: path: spec.template.spec.containers[0].env content: name: HUGEGRAPH_LLM_ENV_PATH - value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/.env + value: /tmp/hugegraph-llm.env - contains: path: spec.template.spec.containers[0].env content: @@ -147,7 +159,7 @@ tests: name: rag-credentials key: password - - it: supports an explicitly disposable RAG pod + - it: supports an explicitly disposable RAG pod without creating overlap template: rag-deployment.yaml set: rag.enabled: true @@ -155,7 +167,7 @@ tests: asserts: - equal: path: spec.strategy.type - value: RollingUpdate + value: Recreate - notExists: path: spec.template.spec.initContainers - contains: diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml index a008b0e7e9..50b587434b 100644 --- a/helm/hugegraph/values-rag.yaml +++ b/helm/hugegraph/values-rag.yaml @@ -33,6 +33,10 @@ rag: hugegraph: autoConfigure: true + # The current RAG application is intentionally single-replica: mutable + # settings live in one process and prompts/resources are writable. The chart + # rejects rag.replicas other than 1 and uses a Recreate deployment strategy. + # The main RAG process has no universal resource profile because model, # embedding and indexing workloads vary widely. Size it for the selected # providers and dataset. @@ -45,8 +49,10 @@ rag: cpu: 100m memory: 64Mi - # Safe non-root UID/GID/fsGroup 1000 and RuntimeDefault seccomp are built - # into the templates, including bare --set rag.enabled=true installs. + # Non-root execution, RuntimeDefault seccomp, no added capabilities, disabled + # privilege escalation and no ServiceAccount token are enforced by templates, + # including bare --set rag.enabled=true installs. UID/GID/fsGroup default to + # 1000 and custom values must remain positive non-root IDs. nodeSelector: {} tolerations: [] affinity: {} @@ -82,7 +88,9 @@ rag: tls: [] # Persistence defaults on when RAG is enabled. The claim stores the editable - # resource tree, generated .env model/graph settings and prompt YAML. The + # resource tree, prompts, schema examples and indexes. The generated .env is + # deliberately kept in /tmp so a stale file cannot override rotated graph or + # model credentials supplied through Kubernetes Secrets after a restart. The # init container adds missing files from a new image but never overwrites # files already changed on the claim. persistence: diff --git a/helm/rag_tests/__init__.py b/helm/rag_tests/__init__.py new file mode 100644 index 0000000000..de87732428 --- /dev/null +++ b/helm/rag_tests/__init__.py @@ -0,0 +1,5 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0. +"""Support modules for the optional RAG Helm contract.""" diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py new file mode 100644 index 0000000000..816f54e331 --- /dev/null +++ b/helm/rag_tests/basic.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + +from .support import RAG_ENV, RAG_MOUNT, component_resources, env_by_name, pod + + +class RagBasicMixin: + def test_disabled_adds_no_resources(self): + documents = self.documents() + self.assertEqual([], component_resources(documents)) + self.assertFalse(any("-rag" in document["metadata"]["name"] for document in documents)) + + def test_bare_enable_has_safe_persistent_defaults(self): + documents = self.documents({}, use_profile=False) + deployment = self.deployment(documents) + container = self.container(documents) + service, = component_resources(documents, kind="Service") + pvc, = component_resources(documents, kind="PersistentVolumeClaim") + + self.assertEqual("hugegraph/rag:latest", container["image"]) + self.assertEqual(1, deployment["spec"]["replicas"]) + self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) + self.assertEqual(8001, container["ports"][0]["containerPort"]) + self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) + self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) + self.assertEqual("ClusterIP", service["spec"]["type"]) + self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) + + pod_security = pod(deployment)["spec"]["securityContext"] + self.assertTrue(pod_security["runAsNonRoot"]) + self.assertEqual(1000, pod_security["runAsUser"]) + self.assertEqual(1000, pod_security["runAsGroup"]) + self.assertEqual(1000, pod_security["fsGroup"]) + self.assertEqual("RuntimeDefault", pod_security["seccompProfile"]["type"]) + self.assertFalse(container["securityContext"]["privileged"]) + self.assertFalse(container["securityContext"]["allowPrivilegeEscalation"]) + self.assertEqual(["ALL"], container["securityContext"]["capabilities"]["drop"]) + self.assertFalse(pod(deployment)["spec"]["automountServiceAccountToken"]) + + environment = env_by_name(container) + self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{RAG_MOUNT}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) + + def test_default_profile_uses_internal_server_contract(self): + documents = self.documents({}) + deployment = self.deployment(documents) + container = self.container(documents) + service, = component_resources(documents, kind="Service") + environment = env_by_name(container) + + self.assertEqual("fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"]) + self.assertEqual("hugegraph", environment["GRAPH_NAME"]["value"]) + self.assertEqual("DEFAULT", environment["GRAPH_SPACE"]["value"]) + self.assertEqual("admin", environment["GRAPH_USER"]["value"]) + self.assertEqual("fixture-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + self.assertEqual("password", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) + self.assertIn("checksum/hugegraph-auth", pod(deployment)["metadata"]["annotations"]) + self.assertEqual("ClusterIP", service["spec"]["type"]) + + def test_custom_port_reaches_process_pod_service_and_probes(self): + documents = self.documents({"port": 9001}) + container = self.container(documents) + service, = component_resources(documents, kind="Service") + self.assertEqual("9001", container["args"][3]) + self.assertEqual(9001, container["ports"][0]["containerPort"]) + self.assertEqual(9001, service["spec"]["ports"][0]["port"]) + for name in ("startupProbe", "readinessProbe", "livenessProbe"): + self.assertEqual("http", container[name]["httpGet"]["port"]) + diff --git a/helm/rag_tests/source_contract.py b/helm/rag_tests/source_contract.py new file mode 100644 index 0000000000..5e60e9de9f --- /dev/null +++ b/helm/rag_tests/source_contract.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + +import unittest + +import yaml + +from .support import CHART, RAG_ENV, RAG_MOUNT + + +class RagSourceContractTest(unittest.TestCase): + def test_profile_is_internal_persistent_and_matches_the_image_contract(self): + values = yaml.safe_load((CHART / "values-rag.yaml").read_text(encoding="utf-8")) + rag = values["rag"] + self.assertTrue(rag["enabled"]) + self.assertNotIn("replicas", rag, "topology presets own the single replica value") + self.assertEqual("hugegraph/rag", rag["image"]["repository"]) + self.assertEqual(8001, rag["port"]) + self.assertEqual({"autoConfigure": True}, rag["hugegraph"]) + self.assertEqual("ClusterIP", rag["service"]["type"]) + self.assertTrue(rag["persistence"]["enabled"]) + self.assertEqual(RAG_MOUNT, rag["persistence"]["mountPath"]) + + def test_single_and_cluster_presets_keep_one_persistent_rag_replica(self): + single = yaml.safe_load((CHART / "values-single.yaml").read_text(encoding="utf-8")) + cluster = yaml.safe_load((CHART / "values-cluster.yaml").read_text(encoding="utf-8")) + self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) + self.assertEqual({"enabled": False, "replicas": 1}, cluster["rag"]) + + def test_template_validation_publishes_the_single_replica_and_key_contract(self): + helpers = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((CHART / "templates").glob("_rag*.tpl")) + ) + self.assertIn("rag.replicas must be 1", helpers) + self.assertIn("rag contains unsupported field", helpers) + self.assertIn("rag.persistence contains unsupported field", helpers) + + def test_templates_use_one_process_port_and_split_runtime_from_durable_config(self): + deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") + service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") + helpers = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((CHART / "templates").glob("_rag*.tpl")) + ) + self.assertIn("hugegraph_llm.demo.rag_demo.app", deployment) + self.assertIn('containerPort: {{ include "hugegraph.rag.port" . | int }}', deployment) + self.assertIn('include "hugegraph.rag.port" . | int | quote', deployment) + self.assertIn("targetPort: http", service) + self.assertIn('include "hugegraph.server.clientUrl"', helpers) + self.assertIn('trimPrefix "http://"', helpers) + self.assertIn(RAG_ENV, helpers) + self.assertIn("HUGEGRAPH_LLM_ENV_PATH", deployment) + self.assertIn("HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", deployment) + self.assertNotIn("touch /data/.hugegraph-rag-seeded", deployment) + diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py new file mode 100644 index 0000000000..0a8436aa94 --- /dev/null +++ b/helm/rag_tests/state.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + +from .support import RAG_ENV, RAG_MOUNT, component_resources, env_by_name, pod + + +class RagStateMixin: + def test_persistence_seeds_resources_but_keeps_runtime_env_ephemeral(self): + documents = self.documents({"persistence": {"enabled": True}}) + deployment = self.deployment(documents) + pvc, = component_resources(documents, kind="PersistentVolumeClaim") + self.assertEqual("fixture-hugegraph-rag-data", pvc["metadata"]["name"]) + self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) + init, = pod(deployment)["spec"]["initContainers"] + self.assertIn("cp -a -n", init["args"][0]) + self.assertNotIn(".hugegraph-rag-seeded", init["args"][0]) + container = self.container(documents) + mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] + self.assertEqual(RAG_MOUNT, mount["mountPath"]) + environment = env_by_name(container) + self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{RAG_MOUNT}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) + + def test_custom_mount_path_updates_only_the_durable_prompt_path(self): + mount_path = "/var/lib/hugegraph-rag" + documents = self.documents({"persistence": {"mountPath": mount_path}}) + container = self.container(documents) + environment = env_by_name(container) + self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{mount_path}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) + mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] + self.assertEqual(mount_path, mount["mountPath"]) + + def test_persistence_can_be_disabled_for_disposable_runs(self): + documents = self.documents({"persistence": {"enabled": False}}) + deployment = self.deployment(documents) + self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) + self.assertEqual([], component_resources(documents, kind="PersistentVolumeClaim")) + self.assertNotIn("initContainers", pod(deployment)["spec"]) + container = self.container(documents) + self.assertFalse(any(item["name"] == "data" for item in container.get("volumeMounts", []))) + environment = env_by_name(container) + self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + f"{RAG_MOUNT}/demo/config_prompt.yaml", + environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], + ) + + def test_multiple_replicas_are_rejected_even_with_shared_storage(self): + for persistence in ( + {"enabled": False}, + {"enabled": True, "accessModes": ["ReadWriteMany"]}, + {"enabled": True, "existingClaim": "shared-rag-resources"}, + ): + with self.subTest(persistence=persistence): + result = self.render({"replicas": 2, "persistence": persistence}) + self.assertNotEqual(0, result.returncode) + self.assertIn("rag.replicas", result.stderr) + + def test_strict_network_policy_allows_only_scoped_server_and_dns_rules(self): + documents = self.documents({}, mode="strict") + policies = component_resources(documents, kind="NetworkPolicy") + self.assertEqual(2, len(policies)) + rag_policy = next( + policy for policy in policies + if policy["spec"]["podSelector"]["matchLabels"]["app.kubernetes.io/component"] == "rag" + ) + server_allow = next(policy for policy in policies if policy is not rag_policy) + self.assertEqual( + "server", + server_allow["spec"]["podSelector"]["matchLabels"]["app.kubernetes.io/component"], + ) + + egress = rag_policy["spec"]["egress"] + self.assertTrue(any( + peer.get("podSelector", {}).get("matchLabels", {}).get("app.kubernetes.io/component") == "server" + for rule in egress + for peer in rule.get("to", []) + )) + for rule in egress: + if rule.get("to"): + continue + ports = {(item.get("protocol"), item.get("port")) for item in rule.get("ports", [])} + self.assertEqual( + {("UDP", 53), ("TCP", 53)}, + ports, + "Only the DNS rule may omit destination peers; an empty rule opens all egress", + ) + + documents = self.documents({"hugegraph": { + "autoConfigure": True, + "url": "http://external:8080", + }}, mode="strict") + policy, = component_resources(documents, kind="NetworkPolicy") + self.assertFalse(any( + peer.get("podSelector", {}).get("matchLabels", {}).get("app.kubernetes.io/component") == "server" + for rule in policy["spec"]["egress"] + for peer in rule.get("to", []) + )) + for rule in policy["spec"]["egress"]: + if rule.get("to"): + continue + ports = {(item.get("protocol"), item.get("port")) for item in rule.get("ports", [])} + self.assertEqual({("UDP", 53), ("TCP", 53)}, ports) + + def test_global_registry_and_digest_render(self): + digest = "sha256:" + "a" * 64 + documents = self.documents( + {"image": {"repository": "hugegraph/rag", "tag": "ignored", "digest": digest}}, + **{"global": {"imageRegistry": "registry.example.com"}}, + ) + expected = f"registry.example.com/hugegraph/rag@{digest}" + deployment = self.deployment(documents) + self.assertEqual(expected, pod(deployment)["spec"]["containers"][0]["image"]) + self.assertEqual(expected, pod(deployment)["spec"]["initContainers"][0]["image"]) + diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py new file mode 100644 index 0000000000..6819da1b5b --- /dev/null +++ b/helm/rag_tests/support.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import yaml + + +CHART = Path(__file__).resolve().parents[1] / "hugegraph" +HELM_TEMPLATE_TIMEOUT_SECONDS = 120 +RAG_MOUNT = "/home/work/hugegraph-llm/src/hugegraph_llm/resources" +RAG_ENV = "/tmp/hugegraph-llm.env" + + +def component_resources(documents, component="rag", kind=None): + return [ + document + for document in documents + if (kind is None or document["kind"] == kind) + and document["metadata"].get("labels", {}).get("app.kubernetes.io/component") == component + ] + + +def pod(workload): + return workload["spec"]["template"] + + +def env_by_name(container): + return {entry["name"]: entry for entry in container.get("env", [])} + + +class RagChartBase(unittest.TestCase): + @classmethod + def setUpClass(cls): + if shutil.which("helm") is None: + raise unittest.SkipTest("Helm is required for rendered chart regression tests") + + def render(self, rag=None, preset=None, mode=None, use_profile=True, **values): + if rag is not None: + values["rag"] = {"enabled": True, **rag} + if mode is not None: + values["mode"] = mode + with tempfile.TemporaryDirectory(prefix="rag-values-") as directory: + configuration = Path(directory) / "values.yaml" + configuration.write_text(yaml.safe_dump(values), encoding="utf-8") + command = ["helm", "template", "fixture", str(CHART), "--namespace", "graphs"] + if preset is not None: + command.extend(["-f", str(CHART / preset)]) + if rag is not None and use_profile: + command.extend(["-f", str(CHART / "values-rag.yaml")]) + command.extend(["-f", str(configuration)]) + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=HELM_TEMPLATE_TIMEOUT_SECONDS, + check=False, + ) + + def documents(self, rag=None, preset=None, mode=None, use_profile=True, **values): + result = self.render(rag, preset=preset, mode=mode, use_profile=use_profile, **values) + self.assertEqual(0, result.returncode, result.stderr) + return [document for document in yaml.safe_load_all(result.stdout) if document] + + def deployment(self, documents): + deployment, = component_resources(documents, kind="Deployment") + return deployment + + def container(self, documents): + container, = pod(self.deployment(documents))["spec"]["containers"] + return container + diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py new file mode 100644 index 0000000000..0fb9a4cf55 --- /dev/null +++ b/helm/rag_tests/validation.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + + +class RagValidationMixin: + def test_invalid_contracts_fail_before_render(self): + invalid = ( + ({"service": {"type": "ExternalName"}}, "service.type"), + ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), + ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), + ({"port": 0}, "rag.port"), + ({"port": 8001.5}, "rag.port"), + ({"replicas": 0}, "rag.replicas"), + ({"replicas": 1.5}, "rag.replicas"), + ({"replicas": 2}, "rag.replicas"), + ({"podSecurityContext": {"runAsUser": 0}}, "runAsUser"), + ({"podSecurityContext": {"fsGroup": 0}}, "fsGroup"), + ({"podSecurityContext": {"seccompProfile": {"type": "Unconfined"}}}, "seccompProfile"), + ({"securityContext": {"privileged": True}}, "privileged"), + ({"securityContext": {"allowPrivilegeEscalation": True}}, "allowPrivilegeEscalation"), + ({"securityContext": {"seccompProfile": {"type": "Unconfined"}}}, "seccompProfile"), + ({"securityContext": {"capabilities": {"add": ["SYS_ADMIN"]}}}, "capabilities.add"), + ({"securityContext": {"capabilities": {"drop": ["NET_RAW"]}}}, "capabilities.drop"), + ({"serviceAccount": {"automountServiceAccountToken": True}}, "automountServiceAccountToken"), + ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), + ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), + ({"env": [{"name": "GRAPH_URL", "value": "a"}, {"name": "GRAPH_URL", "value": "b"}]}, "duplicate"), + ({"persistance": {"enabled": False}}, "persistance"), + ) + for values, expected in invalid: + with self.subTest(values=values): + result = self.render(values) + self.assertNotEqual(0, result.returncode) + self.assertIn(expected, result.stderr) + diff --git a/helm/rag_tests/wiring.py b/helm/rag_tests/wiring.py new file mode 100644 index 0000000000..0e4dc33a4b --- /dev/null +++ b/helm/rag_tests/wiring.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression coverage for the optional HugeGraph RAG Helm component.""" + +from .support import component_resources, env_by_name, pod + + +class RagWiringMixin: + def test_single_and_cluster_presets_share_the_same_graph_contract(self): + for preset in ("values-single.yaml", "values-cluster.yaml"): + with self.subTest(preset=preset): + documents = self.documents({}, preset=preset) + deployment = self.deployment(documents) + environment = env_by_name(self.container(documents)) + self.assertEqual(1, deployment["spec"]["replicas"]) + self.assertEqual( + "fixture-hugegraph-server.graphs.svc:8080", + environment["GRAPH_URL"]["value"], + ) + + def test_service_stays_internal_unless_exposure_is_explicit(self): + for mode in ("easy", "strict"): + with self.subTest(mode=mode): + documents = self.documents({}, mode=mode) + service, = component_resources(documents, kind="Service") + self.assertEqual("ClusterIP", service["spec"]["type"]) + documents = self.documents({"service": {"type": "NodePort", "nodePort": 31001}}, mode="easy") + service, = component_resources(documents, kind="Service") + self.assertEqual("NodePort", service["spec"]["type"]) + self.assertEqual(31001, service["spec"]["ports"][0]["nodePort"]) + + def test_user_environment_replaces_automatic_entries(self): + documents = self.documents({ + "env": [ + {"name": "GRAPH_URL", "value": "external:8080"}, + {"name": "GRAPH_PWD", "valueFrom": {"secretKeyRef": {"name": "external", "key": "pwd"}}}, + ] + }) + environment = env_by_name(self.container(documents)) + self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) + self.assertEqual("external", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + + def test_external_graph_url_in_env_never_inherits_internal_admin_password(self): + documents = self.documents({"env": [{"name": "GRAPH_URL", "value": "external:8080"}]}) + environment = env_by_name(self.container(documents)) + self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) + self.assertNotIn("GRAPH_PWD", environment) + self.assertNotIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"].get("annotations", {})) + + def test_external_server_uses_only_explicit_password_secret(self): + documents = self.documents({"hugegraph": { + "autoConfigure": True, + "url": "http://remote.graphs.svc:8080", + "passwordSecret": {"name": "remote-admin", "key": "credential"}, + }}) + environment = env_by_name(self.container(documents)) + self.assertEqual("http://remote.graphs.svc:8080", environment["GRAPH_URL"]["value"]) + self.assertEqual("remote-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) + self.assertEqual("credential", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) + self.assertIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"]["annotations"]) + + documents = self.documents({"hugegraph": { + "autoConfigure": True, + "url": "http://remote.graphs.svc:8080", + }}) + self.assertNotIn("GRAPH_PWD", env_by_name(self.container(documents))) + + def test_custom_password_secret_defaults_to_password_key(self): + documents = self.documents({"hugegraph": { + "passwordSecret": {"name": "rag-credentials"}, + }}) + password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] + self.assertEqual("rag-credentials", password["name"]) + self.assertEqual("password", password["key"]) + + def test_internal_server_secret_keeps_its_configured_key(self): + documents = self.documents({}, server={"auth": {"admin": {"key": "admin-password"}}}) + password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] + self.assertEqual("admin-password", password["key"]) + diff --git a/helm/test_helm_rag.py b/helm/test_helm_rag.py index 632f04f085..3bb4ae1324 100644 --- a/helm/test_helm_rag.py +++ b/helm/test_helm_rag.py @@ -16,354 +16,32 @@ """Regression coverage for the optional HugeGraph RAG Helm component.""" -from pathlib import Path -import shutil -import subprocess -import tempfile import unittest -import yaml - - -CHART = Path(__file__).resolve().parent / "hugegraph" -HELM_TEMPLATE_TIMEOUT_SECONDS = 120 -RAG_MOUNT = "/home/work/hugegraph-llm/src/hugegraph_llm/resources" - - -def component_resources(documents, component="rag", kind=None): - return [ - document - for document in documents - if (kind is None or document["kind"] == kind) - and document["metadata"].get("labels", {}).get("app.kubernetes.io/component") == component - ] - - -def pod(workload): - return workload["spec"]["template"] - - -def env_by_name(container): - return {entry["name"]: entry for entry in container.get("env", [])} - - -class RagSourceContractTest(unittest.TestCase): - def test_profile_is_internal_persistent_and_matches_the_image_contract(self): - values = yaml.safe_load((CHART / "values-rag.yaml").read_text(encoding="utf-8")) - rag = values["rag"] - self.assertTrue(rag["enabled"]) - self.assertNotIn("replicas", rag, "topology presets own the optional replica count") - self.assertEqual("hugegraph/rag", rag["image"]["repository"]) - self.assertEqual(8001, rag["port"]) - self.assertEqual({"autoConfigure": True}, rag["hugegraph"]) - self.assertEqual("ClusterIP", rag["service"]["type"]) - self.assertTrue(rag["persistence"]["enabled"]) - self.assertEqual(RAG_MOUNT, rag["persistence"]["mountPath"]) - - def test_single_and_cluster_presets_keep_one_persistent_rag_replica(self): - single = yaml.safe_load((CHART / "values-single.yaml").read_text(encoding="utf-8")) - cluster = yaml.safe_load((CHART / "values-cluster.yaml").read_text(encoding="utf-8")) - self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) - self.assertEqual({"enabled": False, "replicas": 1}, cluster["rag"]) - - def test_templates_use_one_process_port_and_persistent_native_paths(self): - deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") - service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") - helpers = (CHART / "templates" / "_rag.tpl").read_text(encoding="utf-8") - self.assertIn("hugegraph_llm.demo.rag_demo.app", deployment) - self.assertIn('containerPort: {{ include "hugegraph.rag.port" . | int }}', deployment) - self.assertIn('include "hugegraph.rag.port" . | int | quote', deployment) - self.assertIn("targetPort: http", service) - self.assertIn('include "hugegraph.server.clientUrl"', helpers) - self.assertIn('trimPrefix "http://"', helpers) - self.assertIn("HUGEGRAPH_LLM_ENV_PATH", deployment) - self.assertIn("HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", deployment) - self.assertNotIn("touch /data/.hugegraph-rag-seeded", deployment) - - -class RagChartTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - if shutil.which("helm") is None: - raise unittest.SkipTest("Helm is required for rendered chart regression tests") - - def render(self, rag=None, preset=None, mode=None, use_profile=True, **values): - if rag is not None: - values["rag"] = {"enabled": True, **rag} - if mode is not None: - values["mode"] = mode - with tempfile.TemporaryDirectory(prefix="rag-values-") as directory: - configuration = Path(directory) / "values.yaml" - configuration.write_text(yaml.safe_dump(values), encoding="utf-8") - command = ["helm", "template", "fixture", str(CHART), "--namespace", "graphs"] - if preset is not None: - command.extend(["-f", str(CHART / preset)]) - if rag is not None and use_profile: - command.extend(["-f", str(CHART / "values-rag.yaml")]) - command.extend(["-f", str(configuration)]) - return subprocess.run( - command, - capture_output=True, - text=True, - timeout=HELM_TEMPLATE_TIMEOUT_SECONDS, - check=False, - ) - - def documents(self, rag=None, preset=None, mode=None, use_profile=True, **values): - result = self.render(rag, preset=preset, mode=mode, use_profile=use_profile, **values) - self.assertEqual(0, result.returncode, result.stderr) - return [document for document in yaml.safe_load_all(result.stdout) if document] - - def deployment(self, documents): - deployment, = component_resources(documents, kind="Deployment") - return deployment - - def container(self, documents): - container, = pod(self.deployment(documents))["spec"]["containers"] - return container - - def test_disabled_adds_no_resources(self): - documents = self.documents() - self.assertEqual([], component_resources(documents)) - self.assertFalse(any("-rag" in document["metadata"]["name"] for document in documents)) - - def test_bare_enable_has_safe_persistent_defaults(self): - documents = self.documents({}, use_profile=False) - deployment = self.deployment(documents) - container = self.container(documents) - service, = component_resources(documents, kind="Service") - pvc, = component_resources(documents, kind="PersistentVolumeClaim") - - self.assertEqual("hugegraph/rag:latest", container["image"]) - self.assertEqual(1, deployment["spec"]["replicas"]) - self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) - self.assertEqual(8001, container["ports"][0]["containerPort"]) - self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) - self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) - self.assertEqual("ClusterIP", service["spec"]["type"]) - self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) - - pod_security = pod(deployment)["spec"]["securityContext"] - self.assertTrue(pod_security["runAsNonRoot"]) - self.assertEqual(1000, pod_security["runAsUser"]) - self.assertEqual(1000, pod_security["runAsGroup"]) - self.assertEqual(1000, pod_security["fsGroup"]) - self.assertFalse(container["securityContext"]["allowPrivilegeEscalation"]) - self.assertEqual(["ALL"], container["securityContext"]["capabilities"]["drop"]) - - environment = env_by_name(container) - self.assertEqual(f"{RAG_MOUNT}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{RAG_MOUNT}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) - - def test_default_profile_uses_internal_server_contract(self): - documents = self.documents({}) - deployment = self.deployment(documents) - container = self.container(documents) - service, = component_resources(documents, kind="Service") - environment = env_by_name(container) - - self.assertEqual("fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"]) - self.assertEqual("hugegraph", environment["GRAPH_NAME"]["value"]) - self.assertEqual("DEFAULT", environment["GRAPH_SPACE"]["value"]) - self.assertEqual("admin", environment["GRAPH_USER"]["value"]) - self.assertEqual("fixture-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) - self.assertEqual("password", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) - self.assertIn("checksum/hugegraph-auth", pod(deployment)["metadata"]["annotations"]) - self.assertEqual("ClusterIP", service["spec"]["type"]) - - def test_custom_port_reaches_process_pod_service_and_probes(self): - documents = self.documents({"port": 9001}) - container = self.container(documents) - service, = component_resources(documents, kind="Service") - self.assertEqual("9001", container["args"][3]) - self.assertEqual(9001, container["ports"][0]["containerPort"]) - self.assertEqual(9001, service["spec"]["ports"][0]["port"]) - for name in ("startupProbe", "readinessProbe", "livenessProbe"): - self.assertEqual("http", container[name]["httpGet"]["port"]) - - def test_single_and_cluster_presets_share_the_same_graph_contract(self): - for preset in ("values-single.yaml", "values-cluster.yaml"): - with self.subTest(preset=preset): - documents = self.documents({}, preset=preset) - deployment = self.deployment(documents) - environment = env_by_name(self.container(documents)) - self.assertEqual(1, deployment["spec"]["replicas"]) - self.assertEqual( - "fixture-hugegraph-server.graphs.svc:8080", - environment["GRAPH_URL"]["value"], - ) - - def test_service_stays_internal_unless_exposure_is_explicit(self): - for mode in ("easy", "strict"): - with self.subTest(mode=mode): - documents = self.documents({}, mode=mode) - service, = component_resources(documents, kind="Service") - self.assertEqual("ClusterIP", service["spec"]["type"]) - documents = self.documents({"service": {"type": "NodePort", "nodePort": 31001}}, mode="easy") - service, = component_resources(documents, kind="Service") - self.assertEqual("NodePort", service["spec"]["type"]) - self.assertEqual(31001, service["spec"]["ports"][0]["nodePort"]) - - def test_user_environment_replaces_automatic_entries(self): - documents = self.documents({ - "env": [ - {"name": "GRAPH_URL", "value": "external:8080"}, - {"name": "GRAPH_PWD", "valueFrom": {"secretKeyRef": {"name": "external", "key": "pwd"}}}, - ] - }) - environment = env_by_name(self.container(documents)) - self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) - self.assertEqual("external", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) - - def test_external_graph_url_in_env_never_inherits_internal_admin_password(self): - documents = self.documents({"env": [{"name": "GRAPH_URL", "value": "external:8080"}]}) - environment = env_by_name(self.container(documents)) - self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) - self.assertNotIn("GRAPH_PWD", environment) - self.assertNotIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"].get("annotations", {})) - - def test_external_server_uses_only_explicit_password_secret(self): - documents = self.documents({"hugegraph": { - "autoConfigure": True, - "url": "http://remote.graphs.svc:8080", - "passwordSecret": {"name": "remote-admin", "key": "credential"}, - }}) - environment = env_by_name(self.container(documents)) - self.assertEqual("http://remote.graphs.svc:8080", environment["GRAPH_URL"]["value"]) - self.assertEqual("remote-admin", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["name"]) - self.assertEqual("credential", environment["GRAPH_PWD"]["valueFrom"]["secretKeyRef"]["key"]) - self.assertIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"]["annotations"]) - - documents = self.documents({"hugegraph": { - "autoConfigure": True, - "url": "http://remote.graphs.svc:8080", - }}) - self.assertNotIn("GRAPH_PWD", env_by_name(self.container(documents))) - - def test_custom_password_secret_defaults_to_password_key(self): - documents = self.documents({"hugegraph": { - "passwordSecret": {"name": "rag-credentials"}, - }}) - password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] - self.assertEqual("rag-credentials", password["name"]) - self.assertEqual("password", password["key"]) - - def test_internal_server_secret_keeps_its_configured_key(self): - documents = self.documents({}, server={"auth": {"admin": {"key": "admin-password"}}}) - password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] - self.assertEqual("admin-password", password["key"]) - - def test_persistence_seeds_and_reuses_all_writable_configuration(self): - documents = self.documents({"persistence": {"enabled": True}}) - deployment = self.deployment(documents) - pvc, = component_resources(documents, kind="PersistentVolumeClaim") - self.assertEqual("fixture-hugegraph-rag-data", pvc["metadata"]["name"]) - self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) - init, = pod(deployment)["spec"]["initContainers"] - self.assertIn("cp -a -n", init["args"][0]) - self.assertNotIn(".hugegraph-rag-seeded", init["args"][0]) - container = self.container(documents) - mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] - self.assertEqual(RAG_MOUNT, mount["mountPath"]) - environment = env_by_name(container) - self.assertEqual(f"{RAG_MOUNT}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{RAG_MOUNT}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) - - def test_custom_mount_path_updates_both_native_config_paths(self): - mount_path = "/var/lib/hugegraph-rag" - documents = self.documents({"persistence": {"mountPath": mount_path}}) - container = self.container(documents) - environment = env_by_name(container) - self.assertEqual(f"{mount_path}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{mount_path}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) - mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] - self.assertEqual(mount_path, mount["mountPath"]) - - def test_persistence_can_be_disabled_for_disposable_runs(self): - documents = self.documents({"persistence": {"enabled": False}}) - deployment = self.deployment(documents) - self.assertEqual([], component_resources(documents, kind="PersistentVolumeClaim")) - self.assertNotIn("initContainers", pod(deployment)["spec"]) - container = self.container(documents) - self.assertFalse(any(item["name"] == "data" for item in container.get("volumeMounts", []))) - environment = env_by_name(container) - self.assertEqual("/tmp/hugegraph-llm.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{RAG_MOUNT}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) - - def test_multiple_generated_persistent_replicas_require_rwx(self): - result = self.render({"replicas": 2, "persistence": {"enabled": True}}) - self.assertNotEqual(0, result.returncode) - self.assertIn("ReadWriteMany", result.stderr) - documents = self.documents({ - "replicas": 2, - "persistence": {"enabled": True, "accessModes": ["ReadWriteMany"]}, - }) - self.assertEqual(2, self.deployment(documents)["spec"]["replicas"]) - - def test_strict_network_policy_allows_internal_server_only(self): - documents = self.documents({}, mode="strict") - policies = component_resources(documents, kind="NetworkPolicy") - self.assertEqual(2, len(policies)) - rag_policy = next(policy for policy in policies if policy["spec"]["podSelector"]["matchLabels"]["app.kubernetes.io/component"] == "rag") - server_allow = next(policy for policy in policies if policy is not rag_policy) - self.assertEqual("server", server_allow["spec"]["podSelector"]["matchLabels"]["app.kubernetes.io/component"]) - self.assertTrue(any(rule.get("to") for rule in rag_policy["spec"]["egress"])) - - documents = self.documents({"hugegraph": { - "autoConfigure": True, - "url": "http://external:8080", - }}, mode="strict") - policy, = component_resources(documents, kind="NetworkPolicy") - self.assertFalse(any( - peer.get("podSelector", {}).get("matchLabels", {}).get("app.kubernetes.io/component") == "server" - for rule in policy["spec"]["egress"] - for peer in rule.get("to", []) - )) - - def test_global_registry_and_digest_render(self): - digest = "sha256:" + "a" * 64 - documents = self.documents( - {"image": {"repository": "hugegraph/rag", "tag": "ignored", "digest": digest}}, - **{"global": {"imageRegistry": "registry.example.com"}}, - ) - expected = f"registry.example.com/hugegraph/rag@{digest}" - deployment = self.deployment(documents) - self.assertEqual(expected, pod(deployment)["spec"]["containers"][0]["image"]) - self.assertEqual(expected, pod(deployment)["spec"]["initContainers"][0]["image"]) - - def test_invalid_contracts_fail_before_render(self): - invalid = ( - ({"service": {"type": "ExternalName"}}, "service.type"), - ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), - ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), - ({"port": 0}, "rag.port"), - ({"port": 8001.5}, "rag.port"), - ({"replicas": 0}, "rag.replicas"), - ({"replicas": 1.5}, "rag.replicas"), - ({"podSecurityContext": {"runAsUser": 0}}, "runAsUser"), - ({"podSecurityContext": {"fsGroup": 0}}, "fsGroup"), - ({"securityContext": {"allowPrivilegeEscalation": True}}, "allowPrivilegeEscalation"), - ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), - ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), - ({"env": [{"name": "GRAPH_URL", "value": "a"}, {"name": "GRAPH_URL", "value": "b"}]}, "duplicate"), - ) - for values, expected in invalid: - with self.subTest(values=values): - result = self.render(values) - self.assertNotEqual(0, result.returncode) - self.assertIn(expected, result.stderr) +try: + from rag_tests.basic import RagBasicMixin + from rag_tests.source_contract import RagSourceContractTest + from rag_tests.state import RagStateMixin + from rag_tests.support import RagChartBase + from rag_tests.validation import RagValidationMixin + from rag_tests.wiring import RagWiringMixin +except ModuleNotFoundError: + from helm.rag_tests.basic import RagBasicMixin + from helm.rag_tests.source_contract import RagSourceContractTest + from helm.rag_tests.state import RagStateMixin + from helm.rag_tests.support import RagChartBase + from helm.rag_tests.validation import RagValidationMixin + from helm.rag_tests.wiring import RagWiringMixin + + +class RagChartTest( + RagBasicMixin, + RagWiringMixin, + RagStateMixin, + RagValidationMixin, + RagChartBase, +): + """Rendered Helm contract assembled from focused behavior mixins.""" if __name__ == "__main__": From 9e4f5e45dd145cdd69fe6d0562b528f767232866 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 04:09:20 +0800 Subject: [PATCH 07/38] probe --- __probe__ | 1 + 1 file changed, 1 insertion(+) create mode 100644 __probe__ diff --git a/__probe__ b/__probe__ new file mode 100644 index 0000000000..24ae15ce97 --- /dev/null +++ b/__probe__ @@ -0,0 +1 @@ +probe \ No newline at end of file From dfc878642dbc9432351d844d7a67ceb0531551c7 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 04:24:31 +0800 Subject: [PATCH 08/38] fix(helm): persist RAG configuration safely Keep the editable HugeGraph AI .env, prompts, indexes and resources on the retained PVC while reconciling current Kubernetes env and envFrom keys before startup so rotated Secrets remain authoritative. Inherit the global persistence switch, support platform-assigned non-root IDs, avoid archive-mode writes to CSI roots, exclude documentation images from the packaged chart, and document the minimal same-release configuration and external editing workflow. --- __probe__ | 1 - helm/hugegraph/.helmignore | 1 + .../docs/images/rag-helm-architecture.svg | 8 +- helm/hugegraph/docs/rag.md | 358 +++++++++++------- helm/hugegraph/templates/_rag.tpl | 22 +- helm/hugegraph/templates/_rag_security.tpl | 30 +- .../templates/_rag_validate_security.tpl | 8 +- helm/hugegraph/templates/rag-deployment.yaml | 39 +- helm/hugegraph/tests/rag_component_test.yaml | 68 +++- helm/hugegraph/values-rag.yaml | 16 +- helm/rag_tests/basic.py | 14 +- helm/rag_tests/source_contract.py | 23 +- helm/rag_tests/state.py | 50 ++- helm/rag_tests/support.py | 5 +- helm/rag_tests/validation.py | 31 +- helm/test_helm_package.py | 3 +- 16 files changed, 444 insertions(+), 233 deletions(-) delete mode 100644 __probe__ diff --git a/__probe__ b/__probe__ deleted file mode 100644 index 24ae15ce97..0000000000 --- a/__probe__ +++ /dev/null @@ -1 +0,0 @@ -probe \ No newline at end of file diff --git a/helm/hugegraph/.helmignore b/helm/hugegraph/.helmignore index e37a0cdfb0..f096f512e8 100644 --- a/helm/hugegraph/.helmignore +++ b/helm/hugegraph/.helmignore @@ -33,6 +33,7 @@ testdata/ # Source documentation diagrams are not runtime chart files. Helm stores these # files in the release Secret; binary assets can exceed Kubernetes' 1 MiB limit. /images/ +docs/images/ # Never package a chart archive inside a chart. *.tgz diff --git a/helm/hugegraph/docs/images/rag-helm-architecture.svg b/helm/hugegraph/docs/images/rag-helm-architecture.svg index ac6706fe41..27a1d18970 100644 --- a/helm/hugegraph/docs/images/rag-helm-architecture.svg +++ b/helm/hugegraph/docs/images/rag-helm-architecture.svg @@ -16,7 +16,7 @@ --> HugeGraph Helm optional RAG integration - One chart manages HugeGraph core services and one optional single-active RAG application for single-node and cluster presets. + One chart manages HugeGraph core services and one optional single-active RAG application with durable resources and declarative environment precedence. @@ -45,7 +45,7 @@ PD1 or 3 replicasmetadata + placement Store1 or 3 replicasHStore data plane Server Servicetopology-neutral DNSauth Secret reused safely - RAG0 or 1 replicaRecreate · ephemeral .env + RAG0 or 1 replicaRecreate · persistent .env @@ -56,8 +56,8 @@ Single preset1 PD + 1 Store + 1 Server Cluster preset3 PD + 3 Store + 3 Server - Separated stateSecret env · retained PVC + Controlled precedenceSecret/env wins · PVC retained - Runtime credentials remain ephemeral; prompts, indexes and resources remain durable on the PVC. + Persistent settings survive restarts; chart-managed and declarative keys are reconciled before startup. diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index d50cb777a6..a98fe27a54 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -1,98 +1,94 @@ # Optional HugeGraph RAG deployment -The main HugeGraph chart owns the Kubernetes topology for the whole system. -`rag.enabled=true` adds the `hugegraph/rag` application from -[hugegraph-ai](https://github.com/apache/hugegraph-ai) without changing the -PD, Store, Server or Hubble topology. RAG remains disabled by default, and the -Docker image and Docker Compose workflows in hugegraph-ai remain independent. +The canonical HugeGraph chart can deploy the `hugegraph/rag` application from +[hugegraph-ai](https://github.com/apache/hugegraph-ai) beside PD, Store, Server +and Hubble. RAG is opt-in and does not change the existing graph topology. -![RAG Helm integration](images/rag-helm-architecture.svg) +![RAG Helm integration](https://raw.githubusercontent.com/apache/hugegraph/master/helm/hugegraph/docs/images/rag-helm-architecture.svg) -## Minimal configuration +## Minimal same-release configuration -For a RAG Pod connected to the HugeGraph Server in the **same Helm release**, the -chart deliberately keeps the required values small: +For the normal case, the only RAG value that must be set is: -1. Enable RAG with `rag.enabled=true` or the supplied `values-rag.yaml` overlay. -2. Provide the selected model-provider credentials and model settings, normally - through a Kubernetes Secret. -3. Set `rag.persistence.storageClassName` or `existingClaim` only when the - cluster has no suitable default StorageClass. +```yaml +rag: + enabled: true +``` + +The chart derives the complete connection to the Server in the same release: + +- `GRAPH_URL` uses the release-scoped Server Service and `server.port`; +- `GRAPH_NAME` defaults to `hugegraph`; +- `GRAPH_SPACE` follows `server.graphSpace`; +- `GRAPH_USER` defaults to `admin`; and +- `GRAPH_PWD` comes from the existing Server administrator Secret when Server + authentication is enabled. + +Do not repeat a Server Pod IP, REST port, graph name, graph space or administrator +Secret for this topology. A custom `rag.port` is also applied consistently to the +RAG process, container, Service and probes. -Do **not** repeat the Server Pod IP, REST port, graph name, graph space or Server -administrator Secret for the normal same-release deployment. The chart derives -them from the Server Service and existing Server values. RAG is deliberately -single-replica in both topology presets, so the ordinary `ReadWriteOnce` claim is -sufficient. +The supplied overlay keeps the ordinary settings in one reviewed file: ```bash -# Single PD + Store + Server, plus one persistent RAG Pod +# One PD + Store + Server, plus one RAG Pod helm upgrade --install hugegraph ./helm/hugegraph \ -n hugegraph --create-namespace \ -f helm/hugegraph/values-single.yaml \ -f helm/hugegraph/values-rag.yaml -# 3 + 3 + 3 HugeGraph cluster, plus one persistent RAG Pod +# 3 + 3 + 3 HugeGraph cluster, plus one RAG Pod helm upgrade --install hugegraph ./helm/hugegraph \ -n hugegraph --create-namespace \ -f helm/hugegraph/values-cluster.yaml \ -f helm/hugegraph/values-rag.yaml ``` -A bare `--set rag.enabled=true` has the same non-root, internal-Service, -single-replica and persistent-resource defaults. The overlay keeps the commonly -reviewed knobs in one file, but no second copy of the Server connection is -required. +When `rag.persistence.enabled` is unset, RAG inherits the top-level +`persistence.enabled` value. The chart default is `true`, so a bare +`--set rag.enabled=true` creates one retained PVC. Set the RAG-specific value only +when this component must differ from the rest of the release. -## Automatic HugeGraph wiring +Model, embedding and prompt settings can be entered through the RAG interface +and retained on the PVC. Kubernetes Secrets remain preferable for declarative +production credentials. -With `rag.hugegraph.autoConfigure=true` and an empty `rag.hugegraph.url`, the -chart injects: - -- `GRAPH_URL` as the same-release Server client Service in the application's - native `host:port` form; -- `GRAPH_NAME=hugegraph`, unless explicitly overridden; -- `GRAPH_SPACE` from `server.graphSpace`; -- `GRAPH_USER=admin`, unless explicitly overridden; and -- `GRAPH_PWD` from the same Server administrator Secret when Server auth is - enabled. +## Automatic HugeGraph wiring and credentials -The chart also passes `rag.port` to the native RAG process. The process, -container port, Service and probes therefore use one resolved port value. - -Any entry in `rag.env` with the same name replaces its automatic counterpart. -When `rag.env` explicitly supplies `GRAPH_URL`, the chart does **not** attach the -same-release administrator password to that endpoint. Supply an explicit -`rag.hugegraph.passwordSecret` or `GRAPH_PWD` for the selected endpoint. Set -`rag.hugegraph.autoConfigure=false` to manage the complete application -environment yourself. +With `rag.hugegraph.autoConfigure=true` and an empty `rag.hugegraph.url`, the +same-release connection above is generated automatically. A matching entry in +`rag.env` replaces its generated counterpart. -The automatic user is convenient for a local or evaluation installation, but it -inherits administrator privileges. Production deployments should create a -dedicated least-privilege HugeGraph user and Secret, then override only the user -and password source while keeping the release-derived Server address: +The automatic `admin` identity is convenient for evaluation but has full Server +privileges. Production deployments should create a dedicated least-privilege +HugeGraph account and override only its identity and Secret while retaining the +automatically derived address: ```yaml rag: enabled: true hugegraph: - autoConfigure: true username: rag-service passwordSecret: name: rag-hugegraph-credentials key: password ``` -When the chart-managed Server administrator Secret changes during a Helm -upgrade, the RAG pod-template checksum changes and RAG rolls out with the new -credential. For an external password Secret, update the Secret and run -`helm upgrade` so its resource version is re-evaluated; changing the Secret alone -does not restart a Pod whose environment was already populated. +The chart-managed Server Secret is covered by a pod-template checksum. An +explicit `rag.hugegraph.passwordSecret` is covered by its live resource version +during `helm upgrade`. In both cases, the restarted Pod uses the new Secret even +when an older value exists in the retained `.env` file. + +When `rag.env` explicitly supplies `GRAPH_URL`, the chart does not attach the +same-release administrator password to that endpoint. Supply an explicit +`passwordSecret` or `GRAPH_PWD` if the selected endpoint requires authentication. +Set `rag.hugegraph.autoConfigure=false` when the complete graph connection is +owned outside the chart. -## Model-provider credentials +## Model-provider Secrets -Keep provider credentials out of values files. Create a Secret and expose it -with `envFrom`: +Keep provider credentials out of values files. Explicit `env` entries are easy +to audit: ```bash kubectl -n hugegraph create secret generic rag-model \ @@ -100,6 +96,24 @@ kubectl -n hugegraph create secret generic rag-model \ --from-literal=OPENAI_EMBEDDING_API_KEY='replace-me' ``` +```yaml +rag: + enabled: true + env: + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: rag-model + key: OPENAI_API_KEY + - name: OPENAI_EMBEDDING_API_KEY + valueFrom: + secretKeyRef: + name: rag-model + key: OPENAI_EMBEDDING_API_KEY +``` + +`envFrom` is also supported without an extra key list: + ```yaml rag: enabled: true @@ -108,53 +122,83 @@ rag: name: rag-model ``` -The application supports other providers through the same native `env` and -`envFrom` mechanisms. The chart does not copy provider credentials into a -ConfigMap or generate a second credential system. Kubernetes injects Secret -values into a container only when the Pod starts, so restart or roll out RAG -after changing a provider Secret. +At every start, any key present in the current Pod environment—including keys +loaded by `envFrom`—takes precedence over the same key in the retained `.env`. +Changing a provider Secret alone does not restart a Pod whose environment was +already populated, so restart RAG after the Secret changes: -The generated application `.env` is intentionally placed at -`/tmp/hugegraph-llm.env`, outside the persistent claim. HugeGraph AI loads an -existing `.env` into the process environment before reading its settings; if -that file were retained, stale graph or model credentials could override newly -rotated Kubernetes Secrets after a restart. +```bash +kubectl -n hugegraph rollout restart deployment/hugegraph-hugegraph-rag +``` + +The exact Deployment name follows the Helm release name. The application may +write credentials to `.env`; treat the RAG PVC as sensitive, restrict access, +use storage encryption where appropriate, and include it in the relevant backup +policy. -## Persistent and externally editable resources +## Persistent and externally editable configuration -Persistence defaults to enabled after RAG is enabled. One claim stores the -application's writable resource tree at: +When persistence is enabled, the claim is mounted at the native HugeGraph AI +resource directory: ```text /home/work/hugegraph-llm/src/hugegraph_llm/resources ``` -The chart separates durable resources from runtime environment settings: +The important writable locations are: ```text -/tmp/hugegraph-llm.env ephemeral model/graph settings -/demo/config_prompt.yaml durable prompts and schema examples -/... durable indexes and other resources +.env model and graph runtime settings +/demo/config_prompt.yaml prompts and graph-schema examples +/... indexes and other generated resources ``` -The effective environment variables are therefore: +The chart sets: ```text -HUGEGRAPH_LLM_ENV_PATH=/tmp/hugegraph-llm.env +HUGEGRAPH_LLM_ENV_PATH=/.env HUGEGRAPH_LLM_PROMPT_CONFIG_PATH=/demo/config_prompt.yaml ``` -A restart or image replacement reuses the persistent resource tree and prompt -configuration, while model and graph settings are reconstructed from the Pod -environment and Kubernetes Secrets. The init container copies only files that -are missing from the claim (`cp -n`); it never overwrites a file already changed -through the UI or by an operator. Consequently, an image upgrade also does not -replace existing prompt/config files with newer packaged defaults. Back up the -claim and update selected files manually when adopting those new defaults. +A restart or image replacement reuses these files. The init container copies +only files missing from the claim and uses an ordinary recursive copy so it does +not attempt privileged ownership or timestamp changes on the CSI volume root. +Existing operator or UI edits are never overwritten. Consequently, an image +upgrade does not replace an existing prompt or configuration file with a newer +packaged default; compare and update selected files manually when adopting those +defaults. + +### Configuration precedence on restart + +HugeGraph AI loads an existing `.env` before Pydantic reads settings. To prevent +an old file from masking a rotated Secret or changed Helm value, the container +performs one narrow reconciliation before starting the application: + +1. Read the configured `.env` file. +2. Remove only keys that are already present in the current Pod environment. +3. Leave every other `.env` key unchanged. +4. Start the native RAG process. + +The result is a simple precedence rule: + +```text +current Kubernetes env / envFrom > retained .env > image defaults +``` + +This covers the automatically generated graph connection, explicit `rag.env`, +and arbitrary Secret or ConfigMap keys supplied through `envFrom` without a +second key registry. UI and operator settings that are not declared in the Pod +environment remain persistent and externally editable. The application may +write the current values back during the run; the same reconciliation happens +before the next start. -The durable files can be inspected or replaced without rebuilding the image. -The following example locates the RAG Pod by labels instead of assuming a -generated name: +A custom `HUGEGRAPH_LLM_ENV_PATH` in `rag.env` follows the same rule. Make that +path writable and mount persistent storage there when it must survive Pod +replacement. + +### Read and update files from outside the Pod + +Locate the Pod through stable labels rather than assuming its generated name: ```bash NS=hugegraph @@ -163,51 +207,64 @@ MOUNT=/home/work/hugegraph-llm/src/hugegraph_llm/resources POD=$(kubectl -n "$NS" get pod \ -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=rag" \ -o jsonpath='{.items[0].metadata.name}') +``` -kubectl -n "$NS" exec "$POD" -- ls -la "$MOUNT" -kubectl -n "$NS" exec -it "$POD" -- sh +Export, edit locally, then replace the files atomically: + +```bash +kubectl -n "$NS" exec "$POD" -- cat "$MOUNT/.env" > rag.env +kubectl -n "$NS" exec "$POD" -- cat "$MOUNT/demo/config_prompt.yaml" \ + > config_prompt.yaml + +# Edit the local files. Do not commit rag.env. +kubectl -n "$NS" exec -i "$POD" -- sh -c \ + 'umask 007; tmp="$1.tmp"; cat > "$tmp" && mv "$tmp" "$1"' sh \ + "$MOUNT/.env" < rag.env +kubectl -n "$NS" exec -i "$POD" -- sh -c \ + 'tmp="$1.tmp"; cat > "$tmp" && mv "$tmp" "$1"' sh \ + "$MOUNT/demo/config_prompt.yaml" < config_prompt.yaml + +kubectl -n "$NS" rollout restart deployment/${RELEASE}-hugegraph-rag ``` -For GitOps or an externally managed resource lifecycle, provision a PVC, seed or -mount it with the desired files, and point the chart at it: +Back up a file before replacing it. Do not print `.env` in shared logs. + +For GitOps, provision and seed a PVC separately, then use it directly: ```yaml rag: enabled: true persistence: - enabled: true existingClaim: managed-rag-resources ``` -`rag.persistence.mountPath` and `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` can be -overridden when another volume layout is required. Overriding -`HUGEGRAPH_LLM_ENV_PATH` to a persistent path is possible through `rag.env`, but -is not recommended: the retained file becomes a higher-priority configuration -source and can mask rotated Secret values. - ## Persistence lifecycle and single-replica contract A chart-generated RAG PVC has `helm.sh/resource-policy: keep`. Disabling RAG or -uninstalling the release removes the RAG workload but retains that PVC and its -data. Delete it manually only after the data is no longer required: +uninstalling the release removes the workload but retains the claim and its data. +Inspect the generated name and delete it only after the data is no longer needed: ```bash -kubectl -n hugegraph delete pvc hugegraph-rag-data +kubectl -n hugegraph get pvc +kubectl -n hugegraph delete pvc hugegraph-hugegraph-rag-data ``` -The exact generated name follows the Helm release name; inspect it before -deleting with `kubectl -n hugegraph get pvc`. +The current HugeGraph RAG application keeps mutable settings in one process and +writes the resource tree without cross-Pod coordination. An RWX volume would +make files mountable by multiple Pods, but would not synchronize in-memory +settings or serialize writes. The chart therefore requires `rag.replicas=1` and +uses `Recreate`, so an update never leaves two active RAG Pods. -The current HugeGraph RAG application keeps mutable settings in process and -writes prompt/resource files without cross-Pod coordination. An RWX volume would -only make the files mountable by multiple Pods; it would not synchronize their -in-memory settings or serialize writes. The chart therefore requires -`rag.replicas=1` and always uses the `Recreate` deployment strategy so an update -never leaves two active RAG Pods. Multi-replica support should be enabled only -after the application provides an external, concurrency-safe configuration -store and cross-Pod reload semantics. +Disable all chart persistence for disposable environments: -Persistence can be disabled explicitly for disposable tests: +```yaml +persistence: + enabled: false +rag: + enabled: true +``` + +Or override only RAG: ```yaml rag: @@ -216,20 +273,17 @@ rag: enabled: false ``` -In that mode prompt, index and other filesystem changes are lost when the Pod is -replaced. Runtime `.env` settings are ephemeral in both modes by design. +Without persistence, `.env` is created under `/tmp` and in-container edits are +lost when the Pod is replaced. -## Use another HugeGraph deployment +## External HugeGraph deployment -Set an explicit URL and credential Secret for an external Server. A custom -Secret defaults to the key `password` when `key` is omitted; specifying the key -is clearer and recommended. +Set an explicit URL and credential source for another Server: ```yaml rag: enabled: true hugegraph: - autoConfigure: true url: http://hugegraph-server.graphs.svc:8080 graphName: hugegraph graphSpace: DEFAULT @@ -239,31 +293,45 @@ rag: key: password ``` -## Exposure and authentication +A custom Secret defaults to key `password` when `key` is omitted, although an +explicit key is clearer. + +## Security contexts and OpenShift + +The templates default to non-root UID/GID/fsGroup `1000`, dropped Linux +capabilities, disabled privilege escalation and RuntimeDefault seccomp. The main +RAG container intentionally keeps `resources: {}` because model, embedding and +index workloads need installation-specific sizing; only the small seeding init +container has generic defaults in `values-rag.yaml`. + +Platforms such as OpenShift restricted SCC assign the numeric identity at +admission time. Keep `runAsNonRoot: true`, but use `auto` to omit the fixed IDs: + +```yaml +rag: + podSecurityContext: + runAsUser: auto + runAsGroup: auto + fsGroup: auto + securityContext: + runAsUser: auto + runAsGroup: auto +``` + +When `fsGroup` is omitted, the chart also omits `fsGroupChangePolicy`. Confirm +that the platform makes the selected PVC writable for the assigned identity. + +## Exposure and NetworkPolicy The RAG Service defaults to `ClusterIP` in both `easy` and `strict` modes. This -is intentionally stricter than the browser-oriented Server and Hubble defaults: -the current RAG application login does not provide unified authentication for -every HTTP API route. - -An explicit `rag.service.type` always wins. Ingress remains disabled until -`rag.ingress.enabled=true`. Treat NodePort, LoadBalancer and Ingress as reviewed -external-exposure choices. Put the application behind an authenticated reverse -proxy or another trusted network boundary and apply model-provider spend limits. -The application's native login can still be enabled for its own UI and guarded -write paths, but it is not a substitute for an authenticated ingress protecting -all routes. - -In `mode=strict`, a NetworkPolicy isolates the Pod. Strict mode permits DNS and -the same-release Server automatically, but does not silently open access to -public model APIs or an Ingress controller. Add the required peers to -`rag.networkPolicy.extraIngress` and provider destinations to -`rag.networkPolicy.extraEgress` using native Kubernetes NetworkPolicy rules. - -The templates enforce non-root execution, `privileged=false`, no added Linux -capabilities, `allowPrivilegeEscalation=false`, RuntimeDefault seccomp and a -disabled ServiceAccount token. UID/GID/fsGroup default to `1000`; explicit -values must remain positive non-root IDs. The small default requests/limits in -`values-rag.yaml` apply only to the resource-seeding init container. The main RAG -container deliberately keeps `resources: {}` because model, embedding and index -workloads require installation-specific sizing. +is deliberately stricter than browser-oriented Server and Hubble defaults: the +current application login does not protect every HTTP route. + +NodePort, LoadBalancer and Ingress are explicit choices. Put externally exposed +RAG behind an authenticated reverse proxy or another reviewed network boundary, +and apply model-provider spend limits. + +In `mode=strict`, NetworkPolicy permits DNS and the same-release Server. Add the +Ingress controller and required model-provider destinations through +`rag.networkPolicy.extraIngress` and `rag.networkPolicy.extraEgress`. Policy +objects are effective only when the cluster CNI implements NetworkPolicy. diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index 03b9c847db..4b1cf4013b 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -105,7 +105,14 @@ password {{- define "hugegraph.rag.persistenceEnabled" -}} {{- $rag := get .Values "rag" | default dict -}} {{- $persistence := get $rag "persistence" | default dict -}} -{{- if hasKey $persistence "enabled" -}}{{- get $persistence "enabled" -}}{{- else -}}true{{- end -}} +{{- if and (hasKey $persistence "enabled") (ne (get $persistence "enabled" | toString) "") -}} +{{- get $persistence "enabled" -}} +{{- else -}} +{{- $global := get .Values "persistence" | default dict -}} +{{- if and (hasKey $global "enabled") (ne (get $global "enabled" | toString) "") -}} +{{- get $global "enabled" -}} +{{- else -}}true{{- end -}} +{{- end -}} {{- end }} {{- define "hugegraph.rag.mountPath" -}} @@ -114,21 +121,19 @@ password {{- get $persistence "mountPath" | default "/home/work/hugegraph-llm/src/hugegraph_llm/resources" -}} {{- end }} -{{/* -Keep the generated .env outside the persistent resource tree. HugeGraph AI -loads an existing .env into os.environ before Pydantic reads the container -environment, so persisting it would let stale graph/model credentials override -rotated Kubernetes Secrets after a restart. -*/}} {{- define "hugegraph.rag.envPath" -}} +{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} +{{- printf "%s/.env" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} +{{- else -}} /tmp/hugegraph-llm.env +{{- end -}} {{- end }} {{- define "hugegraph.rag.promptConfigPath" -}} {{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} {{- printf "%s/demo/config_prompt.yaml" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} {{- else -}} -/home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml +/home/work/hugegraph-lllm/src/hugegraph_llm/resources/demo/config_prompt.yaml {{- end -}} {{- end }} @@ -137,4 +142,3 @@ rotated Kubernetes Secrets after a restart. {{- $persistence := get $rag "persistence" | default dict -}} {{- get $persistence "existingClaim" | default (include "hugegraph.rag.dataName" .) -}} {{- end }} - diff --git a/helm/hugegraph/templates/_rag_security.tpl b/helm/hugegraph/templates/_rag_security.tpl index fd46ccf1c2..607a195292 100644 --- a/helm/hugegraph/templates/_rag_security.tpl +++ b/helm/hugegraph/templates/_rag_security.tpl @@ -24,10 +24,21 @@ "fsGroup" 1000 "fsGroupChangePolicy" "OnRootMismatch" "seccompProfile" (dict "type" "RuntimeDefault") -}} -{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "podSecurityContext" | default dict)) -}} +{{- $overrides := deepCopy (get $rag "podSecurityContext" | default dict) -}} +{{- $context := mergeOverwrite (deepCopy $defaults) $overrides -}} {{- range $field := list "runAsUser" "runAsGroup" "fsGroup" -}} -{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} +{{- if hasKey $overrides $field -}} +{{- $raw := get $overrides $field | toString -}} +{{- if or (eq $raw "") (eq (lower $raw) "auto") -}} +{{- $_ := unset $context $field -}} +{{- else -}} +{{- $_ := set $context $field (int (get $overrides $field)) -}} {{- end -}} +{{- else if hasKey $context $field -}} +{{- $_ := set $context $field (int (get $context $field)) -}} +{{- end -}} +{{- end -}} +{{- if not (hasKey $context "fsGroup") -}}{{- $_ := unset $context "fsGroupChangePolicy" -}}{{- end -}} {{- toYaml $context -}} {{- end }} @@ -41,10 +52,19 @@ "allowPrivilegeEscalation" false "capabilities" (dict "drop" (list "ALL")) "seccompProfile" (dict "type" "RuntimeDefault") -}} -{{- $context := mergeOverwrite (deepCopy $defaults) (deepCopy (get $rag "securityContext" | default dict)) -}} +{{- $overrides := deepCopy (get $rag "securityContext" | default dict) -}} +{{- $context := mergeOverwrite (deepCopy $defaults) $overrides -}} {{- range $field := list "runAsUser" "runAsGroup" -}} -{{- if hasKey $context $field -}}{{- $_ := set $context $field (int (get $context $field)) -}}{{- end -}} +{{- if hasKey $overrides $field -}} +{{- $raw := get $overrides $field | toString -}} +{{- if or (eq $raw "") (eq (lower $raw) "auto") -}} +{{- $_ := unset $context $field -}} +{{- else -}} +{{- $_ := set $context $field (int (get $overrides $field)) -}} +{{- end -}} +{{- else if hasKey $context $field -}} +{{- $_ := set $context $field (int (get $context $field)) -}} +{{- end -}} {{- end -}} {{- toYaml $context -}} {{- end }} - diff --git a/helm/hugegraph/templates/_rag_validate_security.tpl b/helm/hugegraph/templates/_rag_validate_security.tpl index 2bf76e2257..233553c116 100644 --- a/helm/hugegraph/templates/_rag_validate_security.tpl +++ b/helm/hugegraph/templates/_rag_validate_security.tpl @@ -31,8 +31,8 @@ {{- range $field := list "runAsUser" "runAsGroup" -}} {{- if hasKey $context $field -}} {{- $raw := get $context $field | toString -}} -{{- if not (regexMatch "^[1-9][0-9]*$" $raw) -}} -{{- fail (printf "%s.%s must be a positive integer" $scope $field) -}} +{{- if and (ne $raw "") (ne (lower $raw) "auto") (not (regexMatch "^[1-9][0-9]*$" $raw)) -}} +{{- fail (printf "%s.%s must be a positive integer, null or auto" $scope $field) -}} {{- end -}} {{- end -}} {{- end -}} @@ -48,8 +48,8 @@ {{- end -}} {{- if hasKey $podContext "fsGroup" -}} {{- $rawFsGroup := get $podContext "fsGroup" | toString -}} -{{- if not (regexMatch "^[1-9][0-9]*$" $rawFsGroup) -}} -{{- fail "rag.podSecurityContext.fsGroup must be a positive integer" -}} +{{- if and (ne $rawFsGroup "") (ne (lower $rawFsGroup) "auto") (not (regexMatch "^[1-9][0-9]*$" $rawFsGroup)) -}} +{{- fail "rag.podSecurityContext.fsGroup must be a positive integer, null or auto" -}} {{- end -}} {{- end -}} {{- range $field := list "privileged" "allowPrivilegeEscalation" -}} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index d14b9d78c3..383e343540 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -116,7 +116,10 @@ spec: - | # Preserve operator and UI edits. Image upgrades add only files # that are still missing from the persistent resource directory. - cp -a -n /home/work/hugegraph-llm/src/hugegraph_llm/resources/. /data/ + # Recursive copy deliberately avoids archive-mode metadata writes + # against a CSI volume root that remains owned by root. + umask 007 + cp -R -n /home/work/hugegraph-llm/src/hugegraph_llm/resources/. /data/ securityContext: {{- include "hugegraph.rag.securityContext" . | nindent 12 }} resources: @@ -129,15 +132,29 @@ spec: - name: rag image: {{ include "hugegraph.rag.image" . | quote }} imagePullPolicy: {{ get (get $rag "image" | default dict) "pullPolicy" | default "Always" }} - command: - - python - - -m - - hugegraph_llm.demo.rag_demo.app + command: ["/bin/sh", "-ec"] args: - - --host - - 0.0.0.0 - - --port - - {{ include "hugegraph.rag.port" . | int | quote }} + - | + # HugeGraph AI loads an existing .env into os.environ before + # Pydantic reads settings. Remove only keys already supplied by + # this Pod (including envFrom Secrets) so the current Kubernetes + # environment wins while all other UI/operator edits persist. + umask 007 + python - <<'PY' + import os + from pathlib import Path + + from dotenv import dotenv_values, unset_key + + env_path = Path(os.environ["HUGEGRAPH_LLM_ENV_PATH"]) + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.touch(exist_ok=True) + for key in dotenv_values(env_path): + if key in os.environ: + unset_key(str(env_path), key) + PY + exec python -m hugegraph_llm.demo.rag_demo.app \ + --host 0.0.0.0 --port {{ include "hugegraph.rag.port" . | int | quote }} securityContext: {{- include "hugegraph.rag.securityContext" . | nindent 12 }} ports: @@ -207,7 +224,7 @@ spec: path: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "path" | default "/" | quote }} port: http failureThreshold: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "failureThreshold" | default 3 }} - periodSeconds: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "periodSeconds" | default 10 }} + periodSeconds: {{ get (get (get (get $rag "probes" | default dict) "readiness" | default dict) "periodSeconds" | default 10 }} {{- include "hugegraph.probeTuning" (get (get $rag "probes" | default dict) "readiness" | default dict) | nindent 12 }} livenessProbe: httpGet: @@ -221,7 +238,7 @@ spec: {{- if $persistenceEnabled }} - name: data persistentVolumeClaim: - claimName: {{ include "hugegraph.rag.claimName" . }} + claimName: {{ inclue "hugegraph.rag.claimName" . }} {{- end }} {{- with get $rag "extraVolumes" }} {{- toYaml . | nindent 8 }} diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml index 1244d30f9c..bbbdbbda7a 100644 --- a/helm/hugegraph/tests/rag_component_test.yaml +++ b/helm/hugegraph/tests/rag_component_test.yaml @@ -56,7 +56,7 @@ tests: value: seed-resources - matchRegex: path: spec.template.spec.initContainers[0].args[0] - pattern: "cp -a -n" + pattern: "cp -R -n" - equal: path: spec.template.spec.containers[0].image value: hugegraph/rag:latest @@ -69,16 +69,17 @@ tests: - equal: path: spec.template.spec.containers[0].command value: - - python - - -m - - hugegraph_llm.demo.rag_demo.app - - equal: - path: spec.template.spec.containers[0].args - value: - - --host - - 0.0.0.0 - - --port - - "8001" + - /bin/sh + - -ec + - matchRegex: + path: spec.template.spec.containers[0].args[0] + pattern: "if key in os.environ" + - matchRegex: + path: spec.template.spec.containers[0].args[0] + pattern: "unset_key" + - matchRegex: + path: spec.template.spec.containers[0].args[0] + pattern: "--port \"8001\"" - equal: path: spec.template.spec.containers[0].ports[0].containerPort value: 8001 @@ -102,7 +103,7 @@ tests: path: spec.template.spec.containers[0].env content: name: HUGEGRAPH_LLM_ENV_PATH - value: /tmp/hugegraph-llm.env + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/.env - contains: path: spec.template.spec.containers[0].env content: @@ -115,9 +116,9 @@ tests: rag.enabled: true rag.port: 9001 asserts: - - equal: - path: spec.template.spec.containers[0].args[3] - value: "9001" + - matchRegex: + path: spec.template.spec.containers[0].args[0] + pattern: "--port \"9001\"" - equal: path: spec.template.spec.containers[0].ports[0].containerPort value: 9001 @@ -176,6 +177,32 @@ tests: name: HUGEGRAPH_LLM_ENV_PATH value: /tmp/hugegraph-llm.env + - it: supports platform-assigned non-root IDs + template: rag-deployment.yaml + set: + rag.enabled: true + rag.podSecurityContext.runAsUser: auto + rag.podSecurityContext.runAsGroup: auto + rag.podSecurityContext.fsGroup: auto + rag.securityContext.runAsUser: auto + rag.securityContext.runAsGroup: auto + asserts: + - equal: + path: spec.template.spec.securityContext.runAsNonRoot + value: true + - notExists: + path: spec.template.spec.securityContext.runAsUser + - notExists: + path: spec.template.spec.securityContext.runAsGroup + - notExists: + path: spec.template.spec.securityContext.fsGroup + - notExists: + path: spec.template.spec.securityContext.fsGroupChangePolicy + - notExists: + path: spec.template.spec.containers[0].securityContext.runAsUser + - notExists: + path: spec.template.spec.containers[0].securityContext.runAsGroup + - it: keeps the RAG service internal by default in easy mode template: rag-service.yaml set: @@ -234,7 +261,7 @@ tests: path: spec.accessModes[0] value: ReadWriteOnce - - it: creates no claim when persistence is explicitly disabled + - it: creates no claim when RAG persistence is explicitly disabled template: rag-pvc.yaml set: rag.enabled: true @@ -242,3 +269,12 @@ tests: asserts: - hasDocuments: count: 0 + + - it: inherits the global persistence disable when RAG does not override it + template: rag-pvc.yaml + set: + rag.enabled: true + persistence.enabled: false + asserts: + - hasDocuments: + count: 0 diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml index 50b587434b..1e95af002e 100644 --- a/helm/hugegraph/values-rag.yaml +++ b/helm/hugegraph/values-rag.yaml @@ -52,7 +52,7 @@ rag: # Non-root execution, RuntimeDefault seccomp, no added capabilities, disabled # privilege escalation and no ServiceAccount token are enforced by templates, # including bare --set rag.enabled=true installs. UID/GID/fsGroup default to - # 1000 and custom values must remain positive non-root IDs. + # 1000; use the string auto for platform-assigned non-root identities. nodeSelector: {} tolerations: [] affinity: {} @@ -60,6 +60,8 @@ rag: priorityClassName: '' podAnnotations: {} podLabels: {} + # Explicit env/envFrom values automatically take precedence over matching + # keys in the retained .env file on every Pod start. env: [] envFrom: [] terminationGracePeriodSeconds: 30 @@ -87,14 +89,12 @@ rag: pathType: Prefix tls: [] - # Persistence defaults on when RAG is enabled. The claim stores the editable - # resource tree, prompts, schema examples and indexes. The generated .env is - # deliberately kept in /tmp so a stale file cannot override rotated graph or - # model credentials supplied through Kubernetes Secrets after a restart. The - # init container adds missing files from a new image but never overwrites - # files already changed on the claim. + # Unset inherits the top-level persistence.enabled value (true by default). + # The claim stores the editable .env, prompt configuration, indexes and + # resource tree. The init container adds missing image files but never + # overwrites files already changed on the claim. persistence: - enabled: true + enabled: existingClaim: '' size: 10Gi storageClassName: '' diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index 816f54e331..bc51028e2d 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -14,9 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Regression coverage for the optional HugeGraph RAG Helm component.""" +"""Basic rendered contracts for the optional HugeGraph RAG component.""" -from .support import RAG_ENV, RAG_MOUNT, component_resources, env_by_name, pod +from .support import RAG_MOUNT, RAG_PERSISTENT_ENV, component_resources, env_by_name, pod class RagBasicMixin: @@ -36,8 +36,9 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertEqual(1, deployment["spec"]["replicas"]) self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) self.assertEqual(8001, container["ports"][0]["containerPort"]) - self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) - self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) + self.assertEqual(["/bin/sh", "-ec"], container["command"]) + self.assertIn("exec python -m hugegraph_llm.demo.rag_demo.app", container["args"][0]) + self.assertIn("--host 0.0.0.0 --port \"8001\"", container["args"][0]) self.assertEqual("ClusterIP", service["spec"]["type"]) self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) @@ -53,7 +54,7 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertFalse(pod(deployment)["spec"]["automountServiceAccountToken"]) environment = env_by_name(container) - self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual(RAG_PERSISTENT_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual( f"{RAG_MOUNT}/demo/config_prompt.yaml", environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], @@ -79,9 +80,8 @@ def test_custom_port_reaches_process_pod_service_and_probes(self): documents = self.documents({"port": 9001}) container = self.container(documents) service, = component_resources(documents, kind="Service") - self.assertEqual("9001", container["args"][3]) + self.assertIn('--port "9001"', container["args"][0]) self.assertEqual(9001, container["ports"][0]["containerPort"]) self.assertEqual(9001, service["spec"]["ports"][0]["port"]) for name in ("startupProbe", "readinessProbe", "livenessProbe"): self.assertEqual("http", container[name]["httpGet"]["port"]) - diff --git a/helm/rag_tests/source_contract.py b/helm/rag_tests/source_contract.py index 5e60e9de9f..4763208f36 100644 --- a/helm/rag_tests/source_contract.py +++ b/helm/rag_tests/source_contract.py @@ -14,17 +14,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Regression coverage for the optional HugeGraph RAG Helm component.""" +"""Source-level contracts for the optional HugeGraph RAG component.""" import unittest import yaml -from .support import CHART, RAG_ENV, RAG_MOUNT +from .support import CHART, RAG_MOUNT, RAG_PERSISTENT_ENV class RagSourceContractTest(unittest.TestCase): - def test_profile_is_internal_persistent_and_matches_the_image_contract(self): + def test_profile_is_internal_and_inherits_persistence(self): values = yaml.safe_load((CHART / "values-rag.yaml").read_text(encoding="utf-8")) rag = values["rag"] self.assertTrue(rag["enabled"]) @@ -33,10 +33,10 @@ def test_profile_is_internal_persistent_and_matches_the_image_contract(self): self.assertEqual(8001, rag["port"]) self.assertEqual({"autoConfigure": True}, rag["hugegraph"]) self.assertEqual("ClusterIP", rag["service"]["type"]) - self.assertTrue(rag["persistence"]["enabled"]) + self.assertIsNone(rag["persistence"]["enabled"]) self.assertEqual(RAG_MOUNT, rag["persistence"]["mountPath"]) - def test_single_and_cluster_presets_keep_one_persistent_rag_replica(self): + def test_single_and_cluster_presets_keep_one_rag_replica(self): single = yaml.safe_load((CHART / "values-single.yaml").read_text(encoding="utf-8")) cluster = yaml.safe_load((CHART / "values-cluster.yaml").read_text(encoding="utf-8")) self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) @@ -51,7 +51,7 @@ def test_template_validation_publishes_the_single_replica_and_key_contract(self) self.assertIn("rag contains unsupported field", helpers) self.assertIn("rag.persistence contains unsupported field", helpers) - def test_templates_use_one_process_port_and_split_runtime_from_durable_config(self): + def test_templates_use_one_port_and_kubernetes_env_precedence(self): deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") helpers = "\n".join( @@ -60,12 +60,15 @@ def test_templates_use_one_process_port_and_split_runtime_from_durable_config(se ) self.assertIn("hugegraph_llm.demo.rag_demo.app", deployment) self.assertIn('containerPort: {{ include "hugegraph.rag.port" . | int }}', deployment) - self.assertIn('include "hugegraph.rag.port" . | int | quote', deployment) + self.assertIn("--port {{ include", deployment) self.assertIn("targetPort: http", service) self.assertIn('include "hugegraph.server.clientUrl"', helpers) self.assertIn('trimPrefix "http://"', helpers) - self.assertIn(RAG_ENV, helpers) + self.assertIn('printf "%s/.env"', helpers) self.assertIn("HUGEGRAPH_LLM_ENV_PATH", deployment) - self.assertIn("HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", deployment) + self.assertIn("if key in os.environ", deployment) + self.assertIn("unset_key", deployment) + self.assertIn("cp -R -n", deployment) + self.assertNotIn("cp -a", deployment) self.assertNotIn("touch /data/.hugegraph-rag-seeded", deployment) - + self.assertIn(RAG_PERSISTENT_ENV.rsplit("/", 1)[0], helpers) diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index 0a8436aa94..a2f8e64fb5 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -14,37 +14,61 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Regression coverage for the optional HugeGraph RAG Helm component.""" +"""State and network regression coverage for the optional RAG component.""" -from .support import RAG_ENV, RAG_MOUNT, component_resources, env_by_name, pod +from .support import ( + RAG_EPHEMERAL_ENV, + RAG_MOUNT, + RAG_PERSISTENT_ENV, + component_resources, + env_by_name, + pod, +) class RagStateMixin: - def test_persistence_seeds_resources_but_keeps_runtime_env_ephemeral(self): + def test_persistence_seeds_resources_and_reconciles_runtime_environment(self): documents = self.documents({"persistence": {"enabled": True}}) deployment = self.deployment(documents) pvc, = component_resources(documents, kind="PersistentVolumeClaim") self.assertEqual("fixture-hugegraph-rag-data", pvc["metadata"]["name"]) self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) + init, = pod(deployment)["spec"]["initContainers"] - self.assertIn("cp -a -n", init["args"][0]) + self.assertIn("cp -R -n", init["args"][0]) + self.assertNotIn("cp -a", init["args"][0]) self.assertNotIn(".hugegraph-rag-seeded", init["args"][0]) + container = self.container(documents) + startup = container["args"][0] + self.assertIn("dotenv_values", startup) + self.assertIn("if key in os.environ", startup) + self.assertIn("unset_key", startup) + self.assertIn("exec python -m hugegraph_llm.demo.rag_demo.app", startup) + mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] self.assertEqual(RAG_MOUNT, mount["mountPath"]) environment = env_by_name(container) - self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual(RAG_PERSISTENT_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual( f"{RAG_MOUNT}/demo/config_prompt.yaml", environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], ) - def test_custom_mount_path_updates_only_the_durable_prompt_path(self): + def test_env_from_is_rendered_and_covered_by_generic_precedence(self): + documents = self.documents({ + "envFrom": [{"secretRef": {"name": "rag-model"}}], + }) + container = self.container(documents) + self.assertEqual([{"secretRef": {"name": "rag-model"}}], container["envFrom"]) + self.assertIn("if key in os.environ", container["args"][0]) + + def test_custom_mount_path_updates_both_durable_config_paths(self): mount_path = "/var/lib/hugegraph-rag" documents = self.documents({"persistence": {"mountPath": mount_path}}) container = self.container(documents) environment = env_by_name(container) - self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual(f"{mount_path}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual( f"{mount_path}/demo/config_prompt.yaml", environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], @@ -61,12 +85,21 @@ def test_persistence_can_be_disabled_for_disposable_runs(self): container = self.container(documents) self.assertFalse(any(item["name"] == "data" for item in container.get("volumeMounts", []))) environment = env_by_name(container) - self.assertEqual(RAG_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual(RAG_EPHEMERAL_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual( f"{RAG_MOUNT}/demo/config_prompt.yaml", environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], ) + def test_global_persistence_disable_is_inherited_when_rag_override_is_unset(self): + documents = self.documents({}, use_profile=False, persistence={"enabled": False}) + self.assertEqual([], component_resources(documents, kind="PersistentVolumeClaim")) + container = self.container(documents) + self.assertEqual( + RAG_EPHEMERAL_ENV, + env_by_name(container)["HUGEGRAPH_LLM_ENV_PATH"]["value"], + ) + def test_multiple_replicas_are_rejected_even_with_shared_storage(self): for persistence in ( {"enabled": False}, @@ -134,4 +167,3 @@ def test_global_registry_and_digest_render(self): deployment = self.deployment(documents) self.assertEqual(expected, pod(deployment)["spec"]["containers"][0]["image"]) self.assertEqual(expected, pod(deployment)["spec"]["initContainers"][0]["image"]) - diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py index 6819da1b5b..d26b701123 100644 --- a/helm/rag_tests/support.py +++ b/helm/rag_tests/support.py @@ -28,7 +28,9 @@ CHART = Path(__file__).resolve().parents[1] / "hugegraph" HELM_TEMPLATE_TIMEOUT_SECONDS = 120 RAG_MOUNT = "/home/work/hugegraph-llm/src/hugegraph_llm/resources" -RAG_ENV = "/tmp/hugegraph-llm.env" +RAG_PERSISTENT_ENV = f"{RAG_MOUNT}/.env" +RAG_EPHEMERAL_ENV = "/tmp/hugegraph-llm.env" +RAG_ENV = RAG_PERSISTENT_ENV def component_resources(documents, component="rag", kind=None): @@ -88,4 +90,3 @@ def deployment(self, documents): def container(self, documents): container, = pod(self.deployment(documents))["spec"]["containers"] return container - diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 0fb9a4cf55..45936bb960 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -14,7 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Regression coverage for the optional HugeGraph RAG Helm component.""" +"""Validation regression coverage for the optional RAG component.""" + +from .support import pod class RagValidationMixin: @@ -48,3 +50,30 @@ def test_invalid_contracts_fail_before_render(self): self.assertNotEqual(0, result.returncode) self.assertIn(expected, result.stderr) + def test_platform_assigned_non_root_ids_can_be_requested_with_auto(self): + documents = self.documents({ + "podSecurityContext": { + "runAsUser": "auto", + "runAsGroup": "auto", + "fsGroup": "auto", + }, + "securityContext": { + "runAsUser": "auto", + "runAsGroup": "auto", + }, + }) + workload = self.deployment(documents) + pod_context = pod(workload)["spec"]["securityContext"] + self.assertTrue(pod_context["runAsNonRoot"]) + self.assertNotIn("runAsUser", pod_context) + self.assertNotIn("runAsGroup", pod_context) + self.assertNotIn("fsGroup", pod_context) + self.assertNotIn("fsGroupChangePolicy", pod_context) + for container in [ + *pod(workload)["spec"].get("initContainers", []), + *pod(workload)["spec"]["containers"], + ]: + context = container["securityContext"] + self.assertTrue(context["runAsNonRoot"]) + self.assertNotIn("runAsUser", context) + self.assertNotIn("runAsGroup", context) diff --git a/helm/test_helm_package.py b/helm/test_helm_package.py index 5f05ce34b2..181b7f2d5a 100644 --- a/helm/test_helm_package.py +++ b/helm/test_helm_package.py @@ -69,7 +69,7 @@ def test_archive_budget(self): "Chart exceeds its package budget; check non-runtime assets") def test_no_contributor_or_binary_documentation_files(self): - excluded = ("images/", "tests/", "scripts/", "testdata/") + excluded = ("images/", "docs/images/", "tests/", "scripts/", "testdata/") leaked = [name for name in self.files if name.startswith(excluded) or name.endswith(".tgz")] self.assertEqual([], leaked, @@ -145,6 +145,7 @@ def test_packaged_optional_vermeer_images_include_native_token_helper(self): self.assertIn("/go/bin/vermeer-token", rendered) + def load_tests(loader, tests, pattern): """Load the RAG suite only for the narrow packaged-chart CI invocation.""" if pattern != "test_helm_package.py": From dcaa28e0e638760c015f9790c5f121c08f2d7dde Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 04:28:18 +0800 Subject: [PATCH 09/38] fix(helm): correct RAG template rendering Fix the retained PVC claim helper name, remove an extra readiness get invocation, and keep the non-persistent prompt path aligned with the HugeGraph AI image layout. --- helm/hugegraph/templates/_rag.tpl | 2 +- helm/hugegraph/templates/rag-deployment.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index 4b1cf4013b..29373ce291 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -133,7 +133,7 @@ password {{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} {{- printf "%s/demo/config_prompt.yaml" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} {{- else -}} -/home/work/hugegraph-lllm/src/hugegraph_llm/resources/demo/config_prompt.yaml +/home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml {{- end -}} {{- end }} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 383e343540..3fce8c1842 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -224,7 +224,7 @@ spec: path: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "path" | default "/" | quote }} port: http failureThreshold: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "failureThreshold" | default 3 }} - periodSeconds: {{ get (get (get (get $rag "probes" | default dict) "readiness" | default dict) "periodSeconds" | default 10 }} + periodSeconds: {{ get (get (get $rag "probes" | default dict) "readiness" | default dict) "periodSeconds" | default 10 }} {{- include "hugegraph.probeTuning" (get (get $rag "probes" | default dict) "readiness" | default dict) | nindent 12 }} livenessProbe: httpGet: @@ -238,7 +238,7 @@ spec: {{- if $persistenceEnabled }} - name: data persistentVolumeClaim: - claimName: {{ inclue "hugegraph.rag.claimName" . }} + claimName: {{ include "hugegraph.rag.claimName" . }} {{- end }} {{- with get $rag "extraVolumes" }} {{- toYaml . | nindent 8 }} From eaed5b547983add64468ace7629b1833cc89caa2 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 04:36:47 +0800 Subject: [PATCH 10/38] fix(helm): close remaining RAG configuration gaps Reject extra RAG volumes and mounts that collide with the chart-managed persistent data volume, add regression coverage, and document the special GRAPH_PWD envFrom rule plus explicit GitOps rollout triggers for rotated external credentials. --- helm/hugegraph/docs/rag.md | 31 +++++++++++++++++-- .../templates/_rag_validate_runtime.tpl | 10 ++++++ helm/rag_tests/validation.py | 17 +++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index a98fe27a54..e0e4a4dc3e 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -76,8 +76,27 @@ rag: The chart-managed Server Secret is covered by a pod-template checksum. An explicit `rag.hugegraph.passwordSecret` is covered by its live resource version -during `helm upgrade`. In both cases, the restarted Pod uses the new Secret even -when an older value exists in the retained `.env` file. +during an online `helm upgrade`. In both cases, the restarted Pod uses the new +Secret even when an older value exists in the retained `.env` file. + +Offline renderers and GitOps controllers cannot use Helm `lookup` to observe a +live Secret's resource version. Rotating an explicit +`rag.hugegraph.passwordSecret` while applying otherwise identical rendered +manifests therefore needs an explicit rollout trigger after the Secret has been +updated. Either restart the Deployment: + +```bash +RELEASE=hugegraph +kubectl -n hugegraph rollout restart deployment/${RELEASE}-hugegraph-rag +``` + +or change a reviewed Pod annotation in the same GitOps change: + +```yaml +rag: + podAnnotations: + hugegraph.apache.org/credential-revision: "2026-11-04-1" +``` When `rag.env` explicitly supplies `GRAPH_URL`, the chart does not attach the same-release administrator password to that endpoint. Supply an explicit @@ -122,6 +141,14 @@ rag: name: rag-model ``` +Kubernetes gives an explicit `env` entry precedence over the same key imported +through `envFrom`. Because automatic same-release wiring injects `GRAPH_PWD` as +an explicit entry, an `envFrom` Secret cannot replace that particular key. Use +`rag.hugegraph.passwordSecret` (preferred), or declare a `rag.env` entry named +`GRAPH_PWD` with its own `secretKeyRef`; either choice suppresses the automatic +password entry. `envFrom` remains appropriate for provider and other keys that +the chart does not generate. + At every start, any key present in the current Pod environment—including keys loaded by `envFrom`—takes precedence over the same key in the retained `.env`. Changing a provider Secret alone does not restart a Pod whose environment was diff --git a/helm/hugegraph/templates/_rag_validate_runtime.tpl b/helm/hugegraph/templates/_rag_validate_runtime.tpl index ff4cb7fd6d..0d27799758 100644 --- a/helm/hugegraph/templates/_rag_validate_runtime.tpl +++ b/helm/hugegraph/templates/_rag_validate_runtime.tpl @@ -62,5 +62,15 @@ {{- if or (not $mountPath) (not (hasPrefix "/" $mountPath)) -}} {{- fail "rag.persistence.mountPath must be an absolute path" -}} {{- end -}} +{{- range $mount := get $rag "extraVolumeMounts" | default (list) -}} +{{- if eq (get $mount "mountPath" | default "" | toString) $mountPath -}} +{{- fail "rag.extraVolumeMounts must not use rag.persistence.mountPath when persistence is enabled" -}} +{{- end -}} +{{- end -}} +{{- range $volume := get $rag "extraVolumes" | default (list) -}} +{{- if eq (get $volume "name" | default "" | toString) "data" -}} +{{- fail "rag.extraVolumes must not use the reserved volume name data when persistence is enabled" -}} +{{- end -}} +{{- end -}} {{- end -}} {{- end }} diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 45936bb960..dfa408156b 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -16,7 +16,7 @@ """Validation regression coverage for the optional RAG component.""" -from .support import pod +from .support import RAG_MOUNT, pod class RagValidationMixin: @@ -41,6 +41,10 @@ def test_invalid_contracts_fail_before_render(self): ({"serviceAccount": {"automountServiceAccountToken": True}}, "automountServiceAccountToken"), ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), + ({"extraVolumeMounts": [{"name": "other", "mountPath": RAG_MOUNT}]}, + "extraVolumeMounts"), + ({"extraVolumes": [{"name": "data", "emptyDir": {}}]}, + "reserved volume name data"), ({"env": [{"name": "GRAPH_URL", "value": "a"}, {"name": "GRAPH_URL", "value": "b"}]}, "duplicate"), ({"persistance": {"enabled": False}}, "persistance"), ) @@ -50,6 +54,17 @@ def test_invalid_contracts_fail_before_render(self): self.assertNotEqual(0, result.returncode) self.assertIn(expected, result.stderr) + def test_reserved_data_volume_and_mount_path_are_available_when_persistence_is_off(self): + documents = self.documents({ + "persistence": {"enabled": False}, + "extraVolumes": [{"name": "data", "emptyDir": {}}], + "extraVolumeMounts": [{"name": "data", "mountPath": RAG_MOUNT}], + }) + workload = self.deployment(documents) + self.assertIn({"name": "data", "emptyDir": {}}, pod(workload)["spec"]["volumes"]) + self.assertIn({"name": "data", "mountPath": RAG_MOUNT}, + pod(workload)["spec"]["containers"][0]["volumeMounts"]) + def test_platform_assigned_non_root_ids_can_be_requested_with_auto(self): documents = self.documents({ "podSecurityContext": { From 00410c0c441c6f02f2c7c10765a49c74487d05fb Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 04:39:01 +0800 Subject: [PATCH 11/38] fix(helm): validate RAG environment names Reject rag.env names containing '=' or non-printable bytes during Helm rendering, matching the Kubernetes relaxed EnvVar name contract, and add regression coverage for invalid names. --- helm/hugegraph/templates/_rag_validate_metadata.tpl | 3 +++ helm/rag_tests/validation.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/helm/hugegraph/templates/_rag_validate_metadata.tpl b/helm/hugegraph/templates/_rag_validate_metadata.tpl index e2057ca6ed..0f49d1f626 100644 --- a/helm/hugegraph/templates/_rag_validate_metadata.tpl +++ b/helm/hugegraph/templates/_rag_validate_metadata.tpl @@ -34,6 +34,9 @@ {{- if not $name -}} {{- fail (printf "rag.env[%d].name must not be empty" $index) -}} {{- end -}} +{{- if not (regexMatch `^[\x20-\x3c\x3e-\x7e]+$` $name) -}} +{{- fail (printf "rag.env[%d].name must use printable ASCII characters other than '='" $index) -}} +{{- end -}} {{- if hasKey $seenEnv $name -}} {{- fail (printf "rag.env contains duplicate name %s" $name) -}} {{- end -}} diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index dfa408156b..0067abd1e7 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -45,6 +45,8 @@ def test_invalid_contracts_fail_before_render(self): "extraVolumeMounts"), ({"extraVolumes": [{"name": "data", "emptyDir": {}}]}, "reserved volume name data"), + ({"env": [{"name": "BAD=NAME", "value": "x"}]}, "printable ASCII"), + ({"env": [{"name": "BAD\tNAME", "value": "x"}]}, "printable ASCII"), ({"env": [{"name": "GRAPH_URL", "value": "a"}, {"name": "GRAPH_URL", "value": "b"}]}, "duplicate"), ({"persistance": {"enabled": False}}, "persistance"), ) From 5dddbf75d07da4b6ceb0a937e4789922f2016012 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 04:49:39 +0800 Subject: [PATCH 12/38] fix(helm): complete RAG test package license header --- helm/rag_tests/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/helm/rag_tests/__init__.py b/helm/rag_tests/__init__.py index de87732428..8f4d73a13c 100644 --- a/helm/rag_tests/__init__.py +++ b/helm/rag_tests/__init__.py @@ -1,5 +1,16 @@ # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Support modules for the optional RAG Helm contract.""" From 7181b07e8fa5e6ecb5b8fa134ba21e6b55eb362c Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 05:27:58 +0800 Subject: [PATCH 13/38] fix(helm): harden RAG validation and startup --- helm/hugegraph/docs/rag.md | 12 ++-- helm/hugegraph/templates/_rag.tpl | 6 +- .../templates/_rag_validate_metadata.tpl | 4 +- .../templates/_rag_validate_runtime.tpl | 3 + .../templates/_rag_validate_security.tpl | 3 + helm/hugegraph/templates/rag-deployment.yaml | 5 +- helm/rag_tests/state.py | 56 +++++++++++++++++++ helm/rag_tests/validation.py | 13 ++++- helm/test_helm_rag.py | 4 +- 9 files changed, 94 insertions(+), 12 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index e0e4a4dc3e..268931dffb 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -24,9 +24,11 @@ The chart derives the complete connection to the Server in the same release: - `GRAPH_PWD` comes from the existing Server administrator Secret when Server authentication is enabled. -Do not repeat a Server Pod IP, REST port, graph name, graph space or administrator -Secret for this topology. A custom `rag.port` is also applied consistently to the -RAG process, container, Service and probes. +Do not repeat a Server Pod IP, REST port, graph space or administrator Secret for +this topology. `GRAPH_NAME` cannot be inferred from the Server values and defaults +to `hugegraph`; set `rag.hugegraph.graphName` when the target graph differs. A +custom `rag.port` is also applied consistently to the RAG process, container, +Service and probes. The supplied overlay keeps the ordinary settings in one reviewed file: @@ -219,8 +221,8 @@ environment remain persistent and externally editable. The application may write the current values back during the run; the same reconciliation happens before the next start. -A custom `HUGEGRAPH_LLM_ENV_PATH` in `rag.env` follows the same rule. Make that -path writable and mount persistent storage there when it must survive Pod +A custom `HUGEGRAPH_LLM_ENV_PATH` in `rag.env` follows the same rule. It must be +non-empty and writable; mount persistent storage there when it must survive Pod replacement. ### Read and update files from outside the Pod diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index 29373ce291..1b3fbba147 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -106,7 +106,11 @@ password {{- $rag := get .Values "rag" | default dict -}} {{- $persistence := get $rag "persistence" | default dict -}} {{- if and (hasKey $persistence "enabled") (ne (get $persistence "enabled" | toString) "") -}} -{{- get $persistence "enabled" -}} +{{- $enabled := get $persistence "enabled" -}} +{{- if not (kindIs "bool" $enabled) -}} +{{- fail "rag.persistence.enabled must be a boolean or null" -}} +{{- end -}} +{{- $enabled -}} {{- else -}} {{- $global := get .Values "persistence" | default dict -}} {{- if and (hasKey $global "enabled") (ne (get $global "enabled" | toString) "") -}} diff --git a/helm/hugegraph/templates/_rag_validate_metadata.tpl b/helm/hugegraph/templates/_rag_validate_metadata.tpl index 0f49d1f626..ac9718a479 100644 --- a/helm/hugegraph/templates/_rag_validate_metadata.tpl +++ b/helm/hugegraph/templates/_rag_validate_metadata.tpl @@ -34,8 +34,8 @@ {{- if not $name -}} {{- fail (printf "rag.env[%d].name must not be empty" $index) -}} {{- end -}} -{{- if not (regexMatch `^[\x20-\x3c\x3e-\x7e]+$` $name) -}} -{{- fail (printf "rag.env[%d].name must use printable ASCII characters other than '='" $index) -}} +{{- if not (regexMatch `^[-._a-zA-Z][-._a-zA-Z0-9]*$` $name) -}} +{{- fail (printf "rag.env[%d].name must match [-._a-zA-Z][-._a-zA-Z0-9]* for Kubernetes 1.23 compatibility" $index) -}} {{- end -}} {{- if hasKey $seenEnv $name -}} {{- fail (printf "rag.env contains duplicate name %s" $name) -}} diff --git a/helm/hugegraph/templates/_rag_validate_runtime.tpl b/helm/hugegraph/templates/_rag_validate_runtime.tpl index 0d27799758..bbe3fcb153 100644 --- a/helm/hugegraph/templates/_rag_validate_runtime.tpl +++ b/helm/hugegraph/templates/_rag_validate_runtime.tpl @@ -45,6 +45,9 @@ {{- fail "rag.service.nodePort must be an integer" -}} {{- end -}} {{- $nodePort := int $rawNodePort -}} +{{- if and (ne $rawNodePort "0") (eq $nodePort 0) -}} +{{- fail "rag.service.nodePort must fit an integer and be 0/unset or between 1 and 65535" -}} +{{- end -}} {{- if and (ne $nodePort 0) (or (lt $nodePort 1) (gt $nodePort 65535)) -}} {{- fail "rag.service.nodePort must be 0/unset or between 1 and 65535" -}} {{- end -}} diff --git a/helm/hugegraph/templates/_rag_validate_security.tpl b/helm/hugegraph/templates/_rag_validate_security.tpl index 233553c116..558086bbdc 100644 --- a/helm/hugegraph/templates/_rag_validate_security.tpl +++ b/helm/hugegraph/templates/_rag_validate_security.tpl @@ -44,6 +44,9 @@ {{- if ne (get $seccomp "type" | default "" | toString) "RuntimeDefault" -}} {{- fail (printf "%s.seccompProfile.type must stay RuntimeDefault" $scope) -}} {{- end -}} +{{- if hasKey $seccomp "localhostProfile" -}} +{{- fail (printf "%s.seccompProfile.localhostProfile must be unset when type is RuntimeDefault" $scope) -}} +{{- end -}} {{- end -}} {{- end -}} {{- if hasKey $podContext "fsGroup" -}} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 3fce8c1842..31246b94fd 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -146,7 +146,10 @@ spec: from dotenv import dotenv_values, unset_key - env_path = Path(os.environ["HUGEGRAPH_LLM_ENV_PATH"]) + env_path_value = os.environ.get("HUGEGRAPH_LLM_ENV_PATH") + if not env_path_value or not env_path_value.strip(): + raise SystemExit("HUGEGRAPH_LLM_ENV_PATH must not be empty") + env_path = Path(env_path_value) env_path.parent.mkdir(parents=True, exist_ok=True) env_path.touch(exist_ok=True) for key in dotenv_values(env_path): diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index a2f8e64fb5..713a46a417 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -16,6 +16,13 @@ """State and network regression coverage for the optional RAG component.""" +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile + from .support import ( RAG_EPHEMERAL_ENV, RAG_MOUNT, @@ -55,6 +62,55 @@ def test_persistence_seeds_resources_and_reconciles_runtime_environment(self): environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], ) + def test_reconciliation_script_executes_and_rejects_empty_env_path(self): + if importlib.util.find_spec("dotenv") is None: + self.skipTest("python-dotenv is required to execute the rendered reconciliation block") + from dotenv import dotenv_values + + startup = self.container(self.documents({}))["args"][0] + marker = "python - <<'PY'\n" + self.assertIn(marker, startup) + script = startup.split(marker, 1)[1].split("\nPY\n", 1)[0] + + pod_key = "RAG_TEST_POD_VALUE_7F11" + file_key = "RAG_TEST_FILE_VALUE_7F11" + with tempfile.TemporaryDirectory(prefix="rag-reconcile-") as directory: + env_path = Path(directory) / ".env" + env_path.write_text( + f"{pod_key}=stale\n{file_key}=keep\n", + encoding="utf-8", + ) + environment = os.environ.copy() + environment[pod_key] = "fresh" + environment.pop(file_key, None) + environment["HUGEGRAPH_LLM_ENV_PATH"] = str(env_path) + result = subprocess.run( + [sys.executable, "-"], + input=script, + capture_output=True, + text=True, + env=environment, + timeout=30, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + values = dotenv_values(env_path) + self.assertNotIn(pod_key, values) + self.assertEqual("keep", values[file_key]) + + environment["HUGEGRAPH_LLM_ENV_PATH"] = "" + result = subprocess.run( + [sys.executable, "-"], + input=script, + capture_output=True, + text=True, + env=environment, + timeout=30, + check=False, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("HUGEGRAPH_LLM_ENV_PATH must not be empty", result.stderr) + def test_env_from_is_rendered_and_covered_by_generic_precedence(self): documents = self.documents({ "envFrom": [{"secretRef": {"name": "rag-model"}}], diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 0067abd1e7..2cfe413933 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -25,6 +25,7 @@ def test_invalid_contracts_fail_before_render(self): ({"service": {"type": "ExternalName"}}, "service.type"), ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), + ({"service": {"type": "NodePort", "nodePort": "9" * 100}}, "nodePort"), ({"port": 0}, "rag.port"), ({"port": 8001.5}, "rag.port"), ({"replicas": 0}, "rag.replicas"), @@ -33,20 +34,28 @@ def test_invalid_contracts_fail_before_render(self): ({"podSecurityContext": {"runAsUser": 0}}, "runAsUser"), ({"podSecurityContext": {"fsGroup": 0}}, "fsGroup"), ({"podSecurityContext": {"seccompProfile": {"type": "Unconfined"}}}, "seccompProfile"), + ({"podSecurityContext": {"seccompProfile": { + "type": "RuntimeDefault", "localhostProfile": "profiles/rag.json", + }}}, "localhostProfile"), ({"securityContext": {"privileged": True}}, "privileged"), ({"securityContext": {"allowPrivilegeEscalation": True}}, "allowPrivilegeEscalation"), ({"securityContext": {"seccompProfile": {"type": "Unconfined"}}}, "seccompProfile"), + ({"securityContext": {"seccompProfile": { + "type": "RuntimeDefault", "localhostProfile": "profiles/rag.json", + }}}, "localhostProfile"), ({"securityContext": {"capabilities": {"add": ["SYS_ADMIN"]}}}, "capabilities.add"), ({"securityContext": {"capabilities": {"drop": ["NET_RAW"]}}}, "capabilities.drop"), ({"serviceAccount": {"automountServiceAccountToken": True}}, "automountServiceAccountToken"), ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), + ({"persistence": {"enabled": 1}}, "persistence.enabled"), ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), ({"extraVolumeMounts": [{"name": "other", "mountPath": RAG_MOUNT}]}, "extraVolumeMounts"), ({"extraVolumes": [{"name": "data", "emptyDir": {}}]}, "reserved volume name data"), - ({"env": [{"name": "BAD=NAME", "value": "x"}]}, "printable ASCII"), - ({"env": [{"name": "BAD\tNAME", "value": "x"}]}, "printable ASCII"), + ({"env": [{"name": "BAD=NAME", "value": "x"}]}, "Kubernetes 1.23"), + ({"env": [{"name": "BAD NAME", "value": "x"}]}, "Kubernetes 1.23"), + ({"env": [{"name": "BAD\tNAME", "value": "x"}]}, "Kubernetes 1.23"), ({"env": [{"name": "GRAPH_URL", "value": "a"}, {"name": "GRAPH_URL", "value": "b"}]}, "duplicate"), ({"persistance": {"enabled": False}}, "persistance"), ) diff --git a/helm/test_helm_rag.py b/helm/test_helm_rag.py index 3bb4ae1324..1e00165763 100644 --- a/helm/test_helm_rag.py +++ b/helm/test_helm_rag.py @@ -25,7 +25,9 @@ from rag_tests.support import RagChartBase from rag_tests.validation import RagValidationMixin from rag_tests.wiring import RagWiringMixin -except ModuleNotFoundError: +except ModuleNotFoundError as error: + if error.name != "rag_tests": + raise from helm.rag_tests.basic import RagBasicMixin from helm.rag_tests.source_contract import RagSourceContractTest from helm.rag_tests.state import RagStateMixin From 855d2a391fedc40d534df2c0fc189a2cebdba1c4 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 05:36:10 +0800 Subject: [PATCH 14/38] fix(helm): purge stale RAG credentials on restart --- helm/hugegraph/docs/rag.md | 31 ++-- helm/hugegraph/templates/rag-deployment.yaml | 47 +++++- helm/hugegraph/values-cluster.yaml | 7 +- helm/rag_tests/state.py | 144 ++++++++++++++++--- 4 files changed, 194 insertions(+), 35 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 268931dffb..a14aea922d 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -153,8 +153,10 @@ the chart does not generate. At every start, any key present in the current Pod environment—including keys loaded by `envFrom`—takes precedence over the same key in the retained `.env`. -Changing a provider Secret alone does not restart a Pod whose environment was -already populated, so restart RAG after the Secret changes: +Generic provider variables also take precedence over the field-specific values +that HugeGraph AI derives from them. Changing a provider Secret alone does not +restart a Pod whose environment was already populated, so restart RAG after the +Secret changes: ```bash kubectl -n hugegraph rollout restart deployment/hugegraph-hugegraph-rag @@ -204,9 +206,12 @@ an old file from masking a rotated Secret or changed Helm value, the container performs one narrow reconciliation before starting the application: 1. Read the configured `.env` file. -2. Remove only keys that are already present in the current Pod environment. -3. Leave every other `.env` key unchanged. -4. Start the native RAG process. +2. Remove keys that are already present in the current Pod environment. +3. With `autoConfigure=true`, remove every chart-owned `GRAPH_*` connection key, + including an intentionally absent password after an endpoint switch. +4. When a generic provider variable is present, remove the known field-specific + values that HugeGraph AI derives from it. +5. Leave every other `.env` key unchanged and start the native RAG process. The result is a simple precedence rule: @@ -214,12 +219,20 @@ The result is a simple precedence rule: current Kubernetes env / envFrom > retained .env > image defaults ``` +The graph rule models absence as configuration: switching from the chart-managed +Server to an explicit external `GRAPH_URL` cannot leave the previous local +administrator password active in the retained file. The provider rule covers the +current HugeGraph AI aliases for `OPENAI_API_KEY`, `OPENAI_BASE_URL`, +`OPENAI_EMBEDDING_BASE_URL` and `CO_API_URL`. Declare field-specific variables in +`rag.env` or `envFrom` when separate chat, extraction or text2gql values are +required; those explicit variables remain authoritative. + This covers the automatically generated graph connection, explicit `rag.env`, and arbitrary Secret or ConfigMap keys supplied through `envFrom` without a -second key registry. UI and operator settings that are not declared in the Pod -environment remain persistent and externally editable. The application may -write the current values back during the run; the same reconciliation happens -before the next start. +second user-maintained key registry. UI and operator settings that are not owned +by the chart or an active generic provider variable remain persistent and +externally editable. The application may write the current values back during +the run; the same reconciliation happens before the next start. A custom `HUGEGRAPH_LLM_ENV_PATH` in `rag.env` follows the same rule. It must be non-empty and writable; mount persistent storage there when it must survive Pod diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 31246b94fd..f33c1fca7e 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -136,9 +136,9 @@ spec: args: - | # HugeGraph AI loads an existing .env into os.environ before - # Pydantic reads settings. Remove only keys already supplied by - # this Pod (including envFrom Secrets) so the current Kubernetes - # environment wins while all other UI/operator edits persist. + # Pydantic reads settings. Remove values owned by the current Pod, + # by chart graph wiring, or by known upstream provider aliases. + # Unrelated UI/operator edits remain persistent. umask 007 python - <<'PY' import os @@ -152,8 +152,47 @@ spec: env_path = Path(env_path_value) env_path.parent.mkdir(parents=True, exist_ok=True) env_path.touch(exist_ok=True) + + managed_keys = set() + if "{{ $autoConfigure }}" == "true": + # autoConfigure owns the complete graph connection, including + # an intentionally absent password after switching endpoints. + managed_keys.update({ + "GRAPH_URL", + "GRAPH_NAME", + "GRAPH_SPACE", + "GRAPH_USER", + "GRAPH_PWD", + }) + + # HugeGraph AI derives these field-specific settings from the + # generic environment variables, then may persist the derived + # fields. Remove stale derivatives when the generic source is + # currently declarative so rotations take effect on restart. + derived_keys = { + "OPENAI_API_KEY": { + "OPENAI_CHAT_API_KEY", + "OPENAI_EXTRACT_API_KEY", + "OPENAI_TEXT2GQL_API_KEY", + }, + "OPENAI_BASE_URL": { + "OPENAI_CHAT_API_BASE", + "OPENAI_EXTRACT_API_BASE", + "OPENAI_TEXT2GQL_API_BASE", + }, + "OPENAI_EMBEDDING_BASE_URL": { + "OPENAI_EMBEDDING_API_BASE", + }, + "CO_API_URL": { + "COHERE_BASE_URL", + }, + } + for source, aliases in derived_keys.items(): + if source in os.environ: + managed_keys.update(aliases) + for key in dotenv_values(env_path): - if key in os.environ: + if key in os.environ or key in managed_keys: unset_key(str(env_path), key) PY exec python -m hugegraph_llm.demo.rag_demo.app \ diff --git a/helm/hugegraph/values-cluster.yaml b/helm/hugegraph/values-cluster.yaml index 54de702ec1..37bf258715 100644 --- a/helm/hugegraph/values-cluster.yaml +++ b/helm/hugegraph/values-cluster.yaml @@ -132,10 +132,9 @@ server: # Network isolation is selected by mode or an explicit enabled override. -# Optional RAG follows this preset only when explicitly enabled. Start with one -# persistent replica so the default ReadWriteOnce claim works on ordinary -# StorageClasses. Scale beyond one only after selecting a shared ReadWriteMany -# claim with rag.persistence.existingClaim or accessModes. +# Optional RAG follows this preset only when explicitly enabled. The current +# runtime supports exactly one active replica in every storage mode: RWX makes +# files shareable but does not coordinate in-process state or concurrent writes. rag: enabled: false replicas: 1 diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index 713a46a417..c2ecbd2128 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -16,7 +16,6 @@ """State and network regression coverage for the optional RAG component.""" -import importlib.util import os from pathlib import Path import subprocess @@ -33,6 +32,60 @@ ) +_DOTENV_STUB = '''from pathlib import Path + + +def dotenv_values(path): + values = {} + for raw in Path(path).read_text(encoding="utf-8").splitlines(): + if not raw or raw.lstrip().startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + values[key] = value + return values + + +def unset_key(path, key): + target = Path(path) + kept = [] + for raw in target.read_text(encoding="utf-8").splitlines(): + if "=" in raw and raw.split("=", 1)[0] == key: + continue + kept.append(raw) + target.write_text("\\n".join(kept) + ("\\n" if kept else ""), encoding="utf-8") + return True, key +''' + + +def _reconciliation_script(container): + startup = container["args"][0] + marker = "python - <<'PY'\n" + if marker not in startup: + raise AssertionError("rendered startup command does not contain the reconciliation script") + return startup.split(marker, 1)[1].split("\nPY\n", 1)[0] + + +def _execution_environment(directory, env_path): + module_dir = Path(directory) / "pythonpath" + module_dir.mkdir() + (module_dir / "dotenv.py").write_text(_DOTENV_STUB, encoding="utf-8") + environment = os.environ.copy() + existing = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = str(module_dir) + (os.pathsep + existing if existing else "") + environment["HUGEGRAPH_LLM_ENV_PATH"] = str(env_path) + return environment + + +def _read_env(path): + values = {} + for raw in Path(path).read_text(encoding="utf-8").splitlines(): + if not raw or raw.lstrip().startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + values[key] = value + return values + + class RagStateMixin: def test_persistence_seeds_resources_and_reconciles_runtime_environment(self): documents = self.documents({"persistence": {"enabled": True}}) @@ -49,7 +102,8 @@ def test_persistence_seeds_resources_and_reconciles_runtime_environment(self): container = self.container(documents) startup = container["args"][0] self.assertIn("dotenv_values", startup) - self.assertIn("if key in os.environ", startup) + self.assertIn("managed_keys", startup) + self.assertIn("OPENAI_CHAT_API_KEY", startup) self.assertIn("unset_key", startup) self.assertIn("exec python -m hugegraph_llm.demo.rag_demo.app", startup) @@ -62,28 +116,48 @@ def test_persistence_seeds_resources_and_reconciles_runtime_environment(self): environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], ) - def test_reconciliation_script_executes_and_rejects_empty_env_path(self): - if importlib.util.find_spec("dotenv") is None: - self.skipTest("python-dotenv is required to execute the rendered reconciliation block") - from dotenv import dotenv_values - - startup = self.container(self.documents({}))["args"][0] - marker = "python - <<'PY'\n" - self.assertIn(marker, startup) - script = startup.split(marker, 1)[1].split("\nPY\n", 1)[0] + def test_reconciliation_executes_rotations_and_rejects_empty_env_path(self): + documents = self.documents({ + "env": [{"name": "GRAPH_URL", "value": "http://external:8080"}], + }) + container = self.container(documents) + self.assertNotIn("GRAPH_PWD", env_by_name(container)) + script = _reconciliation_script(container) pod_key = "RAG_TEST_POD_VALUE_7F11" file_key = "RAG_TEST_FILE_VALUE_7F11" + stale_values = { + pod_key: "stale", + file_key: "keep", + "GRAPH_URL": "http://old-internal:8080", + "GRAPH_NAME": "old-graph", + "GRAPH_PWD": "stale-local-admin-password", + "OPENAI_CHAT_API_KEY": "stale-provider-key", + "OPENAI_EXTRACT_API_KEY": "stale-provider-key", + "OPENAI_TEXT2GQL_API_KEY": "stale-provider-key", + "OPENAI_CHAT_API_BASE": "https://old-provider.invalid/v1", + "OPENAI_EXTRACT_API_BASE": "https://old-provider.invalid/v1", + "OPENAI_TEXT2GQL_API_BASE": "https://old-provider.invalid/v1", + "OPENAI_EMBEDDING_API_BASE": "https://old-embedding.invalid/v1", + "COHERE_BASE_URL": "https://old-cohere.invalid/v1/rerank", + } with tempfile.TemporaryDirectory(prefix="rag-reconcile-") as directory: env_path = Path(directory) / ".env" env_path.write_text( - f"{pod_key}=stale\n{file_key}=keep\n", + "".join(f"{key}={value}\n" for key, value in stale_values.items()), encoding="utf-8", ) - environment = os.environ.copy() - environment[pod_key] = "fresh" - environment.pop(file_key, None) - environment["HUGEGRAPH_LLM_ENV_PATH"] = str(env_path) + environment = _execution_environment(directory, env_path) + for key in stale_values: + environment.pop(key, None) + environment.update({ + pod_key: "fresh", + "GRAPH_URL": "http://external:8080", + "OPENAI_API_KEY": "fresh-provider-key", + "OPENAI_BASE_URL": "https://new-provider.invalid/v1", + "OPENAI_EMBEDDING_BASE_URL": "https://new-embedding.invalid/v1", + "CO_API_URL": "https://new-cohere.invalid/v1/rerank", + }) result = subprocess.run( [sys.executable, "-"], input=script, @@ -94,8 +168,10 @@ def test_reconciliation_script_executes_and_rejects_empty_env_path(self): check=False, ) self.assertEqual(0, result.returncode, result.stderr) - values = dotenv_values(env_path) - self.assertNotIn(pod_key, values) + values = _read_env(env_path) + removed = set(stale_values) - {file_key} + for key in removed: + self.assertNotIn(key, values, f"stale managed value {key} survived reconciliation") self.assertEqual("keep", values[file_key]) environment["HUGEGRAPH_LLM_ENV_PATH"] = "" @@ -111,6 +187,38 @@ def test_reconciliation_script_executes_and_rejects_empty_env_path(self): self.assertNotEqual(0, result.returncode) self.assertIn("HUGEGRAPH_LLM_ENV_PATH must not be empty", result.stderr) + def test_reconciliation_preserves_file_owned_settings_when_auto_configure_is_off(self): + container = self.container(self.documents({ + "hugegraph": {"autoConfigure": False}, + })) + script = _reconciliation_script(container) + retained = { + "GRAPH_URL": "http://operator-owned:8080", + "GRAPH_PWD": "operator-owned-password", + "OPENAI_CHAT_API_KEY": "operator-owned-provider-key", + } + with tempfile.TemporaryDirectory(prefix="rag-reconcile-owned-") as directory: + env_path = Path(directory) / ".env" + env_path.write_text( + "".join(f"{key}={value}\n" for key, value in retained.items()), + encoding="utf-8", + ) + environment = _execution_environment(directory, env_path) + for key in retained: + environment.pop(key, None) + environment.pop("OPENAI_API_KEY", None) + result = subprocess.run( + [sys.executable, "-"], + input=script, + capture_output=True, + text=True, + env=environment, + timeout=30, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(retained, _read_env(env_path)) + def test_env_from_is_rendered_and_covered_by_generic_precedence(self): documents = self.documents({ "envFrom": [{"secretRef": {"name": "rag-model"}}], From 29a9257a7e954f2bca8860dbc1a9bae679d78175 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 07:23:54 +0800 Subject: [PATCH 15/38] refactor(helm): use native RAG configuration and external mounts (#367) Move configuration precedence and resource ownership to the RAG application, preserve full Server URLs, support external mounts, and fix RAG regression discovery across Helm versions. Requires the updated HugeGraph AI latest/1.8 image from AI #96. Helm 1+1+1 runtime acceptance, credential rotation and persistence checks were completed with a local source-overlay image. --- .github/actions/helm-checks/action.yml | 7 + helm/hugegraph/docs/rag.md | 437 +++++------------- helm/hugegraph/templates/NOTES.txt | 9 + helm/hugegraph/templates/_rag.tpl | 4 +- .../templates/_rag_validate_fields.tpl | 2 +- .../templates/_rag_validate_metadata.tpl | 4 +- helm/hugegraph/templates/rag-deployment.yaml | 99 +--- helm/hugegraph/templates/rag-pvc.yaml | 5 +- helm/hugegraph/tests/rag_component_test.yaml | 39 +- helm/hugegraph/values-rag.yaml | 13 +- helm/hugegraph/values.schema.json | 45 ++ helm/rag_tests/basic.py | 9 +- helm/rag_tests/source_contract.py | 74 --- helm/rag_tests/state.py | 212 +-------- helm/rag_tests/support.py | 2 +- helm/rag_tests/validation.py | 5 +- helm/rag_tests/wiring.py | 6 +- helm/test_helm_package.py | 6 +- helm/test_helm_rag.py | 2 - 19 files changed, 230 insertions(+), 750 deletions(-) delete mode 100644 helm/rag_tests/source_contract.py diff --git a/.github/actions/helm-checks/action.yml b/.github/actions/helm-checks/action.yml index 44e71e2fe4..69d37ee6b3 100644 --- a/.github/actions/helm-checks/action.yml +++ b/.github/actions/helm-checks/action.yml @@ -47,6 +47,13 @@ runs: shell: 'bash -e {0}' run: python3 -m unittest discover -s helm -p 'test_helm_package.py' -v + - name: RAG integration regression + shell: 'bash -e {0}' + run: | + helm lint helm/hugegraph -f helm/hugegraph/values-single.yaml -f helm/hugegraph/values-rag.yaml + helm lint helm/hugegraph -f helm/hugegraph/values-cluster.yaml -f helm/hugegraph/values-rag.yaml + python3 -m unittest discover -s helm -p 'test_helm_rag.py' -v + - name: SeaTunnel integration regression shell: 'bash -e {0}' run: python3 -m unittest discover -s helm -p 'test_helm_seatunnel.py' -v diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index a14aea922d..796b130d64 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -1,379 +1,156 @@ # Optional HugeGraph RAG deployment -The canonical HugeGraph chart can deploy the `hugegraph/rag` application from -[hugegraph-ai](https://github.com/apache/hugegraph-ai) beside PD, Store, Server -and Hubble. RAG is opt-in and does not change the existing graph topology. +The chart deploys one RAG instance that connects to the Server Service. This is +independent of whether HugeGraph uses one PD/Store/Server or a distributed cluster. +RAG remains a single process with a persistent resource directory. -![RAG Helm integration](https://raw.githubusercontent.com/apache/hugegraph/master/helm/hugegraph/docs/images/rag-helm-architecture.svg) +> This refactor requires [HugeGraph AI PR #96](https://github.com/hugegraph/hugegraph-ai/pull/96) in the +> `latest`/1.8 image. Publish that image before using this chart revision. The chart +> no longer patches application configuration at startup. -## Minimal same-release configuration - -For the normal case, the only RAG value that must be set is: - -```yaml -rag: - enabled: true -``` - -The chart derives the complete connection to the Server in the same release: - -- `GRAPH_URL` uses the release-scoped Server Service and `server.port`; -- `GRAPH_NAME` defaults to `hugegraph`; -- `GRAPH_SPACE` follows `server.graphSpace`; -- `GRAPH_USER` defaults to `admin`; and -- `GRAPH_PWD` comes from the existing Server administrator Secret when Server - authentication is enabled. - -Do not repeat a Server Pod IP, REST port, graph space or administrator Secret for -this topology. `GRAPH_NAME` cannot be inferred from the Server values and defaults -to `hugegraph`; set `rag.hugegraph.graphName` when the target graph differs. A -custom `rag.port` is also applied consistently to the RAG process, container, -Service and probes. - -The supplied overlay keeps the ordinary settings in one reviewed file: +## Start and open RAG ```bash -# One PD + Store + Server, plus one RAG Pod helm upgrade --install hugegraph ./helm/hugegraph \ -n hugegraph --create-namespace \ -f helm/hugegraph/values-single.yaml \ -f helm/hugegraph/values-rag.yaml - -# 3 + 3 + 3 HugeGraph cluster, plus one RAG Pod -helm upgrade --install hugegraph ./helm/hugegraph \ - -n hugegraph --create-namespace \ - -f helm/hugegraph/values-cluster.yaml \ - -f helm/hugegraph/values-rag.yaml +kubectl -n hugegraph port-forward svc/hugegraph-hugegraph-rag 8001:8001 ``` -When `rag.persistence.enabled` is unset, RAG inherits the top-level -`persistence.enabled` value. The chart default is `true`, so a bare -`--set rag.enabled=true` creates one retained PVC. Set the RAG-specific value only -when this component must differ from the rest of the release. +Open http://127.0.0.1:8001/. A different release name changes the Service name; +Helm prints the matching command. For distributed HugeGraph, use +`values-cluster.yaml` instead of `values-single.yaml`. -Model, embedding and prompt settings can be entered through the RAG interface -and retained on the PVC. Kubernetes Secrets remain preferable for declarative -production credentials. +The only required RAG value is `rag.enabled: true`. The chart derives the full +Server HTTP URL, graph space, admin identity and administrator Secret from the +release. Graph name defaults to `hugegraph`; override `rag.hugegraph.graphName` +for another graph. Model services and credentials are needed for inference. +The UI being reachable does not establish that Server or model services are ready. -## Automatic HugeGraph wiring and credentials +RAG uses ClusterIP by default. Put external exposure behind an authenticated +reverse proxy: the application's login does not protect every HTTP route. -With `rag.hugegraph.autoConfigure=true` and an empty `rag.hugegraph.url`, the -same-release connection above is generated automatically. A matching entry in -`rag.env` replaces its generated counterpart. +## Configuration ownership -The automatic `admin` identity is convenient for evaluation but has full Server -privileges. Production deployments should create a dedicated least-privilege -HugeGraph account and override only its identity and Secret while retaining the -automatically derived address: +The application reads configuration with this precedence: -```yaml -rag: - enabled: true - hugegraph: - username: rag-service - passwordSecret: - name: rag-hugegraph-credentials - key: password +```text +process environment / Secret > mounted .env > application defaults ``` -The chart-managed Server Secret is covered by a pod-template checksum. An -explicit `rag.hugegraph.passwordSecret` is covered by its live resource version -during an online `helm upgrade`. In both cases, the restarted Pod uses the new -Secret even when an older value exists in the retained `.env` file. +Within one source, a field-specific provider setting overrides its generic key. +For example, `OPENAI_CHAT_API_KEY` overrides `OPENAI_API_KEY` in the same source, +but a generic process key overrides stale field-specific values in `.env`. +Startup does not modify `.env` or rewrite an existing prompt file. UI saves write +only changed settings, and reject changes to environment-managed fields. Injected +Secrets and derived defaults are not copied into `.env`. -Offline renderers and GitOps controllers cannot use Helm `lookup` to observe a -live Secret's resource version. Rotating an explicit -`rag.hugegraph.passwordSecret` while applying otherwise identical rendered -manifests therefore needs an explicit rollout trigger after the Secret has been -updated. Either restart the Deployment: +Change deployment configuration outside the container, then restart RAG: ```bash -RELEASE=hugegraph -kubectl -n hugegraph rollout restart deployment/${RELEASE}-hugegraph-rag -``` - -or change a reviewed Pod annotation in the same GitOps change: - -```yaml -rag: - podAnnotations: - hugegraph.apache.org/credential-revision: "2026-11-04-1" +kubectl -n hugegraph rollout restart deployment/hugegraph-hugegraph-rag ``` -When `rag.env` explicitly supplies `GRAPH_URL`, the chart does not attach the -same-release administrator password to that endpoint. Supply an explicit -`passwordSecret` or `GRAPH_PWD` if the selected endpoint requires authentication. -Set `rag.hugegraph.autoConfigure=false` when the complete graph connection is -owned outside the chart. +There is no automatic hot reload. Secret changes require a restart too. An online +Helm upgrade observes an explicit graph password Secret's resource version; +offline/GitOps rendering requires an explicit rollout trigger or changed annotation. -## Model-provider Secrets +## Mount configuration without entering the container -Keep provider credentials out of values files. Explicit `env` entries are easy -to audit: +Manage a local `rag.env` and `config_prompt.yaml` and create a Secret/ConfigMap: ```bash -kubectl -n hugegraph create secret generic rag-model \ - --from-literal=OPENAI_API_KEY='replace-me' \ - --from-literal=OPENAI_EMBEDDING_API_KEY='replace-me' -``` - -```yaml -rag: - enabled: true - env: - - name: OPENAI_API_KEY - valueFrom: - secretKeyRef: - name: rag-model - key: OPENAI_API_KEY - - name: OPENAI_EMBEDDING_API_KEY - valueFrom: - secretKeyRef: - name: rag-model - key: OPENAI_EMBEDDING_API_KEY +kubectl -n hugegraph create secret generic rag-settings \ + --from-env-file=rag.env --dry-run=client -o yaml | kubectl apply -f - +kubectl -n hugegraph create configmap rag-prompts \ + --from-file=config_prompt.yaml --dry-run=client -o yaml | kubectl apply -f - ``` -`envFrom` is also supported without an extra key list: +Keep credentials out of Git. Use this overlay after `values-rag.yaml`: ```yaml rag: - enabled: true envFrom: - secretRef: - name: rag-model -``` - -Kubernetes gives an explicit `env` entry precedence over the same key imported -through `envFrom`. Because automatic same-release wiring injects `GRAPH_PWD` as -an explicit entry, an `envFrom` Secret cannot replace that particular key. Use -`rag.hugegraph.passwordSecret` (preferred), or declare a `rag.env` entry named -`GRAPH_PWD` with its own `secretKeyRef`; either choice suppresses the automatic -password entry. `envFrom` remains appropriate for provider and other keys that -the chart does not generate. - -At every start, any key present in the current Pod environment—including keys -loaded by `envFrom`—takes precedence over the same key in the retained `.env`. -Generic provider variables also take precedence over the field-specific values -that HugeGraph AI derives from them. Changing a provider Secret alone does not -restart a Pod whose environment was already populated, so restart RAG after the -Secret changes: - -```bash -kubectl -n hugegraph rollout restart deployment/hugegraph-hugegraph-rag -``` - -The exact Deployment name follows the Helm release name. The application may -write credentials to `.env`; treat the RAG PVC as sensitive, restrict access, -use storage encryption where appropriate, and include it in the relevant backup -policy. - -## Persistent and externally editable configuration - -When persistence is enabled, the claim is mounted at the native HugeGraph AI -resource directory: - -```text -/home/work/hugegraph-llm/src/hugegraph_llm/resources -``` - -The important writable locations are: - -```text -.env model and graph runtime settings -/demo/config_prompt.yaml prompts and graph-schema examples -/... indexes and other generated resources -``` - -The chart sets: - -```text -HUGEGRAPH_LLM_ENV_PATH=/.env -HUGEGRAPH_LLM_PROMPT_CONFIG_PATH=/demo/config_prompt.yaml -``` - -A restart or image replacement reuses these files. The init container copies -only files missing from the claim and uses an ordinary recursive copy so it does -not attempt privileged ownership or timestamp changes on the CSI volume root. -Existing operator or UI edits are never overwritten. Consequently, an image -upgrade does not replace an existing prompt or configuration file with a newer -packaged default; compare and update selected files manually when adopting those -defaults. - -### Configuration precedence on restart - -HugeGraph AI loads an existing `.env` before Pydantic reads settings. To prevent -an old file from masking a rotated Secret or changed Helm value, the container -performs one narrow reconciliation before starting the application: - -1. Read the configured `.env` file. -2. Remove keys that are already present in the current Pod environment. -3. With `autoConfigure=true`, remove every chart-owned `GRAPH_*` connection key, - including an intentionally absent password after an endpoint switch. -4. When a generic provider variable is present, remove the known field-specific - values that HugeGraph AI derives from it. -5. Leave every other `.env` key unchanged and start the native RAG process. - -The result is a simple precedence rule: - -```text -current Kubernetes env / envFrom > retained .env > image defaults -``` - -The graph rule models absence as configuration: switching from the chart-managed -Server to an explicit external `GRAPH_URL` cannot leave the previous local -administrator password active in the retained file. The provider rule covers the -current HugeGraph AI aliases for `OPENAI_API_KEY`, `OPENAI_BASE_URL`, -`OPENAI_EMBEDDING_BASE_URL` and `CO_API_URL`. Declare field-specific variables in -`rag.env` or `envFrom` when separate chat, extraction or text2gql values are -required; those explicit variables remain authoritative. - -This covers the automatically generated graph connection, explicit `rag.env`, -and arbitrary Secret or ConfigMap keys supplied through `envFrom` without a -second user-maintained key registry. UI and operator settings that are not owned -by the chart or an active generic provider variable remain persistent and -externally editable. The application may write the current values back during -the run; the same reconciliation happens before the next start. - -A custom `HUGEGRAPH_LLM_ENV_PATH` in `rag.env` follows the same rule. It must be -non-empty and writable; mount persistent storage there when it must survive Pod -replacement. - -### Read and update files from outside the Pod - -Locate the Pod through stable labels rather than assuming its generated name: - -```bash -NS=hugegraph -RELEASE=hugegraph -MOUNT=/home/work/hugegraph-llm/src/hugegraph_llm/resources -POD=$(kubectl -n "$NS" get pod \ - -l "app.kubernetes.io/instance=${RELEASE},app.kubernetes.io/component=rag" \ - -o jsonpath='{.items[0].metadata.name}') -``` - -Export, edit locally, then replace the files atomically: - -```bash -kubectl -n "$NS" exec "$POD" -- cat "$MOUNT/.env" > rag.env -kubectl -n "$NS" exec "$POD" -- cat "$MOUNT/demo/config_prompt.yaml" \ - > config_prompt.yaml - -# Edit the local files. Do not commit rag.env. -kubectl -n "$NS" exec -i "$POD" -- sh -c \ - 'umask 007; tmp="$1.tmp"; cat > "$tmp" && mv "$tmp" "$1"' sh \ - "$MOUNT/.env" < rag.env -kubectl -n "$NS" exec -i "$POD" -- sh -c \ - 'tmp="$1.tmp"; cat > "$tmp" && mv "$tmp" "$1"' sh \ - "$MOUNT/demo/config_prompt.yaml" < config_prompt.yaml - -kubectl -n "$NS" rollout restart deployment/${RELEASE}-hugegraph-rag -``` - -Back up a file before replacing it. Do not print `.env` in shared logs. - -For GitOps, provision and seed a PVC separately, then use it directly: - -```yaml -rag: - enabled: true - persistence: - existingClaim: managed-rag-resources -``` - -## Persistence lifecycle and single-replica contract - -A chart-generated RAG PVC has `helm.sh/resource-policy: keep`. Disabling RAG or -uninstalling the release removes the workload but retains the claim and its data. -Inspect the generated name and delete it only after the data is no longer needed: - -```bash -kubectl -n hugegraph get pvc -kubectl -n hugegraph delete pvc hugegraph-hugegraph-rag-data -``` - -The current HugeGraph RAG application keeps mutable settings in one process and -writes the resource tree without cross-Pod coordination. An RWX volume would -make files mountable by multiple Pods, but would not synchronize in-memory -settings or serialize writes. The chart therefore requires `rag.replicas=1` and -uses `Recreate`, so an update never leaves two active RAG Pods. - -Disable all chart persistence for disposable environments: - -```yaml -persistence: - enabled: false -rag: - enabled: true -``` - -Or override only RAG: - -```yaml -rag: - enabled: true - persistence: - enabled: false -``` - -Without persistence, `.env` is created under `/tmp` and in-container edits are -lost when the Pod is replaced. - -## External HugeGraph deployment - -Set an explicit URL and credential source for another Server: + name: rag-settings + env: + - name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH + value: /etc/rag/config_prompt.yaml + extraVolumes: + - name: prompts + configMap: + name: rag-prompts + extraVolumeMounts: + - name: prompts + mountPath: /etc/rag + readOnly: true +``` + +The prompt file is operator-owned; edit its source and apply the ConfigMap rather +than saving it through the UI. The application can also read a mounted `.env`: +mount its Secret at a separate path and set `HUGEGRAPH_LLM_ENV_PATH` to that file. +A read-only `.env` is supported at startup; UI/API saves require a writable file. +Keep configuration mounts separate from the writable resource directory. + +Automatic graph settings are explicit environment entries and take precedence over +`envFrom`. To replace the automatic password use `rag.hugegraph.passwordSecret` +or an explicit `rag.env` entry named `GRAPH_PWD` with a `secretKeyRef`. +Set `rag.hugegraph.autoConfigure: false` to own the entire graph connection outside +the chart. With automatic wiring enabled, an endpoint without configured +credentials receives an explicit empty password so old file credentials cannot +leak to a different Server. + +## Persistent resources + +A StorageClass is required by default. RAG inherits `persistence.enabled` unless +`rag.persistence.enabled` is explicitly set. Its PVC is mounted at +`/var/lib/hugegraph-rag`; the application uses this directory for `.env`, +`demo/config_prompt.yaml`, indexes and generated resources. Missing bundled +examples are read directly from the image; startup does not copy resources or +generate configuration files. Explicit saves and index generation need a writable +resource directory. +Changing `rag.persistence.mountPath` moves the complete resource root. + +Explicit `HUGEGRAPH_LLM_ENV_PATH` and `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` overrides +can point to separate configuration mounts. For an existing externally managed +volume, set `rag.persistence.existingClaim`. Mount that volume in an administrative +job or manage its backing storage to edit files without a shell in the RAG Pod. +UI saves to writable configuration files survive container replacement. + +RAG requires exactly one replica and uses Recreate. The chart retains its PVC +when RAG is disabled or the release is uninstalled; remove the claim explicitly +only when its data is no longer needed. UI-entered credentials may be stored on +this volume, so treat it as sensitive. Image upgrades do not overwrite user files. + +For disposable deployments, set `rag.persistence.enabled: false`. File changes +are then lost on container replacement. + +## External Server and deployment options ```yaml rag: - enabled: true hugegraph: url: http://hugegraph-server.graphs.svc:8080 graphName: hugegraph graphSpace: DEFAULT username: rag-service passwordSecret: - name: external-hugegraph-credentials + name: rag-hugegraph-credentials key: password ``` -A custom Secret defaults to key `password` when `key` is omitted, although an -explicit key is clearer. - -## Security contexts and OpenShift - -The templates default to non-root UID/GID/fsGroup `1000`, dropped Linux -capabilities, disabled privilege escalation and RuntimeDefault seccomp. The main -RAG container intentionally keeps `resources: {}` because model, embedding and -index workloads need installation-specific sizing; only the small seeding init -container has generic defaults in `values-rag.yaml`. - -Platforms such as OpenShift restricted SCC assign the numeric identity at -admission time. Keep `runAsNonRoot: true`, but use `auto` to omit the fixed IDs: - -```yaml -rag: - podSecurityContext: - runAsUser: auto - runAsGroup: auto - fsGroup: auto - securityContext: - runAsUser: auto - runAsGroup: auto -``` - -When `fsGroup` is omitted, the chart also omits `fsGroupChangePolicy`. Confirm -that the platform makes the selected PVC writable for the assigned identity. - -## Exposure and NetworkPolicy - -The RAG Service defaults to `ClusterIP` in both `easy` and `strict` modes. This -is deliberately stricter than browser-oriented Server and Hubble defaults: the -current application login does not protect every HTTP route. +For production use, provision a dedicated least-privilege account instead of the +automatically connected administrator account. Keep full `http://` or `https://` +URLs for explicitly configured endpoints. -NodePort, LoadBalancer and Ingress are explicit choices. Put externally exposed -RAG behind an authenticated reverse proxy or another reviewed network boundary, -and apply model-provider spend limits. +RAG defaults to non-root UID/GID/fsGroup 1000 with dropped capabilities and no +ServiceAccount token. On platforms assigning identities, use `auto` for +`rag.podSecurityContext.runAsUser`, `runAsGroup`, `fsGroup` and their container +counterparts, and ensure the volume is writable. Size `rag.resources` for the +selected models, indexing workload and dataset. -In `mode=strict`, NetworkPolicy permits DNS and the same-release Server. Add the -Ingress controller and required model-provider destinations through -`rag.networkPolicy.extraIngress` and `rag.networkPolicy.extraEgress`. Policy -objects are effective only when the cluster CNI implements NetworkPolicy. +In strict mode, NetworkPolicy permits DNS and the same-release Server. Configure +model-provider destinations and the ingress controller through +`rag.networkPolicy.extraEgress` and `extraIngress`. Enforcement requires a +NetworkPolicy-capable CNI. diff --git a/helm/hugegraph/templates/NOTES.txt b/helm/hugegraph/templates/NOTES.txt index 7035eefd2b..7d08db3ce2 100644 --- a/helm/hugegraph/templates/NOTES.txt +++ b/helm/hugegraph/templates/NOTES.txt @@ -54,6 +54,7 @@ Watch startup (run each command in a separate terminal): kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }} -w kubectl get pvc -n {{ .Release.Namespace }} -w {{- $claims := 0 }} +{{- if and (.Values.rag | default dict).enabled (eq (include "hugegraph.rag.persistenceEnabled" .) "true") }}{{ $claims = add $claims 1 }}{{ end }} {{- if .Values.pd.storage.enabled }}{{ $claims = add $claims (int .Values.pd.replicas) }}{{ end }} {{- if .Values.store.storage.enabled }}{{ $claims = add $claims (int .Values.store.replicas) }}{{ end }} {{- if and $hubbleEnabled .Values.hubble.persistence.enabled }}{{ $claims = add $claims 1 }}{{ end }} @@ -71,6 +72,14 @@ Store waits for a PD quorum before startup. If a Pod remains in Init, inspect its init-container logs and PD readiness; see helm/hugegraph/docs/operations.md#troubleshooting. Access: +{{- if (.Values.rag | default dict).enabled }} + + RAG (single instance): + Port-forward: kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "hugegraph.rag.name" . }} {{ include "hugegraph.rag.port" . }}:{{ include "hugegraph.rag.port" . }} + Open http://127.0.0.1:{{ include "hugegraph.rag.port" . }}/ + External configuration and model credentials: helm/hugegraph/docs/rag.md + Configuration changes require a RAG rollout restart. Model credentials are needed for inference. +{{- end }} {{- if $vermeerEnabled }} {{- $vName := include "hugegraph.vermeer.masterName" . }} diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index 1b3fbba147..ed2bca0bd4 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -59,7 +59,7 @@ ClusterIP {{- if $url -}} {{- $url -}} {{- else -}} -{{- include "hugegraph.server.clientUrl" . | trimPrefix "http://" | trimPrefix "https://" -}} +{{- include "hugegraph.server.clientUrl" . -}} {{- end -}} {{- end }} @@ -122,7 +122,7 @@ password {{- define "hugegraph.rag.mountPath" -}} {{- $rag := get .Values "rag" | default dict -}} {{- $persistence := get $rag "persistence" | default dict -}} -{{- get $persistence "mountPath" | default "/home/work/hugegraph-llm/src/hugegraph_llm/resources" -}} +{{- get $persistence "mountPath" | default "/var/lib/hugegraph-rag" -}} {{- end }} {{- define "hugegraph.rag.envPath" -}} diff --git a/helm/hugegraph/templates/_rag_validate_fields.tpl b/helm/hugegraph/templates/_rag_validate_fields.tpl index 7a57903923..9d258ae533 100644 --- a/helm/hugegraph/templates/_rag_validate_fields.tpl +++ b/helm/hugegraph/templates/_rag_validate_fields.tpl @@ -17,7 +17,7 @@ {{- define "hugegraph.rag.validate.fields" -}} {{- $rag := get .Values "rag" | default dict -}} -{{- $allowedRagKeys := list "enabled" "replicas" "image" "port" "hugegraph" "resources" "initResources" "podSecurityContext" "securityContext" "nodeSelector" "tolerations" "affinity" "topologySpreadConstraints" "priorityClassName" "podAnnotations" "podLabels" "env" "envFrom" "terminationGracePeriodSeconds" "serviceAccount" "service" "ingress" "persistence" "extraVolumes" "extraVolumeMounts" "probes" "networkPolicy" -}} +{{- $allowedRagKeys := list "enabled" "replicas" "image" "port" "hugegraph" "resources" "podSecurityContext" "securityContext" "nodeSelector" "tolerations" "affinity" "topologySpreadConstraints" "priorityClassName" "podAnnotations" "podLabels" "env" "envFrom" "terminationGracePeriodSeconds" "serviceAccount" "service" "ingress" "persistence" "extraVolumes" "extraVolumeMounts" "probes" "networkPolicy" -}} {{- range $key, $_ := $rag -}} {{- if not (has $key $allowedRagKeys) -}} {{- fail (printf "rag contains unsupported field %s" $key) -}} diff --git a/helm/hugegraph/templates/_rag_validate_metadata.tpl b/helm/hugegraph/templates/_rag_validate_metadata.tpl index ac9718a479..4f9892c092 100644 --- a/helm/hugegraph/templates/_rag_validate_metadata.tpl +++ b/helm/hugegraph/templates/_rag_validate_metadata.tpl @@ -34,8 +34,8 @@ {{- if not $name -}} {{- fail (printf "rag.env[%d].name must not be empty" $index) -}} {{- end -}} -{{- if not (regexMatch `^[-._a-zA-Z][-._a-zA-Z0-9]*$` $name) -}} -{{- fail (printf "rag.env[%d].name must match [-._a-zA-Z][-._a-zA-Z0-9]* for Kubernetes 1.23 compatibility" $index) -}} +{{- if not (regexMatch `^[._a-zA-Z][-._a-zA-Z0-9]*$` $name) -}} +{{- fail (printf "rag.env[%d].name must match [._a-zA-Z][-._a-zA-Z0-9]* for Kubernetes 1.23 compatibility" $index) -}} {{- end -}} {{- if hasKey $seenEnv $name -}} {{- fail (printf "rag.env contains duplicate name %s" $name) -}} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index f33c1fca7e..be0749b9ce 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -106,97 +106,12 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} - {{- if $persistenceEnabled }} - initContainers: - - name: seed-resources - image: {{ include "hugegraph.rag.image" . | quote }} - imagePullPolicy: {{ get (get $rag "image" | default dict) "pullPolicy" | default "Always" }} - command: ["/bin/sh", "-ec"] - args: - - | - # Preserve operator and UI edits. Image upgrades add only files - # that are still missing from the persistent resource directory. - # Recursive copy deliberately avoids archive-mode metadata writes - # against a CSI volume root that remains owned by root. - umask 007 - cp -R -n /home/work/hugegraph-llm/src/hugegraph_llm/resources/. /data/ - securityContext: - {{- include "hugegraph.rag.securityContext" . | nindent 12 }} - resources: - {{- toYaml (get $rag "initResources" | default dict) | nindent 12 }} - volumeMounts: - - name: data - mountPath: /data - {{- end }} containers: - name: rag image: {{ include "hugegraph.rag.image" . | quote }} imagePullPolicy: {{ get (get $rag "image" | default dict) "pullPolicy" | default "Always" }} - command: ["/bin/sh", "-ec"] - args: - - | - # HugeGraph AI loads an existing .env into os.environ before - # Pydantic reads settings. Remove values owned by the current Pod, - # by chart graph wiring, or by known upstream provider aliases. - # Unrelated UI/operator edits remain persistent. - umask 007 - python - <<'PY' - import os - from pathlib import Path - - from dotenv import dotenv_values, unset_key - - env_path_value = os.environ.get("HUGEGRAPH_LLM_ENV_PATH") - if not env_path_value or not env_path_value.strip(): - raise SystemExit("HUGEGRAPH_LLM_ENV_PATH must not be empty") - env_path = Path(env_path_value) - env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.touch(exist_ok=True) - - managed_keys = set() - if "{{ $autoConfigure }}" == "true": - # autoConfigure owns the complete graph connection, including - # an intentionally absent password after switching endpoints. - managed_keys.update({ - "GRAPH_URL", - "GRAPH_NAME", - "GRAPH_SPACE", - "GRAPH_USER", - "GRAPH_PWD", - }) - - # HugeGraph AI derives these field-specific settings from the - # generic environment variables, then may persist the derived - # fields. Remove stale derivatives when the generic source is - # currently declarative so rotations take effect on restart. - derived_keys = { - "OPENAI_API_KEY": { - "OPENAI_CHAT_API_KEY", - "OPENAI_EXTRACT_API_KEY", - "OPENAI_TEXT2GQL_API_KEY", - }, - "OPENAI_BASE_URL": { - "OPENAI_CHAT_API_BASE", - "OPENAI_EXTRACT_API_BASE", - "OPENAI_TEXT2GQL_API_BASE", - }, - "OPENAI_EMBEDDING_BASE_URL": { - "OPENAI_EMBEDDING_API_BASE", - }, - "CO_API_URL": { - "COHERE_BASE_URL", - }, - } - for source, aliases in derived_keys.items(): - if source in os.environ: - managed_keys.update(aliases) - - for key in dotenv_values(env_path): - if key in os.environ or key in managed_keys: - unset_key(str(env_path), key) - PY - exec python -m hugegraph_llm.demo.rag_demo.app \ - --host 0.0.0.0 --port {{ include "hugegraph.rag.port" . | int | quote }} + command: ["python", "-m", "hugegraph_llm.demo.rag_demo.app"] + args: ["--host", "0.0.0.0", "--port", {{ include "hugegraph.rag.port" . | int | quote }}] securityContext: {{- include "hugegraph.rag.securityContext" . | nindent 12 }} ports: @@ -227,6 +142,16 @@ spec: name: {{ $passwordSecret | quote }} key: {{ include "hugegraph.rag.passwordSecretKey" . | quote }} {{- end }} + {{- if and $autoConfigure (not $injectPassword) (not (hasKey $envNames "GRAPH_PWD")) }} + # Explicitly clear a retained password when the selected endpoint + # has no configured credential. Absence must not revive old secrets. + - name: GRAPH_PWD + value: "" + {{- end }} + {{- if and $persistenceEnabled (not (hasKey $envNames "HUGEGRAPH_LLM_RESOURCE_PATH")) }} + - name: HUGEGRAPH_LLM_RESOURCE_PATH + value: {{ include "hugegraph.rag.mountPath" . | quote }} + {{- end }} {{- if not (hasKey $envNames "HUGEGRAPH_LLM_ENV_PATH") }} - name: HUGEGRAPH_LLM_ENV_PATH value: {{ include "hugegraph.rag.envPath" . | quote }} diff --git a/helm/hugegraph/templates/rag-pvc.yaml b/helm/hugegraph/templates/rag-pvc.yaml index 0472c1fba4..fe79a5a52c 100644 --- a/helm/hugegraph/templates/rag-pvc.yaml +++ b/helm/hugegraph/templates/rag-pvc.yaml @@ -18,7 +18,10 @@ {{ include "hugegraph.resolveValues" . }} {{- $rag := get .Values "rag" | default dict }} {{- $persistence := get $rag "persistence" | default dict }} -{{- $persistenceEnabled := eq (include "hugegraph.rag.persistenceEnabled" .) "true" }} +{{- $persistenceEnabled := false }} +{{- if get $rag "enabled" | default false }} +{{- $persistenceEnabled = eq (include "hugegraph.rag.persistenceEnabled" .) "true" }} +{{- end }} {{- if and (get $rag "enabled" | default false) $persistenceEnabled (not (get $persistence "existingClaim" | default "")) }} {{- include "hugegraph.rag.validate" . }} apiVersion: v1 diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml index bbbdbbda7a..5fc88f7e39 100644 --- a/helm/hugegraph/tests/rag_component_test.yaml +++ b/helm/hugegraph/tests/rag_component_test.yaml @@ -51,12 +51,8 @@ tests: - equal: path: spec.template.spec.automountServiceAccountToken value: false - - equal: - path: spec.template.spec.initContainers[0].name - value: seed-resources - - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: "cp -R -n" + - notExists: + path: spec.template.spec.initContainers - equal: path: spec.template.spec.containers[0].image value: hugegraph/rag:latest @@ -69,28 +65,23 @@ tests: - equal: path: spec.template.spec.containers[0].command value: - - /bin/sh - - -ec - - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "if key in os.environ" - - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "unset_key" - - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "--port \"8001\"" + - python + - -m + - hugegraph_llm.demo.rag_demo.app + - equal: + path: spec.template.spec.containers[0].args + value: ["--host", "0.0.0.0", "--port", "8001"] - equal: path: spec.template.spec.containers[0].ports[0].containerPort value: 8001 - equal: path: spec.template.spec.containers[0].volumeMounts[0].mountPath - value: /home/work/hugegraph-llm/src/hugegraph_llm/resources + value: /var/lib/hugegraph-rag - contains: path: spec.template.spec.containers[0].env content: name: GRAPH_URL - value: RELEASE-NAME-hugegraph-server.NAMESPACE.svc:8080 + value: http://RELEASE-NAME-hugegraph-server.NAMESPACE.svc:8080 - contains: path: spec.template.spec.containers[0].env content: @@ -103,12 +94,12 @@ tests: path: spec.template.spec.containers[0].env content: name: HUGEGRAPH_LLM_ENV_PATH - value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/.env + value: /var/lib/hugegraph-rag/.env - contains: path: spec.template.spec.containers[0].env content: name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH - value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml + value: /var/lib/hugegraph-rag/demo/config_prompt.yaml - it: passes a custom port to the process and Pod contract template: rag-deployment.yaml @@ -116,9 +107,9 @@ tests: rag.enabled: true rag.port: 9001 asserts: - - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "--port \"9001\"" + - equal: + path: spec.template.spec.containers[0].args[3] + value: "9001" - equal: path: spec.template.spec.containers[0].ports[0].containerPort value: 9001 diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml index 1e95af002e..b97c2a4e6d 100644 --- a/helm/hugegraph/values-rag.yaml +++ b/helm/hugegraph/values-rag.yaml @@ -41,13 +41,6 @@ rag: # embedding and indexing workloads vary widely. Size it for the selected # providers and dataset. resources: {} - initResources: - requests: - cpu: 10m - memory: 16Mi - limits: - cpu: 100m - memory: 64Mi # Non-root execution, RuntimeDefault seccomp, no added capabilities, disabled # privilege escalation and no ServiceAccount token are enforced by templates, @@ -91,8 +84,8 @@ rag: # Unset inherits the top-level persistence.enabled value (true by default). # The claim stores the editable .env, prompt configuration, indexes and - # resource tree. The init container adds missing image files but never - # overwrites files already changed on the claim. + # resource tree. Missing example assets are read from the image, so startup + # needs no seeding. Requires the paired HugeGraph AI configuration refactor. persistence: enabled: existingClaim: '' @@ -100,7 +93,7 @@ rag: storageClassName: '' accessModes: - ReadWriteOnce - mountPath: /home/work/hugegraph-llm/src/hugegraph_llm/resources + mountPath: /var/lib/hugegraph-rag extraVolumes: [] extraVolumeMounts: [] diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 595a5f6ab6..4cd14707f9 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -164,6 +164,51 @@ ] } } + }, + "rag": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "properties": { + "hugegraph": { + "type": "object", + "properties": { + "autoConfigure": { + "type": "boolean" + } + } + }, + "persistence": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + } + } + } + } + } + ] } }, "definitions": { diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index bc51028e2d..0ff7b87011 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -36,9 +36,8 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertEqual(1, deployment["spec"]["replicas"]) self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) self.assertEqual(8001, container["ports"][0]["containerPort"]) - self.assertEqual(["/bin/sh", "-ec"], container["command"]) - self.assertIn("exec python -m hugegraph_llm.demo.rag_demo.app", container["args"][0]) - self.assertIn("--host 0.0.0.0 --port \"8001\"", container["args"][0]) + self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) + self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) self.assertEqual("ClusterIP", service["spec"]["type"]) self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) @@ -67,7 +66,7 @@ def test_default_profile_uses_internal_server_contract(self): service, = component_resources(documents, kind="Service") environment = env_by_name(container) - self.assertEqual("fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"]) + self.assertEqual("http://fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"]) self.assertEqual("hugegraph", environment["GRAPH_NAME"]["value"]) self.assertEqual("DEFAULT", environment["GRAPH_SPACE"]["value"]) self.assertEqual("admin", environment["GRAPH_USER"]["value"]) @@ -80,7 +79,7 @@ def test_custom_port_reaches_process_pod_service_and_probes(self): documents = self.documents({"port": 9001}) container = self.container(documents) service, = component_resources(documents, kind="Service") - self.assertIn('--port "9001"', container["args"][0]) + self.assertEqual("9001", container["args"][-1]) self.assertEqual(9001, container["ports"][0]["containerPort"]) self.assertEqual(9001, service["spec"]["ports"][0]["port"]) for name in ("startupProbe", "readinessProbe", "livenessProbe"): diff --git a/helm/rag_tests/source_contract.py b/helm/rag_tests/source_contract.py deleted file mode 100644 index 4763208f36..0000000000 --- a/helm/rag_tests/source_contract.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Source-level contracts for the optional HugeGraph RAG component.""" - -import unittest - -import yaml - -from .support import CHART, RAG_MOUNT, RAG_PERSISTENT_ENV - - -class RagSourceContractTest(unittest.TestCase): - def test_profile_is_internal_and_inherits_persistence(self): - values = yaml.safe_load((CHART / "values-rag.yaml").read_text(encoding="utf-8")) - rag = values["rag"] - self.assertTrue(rag["enabled"]) - self.assertNotIn("replicas", rag, "topology presets own the single replica value") - self.assertEqual("hugegraph/rag", rag["image"]["repository"]) - self.assertEqual(8001, rag["port"]) - self.assertEqual({"autoConfigure": True}, rag["hugegraph"]) - self.assertEqual("ClusterIP", rag["service"]["type"]) - self.assertIsNone(rag["persistence"]["enabled"]) - self.assertEqual(RAG_MOUNT, rag["persistence"]["mountPath"]) - - def test_single_and_cluster_presets_keep_one_rag_replica(self): - single = yaml.safe_load((CHART / "values-single.yaml").read_text(encoding="utf-8")) - cluster = yaml.safe_load((CHART / "values-cluster.yaml").read_text(encoding="utf-8")) - self.assertEqual({"enabled": False, "replicas": 1}, single["rag"]) - self.assertEqual({"enabled": False, "replicas": 1}, cluster["rag"]) - - def test_template_validation_publishes_the_single_replica_and_key_contract(self): - helpers = "\n".join( - path.read_text(encoding="utf-8") - for path in sorted((CHART / "templates").glob("_rag*.tpl")) - ) - self.assertIn("rag.replicas must be 1", helpers) - self.assertIn("rag contains unsupported field", helpers) - self.assertIn("rag.persistence contains unsupported field", helpers) - - def test_templates_use_one_port_and_kubernetes_env_precedence(self): - deployment = (CHART / "templates" / "rag-deployment.yaml").read_text(encoding="utf-8") - service = (CHART / "templates" / "rag-service.yaml").read_text(encoding="utf-8") - helpers = "\n".join( - path.read_text(encoding="utf-8") - for path in sorted((CHART / "templates").glob("_rag*.tpl")) - ) - self.assertIn("hugegraph_llm.demo.rag_demo.app", deployment) - self.assertIn('containerPort: {{ include "hugegraph.rag.port" . | int }}', deployment) - self.assertIn("--port {{ include", deployment) - self.assertIn("targetPort: http", service) - self.assertIn('include "hugegraph.server.clientUrl"', helpers) - self.assertIn('trimPrefix "http://"', helpers) - self.assertIn('printf "%s/.env"', helpers) - self.assertIn("HUGEGRAPH_LLM_ENV_PATH", deployment) - self.assertIn("if key in os.environ", deployment) - self.assertIn("unset_key", deployment) - self.assertIn("cp -R -n", deployment) - self.assertNotIn("cp -a", deployment) - self.assertNotIn("touch /data/.hugegraph-rag-seeded", deployment) - self.assertIn(RAG_PERSISTENT_ENV.rsplit("/", 1)[0], helpers) diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index c2ecbd2128..1ecd33dcc7 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -16,216 +16,23 @@ """State and network regression coverage for the optional RAG component.""" -import os -from pathlib import Path -import subprocess -import sys -import tempfile - from .support import ( - RAG_EPHEMERAL_ENV, - RAG_MOUNT, - RAG_PERSISTENT_ENV, - component_resources, - env_by_name, - pod, + RAG_EPHEMERAL_ENV, RAG_MOUNT, component_resources, env_by_name, pod, ) -_DOTENV_STUB = '''from pathlib import Path - - -def dotenv_values(path): - values = {} - for raw in Path(path).read_text(encoding="utf-8").splitlines(): - if not raw or raw.lstrip().startswith("#") or "=" not in raw: - continue - key, value = raw.split("=", 1) - values[key] = value - return values - - -def unset_key(path, key): - target = Path(path) - kept = [] - for raw in target.read_text(encoding="utf-8").splitlines(): - if "=" in raw and raw.split("=", 1)[0] == key: - continue - kept.append(raw) - target.write_text("\\n".join(kept) + ("\\n" if kept else ""), encoding="utf-8") - return True, key -''' - - -def _reconciliation_script(container): - startup = container["args"][0] - marker = "python - <<'PY'\n" - if marker not in startup: - raise AssertionError("rendered startup command does not contain the reconciliation script") - return startup.split(marker, 1)[1].split("\nPY\n", 1)[0] - - -def _execution_environment(directory, env_path): - module_dir = Path(directory) / "pythonpath" - module_dir.mkdir() - (module_dir / "dotenv.py").write_text(_DOTENV_STUB, encoding="utf-8") - environment = os.environ.copy() - existing = environment.get("PYTHONPATH") - environment["PYTHONPATH"] = str(module_dir) + (os.pathsep + existing if existing else "") - environment["HUGEGRAPH_LLM_ENV_PATH"] = str(env_path) - return environment - - -def _read_env(path): - values = {} - for raw in Path(path).read_text(encoding="utf-8").splitlines(): - if not raw or raw.lstrip().startswith("#") or "=" not in raw: - continue - key, value = raw.split("=", 1) - values[key] = value - return values - - class RagStateMixin: - def test_persistence_seeds_resources_and_reconciles_runtime_environment(self): - documents = self.documents({"persistence": {"enabled": True}}) + def test_native_process_owns_configuration_and_resource_initialization(self): + documents = self.documents({}) deployment = self.deployment(documents) - pvc, = component_resources(documents, kind="PersistentVolumeClaim") - self.assertEqual("fixture-hugegraph-rag-data", pvc["metadata"]["name"]) - self.assertEqual("Recreate", deployment["spec"]["strategy"]["type"]) - - init, = pod(deployment)["spec"]["initContainers"] - self.assertIn("cp -R -n", init["args"][0]) - self.assertNotIn("cp -a", init["args"][0]) - self.assertNotIn(".hugegraph-rag-seeded", init["args"][0]) - - container = self.container(documents) - startup = container["args"][0] - self.assertIn("dotenv_values", startup) - self.assertIn("managed_keys", startup) - self.assertIn("OPENAI_CHAT_API_KEY", startup) - self.assertIn("unset_key", startup) - self.assertIn("exec python -m hugegraph_llm.demo.rag_demo.app", startup) - - mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] - self.assertEqual(RAG_MOUNT, mount["mountPath"]) - environment = env_by_name(container) - self.assertEqual(RAG_PERSISTENT_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{RAG_MOUNT}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) - - def test_reconciliation_executes_rotations_and_rejects_empty_env_path(self): - documents = self.documents({ - "env": [{"name": "GRAPH_URL", "value": "http://external:8080"}], - }) container = self.container(documents) - self.assertNotIn("GRAPH_PWD", env_by_name(container)) - script = _reconciliation_script(container) - - pod_key = "RAG_TEST_POD_VALUE_7F11" - file_key = "RAG_TEST_FILE_VALUE_7F11" - stale_values = { - pod_key: "stale", - file_key: "keep", - "GRAPH_URL": "http://old-internal:8080", - "GRAPH_NAME": "old-graph", - "GRAPH_PWD": "stale-local-admin-password", - "OPENAI_CHAT_API_KEY": "stale-provider-key", - "OPENAI_EXTRACT_API_KEY": "stale-provider-key", - "OPENAI_TEXT2GQL_API_KEY": "stale-provider-key", - "OPENAI_CHAT_API_BASE": "https://old-provider.invalid/v1", - "OPENAI_EXTRACT_API_BASE": "https://old-provider.invalid/v1", - "OPENAI_TEXT2GQL_API_BASE": "https://old-provider.invalid/v1", - "OPENAI_EMBEDDING_API_BASE": "https://old-embedding.invalid/v1", - "COHERE_BASE_URL": "https://old-cohere.invalid/v1/rerank", - } - with tempfile.TemporaryDirectory(prefix="rag-reconcile-") as directory: - env_path = Path(directory) / ".env" - env_path.write_text( - "".join(f"{key}={value}\n" for key, value in stale_values.items()), - encoding="utf-8", - ) - environment = _execution_environment(directory, env_path) - for key in stale_values: - environment.pop(key, None) - environment.update({ - pod_key: "fresh", - "GRAPH_URL": "http://external:8080", - "OPENAI_API_KEY": "fresh-provider-key", - "OPENAI_BASE_URL": "https://new-provider.invalid/v1", - "OPENAI_EMBEDDING_BASE_URL": "https://new-embedding.invalid/v1", - "CO_API_URL": "https://new-cohere.invalid/v1/rerank", - }) - result = subprocess.run( - [sys.executable, "-"], - input=script, - capture_output=True, - text=True, - env=environment, - timeout=30, - check=False, - ) - self.assertEqual(0, result.returncode, result.stderr) - values = _read_env(env_path) - removed = set(stale_values) - {file_key} - for key in removed: - self.assertNotIn(key, values, f"stale managed value {key} survived reconciliation") - self.assertEqual("keep", values[file_key]) - - environment["HUGEGRAPH_LLM_ENV_PATH"] = "" - result = subprocess.run( - [sys.executable, "-"], - input=script, - capture_output=True, - text=True, - env=environment, - timeout=30, - check=False, - ) - self.assertNotEqual(0, result.returncode) - self.assertIn("HUGEGRAPH_LLM_ENV_PATH must not be empty", result.stderr) - - def test_reconciliation_preserves_file_owned_settings_when_auto_configure_is_off(self): - container = self.container(self.documents({ - "hugegraph": {"autoConfigure": False}, - })) - script = _reconciliation_script(container) - retained = { - "GRAPH_URL": "http://operator-owned:8080", - "GRAPH_PWD": "operator-owned-password", - "OPENAI_CHAT_API_KEY": "operator-owned-provider-key", - } - with tempfile.TemporaryDirectory(prefix="rag-reconcile-owned-") as directory: - env_path = Path(directory) / ".env" - env_path.write_text( - "".join(f"{key}={value}\n" for key, value in retained.items()), - encoding="utf-8", - ) - environment = _execution_environment(directory, env_path) - for key in retained: - environment.pop(key, None) - environment.pop("OPENAI_API_KEY", None) - result = subprocess.run( - [sys.executable, "-"], - input=script, - capture_output=True, - text=True, - env=environment, - timeout=30, - check=False, - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual(retained, _read_env(env_path)) + self.assertNotIn("initContainers", pod(deployment)["spec"]) + self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) + self.assertEqual(RAG_MOUNT, env_by_name(container)["HUGEGRAPH_LLM_RESOURCE_PATH"]["value"]) - def test_env_from_is_rendered_and_covered_by_generic_precedence(self): - documents = self.documents({ - "envFrom": [{"secretRef": {"name": "rag-model"}}], - }) - container = self.container(documents) + def test_env_from_reaches_the_native_application(self): + container = self.container(self.documents({"envFrom": [{"secretRef": {"name": "rag-model"}}]})) self.assertEqual([{"secretRef": {"name": "rag-model"}}], container["envFrom"]) - self.assertIn("if key in os.environ", container["args"][0]) def test_custom_mount_path_updates_both_durable_config_paths(self): mount_path = "/var/lib/hugegraph-rag" @@ -251,7 +58,7 @@ def test_persistence_can_be_disabled_for_disposable_runs(self): environment = env_by_name(container) self.assertEqual(RAG_EPHEMERAL_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) self.assertEqual( - f"{RAG_MOUNT}/demo/config_prompt.yaml", + "/home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml", environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], ) @@ -330,4 +137,3 @@ def test_global_registry_and_digest_render(self): expected = f"registry.example.com/hugegraph/rag@{digest}" deployment = self.deployment(documents) self.assertEqual(expected, pod(deployment)["spec"]["containers"][0]["image"]) - self.assertEqual(expected, pod(deployment)["spec"]["initContainers"][0]["image"]) diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py index d26b701123..0dc9516e5d 100644 --- a/helm/rag_tests/support.py +++ b/helm/rag_tests/support.py @@ -27,7 +27,7 @@ CHART = Path(__file__).resolve().parents[1] / "hugegraph" HELM_TEMPLATE_TIMEOUT_SECONDS = 120 -RAG_MOUNT = "/home/work/hugegraph-llm/src/hugegraph_llm/resources" +RAG_MOUNT = "/var/lib/hugegraph-rag" RAG_PERSISTENT_ENV = f"{RAG_MOUNT}/.env" RAG_EPHEMERAL_ENV = "/tmp/hugegraph-llm.env" RAG_ENV = RAG_PERSISTENT_ENV diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 2cfe413933..a600414c70 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -22,6 +22,9 @@ class RagValidationMixin: def test_invalid_contracts_fail_before_render(self): invalid = ( + ({"enabled": "false"}, "enabled"), + ({"hugegraph": {"autoConfigure": "false"}}, "autoConfigure"), + ({"env": [{"name": "-BAD", "value": "x"}]}, "rag.env"), ({"service": {"type": "ExternalName"}}, "service.type"), ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), @@ -63,7 +66,7 @@ def test_invalid_contracts_fail_before_render(self): with self.subTest(values=values): result = self.render(values) self.assertNotEqual(0, result.returncode) - self.assertIn(expected, result.stderr) + self.assertIn(expected.replace("/", "."), result.stderr.replace("/", ".")) def test_reserved_data_volume_and_mount_path_are_available_when_persistence_is_off(self): documents = self.documents({ diff --git a/helm/rag_tests/wiring.py b/helm/rag_tests/wiring.py index 0e4dc33a4b..53d57260a3 100644 --- a/helm/rag_tests/wiring.py +++ b/helm/rag_tests/wiring.py @@ -28,7 +28,7 @@ def test_single_and_cluster_presets_share_the_same_graph_contract(self): environment = env_by_name(self.container(documents)) self.assertEqual(1, deployment["spec"]["replicas"]) self.assertEqual( - "fixture-hugegraph-server.graphs.svc:8080", + "http://fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"], ) @@ -58,7 +58,7 @@ def test_external_graph_url_in_env_never_inherits_internal_admin_password(self): documents = self.documents({"env": [{"name": "GRAPH_URL", "value": "external:8080"}]}) environment = env_by_name(self.container(documents)) self.assertEqual("external:8080", environment["GRAPH_URL"]["value"]) - self.assertNotIn("GRAPH_PWD", environment) + self.assertEqual("", environment["GRAPH_PWD"]["value"]) self.assertNotIn("checksum/hugegraph-auth", pod(self.deployment(documents))["metadata"].get("annotations", {})) def test_external_server_uses_only_explicit_password_secret(self): @@ -77,7 +77,7 @@ def test_external_server_uses_only_explicit_password_secret(self): "autoConfigure": True, "url": "http://remote.graphs.svc:8080", }}) - self.assertNotIn("GRAPH_PWD", env_by_name(self.container(documents))) + self.assertEqual("", env_by_name(self.container(documents))["GRAPH_PWD"]["value"]) def test_custom_password_secret_defaults_to_password_key(self): documents = self.documents({"hugegraph": { diff --git a/helm/test_helm_package.py b/helm/test_helm_package.py index 181b7f2d5a..4a35d0c9f9 100644 --- a/helm/test_helm_package.py +++ b/helm/test_helm_package.py @@ -145,20 +145,18 @@ def test_packaged_optional_vermeer_images_include_native_token_helper(self): self.assertIn("/go/bin/vermeer-token", rendered) - def load_tests(loader, tests, pattern): """Load the RAG suite only for the narrow packaged-chart CI invocation.""" if pattern != "test_helm_package.py": return tests try: - from test_helm_rag import RagChartTest, RagSourceContractTest + from test_helm_rag import RagChartTest except ModuleNotFoundError as error: if error.name != "test_helm_rag": raise - from helm.test_helm_rag import RagChartTest, RagSourceContractTest + from helm.test_helm_rag import RagChartTest - tests.addTests(loader.loadTestsFromTestCase(RagSourceContractTest)) tests.addTests(loader.loadTestsFromTestCase(RagChartTest)) return tests diff --git a/helm/test_helm_rag.py b/helm/test_helm_rag.py index 1e00165763..f2ac4e1f4e 100644 --- a/helm/test_helm_rag.py +++ b/helm/test_helm_rag.py @@ -20,7 +20,6 @@ try: from rag_tests.basic import RagBasicMixin - from rag_tests.source_contract import RagSourceContractTest from rag_tests.state import RagStateMixin from rag_tests.support import RagChartBase from rag_tests.validation import RagValidationMixin @@ -29,7 +28,6 @@ if error.name != "rag_tests": raise from helm.rag_tests.basic import RagBasicMixin - from helm.rag_tests.source_contract import RagSourceContractTest from helm.rag_tests.state import RagStateMixin from helm.rag_tests.support import RagChartBase from helm.rag_tests.validation import RagValidationMixin From 81560b8648f325fbadbe456ddc4b08586fad612a Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 07:43:08 +0800 Subject: [PATCH 16/38] fix(helm): validate RAG probes and clarify storage changes --- helm/hugegraph/docs/rag.md | 14 +++++++++++-- helm/hugegraph/values.schema.json | 34 +++++++++++++++++++++++++++++++ helm/rag_tests/validation.py | 9 ++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 796b130d64..2922873cc3 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -99,7 +99,12 @@ or an explicit `rag.env` entry named `GRAPH_PWD` with a `secretKeyRef`. Set `rag.hugegraph.autoConfigure: false` to own the entire graph connection outside the chart. With automatic wiring enabled, an endpoint without configured credentials receives an explicit empty password so old file credentials cannot -leak to a different Server. +leak to a different Server. With `autoConfigure: false`, file credentials remain +valid for their configured endpoint. The application clears a file password when +a process-level `GRAPH_URL` switches to a different endpoint without a process +password; explicitly supply the new endpoint's `GRAPH_PWD` through a Secret (or an +empty value). When editing the mounted file itself, update its URL and credentials +together. ## Persistent resources @@ -124,7 +129,12 @@ only when its data is no longer needed. UI-entered credentials may be stored on this volume, so treat it as sensitive. Image upgrades do not overwrite user files. For disposable deployments, set `rag.persistence.enabled: false`. File changes -are then lost on container replacement. +are then lost on container replacement. Switching persistence off or changing +`rag.persistence.existingClaim` mounts the newly selected storage; the old +chart-managed PVC is retained, but its `.env`, prompts and indexes are neither +read nor migrated automatically. Back up and copy the required files to the new +volume before switching. Re-enabling the original claim exposes its existing +files again. ## External Server and deployment options diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 4cd14707f9..5636d19fd1 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -204,6 +204,40 @@ ] } } + }, + "probes": { + "type": "object", + "properties": { + "startup": { + "type": "object", + "properties": { + "successThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 1 + } + } + }, + "readiness": { + "type": "object", + "properties": { + "successThreshold": { + "type": "integer", + "minimum": 1 + } + } + }, + "liveness": { + "type": "object", + "properties": { + "successThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 1 + } + } + } + } } } } diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index a600414c70..4b2607f51f 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -29,6 +29,8 @@ def test_invalid_contracts_fail_before_render(self): ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), ({"service": {"type": "NodePort", "nodePort": "9" * 100}}, "nodePort"), + ({"probes": {"startup": {"successThreshold": 2}}}, "startup.successThreshold"), + ({"probes": {"liveness": {"successThreshold": 2}}}, "liveness.successThreshold"), ({"port": 0}, "rag.port"), ({"port": 8001.5}, "rag.port"), ({"replicas": 0}, "rag.replicas"), @@ -106,3 +108,10 @@ def test_platform_assigned_non_root_ids_can_be_requested_with_auto(self): self.assertTrue(context["runAsNonRoot"]) self.assertNotIn("runAsUser", context) self.assertNotIn("runAsGroup", context) + + def test_readiness_allows_multiple_successes(self): + workload = self.deployment(self.documents({ + "probes": {"readiness": {"successThreshold": 2}}, + })) + container = pod(workload)["spec"]["containers"][0] + self.assertEqual(2, container["readinessProbe"]["successThreshold"]) From b0da1116e648044ac658148e828319e202ae787b Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 07:47:40 +0800 Subject: [PATCH 17/38] test(helm): strengthen RAG storage and network assertions --- helm/rag_tests/state.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index 1ecd33dcc7..a86a005628 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -35,7 +35,7 @@ def test_env_from_reaches_the_native_application(self): self.assertEqual([{"secretRef": {"name": "rag-model"}}], container["envFrom"]) def test_custom_mount_path_updates_both_durable_config_paths(self): - mount_path = "/var/lib/hugegraph-rag" + mount_path = "/srv/hugegraph-rag" documents = self.documents({"persistence": {"mountPath": mount_path}}) container = self.container(documents) environment = env_by_name(container) @@ -97,11 +97,25 @@ def test_strict_network_policy_allows_only_scoped_server_and_dns_rules(self): ) egress = rag_policy["spec"]["egress"] - self.assertTrue(any( - peer.get("podSelector", {}).get("matchLabels", {}).get("app.kubernetes.io/component") == "server" - for rule in egress - for peer in rule.get("to", []) - )) + server_rules = [ + rule for rule in egress + if any( + peer.get("podSelector", {}).get("matchLabels", {}).get( + "app.kubernetes.io/component" + ) == "server" + for peer in rule.get("to", []) + ) + ] + self.assertEqual(1, len(server_rules)) + server_ports = { + (item.get("protocol"), item.get("port")) + for item in server_rules[0].get("ports", []) + } + self.assertEqual( + {("TCP", 8080)}, + server_ports, + "Internal Server egress must remain scoped to the REST port", + ) for rule in egress: if rule.get("to"): continue From bd5f88c378e2b32359f129041d6d234b8f809993 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 07:52:05 +0800 Subject: [PATCH 18/38] test(helm): validate enabled RAG presets with kubeconform --- .github/actions/helm-checks/action.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/helm-checks/action.yml b/.github/actions/helm-checks/action.yml index 69d37ee6b3..1e7b2604c9 100644 --- a/.github/actions/helm-checks/action.yml +++ b/.github/actions/helm-checks/action.yml @@ -89,6 +89,7 @@ runs: run: | for preset in "" "-f helm/hugegraph/values-single.yaml" "-f helm/hugegraph/values-cluster.yaml" "-f helm/hugegraph/values-strict.yaml" \ "-f helm/hugegraph/examples/values-seatunnel-standalone.yaml" "-f helm/hugegraph/examples/values-seatunnel-cluster.yaml" \ + "-f helm/hugegraph/values-single.yaml -f helm/hugegraph/values-rag.yaml" "-f helm/hugegraph/values-cluster.yaml -f helm/hugegraph/values-rag.yaml" \ "--set vermeer.enabled=true" "--set vermeer.enabled=true --set vermeer.mode=cluster --set mode=strict"; do # shellcheck disable=SC2086 # $preset intentionally splits into flags helm template ci helm/hugegraph $preset > /dev/null @@ -528,6 +529,7 @@ runs: tar -xzf /tmp/kc.tar.gz -C /tmp for preset in "" "-f helm/hugegraph/values-single.yaml" "-f helm/hugegraph/values-cluster.yaml" "-f helm/hugegraph/values-strict.yaml" \ "-f helm/hugegraph/examples/values-seatunnel-standalone.yaml" "-f helm/hugegraph/examples/values-seatunnel-cluster.yaml" \ + "-f helm/hugegraph/values-single.yaml -f helm/hugegraph/values-rag.yaml" "-f helm/hugegraph/values-cluster.yaml -f helm/hugegraph/values-rag.yaml" \ "--set vermeer.enabled=true" "--set vermeer.enabled=true --set vermeer.mode=cluster --set mode=strict"; do # shellcheck disable=SC2086 # $preset intentionally splits into flags helm template ci helm/hugegraph $preset | /tmp/kubeconform -strict -summary -kubernetes-version 1.23.0 From b83ba4e5dd62804aeed4cafb382eac197a86d5ea Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 09:25:54 +0800 Subject: [PATCH 19/38] fix(helm): validate RAG resource switches and clarify release gate --- helm/hugegraph/docs/rag.md | 13 +++++++++---- helm/hugegraph/values.schema.json | 16 ++++++++++++++++ helm/rag_tests/validation.py | 14 +++++++++++++- helm/rag_tests/wiring.py | 1 - 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 2922873cc3..8d2831f12b 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -5,8 +5,10 @@ independent of whether HugeGraph uses one PD/Store/Server or a distributed clust RAG remains a single process with a persistent resource directory. > This refactor requires [HugeGraph AI PR #96](https://github.com/hugegraph/hugegraph-ai/pull/96) in the -> `latest`/1.8 image. Publish that image before using this chart revision. The chart -> no longer patches application configuration at startup. +> `latest`/1.8 image. Merge #96 and publish and validate that image before merging +> or enabling this chart revision. Until then, this chart change remains blocked +> on that release; an older `latest` image is not supported. The chart no longer +> patches application configuration at startup. ## Start and open RAG @@ -40,8 +42,11 @@ process environment / Secret > mounted .env > application defaults ``` Within one source, a field-specific provider setting overrides its generic key. -For example, `OPENAI_CHAT_API_KEY` overrides `OPENAI_API_KEY` in the same source, -but a generic process key overrides stale field-specific values in `.env`. +For example, `OPENAI_CHAT_API_KEY` overrides `OPENAI_API_KEY` in the same source. +A generic process key overrides file credentials for the official OpenAI endpoint +or an endpoint also configured through the process environment. A custom endpoint +configured only in `.env` keeps its paired file key; an active OpenAI role without +that key is rejected rather than forwarding the generic process key. Startup does not modify `.env` or rewrite an existing prompt file. UI saves write only changed settings, and reject changes to environment-managed fields. Injected Secrets and derived defaults are not copied into `.env`. diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 5636d19fd1..916ba5609a 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -238,6 +238,22 @@ } } } + }, + "serviceAccount": { + "type": "object", + "properties": { + "create": { + "type": "boolean" + } + } + }, + "ingress": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } } } } diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 4b2607f51f..7cd3aab2da 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -16,13 +16,25 @@ """Validation regression coverage for the optional RAG component.""" -from .support import RAG_MOUNT, pod +from .support import RAG_MOUNT, component_resources, pod class RagValidationMixin: + def test_boolean_resource_switches_accept_true_and_false(self): + for enabled in (False, True): + with self.subTest(enabled=enabled): + documents = self.documents({ + "serviceAccount": {"create": enabled}, + "ingress": {"enabled": enabled}, + }) + for kind in ("ServiceAccount", "Ingress"): + self.assertEqual(int(enabled), len(component_resources(documents, kind=kind))) + def test_invalid_contracts_fail_before_render(self): invalid = ( ({"enabled": "false"}, "enabled"), + ({"serviceAccount": {"create": "false"}}, "serviceAccount.create"), + ({"ingress": {"enabled": "false"}}, "ingress.enabled"), ({"hugegraph": {"autoConfigure": "false"}}, "autoConfigure"), ({"env": [{"name": "-BAD", "value": "x"}]}, "rag.env"), ({"service": {"type": "ExternalName"}}, "service.type"), diff --git a/helm/rag_tests/wiring.py b/helm/rag_tests/wiring.py index 53d57260a3..789a6ca326 100644 --- a/helm/rag_tests/wiring.py +++ b/helm/rag_tests/wiring.py @@ -91,4 +91,3 @@ def test_internal_server_secret_keeps_its_configured_key(self): documents = self.documents({}, server={"auth": {"admin": {"key": "admin-password"}}}) password = env_by_name(self.container(documents))["GRAPH_PWD"]["valueFrom"]["secretKeyRef"] self.assertEqual("admin-password", password["key"]) - From 872cce63fa3a6acf85862153ab97b4b90e55c53b Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 10:39:19 +0800 Subject: [PATCH 20/38] test(helm): strengthen RAG resource and network boundaries --- helm/hugegraph/docs/rag.md | 3 +++ helm/rag_tests/state.py | 7 ++++--- helm/rag_tests/validation.py | 15 ++++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 8d2831f12b..c885508a8d 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -47,6 +47,9 @@ A generic process key overrides file credentials for the official OpenAI endpoin or an endpoint also configured through the process environment. A custom endpoint configured only in `.env` keeps its paired file key; an active OpenAI role without that key is rejected rather than forwarding the generic process key. +When a process override changes a graph, model or vector endpoint, supply its +matching credentials in the deployment. Credentials for another file endpoint +are ignored with a warning; equivalent endpoints keep their file credentials. Startup does not modify `.env` or rewrite an existing prompt file. UI saves write only changed settings, and reject changes to environment-managed fields. Injected Secrets and derived defaults are not copied into `.env`. diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index a86a005628..0d6c9198ce 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -46,6 +46,7 @@ def test_custom_mount_path_updates_both_durable_config_paths(self): ) mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] self.assertEqual(mount_path, mount["mountPath"]) + self.assertEqual(mount_path, environment["HUGEGRAPH_LLM_RESOURCE_PATH"]["value"]) def test_persistence_can_be_disabled_for_disposable_runs(self): documents = self.documents({"persistence": {"enabled": False}}) @@ -117,8 +118,9 @@ def test_strict_network_policy_allows_only_scoped_server_and_dns_rules(self): "Internal Server egress must remain scoped to the REST port", ) for rule in egress: - if rule.get("to"): + if rule in server_rules: continue + self.assertFalse(rule.get("to"), "Only the scoped Server rule may name destination peers") ports = {(item.get("protocol"), item.get("port")) for item in rule.get("ports", [])} self.assertEqual( {("UDP", 53), ("TCP", 53)}, @@ -137,8 +139,7 @@ def test_strict_network_policy_allows_only_scoped_server_and_dns_rules(self): for peer in rule.get("to", []) )) for rule in policy["spec"]["egress"]: - if rule.get("to"): - continue + self.assertFalse(rule.get("to"), "Without a Server peer, only the DNS rule may be rendered") ports = {(item.get("protocol"), item.get("port")) for item in rule.get("ports", [])} self.assertEqual({("UDP", 53), ("TCP", 53)}, ports) diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 7cd3aab2da..793bfd2af8 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -20,15 +20,16 @@ class RagValidationMixin: - def test_boolean_resource_switches_accept_true_and_false(self): - for enabled in (False, True): - with self.subTest(enabled=enabled): + def test_boolean_resource_switches_control_their_own_resources(self): + for service_account, ingress in ((False, False), (False, True), (True, False), (True, True)): + with self.subTest(serviceAccount=service_account, ingress=ingress): documents = self.documents({ - "serviceAccount": {"create": enabled}, - "ingress": {"enabled": enabled}, + "serviceAccount": {"create": service_account}, + "ingress": {"enabled": ingress}, }) - for kind in ("ServiceAccount", "Ingress"): - self.assertEqual(int(enabled), len(component_resources(documents, kind=kind))) + expected = {"ServiceAccount": int(service_account), "Ingress": int(ingress)} + for kind, count in expected.items(): + self.assertEqual(count, len(component_resources(documents, kind=kind))) def test_invalid_contracts_fail_before_render(self): invalid = ( From f9ada053f127b96991c615a76dc5f14e1cce6cba Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 11:50:45 +0800 Subject: [PATCH 21/38] fix(helm): validate optional RAG probe timing --- helm/hugegraph/values.schema.json | 67 +++++++++++++++++++++---------- helm/rag_tests/validation.py | 14 +++++++ 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 916ba5609a..4ba0958235 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -209,33 +209,35 @@ "type": "object", "properties": { "startup": { - "type": "object", - "properties": { - "successThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 1 + "allOf": [ + { + "$ref": "#/definitions/ragProbe" + }, + { + "properties": { + "successThreshold": { + "maximum": 1 + } + } } - } + ] }, "readiness": { - "type": "object", - "properties": { - "successThreshold": { - "type": "integer", - "minimum": 1 - } - } + "$ref": "#/definitions/ragProbe" }, "liveness": { - "type": "object", - "properties": { - "successThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 1 + "allOf": [ + { + "$ref": "#/definitions/ragProbe" + }, + { + "properties": { + "successThreshold": { + "maximum": 1 + } + } } - } + ] } } }, @@ -554,6 +556,29 @@ } } }, + "ragProbe": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "periodSeconds": { + "$ref": "#/definitions/probe/properties/periodSeconds" + }, + "failureThreshold": { + "$ref": "#/definitions/probe/properties/failureThreshold" + }, + "timeoutSeconds": { + "$ref": "#/definitions/probe/properties/timeoutSeconds" + }, + "initialDelaySeconds": { + "$ref": "#/definitions/probe/properties/initialDelaySeconds" + }, + "successThreshold": { + "$ref": "#/definitions/probe/properties/successThreshold" + } + } + }, "probes": { "type": "object", "additionalProperties": false, diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 793bfd2af8..ab5116d7f7 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -42,6 +42,11 @@ def test_invalid_contracts_fail_before_render(self): ({"service": {"type": "ClusterIP", "nodePort": 31001}}, "nodePort"), ({"service": {"type": "NodePort", "nodePort": "3100x"}}, "nodePort"), ({"service": {"type": "NodePort", "nodePort": "9" * 100}}, "nodePort"), + ({"probes": {"liveness": {"periodSeconds": -5}}}, "liveness.periodSeconds"), + ({"probes": {"startup": {"failureThreshold": 0}}}, "startup.failureThreshold"), + ({"probes": {"readiness": {"timeoutSeconds": 0}}}, "readiness.timeoutSeconds"), + ({"probes": {"startup": {"initialDelaySeconds": -1}}}, "startup.initialDelaySeconds"), + ({"probes": {"readiness": {"periodSeconds": "5"}}}, "readiness.periodSeconds"), ({"probes": {"startup": {"successThreshold": 2}}}, "startup.successThreshold"), ({"probes": {"liveness": {"successThreshold": 2}}}, "liveness.successThreshold"), ({"port": 0}, "rag.port"), @@ -128,3 +133,12 @@ def test_readiness_allows_multiple_successes(self): })) container = pod(workload)["spec"]["containers"][0] self.assertEqual(2, container["readinessProbe"]["successThreshold"]) + + def test_partial_probe_options_keep_defaults(self): + workload = self.deployment(self.documents({ + "probes": {"readiness": {"initialDelaySeconds": 0, "periodSeconds": 1}}, + }, use_profile=False)) + probe = pod(workload)["spec"]["containers"][0]["readinessProbe"] + self.assertEqual(0, probe["initialDelaySeconds"]) + self.assertEqual(1, probe["periodSeconds"]) + self.assertGreaterEqual(probe["failureThreshold"], 1) From 49d1b5a15810b5b357e3a77285723019738ddfb5 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 13:17:56 +0800 Subject: [PATCH 22/38] fix(helm): validate RAG grace period and probe paths --- helm/hugegraph/docs/rag.md | 14 ++++++++++++-- helm/hugegraph/values.schema.json | 7 ++++++- helm/rag_tests/validation.py | 8 +++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index c885508a8d..4982cd185e 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -51,8 +51,9 @@ When a process override changes a graph, model or vector endpoint, supply its matching credentials in the deployment. Credentials for another file endpoint are ignored with a warning; equivalent endpoints keep their file credentials. Startup does not modify `.env` or rewrite an existing prompt file. UI saves write -only changed settings, and reject changes to environment-managed fields. Injected -Secrets and derived defaults are not copied into `.env`. +changed settings and the target paired with a newly saved user credential; they +reject changes to environment-managed fields. Injected Secrets and unrelated +derived defaults are not copied into `.env`. Change deployment configuration outside the container, then restart RAG: @@ -172,3 +173,12 @@ In strict mode, NetworkPolicy permits DNS and the same-release Server. Configure model-provider destinations and the ingress controller through `rag.networkPolicy.extraEgress` and `extraIngress`. Enforcement requires a NetworkPolicy-capable CNI. + +RAG probe paths must start with `/`; `terminationGracePeriodSeconds` must be a +non-negative integer. Partial probe overrides keep the remaining defaults. +A process alias such as `OPENAI_BASE_URL` overrides a specific value in the file; +specific names win over aliases within the same source. A process endpoint +without a stored credential leaves credential input editable. Saving a user +credential also records its effective target for future pairing checks; managed +keys are never exported. LiteLLM models participate in target ownership because +their prefixes select the provider. diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 4ba0958235..aacab560a6 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -256,6 +256,10 @@ "type": "boolean" } } + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 0 } } } @@ -560,7 +564,8 @@ "type": "object", "properties": { "path": { - "type": "string" + "type": "string", + "pattern": "^/" }, "periodSeconds": { "$ref": "#/definitions/probe/properties/periodSeconds" diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index ab5116d7f7..640d426b98 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -49,6 +49,9 @@ def test_invalid_contracts_fail_before_render(self): ({"probes": {"readiness": {"periodSeconds": "5"}}}, "readiness.periodSeconds"), ({"probes": {"startup": {"successThreshold": 2}}}, "startup.successThreshold"), ({"probes": {"liveness": {"successThreshold": 2}}}, "liveness.successThreshold"), + ({"terminationGracePeriodSeconds": -10}, "terminationGracePeriodSeconds"), + ({"probes": {"readiness": {"path": "health"}}}, "readiness.path"), + ({"probes": {"startup": {"path": ""}}}, "startup.path"), ({"port": 0}, "rag.port"), ({"port": 8001.5}, "rag.port"), ({"replicas": 0}, "rag.replicas"), @@ -136,9 +139,12 @@ def test_readiness_allows_multiple_successes(self): def test_partial_probe_options_keep_defaults(self): workload = self.deployment(self.documents({ - "probes": {"readiness": {"initialDelaySeconds": 0, "periodSeconds": 1}}, + "probes": {"readiness": {"path": "/health", "initialDelaySeconds": 0, "periodSeconds": 1}}, + "terminationGracePeriodSeconds": 0, }, use_profile=False)) + self.assertEqual(0, pod(workload)["spec"]["terminationGracePeriodSeconds"]) probe = pod(workload)["spec"]["containers"][0]["readinessProbe"] + self.assertEqual("/health", probe["httpGet"]["path"]) self.assertEqual(0, probe["initialDelaySeconds"]) self.assertEqual(1, probe["periodSeconds"]) self.assertGreaterEqual(probe["failureThreshold"], 1) From ada4222bab17972ce0ec1032f9abeb7c8e3e9f78 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 13:44:01 +0800 Subject: [PATCH 23/38] fix(helm): validate RAG persistent volume settings --- helm/hugegraph/docs/rag.md | 4 ++++ helm/hugegraph/templates/rag-pvc.yaml | 2 +- helm/hugegraph/values.schema.json | 20 ++++++++++++++++++++ helm/rag_tests/support.py | 5 ++++- helm/rag_tests/validation.py | 21 +++++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 4982cd185e..4d603ba667 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -125,6 +125,10 @@ examples are read directly from the image; startup does not copy resources or generate configuration files. Explicit saves and index generation need a writable resource directory. Changing `rag.persistence.mountPath` moves the complete resource root. +`rag.persistence.size` must be a positive Kubernetes storage quantity, such as +`"10Gi"`, `"500M"` or `"1e9"` (quote numeric-looking quantities). Access modes must be a non-empty list of +`ReadWriteOnce`, `ReadOnlyMany`, `ReadWriteMany` or `ReadWriteOncePod`; support +for a mode still depends on the cluster and storage driver. Explicit `HUGEGRAPH_LLM_ENV_PATH` and `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` overrides can point to separate configuration mounts. For an existing externally managed diff --git a/helm/hugegraph/templates/rag-pvc.yaml b/helm/hugegraph/templates/rag-pvc.yaml index fe79a5a52c..4a3a978a55 100644 --- a/helm/hugegraph/templates/rag-pvc.yaml +++ b/helm/hugegraph/templates/rag-pvc.yaml @@ -44,5 +44,5 @@ spec: {{- end }} resources: requests: - storage: {{ get $persistence "size" | default "10Gi" }} + storage: {{ get $persistence "size" | default "10Gi" | quote }} {{- end }} diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index aacab560a6..55277cbca1 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -202,6 +202,26 @@ "boolean", "null" ] + }, + "size": { + "type": "string", + "pattern": "^\\+?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+|[numkKMGTPE]|[KMGTPE]i)?$", + "not": { + "pattern": "^\\+?0*(\\.0*)?([eE][+-]?[0-9]+|[numkKMGTPE]|[KMGTPE]i)?$" + } + }, + "accessModes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "ReadWriteOnce", + "ReadOnlyMany", + "ReadWriteMany", + "ReadWriteOncePod" + ] + } } } }, diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py index 0dc9516e5d..78589c4184 100644 --- a/helm/rag_tests/support.py +++ b/helm/rag_tests/support.py @@ -17,6 +17,7 @@ """Regression coverage for the optional HugeGraph RAG Helm component.""" from pathlib import Path +import json import shutil import subprocess import tempfile @@ -63,7 +64,9 @@ def render(self, rag=None, preset=None, mode=None, use_profile=True, **values): values["mode"] = mode with tempfile.TemporaryDirectory(prefix="rag-values-") as directory: configuration = Path(directory) / "values.yaml" - configuration.write_text(yaml.safe_dump(values), encoding="utf-8") + # JSON is valid YAML and preserves string types such as "1e9" + # across Python YAML 1.1 and Helm YAML 1.2 parsers. + configuration.write_text(json.dumps(values), encoding="utf-8") command = ["helm", "template", "fixture", str(CHART), "--namespace", "graphs"] if preset is not None: command.extend(["-f", str(CHART / preset)]) diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 640d426b98..b83c65b913 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -74,6 +74,12 @@ def test_invalid_contracts_fail_before_render(self): ({"serviceAccount": {"automountServiceAccountToken": True}}, "automountServiceAccountToken"), ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), ({"persistence": {"enabled": 1}}, "persistence.enabled"), + ({"persistence": {"size": "abc"}}, "persistence.size"), + ({"persistence": {"size": "-1Gi"}}, "persistence.size"), + ({"persistence": {"size": "0Gi"}}, "persistence.size"), + ({"persistence": {"size": "1e"}}, "persistence.size"), + ({"persistence": {"accessModes": ["ReadWriteSome"]}}, "accessModes"), + ({"persistence": {"accessModes": "ReadWriteOnce"}}, "accessModes"), ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), ({"extraVolumeMounts": [{"name": "other", "mountPath": RAG_MOUNT}]}, "extraVolumeMounts"), @@ -148,3 +154,18 @@ def test_partial_probe_options_keep_defaults(self): self.assertEqual(0, probe["initialDelaySeconds"]) self.assertEqual(1, probe["periodSeconds"]) self.assertGreaterEqual(probe["failureThreshold"], 1) + + def test_pvc_quantities_and_access_modes_preserve_supported_values(self): + for size, access_modes in ( + ("10Gi", ["ReadWriteOnce"]), + ("1.5Gi", ["ReadWriteMany"]), + ("500M", ["ReadOnlyMany"]), + ("1e9", ["ReadWriteOncePod"]), + ("1000", ["ReadWriteOnce"]), + (".5Gi", ["ReadWriteOnce", "ReadOnlyMany"]), + ): + with self.subTest(size=size, access_modes=access_modes): + documents = self.documents({"persistence": {"size": size, "accessModes": access_modes}}) + claim, = component_resources(documents, kind="PersistentVolumeClaim") + self.assertEqual(size, claim["spec"]["resources"]["requests"]["storage"]) + self.assertEqual(access_modes, claim["spec"]["accessModes"]) From 7b69834e5aa767b90a0d4bc1b32023482812b3f9 Mon Sep 17 00:00:00 2001 From: imbajin Date: Wed, 4 Nov 2026 15:16:52 +0800 Subject: [PATCH 24/38] fix(helm): reject invalid kilo suffix and preserve claim name types --- helm/hugegraph/docs/rag.md | 12 ++++++++---- helm/hugegraph/templates/rag-deployment.yaml | 2 +- helm/hugegraph/values.schema.json | 7 +++++-- helm/rag_tests/validation.py | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 4d603ba667..7fac305d5c 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -128,11 +128,14 @@ Changing `rag.persistence.mountPath` moves the complete resource root. `rag.persistence.size` must be a positive Kubernetes storage quantity, such as `"10Gi"`, `"500M"` or `"1e9"` (quote numeric-looking quantities). Access modes must be a non-empty list of `ReadWriteOnce`, `ReadOnlyMany`, `ReadWriteMany` or `ReadWriteOncePod`; support -for a mode still depends on the cluster and storage driver. +for a mode still depends on the cluster and storage driver. Decimal kilo uses +lowercase `k` (such as `"100k"`); binary kilo uses `Ki` (`"100Ki"`). Bare uppercase +`K` is not a valid Kubernetes suffix. Explicit `HUGEGRAPH_LLM_ENV_PATH` and `HUGEGRAPH_LLM_PROMPT_CONFIG_PATH` overrides can point to separate configuration mounts. For an existing externally managed -volume, set `rag.persistence.existingClaim`. Mount that volume in an administrative +volume, set `rag.persistence.existingClaim` to a string (quote numeric names such +as `"123"`). Mount that volume in an administrative job or manage its backing storage to edit files without a shell in the RAG Pod. UI saves to writable configuration files survive container replacement. @@ -181,8 +184,9 @@ NetworkPolicy-capable CNI. RAG probe paths must start with `/`; `terminationGracePeriodSeconds` must be a non-negative integer. Partial probe overrides keep the remaining defaults. A process alias such as `OPENAI_BASE_URL` overrides a specific value in the file; -specific names win over aliases within the same source. A process endpoint -without a stored credential leaves credential input editable. Saving a user +specific names win over aliases within the same source. Only credentials injected through the process are read-only. When a process +endpoint invalidates an old file credential, the application clears it on load; +the user can enter a replacement credential for the new target. Saving a user credential also records its effective target for future pairing checks; managed keys are never exported. LiteLLM models participate in target ownership because their prefixes select the provider. diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index be0749b9ce..9197f0320c 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -205,7 +205,7 @@ spec: {{- if $persistenceEnabled }} - name: data persistentVolumeClaim: - claimName: {{ include "hugegraph.rag.claimName" . }} + claimName: {{ include "hugegraph.rag.claimName" . | quote }} {{- end }} {{- with get $rag "extraVolumes" }} {{- toYaml . | nindent 8 }} diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 55277cbca1..962397f878 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -205,9 +205,9 @@ }, "size": { "type": "string", - "pattern": "^\\+?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+|[numkKMGTPE]|[KMGTPE]i)?$", + "pattern": "^\\+?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+|[numkMGTPE]|[KMGTPE]i)?$", "not": { - "pattern": "^\\+?0*(\\.0*)?([eE][+-]?[0-9]+|[numkKMGTPE]|[KMGTPE]i)?$" + "pattern": "^\\+?0*(\\.0*)?([eE][+-]?[0-9]+|[numkMGTPE]|[KMGTPE]i)?$" } }, "accessModes": { @@ -222,6 +222,9 @@ "ReadWriteOncePod" ] } + }, + "existingClaim": { + "type": "string" } } }, diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index b83c65b913..7004ab6545 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -75,6 +75,9 @@ def test_invalid_contracts_fail_before_render(self): ({"hugegraph": {"passwordSecret": {"key": "password"}}}, "passwordSecret.name"), ({"persistence": {"enabled": 1}}, "persistence.enabled"), ({"persistence": {"size": "abc"}}, "persistence.size"), + ({"persistence": {"size": "100K"}}, "persistence.size"), + ({"persistence": {"existingClaim": 123}}, "existingClaim"), + ({"persistence": {"existingClaim": True}}, "existingClaim"), ({"persistence": {"size": "-1Gi"}}, "persistence.size"), ({"persistence": {"size": "0Gi"}}, "persistence.size"), ({"persistence": {"size": "1e"}}, "persistence.size"), @@ -160,6 +163,8 @@ def test_pvc_quantities_and_access_modes_preserve_supported_values(self): ("10Gi", ["ReadWriteOnce"]), ("1.5Gi", ["ReadWriteMany"]), ("500M", ["ReadOnlyMany"]), + ("100k", ["ReadWriteOnce"]), + ("100Ki", ["ReadWriteOnce"]), ("1e9", ["ReadWriteOncePod"]), ("1000", ["ReadWriteOnce"]), (".5Gi", ["ReadWriteOnce", "ReadOnlyMany"]), @@ -169,3 +174,12 @@ def test_pvc_quantities_and_access_modes_preserve_supported_values(self): claim, = component_resources(documents, kind="PersistentVolumeClaim") self.assertEqual(size, claim["spec"]["resources"]["requests"]["storage"]) self.assertEqual(access_modes, claim["spec"]["accessModes"]) + + def test_existing_claim_names_remain_strings(self): + for name in ("123", "external-rag-data"): + with self.subTest(name=name): + documents = self.documents({"persistence": {"existingClaim": name}}) + self.assertEqual([], component_resources(documents, kind="PersistentVolumeClaim")) + volumes = pod(self.deployment(documents))["spec"]["volumes"] + data, = [volume for volume in volumes if volume["name"] == "data"] + self.assertEqual(name, data["persistentVolumeClaim"]["claimName"]) From 3485e72b01d47af69661bfbbe624633c0ae4faeb Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 14:03:22 +0800 Subject: [PATCH 25/38] fix(helm): reject mixed ReadWriteOncePod access modes --- helm/hugegraph/templates/_rag_validate_runtime.tpl | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/helm/hugegraph/templates/_rag_validate_runtime.tpl b/helm/hugegraph/templates/_rag_validate_runtime.tpl index bbe3fcb153..bd71c55a68 100644 --- a/helm/hugegraph/templates/_rag_validate_runtime.tpl +++ b/helm/hugegraph/templates/_rag_validate_runtime.tpl @@ -65,6 +65,13 @@ {{- if or (not $mountPath) (not (hasPrefix "/" $mountPath)) -}} {{- fail "rag.persistence.mountPath must be an absolute path" -}} {{- end -}} +{{- $existingClaim := trim (get $persistence "existingClaim" | default "" | toString) -}} +{{- if not $existingClaim -}} +{{- $accessModes := get $persistence "accessModes" | default (list "ReadWriteOnce") -}} +{{- if and (has "ReadWriteOncePod" $accessModes) (ne (len $accessModes) 1) -}} +{{- fail "rag.persistence.accessModes must contain only ReadWriteOncePod when that mode is selected" -}} +{{- end -}} +{{- end -}} {{- range $mount := get $rag "extraVolumeMounts" | default (list) -}} {{- if eq (get $mount "mountPath" | default "" | toString) $mountPath -}} {{- fail "rag.extraVolumeMounts must not use rag.persistence.mountPath when persistence is enabled" -}} From 8211e6c0c2e024c994d0fb8667229baa0d14814f Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 14:03:55 +0800 Subject: [PATCH 26/38] test(helm): cover exclusive ReadWriteOncePod mode --- helm/rag_tests/validation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 7004ab6545..8d87a7047b 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -83,6 +83,9 @@ def test_invalid_contracts_fail_before_render(self): ({"persistence": {"size": "1e"}}, "persistence.size"), ({"persistence": {"accessModes": ["ReadWriteSome"]}}, "accessModes"), ({"persistence": {"accessModes": "ReadWriteOnce"}}, "accessModes"), + ({"persistence": { + "accessModes": ["ReadWriteOncePod", "ReadWriteOnce"], + }}, "ReadWriteOncePod"), ({"persistence": {"mountPath": "relative/path"}}, "mountPath"), ({"extraVolumeMounts": [{"name": "other", "mountPath": RAG_MOUNT}]}, "extraVolumeMounts"), From 6237345e3bc6e5dc37b4a8717a99adfbc84b0c9c Mon Sep 17 00:00:00 2001 From: HugeGraph Contributor Date: Thu, 5 Nov 2026 10:47:05 +0000 Subject: [PATCH 27/38] docs(helm): align RAG configuration contract --- .../docs/images/rag-helm-architecture.svg | 8 ++-- helm/hugegraph/docs/rag.md | 42 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/helm/hugegraph/docs/images/rag-helm-architecture.svg b/helm/hugegraph/docs/images/rag-helm-architecture.svg index 27a1d18970..bdb5324cf3 100644 --- a/helm/hugegraph/docs/images/rag-helm-architecture.svg +++ b/helm/hugegraph/docs/images/rag-helm-architecture.svg @@ -16,7 +16,7 @@ --> HugeGraph Helm optional RAG integration - One chart manages HugeGraph core services and one optional single-active RAG application with durable resources and declarative environment precedence. + One chart manages HugeGraph core services and one optional single-active RAG application with durable settings and externally mounted configuration. @@ -45,7 +45,7 @@ PD1 or 3 replicasmetadata + placement Store1 or 3 replicasHStore data plane Server Servicetopology-neutral DNSauth Secret reused safely - RAG0 or 1 replicaRecreate · persistent .env + RAG0 or 1 replicaRecreate · persistent settings @@ -56,8 +56,8 @@ Single preset1 PD + 1 Store + 1 Server Cluster preset3 PD + 3 Store + 3 Server - Controlled precedenceSecret/env wins · PVC retained + App-owned settingsBootstrap + retained PVC - Persistent settings survive restarts; chart-managed and declarative keys are reconciled before startup. + Saved settings survive restarts; Helm mounts resources and provides bootstrap defaults. diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 873de0461c..e85e681dbb 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -36,30 +36,35 @@ reverse proxy: the application's login does not protect every HTTP route. ## Configuration ownership The web UI/API manages model connections. Deployment values are bootstrap -fallbacks; a saved complete connection takes precedence across restarts/upgrades: +fallbacks; saved connection fields take precedence across restarts/upgrades: ```text saved settings.yaml > process environment / Secret > mounted .env > application defaults ``` -Within one deployment source, a field-specific setting wins over its generic -alias. Supply the provider, destination, model and key together in that source; -the application does not combine a higher-priority connection with a lower-priority -file's credentials. Helm only mounts files and injects defaults; it does not -rewrite, migrate or reconcile the application's saved settings. +Deployment sources can be combined: for example, put the model URL and name in +a mounted `.env` and the key in a Secret. Within one source, a field-specific +setting wins over its generic alias. Helm only mounts files and injects defaults; +the application resolves their priority and saves its own settings. **Save configuration** validates and saves without contacting the model service. **Test saved connection** is separate; a failed test leaves the saved connection intact. Saved keys never appear in input fields. Blank/omitted key input keeps the -existing key; use **Clear API key** to remove it. A destination change (including -LiteLLM routing model changes) requires a new key or explicit clear. +existing key; use **Clear API key** to remove it. For a connection with an existing +key, changing the model service URL requires a new key or explicit clear; changing +the model at the same URL keeps the key. OpenAI-compatible services and Ollama are +supported. Model requests use the service's default temperature. Information extraction and Text2GQL dynamically use Chat until independently configured; **Use Chat connection** restores inheritance. Embedding/reranker stay separate. Login/tokens and Thin API write opt-in remain deployment controls. Saved application connections live in `settings.yaml` on the writable RAG PVC. -Changing Helm defaults does not replace them. Edit that file offline and restart, -or delete the relevant connection group to return to deployment defaults. +Changing Helm defaults does not replace saved connection fields. When a key is +injected from a Secret as an environment variable and the user saves without +replacing it, the application keeps a reference to that variable rather than +copying the value. +Edit the file offline and restart, or delete the relevant connection group to +return to deployment defaults. Do not edit the file while an application save is in progress. Startup does not modify `.env`, saved settings or prompt files. Normal requests do not persist request prompts. Only explicit saves/index generation require writable storage. @@ -72,9 +77,9 @@ kubectl -n hugegraph rollout restart deployment/hugegraph-hugegraph-rag There is no automatic file hot reload. An online Helm upgrade observes an explicit graph password Secret's resource version; offline/GitOps rendering requires an -explicit rollout trigger or changed annotation. A previously saved graph connection -also takes precedence; update it through its configuration entry or remove its -saved group before relying on changed deployment defaults. +explicit rollout trigger or changed annotation. Saved graph connection fields keep +their values; a password still referencing a Secret follows its updated value after +the restart. Update a user-saved password through its configuration entry. ## Mount configuration without entering the container @@ -156,13 +161,10 @@ when RAG is disabled or the release is uninstalled; remove the claim explicitly only when its data is no longer needed. UI-entered credentials may be stored on this volume, so treat it as sensitive. Image upgrades do not overwrite user files. -For disposable deployments, set `rag.persistence.enabled: false`. File changes -are then lost on container replacement. Switching persistence off or changing -`rag.persistence.existingClaim` mounts the newly selected storage; the old -chart-managed PVC is retained, but its `settings.yaml`, `.env`, prompts and indexes are neither -read nor migrated automatically. Back up and copy the required files to the new -volume before switching. Re-enabling the original claim exposes its existing -files again. +For disposable deployments, set `rag.persistence.enabled: false`; file changes +are then lost on container replacement. Changing or disabling the PVC does not +automatically copy existing settings, prompts or indexes to the new storage. +Back up any files you need before switching. ## External Server and deployment options From 9655878ab25f79f59b00f204774fe79b45f51c65 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 5 Nov 2026 10:47:26 +0000 Subject: [PATCH 28/38] test(helm): consolidate RAG chart coverage --- helm/hugegraph/tests/rag_component_test.yaml | 271 ------------------- helm/rag_tests/basic.py | 5 + 2 files changed, 5 insertions(+), 271 deletions(-) delete mode 100644 helm/hugegraph/tests/rag_component_test.yaml diff --git a/helm/hugegraph/tests/rag_component_test.yaml b/helm/hugegraph/tests/rag_component_test.yaml deleted file mode 100644 index 5fc88f7e39..0000000000 --- a/helm/hugegraph/tests/rag_component_test.yaml +++ /dev/null @@ -1,271 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -suite: Optional RAG component -tests: - - it: renders no RAG deployment while the component is disabled - template: rag-deployment.yaml - asserts: - - hasDocuments: - count: 0 - - - it: connects one persistent non-root RAG pod to the same-release Server - template: rag-deployment.yaml - set: - rag.enabled: true - asserts: - - isKind: - of: Deployment - - equal: - path: spec.replicas - value: 1 - - equal: - path: spec.strategy.type - value: Recreate - - equal: - path: spec.template.spec.securityContext.runAsUser - value: 1000 - - equal: - path: spec.template.spec.securityContext.runAsGroup - value: 1000 - - equal: - path: spec.template.spec.securityContext.fsGroup - value: 1000 - - equal: - path: spec.template.spec.securityContext.seccompProfile.type - value: RuntimeDefault - - equal: - path: spec.template.spec.automountServiceAccountToken - value: false - - notExists: - path: spec.template.spec.initContainers - - equal: - path: spec.template.spec.containers[0].image - value: hugegraph/rag:latest - - equal: - path: spec.template.spec.containers[0].securityContext.privileged - value: false - - equal: - path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation - value: false - - equal: - path: spec.template.spec.containers[0].command - value: - - python - - -m - - hugegraph_llm.demo.rag_demo.app - - equal: - path: spec.template.spec.containers[0].args - value: ["--host", "0.0.0.0", "--port", "8001"] - - equal: - path: spec.template.spec.containers[0].ports[0].containerPort - value: 8001 - - equal: - path: spec.template.spec.containers[0].volumeMounts[0].mountPath - value: /var/lib/hugegraph-rag - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRAPH_URL - value: http://RELEASE-NAME-hugegraph-server.NAMESPACE.svc:8080 - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRAPH_PWD - valueFrom: - secretKeyRef: - name: RELEASE-NAME-admin - key: password - - contains: - path: spec.template.spec.containers[0].env - content: - name: HUGEGRAPH_LLM_ENV_PATH - value: /var/lib/hugegraph-rag/.env - - contains: - path: spec.template.spec.containers[0].env - content: - name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH - value: /var/lib/hugegraph-rag/demo/config_prompt.yaml - - - it: passes a custom port to the process and Pod contract - template: rag-deployment.yaml - set: - rag.enabled: true - rag.port: 9001 - asserts: - - equal: - path: spec.template.spec.containers[0].args[3] - value: "9001" - - equal: - path: spec.template.spec.containers[0].ports[0].containerPort - value: 9001 - - - it: lets an explicit graph endpoint and Secret replace release wiring - template: rag-deployment.yaml - set: - rag.enabled: true - rag.hugegraph.url: http://remote.graphs.svc:8080 - rag.hugegraph.passwordSecret.name: remote-admin - rag.hugegraph.passwordSecret.key: credential - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRAPH_URL - value: http://remote.graphs.svc:8080 - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRAPH_PWD - valueFrom: - secretKeyRef: - name: remote-admin - key: credential - - - it: defaults a custom password Secret to the password key - template: rag-deployment.yaml - set: - rag.enabled: true - rag.hugegraph.passwordSecret.name: rag-credentials - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRAPH_PWD - valueFrom: - secretKeyRef: - name: rag-credentials - key: password - - - it: supports an explicitly disposable RAG pod without creating overlap - template: rag-deployment.yaml - set: - rag.enabled: true - rag.persistence.enabled: false - asserts: - - equal: - path: spec.strategy.type - value: Recreate - - notExists: - path: spec.template.spec.initContainers - - contains: - path: spec.template.spec.containers[0].env - content: - name: HUGEGRAPH_LLM_ENV_PATH - value: /tmp/hugegraph-llm.env - - - it: supports platform-assigned non-root IDs - template: rag-deployment.yaml - set: - rag.enabled: true - rag.podSecurityContext.runAsUser: auto - rag.podSecurityContext.runAsGroup: auto - rag.podSecurityContext.fsGroup: auto - rag.securityContext.runAsUser: auto - rag.securityContext.runAsGroup: auto - asserts: - - equal: - path: spec.template.spec.securityContext.runAsNonRoot - value: true - - notExists: - path: spec.template.spec.securityContext.runAsUser - - notExists: - path: spec.template.spec.securityContext.runAsGroup - - notExists: - path: spec.template.spec.securityContext.fsGroup - - notExists: - path: spec.template.spec.securityContext.fsGroupChangePolicy - - notExists: - path: spec.template.spec.containers[0].securityContext.runAsUser - - notExists: - path: spec.template.spec.containers[0].securityContext.runAsGroup - - - it: keeps the RAG service internal by default in easy mode - template: rag-service.yaml - set: - rag.enabled: true - asserts: - - equal: - path: spec.type - value: ClusterIP - - equal: - path: spec.ports[0].port - value: 8001 - - equal: - path: spec.ports[0].targetPort - value: http - - notExists: - path: spec.ports[0].nodePort - - - it: keeps the RAG service internal by default in strict mode - template: rag-service.yaml - set: - mode: strict - rag.enabled: true - asserts: - - equal: - path: spec.type - value: ClusterIP - - - it: permits an explicit NodePort exposure - template: rag-service.yaml - set: - rag.enabled: true - rag.service.type: NodePort - rag.service.nodePort: 31001 - asserts: - - equal: - path: spec.type - value: NodePort - - equal: - path: spec.ports[0].nodePort - value: 31001 - - - it: creates a retained claim by default after RAG is enabled - template: rag-pvc.yaml - set: - rag.enabled: true - asserts: - - isKind: - of: PersistentVolumeClaim - - equal: - path: metadata.annotations["helm.sh/resource-policy"] - value: keep - - equal: - path: spec.resources.requests.storage - value: 10Gi - - equal: - path: spec.accessModes[0] - value: ReadWriteOnce - - - it: creates no claim when RAG persistence is explicitly disabled - template: rag-pvc.yaml - set: - rag.enabled: true - rag.persistence.enabled: false - asserts: - - hasDocuments: - count: 0 - - - it: inherits the global persistence disable when RAG does not override it - template: rag-pvc.yaml - set: - rag.enabled: true - persistence.enabled: false - asserts: - - hasDocuments: - count: 0 diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index 0ff7b87011..dc57011c25 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -39,7 +39,12 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) self.assertEqual("ClusterIP", service["spec"]["type"]) + self.assertEqual("http", service["spec"]["ports"][0]["targetPort"]) self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) + self.assertEqual(["ReadWriteOnce"], pvc["spec"]["accessModes"]) + self.assertEqual("keep", pvc["metadata"]["annotations"]["helm.sh/resource-policy"]) + data_mount, = [mount for mount in container["volumeMounts"] if mount["name"] == "data"] + self.assertEqual(RAG_MOUNT, data_mount["mountPath"]) pod_security = pod(deployment)["spec"]["securityContext"] self.assertTrue(pod_security["runAsNonRoot"]) From 85a938220ee58d3eb77fd1f38b12244e7dc6c9b0 Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 5 Nov 2026 10:48:09 +0000 Subject: [PATCH 29/38] test(helm): preserve default service port checks --- helm/rag_tests/basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index dc57011c25..c7bcafc420 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -39,7 +39,9 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertEqual(["python", "-m", "hugegraph_llm.demo.rag_demo.app"], container["command"]) self.assertEqual(["--host", "0.0.0.0", "--port", "8001"], container["args"]) self.assertEqual("ClusterIP", service["spec"]["type"]) + self.assertEqual(8001, service["spec"]["ports"][0]["port"]) self.assertEqual("http", service["spec"]["ports"][0]["targetPort"]) + self.assertNotIn("nodePort", service["spec"]["ports"][0]) self.assertEqual("10Gi", pvc["spec"]["resources"]["requests"]["storage"]) self.assertEqual(["ReadWriteOnce"], pvc["spec"]["accessModes"]) self.assertEqual("keep", pvc["metadata"]["annotations"]["helm.sh/resource-policy"]) From b28f41f0cd81c2bca793737981d89464d7845eef Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 5 Nov 2026 10:49:28 +0000 Subject: [PATCH 30/38] refactor(helm): simplify single RAG replica handling --- helm/hugegraph/templates/_rag.tpl | 5 ----- helm/hugegraph/templates/_rag_validate_runtime.tpl | 9 --------- helm/hugegraph/templates/rag-deployment.yaml | 2 +- helm/hugegraph/values.schema.json | 7 +++++++ helm/rag_tests/state.py | 10 +++++++++- helm/rag_tests/support.py | 4 +++- helm/rag_tests/validation.py | 11 +++++++++++ 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index ed2bca0bd4..e9acb1b80f 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -23,11 +23,6 @@ {{- printf "%s-data" (include "hugegraph.rag.name" . | trunc 58 | trimSuffix "-") -}} {{- end }} -{{- define "hugegraph.rag.replicas" -}} -{{- $rag := get .Values "rag" | default dict -}} -{{- if hasKey $rag "replicas" -}}{{- get $rag "replicas" -}}{{- else -}}1{{- end -}} -{{- end }} - {{- define "hugegraph.rag.port" -}} {{- $rag := get .Values "rag" | default dict -}} {{- if hasKey $rag "port" -}}{{- get $rag "port" -}}{{- else -}}8001{{- end -}} diff --git a/helm/hugegraph/templates/_rag_validate_runtime.tpl b/helm/hugegraph/templates/_rag_validate_runtime.tpl index bd71c55a68..eb7ac55630 100644 --- a/helm/hugegraph/templates/_rag_validate_runtime.tpl +++ b/helm/hugegraph/templates/_rag_validate_runtime.tpl @@ -17,15 +17,6 @@ {{- define "hugegraph.rag.validate.runtime" -}} {{- $rag := get .Values "rag" | default dict -}} -{{- $rawReplicas := "1" -}} -{{- if hasKey $rag "replicas" -}}{{- $rawReplicas = get $rag "replicas" | toString -}}{{- end -}} -{{- if not (regexMatch "^[0-9]+$" $rawReplicas) -}} -{{- fail "rag.replicas must be the integer 1" -}} -{{- end -}} -{{- $replicas := int $rawReplicas -}} -{{- if ne $replicas 1 -}} -{{- fail "rag.replicas must be 1: the current HugeGraph RAG runtime keeps mutable configuration in one process and one writable resource tree, so multi-replica deployment is not supported" -}} -{{- end -}} {{- $rawPort := "8001" -}} {{- if hasKey $rag "port" -}}{{- $rawPort = get $rag "port" | toString -}}{{- end -}} {{- if not (regexMatch "^[0-9]+$" $rawPort) -}} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 9197f0320c..35240b04fe 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -39,7 +39,7 @@ metadata: {{- include "hugegraph.labels" . | nindent 4 }} app.kubernetes.io/component: rag spec: - replicas: {{ include "hugegraph.rag.replicas" . | int }} + replicas: 1 # The application keeps mutable runtime configuration in-process. Recreate # preserves the chart's single-active-pod contract even when persistence is off. strategy: diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 962397f878..3aaa09c00b 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -186,6 +186,10 @@ }, "then": { "properties": { + "replicas": { + "type": "integer", + "const": 1 + }, "hugegraph": { "type": "object", "properties": { @@ -197,6 +201,9 @@ "persistence": { "type": "object", "properties": { + "storageClassName": { + "type": "string" + }, "enabled": { "type": [ "boolean", diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index 0d6c9198ce..bf4bc946ba 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -16,6 +16,8 @@ """State and network regression coverage for the optional RAG component.""" +import yaml + from .support import ( RAG_EPHEMERAL_ENV, RAG_MOUNT, component_resources, env_by_name, pod, ) @@ -81,7 +83,13 @@ def test_multiple_replicas_are_rejected_even_with_shared_storage(self): with self.subTest(persistence=persistence): result = self.render({"replicas": 2, "persistence": persistence}) self.assertNotEqual(0, result.returncode) - self.assertIn("rag.replicas", result.stderr) + self.assertIn("rag.replicas", result.stderr.replace("/", ".")) + + def test_single_replica_remains_fixed_without_schema_validation(self): + result = self.render({"replicas": 2}, skip_schema_validation=True) + self.assertEqual(0, result.returncode, result.stderr) + documents = [document for document in yaml.safe_load_all(result.stdout) if document] + self.assertEqual(1, self.deployment(documents)["spec"]["replicas"]) def test_strict_network_policy_allows_only_scoped_server_and_dns_rules(self): documents = self.documents({}, mode="strict") diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py index 78589c4184..15268fc2c1 100644 --- a/helm/rag_tests/support.py +++ b/helm/rag_tests/support.py @@ -57,7 +57,7 @@ def setUpClass(cls): if shutil.which("helm") is None: raise unittest.SkipTest("Helm is required for rendered chart regression tests") - def render(self, rag=None, preset=None, mode=None, use_profile=True, **values): + def render(self, rag=None, preset=None, mode=None, use_profile=True, skip_schema_validation=False, **values): if rag is not None: values["rag"] = {"enabled": True, **rag} if mode is not None: @@ -68,6 +68,8 @@ def render(self, rag=None, preset=None, mode=None, use_profile=True, **values): # across Python YAML 1.1 and Helm YAML 1.2 parsers. configuration.write_text(json.dumps(values), encoding="utf-8") command = ["helm", "template", "fixture", str(CHART), "--namespace", "graphs"] + if skip_schema_validation: + command.append("--skip-schema-validation") if preset is not None: command.extend(["-f", str(CHART / preset)]) if rag is not None and use_profile: diff --git a/helm/rag_tests/validation.py b/helm/rag_tests/validation.py index 8d87a7047b..bd52ed3a21 100644 --- a/helm/rag_tests/validation.py +++ b/helm/rag_tests/validation.py @@ -78,6 +78,7 @@ def test_invalid_contracts_fail_before_render(self): ({"persistence": {"size": "100K"}}, "persistence.size"), ({"persistence": {"existingClaim": 123}}, "existingClaim"), ({"persistence": {"existingClaim": True}}, "existingClaim"), + ({"persistence": {"storageClassName": 123}}, "storageClassName"), ({"persistence": {"size": "-1Gi"}}, "persistence.size"), ({"persistence": {"size": "0Gi"}}, "persistence.size"), ({"persistence": {"size": "1e"}}, "persistence.size"), @@ -103,6 +104,16 @@ def test_invalid_contracts_fail_before_render(self): self.assertNotEqual(0, result.returncode) self.assertIn(expected.replace("/", "."), result.stderr.replace("/", ".")) + def test_storage_class_name_values_render_as_strings(self): + for storage_class, expected in (("", None), ("-", ""), ("123", "123"), ("fast", "fast")): + with self.subTest(storageClassName=storage_class): + documents = self.documents({"persistence": {"storageClassName": storage_class}}) + claim, = component_resources(documents, kind="PersistentVolumeClaim") + if expected is None: + self.assertNotIn("storageClassName", claim["spec"]) + else: + self.assertEqual(expected, claim["spec"]["storageClassName"]) + def test_reserved_data_volume_and_mount_path_are_available_when_persistence_is_off(self): documents = self.documents({ "persistence": {"enabled": False}, From 55857d1724a1700688ef6016ee3cc3a45d101c0e Mon Sep 17 00:00:00 2001 From: HugeGraph Contributor Date: Thu, 5 Nov 2026 10:49:32 +0000 Subject: [PATCH 31/38] refactor(helm): derive RAG config paths in application --- helm/hugegraph/templates/_rag.tpl | 16 --------------- helm/hugegraph/templates/rag-deployment.yaml | 8 ++++---- helm/rag_tests/basic.py | 10 ++++------ helm/rag_tests/state.py | 21 ++++++++++++++------ helm/rag_tests/support.py | 2 -- 5 files changed, 23 insertions(+), 34 deletions(-) diff --git a/helm/hugegraph/templates/_rag.tpl b/helm/hugegraph/templates/_rag.tpl index e9acb1b80f..54f95c873d 100644 --- a/helm/hugegraph/templates/_rag.tpl +++ b/helm/hugegraph/templates/_rag.tpl @@ -120,22 +120,6 @@ password {{- get $persistence "mountPath" | default "/var/lib/hugegraph-rag" -}} {{- end }} -{{- define "hugegraph.rag.envPath" -}} -{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} -{{- printf "%s/.env" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} -{{- else -}} -/tmp/hugegraph-llm.env -{{- end -}} -{{- end }} - -{{- define "hugegraph.rag.promptConfigPath" -}} -{{- if eq (include "hugegraph.rag.persistenceEnabled" .) "true" -}} -{{- printf "%s/demo/config_prompt.yaml" (include "hugegraph.rag.mountPath" . | trimSuffix "/") -}} -{{- else -}} -/home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml -{{- end -}} -{{- end }} - {{- define "hugegraph.rag.claimName" -}} {{- $rag := get .Values "rag" | default dict -}} {{- $persistence := get $rag "persistence" | default dict -}} diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 35240b04fe..3d97b383c0 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -152,13 +152,13 @@ spec: - name: HUGEGRAPH_LLM_RESOURCE_PATH value: {{ include "hugegraph.rag.mountPath" . | quote }} {{- end }} - {{- if not (hasKey $envNames "HUGEGRAPH_LLM_ENV_PATH") }} + {{- if and (not $persistenceEnabled) (not (hasKey $envNames "HUGEGRAPH_LLM_ENV_PATH")) }} - name: HUGEGRAPH_LLM_ENV_PATH - value: {{ include "hugegraph.rag.envPath" . | quote }} + value: /tmp/hugegraph-llm.env {{- end }} - {{- if not (hasKey $envNames "HUGEGRAPH_LLM_PROMPT_CONFIG_PATH") }} + {{- if and (not $persistenceEnabled) (not (hasKey $envNames "HUGEGRAPH_LLM_PROMPT_CONFIG_PATH")) }} - name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH - value: {{ include "hugegraph.rag.promptConfigPath" . | quote }} + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml {{- end }} {{- with get $rag "env" }} {{- toYaml . | nindent 12 }} diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index c7bcafc420..80b37bee1c 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -16,7 +16,7 @@ """Basic rendered contracts for the optional HugeGraph RAG component.""" -from .support import RAG_MOUNT, RAG_PERSISTENT_ENV, component_resources, env_by_name, pod +from .support import RAG_MOUNT, component_resources, env_by_name, pod class RagBasicMixin: @@ -60,11 +60,9 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertFalse(pod(deployment)["spec"]["automountServiceAccountToken"]) environment = env_by_name(container) - self.assertEqual(RAG_PERSISTENT_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{RAG_MOUNT}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) + self.assertEqual(RAG_MOUNT, environment["HUGEGRAPH_LLM_RESOURCE_PATH"]["value"]) + self.assertNotIn("HUGEGRAPH_LLM_ENV_PATH", environment) + self.assertNotIn("HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", environment) def test_default_profile_uses_internal_server_contract(self): documents = self.documents({}) diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index bf4bc946ba..aa9337c464 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -36,20 +36,29 @@ def test_env_from_reaches_the_native_application(self): container = self.container(self.documents({"envFrom": [{"secretRef": {"name": "rag-model"}}]})) self.assertEqual([{"secretRef": {"name": "rag-model"}}], container["envFrom"]) - def test_custom_mount_path_updates_both_durable_config_paths(self): + def test_custom_mount_path_sets_resource_root_for_application_defaults(self): mount_path = "/srv/hugegraph-rag" documents = self.documents({"persistence": {"mountPath": mount_path}}) container = self.container(documents) environment = env_by_name(container) - self.assertEqual(f"{mount_path}/.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) - self.assertEqual( - f"{mount_path}/demo/config_prompt.yaml", - environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"], - ) + self.assertNotIn("HUGEGRAPH_LLM_ENV_PATH", environment) + self.assertNotIn("HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", environment) mount, = [item for item in container["volumeMounts"] if item["name"] == "data"] self.assertEqual(mount_path, mount["mountPath"]) self.assertEqual(mount_path, environment["HUGEGRAPH_LLM_RESOURCE_PATH"]["value"]) + def test_explicit_configuration_paths_and_env_from_are_preserved(self): + explicit = [ + {"name": "HUGEGRAPH_LLM_ENV_PATH", "value": "/mounted/config.env"}, + {"name": "HUGEGRAPH_LLM_PROMPT_CONFIG_PATH", "value": "/mounted/prompts.yaml"}, + ] + env_from = [{"secretRef": {"name": "rag-model"}}] + container = self.container(self.documents({"env": explicit, "envFrom": env_from})) + environment = env_by_name(container) + self.assertEqual("/mounted/config.env", environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual("/mounted/prompts.yaml", environment["HUGEGRAPH_LLM_PROMPT_CONFIG_PATH"]["value"]) + self.assertEqual(env_from, container["envFrom"]) + def test_persistence_can_be_disabled_for_disposable_runs(self): documents = self.documents({"persistence": {"enabled": False}}) deployment = self.deployment(documents) diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py index 15268fc2c1..8da5b75749 100644 --- a/helm/rag_tests/support.py +++ b/helm/rag_tests/support.py @@ -29,9 +29,7 @@ CHART = Path(__file__).resolve().parents[1] / "hugegraph" HELM_TEMPLATE_TIMEOUT_SECONDS = 120 RAG_MOUNT = "/var/lib/hugegraph-rag" -RAG_PERSISTENT_ENV = f"{RAG_MOUNT}/.env" RAG_EPHEMERAL_ENV = "/tmp/hugegraph-llm.env" -RAG_ENV = RAG_PERSISTENT_ENV def component_resources(documents, component="rag", kind=None): From e84c1dca6191fa3ec191f833f37542280292a8a1 Mon Sep 17 00:00:00 2001 From: HugeGraph Contributor Date: Thu, 5 Nov 2026 12:49:14 +0000 Subject: [PATCH 32/38] docs(helm): clarify cluster RAG setup --- helm/hugegraph/docs/rag.md | 43 +++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index e85e681dbb..541f604abe 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -12,17 +12,37 @@ RAG remains a single process with a persistent resource directory. ## Start and open RAG +Run these commands from the repository root. Before installing, provide a +Kubernetes cluster with enough CPU and memory, working persistent-volume +provisioning, and access to the compatible latest/1.8 RAG image described above. +The cluster preset runs three PD, three Store and three Server replicas and +requires scheduling them across nodes; RAG itself still runs one replica. By +default the RAG resource PVC requests 10Gi, separately from PD and Store storage. + +For a distributed HugeGraph cluster with RAG: + +```bash +helm upgrade --install hugegraph ./helm/hugegraph \ + -n hugegraph --create-namespace \ + -f helm/hugegraph/values-cluster.yaml \ + -f helm/hugegraph/values-rag.yaml \ + --wait --timeout 15m +kubectl -n hugegraph port-forward svc/hugegraph-hugegraph-rag 8001:8001 +``` + +For a single-node HugeGraph deployment with RAG: + ```bash helm upgrade --install hugegraph ./helm/hugegraph \ -n hugegraph --create-namespace \ -f helm/hugegraph/values-single.yaml \ - -f helm/hugegraph/values-rag.yaml + -f helm/hugegraph/values-rag.yaml \ + --wait --timeout 15m kubectl -n hugegraph port-forward svc/hugegraph-hugegraph-rag 8001:8001 ``` Open http://127.0.0.1:8001/. A different release name changes the Service name; -Helm prints the matching command. For distributed HugeGraph, use -`values-cluster.yaml` instead of `values-single.yaml`. +Helm prints the matching command. The only required RAG value is `rag.enabled: true`. The chart derives the full Server HTTP URL, graph space, admin identity and administrator Secret from the @@ -30,6 +50,23 @@ release. Graph name defaults to `hugegraph`; override `rag.hugegraph.graphName` for another graph. Model services and credentials are needed for inference. The UI being reachable does not establish that Server or model services are ready. +### Configure models in the web UI + +No model key needs to be placed in Helm values before installation. After opening +the RAG page, use the configuration section to select an OpenAI-compatible or +Ollama Chat provider and enter its address, model and, where required, API key. +Choose **Save configuration**, then **Test saved connection**. Configure an +Embedding provider in the same way before workflows that need embeddings or +vector indexes. Reranker and external vector-engine settings are optional and +depend on the chosen workflow. Extraction and Text2GQL use Chat by default. + +When RAG and Server belong to this release, the chart supplies the Server +connection and administrator Secret automatically. Use the HugeGraph server +configuration section only when connecting to a different Server or graph. +Model endpoints must be reachable from the RAG Pod. Values saved in the web UI +persist on the RAG PVC; alternatively, operators can supply bootstrap defaults +through mounted files and Kubernetes Secrets as described below. + RAG uses ClusterIP by default. Put external exposure behind an authenticated reverse proxy: the application's login does not protect every HTTP route. From 279a053b18178c077b87d325955d23a3b88fa51b Mon Sep 17 00:00:00 2001 From: orion Date: Thu, 5 Nov 2026 13:14:22 +0000 Subject: [PATCH 33/38] Remove unused RAG architecture illustration --- .../docs/images/rag-helm-architecture.svg | 63 ------------------- 1 file changed, 63 deletions(-) delete mode 100644 helm/hugegraph/docs/images/rag-helm-architecture.svg diff --git a/helm/hugegraph/docs/images/rag-helm-architecture.svg b/helm/hugegraph/docs/images/rag-helm-architecture.svg deleted file mode 100644 index bdb5324cf3..0000000000 --- a/helm/hugegraph/docs/images/rag-helm-architecture.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - HugeGraph Helm optional RAG integration - One chart manages HugeGraph core services and one optional single-active RAG application with durable settings and externally mounted configuration. - - - - - - - One Helm chart, optional GraphRAG runtime - Default remains unchanged · enable with rag.enabled=true · same template for 1+1+1 and 3+3+3 - - - - - - hugegraph-helm release - - RAG OFF by default - - - - - - - - PD1 or 3 replicasmetadata + placement - Store1 or 3 replicasHStore data plane - Server Servicetopology-neutral DNSauth Secret reused safely - RAG0 or 1 replicaRecreate · persistent settings - - - - - - - - - Single preset1 PD + 1 Store + 1 Server - Cluster preset3 PD + 3 Store + 3 Server - App-owned settingsBootstrap + retained PVC - - - Saved settings survive restarts; Helm mounts resources and provides bootstrap defaults. - From abc75bdafbb0bf459065013e8f49bdc3a7acf621 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 21:24:50 +0800 Subject: [PATCH 34/38] docs(helm): remove temporary RAG release gate history --- helm/hugegraph/docs/rag.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 541f604abe..2112f1cc9b 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -4,17 +4,16 @@ The chart deploys one RAG instance that connects to the Server Service. This is independent of whether HugeGraph uses one PD/Store/Server or a distributed cluster. RAG remains a single process with a persistent resource directory. -> This refactor requires [HugeGraph AI PR #96](https://github.com/hugegraph/hugegraph-ai/pull/96) in the -> `latest`/1.8 image. Merge #96 and publish and validate that image before merging -> or enabling this chart revision. Until then, this chart change remains blocked -> on that release; an older `latest` image is not supported. The chart no longer -> patches application configuration at startup. +> Use a HugeGraph RAG image built from Apache HugeGraph AI 1.8.0 or later. +> Earlier images do not implement the external-configuration contract required by +> this chart. The chart mounts resources and supplies bootstrap defaults; it does +> not patch application configuration at startup. ## Start and open RAG Run these commands from the repository root. Before installing, provide a Kubernetes cluster with enough CPU and memory, working persistent-volume -provisioning, and access to the compatible latest/1.8 RAG image described above. +provisioning, and access to a compatible HugeGraph RAG 1.8.0-or-later image. The cluster preset runs three PD, three Store and three Server replicas and requires scheduling them across nodes; RAG itself still runs one replica. By default the RAG resource PVC requests 10Gi, separately from PD and Store storage. @@ -241,5 +240,5 @@ directory/PVC: ordinary saves use atomic replacement; a writable single-file bin mount uses an in-place fallback on `EBUSY`, which cannot guarantee atomicity if interrupted. This does not affect read-only bootstrap Secret/ConfigMap mounts. -See the paired AI PR's [application configuration guide](https://github.com/hugegraph/hugegraph-ai/blob/refactor/external-rag-config/hugegraph-llm/config.md) -for API examples, complete saved connection groups and restore-default behavior. +See the [application configuration guide](https://github.com/apache/hugegraph-ai/blob/main/hugegraph-llm/config.md) +for API examples, complete saved connection groups and restore-default behavior. \ No newline at end of file From 1dd99e4c380882c6610415b1015c995112517786 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 21:30:17 +0800 Subject: [PATCH 35/38] test(helm): skip schema bypass check on older Helm --- helm/rag_tests/state.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/helm/rag_tests/state.py b/helm/rag_tests/state.py index aa9337c464..0e020daa94 100644 --- a/helm/rag_tests/state.py +++ b/helm/rag_tests/state.py @@ -16,6 +16,8 @@ """State and network regression coverage for the optional RAG component.""" +import subprocess + import yaml from .support import ( @@ -95,6 +97,17 @@ def test_multiple_replicas_are_rejected_even_with_shared_storage(self): self.assertIn("rag.replicas", result.stderr.replace("/", ".")) def test_single_replica_remains_fixed_without_schema_validation(self): + template_help = subprocess.run( + ["helm", "template", "--help"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(0, template_help.returncode, template_help.stderr) + if "--skip-schema-validation" not in template_help.stdout: + self.skipTest("helm template lacks --skip-schema-validation") + result = self.render({"replicas": 2}, skip_schema_validation=True) self.assertEqual(0, result.returncode, result.stderr) documents = [document for document in yaml.safe_load_all(result.stdout) if document] From 14134fd5a6624e6a2230c4415adba19281bdf406 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 21:47:00 +0800 Subject: [PATCH 36/38] fix(helm): pin validated RAG image --- helm/hugegraph/values-rag.yaml | 8 ++++++-- helm/rag_tests/basic.py | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/helm/hugegraph/values-rag.yaml b/helm/hugegraph/values-rag.yaml index 28ac1475c5..39e235c02f 100644 --- a/helm/hugegraph/values-rag.yaml +++ b/helm/hugegraph/values-rag.yaml @@ -23,7 +23,10 @@ rag: image: repository: hugegraph/rag tag: latest - digest: '' + # The digest wins over tag and pins the multi-architecture image validated + # with this chart. Override repository and clear or replace digest together; + # tag is used only when digest is empty. + digest: sha256:4eec308ba92d952221a7f42f1ddfd33ece18bde15a25396e1417ece180630aa1 pullPolicy: Always port: 8001 @@ -85,7 +88,8 @@ rag: # Unset inherits the top-level persistence.enabled value (true by default). # The claim stores the editable .env, prompt configuration, indexes and # resource tree. Missing example assets are read from the image, so startup - # needs no seeding. Requires the paired HugeGraph AI configuration refactor. + # needs no seeding. The pinned image implements the required external- + # configuration contract. persistence: enabled: existingClaim: '' diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index 80b37bee1c..0b932e8405 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -71,6 +71,11 @@ def test_default_profile_uses_internal_server_contract(self): service, = component_resources(documents, kind="Service") environment = env_by_name(container) + self.assertEqual( + "hugegraph/rag@sha256:" + "4eec308ba92d952221a7f42f1ddfd33ece18bde15a25396e1417ece180630aa1", + container["image"], + ) self.assertEqual("http://fixture-hugegraph-server.graphs.svc:8080", environment["GRAPH_URL"]["value"]) self.assertEqual("hugegraph", environment["GRAPH_NAME"]["value"]) self.assertEqual("DEFAULT", environment["GRAPH_SPACE"]["value"]) From 30ee6d08a9446fe0b9b04e4dc216a354672033d4 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 21:51:14 +0800 Subject: [PATCH 37/38] fix(helm): isolate RAG service environment --- helm/hugegraph/templates/rag-deployment.yaml | 3 +++ helm/rag_tests/basic.py | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/helm/hugegraph/templates/rag-deployment.yaml b/helm/hugegraph/templates/rag-deployment.yaml index 3d97b383c0..43fafbbdd3 100644 --- a/helm/hugegraph/templates/rag-deployment.yaml +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -67,6 +67,9 @@ spec: {{- with get $rag "podAnnotations" }}{{ toYaml . | nindent 8 }}{{- end }} {{- end }} spec: + # Kubernetes Service links can overwrite typed application settings such + # as QDRANT_PORT and MILVUS_PORT with tcp://... discovery placeholders. + enableServiceLinks: false {{- $serviceAccount := get $rag "serviceAccount" | default dict }} {{- $serviceAccountCreate := true }} {{- if hasKey $serviceAccount "create" }}{{- $serviceAccountCreate = get $serviceAccount "create" }}{{- end }} diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py index 0b932e8405..11ba9c4a3e 100644 --- a/helm/rag_tests/basic.py +++ b/helm/rag_tests/basic.py @@ -48,7 +48,9 @@ def test_bare_enable_has_safe_persistent_defaults(self): data_mount, = [mount for mount in container["volumeMounts"] if mount["name"] == "data"] self.assertEqual(RAG_MOUNT, data_mount["mountPath"]) - pod_security = pod(deployment)["spec"]["securityContext"] + pod_spec = pod(deployment)["spec"] + self.assertFalse(pod_spec["enableServiceLinks"]) + pod_security = pod_spec["securityContext"] self.assertTrue(pod_security["runAsNonRoot"]) self.assertEqual(1000, pod_security["runAsUser"]) self.assertEqual(1000, pod_security["runAsGroup"]) @@ -57,7 +59,7 @@ def test_bare_enable_has_safe_persistent_defaults(self): self.assertFalse(container["securityContext"]["privileged"]) self.assertFalse(container["securityContext"]["allowPrivilegeEscalation"]) self.assertEqual(["ALL"], container["securityContext"]["capabilities"]["drop"]) - self.assertFalse(pod(deployment)["spec"]["automountServiceAccountToken"]) + self.assertFalse(pod_spec["automountServiceAccountToken"]) environment = env_by_name(container) self.assertEqual(RAG_MOUNT, environment["HUGEGRAPH_LLM_RESOURCE_PATH"]["value"]) From 767d789aa6422ef5e9fdb106e6001dfab6abf583 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 5 Nov 2026 21:52:45 +0800 Subject: [PATCH 38/38] docs(helm): use rendered RAG resource names --- helm/hugegraph/docs/rag.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/helm/hugegraph/docs/rag.md b/helm/hugegraph/docs/rag.md index 2112f1cc9b..072d33297d 100644 --- a/helm/hugegraph/docs/rag.md +++ b/helm/hugegraph/docs/rag.md @@ -26,7 +26,7 @@ helm upgrade --install hugegraph ./helm/hugegraph \ -f helm/hugegraph/values-cluster.yaml \ -f helm/hugegraph/values-rag.yaml \ --wait --timeout 15m -kubectl -n hugegraph port-forward svc/hugegraph-hugegraph-rag 8001:8001 +kubectl -n hugegraph port-forward svc/hugegraph-rag 8001:8001 ``` For a single-node HugeGraph deployment with RAG: @@ -37,7 +37,7 @@ helm upgrade --install hugegraph ./helm/hugegraph \ -f helm/hugegraph/values-single.yaml \ -f helm/hugegraph/values-rag.yaml \ --wait --timeout 15m -kubectl -n hugegraph port-forward svc/hugegraph-hugegraph-rag 8001:8001 +kubectl -n hugegraph port-forward svc/hugegraph-rag 8001:8001 ``` Open http://127.0.0.1:8001/. A different release name changes the Service name; @@ -108,7 +108,7 @@ request prompts. Only explicit saves/index generation require writable storage. Deployment file/Secret changes require a restart: ```bash -kubectl -n hugegraph rollout restart deployment/hugegraph-hugegraph-rag +kubectl -n hugegraph rollout restart deployment/hugegraph-rag ``` There is no automatic file hot reload. An online Helm upgrade observes an explicit