@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.
@@ -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
+ }