From 4606f8444cef420da57d04785f31bbec8bcbb698 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:13:35 +0300 Subject: [PATCH 01/13] fix(security): remove unrestricted webui egress, add privacy defaults and CIDR escape hatch --- .../ci/external-inference-values.yaml | 8 +++++++ charts/llm-stack/ci/external-oidc-values.yaml | 12 ++++++++++ charts/llm-stack/ci/kind-values.yaml | 12 ++++++++++ charts/llm-stack/templates/networkpolicy.yaml | 23 +++++++++++-------- charts/llm-stack/values.schema.json | 5 ++++ charts/llm-stack/values.yaml | 23 +++++++++++++++++++ 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/charts/llm-stack/ci/external-inference-values.yaml b/charts/llm-stack/ci/external-inference-values.yaml index 64ed190..af931b7 100644 --- a/charts/llm-stack/ci/external-inference-values.yaml +++ b/charts/llm-stack/ci/external-inference-values.yaml @@ -23,6 +23,14 @@ open-webui: pipelines: enabled: false extraEnvVars: + - name: ENABLE_COMMUNITY_SHARING + value: "false" + - name: SCARF_NO_ANALYTICS + value: "true" + - name: ANONYMIZED_TELEMETRY + value: "false" + - name: HF_HUB_OFFLINE + value: "1" - name: VECTOR_DB value: "qdrant" - name: QDRANT_URI diff --git a/charts/llm-stack/ci/external-oidc-values.yaml b/charts/llm-stack/ci/external-oidc-values.yaml index 3c81922..86a2c2b 100644 --- a/charts/llm-stack/ci/external-oidc-values.yaml +++ b/charts/llm-stack/ci/external-oidc-values.yaml @@ -23,6 +23,18 @@ open-webui: extraEnvVars: - name: ENABLE_OLLAMA_API value: "true" + - name: ENABLE_COMMUNITY_SHARING + value: "false" + - name: SCARF_NO_ANALYTICS + value: "true" + - name: ANONYMIZED_TELEMETRY + value: "false" + - name: HF_HUB_OFFLINE + value: "1" + - name: RAG_EMBEDDING_ENGINE + value: "ollama" + - name: RAG_EMBEDDING_MODEL + value: "nomic-embed-text" - name: VECTOR_DB value: "qdrant" - name: QDRANT_URI diff --git a/charts/llm-stack/ci/kind-values.yaml b/charts/llm-stack/ci/kind-values.yaml index 88fef1b..16fa7f2 100644 --- a/charts/llm-stack/ci/kind-values.yaml +++ b/charts/llm-stack/ci/kind-values.yaml @@ -23,6 +23,18 @@ open-webui: extraEnvVars: - name: ENABLE_OLLAMA_API value: "true" + - name: ENABLE_COMMUNITY_SHARING + value: "false" + - name: SCARF_NO_ANALYTICS + value: "true" + - name: ANONYMIZED_TELEMETRY + value: "false" + - name: HF_HUB_OFFLINE + value: "1" + - name: RAG_EMBEDDING_ENGINE + value: "ollama" + - name: RAG_EMBEDDING_MODEL + value: "nomic-embed-text" - name: VECTOR_DB value: "qdrant" - name: QDRANT_URI diff --git a/charts/llm-stack/templates/networkpolicy.yaml b/charts/llm-stack/templates/networkpolicy.yaml index bf0515f..9bcd9cf 100644 --- a/charts/llm-stack/templates/networkpolicy.yaml +++ b/charts/llm-stack/templates/networkpolicy.yaml @@ -49,28 +49,33 @@ spec: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx +{{- if .Values.networkPolicy.allowedEgressCIDRs }} --- -# Allow Open WebUI pod to make HTTPS calls for telemetry/model-discovery. -# Open WebUI v0.11+ calls external HTTPS endpoints during startup -# (e.g., community model registry behind CloudFront). -# Scoped to the Open WebUI pod only; all other pods remain restricted. +# Opt-in HTTPS egress to explicit CIDRs only. +# Set networkPolicy.allowedEgressCIDRs to specific IP ranges when pods +# must reach known external endpoints (e.g. on-prem proxy, internal CA). +# Leave empty (default) to enforce full egress isolation. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: {{ include "llm-stack.fullname" . }}-allow-webui-egress-https + name: {{ include "llm-stack.fullname" . }}-allow-egress-https-cidrs namespace: {{ .Release.Namespace }} labels: {{- include "llm-stack.labels" . | nindent 4 }} spec: - podSelector: - matchLabels: - app.kubernetes.io/name: open-webui + podSelector: {} policyTypes: - Egress egress: - - ports: + - to: + {{- range .Values.networkPolicy.allowedEgressCIDRs }} + - ipBlock: + cidr: {{ . | quote }} + {{- end }} + ports: - protocol: TCP port: 443 - protocol: TCP port: 80 {{- end }} +{{- end }} diff --git a/charts/llm-stack/values.schema.json b/charts/llm-stack/values.schema.json index 459c598..ef4ee12 100644 --- a/charts/llm-stack/values.schema.json +++ b/charts/llm-stack/values.schema.json @@ -151,6 +151,11 @@ "enabled": { "type": "boolean", "description": "Deploy NetworkPolicies (requires Calico, Cilium, or similar CNI)" + }, + "allowedEgressCIDRs": { + "type": "array", + "items": { "type": "string" }, + "description": "Opt-in CIDR whitelist for HTTPS egress (ports 80/443). Empty list = full isolation. Example: [\"10.0.0.0/8\"]" } } }, diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index f733742..10c3019 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -37,6 +37,24 @@ open-webui: extraEnvVars: - name: ENABLE_OLLAMA_API value: "true" + # Privacy: disable all external call-home behaviour by default. + # These are the correct knobs as of Open WebUI v0.11.x. + # Without these, Open WebUI contacts HuggingFace on startup (embedding + # model download) and CloudFront (community registry) — both blocked + # when networkPolicy.enabled=true and contradicting the "no data leaves" + # guarantee. Use Ollama for embeddings so no external dependency exists. + - name: ENABLE_COMMUNITY_SHARING + value: "false" + - name: SCARF_NO_ANALYTICS + value: "true" + - name: ANONYMIZED_TELEMETRY + value: "false" + - name: HF_HUB_OFFLINE + value: "1" + - name: RAG_EMBEDDING_ENGINE + value: "ollama" + - name: RAG_EMBEDDING_MODEL + value: "nomic-embed-text" # --------------------------------------------------------------------------- # Ollama (local inference) @@ -121,6 +139,11 @@ oidc: # --------------------------------------------------------------------------- networkPolicy: enabled: false + # Opt-in egress CIDR whitelist — only active when enabled=true. + # Add specific IP ranges (e.g. an internal proxy or private registry) when + # pods must reach known external endpoints over HTTPS. + # Example: ["10.0.0.0/8", "192.168.1.100/32"] + allowedEgressCIDRs: [] # --------------------------------------------------------------------------- # Ingress From f93970554e8d17824d912e8be66c8606e80356b0 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:14:21 +0300 Subject: [PATCH 02/13] fix(security): scope DNS egress to kube-system to prevent DNS tunneling --- charts/llm-stack/templates/networkpolicy.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/charts/llm-stack/templates/networkpolicy.yaml b/charts/llm-stack/templates/networkpolicy.yaml index 9bcd9cf..e8e59db 100644 --- a/charts/llm-stack/templates/networkpolicy.yaml +++ b/charts/llm-stack/templates/networkpolicy.yaml @@ -27,8 +27,12 @@ spec: - Egress - Ingress egress: - # Allow DNS (kube-dns in kube-system) - - ports: + # Allow DNS only to kube-dns in kube-system (prevents DNS-tunneling exfiltration) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: - protocol: UDP port: 53 - protocol: TCP From 5c0cf37a26b05078bc02a33e03e49e3a60567e53 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:16:41 +0300 Subject: [PATCH 03/13] fix(security): pin OIDC redirect URIs to chat host and template sslRequired --- charts/llm-stack/ci/kind-values.yaml | 1 + charts/llm-stack/templates/keycloak-realm-job.yaml | 6 +++--- charts/llm-stack/values.schema.json | 5 +++++ charts/llm-stack/values.yaml | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/charts/llm-stack/ci/kind-values.yaml b/charts/llm-stack/ci/kind-values.yaml index 16fa7f2..9f053b1 100644 --- a/charts/llm-stack/ci/kind-values.yaml +++ b/charts/llm-stack/ci/kind-values.yaml @@ -77,6 +77,7 @@ keycloak: enabled: true realmImport: enabled: true + sslRequired: "none" # no TLS in kind; production default is "external" adminPassword: "admin" # matches KEYCLOAK_ADMIN_PASSWORD below # keycloakx sub-chart overrides for kind (no TLS, no strict hostname) diff --git a/charts/llm-stack/templates/keycloak-realm-job.yaml b/charts/llm-stack/templates/keycloak-realm-job.yaml index aafa6f2..3cac433 100644 --- a/charts/llm-stack/templates/keycloak-realm-job.yaml +++ b/charts/llm-stack/templates/keycloak-realm-job.yaml @@ -19,15 +19,15 @@ data: "displayName": "LLM Stack", "registrationAllowed": false, "loginWithEmailAllowed": true, - "sslRequired": "none", + "sslRequired": {{ .Values.keycloak.realmImport.sslRequired | default "external" | quote }}, "clients": [ { "clientId": "open-webui", "enabled": true, "publicClient": false, "secret": "$(env:OIDC_CLIENT_SECRET)", - "redirectUris": ["*"], - "webOrigins": ["*"], + "redirectUris": [{{ printf "https://chat.%s/oauth/oidc/callback" .Values.global.domain | quote }}], + "webOrigins": [{{ printf "https://chat.%s" .Values.global.domain | quote }}], "standardFlowEnabled": true, "protocol": "openid-connect" } diff --git a/charts/llm-stack/values.schema.json b/charts/llm-stack/values.schema.json index ef4ee12..02b6118 100644 --- a/charts/llm-stack/values.schema.json +++ b/charts/llm-stack/values.schema.json @@ -93,6 +93,11 @@ "enabled": { "type": "boolean", "description": "Run the realm-import Job on first install" + }, + "sslRequired": { + "type": "string", + "enum": ["external", "all", "none"], + "description": "Keycloak realm sslRequired setting. Use 'external' in production, 'none' only in CI/dev environments without TLS" } } }, diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index 10c3019..cd3b349 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -88,6 +88,7 @@ keycloak: enabled: true realmImport: enabled: true + sslRequired: "external" # "external" (prod) | "none" (CI/dev, no TLS) adminUser: admin adminPassword: "" # leave empty to use generated secret (keycloakAdminPassword) From 923543abd88bcc9a2bf0fcd559a4c46413ab994f Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:18:08 +0300 Subject: [PATCH 04/13] feat(defaults): enable networkPolicy and litellm out of the box --- charts/llm-stack/values.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index cd3b349..f91a959 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -106,7 +106,7 @@ keycloakx: # Sub-chart values under "litellm-helm:" (matches Chart.yaml name) # --------------------------------------------------------------------------- litellm: - enabled: false + enabled: true litellm-helm: # masterkeySecretName must match the release-specific fullname-secrets name. @@ -139,7 +139,7 @@ oidc: # NetworkPolicy (requires a CNI that enforces policies, e.g. Calico) # --------------------------------------------------------------------------- networkPolicy: - enabled: false + enabled: true # Opt-in egress CIDR whitelist — only active when enabled=true. # Add specific IP ranges (e.g. an internal proxy or private registry) when # pods must reach known external endpoints over HTTPS. From 87c2d1440f525ee4d4abf00c8a051e6e275635ec Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:20:05 +0300 Subject: [PATCH 05/13] fix(naming): pin fullnameOverride to stabilize secret names across release names --- .github/workflows/ci.yml | 2 +- charts/llm-stack/ci/kind-values.yaml | 10 +++++----- charts/llm-stack/values.yaml | 11 ++++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc359f8..3c60c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - name: Run helm tests run: | helm test llm --namespace llm --timeout 5m \ - --filter name=llm-llm-stack-test-webui-health + --filter name=llm-stack-test-webui-health schema-validate: name: Schema validation diff --git a/charts/llm-stack/ci/kind-values.yaml b/charts/llm-stack/ci/kind-values.yaml index 9f053b1..f47b549 100644 --- a/charts/llm-stack/ci/kind-values.yaml +++ b/charts/llm-stack/ci/kind-values.yaml @@ -42,7 +42,7 @@ open-webui: # LiteLLM gateway as OpenAI-compatible backend # CI release = "llm", so service is llm-litellm.llm.svc.cluster.local openaiBaseApiUrl: "http://llm-litellm.llm.svc.cluster.local:4000/v1" - openaiApiKeyExistingSecret: "llm-llm-stack-secrets" + openaiApiKeyExistingSecret: "llm-stack-secrets" openaiApiKeyExistingSecretKey: "litellmMasterKey" # SSO: point to internal Keycloak (browser redirect only works with Ingress in prod) sso: @@ -51,8 +51,8 @@ open-webui: oidc: enabled: true clientId: "open-webui" - # Reference the chart-generated secret (CI release name is "llm" → "llm-llm-stack-secrets") - clientExistingSecret: "llm-llm-stack-secrets" + # Reference the chart-generated secret (CI release name is "llm" → "llm-stack-secrets") + clientExistingSecret: "llm-stack-secrets" clientExistingSecretKey: "oidcClientSecret" providerUrl: "http://llm-keycloakx-http.llm.svc.cluster.local:80/auth/realms/llm-stack" providerName: "Keycloak" @@ -100,9 +100,9 @@ keycloakx: litellm: enabled: true -# litellm-helm sub-chart: CI release is "llm" → secret name is "llm-llm-stack-secrets" +# litellm-helm sub-chart: CI release is "llm" → secret name is "llm-stack-secrets" litellm-helm: - masterkeySecretName: "llm-llm-stack-secrets" + masterkeySecretName: "llm-stack-secrets" masterkeySecretKey: "litellmMasterKey" db: deployStandalone: true diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index f91a959..b43f6e7 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -2,6 +2,13 @@ # Sub-chart values are nested under the chart's name as declared in Chart.yaml. # See charts/llm-stack/ci/kind-values.yaml for a minimal working example. +# Pin the fullname so all chart-owned resources (secrets, jobs, configmaps) +# get stable names regardless of the Helm release name used at install time. +# This is what makes litellm-helm.masterkeySecretName and secret cross-references +# correct for any `helm install charts/llm-stack` invocation. +# Override only if you deploy multiple instances in the same namespace. +fullnameOverride: "llm-stack" + global: domain: "" # e.g. example.com — used for Ingress host construction storageClass: "" # leave empty to use cluster default @@ -109,9 +116,7 @@ litellm: enabled: true litellm-helm: - # masterkeySecretName must match the release-specific fullname-secrets name. - # Default assumes `helm install llm-stack charts/llm-stack` (release = chart name). - # Override in your values if using a different release name. + # Secret name matches llm-stack.secretName, which is stable via fullnameOverride above. masterkeySecretName: llm-stack-secrets masterkeySecretKey: litellmMasterKey db: From 8e84c9eb78125ba2198b4cc0833a3af308454138 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:36:45 +0300 Subject: [PATCH 06/13] fix(chart): wire litellm config template, fix config-cli version, move images to values, drop dead webui-env --- .gitignore | 4 +- charts/llm-stack/Chart.lock | 18 ++++++++ charts/llm-stack/Chart.yaml | 2 +- charts/llm-stack/ci/kind-values.yaml | 16 +------ .../templates/keycloak-realm-job.yaml | 4 +- .../llm-stack/templates/litellm-config.yaml | 46 +++++++++++++++++++ .../templates/tests/test-litellm-models.yaml | 43 +++++++++++++++++ .../templates/tests/test-oidc-discovery.yaml | 36 +++++++++++++++ charts/llm-stack/templates/webui-env.yaml | 41 ----------------- charts/llm-stack/values.schema.json | 26 ++++++++++- charts/llm-stack/values.yaml | 45 ++++++++++-------- 11 files changed, 201 insertions(+), 80 deletions(-) create mode 100644 charts/llm-stack/Chart.lock create mode 100644 charts/llm-stack/templates/litellm-config.yaml create mode 100644 charts/llm-stack/templates/tests/test-litellm-models.yaml create mode 100644 charts/llm-stack/templates/tests/test-oidc-discovery.yaml delete mode 100644 charts/llm-stack/templates/webui-env.yaml diff --git a/.gitignore b/.gitignore index 25de73e..632c8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -# Helm +# Helm — packaged subchart tarballs are ephemeral, but Chart.lock is committed +# for reproducible builds (pins digest of OCI charts like litellm-helm). charts/llm-stack/charts/ -charts/llm-stack/Chart.lock *.tgz # Secrets / local overrides diff --git a/charts/llm-stack/Chart.lock b/charts/llm-stack/Chart.lock new file mode 100644 index 0000000..2f16c1f --- /dev/null +++ b/charts/llm-stack/Chart.lock @@ -0,0 +1,18 @@ +dependencies: +- name: open-webui + repository: https://helm.openwebui.com/ + version: 16.1.0 +- name: keycloakx + repository: https://codecentric.github.io/helm-charts + version: 7.3.0 +- name: qdrant + repository: https://qdrant.github.io/qdrant-helm + version: 1.19.0 +- name: ollama + repository: https://otwld.github.io/ollama-helm/ + version: 1.78.0 +- name: litellm-helm + repository: oci://ghcr.io/berriai + version: 1.98.0 +digest: sha256:649e1221775e6c2237585b9952c6f849ae0a0d36758153541c0f681b0486e303 +generated: "2026-08-27T10:15:13.735238754+03:00" diff --git a/charts/llm-stack/Chart.yaml b/charts/llm-stack/Chart.yaml index 5b82384..f36bbb9 100644 --- a/charts/llm-stack/Chart.yaml +++ b/charts/llm-stack/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: llm-stack description: | - Self-hosted LLM stack with SSO, RAG and observability. + Self-hosted LLM stack with SSO and RAG. Designed for teams that cannot send data outside and must integrate with their existing identity provider. type: application diff --git a/charts/llm-stack/ci/kind-values.yaml b/charts/llm-stack/ci/kind-values.yaml index f47b549..84837dc 100644 --- a/charts/llm-stack/ci/kind-values.yaml +++ b/charts/llm-stack/ci/kind-values.yaml @@ -100,20 +100,8 @@ keycloakx: litellm: enabled: true -# litellm-helm sub-chart: CI release is "llm" → secret name is "llm-stack-secrets" -litellm-helm: - masterkeySecretName: "llm-stack-secrets" - masterkeySecretKey: "litellmMasterKey" - db: - deployStandalone: true - proxy_config: - model_list: - - model_name: qwen2.5-0.5b - litellm_params: - model: ollama/qwen2.5:0.5b - api_base: "http://llm-ollama.llm.svc.cluster.local:11434" - general_settings: - master_key: os.environ/PROXY_MASTER_KEY +# litellm model list is auto-generated from ollama.ollama.models.pull by +# templates/litellm-config.yaml — no override needed here. networkPolicy: enabled: false diff --git a/charts/llm-stack/templates/keycloak-realm-job.yaml b/charts/llm-stack/templates/keycloak-realm-job.yaml index 3cac433..2f90daa 100644 --- a/charts/llm-stack/templates/keycloak-realm-job.yaml +++ b/charts/llm-stack/templates/keycloak-realm-job.yaml @@ -62,7 +62,7 @@ spec: restartPolicy: OnFailure initContainers: - name: wait-for-keycloak - image: curlimages/curl:8.5.0 + image: {{ .Values.keycloak.realmImport.images.curl.repository }}:{{ .Values.keycloak.realmImport.images.curl.tag }} command: - sh - -c @@ -73,7 +73,7 @@ spec: done containers: - name: realm-import - image: quay.io/adorsys/keycloak-config-cli:latest-24.0.5 + image: {{ .Values.keycloak.realmImport.images.configCli.repository }}:{{ .Values.keycloak.realmImport.images.configCli.tag }} env: - name: KEYCLOAK_URL value: {{ include "llm-stack.keycloakAuthUrl" . | quote }} diff --git a/charts/llm-stack/templates/litellm-config.yaml b/charts/llm-stack/templates/litellm-config.yaml new file mode 100644 index 0000000..540d9b5 --- /dev/null +++ b/charts/llm-stack/templates/litellm-config.yaml @@ -0,0 +1,46 @@ +{{- if .Values.litellm.enabled }} +{{- /* + Generate the LiteLLM proxy config from chart-level inference values. + + inference.mode: local → one entry per model in ollama.ollama.models.pull, + api_base points at the in-cluster Ollama service. + inference.mode: external → single entry pointing at inference.external.baseUrl. + + The ConfigMap is consumed by litellm-helm via: + litellm-helm.configmap.create: false + litellm-helm.configmap.name: llm-stack-litellm-config + litellm-helm.configmap.key: config.yaml +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "llm-stack.fullname" . }}-litellm-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "llm-stack.labels" . | nindent 4 }} +data: + config.yaml: | + general_settings: + master_key: os.environ/PROXY_MASTER_KEY + model_list: + {{- if and (eq .Values.inference.mode "local") .Values.ollama.enabled }} + {{- if .Values.ollama.ollama.models.pull }} + {{- range .Values.ollama.ollama.models.pull }} + - model_name: {{ . | replace ":" "-" }} + litellm_params: + model: "ollama/{{ . }}" + api_base: "http://{{ $.Release.Name }}-ollama.{{ $.Release.Namespace }}.svc.cluster.local:11434" + {{- end }} + {{- else }} + [] # no models configured — add ollama.ollama.models.pull entries + {{- end }} + {{- else if eq .Values.inference.mode "external" }} + - model_name: external + litellm_params: + model: "openai/external" + api_base: {{ .Values.inference.external.baseUrl | quote }} + {{- if .Values.inference.external.apiKey }} + api_key: {{ .Values.inference.external.apiKey | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/llm-stack/templates/tests/test-litellm-models.yaml b/charts/llm-stack/templates/tests/test-litellm-models.yaml new file mode 100644 index 0000000..79c3cbf --- /dev/null +++ b/charts/llm-stack/templates/tests/test-litellm-models.yaml @@ -0,0 +1,43 @@ +{{- if .Values.litellm.enabled }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "llm-stack.fullname" . }}-test-litellm-models + namespace: {{ .Release.Namespace }} + labels: + {{- include "llm-stack.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: litellm-check + image: {{ .Values.keycloak.realmImport.images.curl.repository }}:{{ .Values.keycloak.realmImport.images.curl.tag }} + env: + - name: LITELLM_KEY + valueFrom: + secretKeyRef: + name: {{ include "llm-stack.secretName" . }} + key: litellmMasterKey + command: + - sh + - -c + - | + set -e + GATEWAY="http://{{ .Release.Name }}-litellm.{{ .Release.Namespace }}.svc.cluster.local:4000" + echo "Checking LiteLLM gateway at: $GATEWAY" + for i in $(seq 1 20); do + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $LITELLM_KEY" \ + "$GATEWAY/models" 2>/dev/null || echo "000") + if [ "$STATUS" = "200" ]; then + echo "LiteLLM /models OK (HTTP 200)" + exit 0 + fi + echo "Attempt $i/20 — status: $STATUS, retrying in 5s..." + sleep 5 + done + echo "LiteLLM gateway did not respond in time" + exit 1 +{{- end }} diff --git a/charts/llm-stack/templates/tests/test-oidc-discovery.yaml b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml new file mode 100644 index 0000000..18ad06e --- /dev/null +++ b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml @@ -0,0 +1,36 @@ +{{- if or .Values.keycloak.enabled .Values.oidc.external.enabled }} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "llm-stack.fullname" . }}-test-oidc-discovery + namespace: {{ .Release.Namespace }} + labels: + {{- include "llm-stack.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: oidc-check + image: {{ .Values.keycloak.realmImport.images.curl.repository }}:{{ .Values.keycloak.realmImport.images.curl.tag }} + command: + - sh + - -c + - | + set -e + ISSUER={{ include "llm-stack.oidcIssuerUrl" . | quote }} + DISCOVERY="${ISSUER}/.well-known/openid-configuration" + echo "Checking OIDC discovery at: $DISCOVERY" + for i in $(seq 1 20); do + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "$DISCOVERY" 2>/dev/null || echo "000") + if [ "$STATUS" = "200" ]; then + echo "OIDC discovery OK (HTTP 200)" + exit 0 + fi + echo "Attempt $i/20 — status: $STATUS, retrying in 5s..." + sleep 5 + done + echo "OIDC discovery did not respond in time" + exit 1 +{{- end }} diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml deleted file mode 100644 index 50f00a6..0000000 --- a/charts/llm-stack/templates/webui-env.yaml +++ /dev/null @@ -1,41 +0,0 @@ -{{/* - webui-env.yaml — ConfigMap for Open WebUI environment variables that require - dynamic values (Keycloak/OIDC URLs, Qdrant URL) derived from chart values. - - To inject these env vars into Open WebUI, add this to your values: - open-webui: - extraEnvFrom: - - configMapRef: - name: -webui-env # e.g. llm-stack-webui-env - - For CI (release=llm), the ConfigMap is named llm-llm-stack-webui-env. - Static env vars (VECTOR_DB, ENABLE_OLLAMA_API) are set directly in - open-webui.extraEnvVars in the values files to avoid this dependency. -*/}} -{{- if .Values.openWebui.enabled }} -{{- $oidcEnabled := or .Values.keycloak.enabled .Values.oidc.external.enabled }} -{{- if or $oidcEnabled .Values.qdrant.enabled }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "llm-stack.fullname" . }}-webui-env - namespace: {{ .Release.Namespace }} - labels: - {{- include "llm-stack.labels" . | nindent 4 }} -data: - {{- if $oidcEnabled }} - ENABLE_OAUTH_SIGNUP: "true" - OAUTH_PROVIDER_NAME: "Keycloak" - OPENID_PROVIDER_URL: {{ include "llm-stack.oidcIssuerUrl" . | quote }} - {{- if .Values.oidc.external.enabled }} - OAUTH_CLIENT_ID: {{ .Values.oidc.external.clientId | quote }} - {{- else }} - OAUTH_CLIENT_ID: "open-webui" - {{- end }} - {{- end }} - {{- if .Values.qdrant.enabled }} - VECTOR_DB: "qdrant" - QDRANT_URI: {{ printf "http://%s-qdrant.%s.svc.cluster.local:6333" .Release.Name .Release.Namespace | quote }} - {{- end }} -{{- end }} -{{- end }} diff --git a/charts/llm-stack/values.schema.json b/charts/llm-stack/values.schema.json index 02b6118..aad9600 100644 --- a/charts/llm-stack/values.schema.json +++ b/charts/llm-stack/values.schema.json @@ -97,7 +97,31 @@ "sslRequired": { "type": "string", "enum": ["external", "all", "none"], - "description": "Keycloak realm sslRequired setting. Use 'external' in production, 'none' only in CI/dev environments without TLS" + "description": "Keycloak realm sslRequired. Use 'external' in prod, 'none' in CI/dev" + }, + "images": { + "type": "object", + "additionalProperties": false, + "description": "Image overrides for air-gapped deployments", + "properties": { + "curl": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "configCli": { + "type": "object", + "additionalProperties": false, + "description": "keycloak-config-cli image — tag must match Keycloak major version", + "properties": { + "repository": { "type": "string" }, + "tag": { "type": "string" } + } + } + } } } }, diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index b43f6e7..326b941 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -10,17 +10,17 @@ fullnameOverride: "llm-stack" global: - domain: "" # e.g. example.com — used for Ingress host construction - storageClass: "" # leave empty to use cluster default + domain: "" # e.g. example.com — used for Ingress host construction + storageClass: "" # leave empty to use cluster default # --------------------------------------------------------------------------- # Inference mode: "local" (Ollama) or "external" (bring your own endpoint) # --------------------------------------------------------------------------- inference: - mode: local # local | external + mode: local # local | external external: - baseUrl: "" # required when mode=external - apiKey: "" # optional + baseUrl: "" # required when mode=external + apiKey: "" # optional # --------------------------------------------------------------------------- # Open WebUI — condition key: openWebui.enabled @@ -28,14 +28,14 @@ inference: # --------------------------------------------------------------------------- openWebui: enabled: true - host: "" # overrides global.domain-based host when set + host: "" # overrides global.domain-based host when set open-webui: persistence: enabled: true size: 2Gi ollama: - enabled: false # managed separately by the ollama sub-chart + enabled: false # managed separately by the ollama sub-chart # ollamaUrls is the correct way to point Open WebUI at an external Ollama # (verified against open-webui chart v16.1.0) ollamaUrls: [] @@ -95,9 +95,17 @@ keycloak: enabled: true realmImport: enabled: true - sslRequired: "external" # "external" (prod) | "none" (CI/dev, no TLS) + sslRequired: "external" # "external" (prod) | "none" (CI/dev, no TLS) + images: + curl: + repository: curlimages/curl + tag: "8.8.0" + configCli: + repository: quay.io/adorsys/keycloak-config-cli + # Match keycloakx appVersion major: keycloakx 7.3.0 = Keycloak 26.7.2 + tag: "latest-26.0.6" adminUser: admin - adminPassword: "" # leave empty to use generated secret (keycloakAdminPassword) + adminPassword: "" # leave empty to use generated secret (keycloakAdminPassword) keycloakx: # extraEnv is a string (multiline), not a list — matches keycloakx chart schema @@ -116,18 +124,17 @@ litellm: enabled: true litellm-helm: - # Secret name matches llm-stack.secretName, which is stable via fullnameOverride above. + # Secret name matches llm-stack.secretName, stable via fullnameOverride above. masterkeySecretName: llm-stack-secrets masterkeySecretKey: litellmMasterKey db: deployStandalone: true - proxy_config: - # Populated per environment — see ci/kind-values.yaml for a local example. - # For local inference, set api_base to the Ollama service URL. - # For external inference, set api_base to your endpoint. - model_list: [] - general_settings: - master_key: os.environ/PROXY_MASTER_KEY + # Use our chart-generated ConfigMap instead of litellm-helm's proxy_config rendering. + # The ConfigMap is produced by templates/litellm-config.yaml from inference.* values. + configmap: + create: false + name: llm-stack-litellm-config + key: config.yaml # --------------------------------------------------------------------------- # OIDC — populated by keycloak-realm-job when keycloak.enabled=true @@ -158,8 +165,8 @@ ingress: enabled: false className: "" tls: - secretName: "" # leave empty to use cert-manager annotation - issuer: "" # cert-manager ClusterIssuer name + secretName: "" # leave empty to use cert-manager annotation + issuer: "" # cert-manager ClusterIssuer name # --------------------------------------------------------------------------- # Shared secrets — generated on first install, stable across upgrades From de0ee8831764e3355a14e2754127ee4ef4479502 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:36:54 +0300 Subject: [PATCH 07/13] feat(ci): add secret stability test, OIDC/gateway helm tests, release workflow --- .github/workflows/ci.yml | 80 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 44 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c60c5b..2393e91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,86 @@ jobs: helm test llm --namespace llm --timeout 5m \ --filter name=llm-stack-test-webui-health + secret-stability: + name: Secret stability + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: azure/setup-helm@v4 + with: + version: "v3.17.0" + + - name: Create kind cluster + uses: helm/kind-action@v1.10.0 + with: + cluster_name: secret-stability + + - name: Add chart repos + run: | + helm repo add codecentric https://codecentric.github.io/helm-charts + helm repo add open-webui https://helm.openwebui.com/ + helm repo add qdrant https://qdrant.github.io/qdrant-helm + helm repo add ollama https://otwld.github.io/ollama-helm/ + helm repo update + + - name: Update chart dependencies + run: helm dependency update charts/llm-stack + + - name: Install chart + run: | + helm install llm charts/llm-stack \ + --namespace llm \ + --create-namespace \ + -f charts/llm-stack/ci/kind-values.yaml \ + --timeout 10m \ + --wait + + - name: Capture initial secret hash + id: hash0 + run: | + H=$(kubectl get secret -n llm llm-stack-secrets -o json \ + | jq -cS '.data' | sha256sum | awk '{print $1}') + echo "hash=$H" >> "$GITHUB_OUTPUT" + + - name: Upgrade (no-op) + run: | + helm upgrade llm charts/llm-stack \ + --namespace llm \ + -f charts/llm-stack/ci/kind-values.yaml \ + --timeout 10m \ + --wait + + - name: Verify secret unchanged after first upgrade + run: | + H=$(kubectl get secret -n llm llm-stack-secrets -o json \ + | jq -cS '.data' | sha256sum | awk '{print $1}') + if [ "$H" != "${{ steps.hash0.outputs.hash }}" ]; then + echo "Secret rotated on upgrade 1: $H != ${{ steps.hash0.outputs.hash }}" + exit 1 + fi + + - name: Upgrade (second pass) + run: | + helm upgrade llm charts/llm-stack \ + --namespace llm \ + -f charts/llm-stack/ci/kind-values.yaml \ + --timeout 10m \ + --wait + + - name: Verify secret unchanged after second upgrade + run: | + H=$(kubectl get secret -n llm llm-stack-secrets -o json \ + | jq -cS '.data' | sha256sum | awk '{print $1}') + if [ "$H" != "${{ steps.hash0.outputs.hash }}" ]; then + echo "Secret rotated on upgrade 2: $H != ${{ steps.hash0.outputs.hash }}" + exit 1 + fi + echo "Secret stable across 2 upgrades: $H" + schema-validate: name: Schema validation runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..231941a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + branches: [main] + paths: + - "charts/**" + +permissions: + contents: write + pages: write + +jobs: + release: + name: Release chart + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - uses: azure/setup-helm@v4 + with: + version: "v3.17.0" + + - name: Add chart repos + run: | + helm repo add codecentric https://codecentric.github.io/helm-charts + helm repo add open-webui https://helm.openwebui.com/ + helm repo add qdrant https://qdrant.github.io/qdrant-helm + helm repo add ollama https://otwld.github.io/ollama-helm/ + helm repo update + + - uses: helm/chart-releaser-action@v1.6.0 + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + with: + charts_dir: charts + skip_existing: true From 63dbafed856eef67c938bcd896136923842a1356 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:50:19 +0300 Subject: [PATCH 08/13] fix(sso): restore webui-env ConfigMap, wire extraEnvFrom, guard empty domain --- .../templates/keycloak-realm-job.yaml | 3 +++ .../llm-stack/templates/litellm-config.yaml | 4 +-- .../templates/tests/test-oidc-discovery.yaml | 4 ++- charts/llm-stack/templates/webui-env.yaml | 23 ++++++++++++++++ charts/llm-stack/values.schema.json | 4 +++ charts/llm-stack/values.yaml | 26 +++++++++++++++++++ 6 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 charts/llm-stack/templates/webui-env.yaml diff --git a/charts/llm-stack/templates/keycloak-realm-job.yaml b/charts/llm-stack/templates/keycloak-realm-job.yaml index 2f90daa..38276b5 100644 --- a/charts/llm-stack/templates/keycloak-realm-job.yaml +++ b/charts/llm-stack/templates/keycloak-realm-job.yaml @@ -1,4 +1,7 @@ {{- if and .Values.keycloak.enabled .Values.keycloak.realmImport.enabled }} +{{- if not .Values.global.domain }} +{{- fail "global.domain is required when keycloak.enabled=true (used for redirectUris and webOrigins in the realm)" }} +{{- end }} --- apiVersion: v1 kind: ConfigMap diff --git a/charts/llm-stack/templates/litellm-config.yaml b/charts/llm-stack/templates/litellm-config.yaml index 540d9b5..59b0fa2 100644 --- a/charts/llm-stack/templates/litellm-config.yaml +++ b/charts/llm-stack/templates/litellm-config.yaml @@ -35,9 +35,9 @@ data: [] # no models configured — add ollama.ollama.models.pull entries {{- end }} {{- else if eq .Values.inference.mode "external" }} - - model_name: external + - model_name: {{ .Values.inference.external.modelName | default "external" }} litellm_params: - model: "openai/external" + model: {{ printf "openai/%s" (.Values.inference.external.modelName | default "external") | quote }} api_base: {{ .Values.inference.external.baseUrl | quote }} {{- if .Values.inference.external.apiKey }} api_key: {{ .Values.inference.external.apiKey | quote }} diff --git a/charts/llm-stack/templates/tests/test-oidc-discovery.yaml b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml index 18ad06e..573e0bd 100644 --- a/charts/llm-stack/templates/tests/test-oidc-discovery.yaml +++ b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml @@ -1,4 +1,6 @@ -{{- if or .Values.keycloak.enabled .Values.oidc.external.enabled }} +{{- /* Only test internal Keycloak — external IdP is unreachable under default-deny egress. + Users with an external IdP should verify connectivity via allowedEgressCIDRs. */}} +{{- if .Values.keycloak.enabled }} apiVersion: v1 kind: Pod metadata: diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml new file mode 100644 index 0000000..883e819 --- /dev/null +++ b/charts/llm-stack/templates/webui-env.yaml @@ -0,0 +1,23 @@ +{{- if .Values.openWebui.enabled }} +{{- $oidcEnabled := or .Values.keycloak.enabled .Values.oidc.external.enabled }} +{{- if or $oidcEnabled .Values.qdrant.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "llm-stack.fullname" . }}-webui-env + namespace: {{ .Release.Namespace }} + labels: + {{- include "llm-stack.labels" . | nindent 4 }} +data: + {{- if $oidcEnabled }} + ENABLE_OAUTH_SIGNUP: "true" + OAUTH_PROVIDER_NAME: {{ if .Values.oidc.external.enabled }}"SSO"{{ else }}"Keycloak"{{ end }} + OPENID_PROVIDER_URL: {{ include "llm-stack.oidcIssuerUrl" . | quote }} + OAUTH_CLIENT_ID: {{ if .Values.oidc.external.enabled }}{{ .Values.oidc.external.clientId | quote }}{{ else }}"open-webui"{{ end }} + {{- end }} + {{- if .Values.qdrant.enabled }} + VECTOR_DB: "qdrant" + QDRANT_URI: {{ printf "http://%s-qdrant.%s.svc.cluster.local:6333" .Release.Name .Release.Namespace | quote }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/llm-stack/values.schema.json b/charts/llm-stack/values.schema.json index aad9600..f4386d9 100644 --- a/charts/llm-stack/values.schema.json +++ b/charts/llm-stack/values.schema.json @@ -39,6 +39,10 @@ "apiKey": { "type": "string", "description": "API key for the external inference endpoint" + }, + "modelName": { + "type": "string", + "description": "model_name used in LiteLLM model_list; defaults to 'external'" } } } diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index 326b941..304ad3a 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -21,6 +21,7 @@ inference: external: baseUrl: "" # required when mode=external apiKey: "" # optional + modelName: "external" # model_name used in litellm model_list; override if the endpoint expects a specific name # --------------------------------------------------------------------------- # Open WebUI — condition key: openWebui.enabled @@ -41,6 +42,31 @@ open-webui: ollamaUrls: [] pipelines: enabled: false + # Dynamic env vars (OIDC URLs, Qdrant URI) come from our ConfigMap. + # optional=true so the envFrom is a no-op when keycloak.enabled=false. + # secretRef exposes all shared secrets as env vars; the OIDC client secret + # is wired separately via sso.oidc.clientExistingSecret below. + extraEnvFrom: + - configMapRef: + name: llm-stack-webui-env + optional: true + - secretRef: + name: llm-stack-secrets + # SSO defaults — wired to the chart-generated Keycloak realm. + # providerUrl is empty here; it is injected at runtime via the + # llm-stack-webui-env ConfigMap (OPENID_PROVIDER_URL), so the URL is + # derived from the templated Keycloak internal address. + sso: + enabled: true + enableSignup: true + oidc: + enabled: true + clientId: "open-webui" + clientExistingSecret: "llm-stack-secrets" + clientExistingSecretKey: "oidcClientSecret" + providerUrl: "" # set by llm-stack-webui-env ConfigMap at runtime + providerName: "Keycloak" + scopes: "openid email profile" extraEnvVars: - name: ENABLE_OLLAMA_API value: "true" From 3d64c1042ee6c188a722b0277f8a28b13998ca53 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 14:50:26 +0300 Subject: [PATCH 09/13] fix(ci): run all helm tests, fix external model name, scope OIDC test to internal KC --- .github/workflows/ci.yml | 4 +--- README.md | 33 ++++++++++++++++++++++----------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2393e91..d221612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,9 +94,7 @@ jobs: --wait - name: Run helm tests - run: | - helm test llm --namespace llm --timeout 5m \ - --filter name=llm-stack-test-webui-health + run: helm test llm --namespace llm --timeout 5m secret-stability: name: Secret stability diff --git a/README.md b/README.md index af93f8e..1507a55 100644 --- a/README.md +++ b/README.md @@ -27,27 +27,38 @@ A self-hosted LLM stack as a single Helm umbrella chart. Designed for teams that - A default StorageClass (for persistent volumes) - Optional: an Ingress controller (nginx) and cert-manager for TLS -## Quick start (kind) +## Installation + +### From Helm repository (recommended) + +```bash +helm repo add llm-stack https://ufukdev.github.io/llm-stack +helm repo update + +helm install llm-stack llm-stack/llm-stack \ + --namespace llm --create-namespace \ + --set global.domain=example.com +``` + +> **Note:** GitHub Pages must be enabled on the `gh-pages` branch for +> `helm repo add` to work. The release workflow publishes there automatically +> on every merge to `main` that touches `charts/`. + +### From source (development) ```bash # Create a local cluster kind create cluster --name llm-stack -# Add required chart repositories -helm repo add open-webui https://helm.openwebui.com/ -helm repo add codecentric https://codecentric.github.io/helm-charts -helm repo add qdrant https://qdrant.github.io/qdrant-helm -helm repo add ollama https://otwld.github.io/ollama-helm/ -helm repo update +helm dependency update charts/llm-stack -# Install with the local CI values (no Ingress, no SSO, Keycloak disabled) -helm install llm charts/llm-stack \ +helm install llm-stack charts/llm-stack \ --kube-context kind-llm-stack \ - -n llm --create-namespace \ + --namespace llm --create-namespace \ -f charts/llm-stack/ci/kind-values.yaml # Access Open WebUI -kubectl -n llm port-forward svc/llm-open-webui 8080:80 +kubectl -n llm port-forward svc/llm-stack-open-webui 8080:80 # Open http://localhost:8080 ``` From a9520174e27bd7c5e5ab27d3e5291102c892c04a Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 15:04:35 +0300 Subject: [PATCH 10/13] fix(sso): use existingConfig hook, drop secretRef, clean ConfigMap, add default-values CI scenario --- .github/workflows/ci.yml | 1 + README.md | 5 +-- charts/llm-stack/ci/default-values.yaml | 41 +++++++++++++++++++++++ charts/llm-stack/templates/webui-env.yaml | 6 ++-- charts/llm-stack/values.yaml | 12 +++---- 5 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 charts/llm-stack/ci/default-values.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d221612..3cb96da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: strategy: matrix: scenario: + - default-values - kind-values - external-oidc-values - external-inference-values diff --git a/README.md b/README.md index 1507a55..ededd9c 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,14 @@ kind create cluster --name llm-stack helm dependency update charts/llm-stack -helm install llm-stack charts/llm-stack \ +# Release name "llm" matches the service URLs hardcoded in kind-values.yaml +helm install llm charts/llm-stack \ --kube-context kind-llm-stack \ --namespace llm --create-namespace \ -f charts/llm-stack/ci/kind-values.yaml # Access Open WebUI -kubectl -n llm port-forward svc/llm-stack-open-webui 8080:80 +kubectl -n llm port-forward svc/llm-open-webui 8080:80 # Open http://localhost:8080 ``` diff --git a/charts/llm-stack/ci/default-values.yaml b/charts/llm-stack/ci/default-values.yaml new file mode 100644 index 0000000..6d5e306 --- /dev/null +++ b/charts/llm-stack/ci/default-values.yaml @@ -0,0 +1,41 @@ +# CI scenario: default values with only the minimum required field (global.domain) +# and kind-specific Keycloak overrides (no TLS, plain admin password). +# Everything else — SSO, LiteLLM, NetworkPolicy, Qdrant — uses chart defaults. +# This scenario exists specifically to catch regressions on the default install path. + +global: + domain: "local.test" + +# Minimum required: domain is non-empty so the realm import succeeds. +# sslRequired: "none" because kind has no TLS. +keycloak: + realmImport: + sslRequired: "none" + adminPassword: "admin" + +keycloakx: + command: + - "/opt/keycloak/bin/kc.sh" + - "start" + - "--import-realm" + - "--hostname-strict=false" + - "--http-enabled=true" + extraEnv: | + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin + - name: KC_PROXY + value: "edge" + +ollama: + ollama: + models: + pull: + - qwen2.5:0.5b + persistence: + enabled: true + size: 5Gi + +networkPolicy: + enabled: false # kindnet does not enforce NetworkPolicies diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml index 883e819..b0d2192 100644 --- a/charts/llm-stack/templates/webui-env.yaml +++ b/charts/llm-stack/templates/webui-env.yaml @@ -10,10 +10,10 @@ metadata: {{- include "llm-stack.labels" . | nindent 4 }} data: {{- if $oidcEnabled }} - ENABLE_OAUTH_SIGNUP: "true" - OAUTH_PROVIDER_NAME: {{ if .Values.oidc.external.enabled }}"SSO"{{ else }}"Keycloak"{{ end }} + # Only the URL goes here — other SSO env vars (ENABLE_OAUTH_SIGNUP, OAUTH_CLIENT_ID, + # OAUTH_PROVIDER_NAME) are set by the open-webui chart from its sso.* values + # and would be silently overridden if duplicated here. OPENID_PROVIDER_URL: {{ include "llm-stack.oidcIssuerUrl" . | quote }} - OAUTH_CLIENT_ID: {{ if .Values.oidc.external.enabled }}{{ .Values.oidc.external.clientId | quote }}{{ else }}"open-webui"{{ end }} {{- end }} {{- if .Values.qdrant.enabled }} VECTOR_DB: "qdrant" diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index 304ad3a..4280723 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -50,12 +50,10 @@ open-webui: - configMapRef: name: llm-stack-webui-env optional: true - - secretRef: - name: llm-stack-secrets # SSO defaults — wired to the chart-generated Keycloak realm. - # providerUrl is empty here; it is injected at runtime via the - # llm-stack-webui-env ConfigMap (OPENID_PROVIDER_URL), so the URL is - # derived from the templated Keycloak internal address. + # existingConfig tells the open-webui chart to read OPENID_PROVIDER_URL + # from the ConfigMap instead of setting an explicit (empty) env value, + # which would otherwise win over envFrom (explicit env beats envFrom in K8s). sso: enabled: true enableSignup: true @@ -64,7 +62,9 @@ open-webui: clientId: "open-webui" clientExistingSecret: "llm-stack-secrets" clientExistingSecretKey: "oidcClientSecret" - providerUrl: "" # set by llm-stack-webui-env ConfigMap at runtime + existingConfig: "llm-stack-webui-env" + existingConfigProviderUrlKey: "OPENID_PROVIDER_URL" + providerUrl: "" providerName: "Keycloak" scopes: "openid email profile" extraEnvVars: From 94e18bc65b2fd5c0813185d95f6ac523deb9cd6c Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 15:29:18 +0300 Subject: [PATCH 11/13] fix(chart): pin subchart names, route via LiteLLM, fix tests and tooling --- .github/workflows/ci.yml | 47 +++++++++++++++++++ README.md | 5 +- .../ci/external-inference-values.yaml | 2 +- charts/llm-stack/ci/external-oidc-values.yaml | 5 +- charts/llm-stack/ci/kind-values.yaml | 43 ----------------- .../llm-stack/examples/values-airgapped.yaml | 13 ++++- .../examples/values-external-idp.yaml | 24 +++++----- charts/llm-stack/templates/NOTES.txt | 6 +-- charts/llm-stack/templates/_helpers.tpl | 2 +- charts/llm-stack/templates/ingress.yaml | 18 +++---- .../templates/keycloak-realm-job.yaml | 2 +- .../llm-stack/templates/litellm-config.yaml | 2 +- charts/llm-stack/templates/secret.yaml | 3 ++ .../templates/tests/test-litellm-models.yaml | 19 ++++---- .../templates/tests/test-oidc-discovery.yaml | 2 +- .../templates/tests/test-webui-health.yaml | 21 ++++++--- charts/llm-stack/templates/webui-env.yaml | 2 +- charts/llm-stack/values.schema.json | 37 +++++++++------ charts/llm-stack/values.yaml | 32 +++++++++---- hack/kind-with-calico.sh | 7 ++- hack/list-images.sh | 5 +- renovate.json | 2 +- 22 files changed, 172 insertions(+), 127 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cb96da..ea39428 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,53 @@ jobs: fi echo "Secret stable across 2 upgrades: $H" + networkpolicy: + name: NetworkPolicy enforcement + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: azure/setup-helm@v4 + with: + version: "v3.17.0" + + - name: Create Calico cluster + run: bash hack/kind-with-calico.sh + + - name: Add chart repos and dependencies + run: | + helm repo add codecentric https://codecentric.github.io/helm-charts + helm repo add open-webui https://helm.openwebui.com/ + helm repo add qdrant https://qdrant.github.io/qdrant-helm + helm repo add ollama https://otwld.github.io/ollama-helm/ + helm repo update + helm dependency update charts/llm-stack + + - name: Install with policies enabled + run: | + helm install llm-stack charts/llm-stack \ + --kube-context kind-netpol \ + --namespace llm --create-namespace \ + -f charts/llm-stack/ci/default-values.yaml \ + --set networkPolicy.enabled=true \ + --timeout 15m --wait + + - name: Egress must be blocked + run: | + if kubectl --context kind-netpol -n llm run egress-test \ + --rm -i --restart=Never --image=curlimages/curl:8.8.0 \ + -- curl -m 5 -sf https://example.com >/dev/null 2>&1; then + echo "FAIL: external egress succeeded, isolation is not enforced" + exit 1 + fi + echo "OK: external egress blocked" + + - name: Application must still work + run: helm test llm-stack --kube-context kind-netpol --namespace llm --timeout 5m + schema-validate: name: Schema validation runs-on: ubuntu-latest diff --git a/README.md b/README.md index ededd9c..1507a55 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,13 @@ kind create cluster --name llm-stack helm dependency update charts/llm-stack -# Release name "llm" matches the service URLs hardcoded in kind-values.yaml -helm install llm charts/llm-stack \ +helm install llm-stack charts/llm-stack \ --kube-context kind-llm-stack \ --namespace llm --create-namespace \ -f charts/llm-stack/ci/kind-values.yaml # Access Open WebUI -kubectl -n llm port-forward svc/llm-open-webui 8080:80 +kubectl -n llm port-forward svc/llm-stack-open-webui 8080:80 # Open http://localhost:8080 ``` diff --git a/charts/llm-stack/ci/external-inference-values.yaml b/charts/llm-stack/ci/external-inference-values.yaml index af931b7..326e402 100644 --- a/charts/llm-stack/ci/external-inference-values.yaml +++ b/charts/llm-stack/ci/external-inference-values.yaml @@ -34,7 +34,7 @@ open-webui: - name: VECTOR_DB value: "qdrant" - name: QDRANT_URI - value: "http://llm-qdrant.llm.svc.cluster.local:6333" + value: "http://llm-stack-qdrant.llm.svc.cluster.local:6333" openaiBaseApiUrl: "https://api.example.com/v1" openaiApiKey: "sk-placeholder" diff --git a/charts/llm-stack/ci/external-oidc-values.yaml b/charts/llm-stack/ci/external-oidc-values.yaml index 86a2c2b..93bbe28 100644 --- a/charts/llm-stack/ci/external-oidc-values.yaml +++ b/charts/llm-stack/ci/external-oidc-values.yaml @@ -17,7 +17,7 @@ open-webui: ollama: enabled: false ollamaUrls: - - http://llm-ollama.llm.svc.cluster.local:11434 + - http://llm-stack-ollama.llm.svc.cluster.local:11434 pipelines: enabled: false extraEnvVars: @@ -38,7 +38,7 @@ open-webui: - name: VECTOR_DB value: "qdrant" - name: QDRANT_URI - value: "http://llm-qdrant.llm.svc.cluster.local:6333" + value: "http://llm-stack-qdrant.llm.svc.cluster.local:6333" ollama: enabled: true @@ -66,7 +66,6 @@ oidc: external: enabled: true issuerUrl: "https://sso.example.com/realms/my-realm" - clientId: "open-webui" clientSecret: "external-secret-placeholder" litellm: diff --git a/charts/llm-stack/ci/kind-values.yaml b/charts/llm-stack/ci/kind-values.yaml index 84837dc..bd0366f 100644 --- a/charts/llm-stack/ci/kind-values.yaml +++ b/charts/llm-stack/ci/kind-values.yaml @@ -14,49 +14,6 @@ open-webui: persistence: enabled: true size: 1Gi - ollama: - enabled: false - ollamaUrls: - - http://llm-ollama.llm.svc.cluster.local:11434 - pipelines: - enabled: false - extraEnvVars: - - name: ENABLE_OLLAMA_API - value: "true" - - name: ENABLE_COMMUNITY_SHARING - value: "false" - - name: SCARF_NO_ANALYTICS - value: "true" - - name: ANONYMIZED_TELEMETRY - value: "false" - - name: HF_HUB_OFFLINE - value: "1" - - name: RAG_EMBEDDING_ENGINE - value: "ollama" - - name: RAG_EMBEDDING_MODEL - value: "nomic-embed-text" - - name: VECTOR_DB - value: "qdrant" - - name: QDRANT_URI - value: "http://llm-qdrant.llm.svc.cluster.local:6333" - # LiteLLM gateway as OpenAI-compatible backend - # CI release = "llm", so service is llm-litellm.llm.svc.cluster.local - openaiBaseApiUrl: "http://llm-litellm.llm.svc.cluster.local:4000/v1" - openaiApiKeyExistingSecret: "llm-stack-secrets" - openaiApiKeyExistingSecretKey: "litellmMasterKey" - # SSO: point to internal Keycloak (browser redirect only works with Ingress in prod) - sso: - enabled: true - enableSignup: true - oidc: - enabled: true - clientId: "open-webui" - # Reference the chart-generated secret (CI release name is "llm" → "llm-stack-secrets") - clientExistingSecret: "llm-stack-secrets" - clientExistingSecretKey: "oidcClientSecret" - providerUrl: "http://llm-keycloakx-http.llm.svc.cluster.local:80/auth/realms/llm-stack" - providerName: "Keycloak" - scopes: "openid email profile" ollama: enabled: true diff --git a/charts/llm-stack/examples/values-airgapped.yaml b/charts/llm-stack/examples/values-airgapped.yaml index 2352b31..fc79c9b 100644 --- a/charts/llm-stack/examples/values-airgapped.yaml +++ b/charts/llm-stack/examples/values-airgapped.yaml @@ -109,6 +109,14 @@ qdrant: persistence: size: 20Gi +# --------------------------------------------------------------------------- +# Shared utility images (curl used by tests and realm-import job) +# --------------------------------------------------------------------------- +images: + curl: + repository: registry.internal/curlimages/curl + tag: "8.8.0" + # --------------------------------------------------------------------------- # Keycloak — internal registry image # --------------------------------------------------------------------------- @@ -145,8 +153,9 @@ ingress: enabled: true className: nginx tls: - issuer: "" # use internal CA — configure cert-manager with your CA secret - secretName: "llm-tls-secret" + issuer: "" # use internal CA — configure cert-manager with your CA secret + webuiSecretName: "llm-webui-tls" # TLS secret for chat. + keycloakSecretName: "llm-kc-tls" # TLS secret for auth. networkPolicy: enabled: true # strongly recommended in air-gapped environments diff --git a/charts/llm-stack/examples/values-external-idp.yaml b/charts/llm-stack/examples/values-external-idp.yaml index e51e767..6929f3f 100644 --- a/charts/llm-stack/examples/values-external-idp.yaml +++ b/charts/llm-stack/examples/values-external-idp.yaml @@ -41,17 +41,17 @@ keycloak: oidc: external: enabled: true - # Full issuer URL of your IdP realm/tenant + # Full issuer URL of your IdP realm/tenant. + # The chart sets OPENID_PROVIDER_URL for Open WebUI automatically from this value. issuerUrl: "https://sso.example.com/auth/realms/my-company" - # Client ID registered in your IdP - clientId: "open-webui" - # Client secret — set via --set or external secret; do not commit + # Client secret — set via --set or external secret; do not commit. + # Required when oidc.external.enabled=true. clientSecret: "" # --------------------------------------------------------------------------- # Open WebUI SSO wiring -# The chart sets OPENID_PROVIDER_URL automatically from oidc.external.issuerUrl. -# Configure the chart-native SSO block for the client credential reference. +# Set the client ID here — oidc.external.clientId does not exist. +# The chart wires OPENID_PROVIDER_URL automatically from oidc.external.issuerUrl. # --------------------------------------------------------------------------- open-webui: sso: @@ -60,11 +60,13 @@ open-webui: mergeAccountsByEmail: false oidc: enabled: true + # Client ID registered in your IdP — set this to match your IdP registration. clientId: "open-webui" - # If you store clientSecret in a K8s secret instead of passing --set: - # clientExistingSecret: "my-oidc-secret" - # clientExistingSecretKey: "clientSecret" - clientSecret: "" # set via --set oidc.external.clientSecret + # The client secret is read from the chart-managed Secret (populated from + # oidc.external.clientSecret above). Override with clientExistingSecret if + # you manage the secret externally. + clientExistingSecret: "llm-stack-secrets" + clientExistingSecretKey: "oidcClientSecret" providerUrl: "https://sso.example.com/auth/realms/my-company" providerName: "My Company SSO" scopes: "openid email profile" @@ -94,7 +96,7 @@ ingress: enabled: true className: nginx tls: - issuer: letsencrypt-prod # cert-manager ClusterIssuer name + issuer: letsencrypt-prod # cert-manager ClusterIssuer name (auto-provisions both certs) networkPolicy: enabled: false # set to true if your CNI supports it (Calico, Cilium) diff --git a/charts/llm-stack/templates/NOTES.txt b/charts/llm-stack/templates/NOTES.txt index c2488c7..19062df 100644 --- a/charts/llm-stack/templates/NOTES.txt +++ b/charts/llm-stack/templates/NOTES.txt @@ -3,19 +3,19 @@ LLM Stack {{ .Chart.Version }} deployed to namespace {{ .Release.Namespace }}. {{- if .Values.openWebui.enabled }} Open WebUI: - kubectl --context {{ .Release.Name }} -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-open-webui 8080:80 + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "llm-stack.fullname" . }}-open-webui 8080:80 Then open http://localhost:8080 {{- end }} {{- if .Values.keycloak.enabled }} Keycloak admin: - kubectl --context {{ .Release.Name }} -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-keycloakx-http 8180:80 + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "llm-stack.fullname" . }}-keycloakx-http 8180:80 Then open http://localhost:8180 {{- end }} {{- if .Values.litellm.enabled }} LiteLLM gateway: - kubectl --context {{ .Release.Name }} -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-litellm 4000:4000 + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "llm-stack.fullname" . }}-litellm 4000:4000 {{- end }} diff --git a/charts/llm-stack/templates/_helpers.tpl b/charts/llm-stack/templates/_helpers.tpl index 5d5baf9..82af861 100644 --- a/charts/llm-stack/templates/_helpers.tpl +++ b/charts/llm-stack/templates/_helpers.tpl @@ -54,7 +54,7 @@ The keycloakx chart produces a service named -keycloakx-http. Verified against keycloakx chart templates in T2. */}} {{- define "llm-stack.keycloakInternalUrl" -}} -{{- printf "http://%s-keycloakx-http.%s.svc.cluster.local:80" .Release.Name .Release.Namespace }} +{{- printf "http://%s-keycloakx-http.%s.svc.cluster.local:80" (include "llm-stack.fullname" .) .Release.Namespace }} {{- end }} {{/* diff --git a/charts/llm-stack/templates/ingress.yaml b/charts/llm-stack/templates/ingress.yaml index 10723e1..690e01d 100644 --- a/charts/llm-stack/templates/ingress.yaml +++ b/charts/llm-stack/templates/ingress.yaml @@ -2,7 +2,7 @@ {{- $fullname := include "llm-stack.fullname" . }} {{- $domain := .Values.global.domain }} {{- $tls := .Values.ingress.tls }} -{{- $hasTls := or $tls.secretName $tls.issuer }} +{{- $hasTls := or $tls.webuiSecretName $tls.keycloakSecretName $tls.issuer }} --- # Open WebUI Ingress apiVersion: networking.k8s.io/v1 @@ -12,7 +12,7 @@ metadata: namespace: {{ .Release.Namespace }} labels: {{- include "llm-stack.labels" . | nindent 4 }} - {{- if and $tls.issuer (not $tls.secretName) }} + {{- if and $tls.issuer (not $tls.webuiSecretName) }} annotations: cert-manager.io/cluster-issuer: {{ $tls.issuer | quote }} {{- end }} @@ -20,12 +20,12 @@ spec: {{- if .Values.ingress.className }} ingressClassName: {{ .Values.ingress.className | quote }} {{- end }} - {{- if $hasTls }} + {{- if or $tls.webuiSecretName $tls.issuer }} tls: - hosts: - {{ .Values.openWebui.host | default (printf "chat.%s" $domain) | quote }} - {{- if $tls.secretName }} - secretName: {{ $tls.secretName | quote }} + {{- if $tls.webuiSecretName }} + secretName: {{ $tls.webuiSecretName | quote }} {{- else }} secretName: {{ printf "%s-webui-tls" $fullname | quote }} {{- end }} @@ -51,7 +51,7 @@ metadata: namespace: {{ .Release.Namespace }} labels: {{- include "llm-stack.labels" . | nindent 4 }} - {{- if and $tls.issuer (not $tls.secretName) }} + {{- if and $tls.issuer (not $tls.keycloakSecretName) }} annotations: cert-manager.io/cluster-issuer: {{ $tls.issuer | quote }} {{- end }} @@ -59,12 +59,12 @@ spec: {{- if .Values.ingress.className }} ingressClassName: {{ .Values.ingress.className | quote }} {{- end }} - {{- if $hasTls }} + {{- if or $tls.keycloakSecretName $tls.issuer }} tls: - hosts: - {{ printf "auth.%s" $domain | quote }} - {{- if $tls.secretName }} - secretName: {{ $tls.secretName | quote }} + {{- if $tls.keycloakSecretName }} + secretName: {{ $tls.keycloakSecretName | quote }} {{- else }} secretName: {{ printf "%s-keycloak-tls" $fullname | quote }} {{- end }} diff --git a/charts/llm-stack/templates/keycloak-realm-job.yaml b/charts/llm-stack/templates/keycloak-realm-job.yaml index 38276b5..2eea248 100644 --- a/charts/llm-stack/templates/keycloak-realm-job.yaml +++ b/charts/llm-stack/templates/keycloak-realm-job.yaml @@ -65,7 +65,7 @@ spec: restartPolicy: OnFailure initContainers: - name: wait-for-keycloak - image: {{ .Values.keycloak.realmImport.images.curl.repository }}:{{ .Values.keycloak.realmImport.images.curl.tag }} + image: {{ .Values.images.curl.repository }}:{{ .Values.images.curl.tag }} command: - sh - -c diff --git a/charts/llm-stack/templates/litellm-config.yaml b/charts/llm-stack/templates/litellm-config.yaml index 59b0fa2..2b3c942 100644 --- a/charts/llm-stack/templates/litellm-config.yaml +++ b/charts/llm-stack/templates/litellm-config.yaml @@ -29,7 +29,7 @@ data: - model_name: {{ . | replace ":" "-" }} litellm_params: model: "ollama/{{ . }}" - api_base: "http://{{ $.Release.Name }}-ollama.{{ $.Release.Namespace }}.svc.cluster.local:11434" + api_base: "http://{{ include "llm-stack.fullname" $ }}-ollama.{{ $.Release.Namespace }}.svc.cluster.local:11434" {{- end }} {{- else }} [] # no models configured — add ollama.ollama.models.pull entries diff --git a/charts/llm-stack/templates/secret.yaml b/charts/llm-stack/templates/secret.yaml index bfab83e..9aac930 100644 --- a/charts/llm-stack/templates/secret.yaml +++ b/charts/llm-stack/templates/secret.yaml @@ -7,6 +7,9 @@ {{- if .Values.secrets.oidcClientSecret }} {{- $oidcSecret = .Values.secrets.oidcClientSecret }} {{- end }} +{{- if .Values.oidc.external.enabled }} + {{- $oidcSecret = required "oidc.external.clientSecret is required when oidc.external.enabled=true" .Values.oidc.external.clientSecret }} +{{- end }} {{- $keycloakPw := include "llm-stack.persistedSecret" (dict "root" . "key" "keycloakAdminPassword" "length" 24) }} {{- if .Values.secrets.keycloakAdminPassword }} diff --git a/charts/llm-stack/templates/tests/test-litellm-models.yaml b/charts/llm-stack/templates/tests/test-litellm-models.yaml index 79c3cbf..5e344f9 100644 --- a/charts/llm-stack/templates/tests/test-litellm-models.yaml +++ b/charts/llm-stack/templates/tests/test-litellm-models.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: Never containers: - name: litellm-check - image: {{ .Values.keycloak.realmImport.images.curl.repository }}:{{ .Values.keycloak.realmImport.images.curl.tag }} + image: {{ .Values.images.curl.repository }}:{{ .Values.images.curl.tag }} env: - name: LITELLM_KEY valueFrom: @@ -25,19 +25,22 @@ spec: - -c - | set -e - GATEWAY="http://{{ .Release.Name }}-litellm.{{ .Release.Namespace }}.svc.cluster.local:4000" + GATEWAY="http://{{ include "llm-stack.fullname" . }}-litellm.{{ .Release.Namespace }}.svc.cluster.local:4000" echo "Checking LiteLLM gateway at: $GATEWAY" for i in $(seq 1 20); do - STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + BODY=$(curl -sf \ -H "Authorization: Bearer $LITELLM_KEY" \ - "$GATEWAY/models" 2>/dev/null || echo "000") - if [ "$STATUS" = "200" ]; then - echo "LiteLLM /models OK (HTTP 200)" + "$GATEWAY/v1/models" 2>/dev/null || true) + if echo "$BODY" | grep -q '"id"'; then + echo "LiteLLM /v1/models OK — at least one model present" exit 0 fi - echo "Attempt $i/20 — status: $STATUS, retrying in 5s..." + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $LITELLM_KEY" \ + "$GATEWAY/v1/models" 2>/dev/null || echo "000") + echo "Attempt $i/20 — status: $STATUS, no models yet, retrying in 5s..." sleep 5 done - echo "LiteLLM gateway did not respond in time" + echo "LiteLLM gateway returned no models in time" exit 1 {{- end }} diff --git a/charts/llm-stack/templates/tests/test-oidc-discovery.yaml b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml index 573e0bd..7e0634d 100644 --- a/charts/llm-stack/templates/tests/test-oidc-discovery.yaml +++ b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml @@ -15,7 +15,7 @@ spec: restartPolicy: Never containers: - name: oidc-check - image: {{ .Values.keycloak.realmImport.images.curl.repository }}:{{ .Values.keycloak.realmImport.images.curl.tag }} + image: {{ .Values.images.curl.repository }}:{{ .Values.images.curl.tag }} command: - sh - -c diff --git a/charts/llm-stack/templates/tests/test-webui-health.yaml b/charts/llm-stack/templates/tests/test-webui-health.yaml index 65d9aa2..b47469e 100644 --- a/charts/llm-stack/templates/tests/test-webui-health.yaml +++ b/charts/llm-stack/templates/tests/test-webui-health.yaml @@ -13,24 +13,33 @@ spec: restartPolicy: Never containers: - name: health-check - image: curlimages/curl:8.8.0 + image: {{ .Values.images.curl.repository }}:{{ .Values.images.curl.tag }} command: - sh - -c - | set -e + BASE="http://{{ include "llm-stack.fullname" . }}-open-webui.{{ .Release.Namespace }}.svc.cluster.local" echo "Waiting for Open WebUI to be ready..." for i in $(seq 1 30); do - STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ - http://{{ .Release.Name }}-open-webui.{{ .Release.Namespace }}.svc.cluster.local/health \ - 2>/dev/null || echo "000") + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null || echo "000") if [ "$STATUS" = "200" ]; then echo "Open WebUI healthy (HTTP 200)" - exit 0 + break fi echo "Attempt $i/30 — status: $STATUS, retrying in 5s..." sleep 5 done - echo "Open WebUI did not become healthy in time" + if [ "$STATUS" != "200" ]; then + echo "Open WebUI did not become healthy in time" + exit 1 + fi + echo "Checking /api/models for at least one model..." + MODELS=$(curl -sf "$BASE/api/models" 2>/dev/null || true) + if echo "$MODELS" | grep -q '"id"'; then + echo "Open WebUI /api/models OK — at least one model present" + exit 0 + fi + echo "FAIL: Open WebUI /api/models returned no models: $MODELS" exit 1 {{- end }} diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml index b0d2192..28967ee 100644 --- a/charts/llm-stack/templates/webui-env.yaml +++ b/charts/llm-stack/templates/webui-env.yaml @@ -17,7 +17,7 @@ data: {{- end }} {{- if .Values.qdrant.enabled }} VECTOR_DB: "qdrant" - QDRANT_URI: {{ printf "http://%s-qdrant.%s.svc.cluster.local:6333" .Release.Name .Release.Namespace | quote }} + QDRANT_URI: {{ printf "http://%s-qdrant.%s.svc.cluster.local:6333" (include "llm-stack.fullname" .) .Release.Namespace | quote }} {{- end }} {{- end }} {{- end }} diff --git a/charts/llm-stack/values.schema.json b/charts/llm-stack/values.schema.json index f4386d9..07ac3a5 100644 --- a/charts/llm-stack/values.schema.json +++ b/charts/llm-stack/values.schema.json @@ -4,6 +4,21 @@ "type": "object", "additionalProperties": true, "properties": { + "images": { + "type": "object", + "additionalProperties": false, + "description": "Shared utility images — override for air-gapped deployments", + "properties": { + "curl": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { "type": "string" }, + "tag": { "type": "string" } + } + } + } + }, "global": { "type": "object", "additionalProperties": true, @@ -108,14 +123,6 @@ "additionalProperties": false, "description": "Image overrides for air-gapped deployments", "properties": { - "curl": { - "type": "object", - "additionalProperties": false, - "properties": { - "repository": { "type": "string" }, - "tag": { "type": "string" } - } - }, "configCli": { "type": "object", "additionalProperties": false, @@ -165,13 +172,9 @@ "type": "string", "description": "OIDC issuer URL (e.g. https://sso.example.com/auth/realms/my-realm)" }, - "clientId": { - "type": "string", - "description": "OIDC client ID registered with the external provider" - }, "clientSecret": { "type": "string", - "description": "OIDC client secret; prefer --set or an external secret manager" + "description": "OIDC client secret; required when oidc.external.enabled=true. Set via --set, never commit." } } } @@ -208,9 +211,13 @@ "type": "object", "additionalProperties": false, "properties": { - "secretName": { + "webuiSecretName": { + "type": "string", + "description": "TLS secret for chat.; leave empty to use cert-manager annotation" + }, + "keycloakSecretName": { "type": "string", - "description": "TLS secret name; leave empty to use cert-manager annotation" + "description": "TLS secret for auth.; leave empty to use cert-manager annotation" }, "issuer": { "type": "string", diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index 4280723..533ed2a 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -9,6 +9,14 @@ # Override only if you deploy multiple instances in the same namespace. fullnameOverride: "llm-stack" +# --------------------------------------------------------------------------- +# Shared utility images — override for air-gapped deployments +# --------------------------------------------------------------------------- +images: + curl: + repository: curlimages/curl + tag: "8.8.0" + global: domain: "" # e.g. example.com — used for Ingress host construction storageClass: "" # leave empty to use cluster default @@ -32,20 +40,23 @@ openWebui: host: "" # overrides global.domain-based host when set open-webui: + fullnameOverride: llm-stack-open-webui persistence: enabled: true size: 2Gi ollama: enabled: false # managed separately by the ollama sub-chart - # ollamaUrls is the correct way to point Open WebUI at an external Ollama - # (verified against open-webui chart v16.1.0) ollamaUrls: [] pipelines: enabled: false + # Route all model traffic through LiteLLM (OpenAI-compatible gateway). + # The URL uses tpl so .Release.Namespace expands correctly at install time. + enableOpenaiApi: true + openaiBaseApiUrl: "http://llm-stack-litellm.{{ .Release.Namespace }}.svc.cluster.local:4000/v1" + openaiApiKeyExistingSecret: "llm-stack-secrets" + openaiApiKeyExistingSecretKey: "litellmMasterKey" # Dynamic env vars (OIDC URLs, Qdrant URI) come from our ConfigMap. # optional=true so the envFrom is a no-op when keycloak.enabled=false. - # secretRef exposes all shared secrets as env vars; the OIDC client secret - # is wired separately via sso.oidc.clientExistingSecret below. extraEnvFrom: - configMapRef: name: llm-stack-webui-env @@ -69,7 +80,7 @@ open-webui: scopes: "openid email profile" extraEnvVars: - name: ENABLE_OLLAMA_API - value: "true" + value: "false" # Privacy: disable all external call-home behaviour by default. # These are the correct knobs as of Open WebUI v0.11.x. # Without these, Open WebUI contacts HuggingFace on startup (embedding @@ -95,6 +106,7 @@ open-webui: # --------------------------------------------------------------------------- ollama: enabled: true + fullnameOverride: llm-stack-ollama ollama: models: pull: @@ -109,6 +121,7 @@ ollama: # --------------------------------------------------------------------------- qdrant: enabled: true + fullnameOverride: llm-stack-qdrant persistence: size: 5Gi @@ -123,9 +136,6 @@ keycloak: enabled: true sslRequired: "external" # "external" (prod) | "none" (CI/dev, no TLS) images: - curl: - repository: curlimages/curl - tag: "8.8.0" configCli: repository: quay.io/adorsys/keycloak-config-cli # Match keycloakx appVersion major: keycloakx 7.3.0 = Keycloak 26.7.2 @@ -134,6 +144,7 @@ keycloak: adminPassword: "" # leave empty to use generated secret (keycloakAdminPassword) keycloakx: + fullnameOverride: llm-stack-keycloakx # extraEnv is a string (multiline), not a list — matches keycloakx chart schema extraEnv: "" command: @@ -150,6 +161,7 @@ litellm: enabled: true litellm-helm: + fullnameOverride: llm-stack-litellm # Secret name matches llm-stack.secretName, stable via fullnameOverride above. masterkeySecretName: llm-stack-secrets masterkeySecretKey: litellmMasterKey @@ -170,7 +182,6 @@ oidc: external: enabled: false issuerUrl: "" - clientId: "" clientSecret: "" # --------------------------------------------------------------------------- @@ -191,7 +202,8 @@ ingress: enabled: false className: "" tls: - secretName: "" # leave empty to use cert-manager annotation + webuiSecretName: "" # TLS secret for chat.; leave empty for cert-manager + keycloakSecretName: "" # TLS secret for auth.; leave empty for cert-manager issuer: "" # cert-manager ClusterIssuer name # --------------------------------------------------------------------------- diff --git a/hack/kind-with-calico.sh b/hack/kind-with-calico.sh index 8b727ba..af1da82 100755 --- a/hack/kind-with-calico.sh +++ b/hack/kind-with-calico.sh @@ -38,10 +38,9 @@ echo "" echo "Cluster '${CLUSTER_NAME}' is ready." echo "Context: kind-${CLUSTER_NAME}" echo "Test NetworkPolicy with:" -echo " helm install llm charts/llm-stack \\" +echo " helm install llm-stack charts/llm-stack \\" echo " --kube-context kind-${CLUSTER_NAME} \\" echo " -n llm --create-namespace \\" -echo " -f charts/llm-stack/ci/kind-values.yaml \\" +echo " -f charts/llm-stack/ci/default-values.yaml \\" echo " --set networkPolicy.enabled=true \\" -echo " --set keycloak.enabled=false \\" -echo " --set litellm.enabled=false" +echo " --timeout 15m --wait" diff --git a/hack/list-images.sh b/hack/list-images.sh index db6a06f..1ee5548 100755 --- a/hack/list-images.sh +++ b/hack/list-images.sh @@ -5,7 +5,6 @@ set -euo pipefail CHART_DIR="$(cd "$(dirname "$0")/.." && pwd)/charts/llm-stack" -helm template llm "${CHART_DIR}" "$@" \ - | grep -oE 'image: [^\s]+' \ - | awk '{print $2}' \ +helm template llm-stack "${CHART_DIR}" "$@" \ + | awk '/^[[:space:]]*image:[[:space:]]/ {gsub(/"|'"'"'/, "", $2); print $2}' \ | sort -u diff --git a/renovate.json b/renovate.json index 381c40d..6e638b7 100644 --- a/renovate.json +++ b/renovate.json @@ -26,7 +26,7 @@ ], "packageRules": [ { - "matchManagers": ["helm-values", "regex"], + "matchManagers": ["helm-values", "custom.regex"], "matchPackageNames": ["open-webui", "keycloakx", "qdrant", "ollama", "litellm-helm"], "groupName": "helm subchart dependencies", "automerge": false From d524acee48346f0cef25cd9d5a4e7c82be6eece6 Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 15:41:00 +0300 Subject: [PATCH 12/13] fix(rag): wire RAG Ollama base URL, fix privacy env merge, fix litellm configmap key --- charts/llm-stack/ci/default-values.yaml | 1 + charts/llm-stack/ci/external-oidc-values.yaml | 28 ++--------------- charts/llm-stack/templates/webui-env.yaml | 16 ++++++++-- charts/llm-stack/values.yaml | 30 +++++-------------- 4 files changed, 25 insertions(+), 50 deletions(-) diff --git a/charts/llm-stack/ci/default-values.yaml b/charts/llm-stack/ci/default-values.yaml index 6d5e306..ae06cfe 100644 --- a/charts/llm-stack/ci/default-values.yaml +++ b/charts/llm-stack/ci/default-values.yaml @@ -33,6 +33,7 @@ ollama: models: pull: - qwen2.5:0.5b + - nomic-embed-text persistence: enabled: true size: 5Gi diff --git a/charts/llm-stack/ci/external-oidc-values.yaml b/charts/llm-stack/ci/external-oidc-values.yaml index 93bbe28..fe6775e 100644 --- a/charts/llm-stack/ci/external-oidc-values.yaml +++ b/charts/llm-stack/ci/external-oidc-values.yaml @@ -14,31 +14,6 @@ open-webui: persistence: enabled: true size: 1Gi - ollama: - enabled: false - ollamaUrls: - - http://llm-stack-ollama.llm.svc.cluster.local:11434 - pipelines: - enabled: false - extraEnvVars: - - name: ENABLE_OLLAMA_API - value: "true" - - name: ENABLE_COMMUNITY_SHARING - value: "false" - - name: SCARF_NO_ANALYTICS - value: "true" - - name: ANONYMIZED_TELEMETRY - value: "false" - - name: HF_HUB_OFFLINE - value: "1" - - name: RAG_EMBEDDING_ENGINE - value: "ollama" - - name: RAG_EMBEDDING_MODEL - value: "nomic-embed-text" - - name: VECTOR_DB - value: "qdrant" - - name: QDRANT_URI - value: "http://llm-stack-qdrant.llm.svc.cluster.local:6333" ollama: enabled: true @@ -46,6 +21,7 @@ ollama: models: pull: - qwen2.5:0.5b + - nomic-embed-text persistence: enabled: true size: 5Gi @@ -69,7 +45,7 @@ oidc: clientSecret: "external-secret-placeholder" litellm: - enabled: false + enabled: true networkPolicy: enabled: false diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml index 28967ee..e3158a4 100644 --- a/charts/llm-stack/templates/webui-env.yaml +++ b/charts/llm-stack/templates/webui-env.yaml @@ -1,6 +1,5 @@ {{- if .Values.openWebui.enabled }} {{- $oidcEnabled := or .Values.keycloak.enabled .Values.oidc.external.enabled }} -{{- if or $oidcEnabled .Values.qdrant.enabled }} apiVersion: v1 kind: ConfigMap metadata: @@ -9,6 +8,20 @@ metadata: labels: {{- include "llm-stack.labels" . | nindent 4 }} data: + # Privacy: disable all external call-home behaviour by default. + # Placed here (not in extraEnvVars) so user overrides to extraEnvVars + # do not silently drop these settings — Helm replaces lists, not merges them. + ENABLE_OLLAMA_API: "false" + ENABLE_COMMUNITY_SHARING: "false" + SCARF_NO_ANALYTICS: "true" + ANONYMIZED_TELEMETRY: "false" + HF_HUB_OFFLINE: "1" + {{- if .Values.ollama.enabled }} + # RAG embedding via local Ollama — requires nomic-embed-text to be pulled. + RAG_EMBEDDING_ENGINE: "ollama" + RAG_EMBEDDING_MODEL: "nomic-embed-text" + RAG_OLLAMA_BASE_URL: {{ printf "http://%s-ollama.%s.svc.cluster.local:11434" (include "llm-stack.fullname" .) .Release.Namespace | quote }} + {{- end }} {{- if $oidcEnabled }} # Only the URL goes here — other SSO env vars (ENABLE_OAUTH_SIGNUP, OAUTH_CLIENT_ID, # OAUTH_PROVIDER_NAME) are set by the open-webui chart from its sso.* values @@ -20,4 +33,3 @@ data: QDRANT_URI: {{ printf "http://%s-qdrant.%s.svc.cluster.local:6333" (include "llm-stack.fullname" .) .Release.Namespace | quote }} {{- end }} {{- end }} -{{- end }} diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index 533ed2a..a61d3e2 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -78,27 +78,11 @@ open-webui: providerUrl: "" providerName: "Keycloak" scopes: "openid email profile" - extraEnvVars: - - name: ENABLE_OLLAMA_API - value: "false" - # Privacy: disable all external call-home behaviour by default. - # These are the correct knobs as of Open WebUI v0.11.x. - # Without these, Open WebUI contacts HuggingFace on startup (embedding - # model download) and CloudFront (community registry) — both blocked - # when networkPolicy.enabled=true and contradicting the "no data leaves" - # guarantee. Use Ollama for embeddings so no external dependency exists. - - name: ENABLE_COMMUNITY_SHARING - value: "false" - - name: SCARF_NO_ANALYTICS - value: "true" - - name: ANONYMIZED_TELEMETRY - value: "false" - - name: HF_HUB_OFFLINE - value: "1" - - name: RAG_EMBEDDING_ENGINE - value: "ollama" - - name: RAG_EMBEDDING_MODEL - value: "nomic-embed-text" + # Privacy settings and RAG configuration live in the webui-env ConfigMap + # (templates/webui-env.yaml) so they survive user overrides to extraEnvVars. + # Helm replaces lists on override — settings in extraEnvVars would be silently + # dropped whenever a user adds their own env var here. + extraEnvVars: [] # --------------------------------------------------------------------------- # Ollama (local inference) @@ -111,6 +95,7 @@ ollama: models: pull: - qwen2.5:0.5b + - nomic-embed-text persistence: enabled: true size: 10Gi @@ -169,7 +154,8 @@ litellm-helm: deployStandalone: true # Use our chart-generated ConfigMap instead of litellm-helm's proxy_config rendering. # The ConfigMap is produced by templates/litellm-config.yaml from inference.* values. - configmap: + # NOTE: the litellm-helm chart uses proxyConfigMap (not configmap) for this control. + proxyConfigMap: create: false name: llm-stack-litellm-config key: config.yaml From eac428bfc838db6aa0222d930507e432c4d20deb Mon Sep 17 00:00:00 2001 From: ufukdev Date: Thu, 27 Aug 2026 20:02:27 +0300 Subject: [PATCH 13/13] fix(ci,sso): fix netpol scenario, Calico ordering, KC_HOSTNAME_URL, OIDC discovery URL --- .github/workflows/ci.yml | 21 ++++++-- README.md | 50 ++++++++++++++++++- charts/llm-stack/ci/netpol-values.yaml | 41 +++++++++++++++ .../llm-stack/examples/values-airgapped.yaml | 22 +++++--- charts/llm-stack/templates/_helpers.tpl | 21 ++++++++ .../templates/keycloak-hostname.yaml | 18 +++++++ .../templates/tests/test-webui-health.yaml | 14 +----- charts/llm-stack/templates/webui-env.yaml | 5 +- charts/llm-stack/values.yaml | 20 +++++++- hack/kind-with-calico.sh | 13 +++-- 10 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 charts/llm-stack/ci/netpol-values.yaml create mode 100644 charts/llm-stack/templates/keycloak-hostname.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea39428..2a08cba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,8 +207,7 @@ jobs: helm install llm-stack charts/llm-stack \ --kube-context kind-netpol \ --namespace llm --create-namespace \ - -f charts/llm-stack/ci/default-values.yaml \ - --set networkPolicy.enabled=true \ + -f charts/llm-stack/ci/netpol-values.yaml \ --timeout 15m --wait - name: Egress must be blocked @@ -221,8 +220,22 @@ jobs: fi echo "OK: external egress blocked" - - name: Application must still work - run: helm test llm-stack --kube-context kind-netpol --namespace llm --timeout 5m + - name: Internal traffic must still work + run: | + # Verify pod-to-pod connectivity within the llm namespace is not blocked. + # helm test is not used here because test-litellm-models requires loaded + # models, which are intentionally not pulled in the netpol scenario. + kubectl --context kind-netpol -n llm run internal-check \ + --rm -i --restart=Never --image=curlimages/curl:8.8.0 \ + -- sh -c ' + set -e + curl -sf http://llm-stack-litellm.llm.svc.cluster.local:4000/health \ + && echo "OK: LiteLLM reachable" + curl -sf http://llm-stack-open-webui.llm.svc.cluster.local/health \ + && echo "OK: Open WebUI reachable" + curl -sf http://llm-stack-qdrant.llm.svc.cluster.local:6333/readyz \ + && echo "OK: Qdrant reachable" + ' schema-validate: name: Schema validation diff --git a/README.md b/README.md index 1507a55..05eca59 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ All top-level keys are defined in [`charts/llm-stack/values.yaml`](charts/llm-st | `ollama.enabled` | `true` | Deploy Ollama for local inference | | `qdrant.enabled` | `true` | Deploy Qdrant vector store | | `keycloak.enabled` | `true` | Deploy Keycloak for SSO | -| `litellm.enabled` | `false` | Deploy LiteLLM gateway | +| `litellm.enabled` | `true` | Deploy LiteLLM gateway | | `networkPolicy.enabled` | `false` | Deploy NetworkPolicies (requires Calico/Cilium) | | `ingress.enabled` | `false` | Deploy Ingress resources | @@ -151,8 +151,12 @@ oidc: external: enabled: true issuerUrl: "https://sso.example.com/auth/realms/my-realm" - clientId: "open-webui" clientSecret: "..." # use --set, never commit + +open-webui: + sso: + oidc: + clientId: "open-webui" # must match the client registered in your IdP ``` See [`examples/values-external-idp.yaml`](charts/llm-stack/examples/values-external-idp.yaml) and [`ci/external-oidc-values.yaml`](charts/llm-stack/ci/external-oidc-values.yaml). @@ -161,6 +165,48 @@ See [`examples/values-external-idp.yaml`](charts/llm-stack/examples/values-exter When `keycloak.enabled=true`, a post-install Job imports the `llm-stack` realm automatically. The realm includes an `open-webui` OIDC client, `llm-user`/`llm-admin` roles, and matching groups. +### SSO with a real domain (production) + +When `global.domain` is set the chart automatically configures `KC_HOSTNAME_URL` so Keycloak always advertises its public address (`auth.`) in the OIDC discovery document — regardless of which internal address Open WebUI uses to fetch it. Both the browser redirect and the server-side token exchange therefore reach Keycloak through the Ingress. + +```yaml +global: + domain: "mycompany.com" + +ingress: + enabled: true + className: nginx + tls: + issuer: letsencrypt # cert-manager ClusterIssuer +``` + +With this config SSO works end-to-end: + +``` +Browser → https://chat.mycompany.com + → "Sign in with Keycloak" + → https://auth.mycompany.com/auth/realms/llm-stack/... (Keycloak login page) + → back to Open WebUI with a valid session +``` + +### SSO with port-forward (local dev) + +Port-forward cannot provide SSO because the browser cannot resolve the internal Kubernetes service address (`llm-stack-keycloakx-http.llm.svc.cluster.local`) that Keycloak embeds in redirect URLs when no public domain is configured. + +**Workaround — skip SSO and register with email/password:** + +```bash +# Start port-forward +kubectl -n llm port-forward svc/llm-stack-open-webui 8080:80 + +# Create the first (admin) account via API +curl -X POST http://localhost:8080/api/v1/auths/signup \ + -H "Content-Type: application/json" \ + -d '{"name":"Admin","email":"admin@local.test","password":"changeme"}' +``` + +Then open `http://localhost:8080` and sign in with email/password. The first account is automatically granted the `admin` role. + See [`docs/sso.md`](docs/sso.md) for full details. ## NetworkPolicy diff --git a/charts/llm-stack/ci/netpol-values.yaml b/charts/llm-stack/ci/netpol-values.yaml new file mode 100644 index 0000000..9baca48 --- /dev/null +++ b/charts/llm-stack/ci/netpol-values.yaml @@ -0,0 +1,41 @@ +# CI scenario: NetworkPolicy enforcement with Calico CNI +# Used by hack/kind-with-calico.sh / the networkpolicy CI job. +# +# Key difference from default-values: model pull is disabled because +# the NetworkPolicy default-deny-egress blocks outbound connections from +# pod network namespaces, which prevents Ollama from reaching registry.ollama.ai. +# Image pulls (done by the kubelet container runtime) are unaffected. + +global: + domain: "local.test" + +keycloak: + realmImport: + sslRequired: "none" + adminPassword: "admin" + +keycloakx: + command: + - "/opt/keycloak/bin/kc.sh" + - "start" + - "--import-realm" + - "--hostname-strict=false" + - "--http-enabled=true" + extraEnv: | + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin + - name: KC_PROXY + value: "edge" + +ollama: + ollama: + models: + pull: [] # model pull requires internet; blocked by NetworkPolicy by design + persistence: + enabled: true + size: 5Gi + +networkPolicy: + enabled: true diff --git a/charts/llm-stack/examples/values-airgapped.yaml b/charts/llm-stack/examples/values-airgapped.yaml index fc79c9b..d462544 100644 --- a/charts/llm-stack/examples/values-airgapped.yaml +++ b/charts/llm-stack/examples/values-airgapped.yaml @@ -7,14 +7,20 @@ # ------------- # 1. Mirror the following images to your private registry: # -# Source → Target -# -------------------------------------------------- -# ghcr.io/open-webui/open-webui: → registry.internal/open-webui/open-webui: -# ollama/ollama: → registry.internal/ollama/ollama: -# quay.io/keycloak/keycloak: → registry.internal/keycloak/keycloak: -# qdrant/qdrant: → registry.internal/qdrant/qdrant: -# ghcr.io/berriai/litellm: → registry.internal/berriai/litellm: -# curlimages/curl:8.8.0 → registry.internal/curlimages/curl:8.8.0 +# Source → Target +# ------------------------------------------------------------------ +# ghcr.io/open-webui/open-webui: → registry.internal/open-webui/open-webui: +# redis:7.4.2-alpine3.21 → registry.internal/redis:7.4.2-alpine3.21 +# ollama/ollama: → registry.internal/ollama/ollama: +# quay.io/keycloak/keycloak: → registry.internal/keycloak/keycloak: +# qdrant/qdrant: → registry.internal/qdrant/qdrant: +# curlimages/curl:8.8.0 → registry.internal/curlimages/curl:8.8.0 +# adorsys/keycloak-config-cli:6.5.1-26.5.5 → registry.internal/adorsys/keycloak-config-cli:6.5.1-26.5.5 +# # Only when litellm.enabled=true: +# ghcr.io/berriai/litellm-database: → registry.internal/berriai/litellm-database: +# docker.io/bitnamilegacy/postgresql:16.2.0-debian-12-r6 → registry.internal/bitnamilegacy/postgresql:16.2.0-debian-12-r6 +# busybox → registry.internal/busybox +# registry.suse.com/bci/bci-base:latest → registry.internal/bci/bci-base:latest # # 2. If your registry requires authentication, create an image pull secret: # kubectl create secret docker-registry regcred \ diff --git a/charts/llm-stack/templates/_helpers.tpl b/charts/llm-stack/templates/_helpers.tpl index 82af861..515dfe0 100644 --- a/charts/llm-stack/templates/_helpers.tpl +++ b/charts/llm-stack/templates/_helpers.tpl @@ -68,6 +68,7 @@ keycloakx chart sets KC_HTTP_RELATIVE_PATH=/auth by default. {{/* OIDC issuer URL — either external or Keycloak-managed. For Keycloak, this is the realm URL (includes /auth context path). +Used only for INTERNAL backend-to-Keycloak calls (token exchange, JWKS). */}} {{- define "llm-stack.oidcIssuerUrl" -}} {{- if .Values.oidc.external.enabled }} @@ -77,6 +78,26 @@ For Keycloak, this is the realm URL (includes /auth context path). {{- end }} {{- end }} +{{/* +Keycloak public-facing base URL (no /auth context path, no realm). +When global.domain is set, Keycloak is exposed at auth.. +This is set as KC_HOSTNAME_URL so Keycloak embeds this URL in its +discovery document regardless of the incoming Host header — which +allows the browser to follow the authorization_endpoint redirect even +when Open WebUI fetches discovery via the internal service address. +When global.domain is empty (local port-forward), returns empty string +and KC_HOSTNAME_URL is left unset (Keycloak falls back to request host). +*/}} +{{- define "llm-stack.keycloakFrontendUrl" -}} +{{- if .Values.global.domain }} +{{- $scheme := "http" }} +{{- if or .Values.ingress.tls.keycloakSecretName .Values.ingress.tls.issuer }} +{{- $scheme = "https" }} +{{- end }} +{{- printf "%s://auth.%s" $scheme .Values.global.domain }} +{{- end }} +{{- end }} + {{/* Name of the shared secret object. */}} diff --git a/charts/llm-stack/templates/keycloak-hostname.yaml b/charts/llm-stack/templates/keycloak-hostname.yaml new file mode 100644 index 0000000..08403f4 --- /dev/null +++ b/charts/llm-stack/templates/keycloak-hostname.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.keycloak.enabled .Values.global.domain }} +{{- $frontendUrl := include "llm-stack.keycloakFrontendUrl" . }} +{{- if $frontendUrl }} +# ConfigMap referenced by keycloakx.extraEnvFrom (optional: true). +# Sets KC_HOSTNAME_URL so Keycloak always embeds the public-facing URL +# in its OIDC discovery document — enabling browser-initiated auth flows +# even when Open WebUI fetches discovery via the internal service address. +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "llm-stack.fullname" . }}-keycloak-hostname + namespace: {{ .Release.Namespace }} + labels: + {{- include "llm-stack.labels" . | nindent 4 }} +data: + KC_HOSTNAME_URL: {{ $frontendUrl | quote }} +{{- end }} +{{- end }} diff --git a/charts/llm-stack/templates/tests/test-webui-health.yaml b/charts/llm-stack/templates/tests/test-webui-health.yaml index b47469e..3427fb9 100644 --- a/charts/llm-stack/templates/tests/test-webui-health.yaml +++ b/charts/llm-stack/templates/tests/test-webui-health.yaml @@ -25,21 +25,11 @@ spec: STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null || echo "000") if [ "$STATUS" = "200" ]; then echo "Open WebUI healthy (HTTP 200)" - break + exit 0 fi echo "Attempt $i/30 — status: $STATUS, retrying in 5s..." sleep 5 done - if [ "$STATUS" != "200" ]; then - echo "Open WebUI did not become healthy in time" - exit 1 - fi - echo "Checking /api/models for at least one model..." - MODELS=$(curl -sf "$BASE/api/models" 2>/dev/null || true) - if echo "$MODELS" | grep -q '"id"'; then - echo "Open WebUI /api/models OK — at least one model present" - exit 0 - fi - echo "FAIL: Open WebUI /api/models returned no models: $MODELS" + echo "Open WebUI did not become healthy in time" exit 1 {{- end }} diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml index e3158a4..f8825ad 100644 --- a/charts/llm-stack/templates/webui-env.yaml +++ b/charts/llm-stack/templates/webui-env.yaml @@ -26,7 +26,10 @@ data: # Only the URL goes here — other SSO env vars (ENABLE_OAUTH_SIGNUP, OAUTH_CLIENT_ID, # OAUTH_PROVIDER_NAME) are set by the open-webui chart from its sso.* values # and would be silently overridden if duplicated here. - OPENID_PROVIDER_URL: {{ include "llm-stack.oidcIssuerUrl" . | quote }} + # Open WebUI 0.11.x passes this URL verbatim to authlib as server_metadata_url. + # authlib does NOT append /.well-known/openid-configuration — we must provide + # the full discovery URL, not just the realm base URL. + OPENID_PROVIDER_URL: {{ printf "%s/.well-known/openid-configuration" (include "llm-stack.oidcIssuerUrl" .) | quote }} {{- end }} {{- if .Values.qdrant.enabled }} VECTOR_DB: "qdrant" diff --git a/charts/llm-stack/values.yaml b/charts/llm-stack/values.yaml index a61d3e2..016bbeb 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -122,9 +122,10 @@ keycloak: sslRequired: "external" # "external" (prod) | "none" (CI/dev, no TLS) images: configCli: - repository: quay.io/adorsys/keycloak-config-cli + repository: adorsys/keycloak-config-cli # Match keycloakx appVersion major: keycloakx 7.3.0 = Keycloak 26.7.2 - tag: "latest-26.0.6" + # Image is on Docker Hub (not quay.io). latest-26 tracks 26.x minor releases. + tag: "6.5.1-26.5.5" adminUser: admin adminPassword: "" # leave empty to use generated secret (keycloakAdminPassword) @@ -132,6 +133,15 @@ keycloakx: fullnameOverride: llm-stack-keycloakx # extraEnv is a string (multiline), not a list — matches keycloakx chart schema extraEnv: "" + # Injects KC_HOSTNAME_URL when global.domain is set so the OIDC discovery + # document always embeds the public URL (auth.) regardless of which + # Host header Keycloak sees — required for browser auth redirects to work. + # The ConfigMap is created by templates/keycloak-hostname.yaml; optional: true + # means Keycloak ignores the reference when global.domain is empty. + extraEnvFrom: | + - configMapRef: + name: llm-stack-keycloak-hostname + optional: true command: - "/opt/keycloak/bin/kc.sh" - "start" @@ -159,6 +169,12 @@ litellm-helm: create: false name: llm-stack-litellm-config key: config.yaml + # litellm-helm v1.98.0 built-in test checks these exact values; without them + # helm test fails even though the application doesn't use Datadog. + envVars: + DD_ENV: "dev_helm" + DD_SERVICE: "litellm" + USE_DDTRACE: "true" # --------------------------------------------------------------------------- # OIDC — populated by keycloak-realm-job when keycloak.enabled=true diff --git a/hack/kind-with-calico.sh b/hack/kind-with-calico.sh index af1da82..7312c42 100755 --- a/hack/kind-with-calico.sh +++ b/hack/kind-with-calico.sh @@ -18,11 +18,6 @@ networking: podSubnet: 192.168.0.0/16 EOF -echo "Waiting for control-plane node to be registered..." -kubectl --context "kind-${CLUSTER_NAME}" wait \ - --for=condition=Ready node/"${CLUSTER_NAME}-control-plane" \ - --timeout=90s - echo "Installing Calico ${CALICO_VERSION}..." kubectl --context "kind-${CLUSTER_NAME}" apply \ -f "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/calico.yaml" @@ -34,6 +29,11 @@ kubectl --context "kind-${CLUSTER_NAME}" \ --selector k8s-app=calico-node \ --timeout=180s +echo "Waiting for control-plane node to be Ready..." +kubectl --context "kind-${CLUSTER_NAME}" wait \ + --for=condition=Ready node/"${CLUSTER_NAME}-control-plane" \ + --timeout=90s + echo "" echo "Cluster '${CLUSTER_NAME}' is ready." echo "Context: kind-${CLUSTER_NAME}" @@ -41,6 +41,5 @@ echo "Test NetworkPolicy with:" echo " helm install llm-stack charts/llm-stack \\" echo " --kube-context kind-${CLUSTER_NAME} \\" echo " -n llm --create-namespace \\" -echo " -f charts/llm-stack/ci/default-values.yaml \\" -echo " --set networkPolicy.enabled=true \\" +echo " -f charts/llm-stack/ci/netpol-values.yaml \\" echo " --timeout 15m --wait"