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. #