@r8s/chromadb 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__/chromadb.test.ts +374 -0
- package/examples/basic.tsx +3 -0
- package/package.json +41 -0
- package/src/index.tsx +368 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,374 @@
|
|
|
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
|
+
// ChromaDb recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, all props, gateway/ingress adaptation
|
|
11
|
+
// 3. Security: no plaintext credentials in rendered output
|
|
12
|
+
import { ChromaDb } from '../src/index'
|
|
13
|
+
|
|
14
|
+
/** Render ChromaDb inside a Platform-like secrets backend (OpenBao). */
|
|
15
|
+
function renderChromaDb(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
16
|
+
return render(
|
|
17
|
+
jsx(SecretContext.Provider, {
|
|
18
|
+
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
19
|
+
children: jsx(ChromaDb, props as never),
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Render ChromaDb wrapped only in an OperatorContext (no secrets backend). */
|
|
25
|
+
function renderChromaDbWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
|
|
26
|
+
return jsx(OperatorContext.Provider, {
|
|
27
|
+
value: operators_,
|
|
28
|
+
children: jsx(ChromaDb, props as never),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
33
|
+
|
|
34
|
+
describe('operator declarations', () => {
|
|
35
|
+
it('declares the cnpg operator via the Database recipe when pg is enabled', () => {
|
|
36
|
+
const result = renderChromaDb({ host: 'vectors.example.com', pg: true })
|
|
37
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('requires no cnpg operator without pg', () => {
|
|
41
|
+
const result = renderChromaDb({ host: 'vectors.example.com' })
|
|
42
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('deduplicates operators provided via context', () => {
|
|
46
|
+
const result = render(
|
|
47
|
+
renderChromaDbWithContext([operators['cnpg']()], {
|
|
48
|
+
host: 'vectors.example.com',
|
|
49
|
+
pg: true,
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
const names = result.operators.map((op) => op.name)
|
|
53
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('allows version overrides through context operators', () => {
|
|
57
|
+
const result = render(
|
|
58
|
+
renderChromaDbWithContext([operators['cnpg']('1.26.0')], {
|
|
59
|
+
host: 'vectors.example.com',
|
|
60
|
+
pg: true,
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
const cnpg = result.operators.filter((op) => op.name === 'cnpg')
|
|
64
|
+
expect(cnpg).toHaveLength(1)
|
|
65
|
+
expect(cnpg[0].version).toBe('1.26.0')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('rendering defaults', () => {
|
|
70
|
+
it('renders PVC, deployment, service and endpoint', () => {
|
|
71
|
+
const result = renderChromaDb({ host: 'vectors.example.com' })
|
|
72
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
73
|
+
expect(kinds).toContain('PersistentVolumeClaim')
|
|
74
|
+
expect(kinds).toContain('Deployment')
|
|
75
|
+
expect(kinds).toContain('Service')
|
|
76
|
+
expect(kinds).toContain('Ingress')
|
|
77
|
+
expect(kinds).not.toContain('Cluster')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('defaults to a single replica (RWO-safe)', () => {
|
|
81
|
+
const result = renderChromaDb({ host: 'vectors.example.com' })
|
|
82
|
+
const deployment = result.resources.find(
|
|
83
|
+
(r) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
84
|
+
) as any
|
|
85
|
+
expect(deployment.spec.replicas).toBe(1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders the data PVC with the requested storage', () => {
|
|
89
|
+
const result = renderChromaDb({ host: 'vectors.example.com' })
|
|
90
|
+
const pvc = result.resources.find((r) => r.kind === 'PersistentVolumeClaim') as any
|
|
91
|
+
expect(pvc.metadata.name).toBe('chromadb-data')
|
|
92
|
+
expect(pvc.spec.resources.requests.storage).toBe('50Gi')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('wires the v2 heartbeat health checks on port 8000', () => {
|
|
96
|
+
const result = renderChromaDb({ host: 'vectors.example.com' })
|
|
97
|
+
const deployment = result.resources.find(
|
|
98
|
+
(r) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
99
|
+
) as any
|
|
100
|
+
const container = deployment.spec.template.spec.containers[0]
|
|
101
|
+
expect(container.livenessProbe.httpGet.path).toBe('/api/v2/heartbeat')
|
|
102
|
+
expect(container.livenessProbe.httpGet.port).toBe(8000)
|
|
103
|
+
expect(container.readinessProbe.httpGet.path).toBe('/api/v2/heartbeat')
|
|
104
|
+
expect(container.ports[0].containerPort).toBe(8000)
|
|
105
|
+
expect(container.volumeMounts).toContainEqual({ name: 'data', mountPath: '/data' })
|
|
106
|
+
expect(deployment.spec.template.spec.volumes[0].persistentVolumeClaim.claimName).toBe(
|
|
107
|
+
'chromadb-data'
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('supports the probePath prop for older v1-API images', () => {
|
|
112
|
+
const result = renderChromaDb({
|
|
113
|
+
host: 'vectors.example.com',
|
|
114
|
+
probePath: '/api/v1/heartbeat',
|
|
115
|
+
})
|
|
116
|
+
const deployment = result.resources.find(
|
|
117
|
+
(r) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
118
|
+
) as any
|
|
119
|
+
const container = deployment.spec.template.spec.containers[0]
|
|
120
|
+
expect(container.livenessProbe.httpGet.path).toBe('/api/v1/heartbeat')
|
|
121
|
+
expect(container.readinessProbe.httpGet.path).toBe('/api/v1/heartbeat')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('wires CHROMA_SERVER_HTTP_PORT to the port prop on a custom port', () => {
|
|
125
|
+
const result = renderChromaDb({ host: 'vectors.example.com', port: 9000 })
|
|
126
|
+
const deployment = result.resources.find(
|
|
127
|
+
(r) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
128
|
+
) as any
|
|
129
|
+
const container = deployment.spec.template.spec.containers[0]
|
|
130
|
+
const httpPort = container.env.find((e: any) => e.name === 'CHROMA_SERVER_HTTP_PORT')
|
|
131
|
+
expect(httpPort.value).toBe('9000')
|
|
132
|
+
expect(container.ports[0].containerPort).toBe(9000)
|
|
133
|
+
const service = result.resources.find(
|
|
134
|
+
(r) => r.kind === 'Service' && r.metadata.name === 'chromadb'
|
|
135
|
+
) as any
|
|
136
|
+
expect(service.spec.ports[0].port).toBe(9000)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
140
|
+
const result = render(
|
|
141
|
+
jsx(RoutingContext.Provider, {
|
|
142
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
143
|
+
children: jsx(SecretContext.Provider, {
|
|
144
|
+
value: openbao as never,
|
|
145
|
+
children: jsx(ChromaDb, { host: 'vectors.example.com' }),
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
150
|
+
expect(kinds).toContain('HTTPRoute')
|
|
151
|
+
expect(kinds).not.toContain('Ingress')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
155
|
+
const result = render(
|
|
156
|
+
jsx(RoutingContext.Provider, {
|
|
157
|
+
value: { mode: 'ingress' },
|
|
158
|
+
children: jsx(SecretContext.Provider, {
|
|
159
|
+
value: openbao as never,
|
|
160
|
+
children: jsx(ChromaDb, { host: 'vectors.example.com' }),
|
|
161
|
+
}),
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
165
|
+
expect(ingress).toBeDefined()
|
|
166
|
+
expect(ingress.spec.rules[0].host).toBe('vectors.example.com')
|
|
167
|
+
expect(ingress.spec.rules[0].http.paths[0].backend.service.port.number).toBe(8000)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('passes resource validation', () => {
|
|
171
|
+
const result = renderChromaDb({
|
|
172
|
+
host: 'vectors.example.com',
|
|
173
|
+
pg: true,
|
|
174
|
+
autoscaling: true,
|
|
175
|
+
storageClassName: 'fast-ssd-rwx',
|
|
176
|
+
auth: true,
|
|
177
|
+
authTokenSecretName: 'chromadb-auth-token',
|
|
178
|
+
})
|
|
179
|
+
for (const resource of result.resources) {
|
|
180
|
+
expect(validateResource(resource)).toEqual([])
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('rendering with all props', () => {
|
|
186
|
+
it('accepts the full prop surface', () => {
|
|
187
|
+
const result = renderChromaDb({
|
|
188
|
+
name: 'vectors',
|
|
189
|
+
namespace: 'ai',
|
|
190
|
+
version: '0.6.3',
|
|
191
|
+
host: 'vectors.example.com',
|
|
192
|
+
port: 8000,
|
|
193
|
+
replicas: 3,
|
|
194
|
+
storage: '100Gi',
|
|
195
|
+
storageClassName: 'fast-ssd-rwx',
|
|
196
|
+
auth: true,
|
|
197
|
+
authTokenSecretName: 'vectors-auth-token',
|
|
198
|
+
autoscaling: true,
|
|
199
|
+
pg: true,
|
|
200
|
+
resources: {
|
|
201
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
202
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
203
|
+
},
|
|
204
|
+
tls: { secretName: 'vectors-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
205
|
+
})
|
|
206
|
+
const deployment = result.resources.find(
|
|
207
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'vectors'
|
|
208
|
+
) as any
|
|
209
|
+
expect(deployment.spec.template.spec.containers[0].image).toBe(
|
|
210
|
+
'ghcr.io/chroma-core/chroma:0.6.3'
|
|
211
|
+
)
|
|
212
|
+
expect(deployment.spec.replicas).toBe(3)
|
|
213
|
+
expect(deployment.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
214
|
+
|
|
215
|
+
const pvc = result.resources.find((r) => r.kind === 'PersistentVolumeClaim') as any
|
|
216
|
+
expect(pvc.metadata.name).toBe('vectors-data')
|
|
217
|
+
expect(pvc.spec.resources.requests.storage).toBe('100Gi')
|
|
218
|
+
expect(pvc.spec.storageClassName).toBe('fast-ssd-rwx')
|
|
219
|
+
|
|
220
|
+
const hpa = result.resources.find((r) => r.kind === 'HorizontalPodAutoscaler') as any
|
|
221
|
+
expect(hpa).toBeDefined()
|
|
222
|
+
expect(hpa.spec.scaleTargetRef).toMatchObject({ kind: 'Deployment', name: 'vectors' })
|
|
223
|
+
expect(hpa.spec.minReplicas).toBe(3)
|
|
224
|
+
expect(hpa.spec.maxReplicas).toBe(9)
|
|
225
|
+
expect(hpa.spec.metrics[0].resource.target.averageUtilization).toBe(70)
|
|
226
|
+
|
|
227
|
+
const cluster = result.resources.find((r) => r.kind === 'Cluster') as any
|
|
228
|
+
expect(cluster).toBeDefined()
|
|
229
|
+
expect(cluster.metadata.name).toBe('vectors-meta')
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('secrets handling', () => {
|
|
234
|
+
it('accepts an explicit authTokenSecretName without a backend', () => {
|
|
235
|
+
expect(() =>
|
|
236
|
+
render(
|
|
237
|
+
jsx(ChromaDb, {
|
|
238
|
+
host: 'vectors.example.com',
|
|
239
|
+
auth: true,
|
|
240
|
+
authTokenSecretName: 'existing-token-secret',
|
|
241
|
+
})
|
|
242
|
+
)
|
|
243
|
+
).not.toThrow()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('provisions the auth token through a secrets backend at <path>/<name>/auth-token', () => {
|
|
247
|
+
const result = renderChromaDb({ host: 'vectors.example.com', auth: true })
|
|
248
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
249
|
+
expect(kinds).toContain('OpenBaoStaticSecret')
|
|
250
|
+
const secret = result.resources.find((r) => r.kind === 'OpenBaoStaticSecret') as any
|
|
251
|
+
expect(secret.metadata.name).toBe('chromadb-auth-token')
|
|
252
|
+
expect(secret.spec.path).toBe('test/chromadb/auth-token')
|
|
253
|
+
expect(secret.spec.destination.name).toBe('chromadb-auth-token')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('provisions the auth token through vault', () => {
|
|
257
|
+
const result = render(
|
|
258
|
+
jsx(SecretContext.Provider, {
|
|
259
|
+
value: { backend: 'vault', mount: 'kv', path: 'ai' },
|
|
260
|
+
children: jsx(ChromaDb, { name: 'vectors', host: 'vectors.example.com', auth: true }),
|
|
261
|
+
})
|
|
262
|
+
)
|
|
263
|
+
const secret = result.resources.find((r) => r.kind === 'VaultStaticSecret') as any
|
|
264
|
+
expect(secret).toBeDefined()
|
|
265
|
+
expect(secret.spec.path).toBe('ai/vectors/auth-token')
|
|
266
|
+
expect(secret.spec.destination.name).toBe('vectors-auth-token')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('wires the backend-provisioned auth token via secretKeyRef', () => {
|
|
270
|
+
const result = renderChromaDb({ host: 'vectors.example.com', auth: true })
|
|
271
|
+
const deployment = result.resources.find(
|
|
272
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
273
|
+
) as any
|
|
274
|
+
const env = deployment.spec.template.spec.containers[0].env
|
|
275
|
+
const token = env.find((e: any) => e.name === 'CHROMA_SERVER_AUTH_CREDENTIALS')
|
|
276
|
+
expect(token.valueFrom.secretKeyRef.name).toBe('chromadb-auth-token')
|
|
277
|
+
expect(token.valueFrom.secretKeyRef.key).toBe('token')
|
|
278
|
+
expect(token.value).toBeUndefined()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('wires the auth token via secretKeyRef (never plaintext env)', () => {
|
|
282
|
+
const result = renderChromaDb({
|
|
283
|
+
host: 'vectors.example.com',
|
|
284
|
+
auth: true,
|
|
285
|
+
authTokenSecretName: 'existing-token-secret',
|
|
286
|
+
})
|
|
287
|
+
const deployment = result.resources.find(
|
|
288
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
289
|
+
) as any
|
|
290
|
+
const env = deployment.spec.template.spec.containers[0].env
|
|
291
|
+
const token = env.find((e: any) => e.name === 'CHROMA_SERVER_AUTH_CREDENTIALS')
|
|
292
|
+
expect(token.valueFrom.secretKeyRef.name).toBe('existing-token-secret')
|
|
293
|
+
expect(token.valueFrom.secretKeyRef.key).toBe('token')
|
|
294
|
+
expect(token.value).toBeUndefined()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('wires Postgres credentials via secretKeyRef when pg is enabled', () => {
|
|
298
|
+
const result = renderChromaDb({ host: 'vectors.example.com', pg: true })
|
|
299
|
+
const deployment = result.resources.find(
|
|
300
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'chromadb'
|
|
301
|
+
) as any
|
|
302
|
+
const env = deployment.spec.template.spec.containers[0].env
|
|
303
|
+
const host = env.find((e: any) => e.name === 'CHROMA_POSTGRES_HOST')
|
|
304
|
+
const password = env.find((e: any) => e.name === 'CHROMA_POSTGRES_PASSWORD')
|
|
305
|
+
expect(host.value).toBe('chromadb-meta-rw')
|
|
306
|
+
expect(password.valueFrom.secretKeyRef.name).toBe('chromadb-meta-db-credentials')
|
|
307
|
+
expect(password.valueFrom.secretKeyRef.key).toBe('password')
|
|
308
|
+
expect(password.value).toBeUndefined()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('provisions Postgres credentials through the openbao backend when pg is enabled', () => {
|
|
312
|
+
const result = renderChromaDb({ host: 'vectors.example.com', pg: true })
|
|
313
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
314
|
+
expect(kinds).toContain('OpenBaoStaticSecret')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('provisions Postgres credentials through vault when pg is enabled', () => {
|
|
318
|
+
const result = render(
|
|
319
|
+
jsx(SecretContext.Provider, {
|
|
320
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
321
|
+
children: jsx(ChromaDb, { host: 'vectors.example.com', pg: true }),
|
|
322
|
+
})
|
|
323
|
+
)
|
|
324
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
325
|
+
expect(kinds).toContain('VaultStaticSecret')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
329
|
+
const result = renderChromaDb({
|
|
330
|
+
name: 'vectors',
|
|
331
|
+
host: 'vectors.example.com',
|
|
332
|
+
replicas: 3,
|
|
333
|
+
storageClassName: 'vectors-rwx',
|
|
334
|
+
auth: true,
|
|
335
|
+
authTokenSecretName: 'vectors-auth-token',
|
|
336
|
+
autoscaling: true,
|
|
337
|
+
pg: true,
|
|
338
|
+
})
|
|
339
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
340
|
+
if (!passed) {
|
|
341
|
+
console.error('Plaintext credential violations:', errors)
|
|
342
|
+
}
|
|
343
|
+
expect(passed).toBe(true)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('validation errors', () => {
|
|
348
|
+
it('throws when auth is enabled without an auth token secret or backend', () => {
|
|
349
|
+
expect(() => render(jsx(ChromaDb, { host: 'vectors.example.com', auth: true }))).toThrow(
|
|
350
|
+
/auth token/
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('throws when autoscaling on RWO storage without an RWX StorageClass', () => {
|
|
355
|
+
expect(() =>
|
|
356
|
+
renderChromaDb({
|
|
357
|
+
host: 'vectors.example.com',
|
|
358
|
+
autoscaling: true,
|
|
359
|
+
storageClassName: 'fast-ssd',
|
|
360
|
+
})
|
|
361
|
+
).toThrow(/Multi-Attach/)
|
|
362
|
+
expect(() => renderChromaDb({ host: 'vectors.example.com', autoscaling: true })).toThrow(
|
|
363
|
+
/ReadWriteMany/
|
|
364
|
+
)
|
|
365
|
+
// Explicit RWX class names (any case) are allowed
|
|
366
|
+
expect(() =>
|
|
367
|
+
renderChromaDb({
|
|
368
|
+
host: 'vectors.example.com',
|
|
369
|
+
autoscaling: true,
|
|
370
|
+
storageClassName: 'NFS-RWX',
|
|
371
|
+
})
|
|
372
|
+
).not.toThrow()
|
|
373
|
+
})
|
|
374
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/chromadb",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "ChromaDB vector database — persistent storage, optional Postgres metadata backend, token auth, CPU autoscaling",
|
|
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/chromadb"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"chromadb",
|
|
16
|
+
"vector-database",
|
|
17
|
+
"embeddings",
|
|
18
|
+
"rag"
|
|
19
|
+
],
|
|
20
|
+
"r8s": {
|
|
21
|
+
"category": "AI & Embeddings"
|
|
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,368 @@
|
|
|
1
|
+
import { jsx, Fragment, useContext } from '@r8s/core'
|
|
2
|
+
import { SecretContext } from '@r8s/core/defaults'
|
|
3
|
+
import { Database, Endpoint } from '@r8s/recipes'
|
|
4
|
+
|
|
5
|
+
export interface ChromaDbProps {
|
|
6
|
+
/** Resource name (defaults to 'chromadb') */
|
|
7
|
+
name?: string
|
|
8
|
+
/** Kubernetes namespace (defaults to 'default') */
|
|
9
|
+
namespace?: string
|
|
10
|
+
/** Container image tag (defaults to 'latest' — pin a version in production) */
|
|
11
|
+
version?: string
|
|
12
|
+
/** Public hostname for the vector API (required) */
|
|
13
|
+
host: string
|
|
14
|
+
/** Port Chroma listens on (defaults to 8000) */
|
|
15
|
+
port?: number
|
|
16
|
+
/**
|
|
17
|
+
* Number of replicas (defaults to 1). The embedded data PVC is
|
|
18
|
+
* ReadWriteOnce — extra replicas on other nodes cause Multi-Attach
|
|
19
|
+
* errors; scale out only with a ReadWriteMany StorageClass via
|
|
20
|
+
* `storageClassName` (required for `autoscaling`).
|
|
21
|
+
*/
|
|
22
|
+
replicas?: number
|
|
23
|
+
/** Persistent storage size for the embedded data volume (defaults to '50Gi') */
|
|
24
|
+
storage?: string
|
|
25
|
+
/** StorageClass for the data PersistentVolumeClaim (optional — cluster default) */
|
|
26
|
+
storageClassName?: string
|
|
27
|
+
/**
|
|
28
|
+
* HTTP path for liveness/readiness probes (defaults to
|
|
29
|
+
* '/api/v2/heartbeat' — the current image API). Set '/api/v1/heartbeat'
|
|
30
|
+
* for older images still serving the v1 API.
|
|
31
|
+
*/
|
|
32
|
+
probePath?: string
|
|
33
|
+
/** Require token authentication for the server (defaults to false) */
|
|
34
|
+
auth?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* Name of an existing Secret holding key `token` — the Chroma server
|
|
37
|
+
* auth credential. Required when `auth` is true, unless a secrets
|
|
38
|
+
* backend (openbao/vault) is configured on the surrounding Platform —
|
|
39
|
+
* the backend then provisions the token at path
|
|
40
|
+
* `<path>/<name>/auth-token` (key: token). Plaintext credentials are
|
|
41
|
+
* not supported.
|
|
42
|
+
*/
|
|
43
|
+
authTokenSecretName?: string
|
|
44
|
+
/** Autoscale the Deployment via a CPU-based HorizontalPodAutoscaler (defaults to false) */
|
|
45
|
+
autoscaling?: boolean
|
|
46
|
+
/** Provision a CNPG Postgres cluster as the metadata store (defaults to false) */
|
|
47
|
+
pg?: boolean
|
|
48
|
+
/** Requested resources */
|
|
49
|
+
resources?: {
|
|
50
|
+
requests?: { cpu?: string; memory?: string }
|
|
51
|
+
limits?: { cpu?: string; memory?: string }
|
|
52
|
+
}
|
|
53
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
54
|
+
tls?: {
|
|
55
|
+
secretName: string
|
|
56
|
+
clusterIssuer: string
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ChromaDB — open-source vector database for AI embeddings.
|
|
62
|
+
*
|
|
63
|
+
* @title ChromaDb
|
|
64
|
+
* @category AI & Embeddings
|
|
65
|
+
*
|
|
66
|
+
* Composes:
|
|
67
|
+
* - PersistentVolumeClaim (`<name>-data`) mounted at /data for embedded
|
|
68
|
+
* persistence (Chroma's default persist directory in the official image)
|
|
69
|
+
* - Chroma Deployment + Service (port 8000, heartbeat health checks,
|
|
70
|
+
* /api/v2/heartbeat by default — override with `probePath` for older
|
|
71
|
+
* v1-API images)
|
|
72
|
+
* - Endpoint (nginx Ingress or Envoy Gateway depending on RoutingContext)
|
|
73
|
+
* - Optional CNPG Postgres cluster for the metadata store (`pg`)
|
|
74
|
+
* - Optional CPU-based HorizontalPodAutoscaler (`autoscaling`) — requires
|
|
75
|
+
* a ReadWriteMany StorageClass (`storageClassName` ending in 'rwx')
|
|
76
|
+
* - Optional token auth (`auth`): provisioned at path
|
|
77
|
+
* `<path>/<name>/auth-token` through the Platform secrets backend
|
|
78
|
+
* (openbao / vault), or referenced from an existing Secret (key: token)
|
|
79
|
+
*
|
|
80
|
+
* ChromaDB has no dedicated operator — it runs as a plain Deployment.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* import { ChromaDb } from '@r8s/chromadb'
|
|
84
|
+
*
|
|
85
|
+
* export default <ChromaDb name="vectors" host="vectors.example.com" />
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* import { Platform } from '@r8s/recipes'
|
|
89
|
+
* import { ChromaDb } from '@r8s/chromadb'
|
|
90
|
+
*
|
|
91
|
+
* export default (
|
|
92
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'ai' }}>
|
|
93
|
+
* <ChromaDb
|
|
94
|
+
* name="vectors"
|
|
95
|
+
* host="vectors.example.com"
|
|
96
|
+
* storage="50Gi"
|
|
97
|
+
* replicas={2}
|
|
98
|
+
* storageClassName="nfs-rwx"
|
|
99
|
+
* pg
|
|
100
|
+
* autoscaling
|
|
101
|
+
* auth
|
|
102
|
+
* />
|
|
103
|
+
* </Platform>
|
|
104
|
+
* )
|
|
105
|
+
*/
|
|
106
|
+
export function ChromaDb(props: ChromaDbProps) {
|
|
107
|
+
const {
|
|
108
|
+
name = 'chromadb',
|
|
109
|
+
namespace = 'default',
|
|
110
|
+
version = 'latest',
|
|
111
|
+
host,
|
|
112
|
+
port = 8000,
|
|
113
|
+
replicas = 1,
|
|
114
|
+
storage = '50Gi',
|
|
115
|
+
storageClassName,
|
|
116
|
+
probePath = '/api/v2/heartbeat',
|
|
117
|
+
auth = false,
|
|
118
|
+
authTokenSecretName,
|
|
119
|
+
autoscaling = false,
|
|
120
|
+
pg = false,
|
|
121
|
+
resources = {
|
|
122
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
123
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
124
|
+
},
|
|
125
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
126
|
+
} = props
|
|
127
|
+
|
|
128
|
+
const secretProvider = useContext(SecretContext)
|
|
129
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
130
|
+
|
|
131
|
+
const tokenSecretName = authTokenSecretName ?? `${name}-auth-token`
|
|
132
|
+
|
|
133
|
+
// --- Autoscaling coherence --------------------------------------------------
|
|
134
|
+
// The data PVC is ReadWriteOnce: a HorizontalPodAutoscaler scaling to
|
|
135
|
+
// replicas on other nodes triggers Multi-Attach errors. Scaling out is
|
|
136
|
+
// only safe on a ReadWriteMany StorageClass.
|
|
137
|
+
if (autoscaling && !(storageClassName && storageClassName.toLowerCase().endsWith('rwx'))) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`ChromaDb "${name}" cannot autoscale on ReadWriteOnce storage.\n` +
|
|
140
|
+
`\n` +
|
|
141
|
+
`The data PVC uses ReadWriteOnce — scaling to replicas beyond one node ` +
|
|
142
|
+
`triggers Kubernetes Multi-Attach errors. A HorizontalPodAutoscaler ` +
|
|
143
|
+
`needs a ReadWriteMany (RWX) StorageClass.\n` +
|
|
144
|
+
`\n` +
|
|
145
|
+
`Fix: point storageClassName at a ReadWriteMany StorageClass ` +
|
|
146
|
+
`(its name must end with 'rwx'):\n` +
|
|
147
|
+
` <ChromaDb name="${name}" host="${host}" storageClassName="nfs-rwx" autoscaling />`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Auth token — provisioned through the backend or referenced -------------
|
|
152
|
+
// The token authenticates every request to the vector store — it must
|
|
153
|
+
// never be rendered as plaintext.
|
|
154
|
+
if (auth && !authTokenSecretName) {
|
|
155
|
+
if (
|
|
156
|
+
!secretProvider ||
|
|
157
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`ChromaDb "${name}" requires an auth token (key: token).\n` +
|
|
161
|
+
`\n` +
|
|
162
|
+
`The CHROMA_SERVER_AUTH_CREDENTIALS token authenticates every request ` +
|
|
163
|
+
`to the vector store — it must not be rendered as plaintext.\n` +
|
|
164
|
+
`\n` +
|
|
165
|
+
`Fix: configure a secrets backend on the Platform holding the token ` +
|
|
166
|
+
`at path <mount-path>/${name}/auth-token:\n` +
|
|
167
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'ai' }}>\n` +
|
|
168
|
+
` <ChromaDb name="${name}" host="${host}" auth={true} />\n` +
|
|
169
|
+
` </Platform>\n` +
|
|
170
|
+
`\n` +
|
|
171
|
+
`Or reference a pre-created Secret (key: token):\n` +
|
|
172
|
+
` <ChromaDb name="${name}" host="${host}" auth={true} authTokenSecretName="${name}-auth-token" />`
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const spec = {
|
|
177
|
+
...(secretProvider.backend === 'vault'
|
|
178
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
179
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
180
|
+
mount: secretProvider.mount,
|
|
181
|
+
type: 'kv-v2' as const,
|
|
182
|
+
path: `${secretProvider.path ?? name}/${name}/auth-token`,
|
|
183
|
+
destination: { create: true, name: tokenSecretName },
|
|
184
|
+
}
|
|
185
|
+
resources_.push(
|
|
186
|
+
secretProvider.backend === 'vault'
|
|
187
|
+
? jsx('VaultStaticSecret', {
|
|
188
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
189
|
+
kind: 'VaultStaticSecret',
|
|
190
|
+
metadata: { name: `${name}-auth-token`, namespace },
|
|
191
|
+
spec,
|
|
192
|
+
})
|
|
193
|
+
: jsx('OpenBaoStaticSecret', {
|
|
194
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
195
|
+
kind: 'OpenBaoStaticSecret',
|
|
196
|
+
metadata: { name: `${name}-auth-token`, namespace },
|
|
197
|
+
spec,
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- Env wiring -------------------------------------------------------------
|
|
203
|
+
// Only CHROMA_* env vars — every credential arrives via secretKeyRef at
|
|
204
|
+
// runtime (pushed first so dependent $(VAR) expansion resolves).
|
|
205
|
+
const envVars: {
|
|
206
|
+
name: string
|
|
207
|
+
value?: string
|
|
208
|
+
valueFrom?: { secretKeyRef: { name: string; key: string } }
|
|
209
|
+
}[] = []
|
|
210
|
+
|
|
211
|
+
// The listener port must track the `port` prop or the server ignores it
|
|
212
|
+
// and binds the image default (8000), breaking the Service and probes.
|
|
213
|
+
envVars.push({ name: 'CHROMA_SERVER_HTTP_PORT', value: String(port) })
|
|
214
|
+
|
|
215
|
+
if (pg) {
|
|
216
|
+
// Metadata store: CNPG cluster provisioned as `${name}-meta`. Its
|
|
217
|
+
// connection info follows the Database recipe convention.
|
|
218
|
+
const dbHost = `${name}-meta-rw`
|
|
219
|
+
envVars.push(
|
|
220
|
+
{ name: 'CHROMA_POSTGRES_HOST', value: dbHost },
|
|
221
|
+
{ name: 'CHROMA_POSTGRES_PORT', value: '5432' },
|
|
222
|
+
{ name: 'CHROMA_POSTGRES_DATABASE', value: `${name}-meta` },
|
|
223
|
+
{ name: 'CHROMA_POSTGRES_USER', value: `${name}-meta` },
|
|
224
|
+
{
|
|
225
|
+
name: 'CHROMA_POSTGRES_PASSWORD',
|
|
226
|
+
valueFrom: {
|
|
227
|
+
secretKeyRef: { name: `${name}-meta-db-credentials`, key: 'password' },
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (auth) {
|
|
234
|
+
envVars.push(
|
|
235
|
+
{
|
|
236
|
+
name: 'CHROMA_SERVER_AUTH_CREDENTIALS',
|
|
237
|
+
valueFrom: { secretKeyRef: { name: tokenSecretName, key: 'token' } },
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'CHROMA_SERVER_AUTH_PROVIDER',
|
|
241
|
+
value: 'chromadb.auth.token.TokenAuthenticationServerProvider',
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Data-plane resources ----------------------------------------------------
|
|
247
|
+
// WebService cannot mount volumes, so the app runs as a raw Deployment
|
|
248
|
+
// with heartbeat probes. Chroma persists to /data by default in the
|
|
249
|
+
// official image.
|
|
250
|
+
const dataPlane: ReturnType<typeof jsx>[] = []
|
|
251
|
+
|
|
252
|
+
dataPlane.push(
|
|
253
|
+
jsx('PersistentVolumeClaim', {
|
|
254
|
+
apiVersion: 'v1',
|
|
255
|
+
kind: 'PersistentVolumeClaim',
|
|
256
|
+
metadata: { name: `${name}-data`, namespace },
|
|
257
|
+
spec: {
|
|
258
|
+
accessModes: ['ReadWriteOnce'],
|
|
259
|
+
...(storageClassName && { storageClassName }),
|
|
260
|
+
resources: { requests: { storage } },
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// --- Deployment + Service (raw jsx — WebService lacks volume mounts) --------
|
|
266
|
+
dataPlane.push(
|
|
267
|
+
jsx('Deployment', {
|
|
268
|
+
apiVersion: 'apps/v1',
|
|
269
|
+
kind: 'Deployment',
|
|
270
|
+
metadata: { name, namespace, labels: { app: name } },
|
|
271
|
+
spec: {
|
|
272
|
+
replicas,
|
|
273
|
+
selector: { matchLabels: { app: name } },
|
|
274
|
+
template: {
|
|
275
|
+
metadata: { labels: { app: name } },
|
|
276
|
+
spec: {
|
|
277
|
+
containers: [
|
|
278
|
+
{
|
|
279
|
+
name: 'chroma',
|
|
280
|
+
image: `ghcr.io/chroma-core/chroma:${version}`,
|
|
281
|
+
imagePullPolicy: 'Always',
|
|
282
|
+
ports: [{ containerPort: port }],
|
|
283
|
+
env: envVars,
|
|
284
|
+
...(resources && { resources }),
|
|
285
|
+
volumeMounts: [{ name: 'data', mountPath: '/data' }],
|
|
286
|
+
livenessProbe: {
|
|
287
|
+
httpGet: { path: probePath, port },
|
|
288
|
+
initialDelaySeconds: 10,
|
|
289
|
+
periodSeconds: 10,
|
|
290
|
+
},
|
|
291
|
+
readinessProbe: {
|
|
292
|
+
httpGet: { path: probePath, port },
|
|
293
|
+
initialDelaySeconds: 5,
|
|
294
|
+
periodSeconds: 5,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
volumes: [{ name: 'data', persistentVolumeClaim: { claimName: `${name}-data` } }],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
jsx('Service', {
|
|
304
|
+
apiVersion: 'v1',
|
|
305
|
+
kind: 'Service',
|
|
306
|
+
metadata: { name, namespace },
|
|
307
|
+
spec: {
|
|
308
|
+
type: 'ClusterIP',
|
|
309
|
+
selector: { app: name },
|
|
310
|
+
ports: [{ name: 'http', port, targetPort: port, protocol: 'TCP' }],
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
// --- Metadata store (CNPG) — wraps the data-plane resources so connection
|
|
316
|
+
// info stays consistent with the Database recipe convention -----------------
|
|
317
|
+
if (pg) {
|
|
318
|
+
resources_.push(
|
|
319
|
+
jsx(Database, {
|
|
320
|
+
name: `${name}-meta`,
|
|
321
|
+
namespace,
|
|
322
|
+
storage: '10Gi',
|
|
323
|
+
children: jsx(Fragment, { children: dataPlane }),
|
|
324
|
+
})
|
|
325
|
+
)
|
|
326
|
+
} else {
|
|
327
|
+
resources_.push(...dataPlane)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- Autoscaling --------------------------------------------------------------
|
|
331
|
+
if (autoscaling) {
|
|
332
|
+
resources_.push(
|
|
333
|
+
jsx('HorizontalPodAutoscaler', {
|
|
334
|
+
apiVersion: 'autoscaling/v2',
|
|
335
|
+
kind: 'HorizontalPodAutoscaler',
|
|
336
|
+
metadata: { name: `${name}-hpa`, namespace },
|
|
337
|
+
spec: {
|
|
338
|
+
scaleTargetRef: { apiVersion: 'apps/v1', kind: 'Deployment', name },
|
|
339
|
+
minReplicas: replicas,
|
|
340
|
+
maxReplicas: replicas * 3,
|
|
341
|
+
metrics: [
|
|
342
|
+
{
|
|
343
|
+
type: 'Resource',
|
|
344
|
+
resource: {
|
|
345
|
+
name: 'cpu',
|
|
346
|
+
target: { type: 'Utilization', averageUtilization: 70 },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
})
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Endpoint ------------------------------------------------------------------
|
|
356
|
+
resources_.push(
|
|
357
|
+
<Endpoint
|
|
358
|
+
name={`${name}-endpoint`}
|
|
359
|
+
namespace={namespace}
|
|
360
|
+
host={host}
|
|
361
|
+
serviceName={name}
|
|
362
|
+
servicePort={port}
|
|
363
|
+
tls={tls}
|
|
364
|
+
/>
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return jsx(Fragment, { children: resources_ })
|
|
368
|
+
}
|
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
|
+
}
|