@r8s/librechat 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__/librechat.test.ts +545 -0
- package/examples/basic.tsx +17 -0
- package/package.json +41 -0
- package/src/index.tsx +384 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,545 @@
|
|
|
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
|
+
// LibreChat recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, Meilisearch, all props, gateway/ingress adaptation
|
|
11
|
+
// 3. MongoDB wiring: MONGO_URI expansion + secretKeyRef credentials
|
|
12
|
+
// 4. Env wiring: reverse proxy, redis, search, session credentials,
|
|
13
|
+
// upstream env names, no duplicate env names
|
|
14
|
+
// 5. Security: no plaintext credentials in rendered output
|
|
15
|
+
import { LibreChat } from '../src/index'
|
|
16
|
+
|
|
17
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
18
|
+
|
|
19
|
+
const mongodb = {
|
|
20
|
+
host: 'mongo.data.svc.cluster.local',
|
|
21
|
+
port: 27017,
|
|
22
|
+
passwordSecret: 'chat-mongodb-credentials',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sso = {
|
|
26
|
+
issuer: 'https://keycloak.example.com/realms/platform',
|
|
27
|
+
clientId: 'librechat',
|
|
28
|
+
clientSecretRef: { secret: 'librechat-sso', key: 'clientSecret' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Render LibreChat inside a Platform-like secrets backend (OpenBao). */
|
|
32
|
+
function renderLibreChat(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
33
|
+
return render(
|
|
34
|
+
jsx(SecretContext.Provider, {
|
|
35
|
+
value: openbao as never,
|
|
36
|
+
children: jsx(LibreChat, props as never),
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Wrap LibreChat in an OperatorContext (no secrets backend). */
|
|
42
|
+
function elementWithContext(ops: any[], props: Record<string, unknown>): r8sElement {
|
|
43
|
+
return jsx(OperatorContext.Provider, {
|
|
44
|
+
value: ops,
|
|
45
|
+
children: jsx(LibreChat, props as never),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* k8s rejects duplicate env names in one container — assert every env
|
|
51
|
+
* list we render is unique (this bit us with `$(VAR)` self-echoes of
|
|
52
|
+
* secretKeyRef-backed vars).
|
|
53
|
+
*/
|
|
54
|
+
function assertUniqueEnvNames(env: Array<{ name: string }>): void {
|
|
55
|
+
const names = env.map((e) => e.name)
|
|
56
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i)
|
|
57
|
+
expect(dupes).toEqual([])
|
|
58
|
+
expect(new Set(names).size).toBe(names.length)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('operator declarations', () => {
|
|
62
|
+
it('declares the redis operator when cache is enabled', () => {
|
|
63
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
64
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('skips the redis operator when cache is disabled', () => {
|
|
68
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb, cache: false })
|
|
69
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('never declares cnpg (MongoDB is provisioned externally)', () => {
|
|
73
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
74
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('deduplicates operators provided via context', () => {
|
|
78
|
+
const result = render(
|
|
79
|
+
elementWithContext([operators['redis-operator']()], {
|
|
80
|
+
host: 'chat.example.com',
|
|
81
|
+
mongodb,
|
|
82
|
+
secretsName: 'existing-secrets',
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
const names = result.operators.map((op) => op.name)
|
|
86
|
+
expect(names.filter((n) => n === 'redis-operator')).toHaveLength(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('allows version overrides through context operators', () => {
|
|
90
|
+
const result = render(
|
|
91
|
+
elementWithContext([operators['redis-operator']('1.0.0')], {
|
|
92
|
+
host: 'chat.example.com',
|
|
93
|
+
mongodb,
|
|
94
|
+
secretsName: 'existing-secrets',
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
const redis = result.operators.filter((op) => op.name === 'redis-operator')
|
|
98
|
+
expect(redis).toHaveLength(1)
|
|
99
|
+
expect(redis[0].version).toBe('1.0.0')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('rendering defaults', () => {
|
|
104
|
+
it('renders app deployment, service, ingress and redis', () => {
|
|
105
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
106
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
107
|
+
expect(kinds).toContain('Deployment')
|
|
108
|
+
expect(kinds).toContain('Service')
|
|
109
|
+
expect(kinds).toContain('Ingress')
|
|
110
|
+
expect(kinds).toContain('RedisReplication')
|
|
111
|
+
expect(kinds).not.toContain('RedisCluster')
|
|
112
|
+
expect(kinds).toContain('OpenBaoStaticSecret')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('renders a Meilisearch deployment with health probes and resources when search is enabled', () => {
|
|
116
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb, search: true })
|
|
117
|
+
const deployments = result.resources.filter((r) => r.kind === 'Deployment')
|
|
118
|
+
const services = result.resources.filter((r) => r.kind === 'Service')
|
|
119
|
+
expect(deployments.map((d: any) => d.metadata.name)).toContain('librechat-meilisearch')
|
|
120
|
+
expect(services.map((s: any) => s.metadata.name)).toContain('librechat-meilisearch')
|
|
121
|
+
const meili = deployments.find((d: any) => d.metadata.name === 'librechat-meilisearch') as any
|
|
122
|
+
expect(meili.spec.template.spec.containers[0].image).toBe('getmeili/meilisearch:v1.6')
|
|
123
|
+
const meiliContainer = meili.spec.template.spec.containers[0]
|
|
124
|
+
expect(meiliContainer.livenessProbe.httpGet).toEqual({ path: '/health', port: 7700 })
|
|
125
|
+
expect(meiliContainer.readinessProbe.httpGet).toEqual({ path: '/health', port: 7700 })
|
|
126
|
+
expect(meiliContainer.resources).toEqual({
|
|
127
|
+
requests: { memory: '256Mi', cpu: '100m' },
|
|
128
|
+
limits: { memory: '1Gi', cpu: '500m' },
|
|
129
|
+
})
|
|
130
|
+
assertUniqueEnvNames(meiliContainer.env)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('renders no Meilisearch resources and no SEARCH flag when search is disabled', () => {
|
|
134
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
135
|
+
const names = result.resources
|
|
136
|
+
.filter((r) => r.kind === 'Deployment' || r.kind === 'Service')
|
|
137
|
+
.map((r: any) => r.metadata.name)
|
|
138
|
+
expect(names).not.toContain('librechat-meilisearch')
|
|
139
|
+
const app = result.resources.find(
|
|
140
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
141
|
+
) as any
|
|
142
|
+
const env = app.spec.template.spec.containers[0].env
|
|
143
|
+
expect(env.find((e: any) => e.name === 'SEARCH')).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('quotes the redis master service and enables USE_REDIS', () => {
|
|
147
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
148
|
+
const redis = result.resources.find((r) => r.kind === 'RedisReplication') as any
|
|
149
|
+
expect(redis.metadata.name).toBe('librechat-redis')
|
|
150
|
+
expect(redis.spec.clusterSize).toBe(3)
|
|
151
|
+
expect(redis.spec.kubernetesConfig.image).toBe('redis:7.2-alpine')
|
|
152
|
+
|
|
153
|
+
const app = result.resources.find(
|
|
154
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
155
|
+
) as any
|
|
156
|
+
const env = app.spec.template.spec.containers[0].env
|
|
157
|
+
expect(env.find((e: any) => e.name === 'USE_REDIS').value).toBe('true')
|
|
158
|
+
expect(env.find((e: any) => e.name === 'REDIS_URI').value).toBe('redis://librechat-redis:6379')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
162
|
+
const result = render(
|
|
163
|
+
jsx(RoutingContext.Provider, {
|
|
164
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
165
|
+
children: jsx(SecretContext.Provider, {
|
|
166
|
+
value: openbao as never,
|
|
167
|
+
children: jsx(LibreChat, { host: 'chat.example.com', mongodb }),
|
|
168
|
+
}),
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
172
|
+
expect(kinds).toContain('HTTPRoute')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
176
|
+
const result = render(
|
|
177
|
+
jsx(RoutingContext.Provider, {
|
|
178
|
+
value: { mode: 'ingress' },
|
|
179
|
+
children: jsx(SecretContext.Provider, {
|
|
180
|
+
value: openbao as never,
|
|
181
|
+
children: jsx(LibreChat, { host: 'chat.example.com', mongodb }),
|
|
182
|
+
}),
|
|
183
|
+
})
|
|
184
|
+
)
|
|
185
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
186
|
+
expect(ingress).toBeDefined()
|
|
187
|
+
expect(ingress.spec.rules[0].host).toBe('chat.example.com')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('sets websocket-friendly proxy annotations on the Ingress', () => {
|
|
191
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
192
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
193
|
+
expect(ingress.metadata.annotations).toMatchObject({
|
|
194
|
+
'nginx.ingress.kubernetes.io/proxy-read-timeout': '300',
|
|
195
|
+
'nginx.ingress.kubernetes.io/proxy-send-timeout': '300',
|
|
196
|
+
'nginx.ingress.kubernetes.io/proxy-buffering': 'off',
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('passes resource validation', () => {
|
|
201
|
+
const result = renderLibreChat({
|
|
202
|
+
host: 'chat.example.com',
|
|
203
|
+
mongodb,
|
|
204
|
+
search: true,
|
|
205
|
+
sso,
|
|
206
|
+
})
|
|
207
|
+
for (const resource of result.resources) {
|
|
208
|
+
expect(validateResource(resource)).toEqual([])
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('rendering with all props', () => {
|
|
214
|
+
it('accepts the full prop surface', () => {
|
|
215
|
+
const result = renderLibreChat({
|
|
216
|
+
name: 'chat',
|
|
217
|
+
namespace: 'chat',
|
|
218
|
+
version: '0.7.8',
|
|
219
|
+
host: 'chat.example.com',
|
|
220
|
+
port: 3080,
|
|
221
|
+
replicas: 3,
|
|
222
|
+
mongodb: { ...mongodb, username: 'chat' },
|
|
223
|
+
cache: true,
|
|
224
|
+
search: true,
|
|
225
|
+
sso,
|
|
226
|
+
backend: 'https://api.berget.ai/v1',
|
|
227
|
+
resources: {
|
|
228
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
229
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
230
|
+
},
|
|
231
|
+
tls: { secretName: 'chat-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const app = result.resources.find(
|
|
235
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'chat'
|
|
236
|
+
) as any
|
|
237
|
+
expect(app).toBeDefined()
|
|
238
|
+
expect(app.spec.replicas).toBe(3)
|
|
239
|
+
expect(app.spec.template.spec.containers[0].image).toBe('ghcr.io/danny-avila/librechat:0.7.8')
|
|
240
|
+
expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
241
|
+
expect(app.spec.template.spec.containers[0].ports[0].containerPort).toBe(3080)
|
|
242
|
+
assertUniqueEnvNames(app.spec.template.spec.containers[0].env)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('mongodb wiring', () => {
|
|
247
|
+
it('builds MONGO_URI with $(VAR) expansion and the database named after the resource', () => {
|
|
248
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
249
|
+
const app = result.resources.find(
|
|
250
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
251
|
+
) as any
|
|
252
|
+
const env = app.spec.template.spec.containers[0].env
|
|
253
|
+
const mongoUri = env.find((e: any) => e.name === 'MONGO_URI')
|
|
254
|
+
expect(mongoUri.value).toBe(
|
|
255
|
+
'mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@mongo.data.svc.cluster.local:27017/librechat'
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('appends authSource when the mongodb connection declares one', () => {
|
|
260
|
+
const result = renderLibreChat({
|
|
261
|
+
host: 'chat.example.com',
|
|
262
|
+
mongodb: { ...mongodb, authSource: 'admin' },
|
|
263
|
+
})
|
|
264
|
+
const app = result.resources.find(
|
|
265
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
266
|
+
) as any
|
|
267
|
+
const env = app.spec.template.spec.containers[0].env
|
|
268
|
+
expect(env.find((e: any) => e.name === 'MONGO_URI').value).toBe(
|
|
269
|
+
'mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@mongo.data.svc.cluster.local:27017/librechat?authSource=admin'
|
|
270
|
+
)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('delivers MONGO_USERNAME / MONGO_PASSWORD via secretKeyRef from the password secret', () => {
|
|
274
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
275
|
+
const app = result.resources.find(
|
|
276
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
277
|
+
) as any
|
|
278
|
+
const env = app.spec.template.spec.containers[0].env
|
|
279
|
+
const username = env.find((e: any) => e.name === 'MONGO_USERNAME')
|
|
280
|
+
const password = env.find((e: any) => e.name === 'MONGO_PASSWORD')
|
|
281
|
+
expect(username.valueFrom.secretKeyRef).toEqual({
|
|
282
|
+
name: 'chat-mongodb-credentials',
|
|
283
|
+
key: 'username',
|
|
284
|
+
})
|
|
285
|
+
expect(password.valueFrom.secretKeyRef).toEqual({
|
|
286
|
+
name: 'chat-mongodb-credentials',
|
|
287
|
+
key: 'password',
|
|
288
|
+
})
|
|
289
|
+
expect(username.value).toBeUndefined()
|
|
290
|
+
expect(password.value).toBeUndefined()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('inlines an explicitly provided username (identifier, not secret)', () => {
|
|
294
|
+
const result = renderLibreChat({
|
|
295
|
+
host: 'chat.example.com',
|
|
296
|
+
mongodb: { ...mongodb, username: 'chat' },
|
|
297
|
+
})
|
|
298
|
+
const app = result.resources.find(
|
|
299
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
300
|
+
) as any
|
|
301
|
+
const env = app.spec.template.spec.containers[0].env
|
|
302
|
+
const username = env.find((e: any) => e.name === 'MONGO_USERNAME')
|
|
303
|
+
const password = env.find((e: any) => e.name === 'MONGO_PASSWORD')
|
|
304
|
+
expect(username.value).toBe('chat')
|
|
305
|
+
expect(username.valueFrom).toBeUndefined()
|
|
306
|
+
expect(password.valueFrom.secretKeyRef.key).toBe('password')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('shares MEILI_MASTER_KEY with the main app via the secrets bundle when search is on', () => {
|
|
310
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb, search: true })
|
|
311
|
+
const app = result.resources.find(
|
|
312
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
313
|
+
) as any
|
|
314
|
+
const env = app.spec.template.spec.containers[0].env
|
|
315
|
+
expect(env.find((e: any) => e.name === 'SEARCH').value).toBe('true')
|
|
316
|
+
expect(env.find((e: any) => e.name === 'MEILI_HOST').value).toBe(
|
|
317
|
+
'http://librechat-meilisearch:7700'
|
|
318
|
+
)
|
|
319
|
+
const meiliKey = env.find((e: any) => e.name === 'MEILI_MASTER_KEY')
|
|
320
|
+
expect(meiliKey.valueFrom.secretKeyRef.name).toBe('librechat-secrets')
|
|
321
|
+
expect(meiliKey.valueFrom.secretKeyRef.key).toBe('meiliMasterKey')
|
|
322
|
+
expect(meiliKey.value).toBeUndefined()
|
|
323
|
+
assertUniqueEnvNames(env)
|
|
324
|
+
|
|
325
|
+
const meiliDeployment = result.resources.find(
|
|
326
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat-meilisearch'
|
|
327
|
+
) as any
|
|
328
|
+
const meiliEnv = meiliDeployment.spec.template.spec.containers[0].env
|
|
329
|
+
const meiliMasterKey = meiliEnv.find((e: any) => e.name === 'MEILI_MASTER_KEY')
|
|
330
|
+
expect(meiliMasterKey.valueFrom.secretKeyRef.name).toBe('librechat-secrets')
|
|
331
|
+
expect(meiliMasterKey.value).toBeUndefined()
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
describe('session credentials', () => {
|
|
336
|
+
it('wires JWT + CREDS multi-user credentials via secretKeyRef from the secrets bundle', () => {
|
|
337
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
338
|
+
const app = result.resources.find(
|
|
339
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
340
|
+
) as any
|
|
341
|
+
const env = app.spec.template.spec.containers[0].env
|
|
342
|
+
const jwtSecret = env.find((e: any) => e.name === 'JWT_SECRET')
|
|
343
|
+
const jwtRefresh = env.find((e: any) => e.name === 'JWT_REFRESH_SECRET')
|
|
344
|
+
const credsKey = env.find((e: any) => e.name === 'CREDS_KEY')
|
|
345
|
+
const credsIv = env.find((e: any) => e.name === 'CREDS_IV')
|
|
346
|
+
expect(jwtSecret).toMatchObject({
|
|
347
|
+
valueFrom: { secretKeyRef: { name: 'librechat-secrets', key: 'jwtSecret' } },
|
|
348
|
+
})
|
|
349
|
+
expect(jwtRefresh).toMatchObject({
|
|
350
|
+
valueFrom: { secretKeyRef: { name: 'librechat-secrets', key: 'jwtRefreshSecret' } },
|
|
351
|
+
})
|
|
352
|
+
expect(credsKey).toMatchObject({
|
|
353
|
+
valueFrom: { secretKeyRef: { name: 'librechat-secrets', key: 'credsKey' } },
|
|
354
|
+
})
|
|
355
|
+
expect(credsIv).toMatchObject({
|
|
356
|
+
valueFrom: { secretKeyRef: { name: 'librechat-secrets', key: 'credsIv' } },
|
|
357
|
+
})
|
|
358
|
+
for (const e of [jwtSecret, jwtRefresh, credsKey, credsIv]) {
|
|
359
|
+
expect(e.value).toBeUndefined()
|
|
360
|
+
}
|
|
361
|
+
// REFRESH_TOKEN_EXPIRY is a plain TTL — delivered via configMapKeyRef
|
|
362
|
+
const refreshTokenExpiry = env.find((e: any) => e.name === 'REFRESH_TOKEN_EXPIRY')
|
|
363
|
+
expect(refreshTokenExpiry.valueFrom.configMapKeyRef).toEqual({
|
|
364
|
+
name: 'librechat-config',
|
|
365
|
+
key: 'REFRESH_TOKEN_EXPIRY',
|
|
366
|
+
})
|
|
367
|
+
expect(refreshTokenExpiry.value).toBeUndefined()
|
|
368
|
+
const configMap = result.resources.find(
|
|
369
|
+
(r: any) => r.kind === 'ConfigMap' && r.metadata.name === 'librechat-config'
|
|
370
|
+
) as any
|
|
371
|
+
expect(configMap.data).toEqual({ REFRESH_TOKEN_EXPIRY: '604800' })
|
|
372
|
+
assertUniqueEnvNames(env)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('resolves session credential keys against an explicit secretsName', () => {
|
|
376
|
+
const result = renderLibreChat({
|
|
377
|
+
host: 'chat.example.com',
|
|
378
|
+
mongodb,
|
|
379
|
+
secretsName: 'existing-secrets',
|
|
380
|
+
})
|
|
381
|
+
const app = result.resources.find(
|
|
382
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
383
|
+
) as any
|
|
384
|
+
const env = app.spec.template.spec.containers[0].env
|
|
385
|
+
for (const envName of ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'CREDS_KEY', 'CREDS_IV']) {
|
|
386
|
+
expect(env.find((e: any) => e.name === envName).valueFrom.secretKeyRef.name).toBe(
|
|
387
|
+
'existing-secrets'
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
describe('secrets handling', () => {
|
|
394
|
+
it('provisions the app secrets bundle through a secrets backend', () => {
|
|
395
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
396
|
+
const bao = result.resources.find((r) => r.kind === 'OpenBaoStaticSecret') as any
|
|
397
|
+
expect(bao).toBeDefined()
|
|
398
|
+
expect(bao.spec.destination.name).toBe('librechat-secrets')
|
|
399
|
+
expect(bao.spec.path).toBe('test/librechat/secrets')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('provisions the app secrets bundle through Vault', () => {
|
|
403
|
+
const result = render(
|
|
404
|
+
jsx(SecretContext.Provider, {
|
|
405
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
406
|
+
children: jsx(LibreChat, { host: 'chat.example.com', mongodb }),
|
|
407
|
+
})
|
|
408
|
+
)
|
|
409
|
+
const vault = result.resources.find((r) => r.kind === 'VaultStaticSecret') as any
|
|
410
|
+
expect(vault).toBeDefined()
|
|
411
|
+
expect(vault.spec.destination.name).toBe('librechat-secrets')
|
|
412
|
+
expect(vault.spec.path).toBe('apps/librechat/secrets')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('accepts an existing secretsName without a backend', () => {
|
|
416
|
+
expect(() =>
|
|
417
|
+
render(
|
|
418
|
+
jsx(LibreChat, {
|
|
419
|
+
host: 'chat.example.com',
|
|
420
|
+
mongodb,
|
|
421
|
+
secretsName: 'existing-secrets',
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
).not.toThrow()
|
|
425
|
+
const result = render(
|
|
426
|
+
jsx(LibreChat, {
|
|
427
|
+
host: 'chat.example.com',
|
|
428
|
+
mongodb,
|
|
429
|
+
secretsName: 'existing-secrets',
|
|
430
|
+
})
|
|
431
|
+
)
|
|
432
|
+
expect(
|
|
433
|
+
result.resources.find(
|
|
434
|
+
(r) => r.kind === 'OpenBaoStaticSecret' || r.kind === 'VaultStaticSecret'
|
|
435
|
+
)
|
|
436
|
+
).toBeUndefined()
|
|
437
|
+
const app = result.resources.find(
|
|
438
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
439
|
+
) as any
|
|
440
|
+
const env = app.spec.template.spec.containers[0].env
|
|
441
|
+
expect(env.find((e: any) => e.name === 'SECRET_KEY').valueFrom.secretKeyRef.name).toBe(
|
|
442
|
+
'existing-secrets'
|
|
443
|
+
)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('throws when no secrets backend and no secretsName', () => {
|
|
447
|
+
expect(() => render(jsx(LibreChat, { host: 'chat.example.com', mongodb }))).toThrow(
|
|
448
|
+
/application secrets/
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('wires app credentials via secretKeyRef (never plaintext env)', () => {
|
|
453
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb })
|
|
454
|
+
const app = result.resources.find(
|
|
455
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
456
|
+
) as any
|
|
457
|
+
const env = app.spec.template.spec.containers[0].env
|
|
458
|
+
const secretKey = env.find((e: any) => e.name === 'SECRET_KEY')
|
|
459
|
+
const modelApiKey = env.find((e: any) => e.name === 'OPENAI_API_KEY')
|
|
460
|
+
expect(secretKey.valueFrom.secretKeyRef.name).toBe('librechat-secrets')
|
|
461
|
+
expect(secretKey.valueFrom.secretKeyRef.key).toBe('secretKey')
|
|
462
|
+
expect(modelApiKey.valueFrom.secretKeyRef.name).toBe('librechat-secrets')
|
|
463
|
+
expect(modelApiKey.valueFrom.secretKeyRef.key).toBe('modelApiKey')
|
|
464
|
+
expect(secretKey.value).toBeUndefined()
|
|
465
|
+
expect(modelApiKey.value).toBeUndefined()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
469
|
+
const result = renderLibreChat({
|
|
470
|
+
host: 'chat.example.com',
|
|
471
|
+
mongodb,
|
|
472
|
+
search: true,
|
|
473
|
+
sso,
|
|
474
|
+
})
|
|
475
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
476
|
+
if (!passed) {
|
|
477
|
+
console.error('Plaintext credential violations:', errors)
|
|
478
|
+
}
|
|
479
|
+
expect(passed).toBe(true)
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
describe('sso wiring', () => {
|
|
484
|
+
it('sets OPENID env vars and wires the client secret via secretKeyRef', () => {
|
|
485
|
+
const result = renderLibreChat({ host: 'chat.example.com', mongodb, sso })
|
|
486
|
+
const app = result.resources.find(
|
|
487
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
488
|
+
) as any
|
|
489
|
+
const env = app.spec.template.spec.containers[0].env
|
|
490
|
+
const names = env.map((e: any) => e.name)
|
|
491
|
+
assertUniqueEnvNames(env)
|
|
492
|
+
expect(env.find((e: any) => e.name === 'ALLOW_SOCIAL_LOGIN').value).toBe('true')
|
|
493
|
+
expect(env.find((e: any) => e.name === 'DOMAIN_SERVER').value).toBe('https://chat.example.com')
|
|
494
|
+
expect(env.find((e: any) => e.name === 'DOMAIN_CLIENT').value).toBe('https://chat.example.com')
|
|
495
|
+
expect(env.find((e: any) => e.name === 'OPENID_ISSUER').value).toBe(sso.issuer)
|
|
496
|
+
expect(env.find((e: any) => e.name === 'OPENID_CLIENT_ID').value).toBe(sso.clientId)
|
|
497
|
+
expect(env.find((e: any) => e.name === 'OPENID_SCOPES').value).toBe('openid profile email')
|
|
498
|
+
expect(env.find((e: any) => e.name === 'OPENID_CALLBACK_URL').value).toBe(
|
|
499
|
+
'https://chat.example.com/oauth/openid/callback'
|
|
500
|
+
)
|
|
501
|
+
const clientSecret = env.find((e: any) => e.name === 'OPENID_CLIENT_SECRET')
|
|
502
|
+
expect(clientSecret.valueFrom.secretKeyRef).toEqual({
|
|
503
|
+
name: 'librechat-sso',
|
|
504
|
+
key: 'clientSecret',
|
|
505
|
+
})
|
|
506
|
+
expect(clientSecret.value).toBeUndefined()
|
|
507
|
+
// no $(VAR) self-echo of the secretKeyRef-backed var
|
|
508
|
+
expect(names).not.toContain('OPENID_SCOPE')
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
describe('openai backend wiring', () => {
|
|
513
|
+
it('routes model calls through OPENAI_REVERSE_PROXY (no OPENAI_API_BASE_URL)', () => {
|
|
514
|
+
const result = renderLibreChat({
|
|
515
|
+
host: 'chat.example.com',
|
|
516
|
+
mongodb,
|
|
517
|
+
secretsName: 'existing-secrets',
|
|
518
|
+
})
|
|
519
|
+
const app = result.resources.find(
|
|
520
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
521
|
+
) as any
|
|
522
|
+
const env = app.spec.template.spec.containers[0].env
|
|
523
|
+
const names = env.map((e: any) => e.name)
|
|
524
|
+
expect(env.find((e: any) => e.name === 'OPENAI_REVERSE_PROXY').value).toBe(
|
|
525
|
+
'https://api.berget.ai/v1/chat/completions'
|
|
526
|
+
)
|
|
527
|
+
expect(names).not.toContain('OPENAI_API_BASE_URL')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('honors a backend override with the chat/completions suffix', () => {
|
|
531
|
+
const result = renderLibreChat({
|
|
532
|
+
host: 'chat.example.com',
|
|
533
|
+
mongodb,
|
|
534
|
+
backend: 'https://api.example.com/v1',
|
|
535
|
+
secretsName: 'existing-secrets',
|
|
536
|
+
})
|
|
537
|
+
const app = result.resources.find(
|
|
538
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'librechat'
|
|
539
|
+
) as any
|
|
540
|
+
const env = app.spec.template.spec.containers[0].env
|
|
541
|
+
expect(env.find((e: any) => e.name === 'OPENAI_REVERSE_PROXY').value).toBe(
|
|
542
|
+
'https://api.example.com/v1/chat/completions'
|
|
543
|
+
)
|
|
544
|
+
})
|
|
545
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Platform } from '@r8s/recipes'
|
|
2
|
+
import { LibreChat } from '@r8s/librechat'
|
|
3
|
+
|
|
4
|
+
export default (
|
|
5
|
+
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
+
<LibreChat
|
|
7
|
+
name="chat"
|
|
8
|
+
host="chat.example.com"
|
|
9
|
+
mongodb={{ host: 'mongo.data.svc.cluster.local', passwordSecret: 'chat-mongodb-credentials' }}
|
|
10
|
+
sso={{
|
|
11
|
+
issuer: 'https://keycloak.example.com/realms/platform',
|
|
12
|
+
clientId: 'librechat',
|
|
13
|
+
clientSecretRef: { secret: 'librechat-sso', key: 'clientSecret' },
|
|
14
|
+
}}
|
|
15
|
+
/>
|
|
16
|
+
</Platform>
|
|
17
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/librechat",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "LibreChat multi-model AI chat — MongoDB (provisioned externally), Redis sessions, optional Meilisearch, OIDC SSO",
|
|
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/librechat"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"librechat",
|
|
16
|
+
"chat",
|
|
17
|
+
"llm",
|
|
18
|
+
"rag"
|
|
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,384 @@
|
|
|
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 { WebService, Endpoint } from '@r8s/recipes'
|
|
5
|
+
import { RedisReplicationComponent } from '@r8s/crds/redis'
|
|
6
|
+
import type { SecretRef } from '@r8s/recipes'
|
|
7
|
+
import type { ConfigMap, EnvVar } from '@r8s/k8s-types'
|
|
8
|
+
|
|
9
|
+
export interface MongoConnection {
|
|
10
|
+
/** MongoDB host (cluster-internal service or external host) */
|
|
11
|
+
host: string
|
|
12
|
+
/** MongoDB port (defaults to 27017) */
|
|
13
|
+
port?: number
|
|
14
|
+
/**
|
|
15
|
+
* Database username. Inlined as a plain env var — usernames are
|
|
16
|
+
* identifiers, not secrets (same convention as the n8n/outline DB
|
|
17
|
+
* recipes). When omitted, the username is read from the password
|
|
18
|
+
* secret (key: `username`).
|
|
19
|
+
*/
|
|
20
|
+
username?: string
|
|
21
|
+
/**
|
|
22
|
+
* Name of an existing Secret holding the MongoDB credentials.
|
|
23
|
+
* Keys: `username`, `password`.
|
|
24
|
+
*/
|
|
25
|
+
passwordSecret: string
|
|
26
|
+
/**
|
|
27
|
+
* authSource query parameter appended to MONGO_URI when set
|
|
28
|
+
* (e.g. 'admin' for databases authenticating against the admin db).
|
|
29
|
+
*/
|
|
30
|
+
authSource?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LibreChatProps {
|
|
34
|
+
/** Resource name (defaults to 'librechat') */
|
|
35
|
+
name?: string
|
|
36
|
+
/** Kubernetes namespace (defaults to 'default') */
|
|
37
|
+
namespace?: string
|
|
38
|
+
/** Container image tag (defaults to 'latest' — pin a version in production) */
|
|
39
|
+
version?: string
|
|
40
|
+
/** Public hostname for the chat UI (required) */
|
|
41
|
+
host: string
|
|
42
|
+
/** Port the app listens on in-container (defaults to 3080) */
|
|
43
|
+
port?: number
|
|
44
|
+
/** Number of replicas (defaults to 1) */
|
|
45
|
+
replicas?: number
|
|
46
|
+
/**
|
|
47
|
+
* External MongoDB connection (REQUIRED). LibreChat stores users,
|
|
48
|
+
* conversations and messages in MongoDB — this component does NOT
|
|
49
|
+
* provision it. Run MongoDB separately (replica-set StatefulSet,
|
|
50
|
+
* operator or managed service) and point this prop at it.
|
|
51
|
+
*/
|
|
52
|
+
mongodb: MongoConnection
|
|
53
|
+
/** Provision a redis replication group for session caching (default: true) */
|
|
54
|
+
cache?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Add a Meilisearch sidecar service for full-text / RAG search
|
|
57
|
+
* (default: false). MEILI_MASTER_KEY is shared from the app secrets
|
|
58
|
+
* bundle (key: meiliMasterKey). Sets SEARCH=true on the app so it
|
|
59
|
+
* actually queries the meilisearch instance.
|
|
60
|
+
*/
|
|
61
|
+
search?: boolean
|
|
62
|
+
/**
|
|
63
|
+
* OIDC SSO client — register LibreChat as a client in Keycloak (the
|
|
64
|
+
* Auth recipe) and reference the client secret through the backend.
|
|
65
|
+
* Uses the upstream OPENID_* env names; ALLOW_SOCIAL_LOGIN plus
|
|
66
|
+
* DOMAIN_SERVER/DOMAIN_CLIENT are set from `host`.
|
|
67
|
+
*/
|
|
68
|
+
sso?: {
|
|
69
|
+
issuer: string
|
|
70
|
+
clientId: string
|
|
71
|
+
clientSecretRef: SecretRef
|
|
72
|
+
scopes?: string
|
|
73
|
+
}
|
|
74
|
+
/** OpenAI-compatible API base URL for model calls (defaults to https://api.berget.ai/v1) */
|
|
75
|
+
backend?: string
|
|
76
|
+
/**
|
|
77
|
+
* Name of an existing Secret holding `secretKey`, `modelApiKey`,
|
|
78
|
+
* the multi-user session credentials `jwtSecret`, `jwtRefreshSecret`,
|
|
79
|
+
* `credsKey`, `credsIv` — and `meiliMasterKey` when `search` is enabled.
|
|
80
|
+
*
|
|
81
|
+
* Hex sizing: jwtSecret / jwtRefreshSecret / credsKey are 64 hex chars
|
|
82
|
+
* (32 bytes); credsIv is 32 hex chars (16 bytes — AES-IV).
|
|
83
|
+
*
|
|
84
|
+
* Required unless a secrets backend (openbao/vault) is configured on
|
|
85
|
+
* the surrounding Platform — the backend then provisions them.
|
|
86
|
+
* Plaintext secrets are not supported.
|
|
87
|
+
*/
|
|
88
|
+
secretsName?: string
|
|
89
|
+
/** Requested resources */
|
|
90
|
+
resources?: {
|
|
91
|
+
requests?: { cpu?: string; memory?: string }
|
|
92
|
+
limits?: { cpu?: string; memory?: string }
|
|
93
|
+
}
|
|
94
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
95
|
+
tls?: {
|
|
96
|
+
secretName: string
|
|
97
|
+
clusterIssuer: string
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const OPERATOR_REDIS = 'redis-operator'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* LibreChat — multi-model AI chat UI with external MongoDB, Redis
|
|
105
|
+
* sessions, optional Meilisearch and OIDC SSO.
|
|
106
|
+
*
|
|
107
|
+
* @title LibreChat
|
|
108
|
+
* @category AI & Chat
|
|
109
|
+
*
|
|
110
|
+
* Composes:
|
|
111
|
+
* - LibreChat Deployment + Service + Endpoint (MongoDB is provisioned
|
|
112
|
+
* externally — pass its coordinates via the required `mongodb` prop)
|
|
113
|
+
* - Redis replication group for session caching (default on, service
|
|
114
|
+
* ${name}-redis; USE_REDIS=true points sessions at it)
|
|
115
|
+
* - Optional Meilisearch Deployment + Service for message/RAG search
|
|
116
|
+
* (SEARCH=true wires the app to it)
|
|
117
|
+
* - SECRET_KEY / OPENAI_API_KEY / JWT + CREDS session credentials /
|
|
118
|
+
* MEILI_MASTER_KEY provisioned through the Platform secrets backend
|
|
119
|
+
* (openbao / vault), or referenced from an existing Secret
|
|
120
|
+
* - OIDC SSO against the Keycloak `Auth` recipe
|
|
121
|
+
*
|
|
122
|
+
* Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
|
|
123
|
+
* the app secrets bundle (keys: secretKey, modelApiKey, jwtSecret,
|
|
124
|
+
* jwtRefreshSecret, credsKey, credsIv, meiliMasterKey) is provisioned
|
|
125
|
+
* for you. Without a backend you must point `secretsName` at a
|
|
126
|
+
* pre-created Secret. MongoDB itself is NOT provisioned here — run it
|
|
127
|
+
* separately and connect via `mongodb`.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* import { Platform } from '@r8s/recipes'
|
|
131
|
+
* import { LibreChat } from '@r8s/librechat'
|
|
132
|
+
*
|
|
133
|
+
* export default (
|
|
134
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
135
|
+
* <LibreChat
|
|
136
|
+
* name="chat"
|
|
137
|
+
* host="chat.example.com"
|
|
138
|
+
* mongodb={{ host: 'mongo.data.svc.cluster.local', passwordSecret: 'chat-mongodb-credentials' }}
|
|
139
|
+
* sso={{
|
|
140
|
+
* issuer: 'https://keycloak.example.com/realms/platform',
|
|
141
|
+
* clientId: 'librechat',
|
|
142
|
+
* clientSecretRef: { secret: 'librechat-sso', key: 'clientSecret' },
|
|
143
|
+
* }}
|
|
144
|
+
* />
|
|
145
|
+
* </Platform>
|
|
146
|
+
* )
|
|
147
|
+
*/
|
|
148
|
+
export function LibreChat(props: LibreChatProps) {
|
|
149
|
+
const {
|
|
150
|
+
name = 'librechat',
|
|
151
|
+
namespace = 'default',
|
|
152
|
+
version = 'latest',
|
|
153
|
+
host,
|
|
154
|
+
port = 3080,
|
|
155
|
+
replicas = 1,
|
|
156
|
+
mongodb,
|
|
157
|
+
cache = true,
|
|
158
|
+
search = false,
|
|
159
|
+
sso,
|
|
160
|
+
backend = 'https://api.berget.ai/v1',
|
|
161
|
+
secretsName,
|
|
162
|
+
resources = {
|
|
163
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
164
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
165
|
+
},
|
|
166
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
167
|
+
} = props
|
|
168
|
+
|
|
169
|
+
const sharedOperators = useContext(OperatorContext)
|
|
170
|
+
const secretProvider = useContext(SecretContext)
|
|
171
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
172
|
+
|
|
173
|
+
const appSecretsName = secretsName ?? `${name}-secrets`
|
|
174
|
+
|
|
175
|
+
// --- Secret provisioning ---------------------------------------------------
|
|
176
|
+
// SECRET_KEY signs sessions, modelApiKey pays for inference, the JWT +
|
|
177
|
+
// CREDS keys carry multi-user login state and meiliMasterKey guards the
|
|
178
|
+
// search index — none of them may be rendered as plaintext. With a
|
|
179
|
+
// secrets backend the bundle (keys: secretKey, modelApiKey, jwtSecret,
|
|
180
|
+
// jwtRefreshSecret, credsKey, credsIv, meiliMasterKey) is provisioned
|
|
181
|
+
// through the backend; otherwise reference a pre-created Secret.
|
|
182
|
+
if (!secretsName) {
|
|
183
|
+
if (
|
|
184
|
+
!secretProvider ||
|
|
185
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`LibreChat "${name}" requires application secrets (SECRET_KEY, modelApiKey).\n` +
|
|
189
|
+
`\n` +
|
|
190
|
+
`These must not be rendered as plaintext.\n` +
|
|
191
|
+
`\n` +
|
|
192
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
193
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
194
|
+
` <LibreChat name="${name}" host="${host}" />\n` +
|
|
195
|
+
` </Platform>\n` +
|
|
196
|
+
`\n` +
|
|
197
|
+
`Or reference a pre-created Secret (keys: secretKey, modelApiKey, jwtSecret, jwtRefreshSecret, credsKey, credsIv` +
|
|
198
|
+
`${search ? ', meiliMasterKey' : ''}):\n` +
|
|
199
|
+
` <LibreChat name="${name}" host="${host}" secretsName="${name}-secrets" />`
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const spec = {
|
|
204
|
+
...(secretProvider.backend === 'vault'
|
|
205
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
206
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
207
|
+
mount: secretProvider.mount,
|
|
208
|
+
type: 'kv-v2' as const,
|
|
209
|
+
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
210
|
+
destination: { create: true, name: appSecretsName },
|
|
211
|
+
}
|
|
212
|
+
resources_.push(
|
|
213
|
+
secretProvider.backend === 'vault'
|
|
214
|
+
? jsx('VaultStaticSecret', {
|
|
215
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
216
|
+
kind: 'VaultStaticSecret',
|
|
217
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
218
|
+
spec,
|
|
219
|
+
})
|
|
220
|
+
: jsx('OpenBaoStaticSecret', {
|
|
221
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
222
|
+
kind: 'OpenBaoStaticSecret',
|
|
223
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
224
|
+
spec,
|
|
225
|
+
})
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Operators --------------------------------------------------------------
|
|
230
|
+
if (cache && !sharedOperators.some((op) => op.name === OPERATOR_REDIS)) {
|
|
231
|
+
resources_.push(declareOperator(operators[OPERATOR_REDIS]()))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Redis — session cache (OT-Container-Kit operator). A replication group
|
|
235
|
+
// so the single ${name}-redis master service fronts a 3-node set.
|
|
236
|
+
if (cache) {
|
|
237
|
+
resources_.push(
|
|
238
|
+
RedisReplicationComponent({
|
|
239
|
+
metadata: { name: `${name}-redis`, namespace },
|
|
240
|
+
spec: {
|
|
241
|
+
clusterSize: 3,
|
|
242
|
+
kubernetesConfig: { image: 'redis:7.2-alpine' },
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Env wiring ----------------------------------------------------------------
|
|
249
|
+
// Every credential is referenced via secretKeyRef — no plaintext and no
|
|
250
|
+
// redundant $(VAR) self-echoes in the manifest. The WebService declares
|
|
251
|
+
// secret-backed vars before plain env vars, so dependent expansion on
|
|
252
|
+
// MONGO_URI resolves.
|
|
253
|
+
const mongoPort = mongodb.port ?? 27017
|
|
254
|
+
|
|
255
|
+
const env: Record<string, string> = {
|
|
256
|
+
HOST: '0.0.0.0',
|
|
257
|
+
PORT: String(port),
|
|
258
|
+
// Deterministic parts inlined; credentials arrive via MONGO_USERNAME /
|
|
259
|
+
// MONGO_PASSWORD declared earlier through secretKeyRef
|
|
260
|
+
MONGO_URI:
|
|
261
|
+
`mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@${mongodb.host}:${mongoPort}/${name}` +
|
|
262
|
+
(mongodb.authSource ? `?authSource=${mongodb.authSource}` : ''),
|
|
263
|
+
// LibreChat speaks to OpenAI-compatible backends through its reverse
|
|
264
|
+
// proxy (there is no OPENAI_API_BASE_URL env upstream)
|
|
265
|
+
OPENAI_REVERSE_PROXY: `${backend}/chat/completions`,
|
|
266
|
+
...(cache ? { USE_REDIS: 'true', REDIS_URI: `redis://${name}-redis:6379` } : {}),
|
|
267
|
+
...(search
|
|
268
|
+
? {
|
|
269
|
+
SEARCH: 'true',
|
|
270
|
+
MEILI_HOST: `http://${name}-meilisearch:7700`,
|
|
271
|
+
MEILI_NO_SYNC: 'false',
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
274
|
+
...(sso
|
|
275
|
+
? {
|
|
276
|
+
ALLOW_SOCIAL_LOGIN: 'true',
|
|
277
|
+
DOMAIN_SERVER: `https://${host}`,
|
|
278
|
+
DOMAIN_CLIENT: `https://${host}`,
|
|
279
|
+
OPENID_ISSUER: sso.issuer,
|
|
280
|
+
OPENID_CLIENT_ID: sso.clientId,
|
|
281
|
+
OPENID_SCOPES: sso.scopes ?? 'openid profile email',
|
|
282
|
+
OPENID_CALLBACK_URL: `https://${host}/oauth/openid/callback`,
|
|
283
|
+
}
|
|
284
|
+
: {}),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The username is an identifier, not a secret — when the caller supplies
|
|
288
|
+
// it explicitly it is inlined as plain env; otherwise it is read from the
|
|
289
|
+
// MongoDB credentials secret (key: username) like the password.
|
|
290
|
+
if (mongodb.username) {
|
|
291
|
+
env.MONGO_USERNAME = mongodb.username
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Credentials delivered via secretKeyRef (runtime injection). JWT_SECRET /
|
|
295
|
+
// JWT_REFRESH_SECRET / CREDS_KEY / CREDS_IV back multi-user sessions:
|
|
296
|
+
// per-user message encryption and refresh-token issuance need shared
|
|
297
|
+
// random values across replicas. Hex sizing: jwtSecret, jwtRefreshSecret
|
|
298
|
+
// and credsKey are 64 hex chars (32 bytes); credsIv is 32 hex chars
|
|
299
|
+
// (16 bytes).
|
|
300
|
+
const secrets: Record<string, SecretRef | string> = {
|
|
301
|
+
...(mongodb.username
|
|
302
|
+
? {}
|
|
303
|
+
: { MONGO_USERNAME: { secret: mongodb.passwordSecret, key: 'username' } }),
|
|
304
|
+
MONGO_PASSWORD: { secret: mongodb.passwordSecret, key: 'password' },
|
|
305
|
+
SECRET_KEY: { secret: appSecretsName, key: 'secretKey' },
|
|
306
|
+
OPENAI_API_KEY: { secret: appSecretsName, key: 'modelApiKey' },
|
|
307
|
+
JWT_SECRET: { secret: appSecretsName, key: 'jwtSecret' },
|
|
308
|
+
JWT_REFRESH_SECRET: { secret: appSecretsName, key: 'jwtRefreshSecret' },
|
|
309
|
+
CREDS_KEY: { secret: appSecretsName, key: 'credsKey' },
|
|
310
|
+
CREDS_IV: { secret: appSecretsName, key: 'credsIv' },
|
|
311
|
+
...(search ? { MEILI_MASTER_KEY: { secret: appSecretsName, key: 'meiliMasterKey' } } : {}),
|
|
312
|
+
...(sso ? { OPENID_CLIENT_SECRET: sso.clientSecretRef } : {}),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- Runtime config (non-secret) -------------------------------------------
|
|
316
|
+
// REFRESH_TOKEN_EXPIRY is a plain TTL (7 days), not a credential — it is
|
|
317
|
+
// rendered as a ConfigMap and injected via configMapKeyRef, keeping
|
|
318
|
+
// plaintext env wiring (and the no-plaintext-secrets guardrail) clean.
|
|
319
|
+
const configMapName = `${name}-config`
|
|
320
|
+
const configMap: ConfigMap = {
|
|
321
|
+
apiVersion: 'v1',
|
|
322
|
+
kind: 'ConfigMap',
|
|
323
|
+
metadata: { name: configMapName, namespace },
|
|
324
|
+
data: { REFRESH_TOKEN_EXPIRY: '604800' },
|
|
325
|
+
}
|
|
326
|
+
resources_.push(jsx('ConfigMap', configMap))
|
|
327
|
+
|
|
328
|
+
// --- App + optional Meilisearch + endpoint --------------------------------------
|
|
329
|
+
resources_.push(
|
|
330
|
+
<WebService
|
|
331
|
+
name={name}
|
|
332
|
+
namespace={namespace}
|
|
333
|
+
image={`ghcr.io/danny-avila/librechat:${version}`}
|
|
334
|
+
port={port}
|
|
335
|
+
replicas={replicas}
|
|
336
|
+
resources={resources}
|
|
337
|
+
env={env}
|
|
338
|
+
secrets={secrets}
|
|
339
|
+
rawEnv={[
|
|
340
|
+
{
|
|
341
|
+
name: 'REFRESH_TOKEN_EXPIRY',
|
|
342
|
+
valueFrom: { configMapKeyRef: { name: configMapName, key: 'REFRESH_TOKEN_EXPIRY' } },
|
|
343
|
+
} as EnvVar,
|
|
344
|
+
]}
|
|
345
|
+
/>
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if (search) {
|
|
349
|
+
resources_.push(
|
|
350
|
+
<WebService
|
|
351
|
+
name={`${name}-meilisearch`}
|
|
352
|
+
namespace={namespace}
|
|
353
|
+
image="getmeili/meilisearch:v1.6"
|
|
354
|
+
port={7700}
|
|
355
|
+
replicas={1}
|
|
356
|
+
env={{ MEILI_NO_ANALYTICS: 'true', MEILI_ENV: 'production' }}
|
|
357
|
+
secrets={{ MEILI_MASTER_KEY: { secret: appSecretsName, key: 'meiliMasterKey' } }}
|
|
358
|
+
probes={{ liveness: { path: '/health' }, readiness: { path: '/health' } }}
|
|
359
|
+
resources={{
|
|
360
|
+
requests: { memory: '256Mi', cpu: '100m' },
|
|
361
|
+
limits: { memory: '1Gi', cpu: '500m' },
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
resources_.push(
|
|
368
|
+
<Endpoint
|
|
369
|
+
name={`${name}-endpoint`}
|
|
370
|
+
namespace={namespace}
|
|
371
|
+
host={host}
|
|
372
|
+
serviceName={name}
|
|
373
|
+
servicePort={port}
|
|
374
|
+
tls={tls}
|
|
375
|
+
annotations={{
|
|
376
|
+
'nginx.ingress.kubernetes.io/proxy-read-timeout': '300',
|
|
377
|
+
'nginx.ingress.kubernetes.io/proxy-send-timeout': '300',
|
|
378
|
+
'nginx.ingress.kubernetes.io/proxy-buffering': 'off',
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return jsx(Fragment, { children: resources_ })
|
|
384
|
+
}
|
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
|
+
}
|