@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.
@@ -1,457 +0,0 @@
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
- })
@@ -1,8 +0,0 @@
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
- )