@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.
- package/__tests__/open-webui.test.ts +457 -0
- package/examples/basic.tsx +8 -0
- package/package.json +41 -0
- package/src/index.tsx +426 -0
- package/tsconfig.json +15 -0
|
@@ -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
|
+
}
|