diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc359f8..2a08cba 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 @@ -94,9 +95,147 @@ jobs: --wait - name: Run helm tests + run: helm test llm --namespace llm --timeout 5m + + 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" + + 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/netpol-values.yaml \ + --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: Internal traffic must still work run: | - helm test llm --namespace llm --timeout 5m \ - --filter name=llm-llm-stack-test-webui-health + # 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/.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 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/README.md b/README.md index af93f8e..05eca59 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 ``` @@ -65,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 | @@ -140,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). @@ -150,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/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/default-values.yaml b/charts/llm-stack/ci/default-values.yaml new file mode 100644 index 0000000..ae06cfe --- /dev/null +++ b/charts/llm-stack/ci/default-values.yaml @@ -0,0 +1,42 @@ +# 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 + - nomic-embed-text + persistence: + enabled: true + size: 5Gi + +networkPolicy: + enabled: false # kindnet does not enforce NetworkPolicies diff --git a/charts/llm-stack/ci/external-inference-values.yaml b/charts/llm-stack/ci/external-inference-values.yaml index 64ed190..326e402 100644 --- a/charts/llm-stack/ci/external-inference-values.yaml +++ b/charts/llm-stack/ci/external-inference-values.yaml @@ -23,10 +23,18 @@ 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 - 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 3c81922..fe6775e 100644 --- a/charts/llm-stack/ci/external-oidc-values.yaml +++ b/charts/llm-stack/ci/external-oidc-values.yaml @@ -14,19 +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: VECTOR_DB - value: "qdrant" - - name: QDRANT_URI - value: "http://llm-qdrant.llm.svc.cluster.local:6333" ollama: enabled: true @@ -34,6 +21,7 @@ ollama: models: pull: - qwen2.5:0.5b + - nomic-embed-text persistence: enabled: true size: 5Gi @@ -54,11 +42,10 @@ oidc: external: enabled: true issuerUrl: "https://sso.example.com/realms/my-realm" - clientId: "open-webui" clientSecret: "external-secret-placeholder" litellm: - enabled: false + enabled: true networkPolicy: enabled: false diff --git a/charts/llm-stack/ci/kind-values.yaml b/charts/llm-stack/ci/kind-values.yaml index 88fef1b..bd0366f 100644 --- a/charts/llm-stack/ci/kind-values.yaml +++ b/charts/llm-stack/ci/kind-values.yaml @@ -14,37 +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: 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-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-llm-stack-secrets") - clientExistingSecret: "llm-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 @@ -65,6 +34,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) @@ -87,20 +57,8 @@ keycloakx: litellm: enabled: true -# litellm-helm sub-chart: CI release is "llm" → secret name is "llm-llm-stack-secrets" -litellm-helm: - masterkeySecretName: "llm-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/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 2352b31..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 \ @@ -109,6 +115,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 +159,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..515dfe0 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 }} {{/* @@ -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/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-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/keycloak-realm-job.yaml b/charts/llm-stack/templates/keycloak-realm-job.yaml index aafa6f2..2eea248 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 @@ -19,15 +22,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" } @@ -62,7 +65,7 @@ spec: restartPolicy: OnFailure initContainers: - name: wait-for-keycloak - image: curlimages/curl:8.5.0 + image: {{ .Values.images.curl.repository }}:{{ .Values.images.curl.tag }} command: - sh - -c @@ -73,7 +76,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..2b3c942 --- /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://{{ include "llm-stack.fullname" $ }}-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: {{ .Values.inference.external.modelName | default "external" }} + litellm_params: + 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 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/llm-stack/templates/networkpolicy.yaml b/charts/llm-stack/templates/networkpolicy.yaml index bf0515f..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 @@ -49,28 +53,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/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 new file mode 100644 index 0000000..5e344f9 --- /dev/null +++ b/charts/llm-stack/templates/tests/test-litellm-models.yaml @@ -0,0 +1,46 @@ +{{- 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.images.curl.repository }}:{{ .Values.images.curl.tag }} + env: + - name: LITELLM_KEY + valueFrom: + secretKeyRef: + name: {{ include "llm-stack.secretName" . }} + key: litellmMasterKey + command: + - sh + - -c + - | + set -e + 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 + BODY=$(curl -sf \ + -H "Authorization: Bearer $LITELLM_KEY" \ + "$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 + 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 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 new file mode 100644 index 0000000..7e0634d --- /dev/null +++ b/charts/llm-stack/templates/tests/test-oidc-discovery.yaml @@ -0,0 +1,38 @@ +{{- /* 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: + 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.images.curl.repository }}:{{ .Values.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/tests/test-webui-health.yaml b/charts/llm-stack/templates/tests/test-webui-health.yaml index 65d9aa2..3427fb9 100644 --- a/charts/llm-stack/templates/tests/test-webui-health.yaml +++ b/charts/llm-stack/templates/tests/test-webui-health.yaml @@ -13,17 +13,16 @@ 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 diff --git a/charts/llm-stack/templates/webui-env.yaml b/charts/llm-stack/templates/webui-env.yaml index 50f00a6..f8825ad 100644 --- a/charts/llm-stack/templates/webui-env.yaml +++ b/charts/llm-stack/templates/webui-env.yaml @@ -1,20 +1,5 @@ -{{/* - 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: @@ -23,19 +8,31 @@ metadata: 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" + # 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 + # and would be silently overridden if duplicated here. + # 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" - 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 459c598..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, @@ -39,6 +54,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'" } } } @@ -93,6 +112,27 @@ "enabled": { "type": "boolean", "description": "Run the realm-import Job on first install" + }, + "sslRequired": { + "type": "string", + "enum": ["external", "all", "none"], + "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": { + "configCli": { + "type": "object", + "additionalProperties": false, + "description": "keycloak-config-cli image — tag must match Keycloak major version", + "properties": { + "repository": { "type": "string" }, + "tag": { "type": "string" } + } + } + } } } }, @@ -132,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." } } } @@ -151,6 +187,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\"]" } } }, @@ -170,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 f733742..016bbeb 100644 --- a/charts/llm-stack/values.yaml +++ b/charts/llm-stack/values.yaml @@ -2,18 +2,34 @@ # 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" + +# --------------------------------------------------------------------------- +# 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 + 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 + modelName: "external" # model_name used in litellm model_list; override if the endpoint expects a specific name # --------------------------------------------------------------------------- # Open WebUI — condition key: openWebui.enabled @@ -21,22 +37,52 @@ inference: # --------------------------------------------------------------------------- openWebui: enabled: true - host: "" # overrides global.domain-based host when set + 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) + enabled: false # managed separately by the ollama sub-chart ollamaUrls: [] pipelines: enabled: false - extraEnvVars: - - name: ENABLE_OLLAMA_API - value: "true" + # 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. + extraEnvFrom: + - configMapRef: + name: llm-stack-webui-env + optional: true + # SSO defaults — wired to the chart-generated Keycloak realm. + # 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 + oidc: + enabled: true + clientId: "open-webui" + clientExistingSecret: "llm-stack-secrets" + clientExistingSecretKey: "oidcClientSecret" + existingConfig: "llm-stack-webui-env" + existingConfigProviderUrlKey: "OPENID_PROVIDER_URL" + providerUrl: "" + providerName: "Keycloak" + scopes: "openid email profile" + # 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) @@ -44,10 +90,12 @@ open-webui: # --------------------------------------------------------------------------- ollama: enabled: true + fullnameOverride: llm-stack-ollama ollama: models: pull: - qwen2.5:0.5b + - nomic-embed-text persistence: enabled: true size: 10Gi @@ -58,6 +106,7 @@ ollama: # --------------------------------------------------------------------------- qdrant: enabled: true + fullnameOverride: llm-stack-qdrant persistence: size: 5Gi @@ -70,12 +119,29 @@ keycloak: enabled: true realmImport: enabled: true + sslRequired: "external" # "external" (prod) | "none" (CI/dev, no TLS) + images: + configCli: + repository: adorsys/keycloak-config-cli + # Match keycloakx appVersion major: keycloakx 7.3.0 = Keycloak 26.7.2 + # 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) + 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: "" + # 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" @@ -87,23 +153,28 @@ 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. - # Default assumes `helm install llm-stack charts/llm-stack` (release = chart name). - # Override in your values if using a different release name. + fullnameOverride: llm-stack-litellm + # 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. + # NOTE: the litellm-helm chart uses proxyConfigMap (not configmap) for this control. + proxyConfigMap: + 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 @@ -113,14 +184,18 @@ oidc: external: enabled: false issuerUrl: "" - clientId: "" clientSecret: "" # --------------------------------------------------------------------------- # 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. + # Example: ["10.0.0.0/8", "192.168.1.100/32"] + allowedEgressCIDRs: [] # --------------------------------------------------------------------------- # Ingress @@ -129,8 +204,9 @@ ingress: enabled: false className: "" tls: - secretName: "" # leave empty to use cert-manager annotation - issuer: "" # cert-manager ClusterIssuer name + 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 # --------------------------------------------------------------------------- # Shared secrets — generated on first install, stable across upgrades diff --git a/hack/kind-with-calico.sh b/hack/kind-with-calico.sh index 8b727ba..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,14 +29,17 @@ 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}" 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 " --set networkPolicy.enabled=true \\" -echo " --set keycloak.enabled=false \\" -echo " --set litellm.enabled=false" +echo " -f charts/llm-stack/ci/netpol-values.yaml \\" +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