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/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/.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 b5dbd9c..44eeee3 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -1,28 +1,58 @@ 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 + +# 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 @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) + # 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/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 + # 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 + # 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 $(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 + kubectl apply -f ../charts/nebari-llm-serving/crds/passthroughmodel-crd.yaml # Test Gateways kubectl apply -f gateways.yaml @@ -45,13 +75,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/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/manifests/key-manager.yaml b/dev/manifests/key-manager.yaml index 6fc4c96..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 @@ -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..3f3bc3c 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 @@ -120,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/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/dev/run-dev.sh b/dev/run-dev.sh new file mode 100755 index 0000000..d27ffac --- /dev/null +++ b/dev/run-dev.sh @@ -0,0 +1,97 @@ +#!/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 +# 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 + +# --- 4. port-forward + UI dev server -------------------------------------- +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" >"$PF_LOG" 2>&1 & +PF_PID=$! +for _ in $(seq 1 20); do + if grep -q "Forwarding from" "$PF_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/astro.config.mjs b/docs/astro.config.mjs index 53cf8aa..4ac5b38 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -25,6 +25,7 @@ export default defineConfig({ { label: 'Quickstart', slug: 'quickstart' }, { label: 'Installation', slug: 'installation' }, { label: 'Local Development', slug: 'local-development' }, + { label: 'UI Development', slug: 'ui-development' }, { label: 'Shared Storage', slug: 'shared-storage' }, { label: 'Troubleshooting', slug: 'troubleshooting' }, ], diff --git a/docs/src/content/docs/local-development.md b/docs/src/content/docs/local-development.md index 8d6d018..5ff535a 100644 --- a/docs/src/content/docs/local-development.md +++ b/docs/src/content/docs/local-development.md @@ -5,11 +5,14 @@ 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, -> use a real cluster as documented in the [Installation](/installation/) guide. +> 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 the [Installation](/installation/) +> guide. ## Prerequisites @@ -42,11 +45,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 @@ -161,7 +168,64 @@ 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. Tail logs +## 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. + +> **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](/ui-development/). + +## 11. Tail logs You can tail logs from either component while working: @@ -170,7 +234,7 @@ make logs-operator make logs-key-manager ``` -## 10. Run unit tests +## 12. Run unit tests Run the operator and key manager tests directly without a cluster: @@ -179,7 +243,7 @@ cd operator && make test cd key-manager && go test ./... ``` -## 11. Cleanup +## 13. Cleanup When you are done, delete the kind cluster: diff --git a/docs/src/content/docs/ui-development.md b/docs/src/content/docs/ui-development.md new file mode 100644 index 0000000..68ead8b --- /dev/null +++ b/docs/src/content/docs/ui-development.md @@ -0,0 +1,113 @@ +--- +title: 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 + +- [Local Development](/local-development/) - the full local dev path (operator, + passthrough models, inference). +- [Installation](/installation/) - real-cluster deployment with + Keycloak and the gateway. 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", 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{}) diff --git a/scripts/check-content-parity.mjs b/scripts/check-content-parity.mjs index ca63f28..9242fdd 100644 --- a/scripts/check-content-parity.mjs +++ b/scripts/check-content-parity.mjs @@ -10,18 +10,20 @@ const REF = process.env.HUGO_REF || 'origin/docs-site'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const docsDir = path.join(repoRoot, 'docs/src/content/docs'); -// Two files are intentionally excluded from this parity check: +// Three files are intentionally excluded from this parity check: // - cicd-and-releasing.md documents the Astro docs workflow (not byte-identical to // the Hugo source by design, user-approved). // - installation.md has its screenshot references rewritten from /public absolute // paths to Astro src/assets imports (PR #120 review), so its body diverges from // the frozen Hugo source by design. +// - local-development.md was updated by PR #115 to document the AI Gateway v0.5 +// local dev flow (real ext_proc, passthrough inference, key-manager UI dev mode), +// so its body intentionally diverges from the frozen Hugo source. // migrated file (in src/content/docs) -> hugo source basename (in docs/site/content) const MAP = { 'index.md': '_index.md', 'quickstart.md': 'quickstart.md', - 'local-development.md': 'local-development.md', 'shared-storage.md': 'shared-storage.md', 'troubleshooting.md': 'troubleshooting.md', 'configuration.md': 'configuration.md',