From 5272437716dcaf54a42e1eab78d1092e64a2b9d6 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:35:35 +0200 Subject: [PATCH 1/5] feat(dev): align local dev stack with AI Gateway v0.5 and add key-manager UI dev mode Bump the dev/Makefile dependency stack to versions compatible with the bundled Envoy AI Gateway v0.5.0 (Envoy Gateway v1.6.7, Gateway API v1.4.0) and wire the AI Gateway ext_proc extension into Envoy Gateway at install time. On the previous versions (EG v1.3.0) a PassthroughModel reconciled to Ready but its upstream TLS was never programmed, so provider inference returned 503. Extend the dev manifests with the PassthroughModel RBAC, validating webhook, and shared-TLS issuance via the local self-signed ClusterIssuer, plus Makefile targets and an example model for the OpenRouter passthrough. Add an off-by-default dev mode to the key-manager: LLM_DEV_MODE bypasses auth and injects a fixed identity so the UI runs on a local cluster with no Keycloak. Exposed via keyManager.devMode in the Helm chart and enabled in the dev manifest, with a `make ui` port-forward target. Refs #113, #114 --- .../templates/key-manager-deployment.yaml | 11 +++ charts/nebari-llm-serving/values.yaml | 10 +++ dev/Makefile | 56 +++++++++---- dev/eg-extension-values.yaml | 35 ++++++++ dev/manifests/key-manager.yaml | 10 +++ dev/manifests/operator.yaml | 35 +++++++- dev/manifests/passthrough-test-model.yaml | 33 ++++++++ dev/manifests/webhook.yaml | 14 ++++ docs/getting-started.md | 73 ++++++++++++++-- key-manager/cmd/main.go | 32 ++++++- key-manager/internal/api/middleware.go | 17 ++++ key-manager/internal/api/middleware_test.go | 84 +++++++++++++++++-- 12 files changed, 377 insertions(+), 33 deletions(-) create mode 100644 dev/eg-extension-values.yaml create mode 100644 dev/manifests/passthrough-test-model.yaml diff --git a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml index 619e940..645953e 100644 --- a/charts/nebari-llm-serving/templates/key-manager-deployment.yaml +++ b/charts/nebari-llm-serving/templates/key-manager-deployment.yaml @@ -48,6 +48,17 @@ spec: - name: LLM_OIDC_USERINFO_URL value: {{ .Values.keyManager.oidcUserinfoURL | quote }} {{- end }} + {{- if .Values.keyManager.devMode.enabled }} + # Dev mode: bypasses auth and injects a fixed identity so the UI + # works on a local cluster with no Keycloak. Never enable in a real + # deployment. See nebari-dev/llm-serving-pack#114. + - name: LLM_DEV_MODE + value: "true" + - name: LLM_DEV_USER + value: {{ .Values.keyManager.devMode.user | quote }} + - name: LLM_DEV_GROUPS + value: {{ .Values.keyManager.devMode.groups | join "," | quote }} + {{- end }} resources: limits: cpu: 200m diff --git a/charts/nebari-llm-serving/values.yaml b/charts/nebari-llm-serving/values.yaml index b7e596a..54890f1 100644 --- a/charts/nebari-llm-serving/values.yaml +++ b/charts/nebari-llm-serving/values.yaml @@ -62,6 +62,16 @@ envoyAIGateway: keyManager: enabled: true + # devMode bypasses the key-manager's auth and injects a fixed identity so the + # UI can run on a local cluster with no Keycloak / gateway OIDC layer. Off by + # default; never enable it in a real deployment. See + # nebari-dev/llm-serving-pack#114. Pair it with a direct port-forward to the + # key-manager Service so the gateway OIDC enforcement is bypassed too. + devMode: + enabled: false + user: dev + groups: + - llm image: repository: ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager # tag defaults to .Chart.AppVersion when empty so the chart version and diff --git a/dev/Makefile b/dev/Makefile index b5dbd9c..4c06339 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -1,6 +1,16 @@ CLUSTER_NAME ?= llm-serving-test -.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model logs-operator logs-key-manager clean help +# Dependency versions. These move together: Envoy AI Gateway v0.5.x requires +# Envoy Gateway v1.6.x and Gateway API v1.4.0 (see +# https://aigateway.envoyproxy.io/docs/compatibility/). Bump them as a set +# when upgrading the AI Gateway. +CERT_MANAGER_VERSION ?= v1.17.2 +GATEWAY_API_VERSION ?= v1.4.0 +GIE_VERSION ?= v1.4.0 +ENVOY_GATEWAY_VERSION ?= v1.6.7 +AI_GATEWAY_VERSION ?= v0.5.0 + +.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model apply-passthrough-model create-openrouter-secret ui logs-operator logs-key-manager clean help help: ## Show this help @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @@ -8,21 +18,26 @@ help: ## Show this help setup: ## Create kind cluster and install dependencies kind create cluster --name $(CLUSTER_NAME) # cert-manager - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/$(CERT_MANAGER_VERSION)/cert-manager.yaml kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=120s # Gateway API CRDs - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml - # GIE CRDs (v1.4.0, includes graduated k8s.io group) - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v1.4.0/manifests.yaml - # Envoy Gateway - helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.3.0 -n envoy-gateway-system --create-namespace - kubectl -n envoy-gateway-system rollout status deployment/envoy-gateway --timeout=120s - # Envoy AI Gateway - helm upgrade -i aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm --version v0.5.0 -n envoy-ai-gateway-system --create-namespace - helm upgrade -i aieg oci://docker.io/envoyproxy/ai-gateway-helm --version v0.5.0 -n envoy-ai-gateway-system + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml + # GIE CRDs (includes the graduated inference.networking.k8s.io group) + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/$(GIE_VERSION)/manifests.yaml + # Envoy AI Gateway first: envoy-gateway's extensionManager below points at + # the ai-gateway-controller XDS service, so bring it up before reconfiguring + # envoy-gateway to avoid noisy connection-refused logs during translation. + helm upgrade -i aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm --version $(AI_GATEWAY_VERSION) -n envoy-ai-gateway-system --create-namespace + helm upgrade -i aieg oci://docker.io/envoyproxy/ai-gateway-helm --version $(AI_GATEWAY_VERSION) -n envoy-ai-gateway-system kubectl -n envoy-ai-gateway-system wait --timeout=120s deployment/ai-gateway-controller --for=condition=Available - # LLMModel CRD + # Envoy Gateway, wired with the AI Gateway ext_proc extension (enableBackend, + # extensionManager, backendResources). Without this the per-model routing + # layer 404s and passthrough upstreams never get a TLS transport socket. + helm upgrade -i eg oci://docker.io/envoyproxy/gateway-helm --version $(ENVOY_GATEWAY_VERSION) -n envoy-gateway-system --create-namespace -f eg-extension-values.yaml + kubectl -n envoy-gateway-system rollout status deployment/envoy-gateway --timeout=120s + # CRDs (served LLMModels and external-provider PassthroughModels) kubectl apply -f ../charts/nebari-llm-serving/crds/llmmodel-crd.yaml + kubectl apply -f ../charts/nebari-llm-serving/crds/passthroughmodel-crd.yaml # Test Gateways kubectl apply -f gateways.yaml @@ -45,13 +60,26 @@ deploy-operator: ## Deploy operator kubectl apply -f manifests/webhook.yaml kubectl -n llm-operator-system rollout status deployment/llm-operator --timeout=60s -deploy-key-manager: ## Deploy key manager +deploy-key-manager: ## Deploy key manager (dev mode: auth bypassed, dev identity injected) kubectl apply -f manifests/key-manager.yaml kubectl -n llm-operator-system rollout status deployment/llm-key-manager --timeout=60s -apply-test-model: ## Apply test model +apply-test-model: ## Apply served test model (mock vLLM) kubectl apply -f manifests/test-model.yaml +create-openrouter-secret: ## Create the OpenRouter provider credential (OPENROUTER_API_KEY env var required) + @test -n "$(OPENROUTER_API_KEY)" || { echo "set OPENROUTER_API_KEY"; exit 1; } + kubectl -n llm-operator-system create secret generic openrouter-api-key \ + --from-literal=apiKey="$(OPENROUTER_API_KEY)" \ + --dry-run=client -o yaml | kubectl apply -f - + +apply-passthrough-model: ## Apply the OpenRouter PassthroughModel (run create-openrouter-secret first) + kubectl apply -f manifests/passthrough-test-model.yaml + +ui: ## Port-forward the key-manager UI to http://localhost:8080 (dev mode, no login) + @echo "key-manager UI at http://localhost:8080 (dev mode injects user 'dev')" + kubectl -n llm-operator-system port-forward svc/llm-key-manager 8080:8080 + logs-operator: ## Tail operator logs kubectl -n llm-operator-system logs -f deployment/llm-operator diff --git a/dev/eg-extension-values.yaml b/dev/eg-extension-values.yaml new file mode 100644 index 0000000..621af6b --- /dev/null +++ b/dev/eg-extension-values.yaml @@ -0,0 +1,35 @@ +# Envoy Gateway Helm values that wire in the Envoy AI Gateway ext_proc +# extension. Mirrors docs/install-production.md section 6.1, which sources +# these from envoyproxy/ai-gateway's manifests/envoy-gateway-values.yaml. +# +# - extensionApis.enableBackend lets HTTPRoutes reference InferencePool. +# - extensionManager points envoy-gateway at the AI Gateway controller's XDS +# extension server (port 1063) so it inserts the ext_proc filter. +# - backendResources lists the non-builtin backend kinds the extension handles. +config: + envoyGateway: + gateway: + controllerName: gateway.envoyproxy.io/gatewayclass-controller + extensionApis: + enableEnvoyPatchPolicy: true + enableBackend: true + extensionManager: + hooks: + xdsTranslator: + translation: + listener: { includeAll: true } + route: { includeAll: true } + cluster: { includeAll: true } + secret: { includeAll: true } + post: + - Translation + - Cluster + - Route + service: + fqdn: + hostname: ai-gateway-controller.envoy-ai-gateway-system.svc.cluster.local + port: 1063 + backendResources: + - group: inference.networking.k8s.io + kind: InferencePool + version: v1 diff --git a/dev/manifests/key-manager.yaml b/dev/manifests/key-manager.yaml index 6fc4c96..fd1e849 100644 --- a/dev/manifests/key-manager.yaml +++ b/dev/manifests/key-manager.yaml @@ -91,6 +91,16 @@ spec: value: "IdToken" - name: LLM_LISTEN_ADDR value: ":8080" + # Dev mode: there is no Keycloak or gateway OIDC layer on the kind + # cluster, so bypass auth and inject a fixed identity. The UI and + # /api/* then work behind a plain `make ui` port-forward. Never set + # this in a real deployment. See nebari-dev/llm-serving-pack#114. + - name: LLM_DEV_MODE + value: "true" + - name: LLM_DEV_USER + value: "dev" + - name: LLM_DEV_GROUPS + value: "llm" resources: limits: cpu: 200m diff --git a/dev/manifests/operator.yaml b/dev/manifests/operator.yaml index 899137f..7cbcc34 100644 --- a/dev/manifests/operator.yaml +++ b/dev/manifests/operator.yaml @@ -29,6 +29,15 @@ rules: - apiGroups: ["llm.nebari.dev"] resources: ["llmmodels/finalizers"] verbs: ["update"] + - apiGroups: ["llm.nebari.dev"] + resources: ["passthroughmodels"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["llm.nebari.dev"] + resources: ["passthroughmodels/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["llm.nebari.dev"] + resources: ["passthroughmodels/finalizers"] + verbs: ["update"] - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] @@ -48,10 +57,14 @@ rules: resources: ["inferencepools"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["aigateway.envoyproxy.io"] - resources: ["aigatewayroutes"] + resources: ["aigatewayroutes", "aiservicebackends", "backendsecuritypolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["gateway.envoyproxy.io"] - resources: ["securitypolicies"] + resources: ["securitypolicies", "backends"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # PassthroughModel provider TLS validation toward the external provider. + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["backendtlspolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["gateway.networking.k8s.io"] resources: ["referencegrants"] @@ -59,6 +72,15 @@ rules: - apiGroups: ["monitoring.coreos.com"] resources: ["podmonitors"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Shared-TLS reconciler: issues the cert covering llm/llm-internal via the + # selfsigned-issuer ClusterIssuer (see cert-manager-config.yaml) and patches + # HTTPS listeners onto the shared Gateways. + - apiGroups: ["cert-manager.io"] + resources: ["certificates"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get", "list", "watch", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -95,7 +117,7 @@ spec: imagePullPolicy: Never env: - name: LLM_BASE_DOMAIN - value: "llm.local" + value: "local" - name: LLM_EXTERNAL_GATEWAY_NAME value: "nebari-gateway" - name: LLM_EXTERNAL_GATEWAY_NAMESPACE @@ -104,8 +126,13 @@ spec: value: "nebari-internal-gateway" - name: LLM_INTERNAL_GATEWAY_NAMESPACE value: "envoy-gateway-system" + # Issue the shared llm.local / llm-internal.local cert via the + # local self-signed ClusterIssuer (cert-manager-config.yaml) so the + # operator can patch HTTPS listeners onto the gateways with no ACME. + - name: LLM_CLUSTER_ISSUER_NAME + value: "selfsigned-issuer" - name: LLM_OIDC_ISSUER_URL - value: "https://keycloak.llm.local/realms/nebari" + value: "https://keycloak.local/realms/nebari" - name: LLM_OIDC_GROUPS_CLAIM value: "groups" - name: ENABLE_WEBHOOKS diff --git a/dev/manifests/passthrough-test-model.yaml b/dev/manifests/passthrough-test-model.yaml new file mode 100644 index 0000000..42e59c0 --- /dev/null +++ b/dev/manifests/passthrough-test-model.yaml @@ -0,0 +1,33 @@ +# OpenRouter PassthroughModel for the local dev cluster. +# +# Prereq: the provider credential Secret. Create it with: +# make create-openrouter-secret OPENROUTER_API_KEY=sk-or-v1-... +# +# Once reconciled (kubectl -n llm-operator-system get passthroughmodel), reach +# it through the gateway. The external endpoint uses API-key auth, so inject a +# client key into the api-keys Secret to skip the key-manager: +# kubectl -n llm-operator-system patch secret openrouter-api-keys --type merge \ +# -p '{"stringData":{"localtester":"sk-localtest-abc123"}}' +# kubectl -n envoy-gateway-system port-forward svc/envoy-...-nebari-gateway 8443:443 & +# curl -k https://llm.local:8443/v1/chat/completions \ +# --resolve llm.local:8443:127.0.0.1 \ +# -H "Authorization: Bearer sk-localtest-abc123" -H "Content-Type: application/json" \ +# -d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' +apiVersion: llm.nebari.dev/v1alpha1 +kind: PassthroughModel +metadata: + name: openrouter + namespace: llm-operator-system # per #59 lives in the operator namespace +spec: + provider: + hostname: openrouter.ai + # OpenRouter serves the OpenAI API under /api/v1. + schemaVersion: api/v1 + credentialSecretName: openrouter-api-key + models: + catchAll: true + declared: + - openai/gpt-4o-mini + access: + groups: + - llm diff --git a/dev/manifests/webhook.yaml b/dev/manifests/webhook.yaml index 670f702..a86b80e 100644 --- a/dev/manifests/webhook.yaml +++ b/dev/manifests/webhook.yaml @@ -33,3 +33,17 @@ webhooks: operations: ["CREATE", "UPDATE"] resources: ["llmmodels"] sideEffects: None + - name: vpassthroughmodel-v1alpha1.kb.io + admissionReviewVersions: ["v1"] + clientConfig: + service: + name: llm-operator-webhook-service + namespace: llm-operator-system + path: /validate-llm-nebari-dev-v1alpha1-passthroughmodel + failurePolicy: Fail + rules: + - apiGroups: ["llm.nebari.dev"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE"] + resources: ["passthroughmodels"] + sideEffects: None diff --git a/docs/getting-started.md b/docs/getting-started.md index c2f8c59..2a436e4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,10 +4,12 @@ This guide walks through setting up a local development environment using [kind] > **Scope of this dev path.** The local `kind` setup exercises the > operator, key manager, CRD, webhook, and reconciler logic against a -> real Kubernetes API. It does **not** install Envoy AI Gateway or -> wire up the `ext_proc` filter that production uses for per-model -> dispatch, and it uses a mock vLLM instead of real model-serving -> pods. For end-to-end inference testing with the full routing layer, +> real Kubernetes API, with Envoy Gateway and the Envoy AI Gateway +> `ext_proc` extension wired up. Served `LLMModel`s use a mock vLLM +> instead of real model-serving pods, so there is no GPU inference. +> External-provider `PassthroughModel`s route to the real provider, so +> end-to-end inference works against, for example, OpenRouter (see +> "Test an external provider" below). For a full GPU serving deployment > use a real cluster as documented in > [`install-production.md`](install-production.md). @@ -41,11 +43,15 @@ This creates a kind cluster named `llm-serving-test` and installs: - cert-manager (for webhook TLS) - Gateway API CRDs - Gateway API Inference Extension CRDs -- Envoy Gateway - Envoy AI Gateway -- The `LLMModel` CRD +- Envoy Gateway, wired with the AI Gateway `ext_proc` extension (`dev/eg-extension-values.yaml`) +- The `LLMModel` and `PassthroughModel` CRDs - Test `GatewayClass` and `Gateway` resources +Dependency versions are pinned at the top of `dev/Makefile`. They move as a +set: Envoy AI Gateway v0.5.x requires Envoy Gateway v1.6.x and Gateway API +v1.4.0 (see the [compatibility matrix](https://aigateway.envoyproxy.io/docs/compatibility/)). + The setup takes a few minutes. You can watch progress in the terminal output. ## 3. Build and load images @@ -159,7 +165,60 @@ The response includes the generated key. Keys are stored as Kubernetes Secrets i kubectl -n llm-operator-system get secrets -l llm.nebari.dev/model ``` -## 9. Cleanup +## 9. Test an external provider (PassthroughModel) + +A `PassthroughModel` routes the shared endpoints to an external OpenAI-compatible +provider rather than a locally served model. This path runs end to end on kind +because the provider does the inference. These steps use OpenRouter and assume a +real OpenRouter API key. + +Create the provider credential and apply the example model: + +```bash +make create-openrouter-secret OPENROUTER_API_KEY=sk-or-v1-... +make apply-passthrough-model +kubectl -n llm-operator-system get passthroughmodel openrouter -w +``` + +Once it reports `Ready`, reach it through the gateway. The external endpoint +(`llm.local`) uses API-key auth, so inject a client key into the api-keys Secret +to skip the key-manager: + +```bash +kubectl -n llm-operator-system patch secret openrouter-api-keys --type merge \ + -p '{"stringData":{"localtester":"sk-localtest-abc123"}}' + +SVC=$(kubectl -n envoy-gateway-system get svc \ + -l gateway.envoyproxy.io/owning-gateway-name=nebari-gateway -o name | head -1) +kubectl -n envoy-gateway-system port-forward "$SVC" 8443:443 & + +curl -k https://llm.local:8443/v1/chat/completions \ + --resolve llm.local:8443:127.0.0.1 \ + -H "Authorization: Bearer sk-localtest-abc123" \ + -H "Content-Type: application/json" \ + -d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' +``` + +The internal endpoint (`llm-internal.local`) always requires a real Keycloak +JWT, even when access is public, so it is not reachable on a bare kind cluster. + +## 10. Open the key-manager UI (dev mode) + +The deployed key-manager runs in dev mode on kind: it bypasses auth and injects a +fixed identity (user `dev`, groups `["llm"]`), because there is no Keycloak or +gateway OIDC layer in front of it. Forward its port and open the UI: + +```bash +make ui # serves http://localhost:8080 +``` + +The UI loads without a login and can mint and revoke keys for any model the +`dev` identity's groups grant access to. Dev mode is controlled by +`LLM_DEV_MODE` on the key-manager Deployment (and `keyManager.devMode.enabled` +in the Helm chart); it is off by default and must never be enabled in a real +deployment. + +## 11. Cleanup When you are done, delete the kind cluster: diff --git a/key-manager/cmd/main.go b/key-manager/cmd/main.go index 2d0f136..5719447 100644 --- a/key-manager/cmd/main.go +++ b/key-manager/cmd/main.go @@ -35,6 +35,9 @@ func main() { auditIntervalStr := getEnvOrDefault("LLM_AUDIT_INTERVAL", "5m") oidcUserinfoURL := os.Getenv("LLM_OIDC_USERINFO_URL") // optional listenAddr := getEnvOrDefault("LLM_LISTEN_ADDR", ":8080") + devMode := strings.EqualFold(os.Getenv("LLM_DEV_MODE"), "true") + devUser := getEnvOrDefault("LLM_DEV_USER", "dev") + devGroups := splitAndTrim(getEnvOrDefault("LLM_DEV_GROUPS", "llm")) auditInterval, err := time.ParseDuration(auditIntervalStr) if err != nil { @@ -114,10 +117,22 @@ func main() { mux.Handle("GET /", http.FileServer(http.FS(staticFS))) // Apply auth middleware only to /api/ routes. - authMW := api.AuthMiddleware(api.AuthConfig{ + authConfig := api.AuthConfig{ GroupsClaim: groupsClaim, CookiePrefix: cookiePrefix, - }) + DevMode: devMode, + } + if devMode { + authConfig.DevIdentity = api.UserInfo{ + Username: devUser, + Name: devUser, + Email: devUser + "@local", + Groups: devGroups, + } + logger.Warn("LLM_DEV_MODE is enabled: auth is bypassed and a fixed identity is injected; never enable this in a real deployment", + "username", devUser, "groups", devGroups) + } + authMW := api.AuthMiddleware(authConfig) finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { @@ -160,6 +175,19 @@ func getEnvOrDefault(key, defaultVal string) string { return defaultVal } +// splitAndTrim splits a comma-separated list, trimming whitespace and dropping +// empty entries. Used for LLM_DEV_GROUPS. +func splitAndTrim(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if v := strings.TrimSpace(p); v != "" { + out = append(out, v) + } + } + return out +} + // makeUserinfoLookup returns a UserGroupsLookup that calls the OIDC userinfo endpoint. // For v0.1 this is a stub that returns an error so the auditor skips revocation // safely until token exchange is implemented. diff --git a/key-manager/internal/api/middleware.go b/key-manager/internal/api/middleware.go index a77ba6c..a123c97 100644 --- a/key-manager/internal/api/middleware.go +++ b/key-manager/internal/api/middleware.go @@ -35,6 +35,14 @@ type AuthConfig struct { // CookiePrefix is the prefix for the IdToken cookie (default: "IdToken"). // The middleware accepts any cookie whose name starts with this prefix. CookiePrefix string + // DevMode disables token extraction and injects DevIdentity into every + // request. It exists so the UI can run on a local cluster that has no + // Keycloak or gateway OIDC layer in front of it. It is off by default and + // must never be enabled in a real deployment. + DevMode bool + // DevIdentity is the user injected into the request context when DevMode + // is on. Ignored when DevMode is false. + DevIdentity UserInfo } // AuthMiddleware returns HTTP middleware that extracts user identity from JWT. @@ -52,6 +60,15 @@ func AuthMiddleware(cfg AuthConfig) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Dev mode: skip token handling entirely and inject a fixed + // identity so the UI works without an OIDC provider in front. + if cfg.DevMode { + devUser := cfg.DevIdentity + ctx := context.WithValue(r.Context(), userContextKey, &devUser) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + token, err := extractToken(r, cfg.CookiePrefix) if err != nil { http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized) diff --git a/key-manager/internal/api/middleware_test.go b/key-manager/internal/api/middleware_test.go index bd9de2f..79161fb 100644 --- a/key-manager/internal/api/middleware_test.go +++ b/key-manager/internal/api/middleware_test.go @@ -44,12 +44,12 @@ func handlerThatChecksUser(t *testing.T) http.Handler { func TestAuthMiddleware(t *testing.T) { tests := []struct { - name string - cfg AuthConfig - setupRequest func(r *http.Request) - wantStatus int - wantUsername string - wantGroups []string + name string + cfg AuthConfig + setupRequest func(r *http.Request) + wantStatus int + wantUsername string + wantGroups []string }{ { name: "bearer JWT in Authorization header extracts username and groups", @@ -242,6 +242,78 @@ func TestAuthMiddleware(t *testing.T) { } } +func TestAuthMiddlewareDevMode(t *testing.T) { + devIdentity := UserInfo{ + Username: "dev", + Name: "Dev User", + Email: "dev@local", + Groups: []string{"llm"}, + } + + tests := []struct { + name string + setupRequest func(r *http.Request) + wantUsername string + wantGroups []string + }{ + { + name: "no token injects the dev identity instead of returning 401", + setupRequest: func(r *http.Request) {}, + wantUsername: "dev", + wantGroups: []string{"llm"}, + }, + { + name: "a supplied bearer token is ignored in favor of the dev identity", + setupRequest: func(r *http.Request) { + token := makeTestJWT(map[string]interface{}{ + "preferred_username": "alice", + "groups": []string{"admins"}, + }) + r.Header.Set("Authorization", "Bearer "+token) + }, + wantUsername: "dev", + wantGroups: []string{"llm"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := AuthConfig{ + GroupsClaim: "groups", + CookiePrefix: "IdToken", + DevMode: true, + DevIdentity: devIdentity, + } + handler := AuthMiddleware(cfg)(handlerThatChecksUser(t)) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + tc.setupRequest(req) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Header().Get("X-Username"); got != tc.wantUsername { + t.Errorf("username: got %q, want %q", got, tc.wantUsername) + } + var gotGroups []string + if err := json.Unmarshal([]byte(rec.Header().Get("X-Groups")), &gotGroups); err != nil { + t.Fatalf("failed to parse groups header: %v", err) + } + if len(gotGroups) != len(tc.wantGroups) { + t.Fatalf("groups: got %v, want %v", gotGroups, tc.wantGroups) + } + for i := range gotGroups { + if gotGroups[i] != tc.wantGroups[i] { + t.Errorf("groups[%d]: got %q, want %q", i, gotGroups[i], tc.wantGroups[i]) + } + } + }) + } +} + func TestParseJWTExtractsNameAndEmail(t *testing.T) { token := makeTestJWT(map[string]interface{}{ "preferred_username": "chuck", From f6aeefa4a462cd1125a3faf76a81f42b03a03430 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:46:56 +0200 Subject: [PATCH 2/5] fix(operator): emit BackendTLSPolicy as v1 for the PassthroughModel upstream Gateway API v1.4.0 (required by the bundled Envoy AI Gateway v0.5) graduates BackendTLSPolicy to v1 and no longer serves v1alpha3, so the operator's hardcoded v1alpha3 failed to apply on a version-aligned stack ("no matches for kind BackendTLSPolicy in gateway.networking.k8s.io/v1alpha3") and the passthrough upstream never got a TLS transport socket. Emit v1, which is the same spec shape. Refs #113 --- operator/internal/controller/reconcilers/passthrough.go | 8 ++++---- .../internal/controller/reconcilers/passthrough_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/operator/internal/controller/reconcilers/passthrough.go b/operator/internal/controller/reconcilers/passthrough.go index 2176735..b6acf2c 100644 --- a/operator/internal/controller/reconcilers/passthrough.go +++ b/operator/internal/controller/reconcilers/passthrough.go @@ -166,10 +166,10 @@ func buildProviderBackend(pm *llmv1alpha1.PassthroughModel, labels map[string]st func buildProviderBackendTLSPolicy(pm *llmv1alpha1.PassthroughModel, labels map[string]string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]interface{}{ - // v1alpha3 rather than v1: Envoy Gateway v1.3 (the minimum the - // pack supports) predates the v1 BackendTLSPolicy API. Bump - // alongside the minimum Envoy Gateway version. - "apiVersion": "gateway.networking.k8s.io/v1alpha3", + // BackendTLSPolicy is GA as v1 in the Gateway API Standard channel + // (v1.4.0+), which the bundled Envoy AI Gateway requires. Gateway + // API v1.4.0 no longer serves the old v1alpha3 version. + "apiVersion": "gateway.networking.k8s.io/v1", "kind": "BackendTLSPolicy", "metadata": map[string]interface{}{ "name": pm.Name + "-backend-tls", diff --git a/operator/internal/controller/reconcilers/passthrough_test.go b/operator/internal/controller/reconcilers/passthrough_test.go index 84caa16..45a8f27 100644 --- a/operator/internal/controller/reconcilers/passthrough_test.go +++ b/operator/internal/controller/reconcilers/passthrough_test.go @@ -118,7 +118,7 @@ func TestBuildPassthroughResourcesProviderPlumbing(t *testing.T) { if p.GetName() != "openrouter-backend-tls" { t.Errorf("tls policy name = %q", p.GetName()) } - if p.GetAPIVersion() != "gateway.networking.k8s.io/v1alpha3" || p.GetKind() != "BackendTLSPolicy" { + if p.GetAPIVersion() != "gateway.networking.k8s.io/v1" || p.GetKind() != "BackendTLSPolicy" { t.Errorf("tls policy GVK = %s/%s", p.GetAPIVersion(), p.GetKind()) } validation, _ := specMap(t, p)["validation"].(map[string]interface{}) From 6f4ffe3a6ea28c238130f748762e6d7d8037c968 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:05:24 +0200 Subject: [PATCH 3/5] fix(dev): grant key-manager RBAC to list passthroughmodels The key-manager watches PassthroughModels as well as LLMModels, but the dev manifest's llm-key-manager-models ClusterRole only granted llmmodels, so model sync failed ("cannot list passthroughmodels") and passthrough models never appeared in the UI. Matches the chart's key-manager role. Refs #114 --- dev/manifests/key-manager.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/manifests/key-manager.yaml b/dev/manifests/key-manager.yaml index fd1e849..2852fb3 100644 --- a/dev/manifests/key-manager.yaml +++ b/dev/manifests/key-manager.yaml @@ -11,7 +11,7 @@ metadata: name: llm-key-manager-models rules: - apiGroups: ["llm.nebari.dev"] - resources: ["llmmodels"] + resources: ["llmmodels", "passthroughmodels"] verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 From 7367fb50b046371f4e6ad5f184d4012e770a8da9 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:49:24 +0200 Subject: [PATCH 4/5] feat(dev): add `make run-dev` one-command UI dev environment with hot reload Frontend devs working on the key-manager UI now need only an OpenRouter key in dev/.env and `make run-dev`. The target idempotently brings up the kind cluster, operator, dev-mode key-manager, and three OpenRouter passthrough models, then port-forwards the key-manager and starts a hot-reloading UI dev server. - dev/uidev: a zero-dependency (stdlib-only) Go dev server that serves the UI static files from disk, proxies /api/* to the port-forwarded key-manager, and live-reloads the browser on file edits. The UI is plain static files, so no build step or npm is involved. - dev/run-dev.sh + `make run-dev`: orchestrates cluster/deploy/models/port-forward /UI server, loading OPENROUTER_API_KEY from a gitignored dev/.env. - dev/manifests/dev-models.yaml: three passthrough models so the UI list is populated. - docs/ui-development.md: frontend-dev guide (setup, editing, dev-mode auth, shipping changes, API table, troubleshooting), linked from getting-started. Refs #114 --- .gitignore | 3 + dev/.env.example | 5 ++ dev/Makefile | 5 +- dev/manifests/dev-models.yaml | 60 +++++++++++++ dev/run-dev.sh | 80 +++++++++++++++++ dev/uidev/go.mod | 3 + dev/uidev/main.go | 162 ++++++++++++++++++++++++++++++++++ docs/getting-started.md | 4 + docs/ui-development.md | 112 +++++++++++++++++++++++ 9 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 dev/.env.example create mode 100644 dev/manifests/dev-models.yaml create mode 100755 dev/run-dev.sh create mode 100644 dev/uidev/go.mod create mode 100644 dev/uidev/main.go create mode 100644 docs/ui-development.md diff --git a/.gitignore b/.gitignore index 16182a2..b6cf3dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ CLAUDE.md docs/superpowers/ .claude/ + +# Local dev secrets (OPENROUTER_API_KEY, etc.) +.env diff --git a/dev/.env.example b/dev/.env.example new file mode 100644 index 0000000..cc02522 --- /dev/null +++ b/dev/.env.example @@ -0,0 +1,5 @@ +# Copy to dev/.env and fill in. dev/.env is gitignored. +# +# OpenRouter API key (https://openrouter.ai/keys). Used as the upstream +# provider credential for the PassthroughModels that `make run-dev` deploys. +OPENROUTER_API_KEY= diff --git a/dev/Makefile b/dev/Makefile index 4c06339..7875d60 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -10,11 +10,14 @@ GIE_VERSION ?= v1.4.0 ENVOY_GATEWAY_VERSION ?= v1.6.7 AI_GATEWAY_VERSION ?= v0.5.0 -.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model apply-passthrough-model create-openrouter-secret ui logs-operator logs-key-manager clean help +.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui logs-operator logs-key-manager clean help help: ## Show this help @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) +run-dev: ## One-command UI dev environment: cluster + models + port-forward + hot-reload UI server (needs dev/.env with OPENROUTER_API_KEY) + ./run-dev.sh + setup: ## Create kind cluster and install dependencies kind create cluster --name $(CLUSTER_NAME) # cert-manager diff --git a/dev/manifests/dev-models.yaml b/dev/manifests/dev-models.yaml new file mode 100644 index 0000000..0df40d8 --- /dev/null +++ b/dev/manifests/dev-models.yaml @@ -0,0 +1,60 @@ +# Three OpenRouter passthrough models for the local dev environment, applied by +# `make run-dev` so the key-manager UI has a populated model list to work with. +# Each is its own PassthroughModel so the UI lists three distinct models. +# +# Note: API keys are currently gateway-scoped, not model-scoped (see +# nebari-dev/llm-serving-pack#116), so a key minted for one of these works for +# all of them. That does not affect UI development. +apiVersion: llm.nebari.dev/v1alpha1 +kind: PassthroughModel +metadata: + name: claude-sonnet-45 + namespace: llm-operator-system +spec: + provider: + hostname: openrouter.ai + schemaVersion: api/v1 + credentialSecretName: openrouter-api-key + models: + catchAll: false + declared: + - anthropic/claude-sonnet-4.5 + access: + groups: + - llm +--- +apiVersion: llm.nebari.dev/v1alpha1 +kind: PassthroughModel +metadata: + name: gemini-25-flash + namespace: llm-operator-system +spec: + provider: + hostname: openrouter.ai + schemaVersion: api/v1 + credentialSecretName: openrouter-api-key + models: + catchAll: false + declared: + - google/gemini-2.5-flash + access: + groups: + - llm +--- +apiVersion: llm.nebari.dev/v1alpha1 +kind: PassthroughModel +metadata: + name: llama-33-70b + namespace: llm-operator-system +spec: + provider: + hostname: openrouter.ai + schemaVersion: api/v1 + credentialSecretName: openrouter-api-key + models: + catchAll: false + declared: + - meta-llama/llama-3.3-70b-instruct + access: + groups: + - llm diff --git a/dev/run-dev.sh b/dev/run-dev.sh new file mode 100755 index 0000000..9b9881e --- /dev/null +++ b/dev/run-dev.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# One-command local dev environment for the key-manager UI. +# +# Brings up (idempotently): a kind cluster with all dependencies, the operator, +# the dev-mode key-manager, and three OpenRouter passthrough models. Then it +# port-forwards the key-manager and starts a hot-reloading UI dev server. +# +# Only prerequisite: dev/.env with OPENROUTER_API_KEY set (see .env.example). +set -euo pipefail + +cd "$(dirname "$0")" + +CLUSTER_NAME="${CLUSTER_NAME:-llm-serving-test}" +NS=llm-operator-system +KM_PORT="${KM_PORT:-8080}" +UI_PORT="${UI_PORT:-5173}" + +# --- load .env ------------------------------------------------------------- +if [[ -f .env ]]; then + set -a; . ./.env; set +a +fi +if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then + echo "ERROR: OPENROUTER_API_KEY is not set. Copy dev/.env.example to dev/.env and fill it in." >&2 + exit 1 +fi + +# --- 1. cluster + dependencies -------------------------------------------- +if ! kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then + echo "==> kind cluster '$CLUSTER_NAME' not found; running full setup (a few minutes)..." + make setup +fi + +# --- 2. operator + key-manager -------------------------------------------- +if ! kubectl -n "$NS" get deploy/llm-key-manager >/dev/null 2>&1; then + echo "==> building images and deploying operator + key-manager..." + make build-images + make load-images + make deploy +else + echo "==> operator + key-manager already deployed." +fi + +# --- 3. provider credential + models -------------------------------------- +echo "==> applying OpenRouter credential and dev models..." +kubectl -n "$NS" create secret generic openrouter-api-key \ + --from-literal=apiKey="$OPENROUTER_API_KEY" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null +kubectl apply -f manifests/dev-models.yaml >/dev/null +for m in claude-sonnet-45 gemini-25-flash llama-33-70b; do + kubectl -n "$NS" wait passthroughmodel/$m --for=jsonpath='{.status.phase}'=Ready --timeout=90s +done + +# --- 4. port-forward + UI dev server -------------------------------------- +cleanup() { + echo + echo "==> shutting down..." + [[ -n "${PF_PID:-}" ]] && kill "$PF_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "==> port-forwarding key-manager to localhost:${KM_PORT}..." +kubectl -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >/tmp/km-portforward.log 2>&1 & +PF_PID=$! +for _ in $(seq 1 20); do + if grep -q "Forwarding from" /tmp/km-portforward.log 2>/dev/null; then break; fi + sleep 0.5 +done + +echo +echo " key-manager (embedded UI + API): http://localhost:${KM_PORT}" +echo " hot-reload UI dev server: http://localhost:${UI_PORT} <-- develop here" +echo " edit key-manager/internal/ui/static/* and the browser reloads automatically." +echo " Ctrl-C to stop." +echo + +# Foreground: exits on Ctrl-C, which triggers cleanup of the port-forward. +( cd uidev && go run . \ + -static ../../key-manager/internal/ui/static \ + -api "http://localhost:${KM_PORT}" \ + -addr ":${UI_PORT}" ) diff --git a/dev/uidev/go.mod b/dev/uidev/go.mod new file mode 100644 index 0000000..ad077ce --- /dev/null +++ b/dev/uidev/go.mod @@ -0,0 +1,3 @@ +module uidev + +go 1.25 diff --git a/dev/uidev/main.go b/dev/uidev/main.go new file mode 100644 index 0000000..8ab9ce1 --- /dev/null +++ b/dev/uidev/main.go @@ -0,0 +1,162 @@ +// Command uidev is a zero-dependency dev server for the key-manager UI. +// +// It serves the UI's static files from disk (so edits show up immediately, +// unlike the embedded copy baked into the key-manager image), proxies /api/* +// to the running key-manager (normally a kubectl port-forward on :8080), and +// live-reloads the browser when any static file changes. Frontend devs edit +// key-manager/internal/ui/static/* and just refresh-free reload. +// +// Run via `make run-dev`, or directly: +// +// go run . -static ../../key-manager/internal/ui/static -api http://localhost:8080 -addr :5173 +package main + +import ( + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +func main() { + staticDir := flag.String("static", "../../key-manager/internal/ui/static", "path to the UI static files") + apiURL := flag.String("api", "http://localhost:8080", "key-manager API base URL to proxy /api/* to") + addr := flag.String("addr", ":5173", "address to listen on") + flag.Parse() + + absStatic, err := filepath.Abs(*staticDir) + if err != nil || !dirExists(absStatic) { + log.Fatalf("static dir not found: %s", *staticDir) + } + api, err := url.Parse(*apiURL) + if err != nil { + log.Fatalf("invalid -api URL %q: %v", *apiURL, err) + } + + // Proxy /api/* to the key-manager (the dev-mode instance bypasses auth). + proxy := httputil.NewSingleHostReverseProxy(api) + + mux := http.NewServeMux() + mux.Handle("/api/", proxy) + mux.HandleFunc("/__livereload", liveReloadHandler(absStatic)) + mux.HandleFunc("/", staticHandler(absStatic)) + + fmt.Printf("UI dev server: http://localhost%s (hot reload)\n", normalizeAddr(*addr)) + fmt.Printf(" serving: %s\n", absStatic) + fmt.Printf(" /api/* proxied: %s\n", api) + if err := http.ListenAndServe(*addr, mux); err != nil { + log.Fatal(err) + } +} + +// staticHandler serves files from dir. For HTML responses it injects a small +// live-reload script before so edits trigger a browser reload. +func staticHandler(dir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + clean := filepath.Clean(r.URL.Path) + if clean == "/" || clean == "." { + clean = "/index.html" + } + full := filepath.Join(dir, clean) + // Keep the response inside the static dir. + if !strings.HasPrefix(full, dir) { + http.NotFound(w, r) + return + } + if strings.HasSuffix(clean, ".html") { + b, err := os.ReadFile(full) + if err != nil { + http.NotFound(w, r) + return + } + body := injectLiveReload(string(b)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(body)) + return + } + w.Header().Set("Cache-Control", "no-store") + http.ServeFile(w, r, full) + } +} + +const liveReloadScript = `` + +func injectLiveReload(html string) string { + if i := strings.LastIndex(html, ""); i >= 0 { + return html[:i] + liveReloadScript + html[i:] + } + return html + liveReloadScript +} + +// liveReloadHandler streams a reload event whenever the static dir changes. +// It polls a cheap fingerprint (file count + sizes + max mtime) so it needs no +// third-party file-watching dependency. +func liveReloadHandler(dir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + last := fingerprint(dir) + ticker := time.NewTicker(400 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + if fp := fingerprint(dir); fp != last { + last = fp + fmt.Fprint(w, "data: reload\n\n") + flusher.Flush() + } + } + } + } +} + +// fingerprint returns a string that changes whenever a file under dir is added, +// removed, resized, or modified. +func fingerprint(dir string) string { + var b strings.Builder + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, err := d.Info(); err == nil { + fmt.Fprintf(&b, "%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano()) + } + return nil + }) + return b.String() +} + +func dirExists(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + +func normalizeAddr(addr string) string { + if strings.HasPrefix(addr, ":") { + return addr + } + return ":" + addr +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 2a436e4..8393bc7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -218,6 +218,10 @@ The UI loads without a login and can mint and revoke keys for any model the in the Helm chart); it is off by default and must never be enabled in a real deployment. +> **Working on the UI itself?** Use `make run-dev` instead of the steps above: +> one command brings up the cluster, three models, the port-forward, and a +> hot-reloading dev server. See [ui-development.md](ui-development.md). + ## 11. Cleanup When you are done, delete the kind cluster: diff --git a/docs/ui-development.md b/docs/ui-development.md new file mode 100644 index 0000000..330a098 --- /dev/null +++ b/docs/ui-development.md @@ -0,0 +1,112 @@ +# Key-manager UI development + +A one-command local environment for working on the key-manager web UI, with a +populated model list and hot reload. You do not need to understand the operator, +the gateway, or Kubernetes to use it. + +## What you get + +`make run-dev` brings up a local [kind](https://kind.sigs.k8s.io/) cluster with +the operator, the key-manager (in dev mode, so no Keycloak login), and three +OpenRouter passthrough models, then: + +- port-forwards the key-manager to `http://localhost:8080` (its embedded UI + API), and +- starts a hot-reload dev server at `http://localhost:5173` that serves the UI + from your working copy and reloads the browser when you edit it. + +You develop against `http://localhost:5173`. + +## Prerequisites + +- Docker, [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation), kubectl, helm, and Go 1.25+. +- An [OpenRouter API key](https://openrouter.ai/keys). + +## Setup + +```bash +cd dev +cp .env.example .env +# edit .env and set OPENROUTER_API_KEY=sk-or-v1-... +make run-dev +``` + +First run creates the cluster and builds images, so it takes a few minutes. +Later runs reuse the cluster and start in seconds. When it is ready you will see: + +``` +key-manager (embedded UI + API): http://localhost:8080 +hot-reload UI dev server: http://localhost:5173 <-- develop here +``` + +Open `http://localhost:5173`. You are signed in automatically as user `dev` +(group `llm`), and the model list shows `claude-sonnet-45`, `gemini-25-flash`, +and `llama-33-70b`. Mint and revoke keys against them as a real user would. + +Press Ctrl-C to stop; the port-forward is cleaned up for you. The cluster keeps +running for the next `make run-dev`. + +## Editing the UI + +The UI is plain static files (no build step): + +``` +key-manager/internal/ui/static/ + index.html + app.js + style.css +``` + +Edit any of them and the browser at `:5173` reloads automatically. The dev +server proxies `/api/*` to the port-forwarded key-manager, so the UI talks to a +real backend (real models, real key minting) while you iterate on markup, styles, +and JavaScript. + +### Auth in dev mode + +The key-manager normally sits behind Keycloak and the gateway's OIDC layer. On +this cluster it runs with `LLM_DEV_MODE=true`, which bypasses auth and injects a +fixed identity (`dev`, group `llm`). So `/api/me` returns that identity and every +`/api/*` call works without a token. Never enable dev mode in a real deployment. + +## Shipping a change + +The static files are compiled into the key-manager binary with `go:embed`, so +committing your edits is all that is needed for them to ship in the next image +build. To see your changes in the actual in-cluster pod (rather than the dev +server), rebuild and reload: + +```bash +make build-images && make load-images +kubectl -n llm-operator-system rollout restart deployment/llm-key-manager +``` + +## API reference (what the UI calls) + +All under `/api`, all gated by auth (bypassed in dev mode): + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/me` | Current user identity (username, groups). | +| GET | `/api/models` | Models the user may mint keys for. | +| GET | `/api/keys` | The user's existing keys. | +| POST | `/api/keys` | Mint a key. Body: `{"modelName": ""}`. | +| DELETE | `/api/keys/{namespace}/{model}/{clientID}` | Revoke a key. | + +## Troubleshooting + +- **`SSL_ERROR_RX_RECORD_TOO_LONG` / "Secure Connection Failed"** - the UI is + plain HTTP. Use `http://localhost:5173` (or `:8080`), not `https://`. If your + browser force-upgrades localhost to HTTPS, use a private window or disable + HTTPS-Only mode for the site. +- **Model list is empty** - the key-manager resyncs models every 30s; give it a + moment after `make run-dev`, or check `kubectl -n llm-operator-system get passthroughmodel`. +- **`make run-dev` says `OPENROUTER_API_KEY is not set`** - create `dev/.env` + from `dev/.env.example` and set the key. +- **Start over** - `make teardown` deletes the cluster; `make run-dev` rebuilds it. + +## Related + +- [getting-started.md](getting-started.md) - the full local dev path (operator, + passthrough models, inference). +- [install-production.md](install-production.md) - real-cluster deployment with + Keycloak and the gateway. From 79bf2d0aef7132d164cd011766b37a0cea890de0 Mon Sep 17 00:00:00 2001 From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:10:55 +0200 Subject: [PATCH 5/5] fix(dev): harden local dev stack for Helm 4 and the webhook startup race Addresses review feedback on the local dev path: - Gateway API CRDs: apply with --server-side and pass --force-conflicts to the eg chart install, gated on Helm major version (Helm 4 needs it; Helm 3 has no server-side apply, skips present crds/, and rejects the flag). Fixes the CRD ownership conflict that blocked `make setup` on Helm 4. - make setup: guard `kind create cluster` so a partial setup is re-runnable. - operator.yaml: add a readinessProbe on the webhook port (9443) so the `make deploy` rollout waits until the webhook is serving. - run-dev.sh: bounded retry around the webhook-gated PassthroughModel apply (covers the residual endpoint-propagation window), and a per-run mktemp file for the port-forward log. --- dev/Makefile | 20 ++++++++++++++++---- dev/manifests/operator.yaml | 10 ++++++++++ dev/run-dev.sh | 23 ++++++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/dev/Makefile b/dev/Makefile index 7875d60..44eeee3 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -10,6 +10,14 @@ GIE_VERSION ?= v1.4.0 ENVOY_GATEWAY_VERSION ?= v1.6.7 AI_GATEWAY_VERSION ?= v0.5.0 +# Helm 4 applies the eg chart's bundled Gateway API CRDs (crds/) server-side, +# which conflicts with the standalone install in `setup` (owned by a different +# field manager). Force ownership so the chart wins. Helm 3 has no server-side +# apply and skips already-present crds/, so it neither needs nor accepts this +# flag - keep it empty there. Empty when helm is missing (setup fails later). +HELM_MAJOR := $(shell helm version --template '{{.Version}}' 2>/dev/null | sed -E 's/^v?([0-9]+).*/\1/') +HELM_FORCE_CONFLICTS := $(if $(filter-out 1 2 3,$(HELM_MAJOR)),--force-conflicts,) + .PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui logs-operator logs-key-manager clean help help: ## Show this help @@ -19,12 +27,16 @@ run-dev: ## One-command UI dev environment: cluster + models + port-forward + ho ./run-dev.sh setup: ## Create kind cluster and install dependencies - kind create cluster --name $(CLUSTER_NAME) + # Idempotent: skip creation if the cluster already exists so a setup that + # died midway can be re-run without `make teardown` first. + @kind get clusters | grep -qx $(CLUSTER_NAME) || kind create cluster --name $(CLUSTER_NAME) # cert-manager kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/$(CERT_MANAGER_VERSION)/cert-manager.yaml kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=120s - # Gateway API CRDs - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml + # Gateway API CRDs. Server-side apply so the field ownership matches the + # eg chart's own SSA on Helm 4, letting it cleanly take co-ownership with + # --force-conflicts (see HELM_FORCE_CONFLICTS above) instead of erroring. + kubectl apply --server-side --force-conflicts -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml # GIE CRDs (includes the graduated inference.networking.k8s.io group) kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/$(GIE_VERSION)/manifests.yaml # Envoy AI Gateway first: envoy-gateway's extensionManager below points at @@ -36,7 +48,7 @@ setup: ## Create kind cluster and install dependencies # Envoy Gateway, wired with the AI Gateway ext_proc extension (enableBackend, # extensionManager, backendResources). Without this the per-model routing # layer 404s and passthrough upstreams never get a TLS transport socket. - helm upgrade -i eg oci://docker.io/envoyproxy/gateway-helm --version $(ENVOY_GATEWAY_VERSION) -n envoy-gateway-system --create-namespace -f eg-extension-values.yaml + helm upgrade -i eg oci://docker.io/envoyproxy/gateway-helm --version $(ENVOY_GATEWAY_VERSION) -n envoy-gateway-system --create-namespace $(HELM_FORCE_CONFLICTS) -f eg-extension-values.yaml kubectl -n envoy-gateway-system rollout status deployment/envoy-gateway --timeout=120s # CRDs (served LLMModels and external-provider PassthroughModels) kubectl apply -f ../charts/nebari-llm-serving/crds/llmmodel-crd.yaml diff --git a/dev/manifests/operator.yaml b/dev/manifests/operator.yaml index 7cbcc34..3f3bc3c 100644 --- a/dev/manifests/operator.yaml +++ b/dev/manifests/operator.yaml @@ -147,6 +147,16 @@ spec: - containerPort: 9443 name: webhook-server protocol: TCP + # The validating webhook binds 9443 only after controller-runtime + # loads the mounted serving cert. Gate readiness on that port so + # `kubectl rollout status` in `make deploy` does not return before the + # webhook can accept PassthroughModel creates (the manager's /readyz + # is only a ping and goes green before the webhook is serving). + readinessProbe: + tcpSocket: + port: 9443 + initialDelaySeconds: 2 + periodSeconds: 2 volumeMounts: - name: cert mountPath: /tmp/k8s-webhook-server/serving-certs diff --git a/dev/run-dev.sh b/dev/run-dev.sh index 9b9881e..d27ffac 100755 --- a/dev/run-dev.sh +++ b/dev/run-dev.sh @@ -45,7 +45,20 @@ echo "==> applying OpenRouter credential and dev models..." kubectl -n "$NS" create secret generic openrouter-api-key \ --from-literal=apiKey="$OPENROUTER_API_KEY" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null -kubectl apply -f manifests/dev-models.yaml >/dev/null +# The operator's validating webhook gates PassthroughModel creates and isn't +# guaranteed to be serving the instant `make deploy`'s rollout returns (it waits +# on the cert mount). Retry so a momentary "connection refused" doesn't trip +# `set -e` and abort the whole run. +for attempt in $(seq 1 30); do + kubectl apply -f manifests/dev-models.yaml >/dev/null 2>&1 && break + if [[ $attempt -eq 30 ]]; then + echo "ERROR: operator webhook never became ready" >&2 + kubectl apply -f manifests/dev-models.yaml >&2 || true + exit 1 + fi + echo "==> operator webhook not ready yet, retrying ($attempt)..." + sleep 2 +done for m in claude-sonnet-45 gemini-25-flash llama-33-70b; do kubectl -n "$NS" wait passthroughmodel/$m --for=jsonpath='{.status.phase}'=Ready --timeout=90s done @@ -55,14 +68,18 @@ cleanup() { echo echo "==> shutting down..." [[ -n "${PF_PID:-}" ]] && kill "$PF_PID" 2>/dev/null || true + [[ -n "${PF_LOG:-}" ]] && rm -f "$PF_LOG" } trap cleanup EXIT INT TERM +# Fresh temp file per run: a fixed path could carry "Forwarding from" from a +# crashed prior run and make the readiness check below pass instantly. +PF_LOG="$(mktemp)" echo "==> port-forwarding key-manager to localhost:${KM_PORT}..." -kubectl -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >/tmp/km-portforward.log 2>&1 & +kubectl -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >"$PF_LOG" 2>&1 & PF_PID=$! for _ in $(seq 1 20); do - if grep -q "Forwarding from" /tmp/km-portforward.log 2>/dev/null; then break; fi + if grep -q "Forwarding from" "$PF_LOG" 2>/dev/null; then break; fi sleep 0.5 done