@r8s/eneo 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__/eneo.test.ts +422 -0
- package/examples/basic.tsx +16 -0
- package/package.json +41 -0
- package/src/index.tsx +292 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, jsx } from '@r8s/core'
|
|
3
|
+
import { Namespace, 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
|
+
// Eneo recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, all props, gateway/ingress adaptation, dbStorage
|
|
11
|
+
// 3. Namespace inheritance from the Platform context
|
|
12
|
+
// 4. Security: no plaintext credentials in rendered output
|
|
13
|
+
import { Eneo } from '../src/index'
|
|
14
|
+
|
|
15
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
16
|
+
|
|
17
|
+
/** Render Eneo inside a Platform-like secrets backend (OpenBao). */
|
|
18
|
+
function renderEneo(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
19
|
+
return render(
|
|
20
|
+
jsx(SecretContext.Provider, {
|
|
21
|
+
value: openbao as never,
|
|
22
|
+
children: jsx(Eneo, props as never),
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Render Eneo inside a Platform-like Namespace context (no explicit namespace prop). */
|
|
28
|
+
function renderEneoInNamespace(
|
|
29
|
+
namespaceValue: string,
|
|
30
|
+
props: Record<string, unknown>
|
|
31
|
+
): ReturnType<typeof render> {
|
|
32
|
+
return render(
|
|
33
|
+
jsx(Namespace.Provider, {
|
|
34
|
+
value: namespaceValue,
|
|
35
|
+
children: jsx(SecretContext.Provider, {
|
|
36
|
+
value: openbao as never,
|
|
37
|
+
children: jsx(Eneo, props as never),
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Wrap Eneo in an OperatorContext (no secrets backend). */
|
|
44
|
+
function elementWithContext(ops: any[], props: Record<string, unknown>): r8sElement {
|
|
45
|
+
return jsx(OperatorContext.Provider, {
|
|
46
|
+
value: ops,
|
|
47
|
+
children: jsx(Eneo, props as never),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const objectStorage = {
|
|
52
|
+
endpoint: 'https://s3.internal.example.com',
|
|
53
|
+
bucket: 'eneo-corpora',
|
|
54
|
+
credentialsSecret: 'eneo-object-storage',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sso = {
|
|
58
|
+
issuer: 'https://keycloak.example.com/realms/platform',
|
|
59
|
+
clientId: 'eneo',
|
|
60
|
+
clientSecretRef: { secret: 'eneo-sso', key: 'clientSecret' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('operator declarations', () => {
|
|
64
|
+
it('declares the cnpg operator via the Database recipe', () => {
|
|
65
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
66
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('declares no cache operator on a plain render', () => {
|
|
70
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
71
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('deduplicates operators provided via context', () => {
|
|
75
|
+
const result = render(
|
|
76
|
+
elementWithContext([operators['cnpg']()], {
|
|
77
|
+
host: 'eneo.example.com',
|
|
78
|
+
objectStorage,
|
|
79
|
+
secretsName: 'existing-secrets',
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
const names = result.operators.map((op) => op.name)
|
|
83
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('rendering defaults', () => {
|
|
88
|
+
it('renders deployment, service, ingress and database cluster', () => {
|
|
89
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
90
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
91
|
+
expect(kinds).toContain('Deployment')
|
|
92
|
+
expect(kinds).toContain('Service')
|
|
93
|
+
expect(kinds).toContain('Ingress')
|
|
94
|
+
expect(kinds).toContain('Cluster')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
98
|
+
const result = render(
|
|
99
|
+
jsx(RoutingContext.Provider, {
|
|
100
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
101
|
+
children: jsx(SecretContext.Provider, {
|
|
102
|
+
value: openbao as never,
|
|
103
|
+
children: jsx(Eneo, { host: 'eneo.example.com', objectStorage }),
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
108
|
+
expect(kinds).toContain('HTTPRoute')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
112
|
+
const result = render(
|
|
113
|
+
jsx(RoutingContext.Provider, {
|
|
114
|
+
value: { mode: 'ingress' },
|
|
115
|
+
children: jsx(SecretContext.Provider, {
|
|
116
|
+
value: openbao as never,
|
|
117
|
+
children: jsx(Eneo, { host: 'eneo.example.com', objectStorage }),
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
122
|
+
expect(ingress).toBeDefined()
|
|
123
|
+
expect(ingress.spec.rules[0].host).toBe('eneo.example.com')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('defaults app replicas to 2', () => {
|
|
127
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
128
|
+
const app = result.resources.find(
|
|
129
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
130
|
+
) as any
|
|
131
|
+
expect(app.spec.replicas).toBe(2)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('passes resource validation', () => {
|
|
135
|
+
const result = renderEneo({
|
|
136
|
+
host: 'eneo.example.com',
|
|
137
|
+
objectStorage,
|
|
138
|
+
sso,
|
|
139
|
+
smtp: { host: 'smtp.example.com', port: 587, from: 'no-reply@eneo.example.com' },
|
|
140
|
+
dbStorage: '50Gi',
|
|
141
|
+
})
|
|
142
|
+
for (const resource of result.resources) {
|
|
143
|
+
expect(validateResource(resource)).toEqual([])
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('namespace inheritance', () => {
|
|
149
|
+
it('inherits namespace from the Platform context when namespace prop is not set', () => {
|
|
150
|
+
const result = renderEneoInNamespace('ai', { host: 'eneo.example.com', objectStorage })
|
|
151
|
+
for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
|
|
152
|
+
const resource = result.resources.find((r: any) => r.kind === kind)
|
|
153
|
+
expect(resource).toBeDefined()
|
|
154
|
+
expect(resource.metadata.namespace).toBe('ai')
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('inherits non-default context namespace even with multiple levels', () => {
|
|
159
|
+
const result = renderEneoInNamespace('team-corpora', {
|
|
160
|
+
host: 'eneo.example.com',
|
|
161
|
+
objectStorage,
|
|
162
|
+
secretsName: 'existing-secrets',
|
|
163
|
+
})
|
|
164
|
+
const app = result.resources.find(
|
|
165
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
166
|
+
) as any
|
|
167
|
+
expect(app.metadata.namespace).toBe('team-corpora')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('explicit namespace prop wins over the Platform context', () => {
|
|
171
|
+
const result = renderEneoInNamespace('ai', {
|
|
172
|
+
host: 'eneo.example.com',
|
|
173
|
+
objectStorage,
|
|
174
|
+
namespace: 'assistant-ns',
|
|
175
|
+
})
|
|
176
|
+
const app = result.resources.find(
|
|
177
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
178
|
+
) as any
|
|
179
|
+
expect(app.metadata.namespace).toBe('assistant-ns')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('falls back to default when no Platform namespace is present', () => {
|
|
183
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
184
|
+
const app = result.resources.find(
|
|
185
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
186
|
+
) as any
|
|
187
|
+
expect(app.metadata.namespace).toBe('default')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('document corpus storage', () => {
|
|
192
|
+
it('renders no PVC (corpora live in object storage; local corpus PVC is a v1.1 item)', () => {
|
|
193
|
+
const result = renderEneo({
|
|
194
|
+
host: 'eneo.example.com',
|
|
195
|
+
objectStorage,
|
|
196
|
+
dbStorage: '50Gi',
|
|
197
|
+
})
|
|
198
|
+
expect(result.resources.map((r) => r.kind)).not.toContain('PersistentVolumeClaim')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('passes dbStorage to the Postgres cluster (default 10Gi)', () => {
|
|
202
|
+
const defaults = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
203
|
+
const defaultCluster = defaults.resources.find((r) => r.kind === 'Cluster') as any
|
|
204
|
+
expect(defaultCluster.spec.storage.size).toBe('10Gi')
|
|
205
|
+
|
|
206
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage, dbStorage: '50Gi' })
|
|
207
|
+
const cluster = result.resources.find((r) => r.kind === 'Cluster') as any
|
|
208
|
+
expect(cluster.spec.storage.size).toBe('50Gi')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('rendering with all props', () => {
|
|
213
|
+
it('accepts the full prop surface', () => {
|
|
214
|
+
const result = renderEneo({
|
|
215
|
+
name: 'assistant',
|
|
216
|
+
namespace: 'ai',
|
|
217
|
+
version: '1.4.0',
|
|
218
|
+
host: 'assistant.example.com',
|
|
219
|
+
replicas: 3,
|
|
220
|
+
objectStorage: { ...objectStorage, bucket: 'assistant-corpora', region: 'eu-north-1' },
|
|
221
|
+
sso,
|
|
222
|
+
smtp: { host: 'smtp.example.com', port: 587, from: 'no-reply@assistant.example.com' },
|
|
223
|
+
dbStorage: '100Gi',
|
|
224
|
+
resources: {
|
|
225
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
226
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
227
|
+
},
|
|
228
|
+
tls: { secretName: 'assistant-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const app = result.resources.find(
|
|
232
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'assistant'
|
|
233
|
+
) as any
|
|
234
|
+
expect(app).toBeDefined()
|
|
235
|
+
expect(app.spec.replicas).toBe(3)
|
|
236
|
+
expect(app.spec.template.spec.containers[0].image).toBe('ghcr.io/berget-ai/eneo:1.4.0')
|
|
237
|
+
expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
238
|
+
|
|
239
|
+
const env = app.spec.template.spec.containers[0].env
|
|
240
|
+
expect(env.find((e: any) => e.name === 'S3_BUCKET').value).toBe('assistant-corpora')
|
|
241
|
+
expect(env.find((e: any) => e.name === 'AWS_REGION').value).toBe('eu-north-1')
|
|
242
|
+
expect(env.find((e: any) => e.name === 'OIDC_ISSUER').value).toBe(sso.issuer)
|
|
243
|
+
expect(env.find((e: any) => e.name === 'OIDC_TOKEN_URI').value).toBe(
|
|
244
|
+
'$(OIDC_ISSUER)/protocol/openid-connect/token'
|
|
245
|
+
)
|
|
246
|
+
expect(env.find((e: any) => e.name === 'SMTP_HOST').value).toBe('smtp.example.com')
|
|
247
|
+
expect(env.find((e: any) => e.name === 'SMTP_PORT').value).toBe('587')
|
|
248
|
+
expect(env.find((e: any) => e.name === 'SMTP_FROM').value).toBe(
|
|
249
|
+
'no-reply@assistant.example.com'
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const cluster = result.resources.find((r) => r.kind === 'Cluster') as any
|
|
253
|
+
expect(cluster.spec.storage.size).toBe('100Gi')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('renders unique env var names (k8s rejects duplicates)', () => {
|
|
257
|
+
const result = renderEneo({
|
|
258
|
+
host: 'eneo.example.com',
|
|
259
|
+
objectStorage,
|
|
260
|
+
sso,
|
|
261
|
+
smtp: { host: 'smtp.example.com' },
|
|
262
|
+
})
|
|
263
|
+
const app = result.resources.find(
|
|
264
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
265
|
+
) as any
|
|
266
|
+
const env = app.spec.template.spec.containers[0].env as Array<{ name: string }>
|
|
267
|
+
const names = env.map((e) => e.name)
|
|
268
|
+
expect(new Set(names).size).toBe(names.length)
|
|
269
|
+
// The OIDC client secret must come from the secretKeyRef entry only —
|
|
270
|
+
// no plain env duplicate.
|
|
271
|
+
expect(names.filter((n) => n === 'OIDC_CLIENT_SECRET')).toHaveLength(1)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('secrets handling', () => {
|
|
276
|
+
it('provisions app secrets through the openbao backend', () => {
|
|
277
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
278
|
+
const bao = result.resources.find((r: any) => r.kind === 'OpenBaoStaticSecret') as any
|
|
279
|
+
expect(bao).toBeDefined()
|
|
280
|
+
expect(bao.spec.destination.name).toBe('eneo-secrets')
|
|
281
|
+
expect(bao.spec.path).toBe('test/eneo/secrets')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('provisions app secrets through Vault', () => {
|
|
285
|
+
const result = render(
|
|
286
|
+
jsx(SecretContext.Provider, {
|
|
287
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
288
|
+
children: jsx(Eneo, { host: 'eneo.example.com', objectStorage }),
|
|
289
|
+
})
|
|
290
|
+
)
|
|
291
|
+
const vault = result.resources.find((r: any) => r.kind === 'VaultStaticSecret') as any
|
|
292
|
+
expect(vault).toBeDefined()
|
|
293
|
+
expect(vault.spec.destination.name).toBe('eneo-secrets')
|
|
294
|
+
expect(vault.spec.path).toBe('apps/eneo/secrets')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('throws when no secrets backend and no secretsName (bundle requires appSecret)', () => {
|
|
298
|
+
expect(() => render(jsx(Eneo, { host: 'eneo.example.com', objectStorage }))).toThrow(
|
|
299
|
+
/application secrets \(appSecret\)/
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('requires smtpPassword from the bundle only when the smtp prop is set', () => {
|
|
304
|
+
expect(() =>
|
|
305
|
+
render(
|
|
306
|
+
jsx(Eneo, { host: 'eneo.example.com', objectStorage, smtp: { host: 'smtp.example.com' } })
|
|
307
|
+
)
|
|
308
|
+
).toThrow(/application secrets \(appSecret, smtpPassword\)/)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('accepts an existing secretsName without a backend', () => {
|
|
312
|
+
expect(() =>
|
|
313
|
+
render(
|
|
314
|
+
jsx(Eneo, {
|
|
315
|
+
host: 'eneo.example.com',
|
|
316
|
+
objectStorage,
|
|
317
|
+
secretsName: 'existing-secrets',
|
|
318
|
+
})
|
|
319
|
+
)
|
|
320
|
+
).not.toThrow()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
324
|
+
const result = renderEneo({
|
|
325
|
+
host: 'eneo.example.com',
|
|
326
|
+
objectStorage,
|
|
327
|
+
sso,
|
|
328
|
+
smtp: { host: 'smtp.example.com' },
|
|
329
|
+
secretsName: 'existing-secrets',
|
|
330
|
+
})
|
|
331
|
+
const app = result.resources.find(
|
|
332
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
333
|
+
) as any
|
|
334
|
+
const env = app.spec.template.spec.containers[0].env
|
|
335
|
+
const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
|
|
336
|
+
const smtpPassword = env.find((e: any) => e.name === 'SMTP_PASSWORD')
|
|
337
|
+
const awsAccessKey = env.find((e: any) => e.name === 'AWS_ACCESS_KEY_ID')
|
|
338
|
+
const oidcClientSecret = env.find((e: any) => e.name === 'OIDC_CLIENT_SECRET')
|
|
339
|
+
const pgPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
340
|
+
expect(appSecret.valueFrom.secretKeyRef).toEqual({
|
|
341
|
+
name: 'existing-secrets',
|
|
342
|
+
key: 'appSecret',
|
|
343
|
+
})
|
|
344
|
+
expect(smtpPassword.valueFrom.secretKeyRef).toEqual({
|
|
345
|
+
name: 'existing-secrets',
|
|
346
|
+
key: 'smtpPassword',
|
|
347
|
+
})
|
|
348
|
+
expect(awsAccessKey.valueFrom.secretKeyRef).toEqual({
|
|
349
|
+
name: 'eneo-object-storage',
|
|
350
|
+
key: 'accessKey',
|
|
351
|
+
})
|
|
352
|
+
expect(oidcClientSecret.valueFrom.secretKeyRef).toEqual({
|
|
353
|
+
name: 'eneo-sso',
|
|
354
|
+
key: 'clientSecret',
|
|
355
|
+
})
|
|
356
|
+
expect(pgPassword.valueFrom.secretKeyRef.name).toBe('eneo-db-credentials')
|
|
357
|
+
for (const e of [appSecret, smtpPassword, awsAccessKey, oidcClientSecret, pgPassword]) {
|
|
358
|
+
expect(e.value).toBeUndefined()
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('injects SMTP password via secretKeyRef only when smtp is configured', () => {
|
|
363
|
+
const withSmtp = renderEneo({
|
|
364
|
+
host: 'eneo.example.com',
|
|
365
|
+
objectStorage,
|
|
366
|
+
smtp: { host: 'smtp.example.com', port: 465, from: 'no-reply@eneo.example.com' },
|
|
367
|
+
secretsName: 'existing-secrets',
|
|
368
|
+
})
|
|
369
|
+
const smtpApp = withSmtp.resources.find(
|
|
370
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
371
|
+
) as any
|
|
372
|
+
const smtpEnv = smtpApp.spec.template.spec.containers[0].env
|
|
373
|
+
const smtpPassword = smtpEnv.find((e: any) => e.name === 'SMTP_PASSWORD')
|
|
374
|
+
expect(smtpPassword.valueFrom.secretKeyRef).toEqual({
|
|
375
|
+
name: 'existing-secrets',
|
|
376
|
+
key: 'smtpPassword',
|
|
377
|
+
})
|
|
378
|
+
expect(smtpPassword.value).toBeUndefined()
|
|
379
|
+
expect(smtpEnv.find((e: any) => e.name === 'SMTP_HOST').value).toBe('smtp.example.com')
|
|
380
|
+
expect(smtpEnv.find((e: any) => e.name === 'SMTP_PORT').value).toBe('465')
|
|
381
|
+
expect(smtpEnv.find((e: any) => e.name === 'SMTP_FROM').value).toBe('no-reply@eneo.example.com')
|
|
382
|
+
|
|
383
|
+
const withoutSmtp = renderEneo({
|
|
384
|
+
host: 'eneo.example.com',
|
|
385
|
+
objectStorage,
|
|
386
|
+
secretsName: 'existing-secrets',
|
|
387
|
+
})
|
|
388
|
+
const plainApp = withoutSmtp.resources.find(
|
|
389
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
390
|
+
) as any
|
|
391
|
+
const plainEnv = plainApp.spec.template.spec.containers[0].env
|
|
392
|
+
expect(plainEnv.find((e: any) => e.name === 'SMTP_PASSWORD')).toBeUndefined()
|
|
393
|
+
expect(plainEnv.find((e: any) => e.name === 'SMTP_HOST')).toBeUndefined()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('auto-wires DATABASE_URL from the Database context without plaintext', () => {
|
|
397
|
+
const result = renderEneo({ host: 'eneo.example.com', objectStorage })
|
|
398
|
+
const app = result.resources.find(
|
|
399
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eneo'
|
|
400
|
+
) as any
|
|
401
|
+
const env = app.spec.template.spec.containers[0].env
|
|
402
|
+
const dbUrl = env.find((e: any) => e.name === 'DATABASE_URL')
|
|
403
|
+
expect(dbUrl.value).toBe(
|
|
404
|
+
'postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE)'
|
|
405
|
+
)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
409
|
+
const result = renderEneo({
|
|
410
|
+
host: 'eneo.example.com',
|
|
411
|
+
objectStorage,
|
|
412
|
+
sso,
|
|
413
|
+
smtp: { host: 'smtp.example.com', port: 587, from: 'no-reply@eneo.example.com' },
|
|
414
|
+
dbStorage: '50Gi',
|
|
415
|
+
})
|
|
416
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
417
|
+
if (!passed) {
|
|
418
|
+
console.error('Plaintext credential violations:', errors)
|
|
419
|
+
}
|
|
420
|
+
expect(passed).toBe(true)
|
|
421
|
+
})
|
|
422
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Platform } from '@r8s/recipes'
|
|
2
|
+
import { Eneo } from '@r8s/eneo'
|
|
3
|
+
|
|
4
|
+
export default (
|
|
5
|
+
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
+
<Eneo
|
|
7
|
+
name="eneo"
|
|
8
|
+
host="eneo.example.com"
|
|
9
|
+
objectStorage={{
|
|
10
|
+
endpoint: 'https://s3.internal.example.com',
|
|
11
|
+
bucket: 'eneo-corpora',
|
|
12
|
+
credentialsSecret: 'eneo-object-storage',
|
|
13
|
+
}}
|
|
14
|
+
/>
|
|
15
|
+
</Platform>
|
|
16
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/eneo",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Eneo — open AI platform from Sundsvall municipality (agent workspaces, assistants, document AI)",
|
|
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/eneo"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"eneo",
|
|
16
|
+
"documents",
|
|
17
|
+
"corpora",
|
|
18
|
+
"knowledge"
|
|
19
|
+
],
|
|
20
|
+
"r8s": {
|
|
21
|
+
"category": "Agent Platforms"
|
|
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": "Collaboration & Productivity",
|
|
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,292 @@
|
|
|
1
|
+
import { jsx, Fragment, useContext } from '@r8s/core'
|
|
2
|
+
import { Namespace, SecretContext } from '@r8s/core/defaults'
|
|
3
|
+
import { Database, WebService, Endpoint } from '@r8s/recipes'
|
|
4
|
+
import type { SecretRef } from '@r8s/recipes'
|
|
5
|
+
|
|
6
|
+
export interface EneoProps {
|
|
7
|
+
/** Resource name (defaults to 'eneo') */
|
|
8
|
+
name?: string
|
|
9
|
+
/** Kubernetes namespace (defaults to 'default') */
|
|
10
|
+
namespace?: string
|
|
11
|
+
/** Container image tag (defaults to 'latest' — pin a version in production) */
|
|
12
|
+
version?: string
|
|
13
|
+
/** Public hostname for the Eneo web app (required) */
|
|
14
|
+
host: string
|
|
15
|
+
/** Number of app replicas (defaults to 2 — scale freely, the app is stateless) */
|
|
16
|
+
replicas?: number
|
|
17
|
+
/**
|
|
18
|
+
* S3-compatible object storage for document corpora (RustFS in the
|
|
19
|
+
* platform). Required. Reference a bucket whose credentials live in a
|
|
20
|
+
* Secret provisioned by the secrets backend (keys: accessKey, secretKey)
|
|
21
|
+
* — never plaintext.
|
|
22
|
+
*/
|
|
23
|
+
objectStorage: {
|
|
24
|
+
/** S3 endpoint URL, e.g. https://s3.internal.example.com */
|
|
25
|
+
endpoint: string
|
|
26
|
+
/** Bucket holding document corpora */
|
|
27
|
+
bucket: string
|
|
28
|
+
/** Name of the Secret holding accessKey / secretKey */
|
|
29
|
+
credentialsSecret: string
|
|
30
|
+
/** Region string for the S3 client (defaults to 'us-east-1') */
|
|
31
|
+
region?: string
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* OIDC SSO client — register Eneo as a client in Keycloak (the Auth
|
|
35
|
+
* recipe) and reference the client secret through the backend.
|
|
36
|
+
*/
|
|
37
|
+
sso?: {
|
|
38
|
+
issuer: string
|
|
39
|
+
clientId: string
|
|
40
|
+
clientSecretRef: SecretRef
|
|
41
|
+
scopes?: string
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Outgoing SMTP for invitations and notifications (mirror of the
|
|
45
|
+
* EuroOffice recipe). When set, SMTP_HOST / SMTP_PORT / SMTP_FROM are
|
|
46
|
+
* rendered as plain env and SMTP_PASSWORD is delivered via secretKeyRef
|
|
47
|
+
* from the `${name}-secrets` bundle (key: smtpPassword) — never
|
|
48
|
+
* plaintext. The bundle then requires the `smtpPassword` key as well;
|
|
49
|
+
* without `smtp` only `appSecret` is required from the bundle.
|
|
50
|
+
*/
|
|
51
|
+
smtp?: {
|
|
52
|
+
/** SMTP server hostname, e.g. smtp.example.com */
|
|
53
|
+
host: string
|
|
54
|
+
/** SMTP port (defaults to 587) */
|
|
55
|
+
port?: number
|
|
56
|
+
/** From address for outgoing mail, e.g. no-reply@example.com */
|
|
57
|
+
from?: string
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Name of an existing Secret holding `appSecret` (and `smtpPassword`
|
|
61
|
+
* when `smtp` is set). Required unless a secrets backend
|
|
62
|
+
* (openbao/vault) is configured on the surrounding Platform — the
|
|
63
|
+
* backend then provisions them.
|
|
64
|
+
*/
|
|
65
|
+
secretsName?: string
|
|
66
|
+
/**
|
|
67
|
+
* Storage size for the Postgres cluster (defaults to '10Gi').
|
|
68
|
+
*
|
|
69
|
+
* Document corpora live in object storage (`objectStorage`, S3/RustFS)
|
|
70
|
+
* — Eneo does not persist corpora on a local volume. A local corpus
|
|
71
|
+
* PVC (mounted volumes/sidecars on the app workload) is a v1.1 item.
|
|
72
|
+
*/
|
|
73
|
+
dbStorage?: string
|
|
74
|
+
/** Requested app resources */
|
|
75
|
+
resources?: {
|
|
76
|
+
requests?: { cpu?: string; memory?: string }
|
|
77
|
+
limits?: { cpu?: string; memory?: string }
|
|
78
|
+
}
|
|
79
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
80
|
+
tls?: {
|
|
81
|
+
secretName: string
|
|
82
|
+
clusterIssuer: string
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Eneo — open AI platform from Sundsvall municipality (agent workspaces,
|
|
88
|
+
* assistants, document AI).
|
|
89
|
+
*
|
|
90
|
+
* @title Eneo
|
|
91
|
+
* @category Agent Platforms
|
|
92
|
+
*
|
|
93
|
+
* Composes:
|
|
94
|
+
* - CNPG Postgres cluster (conversations, workspaces, document metadata;
|
|
95
|
+
* size via `dbStorage`)
|
|
96
|
+
* - Eneo Deployment + Service + Endpoint (DATABASE_URL auto-wired)
|
|
97
|
+
* - S3/RustFS bucket reference for document corpora (required) — corpora
|
|
98
|
+
* do not use local volumes; a local corpus PVC is a v1.1 item
|
|
99
|
+
* - App secrets (appSecret, plus smtpPassword when `smtp` is set)
|
|
100
|
+
* provisioned by the Platform secrets backend (openbao / vault), or
|
|
101
|
+
* referenced from an existing Secret
|
|
102
|
+
* - Optional SMTP delivery; password via the `${name}-secrets` bundle
|
|
103
|
+
* - OIDC SSO against the Keycloak `Auth` recipe
|
|
104
|
+
*
|
|
105
|
+
* The namespace is inherited from the surrounding `<Platform>` (via the
|
|
106
|
+
* Namespace context) unless set explicitly.
|
|
107
|
+
*
|
|
108
|
+
* Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
|
|
109
|
+
* the app secrets bundle is provisioned for you. Without a backend you
|
|
110
|
+
* must point `secretsName` at a pre-created Secret.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* import { Platform } from '@r8s/recipes'
|
|
114
|
+
* import { Eneo } from '@r8s/eneo'
|
|
115
|
+
*
|
|
116
|
+
* export default (
|
|
117
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
118
|
+
* <Eneo
|
|
119
|
+
* name="eneo"
|
|
120
|
+
* host="eneo.example.com"
|
|
121
|
+
* objectStorage={{
|
|
122
|
+
* endpoint: 'https://s3.internal.example.com',
|
|
123
|
+
* bucket: 'eneo-corpora',
|
|
124
|
+
* credentialsSecret: 'eneo-object-storage',
|
|
125
|
+
* }}
|
|
126
|
+
* />
|
|
127
|
+
* </Platform>
|
|
128
|
+
* )
|
|
129
|
+
*/
|
|
130
|
+
export function Eneo(props: EneoProps) {
|
|
131
|
+
const {
|
|
132
|
+
name = 'eneo',
|
|
133
|
+
namespace: namespaceProp,
|
|
134
|
+
version = 'latest',
|
|
135
|
+
host,
|
|
136
|
+
replicas = 2,
|
|
137
|
+
objectStorage,
|
|
138
|
+
sso,
|
|
139
|
+
smtp,
|
|
140
|
+
secretsName,
|
|
141
|
+
dbStorage = '10Gi',
|
|
142
|
+
resources = {
|
|
143
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
144
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
145
|
+
},
|
|
146
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
147
|
+
} = props
|
|
148
|
+
|
|
149
|
+
// Inherit namespace from <Platform> context if not explicitly set
|
|
150
|
+
const contextNamespace = useContext(Namespace)
|
|
151
|
+
const namespace =
|
|
152
|
+
namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
|
|
153
|
+
|
|
154
|
+
const secretProvider = useContext(SecretContext)
|
|
155
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
156
|
+
|
|
157
|
+
const platformSecretsName = secretsName ?? `${name}-secrets`
|
|
158
|
+
|
|
159
|
+
// --- App secrets (appSecret / smtpPassword) -------------------------------
|
|
160
|
+
// Session signing and SMTP delivery credentials are the crown jewels of an
|
|
161
|
+
// Eneo install — never render them as plaintext. With a secrets backend
|
|
162
|
+
// they are provisioned through the backend; otherwise reference a
|
|
163
|
+
// pre-created Secret. smtpPassword is only required from the bundle when
|
|
164
|
+
// the `smtp` prop is configured.
|
|
165
|
+
const requiredSecretKeys = smtp ? 'appSecret, smtpPassword' : 'appSecret'
|
|
166
|
+
if (!secretsName) {
|
|
167
|
+
if (
|
|
168
|
+
!secretProvider ||
|
|
169
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
170
|
+
) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Eneo "${name}" requires application secrets (${requiredSecretKeys}).\n` +
|
|
173
|
+
`\n` +
|
|
174
|
+
`These must not be rendered as plaintext.\n` +
|
|
175
|
+
`\n` +
|
|
176
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
177
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
178
|
+
` <Eneo name="${name}" host="${host}" />\n` +
|
|
179
|
+
` </Platform>\n` +
|
|
180
|
+
`\n` +
|
|
181
|
+
`Or reference a pre-created Secret (keys: ${requiredSecretKeys}):\n` +
|
|
182
|
+
` <Eneo name="${name}" host="${host}" secretsName="${name}-secrets" />`
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const spec = {
|
|
187
|
+
...(secretProvider.backend === 'vault'
|
|
188
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
189
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
190
|
+
mount: secretProvider.mount,
|
|
191
|
+
type: 'kv-v2' as const,
|
|
192
|
+
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
193
|
+
destination: { create: true, name: platformSecretsName },
|
|
194
|
+
}
|
|
195
|
+
resources_.push(
|
|
196
|
+
secretProvider.backend === 'vault'
|
|
197
|
+
? jsx('VaultStaticSecret', {
|
|
198
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
199
|
+
kind: 'VaultStaticSecret',
|
|
200
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
201
|
+
spec,
|
|
202
|
+
})
|
|
203
|
+
: jsx('OpenBaoStaticSecret', {
|
|
204
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
205
|
+
kind: 'OpenBaoStaticSecret',
|
|
206
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
207
|
+
spec,
|
|
208
|
+
})
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Env wiring --------------------------------------------------------------
|
|
213
|
+
// Every credential is referenced with $(VAR) expansion or secretKeyRef —
|
|
214
|
+
// no plaintext in the manifest. The WebService declares secret-backed
|
|
215
|
+
// vars before plain env vars, so dependent expansion resolves. DATABASE_URL
|
|
216
|
+
// and PG* vars are auto-wired from the Database context (see below).
|
|
217
|
+
const env: Record<string, string> = {
|
|
218
|
+
PORT: '3000',
|
|
219
|
+
BASE_URL: `https://${host}`,
|
|
220
|
+
S3_ENDPOINT: objectStorage.endpoint,
|
|
221
|
+
S3_BUCKET: objectStorage.bucket,
|
|
222
|
+
AWS_REGION: objectStorage.region ?? 'us-east-1',
|
|
223
|
+
...(sso
|
|
224
|
+
? {
|
|
225
|
+
OIDC_ISSUER: sso.issuer,
|
|
226
|
+
OIDC_CLIENT_ID: sso.clientId,
|
|
227
|
+
OIDC_SCOPES: sso.scopes ?? 'openid email profile',
|
|
228
|
+
OIDC_AUTH_URI: '$(OIDC_ISSUER)/protocol/openid-connect/auth',
|
|
229
|
+
OIDC_TOKEN_URI: '$(OIDC_ISSUER)/protocol/openid-connect/token',
|
|
230
|
+
OIDC_USERINFO_URI: '$(OIDC_ISSUER)/protocol/openid-connect/userinfo',
|
|
231
|
+
}
|
|
232
|
+
: {}),
|
|
233
|
+
...(smtp
|
|
234
|
+
? {
|
|
235
|
+
SMTP_HOST: smtp.host,
|
|
236
|
+
SMTP_PORT: String(smtp.port ?? 587),
|
|
237
|
+
...(smtp.from && { SMTP_FROM: smtp.from }),
|
|
238
|
+
}
|
|
239
|
+
: {}),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Credentials delivered via secretKeyRef (runtime injection).
|
|
243
|
+
// OIDC_CLIENT_SECRET comes from sso.clientSecretRef — do NOT also render
|
|
244
|
+
// it as a plain $(VAR) env entry: duplicate env names are rejected by
|
|
245
|
+
// Kubernetes.
|
|
246
|
+
const secrets: Record<string, SecretRef | string> = {
|
|
247
|
+
APP_SECRET: { secret: platformSecretsName, key: 'appSecret' },
|
|
248
|
+
...(smtp
|
|
249
|
+
? { SMTP_PASSWORD: { secret: platformSecretsName, key: 'smtpPassword' as const } }
|
|
250
|
+
: {}),
|
|
251
|
+
AWS_ACCESS_KEY_ID: { secret: objectStorage.credentialsSecret, key: 'accessKey' },
|
|
252
|
+
AWS_SECRET_ACCESS_KEY: { secret: objectStorage.credentialsSecret, key: 'secretKey' },
|
|
253
|
+
...(sso ? { OIDC_CLIENT_SECRET: sso.clientSecretRef } : {}),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Database + app + endpoint ------------------------------------------------
|
|
257
|
+
// Database wraps the app so credentials stay consistent with the r8s
|
|
258
|
+
// Database recipe (CNPG dedicated cluster provisions the secret) and the
|
|
259
|
+
// WebService auto-wires PG* + DATABASE_URL from DatabaseContext.
|
|
260
|
+
resources_.push(
|
|
261
|
+
jsx(Database, {
|
|
262
|
+
name,
|
|
263
|
+
namespace,
|
|
264
|
+
storage: dbStorage,
|
|
265
|
+
children: (
|
|
266
|
+
<WebService
|
|
267
|
+
name={name}
|
|
268
|
+
namespace={namespace}
|
|
269
|
+
image={`ghcr.io/berget-ai/eneo:${version}`}
|
|
270
|
+
port={3000}
|
|
271
|
+
replicas={replicas}
|
|
272
|
+
resources={resources}
|
|
273
|
+
env={env}
|
|
274
|
+
secrets={secrets}
|
|
275
|
+
/>
|
|
276
|
+
),
|
|
277
|
+
})
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
resources_.push(
|
|
281
|
+
<Endpoint
|
|
282
|
+
name={`${name}-endpoint`}
|
|
283
|
+
namespace={namespace}
|
|
284
|
+
host={host}
|
|
285
|
+
serviceName={name}
|
|
286
|
+
servicePort={3000}
|
|
287
|
+
tls={tls}
|
|
288
|
+
/>
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return jsx(Fragment, { children: resources_ })
|
|
292
|
+
}
|
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
|
+
}
|