From fe451d70aac2ce3ff7c7afefa9a383c62cf38d2c Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 3 Aug 2026 13:39:49 -0700 Subject: [PATCH] feat(helm): make extra ingress paths configurable in the componentized chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The componentized chart's Ingress renders a fixed path set: the UI paths, a hardcoded gateway prefix list mirroring gateway/routes/allowlist.py, and a `/` Prefix catch-all to the backend. values.yaml exposes no path knob, so an operator who needs a route the chart does not know about has to fork the template. That prefix list is a snapshot of the data plane at release time and drifts from it. `/watsonx` is in GATEWAY_PATH_PREFIXES with no ingress rule and no backend prefix, so `/watsonx/*` falls into the catch-all, reaches the backend, and 404s. A provider passthrough prefix added upstream after the chart version an operator runs has the same shape, and a custom general_settings.pass_through_endpoints route has a path only the operator knows, so the chart can never ship a rule for it at all. ingress.extraPaths takes a list of {path, service, pathType} entries rendered in addition to the built-in paths, never in place of them. They render after every built-in path and before the backend catch-all, and an entry repeating a path the chart already routes is rejected: duplicates within one rule are resolved by position or by controller-specific tie breaking, so an entry at `/` Prefix would swallow the whole backend management API rather than add to it. `service` picks the component Service — gateway by default, or backend or ui — so an operator does not have to reconstruct release-templated Service names, and `pathType` defaults to Prefix. A non-mapping entry, an entry with no path, an unknown service, an unknown pathType, and a duplicate each abort the render naming the offending index rather than emitting an Ingress that misroutes traffic. The duplicate check needs the built-in paths as data, so the UI paths move from six literal YAML blocks into a $uiPaths list rendered by one range, and their explanatory comments move from the rendered manifest into template comments. The Ingress object this produces is unchanged: the same 92 paths in the same order with the same backends, verified by parsing both renders. The target component still answers only the paths its own route allowlist keeps, so this makes a route routable, not servable. --- helm/litellm/templates/ingress.yaml | 148 +++++--- .../tests/ingress_extra_paths_tests.yaml | 317 ++++++++++++++++++ helm/litellm/values.yaml | 21 ++ 3 files changed, 432 insertions(+), 54 deletions(-) create mode 100644 helm/litellm/tests/ingress_extra_paths_tests.yaml diff --git a/helm/litellm/templates/ingress.yaml b/helm/litellm/templates/ingress.yaml index b7c78d3fdad..634f898337a 100644 --- a/helm/litellm/templates/ingress.yaml +++ b/helm/litellm/templates/ingress.yaml @@ -5,6 +5,41 @@ {{- $gatewayPort := .Values.gateway.service.port -}} {{- $backendPort := .Values.backend.service.port -}} {{- $uiPort := .Values.ui.service.port -}} +{{/* + Backends addressable from ingress.extraPaths, keyed by the `service` field. +*/}} +{{- $extraPathBackends := dict + "gateway" (dict "name" $gatewayName "port" $gatewayPort) + "backend" (dict "name" $backendName "port" $backendPort) + "ui" (dict "name" $uiName "port" $uiPort) +-}} +{{/* + UI paths (Next.js static export). + + /ui/* is where the SPA serves its login + dashboard routes (e.g. /ui/login). + Without it, /ui/* falls into the catch-all → backend → 404. + + The App Router (output: "export", basePath: "") emits the RSC/flight payload + for every route as a ROOT-level .txt (/index.txt, /teams.txt, + /__next._tree.txt, ...). The client router fetches these on every soft + navigation / prefetch as .txt?_rsc= (the query string is + irrelevant to path matching). They are not under /ui, /_next, or + /litellm-asset-prefix, so without /*.txt they fall to the backend catch-all + → 404 → client-side navigation never settles and the login flow spins in an + infinite redirect loop (/ ⇄ /ui/login). ui/nginx.conf already serves *.txt + from the export; the rule only routes the request to it. Needs an ingress + controller whose ImplementationSpecific path is a wildcard pattern + (AWS ALB: `*` = 0+ chars); this chart targets the AWS Load Balancer + Controller. +*/}} +{{- $uiPaths := list + (dict "path" "/" "pathType" "Exact") + (dict "path" "/favicon.ico" "pathType" "Exact") + (dict "path" "/litellm-asset-prefix" "pathType" "Prefix") + (dict "path" "/_next" "pathType" "Prefix") + (dict "path" "/ui" "pathType" "Prefix") + (dict "path" "/*.txt" "pathType" "ImplementationSpecific") +-}} {{/* Gateway data-plane prefixes — must mirror gateway/routes/allowlist.py. Versioned paths are listed explicitly to avoid routing management routes @@ -39,6 +74,21 @@ routes at startup -> 404. So /test is rendered as a standalone Exact path and /test/* falls through to the backend catch-all. */}} +{{/* + Every "|" this template renders on its own. An + ingress.extraPaths entry that repeats one of these is rejected: duplicates + in a single rule are resolved by position or by controller-specific tie + breaking, so the operator entry could take over a built-in route (an entry + at "/" Prefix would swallow the whole backend management API) instead of + adding to it. +*/}} +{{- $builtinPathKeys := list "/test|Exact" "/|Prefix" -}} +{{- range $uiPaths }} +{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" .path .pathType) }} +{{- end }} +{{- range $gatewayPrefixes }} +{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|Prefix" .) }} +{{- end }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -64,65 +114,15 @@ spec: http: paths: # --- UI (Next.js static export) --- - - path: / - pathType: Exact - backend: - service: - name: {{ $uiName }} - port: - number: {{ $uiPort }} - - path: /favicon.ico - pathType: Exact - backend: - service: - name: {{ $uiName }} - port: - number: {{ $uiPort }} - - path: /litellm-asset-prefix - pathType: Prefix - backend: - service: - name: {{ $uiName }} - port: - number: {{ $uiPort }} - - path: /_next - pathType: Prefix - backend: - service: - name: {{ $uiName }} - port: - number: {{ $uiPort }} - # /ui/* is where the Next.js SPA serves its login + dashboard - # routes (e.g. /ui/login). Without this, /ui/* falls into the - # catch-all → backend → 404. - - path: /ui - pathType: Prefix - backend: - service: - name: {{ $uiName }} - port: - number: {{ $uiPort }} - # Next.js App Router (output: "export", basePath: "") emits the - # RSC/flight payload for every route as a ROOT-level .txt - # (/index.txt, /teams.txt, /__next._tree.txt, ...). The client - # router fetches these on every soft navigation / prefetch as - # .txt?_rsc= (the query string is irrelevant to path - # matching). They are not under /ui, /_next, or - # /litellm-asset-prefix, so without this rule they fall to the - # backend catch-all → 404 → client-side navigation never settles - # and the login flow spins in an infinite redirect loop - # (/ ⇄ /ui/login). ui/nginx.conf already serves *.txt from the - # export; this rule only routes the request to it. Needs an - # ingress controller whose ImplementationSpecific path is a - # wildcard pattern (AWS ALB: `*` = 0+ chars); this chart targets - # the AWS Load Balancer Controller. - - path: /*.txt - pathType: ImplementationSpecific + {{- range $uiPaths }} + - path: {{ .path }} + pathType: {{ .pathType }} backend: service: name: {{ $uiName }} port: number: {{ $uiPort }} + {{- end }} # --- Gateway data plane --- # Exact /test only (see the $gatewayPrefixes comment above); # /test/* MCP management endpoints fall to the backend catch-all. @@ -142,6 +142,46 @@ spec: port: number: {{ $gatewayPort }} {{- end }} + {{- /* + --- Operator-supplied extra paths (ingress.extraPaths) --- + Rendered after every built-in path so an entry can never take + precedence over a default, and before the backend catch-all. + Position only decides the match on controllers that honour manifest + order: the AWS Load Balancer Controller this chart targets sorts + Exact paths first and Prefix paths longest-first, but keeps + ImplementationSpecific paths in manifest order, which is what the + /*.txt rule above already depends on. + */}} + {{- range $idx, $extra := .Values.ingress.extraPaths }} + {{- if not (kindIs "map" $extra) }} + {{- fail (printf "ingress.extraPaths[%d]: each entry must be a mapping with a 'path' key" $idx) }} + {{- end }} + {{- if not $extra.path }} + {{- fail (printf "ingress.extraPaths[%d]: 'path' is required" $idx) }} + {{- end }} + {{- $service := $extra.service | default "gateway" }} + {{- $target := get $extraPathBackends $service }} + {{- if not $target }} + {{- fail (printf "ingress.extraPaths[%d] (path %s): unknown service %q, expected one of backend, gateway, ui" $idx $extra.path $service) }} + {{- end }} + {{- $pathType := $extra.pathType | default "Prefix" }} + {{- if not (has $pathType (list "Prefix" "Exact" "ImplementationSpecific")) }} + {{- fail (printf "ingress.extraPaths[%d] (path %s): unknown pathType %q, expected one of Exact, ImplementationSpecific, Prefix" $idx $extra.path $pathType) }} + {{- end }} + {{- if eq $extra.path "/" }} + {{- fail (printf "ingress.extraPaths[%d]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture" $idx) }} + {{- end }} + {{- if has (printf "%s|%s" $extra.path $pathType) $builtinPathKeys }} + {{- fail (printf "ingress.extraPaths[%d]: path %s with pathType %s is already routed by this chart, and a duplicate would take it over rather than add to it" $idx $extra.path $pathType) }} + {{- end }} + - path: {{ $extra.path | quote }} + pathType: {{ $pathType }} + backend: + service: + name: {{ $target.name }} + port: + number: {{ $target.port }} + {{- end }} # --- Catch-all → backend (management API: /key/*, /user/*, /team/*, ...) --- - path: / pathType: Prefix diff --git a/helm/litellm/tests/ingress_extra_paths_tests.yaml b/helm/litellm/tests/ingress_extra_paths_tests.yaml new file mode 100644 index 00000000000..fc7d5943278 --- /dev/null +++ b/helm/litellm/tests/ingress_extra_paths_tests.yaml @@ -0,0 +1,317 @@ +suite: test ingress.extraPaths +templates: + - ingress.yaml +values: + - ./values/required.yaml +tests: + - it: renders nothing extra between the built-in gateway prefixes and the backend catch-all when unset + set: + ingress.enabled: true + asserts: + - equal: + path: spec.rules[0].http.paths[-1] + value: + path: / + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-backend + port: + number: 4001 + - equal: + path: spec.rules[0].http.paths[-2] + value: + path: /metrics + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + + - it: routes an extra path to the gateway by default, immediately before the backend catch-all + set: + ingress.enabled: true + ingress.extraPaths: + - path: /watsonx + asserts: + - equal: + path: spec.rules[0].http.paths[-2] + value: + path: /watsonx + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - equal: + path: spec.rules[0].http.paths[-1] + value: + path: / + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-backend + port: + number: 4001 + + - it: keeps every built-in path when extra paths are supplied + set: + ingress.enabled: true + ingress.extraPaths: + - path: /watsonx + asserts: + - contains: + path: spec.rules[0].http.paths + content: + path: / + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + - contains: + path: spec.rules[0].http.paths + content: + path: /ui + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + - contains: + path: spec.rules[0].http.paths + content: + path: /test + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - contains: + path: spec.rules[0].http.paths + content: + path: /v1/chat + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - contains: + path: spec.rules[0].http.paths + content: + path: /vertex_ai + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + + - it: renders every entry in order and honours the service and pathType selectors + set: + ingress.enabled: true + ingress.extraPaths: + - path: /watsonx + service: gateway + - path: /my-passthrough + pathType: Exact + service: backend + - path: /brand.txt + pathType: ImplementationSpecific + service: ui + asserts: + - equal: + path: spec.rules[0].http.paths[-4] + value: + path: /watsonx + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - equal: + path: spec.rules[0].http.paths[-3] + value: + path: /my-passthrough + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-backend + port: + number: 4001 + - equal: + path: spec.rules[0].http.paths[-2] + value: + path: /brand.txt + pathType: ImplementationSpecific + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + + - it: addresses the component services by their configured ports + set: + ingress.enabled: true + gateway.service.port: 8000 + backend.service.port: 8001 + ui.service.port: 8080 + ingress.extraPaths: + - path: /watsonx + - path: /my-passthrough + service: backend + - path: /brand.txt + service: ui + asserts: + - equal: + path: spec.rules[0].http.paths[-4].backend.service.port.number + value: 8000 + - equal: + path: spec.rules[0].http.paths[-3].backend.service.port.number + value: 8001 + - equal: + path: spec.rules[0].http.paths[-2].backend.service.port.number + value: 8080 + + - it: rejects an entry naming a service the chart does not deploy + set: + ingress.enabled: true + ingress.extraPaths: + - path: /watsonx + service: proxy + asserts: + - failedTemplate: + errorMessage: 'ingress.extraPaths[0] (path /watsonx): unknown service "proxy", expected one of backend, gateway, ui' + + - it: rejects an entry whose pathType is not a kubernetes pathType + set: + ingress.enabled: true + ingress.extraPaths: + - path: /watsonx + pathType: prefix + asserts: + - failedTemplate: + errorMessage: 'ingress.extraPaths[0] (path /watsonx): unknown pathType "prefix", expected one of Exact, ImplementationSpecific, Prefix' + + - it: rejects an entry with no path + set: + ingress.enabled: true + ingress.extraPaths: + - service: gateway + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: 'path' is required" + + + - it: rejects a root entry that would take over the backend catch-all + set: + ingress.enabled: true + ingress.extraPaths: + - path: / + service: gateway + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture" + + - it: rejects a root entry that would take over the UI root + set: + ingress.enabled: true + ingress.extraPaths: + - path: / + pathType: Exact + service: gateway + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture" + + # A root ImplementationSpecific entry duplicates no built-in pair, so the + # duplicate check alone would admit it. It is still dead: the built-in + # Exact / sorts ahead of it on the AWS Load Balancer Controller and claims + # the only request its pattern matches, so it renders and never routes. + - it: rejects a root entry that would render but never match + set: + ingress.enabled: true + ingress.extraPaths: + - path: / + pathType: ImplementationSpecific + service: gateway + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture" + + - it: rejects an entry that would take over a UI prefix + set: + ingress.enabled: true + ingress.extraPaths: + - path: /ui + service: gateway + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path /ui with pathType Prefix is already routed by this chart, and a duplicate would take it over rather than add to it" + + - it: rejects an entry that would take over the UI RSC payload rule + set: + ingress.enabled: true + ingress.extraPaths: + - path: /*.txt + pathType: ImplementationSpecific + service: backend + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path /*.txt with pathType ImplementationSpecific is already routed by this chart, and a duplicate would take it over rather than add to it" + + - it: rejects an entry that would take over a gateway data-plane prefix + set: + ingress.enabled: true + ingress.extraPaths: + - path: /v1/chat + service: backend + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path /v1/chat with pathType Prefix is already routed by this chart, and a duplicate would take it over rather than add to it" + + - it: rejects an entry that would take over the exact /test route + set: + ingress.enabled: true + ingress.extraPaths: + - path: /test + pathType: Exact + service: backend + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path /test with pathType Exact is already routed by this chart, and a duplicate would take it over rather than add to it" + + - it: allows a built-in path under a different pathType, which is a distinct rule + set: + ingress.enabled: true + ingress.extraPaths: + - path: /ui + pathType: Exact + service: ui + asserts: + - equal: + path: spec.rules[0].http.paths[-2] + value: + path: /ui + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + + - it: rejects a bare string entry instead of failing on template internals + set: + ingress.enabled: true + ingress.extraPaths: + - /watsonx + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: each entry must be a mapping with a 'path' key" diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index cd377667602..4cf13f4c0dd 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -13,6 +13,27 @@ ingress: annotations: {} host: "" # optional; if set, becomes the rule's host tls: [] + # Extra HTTP paths appended to the ingress rule. Additive: every built-in + # UI / gateway / backend path is still rendered, these entries are placed + # after them and before the backend catch-all, and an entry that repeats a + # path the chart already routes is rejected at render time rather than + # silently taking it over. + # + # The chart's built-in gateway prefix list is a snapshot of the data-plane + # surface at release time. Use extraPaths for passthrough routes it does not + # cover: a provider prefix added upstream after this chart version, or a + # custom general_settings.pass_through_endpoints route. + # + # path required; the HTTP path to route + # service which component serves it: gateway (default), backend, or ui + # pathType Prefix (default), Exact, or ImplementationSpecific + # + # The target component only answers paths its own route allowlist keeps, so + # a path here still has to be one that component serves. + extraPaths: [] + # - path: /watsonx + # pathType: Prefix + # service: gateway # Per-component ServiceAccounts for gateway, backend, and ui. #