From b6997292d49729a9f7e7d241f9aaf8b3838df230 Mon Sep 17 00:00:00 2001 From: younsl Date: Tue, 15 Sep 2026 14:44:55 +0900 Subject: [PATCH] feat(helm): render a Gateway API HTTPRoute for the litellm-helm chart Clusters served by a Gateway rather than an ingress controller had no way to publish the proxy from this chart, so operators kept a hand written HTTPRoute outside the release. httpRoute.enabled renders one, attached to the Gateways named in httpRoute.parentRefs, answering on httpRoute.hostnames, with every rule backed by the chart's own Service on service.port. It is independent of ingress.enabled, so both can run during a migration. Signed-off-by: younsl --- helm/litellm-helm/Chart.yaml | 2 +- helm/litellm-helm/README.md | 5 + helm/litellm-helm/templates/NOTES.txt | 12 ++ helm/litellm-helm/templates/httproute.yaml | 49 +++++ helm/litellm-helm/tests/httproute_tests.yaml | 182 +++++++++++++++++++ helm/litellm-helm/values.yaml | 39 ++++ 6 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 helm/litellm-helm/templates/httproute.yaml create mode 100644 helm/litellm-helm/tests/httproute_tests.yaml diff --git a/helm/litellm-helm/Chart.yaml b/helm/litellm-helm/Chart.yaml index a3cb388ffc6..7c1ac121286 100644 --- a/helm/litellm-helm/Chart.yaml +++ b/helm/litellm-helm/Chart.yaml @@ -18,7 +18,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.1.3 +version: 1.2.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md index bf4089404db..26fe6baf67f 100644 --- a/helm/litellm-helm/README.md +++ b/helm/litellm-helm/README.md @@ -43,6 +43,11 @@ If `db.useStackgresOperator` is used (not yet implemented): | `service.loadBalancerClass` | Optional LoadBalancer implementation class (only used when `service.type` is `LoadBalancer`) | `""` | | `ingress.labels` | Additional labels for the Ingress resource | `{}` | | `ingress.*` | See [values.yaml](./values.yaml) for example settings | N/A | +| `httpRoute.enabled` | Create a Gateway API `HTTPRoute` instead of (or alongside) the Ingress. Needs the Gateway API CRDs in the cluster. | `false` | +| `httpRoute.parentRefs` | Gateways the route attaches to. Required when `httpRoute.enabled` is `true`. | `[]` | +| `httpRoute.hostnames` | Hostnames the route answers on. Empty inherits every hostname of the parent listener. | `[]` | +| `httpRoute.rules` | Routing rules. Every rule is backed by this chart's Service; `matches`, `filters`, and `timeouts` are passed through as written. | One `PathPrefix` `/` rule | +| `httpRoute.*` | See [values.yaml](./values.yaml) for example settings | N/A | | `proxyConfigMap.create` | When `true`, render a ConfigMap from `.Values.proxy_config` and mount it. | `true` | | `proxyConfigMap.name` | When `create=false`, name of the existing ConfigMap to mount. | `""` | | `proxyConfigMap.key` | Key in the ConfigMap that contains the proxy config file. | `"config.yaml"` | diff --git a/helm/litellm-helm/templates/NOTES.txt b/helm/litellm-helm/templates/NOTES.txt index 017bbfa78bd..3010a642e75 100644 --- a/helm/litellm-helm/templates/NOTES.txt +++ b/helm/litellm-helm/templates/NOTES.txt @@ -5,6 +5,18 @@ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} {{- end }} {{- end }} +{{- end }} +{{- if .Values.httpRoute.enabled }} +{{- if .Values.httpRoute.hostnames }} +{{- range .Values.httpRoute.hostnames }} + Served by the parent Gateway on: {{ . }} +{{- end }} +{{- else }} + The HTTPRoute inherits the hostnames of its parent Gateway listener: + kubectl get --namespace {{ .Release.Namespace }} httproute {{ include "litellm.fullname" . }} -o jsonpath="{.spec.parentRefs}" +{{- end }} +{{- end }} +{{- if or .Values.ingress.enabled .Values.httpRoute.enabled }} {{- else if contains "NodePort" .Values.service.type }} export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "litellm.fullname" . }}) export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") diff --git a/helm/litellm-helm/templates/httproute.yaml b/helm/litellm-helm/templates/httproute.yaml new file mode 100644 index 00000000000..9b83369f6cb --- /dev/null +++ b/helm/litellm-helm/templates/httproute.yaml @@ -0,0 +1,49 @@ +{{- if .Values.httpRoute.enabled -}} +{{- $fullName := include "litellm.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if not .Values.httpRoute.parentRefs }} +{{- fail "httpRoute.parentRefs must name at least one Gateway when httpRoute.enabled is true. An HTTPRoute with no parent is never attached to a listener, so it accepts no traffic." }} +{{- end }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "litellm.labels" . | nindent 4 }} + {{- with .Values.httpRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- toYaml .Values.httpRoute.parentRefs | nindent 4 }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + - backendRefs: + - group: '' + kind: Service + name: {{ $fullName }} + port: {{ $svcPort }} + weight: 1 + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .matches }} + matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .timeouts }} + timeouts: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/litellm-helm/tests/httproute_tests.yaml b/helm/litellm-helm/tests/httproute_tests.yaml new file mode 100644 index 00000000000..8dc171b65cf --- /dev/null +++ b/helm/litellm-helm/tests/httproute_tests.yaml @@ -0,0 +1,182 @@ +suite: HTTPRoute Configuration Tests +templates: + - httproute.yaml +tests: + - it: should not create an HTTPRoute by default + asserts: + - hasDocuments: + count: 0 + + - it: should fail when enabled without parentRefs + set: + httpRoute.enabled: true + asserts: + - failedTemplate: + errorMessage: httpRoute.parentRefs must name at least one Gateway when httpRoute.enabled is true. An HTTPRoute with no parent is never attached to a listener, so it accepts no traffic. + + - it: should create a v1 HTTPRoute routing / to the chart Service + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + asserts: + - hasDocuments: + count: 1 + - isKind: + of: HTTPRoute + - isAPIVersion: + of: gateway.networking.k8s.io/v1 + - equal: + path: metadata.name + value: RELEASE-NAME-litellm + - equal: + path: spec.parentRefs[0].name + value: external + - equal: + path: spec.rules[0].matches[0].path.type + value: PathPrefix + - equal: + path: spec.rules[0].matches[0].path.value + value: / + - equal: + path: spec.rules[0].backendRefs[0].kind + value: Service + - equal: + path: spec.rules[0].backendRefs[0].name + value: RELEASE-NAME-litellm + - equal: + path: spec.rules[0].backendRefs[0].port + value: 4000 + - notExists: + path: spec.hostnames + + - it: should point the backendRef at the overridden service port + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + service.port: 8080 + asserts: + - equal: + path: spec.rules[0].backendRefs[0].port + value: 8080 + + - it: should name the route and its backend after fullnameOverride + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + fullnameOverride: litellm-proxy + asserts: + - equal: + path: metadata.name + value: litellm-proxy + - equal: + path: spec.rules[0].backendRefs[0].name + value: litellm-proxy + + - it: should keep the namespace and sectionName of every parentRef + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + namespace: gateway-system + sectionName: https + - name: internal + asserts: + - equal: + path: spec.parentRefs[0].namespace + value: gateway-system + - equal: + path: spec.parentRefs[0].sectionName + value: https + - equal: + path: spec.parentRefs[1].name + value: internal + - notExists: + path: spec.parentRefs[1].namespace + + - it: should render the configured hostnames + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + httpRoute.hostnames: + - api.example.local + - api2.example.local + asserts: + - equal: + path: spec.hostnames[0] + value: api.example.local + - equal: + path: spec.hostnames[1] + value: api2.example.local + + - it: should back every rule with the chart Service and keep matches, filters, and timeouts + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + httpRoute.rules: + - matches: + - path: + type: PathPrefix + value: /v1 + filters: + - type: RequestHeaderModifier + requestHeaderModifier: + set: + - name: X-Forwarded-Proto + value: https + - matches: + - path: + type: Exact + value: /health/readiness + timeouts: + request: 10s + backendRequest: 2s + asserts: + - lengthEqual: + path: spec.rules + count: 2 + - equal: + path: spec.rules[0].matches[0].path.value + value: /v1 + - equal: + path: spec.rules[0].filters[0].requestHeaderModifier.set[0].name + value: X-Forwarded-Proto + - equal: + path: spec.rules[0].backendRefs[0].name + value: RELEASE-NAME-litellm + - notExists: + path: spec.rules[0].timeouts + - equal: + path: spec.rules[1].matches[0].path.type + value: Exact + - equal: + path: spec.rules[1].timeouts.request + value: 10s + - equal: + path: spec.rules[1].backendRefs[0].port + value: 4000 + + - it: should carry the chart labels plus any custom labels and annotations + set: + httpRoute.enabled: true + httpRoute.parentRefs: + - name: external + httpRoute.labels: + custom-label: "true" + httpRoute.annotations: + custom-annotation: "value" + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/name"] + value: litellm + - equal: + path: metadata.labels["custom-label"] + value: "true" + - equal: + path: metadata.annotations["custom-annotation"] + value: "value" + diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml index fcee331a5aa..d456e1c4314 100644 --- a/helm/litellm-helm/values.yaml +++ b/helm/litellm-helm/values.yaml @@ -132,6 +132,45 @@ ingress: # hosts: # - chart-example.local +# Gateway API route, as an alternative to ingress.* on clusters served by a +# Gateway rather than an ingress controller. Independent of ingress.enabled: +# both can be on during a migration. The Gateway itself is not created here; +# it is usually owned by the platform team and shared across namespaces, so +# this chart only attaches a route to it. Needs the Gateway API CRDs installed +# in the cluster. +httpRoute: + enabled: false + labels: {} + annotations: {} + # The Gateway(s) this route attaches to. Required when enabled: a route with + # no parent is never attached to a listener, so it accepts no traffic. Set + # `namespace` for a Gateway outside the release namespace, whose listener + # must also allow routes from this namespace. + parentRefs: [] + # - name: example-gateway + # namespace: example-gateway-namespace + # sectionName: https + # Hostnames this route answers on. Each has to match a hostname the parent + # listener serves. Leave empty to inherit every hostname of the listener. + hostnames: [] + # - api.example.local + # Routing rules. Every rule is backed by this chart's Service on + # service.port; matches, filters, and timeouts are passed through as written. + rules: + - matches: + - path: + type: PathPrefix + value: / + # filters: + # - type: RequestHeaderModifier + # requestHeaderModifier: + # add: + # - name: X-Custom-Header + # value: custom-value + # timeouts: + # request: 10s + # backendRequest: 2s + # masterkey: changeit # if set, use this secret for the master key; otherwise, autogenerate a new one