Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/build-images.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,39 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

build-frontend:
name: Build and push frontend image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_PREFIX }}/frontend
tags: |
type=sha
type=ref,event=branch
type=semver,pattern=v{{version}}
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push frontend image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: frontend/
file: frontend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
20 changes: 20 additions & 0 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ jobs:
version: v2.4.0
working-directory: key-manager

lint-frontend:
name: Lint frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: frontend/.node-version
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install deps
working-directory: frontend
run: npm ci

- name: Biome check
working-directory: frontend
run: npm run check

lint-helm:
name: Lint Helm chart
runs-on: ubuntu-latest
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,23 @@ jobs:
- name: Run tests
working-directory: key-manager
run: go test ./...

test-frontend:
name: Test frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: frontend/.node-version
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install deps
working-directory: frontend
run: npm ci

- name: Run tests
working-directory: frontend
run: npm test
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ You apply an `LLMModel` custom resource and the operator handles the rest: model

Each model gets per-model access control via OIDC groups (works with any OIDC provider, tested against Keycloak). Two auth endpoints are created per model: external access via API keys, and internal (in-cluster) access via JWT. Both paths go through Envoy AI Gateway for token counting and rate limiting.

An optional key manager web UI lets users generate and revoke API keys for models they have access to.
An optional key manager lets users generate and revoke API keys for models they have access to: a React single-page app (served by its own nginx image) backed by an API-only Go service, with SPA-managed Keycloak login.

Models can be loaded from HuggingFace (default) or mounted as OCI/modelcar images. Model downloads use a purpose-built [distroless container image](model-downloader/) with pixi-managed dependencies for reproducibility.

Expand Down Expand Up @@ -205,7 +205,9 @@ response = client.chat.completions.create(
| `defaults.epp.image` | Endpoint Picker (llm-d-inference-scheduler) image | `ghcr.io/llm-d/llm-d-inference-scheduler:v0.8.0` |
| `defaults.storage.storageClassName` | Default StorageClass for model PVCs (empty = cluster default) | `""` |
| `defaults.monitoring.enabled` | Enable PodMonitor for Prometheus scraping | `true` |
| `keyManager.enabled` | Deploy the key manager web UI | `true` |
| `keyManager.enabled` | Deploy the key manager API service | `true` |
| `frontend.enabled` | Deploy the React UI (nginx) frontend | `true` |
| `frontend.keycloak.url` | External Keycloak URL for SPA login (required when `frontend.enabled=true`) | `""` |

## Architecture

Expand All @@ -223,7 +225,9 @@ Admin applies LLMModel CR
|
Key Manager (optional)
|
+---> Web UI behind NebariApp (Keycloak/OIDC login)
+---> React SPA (nginx image) behind NebariApp; SPA-managed Keycloak PKCE login
+---> nginx serves the SPA + /config.json and proxies /api to the key-manager
+---> key-manager is API-only; validates the Keycloak bearer against JWKS in-process
+---> Generates API keys, writes to K8s Secrets
+---> Envoy Gateway validates keys natively
```
Expand All @@ -233,7 +237,8 @@ Admin applies LLMModel CR
| Image | Description |
|-------|-------------|
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/operator` | LLM operator - reconciles LLMModel CRDs |
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager web UI and API |
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager REST API |
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend` | LLM serving pack React UI (nginx) |
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/model-downloader` | Model download init container (distroless, pixi-managed) |

### Infrastructure requirements
Expand Down Expand Up @@ -263,8 +268,8 @@ make deploy
# Apply a test model
make apply-test-model

# Watch reconciliation
kubectl -n llm-serving get llmmodels -w
# Watch reconciliation (models live in the operator's namespace)
kubectl -n llm-operator-system get llmmodels -w

# Tail logs
make logs-operator
Expand All @@ -274,11 +279,25 @@ make logs-key-manager
make teardown
```

### Key manager UI

The key manager web UI is a [React](https://react.dev) + TypeScript app (Vite, Tailwind, shadcn/ui) in [`frontend/`](frontend/). In production it ships as its own nginx image (`ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend`) that serves the SPA and proxies `/api` to the API-only key-manager; it is not embedded in the Go binary. For a one-command dev loop that needs **no Keycloak**:

```bash
# One-time: copy dev/.env.example to dev/.env and set OPENROUTER_API_KEY
cd dev && make run-dev
```

This idempotently brings up the kind cluster, deploys the operator and a **dev-mode** key manager (auth bypassed, a fixed `dev` identity injected - see `LLM_DEV_MODE`), applies a few OpenRouter passthrough models so the list is populated, port-forwards the key-manager API to `localhost:8080`, and starts the Vite dev server at **http://localhost:5173** with hot reload. Edit files under `frontend/src/` and the browser updates live - there is no build step and no login. Press `Ctrl-C` to stop (the cluster is left running for the next run).

Requires [Node.js](https://nodejs.org) + npm and an [OpenRouter API key](https://openrouter.ai/keys). To exercise the real Keycloak login flow instead of the bypass, deploy Keycloak (`make deploy-keycloak && make pf-keycloak`) and run the UI without the dev flag (`cd frontend && npm run dev`).

Run tests directly:

```bash
cd operator && make test
cd key-manager && go test ./...
cd frontend && npm test
```

### Documentation site
Expand Down
117 changes: 117 additions & 0 deletions charts/nebari-llm-serving/templates/frontend-configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
{{- if and .Values.frontend.enabled .Values.keyManager.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "nebari-llm-serving.fullname" . }}-frontend-config
namespace: {{ include "nebari-llm-serving.operatorNamespace" . }}
labels:
{{- include "nebari-llm-serving.labels" . | nindent 4 }}
app.kubernetes.io/component: frontend
data:
# Served by nginx at /config.json. The SPA fetches this at startup so Keycloak
# settings change without rebuilding the image. clientId defaults to the
# nebari-operator SPA-client convention (<namespace>-<nebariapp-name>-spa); the
# NebariApp for the UI is named "<fullname>-key-manager", so the SPA client is
# "<operator-namespace>-<fullname>-key-manager-spa".
config.json: |
{
"keycloak": {
"url": {{ required "frontend.keycloak.url is required when frontend.enabled=true" .Values.frontend.keycloak.url | quote }},
"realm": {{ .Values.frontend.keycloak.realm | quote }},
"clientId": {{ default (printf "%s-%s-key-manager-spa" (include "nebari-llm-serving.operatorNamespace" .) (include "nebari-llm-serving.fullname" .)) .Values.frontend.keycloak.clientId | quote }}
}{{ with .Values.frontend.title }},
"title": {{ . | quote }}{{ end }}
}

# nginx configuration for the frontend pod.
#
# Traffic flow (production):
# Browser → Gateway → frontend Service (nginx:{{ .Values.frontend.port }})
# ├─ /api/* → proxy_pass to the key-manager ClusterIP (cluster DNS, never
# │ public); nginx forwards the Authorization: Bearer header as-is
# │ and the key-manager validates it against Keycloak JWKS.
# └─ /* → serve the SPA; keycloak-js handles OIDC PKCE in the browser.
nginx.conf: |
worker_processes auto;
pid /tmp/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;

sendfile on;
keepalive_timeout 65;

access_log /dev/stdout;
error_log /dev/stderr warn;

server {
listen {{ .Values.frontend.port }};
server_name _;

root /usr/share/nginx/html;
index index.html;

# Security headers. add_header does NOT inherit into a location that
# sets its own add_header, so these cover the SPA document (location
# /); the config.json block below repeats the ones that matter for it.
# The framing headers matter because the UI exposes one-click "revoke
# key" - deny embedding to block clickjacking.
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;

gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript
image/svg+xml;
gzip_min_length 1024;

# Proxy /api/* to the key-manager ClusterIP - never exposed publicly.
location /api/ {
proxy_pass http://{{ include "nebari-llm-serving.fullname" . }}-key-manager.{{ include "nebari-llm-serving.operatorNamespace" . }}.svc.cluster.local:8080/api/;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_http_version 1.1;
}

# Cache content-hashed static assets aggressively.
location ~* \.(js|css|png|svg|ico|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}

location /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}

# Runtime config: never cache, so Keycloak settings changes take
# effect on the next page load instead of being pinned by a stale copy.
location = /config.json {
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}

# SPA fallback.
location / {
try_files $uri $uri/ /index.html;
}
}
}
{{- end }}
102 changes: 102 additions & 0 deletions charts/nebari-llm-serving/templates/frontend-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{{- if and .Values.frontend.enabled .Values.keyManager.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "nebari-llm-serving.fullname" . }}-frontend
namespace: {{ include "nebari-llm-serving.operatorNamespace" . }}
labels:
{{- include "nebari-llm-serving.labels" . | nindent 4 }}
app.kubernetes.io/component: frontend
spec:
replicas: 1
selector:
matchLabels:
app: {{ include "nebari-llm-serving.fullname" . }}-frontend
template:
metadata:
annotations:
# Roll the pod when the rendered nginx.conf / config.json changes: a
# ConfigMap update alone does not restart its consumers, so without this a
# config-only `helm upgrade` would leave the old config live in the pod.
checksum/config: {{ include (print $.Template.BasePath "/frontend-configmap.yaml") . | sha256sum }}
labels:
app: {{ include "nebari-llm-serving.fullname" . }}-frontend
{{- include "nebari-llm-serving.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: frontend
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101 # nginx default non-root uid
seccompProfile:
type: RuntimeDefault
containers:
# nginx serves the React SPA and proxies /api/* to the key-manager
# ClusterIP. This is the service the NebariApp targets; auth is handled
# client-side by keycloak-js (PKCE/S256) and validated by the key-manager.
- name: frontend
image: {{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
imagePullPolicy: {{ .Values.frontend.image.pullPolicy | default "IfNotPresent" }}
ports:
- name: http
containerPort: {{ .Values.frontend.port }}
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
# nginx serves /healthz (a static 200) for both probes.
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
limits:
cpu: 200m
memory: 128Mi
requests:
cpu: 25m
memory: 32Mi
volumeMounts:
# nginx needs writable /tmp, /var/cache/nginx, and /var/run.
- name: nginx-tmp
mountPath: /tmp
- name: nginx-cache
mountPath: /var/cache/nginx
- name: nginx-run
mountPath: /var/run
# Helm-rendered nginx.conf (adds the /api/ proxy to the key-manager).
- name: frontend-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
# Helm-rendered config.json, overriding the placeholder baked into
# the image at build time.
- name: frontend-config
mountPath: /usr/share/nginx/html/config.json
subPath: config.json
readOnly: true
volumes:
- name: nginx-tmp
emptyDir: {}
- name: nginx-cache
emptyDir: {}
- name: nginx-run
emptyDir: {}
- name: frontend-config
configMap:
name: {{ include "nebari-llm-serving.fullname" . }}-frontend-config
terminationGracePeriodSeconds: 10
{{- end }}
Loading