diff --git a/.github/actions/helm-checks/action.yml b/.github/actions/helm-checks/action.yml index 2b56a1259f..b79f6374ff 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 @@ -86,6 +93,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 @@ -525,6 +533,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 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/Chart.yaml b/helm/hugegraph/Chart.yaml index 1a835b045b..2b4460b29f 100644 --- a/helm/hugegraph/Chart.yaml +++ b/helm/hugegraph/Chart.yaml @@ -17,7 +17,7 @@ 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 # TODO: set appVersion to "1.8.0" when the release images are published. @@ -26,12 +26,14 @@ 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/rag.md b/helm/hugegraph/docs/rag.md new file mode 100644 index 0000000000..072d33297d --- /dev/null +++ b/helm/hugegraph/docs/rag.md @@ -0,0 +1,244 @@ +# Optional HugeGraph RAG deployment + +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. + +> 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 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. + +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-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 \ + --wait --timeout 15m +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; +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 +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. + +## Configuration ownership + +The web UI/API manages model connections. Deployment values are bootstrap +fallbacks; saved connection fields take precedence across restarts/upgrades: + +```text +saved settings.yaml > process environment / Secret > mounted .env > application defaults +``` + +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. 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 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. + +Deployment file/Secret changes require a restart: + +```bash +kubectl -n hugegraph rollout restart deployment/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. 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 + +Manage a local `rag.env` and `config_prompt.yaml` and create a Secret/ConfigMap: + +```bash +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 - +``` + +Keep credentials out of Git. Use this overlay after `values-rag.yaml`: + +```yaml +rag: + envFrom: + - secretRef: + 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` remains supported while model UI/API saves go to the writable +`settings.yaml` on the resource PVC. Keep bootstrap mounts separate from that +writable directory; a read-only prompt itself still cannot be saved through the UI. + +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. 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 + +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 `settings.yaml`, optional bootstrap `.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. +`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. 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_SETTINGS_PATH`, `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` 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. + +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. 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 + +```yaml +rag: + hugegraph: + url: http://hugegraph-server.graphs.svc:8080 + graphName: hugegraph + graphSpace: DEFAULT + username: rag-service + passwordSecret: + name: rag-hugegraph-credentials + key: password +``` + +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. + +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 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. + +Application settings contain user-saved credentials. New files use mode 0600; +existing permissions are preserved. Restrict PVC/backup access. Prefer a writable +directory/PVC: ordinary saves use atomic replacement; a writable single-file bind +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 [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 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 new file mode 100644 index 0000000000..54f95c873d --- /dev/null +++ b/helm/hugegraph/templates/_rag.tpl @@ -0,0 +1,127 @@ +# +# 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.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 -}} +ClusterIP +{{- 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" . -}} +{{- 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 := 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) -}} +{{- 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 -}} +{{- $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 -}} +password +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.persistenceEnabled" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- if and (hasKey $persistence "enabled") (ne (get $persistence "enabled" | toString) "") -}} +{{- $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) "") -}} +{{- get $global "enabled" -}} +{{- else -}}true{{- end -}} +{{- end -}} +{{- end }} + +{{- define "hugegraph.rag.mountPath" -}} +{{- $rag := get .Values "rag" | default dict -}} +{{- $persistence := get $rag "persistence" | default dict -}} +{{- get $persistence "mountPath" | default "/var/lib/hugegraph-rag" -}} +{{- 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 }} diff --git a/helm/hugegraph/templates/_rag_security.tpl b/helm/hugegraph/templates/_rag_security.tpl new file mode 100644 index 0000000000..607a195292 --- /dev/null +++ b/helm/hugegraph/templates/_rag_security.tpl @@ -0,0 +1,70 @@ +# +# 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") -}} +{{- $overrides := deepCopy (get $rag "podSecurityContext" | default dict) -}} +{{- $context := mergeOverwrite (deepCopy $defaults) $overrides -}} +{{- range $field := list "runAsUser" "runAsGroup" "fsGroup" -}} +{{- 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 }} + +{{- 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") -}} +{{- $overrides := deepCopy (get $rag "securityContext" | default dict) -}} +{{- $context := mergeOverwrite (deepCopy $defaults) $overrides -}} +{{- range $field := list "runAsUser" "runAsGroup" -}} +{{- 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.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..9d258ae533 --- /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" "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..4f9892c092 --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_metadata.tpl @@ -0,0 +1,45 @@ +# +# 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 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) -}} +{{- 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..eb7ac55630 --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_runtime.tpl @@ -0,0 +1,77 @@ +# +# 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 -}} +{{- $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 $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 -}} +{{- 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 -}} +{{- $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" -}} +{{- 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/hugegraph/templates/_rag_validate_security.tpl b/helm/hugegraph/templates/_rag_validate_security.tpl new file mode 100644 index 0000000000..558086bbdc --- /dev/null +++ b/helm/hugegraph/templates/_rag_validate_security.tpl @@ -0,0 +1,101 @@ +# +# 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 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 -}} +{{- 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 -}} +{{- if hasKey $seccomp "localhostProfile" -}} +{{- fail (printf "%s.seccompProfile.localhostProfile must be unset when type is RuntimeDefault" $scope) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if hasKey $podContext "fsGroup" -}} +{{- $rawFsGroup := get $podContext "fsGroup" | toString -}} +{{- 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" -}} +{{- 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 new file mode 100644 index 0000000000..43fafbbdd3 --- /dev/null +++ b/helm/hugegraph/templates/rag-deployment.yaml @@ -0,0 +1,217 @@ +# +# 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 }} +{{- $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: + name: {{ include "hugegraph.rag.name" . }} + labels: + {{- include "hugegraph.labels" . | nindent 4 }} + app.kubernetes.io/component: rag +spec: + 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: + type: Recreate + 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 }} + {{- if or $injectPassword (get $rag "podAnnotations") }} + annotations: + {{- 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: + # 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 }} + 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 }} + securityContext: + {{- include "hugegraph.rag.podSecurityContext" . | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - 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" . | int | quote }}] + securityContext: + {{- include "hugegraph.rag.securityContext" . | nindent 12 }} + ports: + - name: http + containerPort: {{ include "hugegraph.rag.port" . | int }} + 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 }} + {{- if $injectPassword }} + - name: GRAPH_PWD + valueFrom: + secretKeyRef: + 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 and (not $persistenceEnabled) (not (hasKey $envNames "HUGEGRAPH_LLM_ENV_PATH")) }} + - name: HUGEGRAPH_LLM_ENV_PATH + value: /tmp/hugegraph-llm.env + {{- end }} + {{- if and (not $persistenceEnabled) (not (hasKey $envNames "HUGEGRAPH_LLM_PROMPT_CONFIG_PATH")) }} + - name: HUGEGRAPH_LLM_PROMPT_CONFIG_PATH + value: /home/work/hugegraph-llm/src/hugegraph_llm/resources/demo/config_prompt.yaml + {{- 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: {{ include "hugegraph.rag.mountPath" . | 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 $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 $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 $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" . | quote }} + {{- 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..af0e2df77f --- /dev/null +++ b/helm/hugegraph/templates/rag-networkpolicy.yaml @@ -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. +# + +{{ 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 }} +{{- $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 +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..4a3a978a55 --- /dev/null +++ b/helm/hugegraph/templates/rag-pvc.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 }} +{{- $persistence := get $rag "persistence" | default dict }} +{{- $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 +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" | quote }} +{{- 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..37bf258715 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 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 + # 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..39e235c02f --- /dev/null +++ b/helm/hugegraph/values-rag.yaml @@ -0,0 +1,122 @@ +# +# 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. The same-release Server address, port, graph identity +# and administrator Secret are derived by the chart and need no duplication. +rag: + enabled: true + image: + repository: hugegraph/rag + tag: latest + # 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 + + # 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 + + # 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. + resources: {} + + # 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; use the string auto for platform-assigned non-root identities. + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + priorityClassName: '' + podAnnotations: {} + podLabels: {} + # env/envFrom provide bootstrap defaults ahead of .env. Application-saved + # connections in settings.yaml survive upgrades and take precedence. + env: [] + envFrom: [] + terminationGracePeriodSeconds: 30 + + serviceAccount: + create: true + name: '' + annotations: {} + automountServiceAccountToken: false + + # 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 + annotations: {} + ingress: + enabled: false + className: '' + annotations: {} + hosts: + - host: rag.local + paths: + - path: / + pathType: Prefix + tls: [] + + # 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. The pinned image implements the required external- + # configuration contract. + persistence: + enabled: + existingClaim: '' + size: 10Gi + storageClassName: '' + accessModes: + - ReadWriteOnce + mountPath: /var/lib/hugegraph-rag + + 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/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 595a5f6ab6..3aaa09c00b 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -164,6 +164,137 @@ ] } } + }, + "rag": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "properties": { + "replicas": { + "type": "integer", + "const": 1 + }, + "hugegraph": { + "type": "object", + "properties": { + "autoConfigure": { + "type": "boolean" + } + } + }, + "persistence": { + "type": "object", + "properties": { + "storageClassName": { + "type": "string" + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "size": { + "type": "string", + "pattern": "^\\+?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+|[numkMGTPE]|[KMGTPE]i)?$", + "not": { + "pattern": "^\\+?0*(\\.0*)?([eE][+-]?[0-9]+|[numkMGTPE]|[KMGTPE]i)?$" + } + }, + "accessModes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "ReadWriteOnce", + "ReadOnlyMany", + "ReadWriteMany", + "ReadWriteOncePod" + ] + } + }, + "existingClaim": { + "type": "string" + } + } + }, + "probes": { + "type": "object", + "properties": { + "startup": { + "allOf": [ + { + "$ref": "#/definitions/ragProbe" + }, + { + "properties": { + "successThreshold": { + "maximum": 1 + } + } + } + ] + }, + "readiness": { + "$ref": "#/definitions/ragProbe" + }, + "liveness": { + "allOf": [ + { + "$ref": "#/definitions/ragProbe" + }, + { + "properties": { + "successThreshold": { + "maximum": 1 + } + } + } + ] + } + } + }, + "serviceAccount": { + "type": "object", + "properties": { + "create": { + "type": "boolean" + } + } + }, + "ingress": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 0 + } + } + } + } + ] } }, "definitions": { @@ -459,6 +590,30 @@ } } }, + "ragProbe": { + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^/" + }, + "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/__init__.py b/helm/rag_tests/__init__.py new file mode 100644 index 0000000000..8f4d73a13c --- /dev/null +++ b/helm/rag_tests/__init__.py @@ -0,0 +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 "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.""" diff --git a/helm/rag_tests/basic.py b/helm/rag_tests/basic.py new file mode 100644 index 0000000000..11ba9c4a3e --- /dev/null +++ b/helm/rag_tests/basic.py @@ -0,0 +1,98 @@ +#!/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. + +"""Basic rendered contracts for the optional HugeGraph RAG component.""" + +from .support import 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(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"]) + data_mount, = [mount for mount in container["volumeMounts"] if mount["name"] == "data"] + self.assertEqual(RAG_MOUNT, data_mount["mountPath"]) + + 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"]) + 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_spec["automountServiceAccountToken"]) + + environment = env_by_name(container) + 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({}) + deployment = self.deployment(documents) + container = self.container(documents) + 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"]) + 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"][-1]) + 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/state.py b/helm/rag_tests/state.py new file mode 100644 index 0000000000..0e020daa94 --- /dev/null +++ b/helm/rag_tests/state.py @@ -0,0 +1,184 @@ +#!/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. + +"""State and network regression coverage for the optional RAG component.""" + +import subprocess + +import yaml + +from .support import ( + RAG_EPHEMERAL_ENV, RAG_MOUNT, component_resources, env_by_name, pod, +) + + +class RagStateMixin: + def test_native_process_owns_configuration_and_resource_initialization(self): + documents = self.documents({}) + deployment = self.deployment(documents) + container = self.container(documents) + 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_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_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.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) + 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_EPHEMERAL_ENV, environment["HUGEGRAPH_LLM_ENV_PATH"]["value"]) + self.assertEqual( + "/home/work/hugegraph-llm/src/hugegraph_llm/resources/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}, + {"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.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] + 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") + 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"] + 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 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)}, + 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"]: + 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) + + 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"]) diff --git a/helm/rag_tests/support.py b/helm/rag_tests/support.py new file mode 100644 index 0000000000..8da5b75749 --- /dev/null +++ b/helm/rag_tests/support.py @@ -0,0 +1,95 @@ +#!/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 json +import shutil +import subprocess +import tempfile +import unittest + +import yaml + + +CHART = Path(__file__).resolve().parents[1] / "hugegraph" +HELM_TEMPLATE_TIMEOUT_SECONDS = 120 +RAG_MOUNT = "/var/lib/hugegraph-rag" +RAG_EPHEMERAL_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, skip_schema_validation=False, **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" + # 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 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: + 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..bd52ed3a21 --- /dev/null +++ b/helm/rag_tests/validation.py @@ -0,0 +1,199 @@ +#!/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. + +"""Validation regression coverage for the optional RAG component.""" + +from .support import RAG_MOUNT, component_resources, pod + + +class RagValidationMixin: + 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": service_account}, + "ingress": {"enabled": ingress}, + }) + 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 = ( + ({"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"), + ({"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"), + ({"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"), + ({"replicas": 1.5}, "rag.replicas"), + ({"replicas": 2}, "rag.replicas"), + ({"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": {"size": "abc"}}, "persistence.size"), + ({"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"), + ({"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"), + ({"extraVolumes": [{"name": "data", "emptyDir": {}}]}, + "reserved volume name data"), + ({"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"), + ) + for values, expected in invalid: + with self.subTest(values=values): + result = self.render(values) + 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}, + "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": { + "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) + + 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"]) + + def test_partial_probe_options_keep_defaults(self): + workload = self.deployment(self.documents({ + "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) + + def test_pvc_quantities_and_access_modes_preserve_supported_values(self): + for size, access_modes in ( + ("10Gi", ["ReadWriteOnce"]), + ("1.5Gi", ["ReadWriteMany"]), + ("500M", ["ReadOnlyMany"]), + ("100k", ["ReadWriteOnce"]), + ("100Ki", ["ReadWriteOnce"]), + ("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"]) + + 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"]) diff --git a/helm/rag_tests/wiring.py b/helm/rag_tests/wiring.py new file mode 100644 index 0000000000..789a6ca326 --- /dev/null +++ b/helm/rag_tests/wiring.py @@ -0,0 +1,93 @@ +#!/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( + "http://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.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): + 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.assertEqual("", env_by_name(self.container(documents))["GRAPH_PWD"]["value"]) + + 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_package.py b/helm/test_helm_package.py index a9c480a947..4a35d0c9f9 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, @@ -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,21 @@ 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 + except ModuleNotFoundError as error: + if error.name != "test_helm_rag": + raise + from helm.test_helm_rag import RagChartTest + + 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 new file mode 100644 index 0000000000..f2ac4e1f4e --- /dev/null +++ b/helm/test_helm_rag.py @@ -0,0 +1,48 @@ +#!/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 + +try: + from rag_tests.basic import RagBasicMixin + 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 as error: + if error.name != "rag_tests": + raise + from helm.rag_tests.basic import RagBasicMixin + 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__": + unittest.main()