@r8s/open-webui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx DELETED
@@ -1,426 +0,0 @@
1
- import { jsx, Fragment, useContext, declareOperator } from '@r8s/core'
2
- import { OperatorContext, SecretContext } from '@r8s/core/defaults'
3
- import { operators } from '@r8s/crds'
4
- import { Database, Endpoint } from '@r8s/recipes'
5
- import type { SecretRef } from '@r8s/recipes'
6
- import { RedisReplicationComponent } from '@r8s/crds/redis'
7
- import { Deployment, Service, EnvVar, PersistentVolumeClaim } from '@r8s/k8s-types'
8
-
9
- export interface OpenWebuiProps {
10
- /** Resource name (defaults to 'open-webui') */
11
- name?: string
12
- /** Kubernetes namespace (defaults to 'default') */
13
- namespace?: string
14
- /** Container image tag (defaults to 'latest' — pin a version in production) */
15
- version?: string
16
- /** Public hostname for the chat UI (required) */
17
- host: string
18
- /**
19
- * Number of replicas (defaults to 1). Multiple replicas need a shared
20
- * object store for uploads/RAG (this recipe errors when `storage` is
21
- * set with replicas > 1 — its PVC is ReadWriteOnce) and Redis-backed
22
- * websocket coordination (WEBSOCKET_MANAGER=redis + REDIS_URL).
23
- */
24
- replicas?: number
25
- /**
26
- * PVC size for uploads and RAG document storage (e.g. '10Gi'). When set,
27
- * a `${name}-uploads` PersistentVolumeClaim is rendered and mounted at
28
- * /app/backend/data. WebService cannot express volume mounts, which is
29
- * why this component composes a raw Deployment (probes /health:8080).
30
- * The PVC is ReadWriteOnce — combining this with replicas > 1 throws
31
- * (single-node attach); multi-replica installs must move files/RAG to
32
- * an S3-compatible store and set WEBSOCKET_MANAGER=redis.
33
- */
34
- storage?: string
35
- /**
36
- * OpenAI-compatible API base URL for the model backend (defaults to
37
- * 'https://api.berget.ai/v1'). The key itself is never passed as a
38
- * prop — it arrives via secretKeyRef from the secrets bundle below.
39
- */
40
- backend?: string
41
- /**
42
- * Name of an existing Secret holding `modelApiKey` (the key used to
43
- * call `backend`) and `secretKey` (the WEBUI_SECRET_KEY used to sign
44
- * auth tokens). Required unless a secrets backend (openbao/vault) is
45
- * configured on the surrounding Platform — the backend then provisions
46
- * both keys automatically. Plaintext keys are not supported.
47
- */
48
- secretsName?: string
49
- /**
50
- * OAuth/OIDC SSO client — register Open WebUI as a client in Keycloak
51
- * (the Auth recipe) and reference the client secret through the backend.
52
- * Uses the upstream OAUTH_* env names; OPENID_PROVIDER_URL carries the
53
- * issuer (Open WebUI appends /.well-known/openid-configuration itself).
54
- */
55
- sso?: {
56
- /** OIDC discovery issuer, e.g. https://keycloak.example.com/realms/platform */
57
- issuer: string
58
- /** Client id registered at the issuer (non-sensitive) */
59
- clientId: string
60
- /** Reference to the Kubernetes Secret holding the client secret */
61
- clientSecretRef: SecretRef
62
- /** Scope list (defaults to 'openid email profile') */
63
- scopes?: string
64
- }
65
- /** Provision a redis-backed replication group for caching/events (default: false) */
66
- cache?: boolean
67
- /**
68
- * Air-gapped installs: sets OFFLINE_MODE (disable runtime model/param
69
- * fetches), removes the update checks and disables the native Ollama
70
- * API — only OpenAI-compatible backends are served (default: false).
71
- */
72
- offline?: boolean
73
- /** Requested resources */
74
- resources?: {
75
- requests?: { cpu?: string; memory?: string }
76
- limits?: { cpu?: string; memory?: string }
77
- }
78
- /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
79
- tls?: {
80
- secretName: string
81
- clusterIssuer: string
82
- }
83
- }
84
-
85
- const OPERATOR_REDIS = 'redis-operator'
86
-
87
- /**
88
- * Open WebUI — self-hosted chat frontend for OpenAI-compatible backends.
89
- *
90
- * @title OpenWebui
91
- * @category AI & Chat
92
- *
93
- * Composes:
94
- * - CNPG Postgres cluster (users, chats, presets) via the Database recipe
95
- * - Open WebUI Deployment + Service + Endpoint (websocket-friendly
96
- * proxy annotations; works through Ingress or Envoy Gateway)
97
- * - Optional PVC for uploaded files and RAG documents
98
- * (`${name}-uploads` mounted at /app/backend/data)
99
- * - Optional redis replication group for caching (`cache`,
100
- * service ${name}-redis)
101
- * - Model API key + WEBUI_SECRET_KEY provisioned through the Platform
102
- * secrets backend (openbao / vault), or referenced from an existing
103
- * Secret
104
- * - OAuth/OIDC SSO against the Keycloak `Auth` recipe
105
- *
106
- * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
107
- * the OPENAI_API_KEY / WEBUI_SECRET_KEY pair is provisioned for you.
108
- * Without a backend you must point `secretsName` at a pre-created Secret.
109
- * The Perplexity-style "search the web" extras are not wired — only
110
- * OPENAI-models via the OpenAI-compatible `backend`.
111
- *
112
- * @example
113
- * import { Platform } from '@r8s/recipes'
114
- * import { OpenWebui } from '@r8s/open-webui'
115
- *
116
- * export default (
117
- * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
118
- * <OpenWebui
119
- * name="chat"
120
- * host="chat.example.com"
121
- * version="v0.6.5"
122
- * storage="10Gi"
123
- * />
124
- * </Platform>
125
- * )
126
- *
127
- * @example
128
- * import { Platform } from '@r8s/recipes'
129
- * import { OpenWebui } from '@r8s/open-webui'
130
- *
131
- * export default (
132
- * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
133
- * <OpenWebui
134
- * name="chat"
135
- * host="chat.example.com"
136
- * storage="10Gi"
137
- * cache
138
- * sso={{
139
- * issuer: 'https://keycloak.example.com/realms/platform',
140
- * clientId: 'open-webui',
141
- * clientSecretRef: { secret: 'chat-sso', key: 'clientSecret' },
142
- * }}
143
- * />
144
- * </Platform>
145
- * )
146
- */
147
- export function OpenWebui(props: OpenWebuiProps) {
148
- const {
149
- name = 'open-webui',
150
- namespace = 'default',
151
- version = 'latest',
152
- host,
153
- replicas = 1,
154
- storage,
155
- backend = 'https://api.berget.ai/v1',
156
- secretsName,
157
- sso,
158
- cache = false,
159
- offline = false,
160
- resources = {
161
- requests: { memory: '1Gi', cpu: '500m' },
162
- limits: { memory: '4Gi', cpu: '2000m' },
163
- },
164
- tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
165
- } = props
166
-
167
- const sharedOperators = useContext(OperatorContext)
168
- const secretProvider = useContext(SecretContext)
169
- const resources_: ReturnType<typeof jsx>[] = []
170
-
171
- // --- Validation -----------------------------------------------------------
172
- // The uploads PVC is ReadWriteOnce: a single volume can only be attached
173
- // to one node at a time, so extra replicas with `storage` set would
174
- // crash-loop. Multi-replica installs are viable only with a shared
175
- // S3-compatible object store and WEBSOCKET_MANAGER=redis for socket
176
- // fan-out.
177
- if (storage && replicas > 1) {
178
- throw new Error(
179
- `OpenWebui "${name}" cannot combine storage with replicas > 1.\n` +
180
- `\n` +
181
- `The ${name}-uploads PVC is ReadWriteOnce and Open WebUI keeps ` +
182
- `uploads/RAG on it — mounting it on multiple nodes is impossible, ` +
183
- `so extra replicas would stay Unschedulable or crash-loop. ` +
184
- `Multi-replica deployments also need WEBSOCKET_MANAGER=redis plus a ` +
185
- `REDIS_URL for socket coordination.\n` +
186
- `\n` +
187
- `Fix: either run a single replica alongside the PVC:\n` +
188
- ` <OpenWebui name="${name}" host="${host}" storage="${storage}" replicas={1} />\n` +
189
- `\n` +
190
- `Or drop the storage prop and move uploads to S3-compatible storage ` +
191
- `with a Redis instance (cache) before scaling out:\n` +
192
- ` <OpenWebui name="${name}" host="${host}" replicas={3} cache />`
193
- )
194
- }
195
-
196
- const dbHost = `${name}-rw`
197
- const dbCredentialsName = `${name}-db-credentials`
198
- const appSecretsName = secretsName ?? `${name}-secrets`
199
-
200
- // --- Secret provisioning -------------------------------------------------
201
- // The model API key and the WEBUI_SECRET_KEY are the crown jewels of an
202
- // Open WebUI install — never render them as plaintext. With a secrets
203
- // backend they are provisioned through the backend (keys `modelApiKey`
204
- // and `secretKey`); otherwise reference a pre-created Secret.
205
- if (!secretsName) {
206
- if (
207
- !secretProvider ||
208
- (secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
209
- ) {
210
- throw new Error(
211
- `OpenWebui "${name}" requires application secrets (OPENAI_API_KEY, WEBUI_SECRET_KEY).\n` +
212
- `\n` +
213
- `The model API key for "${backend}" and the auth-token signing key must ` +
214
- `not be rendered as plaintext.\n` +
215
- `\n` +
216
- `Fix: configure a secrets backend on the Platform:\n` +
217
- ` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
218
- ` <OpenWebui name="${name}" host="${host}" />\n` +
219
- ` </Platform>\n` +
220
- `\n` +
221
- `Or reference a pre-created Secret (keys: modelApiKey, secretKey):\n` +
222
- ` <OpenWebui name="${name}" host="${host}" secretsName="${name}-secrets" />`
223
- )
224
- }
225
-
226
- const spec = {
227
- ...(secretProvider.backend === 'vault'
228
- ? { vaultAuthRef: secretProvider.authRef }
229
- : { openbaoAuthRef: secretProvider.authRef }),
230
- mount: secretProvider.mount,
231
- type: 'kv-v2' as const,
232
- path: `${secretProvider.path ?? name}/${name}/secrets`,
233
- destination: { create: true, name: appSecretsName },
234
- }
235
- resources_.push(
236
- secretProvider.backend === 'vault'
237
- ? jsx('VaultStaticSecret', {
238
- apiVersion: 'secrets.hashicorp.com/v1beta1',
239
- kind: 'VaultStaticSecret',
240
- metadata: { name: `${name}-secrets`, namespace },
241
- spec,
242
- })
243
- : jsx('OpenBaoStaticSecret', {
244
- apiVersion: 'secrets.openbao.org/v1beta1',
245
- kind: 'OpenBaoStaticSecret',
246
- metadata: { name: `${name}-secrets`, namespace },
247
- spec,
248
- })
249
- )
250
- }
251
-
252
- // --- Operators ------------------------------------------------------------
253
- if (cache && !sharedOperators.some((op) => op.name === OPERATOR_REDIS)) {
254
- resources_.push(declareOperator(operators[OPERATOR_REDIS]()))
255
- }
256
-
257
- // Redis — caching (OT-Container-Kit operator). A replication group so
258
- // the single ${name}-redis master service fronts a 3-node replication
259
- // set (1 master + 2 replicas) instead of a lone stateful pod.
260
- if (cache) {
261
- resources_.push(
262
- RedisReplicationComponent({
263
- metadata: { name: `${name}-redis`, namespace },
264
- spec: {
265
- clusterSize: 3,
266
- kubernetesConfig: { image: 'redis:7.2-alpine' },
267
- },
268
- })
269
- )
270
- }
271
-
272
- // --- Uploads/RAG PVC --------------------------------------------------------
273
- // WebService cannot mount volumes, so the app controller below is a raw
274
- // Deployment. The PVC is rendered alongside and bound when `storage` is
275
- // set. Without it, uploaded files and RAG documents are lost on restart.
276
- if (storage) {
277
- const pvc: PersistentVolumeClaim = {
278
- apiVersion: 'v1',
279
- kind: 'PersistentVolumeClaim',
280
- metadata: { name: `${name}-uploads`, namespace },
281
- spec: {
282
- accessModes: ['ReadWriteOnce'],
283
- resources: { requests: { storage } },
284
- },
285
- }
286
- resources_.push(jsx('PersistentVolumeClaim', pvc))
287
- }
288
-
289
- // --- Env wiring --------------------------------------------------------------
290
- // Every credential arrives via secretKeyRef — declared BEFORE plain env
291
- // vars in the container env array, so Kubernetes dependent-variable
292
- // expansion resolves the $(VAR) templates below at runtime.
293
- const secretRefs: Record<string, SecretRef | string> = {
294
- PGPASSWORD: { secret: dbCredentialsName, key: 'password' },
295
- OPENAI_API_KEY: { secret: appSecretsName, key: 'modelApiKey' },
296
- WEBUI_SECRET_KEY: { secret: appSecretsName, key: 'secretKey' },
297
- ...(sso ? { OAUTH_CLIENT_SECRET: sso.clientSecretRef } : {}),
298
- }
299
-
300
- const envVars: EnvVar[] = []
301
- for (const [envName, ref] of Object.entries(secretRefs)) {
302
- const typed = typeof ref === 'string' ? { secret: ref } : ref
303
- envVars.push({
304
- name: envName,
305
- valueFrom: { secretKeyRef: { name: typed.secret, key: typed.key ?? envName } },
306
- })
307
- }
308
-
309
- const env: Record<string, string> = {
310
- WEBUI_URL: `https://${host}`,
311
- ENABLE_OPENAI_API: 'true',
312
- // This recipe targets OpenAI-compatible backends only — the native
313
- // Ollama API discovery stays off (offline mode forces the same).
314
- ENABLE_OLLAMA_API: 'false',
315
- OPENAI_API_BASE_URL: backend,
316
- // Deterministic parts inlined; the password arrives via $(PGPASSWORD)
317
- // (secret-backed var declared first in the env array).
318
- DATABASE_URL: `postgresql://${name}:$(PGPASSWORD)@${dbHost}:5432/${name}`,
319
- ...(cache ? { REDIS_URL: `redis://${name}-redis:6379` } : {}),
320
- // Upstream OAUTH_* names; ENABLE_OAUTH_SIGNUP (default true) governs
321
- // whether local-password login is offered alongside the SSO flow.
322
- ...(sso
323
- ? {
324
- ENABLE_OAUTH_SIGNUP: 'true',
325
- OPENID_PROVIDER_URL: sso.issuer,
326
- OAUTH_CLIENT_ID: sso.clientId,
327
- OPENID_REDIRECT_URI: `https://${host}/oauth/oidc/callback`,
328
- OAUTH_SCOPES: sso.scopes ?? 'openid email profile',
329
- }
330
- : {}),
331
- ...(offline
332
- ? {
333
- OFFLINE_MODE: 'true',
334
- ENABLE_UPDATE_CHECK: 'false',
335
- }
336
- : {}),
337
- }
338
- for (const [key, value] of Object.entries(env)) {
339
- envVars.push({ name: key, value })
340
- }
341
-
342
- // --- App controller (raw Deployment — WebService cannot mount volumes) -----
343
- // Generous probe delays/failures: first-boot runs Alembic migrations
344
- // against Postgres before /health starts answering.
345
- const deployment: Deployment = {
346
- apiVersion: 'apps/v1',
347
- kind: 'Deployment',
348
- metadata: { name, namespace, labels: { app: name } },
349
- spec: {
350
- replicas,
351
- selector: { matchLabels: { app: name } },
352
- template: {
353
- metadata: { labels: { app: name } },
354
- spec: {
355
- containers: [
356
- {
357
- name: 'open-webui',
358
- image: `ghcr.io/open-webui/open-webui:${version}`,
359
- imagePullPolicy: 'Always',
360
- ports: [{ containerPort: 8080 }],
361
- env: envVars,
362
- resources,
363
- livenessProbe: {
364
- httpGet: { path: '/health', port: 8080 },
365
- initialDelaySeconds: 30,
366
- periodSeconds: 10,
367
- failureThreshold: 6,
368
- },
369
- readinessProbe: {
370
- httpGet: { path: '/health', port: 8080 },
371
- initialDelaySeconds: 30,
372
- periodSeconds: 5,
373
- failureThreshold: 6,
374
- },
375
- ...(storage && {
376
- volumeMounts: [{ name: 'uploads', mountPath: '/app/backend/data' }],
377
- }),
378
- },
379
- ],
380
- ...(storage && {
381
- volumes: [{ name: 'uploads', persistentVolumeClaim: { claimName: `${name}-uploads` } }],
382
- }),
383
- },
384
- },
385
- },
386
- }
387
-
388
- // Database parent wraps the app so the CNPG cluster, its credentials
389
- // secret (${name}-db-credentials) and connection conventions stay
390
- // consistent with the r8s Database recipe. The raw Deployment sets
391
- // DATABASE_URL statically (the WebService auto-PG block does not apply).
392
- resources_.push(
393
- jsx(Database, { name, namespace, storage: '10Gi', children: jsx('Deployment', deployment) })
394
- )
395
-
396
- // --- Service -----------------------------------------------------------------
397
- const service: Service = {
398
- apiVersion: 'v1',
399
- kind: 'Service',
400
- metadata: { name, namespace },
401
- spec: {
402
- type: 'ClusterIP',
403
- selector: { app: name },
404
- ports: [{ port: 8080, targetPort: 8080 }],
405
- },
406
- }
407
- resources_.push(jsx('Service', service))
408
-
409
- // --- Endpoint — websocket-friendly proxy timeouts ------------------------------
410
- resources_.push(
411
- <Endpoint
412
- name={`${name}-endpoint`}
413
- namespace={namespace}
414
- host={host}
415
- serviceName={name}
416
- servicePort={8080}
417
- tls={tls}
418
- annotations={{
419
- 'nginx.ingress.kubernetes.io/proxy-read-timeout': '300',
420
- 'nginx.ingress.kubernetes.io/proxy-send-timeout': '300',
421
- }}
422
- />
423
- )
424
-
425
- return jsx(Fragment, { children: resources_ })
426
- }
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "composite": true
7
- },
8
- "include": ["src/**/*"],
9
- "references": [
10
- { "path": "../core" },
11
- { "path": "../k8s-types" },
12
- { "path": "../crds" },
13
- { "path": "../recipes" }
14
- ]
15
- }