From e277d612e7b5caa6474b6c9434afc74eb52d6b06 Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 01:34:47 -0400 Subject: [PATCH 1/9] feat(self-managed): secure LLM gRPC ingress Refs #1294 and #689. Signed-off-by: Mike Camp --- deploy/helm/gateway-routes/Makefile | 2 +- deploy/helm/gateway-routes/README.md | 38 +++-- .../chart/templates/_helpers.tpl | 30 ++++ .../backendtrafficpolicy-llm-worker-grpc.yaml | 23 +++ .../certificate-llm-worker-grpc.yaml | 29 ++++ .../templates/referencegrant-llm-worker.yaml | 2 +- .../chart/templates/tcproute-llm-worker.yaml | 6 +- deploy/helm/gateway-routes/chart/values.yaml | 21 +++ .../scripts/check-llm-worker-routes.sh | 136 +++++++++++++++++- .../scripts/test-render-routes.sh | 1 + deploy/helm/llm-request-router/README.md | 15 +- .../templates/backend-router.yaml | 1 + .../self-managed/environments/base.yaml | 21 +++ deploy/stacks/self-managed/global.yaml.gotmpl | 51 ++++++- .../tests/llm-router-split-cluster.sh | 92 +++++++++++- .../tests/llm-router-worker-address.sh | 19 ++- docs/user/gateway-routing.md | 111 ++++++++++---- docs/user/llm-function-enablement.md | 53 +++++-- .../features/multi-cluster-helmfile.feature | 70 ++++++++- .../single-cluster-helmfile-llm-pki.feature | 18 +++ .../self-managed-local-bdd-multi.yaml | 10 +- tests/bdd/fixtures_test.go | 5 +- tests/bdd/godog_test.go | 54 ++++++- .../apps/envoy-gateway/README.md | 5 +- .../apps/envoy-gateway/gateway-grpc.yaml | 8 +- .../tests/test-multicluster-make.sh | 8 +- 26 files changed, 741 insertions(+), 88 deletions(-) create mode 100644 deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml create mode 100644 deploy/helm/gateway-routes/chart/templates/certificate-llm-worker-grpc.yaml diff --git a/deploy/helm/gateway-routes/Makefile b/deploy/helm/gateway-routes/Makefile index ce1d41401..b94bedc34 100644 --- a/deploy/helm/gateway-routes/Makefile +++ b/deploy/helm/gateway-routes/Makefile @@ -25,4 +25,4 @@ test-vanity-gateway-route: @sh ./scripts/test-vanity-gateway-route.sh check-llm-worker-routes: - @sh ./scripts/check-llm-worker-routes.sh + @bash ./scripts/check-llm-worker-routes.sh diff --git a/deploy/helm/gateway-routes/README.md b/deploy/helm/gateway-routes/README.md index 41d6189c2..d877e80bd 100644 --- a/deploy/helm/gateway-routes/README.md +++ b/deploy/helm/gateway-routes/README.md @@ -7,11 +7,13 @@ This repository contains the Helm chart for deploying NVCF ingress routes via th The chart deploys `HTTPRoute`, `GRPCRoute`, `TCPRoute`, `UDPRoute`, and `ReferenceGrant` resources that attach to an existing Gateway provisioned separately by the cluster operator, such as Envoy Gateway, Istio, Traefik, or -Kong. It also includes optional `PodMonitor` resources for scraping Envoy +Kong. Secure LLM worker routing also renders an optional cert-manager +`Certificate` and an Envoy Gateway `BackendTrafficPolicy` for long-lived gRPC +streams. The chart includes optional `PodMonitor` resources for scraping Envoy Gateway proxy metrics with Prometheus. -The chart deploys routing configuration only. It does not include any -container images. Backend services referenced by the routes (`api`, +The chart does not include any container images or create Gateways. Backend +services referenced by the routes (`api`, `nvct-api`, `api-keys`, `invocation`, `llm-api-gateway`, `llm-request-router-backend-router`, `vanity-gateway`, `reval`, `sis`, `grpc`, `nats`) must already be deployed separately. @@ -23,7 +25,11 @@ container images. Backend services referenced by the routes (`api`, - `kubectl` - A Gateway API compatible controller installed in the cluster - Existing `Gateway` resources with the listeners required by each enabled route -- A Gateway controller with `UDPRoute` support when LLM worker routing is enabled +- A Gateway controller with `GRPCRoute` and `UDPRoute` support when secure LLM + worker routing is enabled +- cert-manager when `llmRequestRouter.grpcTls.mode=certManager` +- Envoy Gateway's `BackendTrafficPolicy` CRD when secure LLM worker routing is + enabled - The backend services that the routes target, deployed in their respective namespaces ## Getting Started @@ -66,8 +72,10 @@ Important settings to review before deployment: - `nvcfGatewayRoutes.gateways.shared.*` for the HTTP Gateway name, namespace, and listener - `nvcfGatewayRoutes.gateways.grpc.*` for the TCP Gateway name, namespace, and listener - `nvcfGatewayRoutes.gateways.nats.*` for the NATS TCP Gateway name, namespace, and listener -- `nvcfGatewayRoutes.gateways.llmGrpc.*` for the LLM worker gRPC TCP listener +- `nvcfGatewayRoutes.gateways.llmGrpc.*` for the LLM worker gRPC HTTPS listener - `nvcfGatewayRoutes.gateways.llmQuic.*` for the LLM reverse-tunnel UDP listener +- `llmRequestRouter.grpcTls.*` for the dedicated gRPC listener identity, + explicit plaintext opt-in, and certificate ownership mode - `nvcfGatewayRoutes.routes..enabled` to toggle individual routes - `nvcfGatewayRoutes.routes.nvcfApi.grpc.enabled` and `nvcfGatewayRoutes.routes.nvctApi.grpc.enabled` to expose API gRPC routes @@ -98,13 +106,14 @@ Enabled `HTTPRoute` entries must not share a resolved hostname because each `HTT | `grpc` | TCPRoute | Not rendered | `grpc.nvcf:10081` | | `grpcWorker` | TCPRoute (disabled by default) | Not rendered | `grpc.nvcf:10086` | | `nats` | TCPRoute (disabled by default) | Not rendered | `nats.nats-system:4222` | -| `llmWorker` | TCPRoute and UDPRoute (disabled by default) | Not rendered | `llm-request-router-backend-router.:50071/TCP,50072/UDP` | +| `llmWorker` | GRPCRoute (secure) or TCPRoute (explicit development), plus UDPRoute; disabled by default | Not rendered | `llm-request-router-backend-router.:50071/h2c,50072/UDP` | Cross-namespace routing is supported via `ReferenceGrant` resources rendered into each backend namespace. ## Notes -- The chart assumes the Gateway is reachable at the resolved hostnames. DNS records and TLS termination are out of scope and must be configured in the surrounding infrastructure. +- The chart assumes the Gateway is reachable at the resolved hostnames. DNS + records and Gateway creation remain infrastructure responsibilities. - The `nats` TCPRoute is plain TCP and does not render hostnames. Configure DNS or TCP load balancer routing outside this chart. - The `grpc` TCPRoute does not enforce HTTP hostname matching at the Gateway layer. Configure DNS or TCP load balancer routing outside this chart. - The `grpcWorker` TCPRoute is beta support for split or multi-cluster gRPC worker callbacks. It carries HTTP/1 CONNECT callback traffic only. Enable it only when the control-plane grpc-proxy runs one replica with HPA disabled. Multi-replica grpc-proxy requires pod-specific callback routing and is not supported by this shared TCPRoute. @@ -116,5 +125,18 @@ Cross-namespace routing is supported via `ReferenceGrant` resources rendered int Use `nvcfGatewayRoutes.routes.llmWorker.backend.grpcPort` for registration traffic and `nvcfGatewayRoutes.routes.llmWorker.backend.quicPort` for reverse tunnels; this route does not use the generic `backend.port` setting. - Keep the TCP and UDP Gateways separate when the infrastructure requires + Secure mode renders a `GRPCRoute` with no `hostnames` and requires a + dedicated HTTPS listener with no `hostname`. The listener serves the + configured certificate for normal external SNI verification, while the + backend router receives the advertised Stargate identity as HTTP/2 + `:authority`. Setting the listener hostname to the public dial name would + incorrectly require that same value in `:authority`. + `grpcTls.mode=certManager` creates the named Secret through a dedicated + `Certificate` in the gRPC Gateway namespace. `mode=existingSecret` expects + the operator to create that Secret. The HTTPS listener must reference the + same Secret. The Envoy `BackendTrafficPolicy` sets both the request timeout + and maximum stream duration to `0s` for Watch and Register streams. + Plaintext is intended only for development and requires + `grpcTls.allowInsecureHttp=true`; it renders the legacy `TCPRoute`. + Keep the HTTPS and UDP Gateways separate when the infrastructure requires separate load balancers for each protocol. diff --git a/deploy/helm/gateway-routes/chart/templates/_helpers.tpl b/deploy/helm/gateway-routes/chart/templates/_helpers.tpl index 124712752..1e9f357d5 100644 --- a/deploy/helm/gateway-routes/chart/templates/_helpers.tpl +++ b/deploy/helm/gateway-routes/chart/templates/_helpers.tpl @@ -57,6 +57,36 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- required "nvcfGatewayRoutes.routes.llmWorker.backend.namespace is required when llmWorker.enabled is true" .Values.nvcfGatewayRoutes.routes.llmWorker.backend.namespace -}} {{- end }} +{{/* Validate worker-facing gRPC TLS identity ownership and plaintext policy. */}} +{{- define "nvcf-gateway.validateLLMWorkerGrpcTls" -}} +{{- $routeEnabled := .Values.nvcfGatewayRoutes.routes.llmWorker.enabled -}} +{{- $grpcTls := .Values.llmRequestRouter.grpcTls | default dict -}} +{{- $tlsEnabled := dig "enabled" false $grpcTls -}} +{{- $allowInsecure := dig "allowInsecureHttp" false $grpcTls -}} +{{- $mode := dig "mode" "certManager" $grpcTls | toString -}} +{{- if and $tlsEnabled $allowInsecure -}} +{{- fail "llmRequestRouter.grpcTls.enabled and llmRequestRouter.grpcTls.allowInsecureHttp cannot both be true" -}} +{{- end -}} +{{- if and $routeEnabled (not $tlsEnabled) (not $allowInsecure) -}} +{{- fail "llmRequestRouter.grpcTls.allowInsecureHttp must be true when LLM worker routing is plaintext" -}} +{{- end -}} +{{- if $tlsEnabled -}} +{{- if not $routeEnabled -}} +{{- fail "nvcfGatewayRoutes.routes.llmWorker.enabled must be true when llmRequestRouter.grpcTls.enabled is true" -}} +{{- end -}} +{{- if not (has $mode (list "certManager" "existingSecret")) -}} +{{- fail (printf "llmRequestRouter.grpcTls.mode must be certManager or existingSecret, got %q" $mode) -}} +{{- end -}} +{{- required "llmRequestRouter.grpcTls.secretName is required when grpcTls.enabled is true" (dig "secretName" "" $grpcTls) -}} +{{- if eq $mode "certManager" -}} +{{- if empty (dig "dnsNames" (list) $grpcTls) -}} +{{- fail "llmRequestRouter.grpcTls.dnsNames is required when grpcTls.mode is certManager" -}} +{{- end -}} +{{- required "llmRequestRouter.grpcTls.issuerRef.name is required when grpcTls.mode is certManager" (dig "issuerRef" "name" "" $grpcTls) -}} +{{- end -}} +{{- end -}} +{{- end }} + {{/* Validate that enabled HTTPRoutes do not compete for the same hostname and root PathPrefix match on the shared Gateway. All HTTPRoute templates in this diff --git a/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml b/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml new file mode 100644 index 000000000..62a93d279 --- /dev/null +++ b/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- if and .Values.nvcfGatewayRoutes.enabled .Values.nvcfGatewayRoutes.routes.llmWorker.enabled .Values.llmRequestRouter.grpcTls.enabled }} +{{- include "nvcf-gateway.validateLLMWorkerGrpcTls" . }} +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: {{ .Values.nvcfGatewayRoutes.routes.llmWorker.name }}-grpc-streams + namespace: {{ .Values.nvcfGatewayRoutes.gateways.llmGrpc.namespace }} + labels: + {{- include "nvcf-gateway.labels" . | nindent 4 }} + app.kubernetes.io/component: llm-worker-grpc-stream-policy +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: GRPCRoute + name: {{ .Values.nvcfGatewayRoutes.routes.llmWorker.name }}-grpc + timeout: + http: + requestTimeout: 0s + maxStreamDuration: 0s +{{- end }} diff --git a/deploy/helm/gateway-routes/chart/templates/certificate-llm-worker-grpc.yaml b/deploy/helm/gateway-routes/chart/templates/certificate-llm-worker-grpc.yaml new file mode 100644 index 000000000..8fb20f273 --- /dev/null +++ b/deploy/helm/gateway-routes/chart/templates/certificate-llm-worker-grpc.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- $grpcTls := .Values.llmRequestRouter.grpcTls | default dict -}} +{{- if and .Values.nvcfGatewayRoutes.enabled (dig "enabled" false $grpcTls) }} +{{- include "nvcf-gateway.validateLLMWorkerGrpcTls" . }} +{{- if eq (dig "mode" "certManager" $grpcTls) "certManager" }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ dig "secretName" "" $grpcTls | quote }} + namespace: {{ .Values.nvcfGatewayRoutes.gateways.llmGrpc.namespace }} + labels: + {{- include "nvcf-gateway.labels" . | nindent 4 }} + app.kubernetes.io/component: llm-worker-grpc-certificate +spec: + secretName: {{ dig "secretName" "" $grpcTls | quote }} + dnsNames: + {{- dig "dnsNames" (list) $grpcTls | toYaml | nindent 4 }} + issuerRef: + kind: {{ dig "issuerRef" "kind" "ClusterIssuer" $grpcTls | quote }} + name: {{ dig "issuerRef" "name" "" $grpcTls | quote }} + {{- with dig "issuerRef" "group" "" $grpcTls }} + group: {{ . | quote }} + {{- end }} + usages: + - server auth +{{- end }} +{{- end }} diff --git a/deploy/helm/gateway-routes/chart/templates/referencegrant-llm-worker.yaml b/deploy/helm/gateway-routes/chart/templates/referencegrant-llm-worker.yaml index fe7f592ad..b7119dd16 100644 --- a/deploy/helm/gateway-routes/chart/templates/referencegrant-llm-worker.yaml +++ b/deploy/helm/gateway-routes/chart/templates/referencegrant-llm-worker.yaml @@ -13,7 +13,7 @@ metadata: spec: from: - group: gateway.networking.k8s.io - kind: TCPRoute + kind: {{ if .Values.llmRequestRouter.grpcTls.enabled }}GRPCRoute{{ else }}TCPRoute{{ end }} namespace: {{ .Values.nvcfGatewayRoutes.gateways.llmGrpc.namespace }} - group: gateway.networking.k8s.io kind: UDPRoute diff --git a/deploy/helm/gateway-routes/chart/templates/tcproute-llm-worker.yaml b/deploy/helm/gateway-routes/chart/templates/tcproute-llm-worker.yaml index 892ec696e..5a7070b78 100644 --- a/deploy/helm/gateway-routes/chart/templates/tcproute-llm-worker.yaml +++ b/deploy/helm/gateway-routes/chart/templates/tcproute-llm-worker.yaml @@ -2,9 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 {{- if and .Values.nvcfGatewayRoutes.enabled .Values.nvcfGatewayRoutes.routes.llmWorker.enabled }} +{{- include "nvcf-gateway.validateLLMWorkerGrpcTls" . }} {{- $backendNamespace := include "nvcf-gateway.llmWorkerBackendNamespace" . }} -apiVersion: gateway.networking.k8s.io/v1alpha2 -kind: TCPRoute +{{- $tlsEnabled := .Values.llmRequestRouter.grpcTls.enabled }} +apiVersion: gateway.networking.k8s.io/{{ if $tlsEnabled }}v1{{ else }}v1alpha2{{ end }} +kind: {{ if $tlsEnabled }}GRPCRoute{{ else }}TCPRoute{{ end }} metadata: name: {{ .Values.nvcfGatewayRoutes.routes.llmWorker.name }}-grpc namespace: {{ .Values.nvcfGatewayRoutes.gateways.llmGrpc.namespace }} diff --git a/deploy/helm/gateway-routes/chart/values.yaml b/deploy/helm/gateway-routes/chart/values.yaml index 63c982fd1..bac8a8b2f 100644 --- a/deploy/helm/gateway-routes/chart/values.yaml +++ b/deploy/helm/gateway-routes/chart/values.yaml @@ -283,3 +283,24 @@ nvcfGatewayRoutes: # PodMonitors for Envoy Gateway proxy pods. podMonitors: enabled: false + +# Worker-facing gRPC TLS is configured with the request router because the +# certificate and advertised dial URI form one transport contract. The +# Gateway itself remains cluster-operator owned; its dedicated HTTPS listener +# must reference this Secret without constraining the HTTP/2 authority. +llmRequestRouter: + grpcTls: + enabled: false + # Plaintext is retained only for an explicitly selected development path. + allowInsecureHttp: false + # certManager renders a Certificate. existingSecret uses an operator-owned + # Secret and renders no issuance resource. + mode: certManager + secretName: "" + # Required only in certManager mode. In existingSecret mode, the operator + # owns and verifies the certificate SANs. + dnsNames: [] + issuerRef: + kind: ClusterIssuer + name: "" + group: "" diff --git a/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh b/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh index 229bf124f..cadd1c9b5 100755 --- a/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh +++ b/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh @@ -8,8 +8,14 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" chart_dir="${script_dir}/../chart" rendered="$(mktemp)" disabled="$(mktemp)" +plaintext="$(mktemp)" +existing_secret="$(mktemp)" invalid_backend_namespace_error="$(mktemp)" -trap 'rm -f "$rendered" "$disabled" "$invalid_backend_namespace_error"' EXIT +invalid_plaintext_error="$(mktemp)" +invalid_tls_error="$(mktemp)" +invalid_dns_error="$(mktemp)" +invalid_tls_without_route_error="$(mktemp)" +trap 'rm -f "$rendered" "$disabled" "$plaintext" "$existing_secret" "$invalid_backend_namespace_error" "$invalid_plaintext_error" "$invalid_tls_error" "$invalid_dns_error" "$invalid_tls_without_route_error"' EXIT helm template nvcf-gateway-routes "$chart_dir" \ --namespace gateway \ @@ -19,6 +25,11 @@ helm template nvcf-gateway-routes "$chart_dir" \ --set nvcfGatewayRoutes.gateways.llmQuic.name=llm-quic-gateway \ --set nvcfGatewayRoutes.gateways.llmQuic.namespace=gateway \ --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=router-system \ + --set llmRequestRouter.grpcTls.enabled=true \ + --set llmRequestRouter.grpcTls.mode=certManager \ + --set llmRequestRouter.grpcTls.secretName=llm-request-router-grpc-tls \ + --set llmRequestRouter.grpcTls.dnsNames[0]=llm-grpc.example.invalid \ + --set llmRequestRouter.grpcTls.issuerRef.name=nvcf-openbao-pki \ >"$rendered" assert_contains() { @@ -30,16 +41,28 @@ assert_contains() { fi } -assert_contains "kind: TCPRoute" \ - "LLM worker routing must expose gRPC registration over TCP" +assert_contains "kind: GRPCRoute" \ + "secure LLM worker routing must expose gRPC registration through a GRPCRoute" assert_contains "kind: UDPRoute" \ "LLM worker routing must expose reverse tunnels over UDP" +assert_contains "kind: BackendTrafficPolicy" \ + "secure LLM worker routing must disable Envoy timeouts for streaming RPCs" +assert_contains "requestTimeout: 0s" \ + "secure LLM worker routing must disable the request timeout" +assert_contains "maxStreamDuration: 0s" \ + "secure LLM worker routing must disable the maximum stream duration" +assert_contains "kind: Certificate" \ + "cert-manager mode must issue the dedicated gRPC listener certificate" +assert_contains 'secretName: "llm-request-router-grpc-tls"' \ + "the gRPC Certificate must write the configured listener Secret" +assert_contains "llm-grpc.example.invalid" \ + "the gRPC Certificate must contain the external dial hostname" assert_contains "name: llm-request-router-backend-router" \ "LLM worker routes must target the authority/SNI-aware backend router" assert_contains "name: allow-llm-worker-routes" \ "ReferenceGrant must permit cross-namespace LLM worker routes" assert_contains "sectionName: llm-grpc" \ - "TCPRoute must attach to the configured LLM gRPC listener" + "GRPCRoute must attach to the configured LLM gRPC listener" assert_contains "sectionName: llm-quic" \ "UDPRoute must attach to the configured LLM QUIC listener" @@ -60,6 +83,111 @@ if [[ "$backend_namespace_references" != "3" ]]; then exit 1 fi +if awk ' + $0 == "kind: GRPCRoute" { in_route = 1; next } + in_route && /^---$/ { in_route = 0 } + in_route && $1 == "hostnames:" { found = 1 } + END { exit !found } +' "$rendered"; then + echo "FAIL: the secure GRPCRoute must not match the external TLS hostname because Pylon preserves the internal Stargate authority" >&2 + exit 1 +fi + +helm template nvcf-gateway-routes "$chart_dir" \ + --namespace gateway \ + --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ + --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=router-system \ + --set llmRequestRouter.grpcTls.allowInsecureHttp=true \ + >"$plaintext" + +if ! grep -Fq -- "kind: TCPRoute" "$plaintext"; then + echo "FAIL: explicit development plaintext mode must retain the TCPRoute" >&2 + exit 1 +fi +if grep -Eq '^kind: (GRPCRoute|BackendTrafficPolicy|Certificate)$' "$plaintext"; then + echo "FAIL: development plaintext mode must not render secure gRPC resources" >&2 + exit 1 +fi + +helm template nvcf-gateway-routes "$chart_dir" \ + --namespace gateway \ + --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ + --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=router-system \ + --set llmRequestRouter.grpcTls.enabled=true \ + --set llmRequestRouter.grpcTls.mode=existingSecret \ + --set llmRequestRouter.grpcTls.secretName=operator-owned-grpc-tls \ + >"$existing_secret" + +if ! grep -Fq -- "kind: GRPCRoute" "$existing_secret"; then + echo "FAIL: existing-secret mode must retain secure GRPCRoute routing" >&2 + exit 1 +fi +if grep -Fq -- "kind: Certificate" "$existing_secret"; then + echo "FAIL: existing-secret mode must not render a cert-manager Certificate" >&2 + exit 1 +fi + +if helm template nvcf-gateway-routes "$chart_dir" \ + --namespace gateway \ + --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ + --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=router-system \ + >/dev/null 2>"$invalid_plaintext_error"; then + echo "FAIL: enabled plaintext LLM worker routing must require an explicit development opt-in" >&2 + exit 1 +fi +if ! grep -Fq -- "llmRequestRouter.grpcTls.allowInsecureHttp must be true when LLM worker routing is plaintext" "$invalid_plaintext_error"; then + echo "FAIL: implicit plaintext routing must return the expected validation error" >&2 + exit 1 +fi + +if helm template nvcf-gateway-routes "$chart_dir" \ + --namespace gateway \ + --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ + --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=router-system \ + --set llmRequestRouter.grpcTls.enabled=true \ + --set llmRequestRouter.grpcTls.mode=certManager \ + --set llmRequestRouter.grpcTls.secretName=llm-request-router-grpc-tls \ + --set llmRequestRouter.grpcTls.dnsNames[0]=llm-grpc.example.invalid \ + >/dev/null 2>"$invalid_tls_error"; then + echo "FAIL: cert-manager mode must require an issuer" >&2 + exit 1 +fi +if ! grep -Fq -- "llmRequestRouter.grpcTls.issuerRef.name is required when grpcTls.mode is certManager" "$invalid_tls_error"; then + echo "FAIL: incomplete cert-manager mode must return the expected validation error" >&2 + exit 1 +fi + +if helm template nvcf-gateway-routes "$chart_dir" \ + --namespace gateway \ + --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ + --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=router-system \ + --set llmRequestRouter.grpcTls.enabled=true \ + --set llmRequestRouter.grpcTls.mode=certManager \ + --set llmRequestRouter.grpcTls.secretName=llm-request-router-grpc-tls \ + --set llmRequestRouter.grpcTls.issuerRef.name=nvcf-openbao-pki \ + >/dev/null 2>"$invalid_dns_error"; then + echo "FAIL: cert-manager mode must require certificate DNS names" >&2 + exit 1 +fi +if ! grep -Fq -- "llmRequestRouter.grpcTls.dnsNames is required when grpcTls.mode is certManager" "$invalid_dns_error"; then + echo "FAIL: incomplete cert-manager DNS configuration must return the expected validation error" >&2 + exit 1 +fi + +if helm template nvcf-gateway-routes "$chart_dir" \ + --namespace gateway \ + --set llmRequestRouter.grpcTls.enabled=true \ + --set llmRequestRouter.grpcTls.mode=existingSecret \ + --set llmRequestRouter.grpcTls.secretName=operator-owned-grpc-tls \ + >/dev/null 2>"$invalid_tls_without_route_error"; then + echo "FAIL: gRPC TLS must not be enabled without the LLM worker route" >&2 + exit 1 +fi +if ! grep -Fq -- "nvcfGatewayRoutes.routes.llmWorker.enabled must be true when llmRequestRouter.grpcTls.enabled is true" "$invalid_tls_without_route_error"; then + echo "FAIL: gRPC TLS without a route must return the expected validation error" >&2 + exit 1 +fi + if helm template nvcf-gateway-routes "$chart_dir" \ --namespace gateway \ --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ diff --git a/deploy/helm/gateway-routes/scripts/test-render-routes.sh b/deploy/helm/gateway-routes/scripts/test-render-routes.sh index 11dc6075c..6c146c08b 100755 --- a/deploy/helm/gateway-routes/scripts/test-render-routes.sh +++ b/deploy/helm/gateway-routes/scripts/test-render-routes.sh @@ -177,6 +177,7 @@ helm template nvcf-gateway-routes "$repo_root/chart" \ --set nvcfGatewayRoutes.routes.nats.enabled=true \ --set nvcfGatewayRoutes.routes.llmWorker.enabled=true \ --set nvcfGatewayRoutes.routes.llmWorker.backend.namespace=nvcf \ + --set llmRequestRouter.grpcTls.allowInsecureHttp=true \ --set nvcfGatewayRoutes.gateways.nats.name=nats-gateway \ --set nvcfGatewayRoutes.gateways.nats.namespace=gateway \ --set nvcfGatewayRoutes.gateways.nats.listenerName=nats \ diff --git a/deploy/helm/llm-request-router/README.md b/deploy/helm/llm-request-router/README.md index d7c90b0e3..eb760b5c1 100644 --- a/deploy/helm/llm-request-router/README.md +++ b/deploy/helm/llm-request-router/README.md @@ -158,10 +158,19 @@ namespaced Role to it when When `rbac.create=false`, grant `get`, `list`, and `watch` on `discovery.k8s.io/endpointslices` to that account outside this chart. -Route TCP port `50071` and UDP port `50072` to the +Route HTTPS gRPC traffic on port `50071` and UDP traffic on port `50072` to the `llm-request-router-backend-router` Service. The NVCF gateway-routes chart can -create the matching `TCPRoute`, `UDPRoute`, and `ReferenceGrant` resources. -The Gateway implementation must support Gateway API `UDPRoute`. +terminate TLS on a dedicated HTTPS listener and create the matching +`GRPCRoute`, `UDPRoute`, `ReferenceGrant`, gRPC `Certificate`, and stream +timeout policy. The gRPC route forwards h2c to the Service. The Gateway +implementation must support Gateway API `GRPCRoute` and `UDPRoute`. A legacy +plaintext `TCPRoute` is available only with an explicit development opt-in. + +Use the same explicit `https://host:port` URI for the API worker bootstrap and +`pylonGrpcDialAddress`. The external hostname is used for TLS SNI and server +verification. The selected Stargate identity remains the HTTP/2 `:authority`, +so the secure `GRPCRoute` intentionally has no hostname match. The gRPC +listener certificate is distinct from the request-router QUIC certificate. When QUIC verification is enabled, the mounted certificate must cover the advertised per-pod hostname produced by diff --git a/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml b/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml index 22879ddd5..69b70c81f 100644 --- a/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml +++ b/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml @@ -27,6 +27,7 @@ spec: port: {{ .Values.llmRequestRouter.backendRouter.service.grpcPort }} targetPort: grpc protocol: TCP + appProtocol: kubernetes.io/h2c - name: quic port: {{ .Values.llmRequestRouter.backendRouter.service.reverseTunnelPort }} targetPort: quic diff --git a/deploy/stacks/self-managed/environments/base.yaml b/deploy/stacks/self-managed/environments/base.yaml index 1b1789ba6..45ee1d9cd 100644 --- a/deploy/stacks/self-managed/environments/base.yaml +++ b/deploy/stacks/self-managed/environments/base.yaml @@ -346,6 +346,27 @@ addons: pylonGrpcDialAddress: "" pylonReverseTunnelDialAddress: "" + # TLS for the worker-facing gRPC registration and Watch endpoint on + # port 50071. This identity is consumed by the Gateway HTTPS listener; + # it is separate from the request-router QUIC identity on port 50072. + grpcTls: + enabled: false + # Required for a deliberately plaintext external development route. + # Production and PKI-enabled split-cluster profiles enable TLS. + allowInsecureHttp: false + # certManager requests a certificate in the Gateway namespace. + # existingSecret uses a pre-created Secret and renders no Certificate. + mode: certManager + secretName: llm-request-router-grpc-tls + # Required only in certManager mode. With existingSecret, the operator + # owns and verifies the certificate SANs. + dnsNames: [] + issuerRef: + # Defaults to the QUIC PKI issuer when omitted. + kind: "" + name: "" + group: "" + # QUIC TLS certificate for the request router (Stargate). Managed PKI is # active by default whenever addons.llm.enabled is true. Set enabled=false # only when an environment deliberately supplies another transport policy. diff --git a/deploy/stacks/self-managed/global.yaml.gotmpl b/deploy/stacks/self-managed/global.yaml.gotmpl index 410ace25b..c384d3abe 100644 --- a/deploy/stacks/self-managed/global.yaml.gotmpl +++ b/deploy/stacks/self-managed/global.yaml.gotmpl @@ -312,14 +312,20 @@ natsAuthCalloutService: {{- $llmRequestRouterGrpcPort := dig "addons" "llm" "requestRouter" "service" "grpcPort" 50071 .Values }} {{- $llmRequestRouterDefaultAddress := printf "llm-request-router.nvcf.svc.cluster.local:%v" $llmRequestRouterGrpcPort }} {{- $llmRequestRouterWorkerAddress := dig "llmRequestRouterAddress" "" $workerEndpoints | trim | default $llmRequestRouterDefaultAddress }} +{{- $llmRequestRouterWorkerAuthority := $llmRequestRouterWorkerAddress }} +{{- if hasPrefix "https://" $llmRequestRouterWorkerAuthority }} +{{- $llmRequestRouterWorkerAuthority = trimPrefix "https://" $llmRequestRouterWorkerAuthority }} +{{- else if hasPrefix "http://" $llmRequestRouterWorkerAuthority }} +{{- $llmRequestRouterWorkerAuthority = trimPrefix "http://" $llmRequestRouterWorkerAuthority }} +{{- end }} {{- $llmEnabled := dig "addons" "llm" "enabled" false .Values }} {{- if $llmEnabled }} -{{- $llmRequestRouterWorkerAddressError := "global.workerEndpoints.llmRequestRouterAddress must use DNS-or-IPv4:port or [IPv6]:port with port 1-65535" }} +{{- $llmRequestRouterWorkerAddressError := "global.workerEndpoints.llmRequestRouterAddress must use optional http:// or https:// followed by DNS-or-IPv4:port or [IPv6]:port with port 1-65535" }} {{- $llmRequestRouterDNSAddressPattern := `^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*:[0-9]{1,5}$` }} {{- $llmRequestRouterBracketedIPv6Pattern := `^\[[0-9A-Fa-f:.]+\]:[0-9]{1,5}$` }} -{{- $llmRequestRouterWorkerAddressValid := regexMatch $llmRequestRouterDNSAddressPattern $llmRequestRouterWorkerAddress }} -{{- if regexMatch $llmRequestRouterBracketedIPv6Pattern $llmRequestRouterWorkerAddress }} -{{- $llmRequestRouterIPv6Host := regexFind `^\[[0-9A-Fa-f:.]+\]` $llmRequestRouterWorkerAddress | trimPrefix "[" | trimSuffix "]" }} +{{- $llmRequestRouterWorkerAddressValid := regexMatch $llmRequestRouterDNSAddressPattern $llmRequestRouterWorkerAuthority }} +{{- if regexMatch $llmRequestRouterBracketedIPv6Pattern $llmRequestRouterWorkerAuthority }} +{{- $llmRequestRouterIPv6Host := regexFind `^\[[0-9A-Fa-f:.]+\]` $llmRequestRouterWorkerAuthority | trimPrefix "[" | trimSuffix "]" }} {{- $llmRequestRouterIPv6Valid := true }} {{- $llmRequestRouterIPv6CompressionCount := len (regexFindAll `::` $llmRequestRouterIPv6Host -1) }} {{- if or (contains `:::` $llmRequestRouterIPv6Host) (gt $llmRequestRouterIPv6CompressionCount 1) }} @@ -363,7 +369,7 @@ natsAuthCalloutService: {{- if not $llmRequestRouterWorkerAddressValid }} {{- fail $llmRequestRouterWorkerAddressError }} {{- end }} -{{- $llmRequestRouterWorkerPort := regexFind `[0-9]{1,5}$` $llmRequestRouterWorkerAddress | atoi }} +{{- $llmRequestRouterWorkerPort := regexFind `[0-9]{1,5}$` $llmRequestRouterWorkerAuthority | atoi }} {{- if or (lt $llmRequestRouterWorkerPort 1) (gt $llmRequestRouterWorkerPort 65535) }} {{- fail $llmRequestRouterWorkerAddressError }} {{- end }} @@ -868,9 +874,30 @@ llmApiGateway: {{- $pylonGrpcDialAddress := dig "addons" "llm" "requestRouter" "backendRouter" "pylonGrpcDialAddress" "" .Values | default "" | toString | trim }} {{- $pylonReverseTunnelDialAddress := dig "addons" "llm" "requestRouter" "backendRouter" "pylonReverseTunnelDialAddress" "" .Values | default "" | toString | trim }} {{- $backendRouterEnabled := dig "addons" "llm" "requestRouter" "backendRouter" "enabled" $llmEnabled .Values }} +{{- $llmWorkerRouteEnabled := dig "ingress" "gatewayApi" "routes" "llmWorker" "enabled" false .Values }} +{{- $grpcTlsEnabled := dig "addons" "llm" "requestRouter" "grpcTls" "enabled" false .Values }} +{{- $grpcTlsAllowInsecureHttp := dig "addons" "llm" "requestRouter" "grpcTls" "allowInsecureHttp" false .Values }} {{- if and $llmEnabled $backendRouterEnabled (ne (empty $pylonGrpcDialAddress) (empty $pylonReverseTunnelDialAddress)) }} {{- fail "addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress and addons.llm.requestRouter.backendRouter.pylonReverseTunnelDialAddress must either both be set or both be omitted" }} {{- end }} +{{- if and $llmWorkerRouteEnabled $grpcTlsEnabled $grpcTlsAllowInsecureHttp }} +{{- fail "addons.llm.requestRouter.grpcTls.enabled and addons.llm.requestRouter.grpcTls.allowInsecureHttp cannot both be true" }} +{{- end }} +{{- if and $llmWorkerRouteEnabled (not $grpcTlsEnabled) (not $grpcTlsAllowInsecureHttp) }} +{{- fail "addons.llm.requestRouter.grpcTls.allowInsecureHttp must be true for an explicitly plaintext external LLM worker route" }} +{{- end }} +{{- if and $llmWorkerRouteEnabled (empty $pylonGrpcDialAddress) }} +{{- fail "addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress is required when ingress.gatewayApi.routes.llmWorker.enabled is true" }} +{{- end }} +{{- if and $llmWorkerRouteEnabled (ne $llmRequestRouterWorkerAddress $pylonGrpcDialAddress) }} +{{- fail "global.workerEndpoints.llmRequestRouterAddress and addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress must use the same explicit URI when LLM worker routing is enabled" }} +{{- end }} +{{- if and $llmWorkerRouteEnabled $grpcTlsEnabled (not (hasPrefix "https://" $llmRequestRouterWorkerAddress)) }} +{{- fail "secure LLM worker routing requires an explicit https:// global.workerEndpoints.llmRequestRouterAddress" }} +{{- end }} +{{- if and $llmWorkerRouteEnabled $grpcTlsAllowInsecureHttp (not (hasPrefix "http://" $llmRequestRouterWorkerAddress)) }} +{{- fail "development plaintext LLM worker routing requires an explicit http:// global.workerEndpoints.llmRequestRouterAddress" }} +{{- end }} llmRequestRouter: enabled: {{ dig "addons" "llm" "enabled" false .Values }} @@ -920,6 +947,19 @@ llmRequestRouter: imagePullSecrets: {{- toYaml .Values.global.imagePullSecrets | nindent 6 }} {{- end }} + grpcTls: + enabled: {{ $grpcTlsEnabled }} + allowInsecureHttp: {{ $grpcTlsAllowInsecureHttp }} + mode: {{ dig "addons" "llm" "requestRouter" "grpcTls" "mode" "certManager" .Values | quote }} + secretName: {{ dig "addons" "llm" "requestRouter" "grpcTls" "secretName" "llm-request-router-grpc-tls" .Values | quote }} + dnsNames: + {{- dig "addons" "llm" "requestRouter" "grpcTls" "dnsNames" (list) .Values | toYaml | nindent 6 }} + issuerRef: + kind: {{ dig "addons" "llm" "requestRouter" "grpcTls" "issuerRef" "kind" "" .Values | default (dig "addons" "llm" "pki" "issuerKind" "ClusterIssuer" .Values) | quote }} + name: {{ dig "addons" "llm" "requestRouter" "grpcTls" "issuerRef" "name" "" .Values | default (dig "addons" "llm" "pki" "issuerName" "nvcf-openbao-pki" .Values) | quote }} + {{- with dig "addons" "llm" "requestRouter" "grpcTls" "issuerRef" "group" "" .Values }} + group: {{ . | quote }} + {{- end }} {{- with include "nvcf.nodeSelector" (dict "type" "controlplane" "selectors" .Values.global.nodeSelectors) }} {{- . | nindent 2 }} {{- end }} @@ -1330,7 +1370,6 @@ nvcfUi: nvcfGatewayRoutes: {{- $natsRouteEnabled := dig "ingress" "gatewayApi" "routes" "nats" "enabled" false .Values }} {{- $essRouteEnabled := dig "ingress" "gatewayApi" "routes" "ess" "enabled" false .Values }} - {{- $llmWorkerRouteEnabled := dig "ingress" "gatewayApi" "routes" "llmWorker" "enabled" false .Values }} domain: "{{ .Values.global.domain }}" gateways: shared: diff --git a/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh b/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh index 8bb93c2fc..7e78e4504 100755 --- a/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh +++ b/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh @@ -3,6 +3,7 @@ set -euo pipefail stack_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" router_chart_path="$(cd "$stack_dir/../../helm/llm-request-router/llm-request-router" && pwd)" +gateway_chart_path="$(cd "$stack_dir/../../helm/gateway-routes/chart" && pwd)" work_dir="$(mktemp -d)" test_stack_dir="$work_dir/self-managed" environment_name="llm-router-split-cluster-test" @@ -22,7 +23,7 @@ printf '{}\n' >"$secrets_file" printf '%s\n' \ 'global:' \ ' workerEndpoints:' \ - ' llmRequestRouterAddress: llm-grpc.example.com:50071' \ + ' llmRequestRouterAddress: https://llm-grpc.example.com:50071' \ 'addons:' \ ' llm:' \ ' enabled: true' \ @@ -34,11 +35,21 @@ printf '%s\n' \ ' - "*.llm-request-router-headless.nvcf.svc.cluster.local"' \ ' requestRouter:' \ " chartPath: $router_chart_path" \ + ' grpcTls:' \ + ' enabled: true' \ + ' mode: certManager' \ + ' secretName: llm-grpc-tls' \ + ' dnsNames:' \ + ' - llm-grpc.example.com' \ + ' issuerRef:' \ + ' kind: ClusterIssuer' \ + ' name: nvcf-openbao-pki' \ ' backendRouter:' \ ' pylonGrpcDialAddress: https://llm-grpc.example.com:50071' \ ' pylonReverseTunnelDialAddress: llm-quic.example.com:50072' \ 'ingress:' \ ' gatewayApi:' \ + " chartPath: $gateway_chart_path" \ ' controllerNamespace: envoy-gateway-system' \ ' routes:' \ ' llmWorker:' \ @@ -117,13 +128,21 @@ assert_value '.nvcfGatewayRoutes.gateways.llmGrpc.name' 'llm-grpc-gw' assert_value '.nvcfGatewayRoutes.gateways.llmGrpc.listenerName' 'llm-grpc' assert_value '.nvcfGatewayRoutes.gateways.llmQuic.name' 'llm-quic-gw' assert_value '.nvcfGatewayRoutes.gateways.llmQuic.listenerName' 'llm-quic' +assert_value '.llmRequestRouter.grpcTls.enabled' 'true' +assert_value '.llmRequestRouter.grpcTls.mode' 'certManager' +assert_value '.llmRequestRouter.grpcTls.secretName' 'llm-grpc-tls' +assert_value '.llmRequestRouter.grpcTls.dnsNames[0]' 'llm-grpc.example.com' +assert_value '.llmRequestRouter.grpcTls.issuerRef.name' 'nvcf-openbao-pki' assert_file_value "$work_dir/api-values.yaml" \ '.api.remoteConfig.configData.nvcf.llm-request-router.worker-address' \ - 'llm-grpc.example.com:50071' + 'https://llm-grpc.example.com:50071' assert_file_value "$work_dir/router-values.yaml" \ '.llmRequestRouter.backendRouter.pylonGrpcDialAddress' \ 'https://llm-grpc.example.com:50071' +assert_file_value "$work_dir/router-values.yaml" \ + '.llmRequestRouter.grpcTls.enabled' \ + 'true' assert_file_value "$work_dir/router-values.yaml" \ '.llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress' \ 'llm-quic.example.com:50072' @@ -147,6 +166,19 @@ helm template llm-request-router "$router_chart_path" \ test -s "$work_dir/router-manifest.yaml" || fail "request-router source chart did not render from generated stack values" +helm template nvcf-gateway-routes "$gateway_chart_path" \ + --namespace envoy-gateway-system \ + --values "$values_file" \ + >"$work_dir/gateway-manifest.yaml" +test -s "$work_dir/gateway-manifest.yaml" || + fail "gateway-routes source chart did not render from generated stack values" +grep -Fq 'kind: GRPCRoute' "$work_dir/gateway-manifest.yaml" || + fail "generated stack values did not render the secure LLM GRPCRoute" +grep -Fq 'kind: Certificate' "$work_dir/gateway-manifest.yaml" || + fail "generated stack values did not render the dedicated gRPC Certificate" +grep -Fq 'kind: BackendTrafficPolicy' "$work_dir/gateway-manifest.yaml" || + fail "generated stack values did not render the gRPC stream timeout policy" + assert_partial_backend_override_rejected() { local missing_key="$1" local case_name="$2" @@ -191,6 +223,62 @@ assert_partial_backend_override_rejected \ test "$partial_override_failures" -eq 0 || fail "$partial_override_failures partial backend-router override case(s) were not rejected" +assert_invalid_external_grpc_config_rejected() { + local case_name="$1" + local mutation="$2" + local expected_error="$3" + local invalid_environment_name="${environment_name}-${case_name}" + local invalid_environment_file="$test_stack_dir/environments/$invalid_environment_name.yaml" + local invalid_error="$work_dir/$case_name-error.log" + + cp "$environment_file" "$invalid_environment_file" + printf '{}\n' >"$test_stack_dir/secrets/$invalid_environment_name-secrets.yaml" + yq -i "$mutation" "$invalid_environment_file" + + if HELMFILE_ENV="$invalid_environment_name" \ + HELMFILE_CACHE_HOME="$work_dir/helmfile-cache" \ + helmfile \ + --file "$test_stack_dir/helmfile.d/02-core.yaml.gotmpl" \ + --environment default \ + --selector name=llm-request-router \ + write-values \ + --output-file-template "$work_dir/$case_name-values.yaml" \ + >/dev/null 2>"$invalid_error"; then + echo "llm-router-split-cluster: $case_name was accepted" >&2 + return 1 + fi + + grep -Fq "$expected_error" "$invalid_error" || { + echo "llm-router-split-cluster: $case_name returned an unexpected error" >&2 + sed -n '1,80p' "$invalid_error" >&2 + return 1 + } +} + +invalid_config_failures=0 +assert_invalid_external_grpc_config_rejected \ + 'mismatched-grpc-uri' \ + '.global.workerEndpoints.llmRequestRouterAddress = "https://other.example.com:50071"' \ + 'global.workerEndpoints.llmRequestRouterAddress and addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress must use the same explicit URI when LLM worker routing is enabled' || + invalid_config_failures=$((invalid_config_failures + 1)) +assert_invalid_external_grpc_config_rejected \ + 'secure-http-uri' \ + '.global.workerEndpoints.llmRequestRouterAddress = "http://llm-grpc.example.com:50071" | .addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress = "http://llm-grpc.example.com:50071"' \ + 'secure LLM worker routing requires an explicit https:// global.workerEndpoints.llmRequestRouterAddress' || + invalid_config_failures=$((invalid_config_failures + 1)) +assert_invalid_external_grpc_config_rejected \ + 'secure-scheme-less-uri' \ + '.global.workerEndpoints.llmRequestRouterAddress = "llm-grpc.example.com:50071" | .addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress = "llm-grpc.example.com:50071"' \ + 'secure LLM worker routing requires an explicit https:// global.workerEndpoints.llmRequestRouterAddress' || + invalid_config_failures=$((invalid_config_failures + 1)) +assert_invalid_external_grpc_config_rejected \ + 'plaintext-without-opt-in' \ + '.global.workerEndpoints.llmRequestRouterAddress = "http://llm-grpc.example.com:50071" | .addons.llm.requestRouter.backendRouter.pylonGrpcDialAddress = "http://llm-grpc.example.com:50071" | .addons.llm.requestRouter.grpcTls.enabled = false' \ + 'addons.llm.requestRouter.grpcTls.allowInsecureHttp must be true for an explicitly plaintext external LLM worker route' || + invalid_config_failures=$((invalid_config_failures + 1)) +test "$invalid_config_failures" -eq 0 || + fail "$invalid_config_failures invalid external gRPC configuration case(s) were not rejected" + disabled_environment_name="${environment_name}-backend-router-disabled" disabled_environment_file="$test_stack_dir/environments/$disabled_environment_name.yaml" cp "$environment_file" "$disabled_environment_file" diff --git a/deploy/stacks/self-managed/tests/llm-router-worker-address.sh b/deploy/stacks/self-managed/tests/llm-router-worker-address.sh index 55677e208..32b8ae95c 100755 --- a/deploy/stacks/self-managed/tests/llm-router-worker-address.sh +++ b/deploy/stacks/self-managed/tests/llm-router-worker-address.sh @@ -160,7 +160,7 @@ assert_llm_request_router_grpc_port() { test "$actual_port" = "$expected_port" } -invalid_worker_address_error='global.workerEndpoints.llmRequestRouterAddress must use DNS-or-IPv4:port or [IPv6]:port with port 1-65535' +invalid_worker_address_error='global.workerEndpoints.llmRequestRouterAddress must use optional http:// or https:// followed by DNS-or-IPv4:port or [IPv6]:port with port 1-65535' assert_worker_address_rejected() { local case_name="$1" @@ -257,6 +257,20 @@ assert_remote_config_address "$work_dir/external-api-values.yaml" \ "$external_worker_address" || fail "enabled LLM did not honor an explicit external worker address" +https_worker_address='https://router.example.com:443' +write_environment true "$https_worker_address" +render_api_values "$work_dir/https-api-values.yaml" >/dev/null +assert_remote_config_address "$work_dir/https-api-values.yaml" \ + "$https_worker_address" || + fail "enabled LLM did not preserve an explicit HTTPS worker URI" + +http_worker_address='http://router.example.com:50071' +write_environment true "$http_worker_address" +render_api_values "$work_dir/http-api-values.yaml" >/dev/null +assert_remote_config_address "$work_dir/http-api-values.yaml" \ + "$http_worker_address" || + fail "enabled LLM did not preserve an explicit development HTTP worker URI" + ipv4_worker_address='192.0.2.10:50071' write_environment true "$ipv4_worker_address" render_api_values "$work_dir/ipv4-api-values.yaml" >/dev/null @@ -310,6 +324,9 @@ invalid_address_cases=( 'missing-port|router' 'missing-host|:50071' 'non-numeric-port|router:not-a-port' + 'unsupported-scheme|ftp://router.example.com:50071' + 'userinfo|https://user@router.example.com:50071' + 'path|https://router.example.com:50071/watch' 'port-zero|router:0' 'port-too-large|router:65536' 'port-too-long|router:99999999999999999999999999999999999999' diff --git a/docs/user/gateway-routing.md b/docs/user/gateway-routing.md index 1cd193079..5c3d2597c 100644 --- a/docs/user/gateway-routing.md +++ b/docs/user/gateway-routing.md @@ -238,24 +238,24 @@ grpc-proxy replicas are not supported by this shared TCPRoute. ### LLM worker listeners -LLM workers in another cluster or region need a TCP path for gRPC registration -and watches, plus a UDP path for the reverse QUIC tunnel. These paths are -separate from the `grpcWorker` callback route. +LLM workers in another cluster or region need a TLS-terminated HTTP/2 path for +gRPC registration and watches, plus a UDP path for the reverse QUIC tunnel. +These paths are separate from the `grpcWorker` callback route. ```mermaid flowchart LR Worker["LLM worker sidecar"] - TcpListener["Gateway TCP listener
llmGrpc"] - TcpRoute["TCPRoute
llm-worker-grpc"] + HttpsListener["Gateway HTTPS listener
llmGrpc"] + GrpcRoute["GRPCRoute
llm-worker-grpc"] UdpListener["Gateway UDP listener
llmQuic"] UdpRoute["UDPRoute
llm-worker-quic"] Service["Service
llm-request-router-backend-router"] Backend["Backend router"] Router["LLM request-router pod"] - Worker -->|"gRPC registration and watches"| TcpListener - TcpListener --> TcpRoute - TcpRoute -->|"TCP 50071"| Service + Worker -->|"TLS + gRPC registration and watches"| HttpsListener + HttpsListener --> GrpcRoute + GrpcRoute -->|"h2c 50071"| Service Worker -->|"QUIC reverse tunnel"| UdpListener UdpListener --> UdpRoute UdpRoute -->|"UDP 50072"| Service @@ -264,8 +264,8 @@ flowchart LR ``` The following example uses separate Gateways so the infrastructure can create -one TCP load balancer and one UDP load balancer. A provider that supports mixed -TCP and UDP listeners can use one Gateway for both listeners. In that case, +one HTTPS load balancer and one UDP load balancer. A provider that supports +mixed HTTPS and UDP listeners can use one Gateway for both listeners. In that case, set `llmGrpc.name` and `llmQuic.name` to the same Gateway name. ```bash @@ -279,8 +279,13 @@ spec: gatewayClassName: eg listeners: - name: llm-grpc - protocol: TCP + protocol: HTTPS port: 50071 + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: llm-grpc-tls allowedRoutes: namespaces: from: Selector @@ -327,17 +332,44 @@ ingress: name: llm-quic-gateway namespace: envoy-gateway listenerName: llm-quic + +addons: + llm: + pki: + # Include the listener suffix when using the stack-managed issuer. + allowedDomains: cluster.local,example.com + requestRouter: + grpcTls: + enabled: true + mode: certManager + secretName: llm-grpc-tls + dnsNames: + - llm-grpc.example.com + issuerRef: + kind: ClusterIssuer + name: nvcf-openbao-pki ``` -The gateway-routes chart creates `TCPRoute/llm-worker-grpc`, -`UDPRoute/llm-worker-quic`, and -`ReferenceGrant/allow-llm-worker-routes`. Both routes target -`Service/llm-request-router-backend-router` in the `nvcf` namespace on ports -`50071` and `50072`. The Service remains `ClusterIP` by design. Do not change +The gateway-routes chart creates `GRPCRoute/llm-worker-grpc`, +`UDPRoute/llm-worker-quic`, +`ReferenceGrant/allow-llm-worker-routes`, a dedicated gRPC `Certificate`, and +an Envoy `BackendTrafficPolicy` with gRPC stream timeouts disabled. Both routes +target `Service/llm-request-router-backend-router` in the `nvcf` namespace on +ports `50071` and `50072`. The Service remains `ClusterIP` by design. Do not change it to `LoadBalancer` or `NodePort`; the Gateway data-plane Service owns external exposure. -Configure worker-facing TCP and UDP dial addresses as described in +The `GRPCRoute` deliberately omits `hostnames`, and its dedicated HTTPS +listener must omit `hostname`. The listener serves the dedicated certificate +for external-SNI verification, while Pylon keeps the selected Stargate pod +identity in HTTP/2 `:authority` so the backend router can forward each stream. +A listener hostname would constrain both values and reject the internal +authority. With `grpcTls.mode: existingSecret`, create the named Secret in the +Gateway namespace yourself; the chart renders no `Certificate`. Plaintext is +development-only and requires an explicit `http://` dial URI plus +`grpcTls.allowInsecureHttp: true`. + +Configure worker-facing HTTPS and UDP dial addresses as described in [Remote compute clusters and regions](./llm-function-enablement.md#remote-compute-clusters-and-regions). ### Capture Gateway values @@ -414,7 +446,7 @@ and ports accordingly. ## Gateway API Implementations The `nvcf-gateway-routes` chart creates standard Kubernetes Gateway API -resources, including `HTTPRoute`, `TCPRoute`, `UDPRoute`, and +resources, including `HTTPRoute`, `GRPCRoute`, `TCPRoute`, `UDPRoute`, and `ReferenceGrant`. Use a controller that supports every route kind enabled in your environment. @@ -430,17 +462,18 @@ There is no service mesh requirement. Envoy Gateway is not a service mesh. It is Any Gateway API implementation you choose must support: 1. `HTTPRoute` for HTTP/HTTPS routing with hostname matching -2. `TCPRoute` for gRPC invocation, optional split or multi-cluster gRPC +2. `GRPCRoute` for HTTPS worker APIs and secure remote LLM registration +3. `TCPRoute` for gRPC invocation, optional split or multi-cluster gRPC invocation, and NATS routing (requires experimental Gateway API CRDs) -3. `UDPRoute` for remote LLM reverse tunnels when the `llmWorker` route is +4. `UDPRoute` for remote LLM reverse tunnels when the `llmWorker` route is enabled (requires experimental Gateway API CRDs) -4. Cross-namespace routing and `ReferenceGrant` for routes that reference +5. Cross-namespace routing and `ReferenceGrant` for routes that reference Services in another namespace `TCPRoute` and `UDPRoute` are experimental. Some Gateway API implementations have limited support for them. Verify the controller's supported versions -before deploying. Remote LLM workers require both route kinds. +before deploying. Secure remote LLM workers require `GRPCRoute` and `UDPRoute`. @@ -507,8 +540,9 @@ If you have a specific requirement that prevents using Gateway API, you would ne 3. Configure hostname routing manually 4. Set up TCP load balancers for gRPC on port 10081, optional split or multi-cluster gRPC invocation on port 10086, and NATS on port 4222 -5. When serving remote LLM workers, route TCP port 50071 and UDP port 50072 to - `llm-request-router-backend-router` without bypassing its authority and SNI +5. When serving remote LLM workers, terminate verified TLS with ALPN `h2` on + port 50071 and forward h2c to `llm-request-router-backend-router`; route UDP + port 50072 to the same Service without bypassing its authority and SNI selection ## Gateway Architecture @@ -528,7 +562,7 @@ These resources must be created manually before deploying the control plane: (port 4222) listeners - Optional `worker-tcp` (port 10086) listener for split or multi-cluster gRPC invocation -- Optional `llm-grpc` (TCP port 50071) and `llm-quic` (UDP port 50072) +- Optional `llm-grpc` (HTTPS port 50071) and `llm-quic` (UDP port 50072) listeners for remote LLM workers ### Resources created by nvcf-gateway-routes @@ -543,7 +577,8 @@ When you deploy the control plane via helmfile, the `nvcf-gateway-routes` chart - Optional `TCPRoute` for split or multi-cluster gRPC invocation when the `grpcWorker` route is enabled - Optional `TCPRoute` for NATS when the `nats` route is enabled -- Optional `TCPRoute` and `UDPRoute` for LLM worker traffic when the +- Optional `GRPCRoute`, `UDPRoute`, gRPC listener `Certificate` in cert-manager + mode, and Envoy stream timeout policy for LLM worker traffic when the `llmWorker` route is enabled - `ReferenceGrants` for cross-namespace routing permissions @@ -562,7 +597,7 @@ These routes attach to the Gateway you prepared in [Gateway quickstart](./gatewa | gRPC | N/A (TCP routing, no hostname matching) | 10081 | gRPC function invocations | | gRPC worker callback | N/A (TCP routing, no hostname matching) | 10086 | HTTP/1 CONNECT callback from workers to grpc-proxy when the beta `grpcWorker` route is enabled | | NATS | N/A (TCP routing, no hostname matching) | 4222 | NVCA messaging when the NATS route is enabled | -| LLM worker gRPC | N/A (TCP routing, authority selects a request-router pod) | 50071 | Registration and request-router watches through `llm-request-router-backend-router` | +| LLM worker gRPC | External TLS SNI; Stargate identity remains `:authority` | 50071 | TLS-terminated registration and request-router watches through `llm-request-router-backend-router` | | LLM worker QUIC | N/A (UDP routing, SNI selects a request-router pod) | 50072 | Reverse inference tunnels through `llm-request-router-backend-router` | @@ -644,7 +679,8 @@ By default, the route host is `nvcf-ui.` and the backend is 5. NATS connections arrive at port 4222. When enabled, the NATS TCPRoute forwards traffic directly to the NATS service. 6. Remote LLM gRPC connections arrive at the configured `llmGrpc` listener. - The TCPRoute forwards them to the backend-router Service on port 50071. + The listener verifies and terminates TLS, then the GRPCRoute forwards h2c to + the backend-router Service on port 50071 without changing `:authority`. 7. Remote LLM reverse tunnels arrive at the configured `llmQuic` listener. The UDPRoute forwards them to the same Service on port 50072. The TCP and UDP listeners can use separate Gateways and load balancers. @@ -710,19 +746,32 @@ parents and the cross-namespace backend reference: export LLM_GRPC_GATEWAY_NAMESPACE=envoy-gateway export LLM_QUIC_GATEWAY_NAMESPACE=envoy-gateway -kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get tcproute llm-worker-grpc \ +kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get grpcroute llm-worker-grpc \ -o jsonpath='{range .status.parents[*].conditions[*]}{.type}={.status}{" reason="}{.reason}{"\n"}{end}' kubectl -n "$LLM_QUIC_GATEWAY_NAMESPACE" get udproute llm-worker-quic \ -o jsonpath='{range .status.parents[*].conditions[*]}{.type}={.status}{" reason="}{.reason}{"\n"}{end}' kubectl -n nvcf get referencegrant allow-llm-worker-routes -o yaml +# cert-manager mode only +kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get certificate llm-grpc-tls +kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get backendtrafficpolicy \ + llm-worker-grpc-streams \ + -o jsonpath='{.spec.timeout.http.requestTimeout}{"\t"}{.spec.timeout.http.maxStreamDuration}{"\n"}' kubectl -n nvcf get service llm-request-router-backend-router \ -o jsonpath='{.spec.type}{"\t"}{range .spec.ports[*]}{.port}{"/"}{.protocol}{" "}{end}{"\n"}' ``` Each route parent must report `Accepted=True` and `ResolvedRefs=True`. The Service type must remain `ClusterIP`, with TCP port `50071` and UDP port -`50072`. Also wait for each referenced Gateway to report `Programmed=True` and -confirm that its status contains an external address. +`50072`. In cert-manager mode the dedicated certificate must be Ready; both +policy values must be `0s`. Also wait for each referenced Gateway to report +`Programmed=True` and confirm that its status contains an external address. +Verify the public certificate and ALPN without disabling validation: + +```bash +openssl s_client -connect llm-grpc.example.com:50071 \ + -servername llm-grpc.example.com -alpn h2 -verify_return_error \ + -CAfile /path/to/worker-trust-bundle.pem Date: Fri, 28 Aug 2026 01:46:26 -0400 Subject: [PATCH 2/9] test(bdd): exercise source LLM router in PKI flow Signed-off-by: Mike Camp --- .../single-cluster-helmfile-llm-pki.feature | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/bdd/features/single-cluster-helmfile-llm-pki.feature b/tests/bdd/features/single-cluster-helmfile-llm-pki.feature index c672a0053..ffde92b55 100644 --- a/tests/bdd/features/single-cluster-helmfile-llm-pki.feature +++ b/tests/bdd/features/single-cluster-helmfile-llm-pki.feature @@ -27,14 +27,16 @@ Feature: Install a local single-cluster NVCF stack with PKI-secured LLM transpor # signing role, and the provisioning hook needs the # nvcf-openbao-migrations tag (no default propagates from env). And I update yaml file "deploy/stacks/self-managed/environments/local-bdd-pki.yaml" with keys: - | global.imagePullSecrets[0].name | nvcr-pull-secret | - | global.helm.sources.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | - | global.image.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | - | addons.llm.pki.enabled | true | - | addons.llm.pki.dnsNames[0] | llm-request-router.nvcf.svc.cluster.local | - | addons.llm.pki.allowedDomains | nvcf.svc.cluster.local | - | addons.llm.pki.image.tag | 0.16.2 | - | observability.profile | disabled | + | global.imagePullSecrets[0].name | nvcr-pull-secret | + | global.helm.sources.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | + | global.image.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | + | api.env.NVCF_SIDECARS_LLM_ROUTER_CLIENT_IMAGE | nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/stargate-client:0.2.0 | + | addons.llm.requestRouter.chartPath | ../../../helm/llm-request-router/llm-request-router | + | addons.llm.pki.enabled | true | + | addons.llm.pki.dnsNames[0] | llm-request-router.nvcf.svc.cluster.local | + | addons.llm.pki.allowedDomains | nvcf.svc.cluster.local | + | addons.llm.pki.image.tag | 0.16.2 | + | observability.profile | disabled | And I copy the file "tests/bdd/fixtures/nvcf-compute-plane-local-bdd.yaml" to "deploy/stacks/nvcf-compute-plane/environments/local-bdd-pki.yaml" And I update yaml file "deploy/stacks/nvcf-compute-plane/environments/local-bdd-pki.yaml" with keys: | global.imagePullSecrets[0].name | nvcr-pull-secret | From 376548e20941ced754c8b3a68e574dcfce8fc787 Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 03:19:54 -0400 Subject: [PATCH 3/9] fix(gateway): target Envoy v1.5 stream timeout API Signed-off-by: Mike Camp --- .../backendtrafficpolicy-llm-worker-grpc.yaml | 1 - .../scripts/check-llm-worker-routes.sh | 13 +++++++++++-- docs/user/gateway-routing.md | 8 +++++--- tests/bdd/features/multi-cluster-helmfile.feature | 1 - 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml b/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml index 62a93d279..36bb01524 100644 --- a/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml +++ b/deploy/helm/gateway-routes/chart/templates/backendtrafficpolicy-llm-worker-grpc.yaml @@ -19,5 +19,4 @@ spec: timeout: http: requestTimeout: 0s - maxStreamDuration: 0s {{- end }} diff --git a/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh b/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh index cadd1c9b5..cf1ddf00e 100755 --- a/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh +++ b/deploy/helm/gateway-routes/scripts/check-llm-worker-routes.sh @@ -41,6 +41,15 @@ assert_contains() { fi } +assert_not_contains() { + local pattern="$1" + local message="$2" + if grep -Fq -- "$pattern" "$rendered"; then + echo "FAIL: ${message}" >&2 + exit 1 + fi +} + assert_contains "kind: GRPCRoute" \ "secure LLM worker routing must expose gRPC registration through a GRPCRoute" assert_contains "kind: UDPRoute" \ @@ -49,8 +58,8 @@ assert_contains "kind: BackendTrafficPolicy" \ "secure LLM worker routing must disable Envoy timeouts for streaming RPCs" assert_contains "requestTimeout: 0s" \ "secure LLM worker routing must disable the request timeout" -assert_contains "maxStreamDuration: 0s" \ - "secure LLM worker routing must disable the maximum stream duration" +assert_not_contains "maxStreamDuration:" \ + "secure LLM worker routing must stay compatible with the pinned Envoy Gateway v1.5 CRD" assert_contains "kind: Certificate" \ "cert-manager mode must issue the dedicated gRPC listener certificate" assert_contains 'secretName: "llm-request-router-grpc-tls"' \ diff --git a/docs/user/gateway-routing.md b/docs/user/gateway-routing.md index 5c3d2597c..b953a0323 100644 --- a/docs/user/gateway-routing.md +++ b/docs/user/gateway-routing.md @@ -755,15 +755,17 @@ kubectl -n nvcf get referencegrant allow-llm-worker-routes -o yaml kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get certificate llm-grpc-tls kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get backendtrafficpolicy \ llm-worker-grpc-streams \ - -o jsonpath='{.spec.timeout.http.requestTimeout}{"\t"}{.spec.timeout.http.maxStreamDuration}{"\n"}' + -o jsonpath='{.spec.timeout.http.requestTimeout}{"\n"}' kubectl -n nvcf get service llm-request-router-backend-router \ -o jsonpath='{.spec.type}{"\t"}{range .spec.ports[*]}{.port}{"/"}{.protocol}{" "}{end}{"\n"}' ``` Each route parent must report `Accepted=True` and `ResolvedRefs=True`. The Service type must remain `ClusterIP`, with TCP port `50071` and UDP port -`50072`. In cert-manager mode the dedicated certificate must be Ready; both -policy values must be `0s`. Also wait for each referenced Gateway to report +`50072`. In cert-manager mode the dedicated certificate must be Ready, and +the request timeout must be `0s`. This is the Envoy Gateway v1.5-compatible +setting that disables the default 15-second timeout for streaming gRPC calls. +Also wait for each referenced Gateway to report `Programmed=True` and confirm that its status contains an external address. Verify the public certificate and ALPN without disabling validation: diff --git a/tests/bdd/features/multi-cluster-helmfile.feature b/tests/bdd/features/multi-cluster-helmfile.feature index 513126b12..393615560 100644 --- a/tests/bdd/features/multi-cluster-helmfile.feature +++ b/tests/bdd/features/multi-cluster-helmfile.feature @@ -174,7 +174,6 @@ Feature: Install a local multi-cluster NVCF stack with Helmfile timeout: http: requestTimeout: 0s - maxStreamDuration: 0s """ # These routes are installed by ncp-local before the Helmfile # stack, then become fully resolved once the control-plane From 8166d587f1dbdf23645dc861875352ef467af9d8 Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 04:51:10 -0400 Subject: [PATCH 4/9] test(bdd): retry transient LLM eligibility Signed-off-by: Mike Camp --- tests/bdd/steps/nvcf_cli_steps.go | 33 +++++++++++++++-- tests/bdd/steps/nvcf_cli_steps_test.go | 50 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/tests/bdd/steps/nvcf_cli_steps.go b/tests/bdd/steps/nvcf_cli_steps.go index 8d01d4a82..38ffdcddf 100644 --- a/tests/bdd/steps/nvcf_cli_steps.go +++ b/tests/bdd/steps/nvcf_cli_steps.go @@ -21,12 +21,15 @@ import ( "context" "fmt" "strings" + "time" "github.com/cucumber/godog" "nvcf-bdd/dsl" ) +var modelInvocationRetryInterval = time.Second + func registerNVCFCLISteps(ctx *godog.ScenarioContext, sc *ScenarioContext) { ctx.Step(`^I use NVCF CLI config "([^"]*)"$`, sc.iUseNVCFCLIConfig) ctx.Step(`^I successfully create function "([^"]*)" from image "([^"]*)" with CLI options:$`, sc.iSuccessfullyCreateFunction) @@ -101,13 +104,39 @@ func (sc *ScenarioContext) iSuccessfullyInvokeModel( timeout string, doc *godog.DocString, ) error { - return sc.runNVCFCLI(ctx, + args := []string{ "function", "invoke", "--inference-url", inferenceURL, "--model-name", model, "--request-body", doc.Content, "--timeout", timeout, - ) + } + retryFor, retryTimeoutErr := time.ParseDuration(timeout + "s") + deadline := time.Now().Add(retryFor) + + for { + err := sc.runNVCFCLI(ctx, args...) + if err == nil { + return nil + } + if retryTimeoutErr != nil || retryFor <= 0 || + !strings.Contains(combinedOutput(sc.LastResult), "no_eligible_candidates") { + return err + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return err + } + waitFor := min(modelInvocationRetryInterval, remaining) + timer := time.NewTimer(waitFor) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } } func (sc *ScenarioContext) iSuccessfullyUndeploySelectedFunction(ctx context.Context) error { diff --git a/tests/bdd/steps/nvcf_cli_steps_test.go b/tests/bdd/steps/nvcf_cli_steps_test.go index 0b7498887..2f0bcc977 100644 --- a/tests/bdd/steps/nvcf_cli_steps_test.go +++ b/tests/bdd/steps/nvcf_cli_steps_test.go @@ -22,6 +22,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/cucumber/godog" @@ -119,6 +120,55 @@ func TestNVCFCLIInvocationAdaptersExposeAllArguments(t *testing.T) { } } +func TestNVCFCLIModelInvocationRetriesNoEligibleCandidates(t *testing.T) { + sc, fake := newScenarioContext(t) + t.Setenv("NVCF_CLI", "nvcf-cli") + sc.NVCFCLIConfig = "config.yaml" + fake.runResults = []harness.Result{ + {ExitCode: 1, Stderr: `API error 404: {"code":"no_eligible_candidates"}`}, + {ExitCode: 0, Stdout: `{"object":"chat.completion"}`}, + } + + previousInterval := modelInvocationRetryInterval + modelInvocationRetryInterval = time.Nanosecond + t.Cleanup(func() { modelInvocationRetryInterval = previousInterval }) + + err := sc.iSuccessfullyInvokeModel( + context.Background(), + "model/name", + "/v1/chat/completions", + "1", + &godog.DocString{Content: `{"messages":[]}`}, + ) + if err != nil { + t.Fatalf("invoke model: %v", err) + } + if len(fake.runs) != 2 { + t.Fatalf("runs = %d, want 2", len(fake.runs)) + } +} + +func TestNVCFCLIModelInvocationDoesNotRetryOtherErrors(t *testing.T) { + sc, fake := newScenarioContext(t) + t.Setenv("NVCF_CLI", "nvcf-cli") + sc.NVCFCLIConfig = "config.yaml" + fake.result = harness.Result{ExitCode: 1, Stderr: "API error 401: unauthorized"} + + err := sc.iSuccessfullyInvokeModel( + context.Background(), + "model/name", + "/v1/chat/completions", + "1", + &godog.DocString{Content: `{"messages":[]}`}, + ) + if err == nil || !strings.Contains(err.Error(), "exit code = 1, want 0") { + t.Fatalf("error = %v, want exit-zero assertion failure", err) + } + if len(fake.runs) != 1 { + t.Fatalf("runs = %d, want 1", len(fake.runs)) + } +} + func TestNVCFCLISuccessStepRequiresExitZero(t *testing.T) { sc, fake := newScenarioContext(t) t.Setenv("NVCF_CLI", "nvcf-cli") From b18d22a2341e870235a1faef33ea2a10905b5a8c Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 09:11:43 -0400 Subject: [PATCH 5/9] fix(bdd): bound LLM eligibility retries Refs #1294. Signed-off-by: Mike Camp --- tests/bdd/steps/nvcf_cli_steps.go | 24 ++++++++-- tests/bdd/steps/nvcf_cli_steps_test.go | 64 ++++++++++++++++++++++++++ tests/bdd/steps/steps_test.go | 6 ++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tests/bdd/steps/nvcf_cli_steps.go b/tests/bdd/steps/nvcf_cli_steps.go index 38ffdcddf..990d5b59b 100644 --- a/tests/bdd/steps/nvcf_cli_steps.go +++ b/tests/bdd/steps/nvcf_cli_steps.go @@ -113,9 +113,15 @@ func (sc *ScenarioContext) iSuccessfullyInvokeModel( } retryFor, retryTimeoutErr := time.ParseDuration(timeout + "s") deadline := time.Now().Add(retryFor) + retryCtx := ctx + if retryTimeoutErr == nil && retryFor > 0 { + var cancel context.CancelFunc + retryCtx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } for { - err := sc.runNVCFCLI(ctx, args...) + err := sc.runNVCFCLI(retryCtx, args...) if err == nil { return nil } @@ -131,11 +137,23 @@ func (sc *ScenarioContext) iSuccessfullyInvokeModel( waitFor := min(modelInvocationRetryInterval, remaining) timer := time.NewTimer(waitFor) select { - case <-ctx.Done(): + case <-retryCtx.Done(): timer.Stop() - return ctx.Err() + if ctx.Err() != nil { + return ctx.Err() + } + return err case <-timer.C: } + if time.Until(deadline) <= 0 { + return err + } + if retryCtx.Err() != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } } } diff --git a/tests/bdd/steps/nvcf_cli_steps_test.go b/tests/bdd/steps/nvcf_cli_steps_test.go index 2f0bcc977..d93a2753f 100644 --- a/tests/bdd/steps/nvcf_cli_steps_test.go +++ b/tests/bdd/steps/nvcf_cli_steps_test.go @@ -148,6 +148,70 @@ func TestNVCFCLIModelInvocationRetriesNoEligibleCandidates(t *testing.T) { } } +func TestNVCFCLIModelInvocationDoesNotRetryWhenWaitReachesDeadline(t *testing.T) { + sc, fake := newScenarioContext(t) + t.Setenv("NVCF_CLI", "nvcf-cli") + sc.NVCFCLIConfig = "config.yaml" + fake.runResults = []harness.Result{ + {ExitCode: 1, Stderr: `API error 404: {"code":"no_eligible_candidates"}`}, + {ExitCode: 0, Stdout: `{"object":"chat.completion"}`}, + } + + previousInterval := modelInvocationRetryInterval + modelInvocationRetryInterval = time.Second + t.Cleanup(func() { modelInvocationRetryInterval = previousInterval }) + + err := sc.iSuccessfullyInvokeModel( + context.Background(), + "model/name", + "/v1/chat/completions", + "0.05", + &godog.DocString{Content: `{"messages":[]}`}, + ) + if err == nil || !strings.Contains(err.Error(), "exit code = 1, want 0") { + t.Fatalf("error = %v, want initial eligibility failure", err) + } + if len(fake.runs) != 1 { + t.Fatalf("runs = %d, want no attempt after retry deadline", len(fake.runs)) + } +} + +func TestNVCFCLIModelInvocationBoundsAttemptByRetryDeadline(t *testing.T) { + sc, fake := newScenarioContext(t) + t.Setenv("NVCF_CLI", "nvcf-cli") + sc.NVCFCLIConfig = "config.yaml" + + var observedBudget time.Duration + fake.runHook = func(ctx context.Context, _ int) (harness.Result, error) { + deadline, ok := ctx.Deadline() + if !ok { + return harness.Result{}, errors.New("attempt context has no deadline") + } + observedBudget = time.Until(deadline) + <-ctx.Done() + return harness.Result{ExitCode: -1}, ctx.Err() + } + + parentCtx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + t.Cleanup(cancel) + err := sc.iSuccessfullyInvokeModel( + parentCtx, + "model/name", + "/v1/chat/completions", + "0.05", + &godog.DocString{Content: `{"messages":[]}`}, + ) + if err == nil { + t.Fatal("invoke model succeeded, want deadline failure") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want deadline exceeded", err) + } + if observedBudget <= 0 || observedBudget > 100*time.Millisecond { + t.Fatalf("attempt context budget = %s, want retry budget near 50ms", observedBudget) + } +} + func TestNVCFCLIModelInvocationDoesNotRetryOtherErrors(t *testing.T) { sc, fake := newScenarioContext(t) t.Setenv("NVCF_CLI", "nvcf-cli") diff --git a/tests/bdd/steps/steps_test.go b/tests/bdd/steps/steps_test.go index 55aca5e6d..1189730d8 100644 --- a/tests/bdd/steps/steps_test.go +++ b/tests/bdd/steps/steps_test.go @@ -43,10 +43,14 @@ type fakeRunner struct { result harness.Result runResults []harness.Result err error + runHook func(context.Context, int) (harness.Result, error) } -func (f *fakeRunner) Run(_ context.Context, command string) (harness.Result, error) { +func (f *fakeRunner) Run(ctx context.Context, command string) (harness.Result, error) { f.runs = append(f.runs, recordedRun{command: command}) + if f.runHook != nil { + return f.runHook(ctx, len(f.runs)) + } if index := len(f.runs) - 1; index < len(f.runResults) { return f.runResults[index], f.err } From 2897b66caa1af186a63f7ecb4e19fc175f363470 Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 09:11:47 -0400 Subject: [PATCH 6/9] docs(llm): clarify secure worker routing Signed-off-by: Mike Camp --- deploy/helm/gateway-routes/README.md | 4 ++-- docs/user/gateway-routing.md | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/deploy/helm/gateway-routes/README.md b/deploy/helm/gateway-routes/README.md index d877e80bd..31bdf8239 100644 --- a/deploy/helm/gateway-routes/README.md +++ b/deploy/helm/gateway-routes/README.md @@ -134,8 +134,8 @@ Cross-namespace routing is supported via `ReferenceGrant` resources rendered int `grpcTls.mode=certManager` creates the named Secret through a dedicated `Certificate` in the gRPC Gateway namespace. `mode=existingSecret` expects the operator to create that Secret. The HTTPS listener must reference the - same Secret. The Envoy `BackendTrafficPolicy` sets both the request timeout - and maximum stream duration to `0s` for Watch and Register streams. + same Secret. The Envoy `BackendTrafficPolicy` sets the request timeout to + `0s` for Watch and Register streams. Plaintext is intended only for development and requires `grpcTls.allowInsecureHttp=true`; it renders the legacy `TCPRoute`. Keep the HTTPS and UDP Gateways separate when the infrastructure requires diff --git a/docs/user/gateway-routing.md b/docs/user/gateway-routing.md index b953a0323..f8a5d70e2 100644 --- a/docs/user/gateway-routing.md +++ b/docs/user/gateway-routing.md @@ -245,7 +245,10 @@ These paths are separate from the `grpcWorker` callback route. ```mermaid flowchart LR Worker["LLM worker sidecar"] - HttpsListener["Gateway HTTPS listener
llmGrpc"] + subgraph GatewayTls["Gateway TLS termination"] + Certificate["TLS Secret
dedicated gRPC certificate"] + HttpsListener["Gateway HTTPS listener
llmGrpc"] + end GrpcRoute["GRPCRoute
llm-worker-grpc"] UdpListener["Gateway UDP listener
llmQuic"] UdpRoute["UDPRoute
llm-worker-quic"] @@ -253,9 +256,10 @@ flowchart LR Backend["Backend router"] Router["LLM request-router pod"] - Worker -->|"TLS + gRPC registration and watches"| HttpsListener + Certificate -.->|"server identity"| HttpsListener + Worker -->|"TLS + gRPC registration and watches
public hostname SNI"| HttpsListener HttpsListener --> GrpcRoute - GrpcRoute -->|"h2c 50071"| Service + GrpcRoute -->|"h2c 50071
Stargate :authority preserved"| Service Worker -->|"QUIC reverse tunnel"| UdpListener UdpListener --> UdpRoute UdpRoute -->|"UDP 50072"| Service @@ -353,8 +357,9 @@ addons: The gateway-routes chart creates `GRPCRoute/llm-worker-grpc`, `UDPRoute/llm-worker-quic`, `ReferenceGrant/allow-llm-worker-routes`, a dedicated gRPC `Certificate`, and -an Envoy `BackendTrafficPolicy` with gRPC stream timeouts disabled. Both routes -target `Service/llm-request-router-backend-router` in the `nvcf` namespace on +an Envoy `BackendTrafficPolicy` with the gRPC request timeout disabled. Both +routes target `Service/llm-request-router-backend-router` in the `nvcf` +namespace on ports `50071` and `50072`. The Service remains `ClusterIP` by design. Do not change it to `LoadBalancer` or `NodePort`; the Gateway data-plane Service owns external exposure. From 76fe305d0ffec4444f590edbfc04764e18080d1d Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 09:35:31 -0400 Subject: [PATCH 7/9] docs(llm): qualify secure gateway resources Signed-off-by: Mike Camp --- docs/user/gateway-routing.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/user/gateway-routing.md b/docs/user/gateway-routing.md index f8a5d70e2..bd1e809ac 100644 --- a/docs/user/gateway-routing.md +++ b/docs/user/gateway-routing.md @@ -354,14 +354,16 @@ addons: name: nvcf-openbao-pki ``` -The gateway-routes chart creates `GRPCRoute/llm-worker-grpc`, -`UDPRoute/llm-worker-quic`, -`ReferenceGrant/allow-llm-worker-routes`, a dedicated gRPC `Certificate`, and -an Envoy `BackendTrafficPolicy` with the gRPC request timeout disabled. Both -routes target `Service/llm-request-router-backend-router` in the `nvcf` -namespace on -ports `50071` and `50072`. The Service remains `ClusterIP` by design. Do not change -it to `LoadBalancer` or `NodePort`; the Gateway data-plane Service owns external +The secure configuration above creates `GRPCRoute/llm-worker-grpc`, +`UDPRoute/llm-worker-quic`, `ReferenceGrant/allow-llm-worker-routes`, and an +Envoy `BackendTrafficPolicy` with the gRPC request timeout disabled. In +`grpcTls.mode=certManager`, it also creates the dedicated gRPC `Certificate`; +`existingSecret` mode uses the operator-owned Secret instead. Plaintext +development mode renders a `TCPRoute` and neither the policy nor the +`Certificate`. Both routes target +`Service/llm-request-router-backend-router` in the `nvcf` namespace on ports +`50071` and `50072`. The Service remains `ClusterIP` by design. Do not change it +to `LoadBalancer` or `NodePort`; the Gateway data-plane Service owns external exposure. The `GRPCRoute` deliberately omits `hostnames`, and its dedicated HTTPS @@ -582,9 +584,12 @@ When you deploy the control plane via helmfile, the `nvcf-gateway-routes` chart - Optional `TCPRoute` for split or multi-cluster gRPC invocation when the `grpcWorker` route is enabled - Optional `TCPRoute` for NATS when the `nats` route is enabled -- Optional `GRPCRoute`, `UDPRoute`, gRPC listener `Certificate` in cert-manager - mode, and Envoy stream timeout policy for LLM worker traffic when the - `llmWorker` route is enabled +- Optional `UDPRoute` plus a `GRPCRoute` for secure LLM worker traffic or a + `TCPRoute` for plaintext development when the `llmWorker` route is enabled +- Optional Envoy stream timeout policy when secure LLM worker routing has + `grpcTls.enabled=true` +- Optional gRPC listener `Certificate` when secure LLM worker routing uses + `grpcTls.mode=certManager` - `ReferenceGrants` for cross-namespace routing permissions These routes attach to the Gateway you prepared in [Gateway quickstart](./gateway-routing.md#gateway-quickstart). From 6226dbe4119866e518a7e858b8601bae0520d9a6 Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 11:59:44 -0400 Subject: [PATCH 8/9] test(self-managed): validate secure LLM gateway resources Signed-off-by: Mike Camp --- .../tests/llm-router-split-cluster.sh | 81 +++++++++++++++++-- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh b/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh index 7e78e4504..952360626 100755 --- a/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh +++ b/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh @@ -122,6 +122,42 @@ assert_value() { assert_file_value "$values_file" "$path" "$expected" } +assert_manifest_value() { + local file="$1" + local expression="$2" + local expected="$3" + local actual + + actual="$(yq ea -r "$expression" "$file")" + test "$actual" = "$expected" || + fail "expected $expression in $(basename "$file") to be $expected, got $actual" +} + +assert_resource_count() { + local file="$1" + local kind="$2" + local name="$3" + local namespace="$4" + local expected="$5" + local expression + + expression="[select(.kind == \"$kind\" and .metadata.name == \"$name\" and .metadata.namespace == \"$namespace\")] | length" + assert_manifest_value "$file" "$expression" "$expected" +} + +assert_resource_field() { + local file="$1" + local kind="$2" + local name="$3" + local namespace="$4" + local field="$5" + local expected="$6" + local expression + + expression="select(.kind == \"$kind\" and .metadata.name == \"$name\" and .metadata.namespace == \"$namespace\") | $field" + assert_manifest_value "$file" "$expression" "$expected" +} + assert_value '.nvcfGatewayRoutes.routes.llmWorker.enabled' 'true' assert_value '.nvcfGatewayRoutes.routes.llmWorker.backend.namespace' 'nvcf' assert_value '.nvcfGatewayRoutes.gateways.llmGrpc.name' 'llm-grpc-gw' @@ -172,12 +208,45 @@ helm template nvcf-gateway-routes "$gateway_chart_path" \ >"$work_dir/gateway-manifest.yaml" test -s "$work_dir/gateway-manifest.yaml" || fail "gateway-routes source chart did not render from generated stack values" -grep -Fq 'kind: GRPCRoute' "$work_dir/gateway-manifest.yaml" || - fail "generated stack values did not render the secure LLM GRPCRoute" -grep -Fq 'kind: Certificate' "$work_dir/gateway-manifest.yaml" || - fail "generated stack values did not render the dedicated gRPC Certificate" -grep -Fq 'kind: BackendTrafficPolicy' "$work_dir/gateway-manifest.yaml" || - fail "generated stack values did not render the gRPC stream timeout policy" + +gateway_manifest="$work_dir/gateway-manifest.yaml" +gateway_namespace='envoy-gateway-system' + +assert_resource_count "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" 1 +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.metadata.labels."app.kubernetes.io/component"' 'llm-worker-grpc-route' +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.spec.parentRefs[0].name' 'llm-grpc-gw' +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.spec.parentRefs[0].namespace' "$gateway_namespace" +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.spec.parentRefs[0].sectionName' 'llm-grpc' +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.spec.rules[0].backendRefs[0].name' 'llm-request-router-backend-router' +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.spec.rules[0].backendRefs[0].namespace' 'nvcf' +assert_resource_field "$gateway_manifest" GRPCRoute llm-worker-grpc "$gateway_namespace" \ + '.spec.rules[0].backendRefs[0].port' '50071' + +assert_resource_count "$gateway_manifest" Certificate llm-grpc-tls "$gateway_namespace" 1 +assert_resource_field "$gateway_manifest" Certificate llm-grpc-tls "$gateway_namespace" \ + '.spec.secretName' 'llm-grpc-tls' +assert_resource_field "$gateway_manifest" Certificate llm-grpc-tls "$gateway_namespace" \ + '.spec.dnsNames[0]' 'llm-grpc.example.com' +assert_resource_field "$gateway_manifest" Certificate llm-grpc-tls "$gateway_namespace" \ + '.spec.issuerRef.kind' 'ClusterIssuer' +assert_resource_field "$gateway_manifest" Certificate llm-grpc-tls "$gateway_namespace" \ + '.spec.issuerRef.name' 'nvcf-openbao-pki' + +assert_resource_count "$gateway_manifest" BackendTrafficPolicy llm-worker-grpc-streams "$gateway_namespace" 1 +assert_resource_field "$gateway_manifest" BackendTrafficPolicy llm-worker-grpc-streams "$gateway_namespace" \ + '.spec.targetRefs[0].group' 'gateway.networking.k8s.io' +assert_resource_field "$gateway_manifest" BackendTrafficPolicy llm-worker-grpc-streams "$gateway_namespace" \ + '.spec.targetRefs[0].kind' 'GRPCRoute' +assert_resource_field "$gateway_manifest" BackendTrafficPolicy llm-worker-grpc-streams "$gateway_namespace" \ + '.spec.targetRefs[0].name' 'llm-worker-grpc' +assert_resource_field "$gateway_manifest" BackendTrafficPolicy llm-worker-grpc-streams "$gateway_namespace" \ + '.spec.timeout.http.requestTimeout' '0s' assert_partial_backend_override_rejected() { local missing_key="$1" From 5713524afedad1a79c0d625a6bf17ac72af70d93 Mon Sep 17 00:00:00 2001 From: Mike Camp Date: Fri, 28 Aug 2026 11:59:45 -0400 Subject: [PATCH 9/9] docs(llm): distinguish secure gateway listener modes Signed-off-by: Mike Camp --- docs/user/gateway-routing.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/user/gateway-routing.md b/docs/user/gateway-routing.md index bd1e809ac..12ab4946b 100644 --- a/docs/user/gateway-routing.md +++ b/docs/user/gateway-routing.md @@ -496,8 +496,12 @@ To use a different Gateway API implementation instead of Envoy Gateway: 4. Create a `Gateway` with `http` (port 80), `tcp` (port 10081), and `nats` (port 4222) listeners. Add `worker-tcp` (port 10086) only when enabling - split or multi-cluster gRPC invocation. Add `llm-grpc` (TCP port 50071) and - `llm-quic` (UDP port 50072) when remote LLM workers use the Gateway. + split or multi-cluster gRPC invocation. When remote LLM workers use the + Gateway securely, add `llm-grpc` as an HTTPS listener on port 50071 with a + TLS certificate reference, plus `llm-quic` as a UDP listener on port 50072. + The rendered `GRPCRoute` attaches to the HTTPS listener. Use a TCP + `llm-grpc` listener only for the explicit plaintext development mode with + `llmRequestRouter.grpcTls.allowInsecureHttp: true`. 5. Update your install configuration to reference your Gateway: @@ -763,6 +767,7 @@ kubectl -n "$LLM_QUIC_GATEWAY_NAMESPACE" get udproute llm-worker-quic \ kubectl -n nvcf get referencegrant allow-llm-worker-routes -o yaml # cert-manager mode only kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get certificate llm-grpc-tls +# secure mode only (grpcTls.enabled: true) kubectl -n "$LLM_GRPC_GATEWAY_NAMESPACE" get backendtrafficpolicy \ llm-worker-grpc-streams \ -o jsonpath='{.spec.timeout.http.requestTimeout}{"\n"}' @@ -772,9 +777,11 @@ kubectl -n nvcf get service llm-request-router-backend-router \ Each route parent must report `Accepted=True` and `ResolvedRefs=True`. The Service type must remain `ClusterIP`, with TCP port `50071` and UDP port -`50072`. In cert-manager mode the dedicated certificate must be Ready, and -the request timeout must be `0s`. This is the Envoy Gateway v1.5-compatible -setting that disables the default 15-second timeout for streaming gRPC calls. +`50072`. In cert-manager mode the dedicated certificate must be Ready. In +secure mode the request timeout must be `0s`; plaintext mode does not render +the `BackendTrafficPolicy`. The zero timeout is the Envoy Gateway +v1.5-compatible setting that disables the default 15-second timeout for +streaming gRPC calls. Also wait for each referenced Gateway to report `Programmed=True` and confirm that its status contains an external address. Verify the public certificate and ALPN without disabling validation: