@r8s/open-webui 0.2.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.
@@ -0,0 +1,457 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, jsx } from '@r8s/core'
3
+ import { OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
4
+ import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
5
+ import { operators } from '@r8s/crds'
6
+ import type { r8sElement } from '@r8s/core'
7
+
8
+ // Open WebUI recipe tests:
9
+ // 1. Operator declarations (cnpg via Database, redis, deduped via OperatorContext)
10
+ // 2. Rendering: defaults incl. uploads PVC, all props, gateway/ingress adaptation
11
+ // 3. Env wiring: upstream env names, no duplicate env names, probes, offline mode
12
+ // 4. Security: no plaintext credentials in rendered output
13
+ import { OpenWebui } from '../src/index'
14
+
15
+ /** Render OpenWebui inside a Platform-like secrets backend (OpenBao). */
16
+ function renderOpenWebui(props: Record<string, unknown>): ReturnType<typeof render> {
17
+ return render(
18
+ jsx(SecretContext.Provider, {
19
+ value: { backend: 'openbao', mount: 'kv', path: 'test' },
20
+ children: jsx(OpenWebui, props as never),
21
+ })
22
+ )
23
+ }
24
+
25
+ /** Render OpenWebui wrapped only in an OperatorContext (no secrets backend). */
26
+ function renderOpenWebuiWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
27
+ return jsx(OperatorContext.Provider, {
28
+ value: operators_,
29
+ children: jsx(OpenWebui, props as never),
30
+ })
31
+ }
32
+
33
+ /**
34
+ * k8s rejects duplicate env names in one container — assert every env
35
+ * list we render is unique.
36
+ */
37
+ function assertUniqueEnvNames(env: Array<{ name: string }>): void {
38
+ const names = env.map((e) => e.name)
39
+ const dupes = names.filter((n, i) => names.indexOf(n) !== i)
40
+ expect(dupes).toEqual([])
41
+ expect(new Set(names).size).toBe(names.length)
42
+ }
43
+
44
+ const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
45
+
46
+ describe('operator declarations', () => {
47
+ it('declares the cnpg operator via the Database recipe', () => {
48
+ const result = renderOpenWebui({ host: 'chat.example.com' })
49
+ expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
50
+ })
51
+
52
+ it('declares the redis operator when cache is enabled', () => {
53
+ const result = renderOpenWebui({ host: 'chat.example.com', cache: true })
54
+ expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(true)
55
+ })
56
+
57
+ it('does not declare operators when cache is off (default)', () => {
58
+ const result = render(
59
+ jsx(OpenWebui, { host: 'chat.example.com', secretsName: 'existing-secrets' })
60
+ )
61
+ const names = result.operators.map((op) => op.name)
62
+ expect(names).toContain('cnpg')
63
+ expect(names).not.toContain('redis-operator')
64
+ })
65
+
66
+ it('deduplicates operators provided via context', () => {
67
+ const result = render(
68
+ renderOpenWebuiWithContext([operators['redis-operator'](), operators['cnpg']()], {
69
+ host: 'chat.example.com',
70
+ cache: true,
71
+ secretsName: 'existing-secrets',
72
+ })
73
+ )
74
+ const names = result.operators.map((op) => op.name)
75
+ expect(names.filter((n) => n === 'redis-operator')).toHaveLength(1)
76
+ expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
77
+ })
78
+
79
+ it('allows version overrides through context operators', () => {
80
+ const result = render(
81
+ renderOpenWebuiWithContext([operators['redis-operator']('1.0.0')], {
82
+ host: 'chat.example.com',
83
+ cache: true,
84
+ secretsName: 'existing-secrets',
85
+ })
86
+ )
87
+ const redis = result.operators.filter((op) => op.name === 'redis-operator')
88
+ expect(redis).toHaveLength(1)
89
+ expect(redis[0].version).toBe('1.0.0')
90
+ })
91
+ })
92
+
93
+ describe('rendering defaults', () => {
94
+ it('renders deployment, service, uploads PVC, database and endpoint', () => {
95
+ const result = renderOpenWebui({ host: 'chat.example.com', storage: '10Gi' })
96
+ const kinds = result.resources.map((r) => r.kind)
97
+ expect(kinds).toContain('Deployment')
98
+ expect(kinds).toContain('Service')
99
+ expect(kinds).toContain('Ingress')
100
+ expect(kinds).toContain('Cluster')
101
+ expect(kinds).toContain('PersistentVolumeClaim')
102
+
103
+ const pvc = result.resources.find(
104
+ (r) => r.kind === 'PersistentVolumeClaim' && (r as any).metadata.name === 'open-webui-uploads'
105
+ ) as any
106
+ expect(pvc.spec.accessModes).toEqual(['ReadWriteOnce'])
107
+ expect(pvc.spec.resources.requests.storage).toBe('10Gi')
108
+ })
109
+
110
+ it('wires the deployment image, port, probes and PVC mount', () => {
111
+ const result = renderOpenWebui({ host: 'chat.example.com', storage: '10Gi' })
112
+ const app = result.resources.find(
113
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
114
+ ) as any
115
+ const container = app.spec.template.spec.containers[0]
116
+ expect(container.image).toBe('ghcr.io/open-webui/open-webui:latest')
117
+ expect(container.ports[0].containerPort).toBe(8080)
118
+ expect(container.livenessProbe.httpGet).toEqual({ path: '/health', port: 8080 })
119
+ expect(container.readinessProbe.httpGet).toEqual({ path: '/health', port: 8080 })
120
+ // First boot runs Alembic migrations — probes give the app room
121
+ expect(container.livenessProbe.initialDelaySeconds).toBe(30)
122
+ expect(container.livenessProbe.failureThreshold).toBe(6)
123
+ expect(container.readinessProbe.initialDelaySeconds).toBe(30)
124
+ expect(container.readinessProbe.failureThreshold).toBe(6)
125
+ expect(container.volumeMounts).toEqual([{ name: 'uploads', mountPath: '/app/backend/data' }])
126
+ expect(app.spec.template.spec.volumes).toEqual([
127
+ { name: 'uploads', persistentVolumeClaim: { claimName: 'open-webui-uploads' } },
128
+ ])
129
+ })
130
+
131
+ it('renders no PVC or volumes when storage is not set', () => {
132
+ const result = renderOpenWebui({ host: 'chat.example.com' })
133
+ const kinds = result.resources.map((r) => r.kind)
134
+ expect(kinds).not.toContain('PersistentVolumeClaim')
135
+ const app = result.resources.find(
136
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
137
+ ) as any
138
+ const container = app.spec.template.spec.containers[0]
139
+ expect(container.volumeMounts).toBeUndefined()
140
+ expect(app.spec.template.spec.volumes).toBeUndefined()
141
+ })
142
+
143
+ it('provisions Redis replication and REDIS_URL against the master service', () => {
144
+ const result = renderOpenWebui({ host: 'chat.example.com', cache: true })
145
+ const kinds = result.resources.map((r) => r.kind)
146
+ expect(kinds).toContain('RedisReplication')
147
+ expect(kinds).not.toContain('RedisCluster')
148
+
149
+ const redis = result.resources.find((r) => r.kind === 'RedisReplication') as any
150
+ expect(redis.metadata.name).toBe('open-webui-redis')
151
+ expect(redis.spec.clusterSize).toBe(3)
152
+ expect(redis.spec.kubernetesConfig.image).toBe('redis:7.2-alpine')
153
+
154
+ const app = result.resources.find(
155
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
156
+ ) as any
157
+ const redisUrl = app.spec.template.spec.containers[0].env.find(
158
+ (e: any) => e.name === 'REDIS_URL'
159
+ )
160
+ expect(redisUrl.value).toBe('redis://open-webui-redis:6379')
161
+ })
162
+
163
+ it('defaults ENABLE_OLLAMA_API to false', () => {
164
+ const result = renderOpenWebui({ host: 'chat.example.com' })
165
+ const app = result.resources.find(
166
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
167
+ ) as any
168
+ const env = app.spec.template.spec.containers[0].env
169
+ expect(env.find((e: any) => e.name === 'ENABLE_OLLAMA_API').value).toBe('false')
170
+ assertUniqueEnvNames(env)
171
+ })
172
+
173
+ it('renders gateway resources when platform uses gateway routing', () => {
174
+ const result = render(
175
+ jsx(RoutingContext.Provider, {
176
+ value: { mode: 'gateway', gatewayClassName: 'eg' },
177
+ children: jsx(SecretContext.Provider, {
178
+ value: openbao as never,
179
+ children: jsx(OpenWebui, { host: 'chat.example.com' }),
180
+ }),
181
+ })
182
+ )
183
+ const kinds = result.resources.map((r) => r.kind)
184
+ expect(kinds).toContain('HTTPRoute')
185
+ })
186
+
187
+ it('renders a valid Ingress when platform uses ingress routing', () => {
188
+ const result = render(
189
+ jsx(RoutingContext.Provider, {
190
+ value: { mode: 'ingress' },
191
+ children: jsx(SecretContext.Provider, {
192
+ value: openbao as never,
193
+ children: jsx(OpenWebui, { host: 'chat.example.com' }),
194
+ }),
195
+ })
196
+ )
197
+ const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
198
+ expect(ingress).toBeDefined()
199
+ expect(ingress.spec.rules[0].host).toBe('chat.example.com')
200
+ })
201
+
202
+ it('points the Ingress at the service on port 8080', () => {
203
+ const result = renderOpenWebui({ host: 'chat.example.com' })
204
+ const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
205
+ expect(ingress.spec.rules[0].http.paths[0].backend.service.name).toBe('open-webui')
206
+ expect(ingress.spec.rules[0].http.paths[0].backend.service.port.number).toBe(8080)
207
+ })
208
+
209
+ it('passes resource validation', () => {
210
+ const result = renderOpenWebui({
211
+ host: 'chat.example.com',
212
+ storage: '10Gi',
213
+ cache: true,
214
+ sso: {
215
+ issuer: 'https://keycloak.example.com/realms/platform',
216
+ clientId: 'open-webui',
217
+ clientSecretRef: { secret: 'open-webui-sso', key: 'clientSecret' },
218
+ },
219
+ })
220
+ for (const resource of result.resources) {
221
+ expect(validateResource(resource)).toEqual([])
222
+ }
223
+ })
224
+ })
225
+
226
+ describe('rendering with all props', () => {
227
+ it('accepts the full prop surface', () => {
228
+ const result = renderOpenWebui({
229
+ name: 'chat',
230
+ namespace: 'chat',
231
+ version: 'v0.6.5',
232
+ host: 'chat.example.com',
233
+ replicas: 3,
234
+ backend: 'https://api.example.com/v1',
235
+ cache: true,
236
+ sso: {
237
+ issuer: 'https://keycloak.example.com/realms/platform',
238
+ clientId: 'chat',
239
+ clientSecretRef: { secret: 'chat-sso', key: 'clientSecret' },
240
+ scopes: 'openid email profile groups',
241
+ },
242
+ resources: {
243
+ requests: { memory: '1Gi', cpu: '500m' },
244
+ limits: { memory: '4Gi', cpu: '2000m' },
245
+ },
246
+ tls: { secretName: 'chat-tls', clusterIssuer: 'letsencrypt-prod' },
247
+ })
248
+ expect(result.resources.length).toBeGreaterThan(0)
249
+
250
+ const app = result.resources.find(
251
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'chat'
252
+ ) as any
253
+ const container = app.spec.template.spec.containers[0]
254
+ expect(container.image).toBe('ghcr.io/open-webui/open-webui:v0.6.5')
255
+ expect(app.spec.replicas).toBe(3)
256
+ expect(container.resources.limits.memory).toBe('4Gi')
257
+
258
+ const backendUrl = container.env.find((e: any) => e.name === 'OPENAI_API_BASE_URL')
259
+ expect(backendUrl.value).toBe('https://api.example.com/v1')
260
+
261
+ const kinds = result.resources.map((r) => r.kind)
262
+ expect(kinds).toContain('RedisReplication')
263
+ expect(kinds).toContain('Ingress')
264
+ expect(kinds).toContain('Cluster')
265
+ assertUniqueEnvNames(container.env)
266
+ })
267
+ })
268
+
269
+ describe('offline mode', () => {
270
+ it('sets OFFLINE_MODE, disables update checks and keeps Ollama off', () => {
271
+ const result = renderOpenWebui({ host: 'chat.example.com', offline: true })
272
+ const app = result.resources.find(
273
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
274
+ ) as any
275
+ const env = app.spec.template.spec.containers[0].env
276
+ expect(env.find((e: any) => e.name === 'OFFLINE_MODE').value).toBe('true')
277
+ expect(env.find((e: any) => e.name === 'ENABLE_UPDATE_CHECK').value).toBe('false')
278
+ expect(env.find((e: any) => e.name === 'ENABLE_OLLAMA_API').value).toBe('false')
279
+ assertUniqueEnvNames(env)
280
+ })
281
+
282
+ it('leaves runtime networking untouched when offline is not set', () => {
283
+ const result = renderOpenWebui({ host: 'chat.example.com' })
284
+ const app = result.resources.find(
285
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
286
+ ) as any
287
+ const env = app.spec.template.spec.containers[0].env
288
+ expect(env.find((e: any) => e.name === 'OFFLINE_MODE')).toBeUndefined()
289
+ expect(env.find((e: any) => e.name === 'ENABLE_UPDATE_CHECK')).toBeUndefined()
290
+ })
291
+ })
292
+
293
+ describe('secrets handling', () => {
294
+ it('provisions the model API key and secret key through OpenBao', () => {
295
+ const result = renderOpenWebui({ host: 'chat.example.com' })
296
+ const kind = result.resources.find((r) => r.kind === 'OpenBaoStaticSecret') as any
297
+ expect(kind).toBeDefined()
298
+ expect(kind.metadata.name).toBe('open-webui-secrets')
299
+ expect(kind.spec.path).toBe('test/open-webui/secrets')
300
+ expect(kind.spec.destination).toEqual({ create: true, name: 'open-webui-secrets' })
301
+ })
302
+
303
+ it('provisions the keys through Vault', () => {
304
+ const result = render(
305
+ jsx(SecretContext.Provider, {
306
+ value: { backend: 'vault', mount: 'kv', path: 'apps' },
307
+ children: jsx(OpenWebui, { host: 'chat.example.com' }),
308
+ })
309
+ )
310
+ const kinds = result.resources.map((r) => r.kind)
311
+ expect(kinds).toContain('VaultStaticSecret')
312
+ expect(kinds).not.toContain('OpenBaoStaticSecret')
313
+ })
314
+
315
+ it('accepts an explicit secretsName without a backend', () => {
316
+ expect(() =>
317
+ render(jsx(OpenWebui, { host: 'chat.example.com', secretsName: 'existing-secrets' }))
318
+ ).not.toThrow()
319
+ const result = render(
320
+ jsx(OpenWebui, { host: 'chat.example.com', secretsName: 'existing-secrets' })
321
+ )
322
+ const kinds = result.resources.map((r) => r.kind)
323
+ expect(kinds).not.toContain('OpenBaoStaticSecret')
324
+ expect(kinds).not.toContain('VaultStaticSecret')
325
+ })
326
+
327
+ it('wires credentials via secretKeyRef (never plaintext env)', () => {
328
+ const result = renderOpenWebui({ host: 'chat.example.com', secretsName: 'existing-secrets' })
329
+ const app = result.resources.find(
330
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
331
+ ) as any
332
+ const env = app.spec.template.spec.containers[0].env
333
+ const modelKey = env.find((e: any) => e.name === 'OPENAI_API_KEY')
334
+ const webuiSecret = env.find((e: any) => e.name === 'WEBUI_SECRET_KEY')
335
+ const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
336
+ expect(modelKey.valueFrom.secretKeyRef.name).toBe('existing-secrets')
337
+ expect(modelKey.valueFrom.secretKeyRef.key).toBe('modelApiKey')
338
+ expect(webuiSecret.valueFrom.secretKeyRef.name).toBe('existing-secrets')
339
+ expect(webuiSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
340
+ expect(dbPassword.valueFrom.secretKeyRef.name).toBe('open-webui-db-credentials')
341
+ expect(dbPassword.valueFrom.secretKeyRef.key).toBe('password')
342
+ expect(modelKey.value).toBeUndefined()
343
+ expect(webuiSecret.value).toBeUndefined()
344
+ expect(dbPassword.value).toBeUndefined()
345
+ })
346
+
347
+ it('declares secretKeyRef env entries before dependent $(VAR) expansion', () => {
348
+ const result = renderOpenWebui({ host: 'chat.example.com' })
349
+ const app = result.resources.find(
350
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
351
+ ) as any
352
+ const env = app.spec.template.spec.containers[0].env
353
+ const names = env.map((e: any) => e.name)
354
+ assertUniqueEnvNames(env)
355
+ expect(names.indexOf('PGPASSWORD')).toBeLessThan(names.indexOf('DATABASE_URL'))
356
+ const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
357
+ expect(databaseUrl.value).toBe(
358
+ 'postgresql://open-webui:$(PGPASSWORD)@open-webui-rw:5432/open-webui'
359
+ )
360
+ const modelKey = env.find((e: any) => e.name === 'OPENAI_API_KEY')
361
+ const backendUrl = env.find((e: any) => e.name === 'OPENAI_API_BASE_URL')
362
+ expect(names.indexOf('OPENAI_API_KEY')).toBeLessThan(names.indexOf('OPENAI_API_BASE_URL'))
363
+ expect(modelKey.valueFrom.secretKeyRef.name).toBe('open-webui-secrets')
364
+ expect(backendUrl.value).toBe('https://api.berget.ai/v1')
365
+ })
366
+
367
+ it('renders no plaintext credentials anywhere', () => {
368
+ const result = renderOpenWebui({
369
+ host: 'chat.example.com',
370
+ storage: '10Gi',
371
+ cache: true,
372
+ sso: {
373
+ issuer: 'https://keycloak.example.com/realms/platform',
374
+ clientId: 'open-webui',
375
+ clientSecretRef: { secret: 'open-webui-sso', key: 'clientSecret' },
376
+ },
377
+ })
378
+ const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
379
+ if (!passed) {
380
+ console.error('Plaintext credential violations:', errors)
381
+ }
382
+ expect(passed).toBe(true)
383
+ })
384
+ })
385
+
386
+ describe('sso wiring', () => {
387
+ it('wires upstream OAUTH env names and the client secret via secretKeyRef', () => {
388
+ const result = renderOpenWebui({
389
+ host: 'chat.example.com',
390
+ sso: {
391
+ issuer: 'https://keycloak.example.com/realms/platform',
392
+ clientId: 'open-webui',
393
+ clientSecretRef: { secret: 'open-webui-sso', key: 'clientSecret' },
394
+ scopes: 'openid email profile',
395
+ },
396
+ })
397
+ const app = result.resources.find(
398
+ (r) => r.kind === 'Deployment' && (r as any).metadata.name === 'open-webui'
399
+ ) as any
400
+ const env = app.spec.template.spec.containers[0].env
401
+ const names = env.map((e: any) => e.name)
402
+ assertUniqueEnvNames(env)
403
+
404
+ const clientId = env.find((e: any) => e.name === 'OAUTH_CLIENT_ID')
405
+ const clientSecret = env.find((e: any) => e.name === 'OAUTH_CLIENT_SECRET')
406
+ const providerUrl = env.find((e: any) => e.name === 'OPENID_PROVIDER_URL')
407
+ const scopes = env.find((e: any) => e.name === 'OAUTH_SCOPES')
408
+ const redirectUri = env.find((e: any) => e.name === 'OPENID_REDIRECT_URI')
409
+ expect(clientId.value).toBe('open-webui')
410
+ expect(clientSecret.valueFrom.secretKeyRef.name).toBe('open-webui-sso')
411
+ expect(clientSecret.valueFrom.secretKeyRef.key).toBe('clientSecret')
412
+ expect(clientSecret.value).toBeUndefined()
413
+ // OPENID_PROVIDER_URL is the issuer — Open WebUI appends the
414
+ // /.well-known/openid-configuration discovery path itself
415
+ expect(providerUrl.value).toBe('https://keycloak.example.com/realms/platform')
416
+ expect(scopes.value).toBe('openid email profile')
417
+ expect(redirectUri.value).toBe('https://chat.example.com/oauth/oidc/callback')
418
+ expect(env.find((e: any) => e.name === 'ENABLE_OAUTH_SIGNUP').value).toBe('true')
419
+ // legacy env names must not leak
420
+ expect(names).not.toContain('OPENID_CLIENT_ID')
421
+ expect(names).not.toContain('OPENID_CLIENT_SECRET')
422
+ expect(names).not.toContain('OPENID_SCOPES')
423
+ expect(names).not.toContain('ENABLE_OPENID_SIGNUP')
424
+ })
425
+ })
426
+
427
+ describe('validation errors', () => {
428
+ it('throws when no secrets backend and no existing Secret ref', () => {
429
+ expect(() => render(jsx(OpenWebui, { host: 'chat.example.com' }))).toThrow(
430
+ /application secrets/
431
+ )
432
+ })
433
+
434
+ it('throws for unknown secrets backends', () => {
435
+ expect(() =>
436
+ render(
437
+ jsx(SecretContext.Provider, {
438
+ value: { backend: 'unknown' as never },
439
+ children: jsx(OpenWebui, { host: 'chat.example.com' }),
440
+ })
441
+ )
442
+ ).toThrow(/application secrets/)
443
+ })
444
+
445
+ it('throws when storage is combined with multiple replicas (RWO single attach)', () => {
446
+ expect(() =>
447
+ renderOpenWebui({ host: 'chat.example.com', storage: '10Gi', replicas: 2 })
448
+ ).toThrow(/storage with replicas/)
449
+ })
450
+
451
+ it('allows single-replica storage and multi-replica without storage', () => {
452
+ expect(() =>
453
+ renderOpenWebui({ host: 'chat.example.com', storage: '10Gi', replicas: 1 })
454
+ ).not.toThrow()
455
+ expect(() => renderOpenWebui({ host: 'chat.example.com', replicas: 3 })).not.toThrow()
456
+ })
457
+ })
@@ -0,0 +1,8 @@
1
+ import { Platform } from '@r8s/recipes'
2
+ import { OpenWebui } from '@r8s/open-webui'
3
+
4
+ export default (
5
+ <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
6
+ <OpenWebui name="chat" host="chat.example.com" version="v0.6.5" storage="10Gi" />
7
+ </Platform>
8
+ )
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@r8s/open-webui",
3
+ "version": "0.2.0",
4
+ "description": "Open WebUI — chat frontend for OpenAI-compatible backends, Postgres persistence, uploads/RAG storage, OIDC SSO, optional Redis cache",
5
+ "license": "MIT",
6
+ "author": "Berget AI AB",
7
+ "homepage": "https://r8s.berget.ai",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/berget-ai/r8s.git",
11
+ "directory": "packages/open-webui"
12
+ },
13
+ "bugs": "https://github.com/berget-ai/r8s/issues",
14
+ "keywords": [
15
+ "open-webui",
16
+ "chat",
17
+ "llm",
18
+ "ai"
19
+ ],
20
+ "r8s": {
21
+ "category": "AI & Chat"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "scripts": {
26
+ "build": "tsc --build",
27
+ "test": "vitest run",
28
+ "clean": "rm -rf dist tsconfig.tsbuildinfo"
29
+ },
30
+ "category": "AI & Assistants",
31
+ "dependencies": {
32
+ "@r8s/core": "^0.2.0",
33
+ "@r8s/crds": "^0.2.0",
34
+ "@r8s/k8s-types": "^0.2.0",
35
+ "@r8s/recipes": "^0.2.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.3.0",
39
+ "vitest": "^1.0.0"
40
+ }
41
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,426 @@
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 ADDED
@@ -0,0 +1,15 @@
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
+ }