@r8s/eurooffice 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__/eurooffice.test.ts +427 -0
- package/examples/basic.tsx +17 -0
- package/package.json +41 -0
- package/src/index.tsx +324 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
// EuroOffice recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, all props, gateway/ingress adaptation, TCP probes
|
|
11
|
+
// 3. Namespace inheritance from the Platform context
|
|
12
|
+
// 4. Security: no plaintext credentials in rendered output
|
|
13
|
+
import { EuroOffice } from '../src/index'
|
|
14
|
+
|
|
15
|
+
const objectStorage = {
|
|
16
|
+
endpoint: 'https://s3.test',
|
|
17
|
+
bucket: 'docs-blobs',
|
|
18
|
+
credentialsSecret: 'docs-blobs-credentials',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
22
|
+
|
|
23
|
+
/** Render EuroOffice inside a Platform-like secrets backend (OpenBao). */
|
|
24
|
+
function renderEuroOffice(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
25
|
+
return render(
|
|
26
|
+
jsx(SecretContext.Provider, {
|
|
27
|
+
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
28
|
+
children: jsx(EuroOffice, { objectStorage, ...props } as never),
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render EuroOffice wrapped only in an OperatorContext (no secrets backend). */
|
|
34
|
+
function renderEuroOfficeWithContext(
|
|
35
|
+
operators_: any[],
|
|
36
|
+
props: Record<string, unknown>
|
|
37
|
+
): r8sElement {
|
|
38
|
+
return jsx(OperatorContext.Provider, {
|
|
39
|
+
value: operators_,
|
|
40
|
+
children: jsx(EuroOffice, {
|
|
41
|
+
objectStorage,
|
|
42
|
+
secretsName: 'existing-secrets',
|
|
43
|
+
...props,
|
|
44
|
+
} as never),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Render EuroOffice inside a Platform-like Namespace context (no explicit namespace prop). */
|
|
49
|
+
function renderEuroOfficeInNamespace(
|
|
50
|
+
namespaceValue: string,
|
|
51
|
+
props: Record<string, unknown>
|
|
52
|
+
): ReturnType<typeof render> {
|
|
53
|
+
return render(
|
|
54
|
+
jsx(Namespace.Provider, {
|
|
55
|
+
value: namespaceValue,
|
|
56
|
+
children: jsx(SecretContext.Provider, {
|
|
57
|
+
value: openbao as never,
|
|
58
|
+
children: jsx(EuroOffice, { objectStorage, ...props } as never),
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('operator declarations', () => {
|
|
65
|
+
it('declares the cnpg operator via the Database recipe', () => {
|
|
66
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
67
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('adds no app-level operators beyond cnpg (Endpoint owns routing operators)', () => {
|
|
71
|
+
const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
|
|
72
|
+
const names = result.operators.map((op) => op.name)
|
|
73
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
74
|
+
expect(names).not.toContain('redis-operator')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('deduplicates operators provided via context', () => {
|
|
78
|
+
const result = render(
|
|
79
|
+
renderEuroOfficeWithContext([operators['cnpg']()], { host: 'docs.example.com' })
|
|
80
|
+
)
|
|
81
|
+
const names = result.operators.map((op) => op.name)
|
|
82
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('allows version overrides through context operators', () => {
|
|
86
|
+
const result = render(renderEuroOfficeWithContext([operators['cnpg']('1.24.0')], {}))
|
|
87
|
+
const cnpg = result.operators.filter((op) => op.name === 'cnpg')
|
|
88
|
+
expect(cnpg).toHaveLength(1)
|
|
89
|
+
expect(cnpg[0].version).toBe('1.24.0')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('rendering defaults', () => {
|
|
94
|
+
it('renders app deployment, service, database and endpoint', () => {
|
|
95
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
96
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
97
|
+
expect(kinds).toContain('Deployment')
|
|
98
|
+
expect(kinds).toContain('Service')
|
|
99
|
+
expect(kinds).toContain('Ingress')
|
|
100
|
+
expect(kinds).toContain('Cluster')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('adds LibreOffice conversion workers when conversions is set', () => {
|
|
104
|
+
const result = renderEuroOffice({
|
|
105
|
+
host: 'docs.example.com',
|
|
106
|
+
conversions: true,
|
|
107
|
+
conversionWorkers: 2,
|
|
108
|
+
})
|
|
109
|
+
const deployments = result.resources.filter((r) => r.kind === 'Deployment')
|
|
110
|
+
const soffice = deployments.find((d: any) => d.metadata.name === 'eurooffice-soffice') as any
|
|
111
|
+
expect(soffice).toBeDefined()
|
|
112
|
+
expect(soffice.spec.replicas).toBe(2)
|
|
113
|
+
expect(soffice.spec.template.spec.containers[0].command).toContain(
|
|
114
|
+
'--accept=socket,host=0.0.0.0,port=2002;urp;'
|
|
115
|
+
)
|
|
116
|
+
const services = result.resources.filter((r) => r.kind === 'Service')
|
|
117
|
+
expect(services.some((s: any) => s.metadata.name === 'eurooffice-soffice')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('probes the soffice workers on the UNO TCP socket (not HTTP)', () => {
|
|
121
|
+
const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
|
|
122
|
+
const soffice = result.resources.find(
|
|
123
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice-soffice'
|
|
124
|
+
) as any
|
|
125
|
+
const container = soffice.spec.template.spec.containers[0]
|
|
126
|
+
expect(container.livenessProbe.tcpSocket).toEqual({ port: 2002 })
|
|
127
|
+
expect(container.readinessProbe.tcpSocket).toEqual({ port: 2002 })
|
|
128
|
+
expect(container.readinessProbe.initialDelaySeconds).toBe(20)
|
|
129
|
+
expect(container.livenessProbe.httpGet).toBeUndefined()
|
|
130
|
+
expect(container.readinessProbe.httpGet).toBeUndefined()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('does not render conversion workers by default', () => {
|
|
134
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
135
|
+
const deployments = result.resources
|
|
136
|
+
.filter((r) => r.kind === 'Deployment')
|
|
137
|
+
.map((d: any) => d.metadata.name)
|
|
138
|
+
expect(deployments).not.toContain('eurooffice-soffice')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('defaults app replicas to 1 when websockets are enabled (no session affinity)', () => {
|
|
142
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
143
|
+
const app = result.resources.find(
|
|
144
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
145
|
+
) as any
|
|
146
|
+
expect(app.spec.replicas).toBe(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('defaults app replicas to 2 when websockets are disabled', () => {
|
|
150
|
+
const result = renderEuroOffice({ host: 'docs.example.com', websockets: false })
|
|
151
|
+
const app = result.resources.find(
|
|
152
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
153
|
+
) as any
|
|
154
|
+
expect(app.spec.replicas).toBe(2)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('renders explicit replicas with websockets enabled (caller opts into sticky sessions)', () => {
|
|
158
|
+
const result = renderEuroOffice({
|
|
159
|
+
host: 'docs.example.com',
|
|
160
|
+
websockets: true,
|
|
161
|
+
replicas: 3,
|
|
162
|
+
})
|
|
163
|
+
const app = result.resources.find(
|
|
164
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
165
|
+
) as any
|
|
166
|
+
expect(app.spec.replicas).toBe(3)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('enables websockets by default and allows disabling them', () => {
|
|
170
|
+
const on = renderEuroOffice({ host: 'docs.example.com' })
|
|
171
|
+
const appOn = on.resources.find(
|
|
172
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
173
|
+
) as any
|
|
174
|
+
const envOn = appOn.spec.template.spec.containers[0].env
|
|
175
|
+
expect(envOn.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('true')
|
|
176
|
+
|
|
177
|
+
const off = renderEuroOffice({ host: 'docs.example.com', websockets: false })
|
|
178
|
+
const appOff = off.resources.find(
|
|
179
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
180
|
+
) as any
|
|
181
|
+
const envOff = appOff.spec.template.spec.containers[0].env
|
|
182
|
+
expect(envOff.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('false')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
186
|
+
const result = render(
|
|
187
|
+
jsx(RoutingContext.Provider, {
|
|
188
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
189
|
+
children: jsx(SecretContext.Provider, {
|
|
190
|
+
value: openbao as never,
|
|
191
|
+
children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
|
|
192
|
+
}),
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
196
|
+
expect(kinds).toContain('HTTPRoute')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
200
|
+
const result = render(
|
|
201
|
+
jsx(RoutingContext.Provider, {
|
|
202
|
+
value: { mode: 'ingress' },
|
|
203
|
+
children: jsx(SecretContext.Provider, {
|
|
204
|
+
value: openbao as never,
|
|
205
|
+
children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
|
|
206
|
+
}),
|
|
207
|
+
})
|
|
208
|
+
)
|
|
209
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
210
|
+
expect(ingress).toBeDefined()
|
|
211
|
+
expect(ingress.spec.rules[0].host).toBe('docs.example.com')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('passes resource validation', () => {
|
|
215
|
+
const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
|
|
216
|
+
for (const resource of result.resources) {
|
|
217
|
+
expect(validateResource(resource)).toEqual([])
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('namespace inheritance', () => {
|
|
223
|
+
it('inherits namespace from the Platform context when namespace prop is not set', () => {
|
|
224
|
+
const result = renderEuroOfficeInNamespace('docs', { host: 'docs.example.com' })
|
|
225
|
+
for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
|
|
226
|
+
const resource = result.resources.find((r: any) => r.kind === kind)
|
|
227
|
+
expect(resource).toBeDefined()
|
|
228
|
+
expect(resource.metadata.namespace).toBe('docs')
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('explicit namespace prop wins over the Platform context', () => {
|
|
233
|
+
const result = renderEuroOfficeInNamespace('docs', {
|
|
234
|
+
host: 'docs.example.com',
|
|
235
|
+
namespace: 'collab',
|
|
236
|
+
})
|
|
237
|
+
const app = result.resources.find(
|
|
238
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
239
|
+
) as any
|
|
240
|
+
expect(app.metadata.namespace).toBe('collab')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('falls back to default when no Platform namespace is present', () => {
|
|
244
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
245
|
+
const app = result.resources.find(
|
|
246
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
247
|
+
) as any
|
|
248
|
+
expect(app.metadata.namespace).toBe('default')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('rendering with all props', () => {
|
|
253
|
+
it('accepts the full prop surface', () => {
|
|
254
|
+
const result = renderEuroOffice({
|
|
255
|
+
name: 'docs',
|
|
256
|
+
namespace: 'docs',
|
|
257
|
+
version: '1.2.0',
|
|
258
|
+
host: 'docs.example.com',
|
|
259
|
+
replicas: 3,
|
|
260
|
+
websockets: false,
|
|
261
|
+
smtp: { host: 'smtp.example.com', port: 465, from: 'no-reply@docs.example.com' },
|
|
262
|
+
conversions: true,
|
|
263
|
+
conversionWorkers: 4,
|
|
264
|
+
objectStorage: {
|
|
265
|
+
endpoint: 'https://s3.internal.example.com',
|
|
266
|
+
bucket: 'docs-blobs',
|
|
267
|
+
credentialsSecret: 'docs-blobs-credentials',
|
|
268
|
+
region: 'eu-north-1',
|
|
269
|
+
},
|
|
270
|
+
resources: {
|
|
271
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
272
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
273
|
+
},
|
|
274
|
+
tls: { secretName: 'docs-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
275
|
+
})
|
|
276
|
+
expect(result.resources.length).toBeGreaterThan(0)
|
|
277
|
+
const app = result.resources.find(
|
|
278
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'docs'
|
|
279
|
+
) as any
|
|
280
|
+
expect(app.spec.template.spec.containers[0].image).toBe('ghcr.io/berget-ai/eurooffice:1.2.0')
|
|
281
|
+
expect(app.spec.replicas).toBe(3)
|
|
282
|
+
expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
283
|
+
const env = app.spec.template.spec.containers[0].env
|
|
284
|
+
expect(env.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('false')
|
|
285
|
+
expect(env.find((e: any) => e.name === 'SMTP_HOST').value).toBe('smtp.example.com')
|
|
286
|
+
expect(env.find((e: any) => e.name === 'SMTP_PORT').value).toBe('465')
|
|
287
|
+
expect(env.find((e: any) => e.name === 'SMTP_FROM').value).toBe('no-reply@docs.example.com')
|
|
288
|
+
expect(env.find((e: any) => e.name === 'AWS_REGION').value).toBe('eu-north-1')
|
|
289
|
+
expect(env.find((e: any) => e.name === 'SOFFICE_HOST').value).toBe('docs-soffice')
|
|
290
|
+
|
|
291
|
+
const soffice = result.resources.find(
|
|
292
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'docs-soffice'
|
|
293
|
+
) as any
|
|
294
|
+
expect(soffice.spec.template.spec.containers[0].image).toBe(
|
|
295
|
+
'ghcr.io/berget-ai/eurooffice:1.2.0'
|
|
296
|
+
)
|
|
297
|
+
expect(soffice.spec.replicas).toBe(4)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('secrets handling', () => {
|
|
302
|
+
it('accepts an explicit secretsName without a backend', () => {
|
|
303
|
+
expect(() =>
|
|
304
|
+
render(
|
|
305
|
+
jsx(EuroOffice, {
|
|
306
|
+
host: 'docs.example.com',
|
|
307
|
+
objectStorage,
|
|
308
|
+
secretsName: 'existing-secrets',
|
|
309
|
+
} as never)
|
|
310
|
+
)
|
|
311
|
+
).not.toThrow()
|
|
312
|
+
|
|
313
|
+
const result = render(
|
|
314
|
+
jsx(EuroOffice, {
|
|
315
|
+
host: 'docs.example.com',
|
|
316
|
+
objectStorage,
|
|
317
|
+
secretsName: 'existing-secrets',
|
|
318
|
+
} as never)
|
|
319
|
+
)
|
|
320
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
321
|
+
expect(kinds).not.toContain('OpenBaoStaticSecret')
|
|
322
|
+
const app = result.resources.find(
|
|
323
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
324
|
+
) as any
|
|
325
|
+
const env = app.spec.template.spec.containers[0].env
|
|
326
|
+
const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
|
|
327
|
+
expect(appSecret.valueFrom.secretKeyRef.name).toBe('existing-secrets')
|
|
328
|
+
expect(appSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('provisions the app secrets bundle through a secrets backend', () => {
|
|
332
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
333
|
+
const secretCr = result.resources.find((r) => r.kind === 'OpenBaoStaticSecret') as any
|
|
334
|
+
expect(secretCr).toBeDefined()
|
|
335
|
+
expect(secretCr.metadata.name).toBe('eurooffice-secrets')
|
|
336
|
+
expect(secretCr.spec.destination.name).toBe('eurooffice-secrets')
|
|
337
|
+
expect(secretCr.spec.path).toBe('test/eurooffice/secrets')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('provisions the app secrets bundle through Vault', () => {
|
|
341
|
+
const result = render(
|
|
342
|
+
jsx(SecretContext.Provider, {
|
|
343
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
344
|
+
children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
|
|
345
|
+
})
|
|
346
|
+
)
|
|
347
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
348
|
+
expect(kinds).toContain('VaultStaticSecret')
|
|
349
|
+
const secretCr = result.resources.find((r) => r.kind === 'VaultStaticSecret') as any
|
|
350
|
+
expect(secretCr.spec.path).toBe('apps/eurooffice/secrets')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
354
|
+
const result = renderEuroOffice({
|
|
355
|
+
host: 'docs.example.com',
|
|
356
|
+
smtp: { host: 'smtp.example.com' },
|
|
357
|
+
})
|
|
358
|
+
const app = result.resources.find(
|
|
359
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
360
|
+
) as any
|
|
361
|
+
const env = app.spec.template.spec.containers[0].env
|
|
362
|
+
const smtpPassword = env.find((e: any) => e.name === 'SMTP_PASSWORD')
|
|
363
|
+
const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
|
|
364
|
+
const accessKey = env.find((e: any) => e.name === 'AWS_ACCESS_KEY_ID')
|
|
365
|
+
const secretAccessKey = env.find((e: any) => e.name === 'AWS_SECRET_ACCESS_KEY')
|
|
366
|
+
expect(smtpPassword.valueFrom.secretKeyRef.name).toBe('eurooffice-secrets')
|
|
367
|
+
expect(smtpPassword.valueFrom.secretKeyRef.key).toBe('smtpPassword')
|
|
368
|
+
expect(appSecret.valueFrom.secretKeyRef.name).toBe('eurooffice-secrets')
|
|
369
|
+
expect(appSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
|
|
370
|
+
expect(accessKey.valueFrom.secretKeyRef.name).toBe('docs-blobs-credentials')
|
|
371
|
+
expect(accessKey.valueFrom.secretKeyRef.key).toBe('accessKey')
|
|
372
|
+
expect(secretAccessKey.valueFrom.secretKeyRef.name).toBe('docs-blobs-credentials')
|
|
373
|
+
expect(secretAccessKey.valueFrom.secretKeyRef.key).toBe('secretKey')
|
|
374
|
+
expect(smtpPassword.value).toBeUndefined()
|
|
375
|
+
expect(appSecret.value).toBeUndefined()
|
|
376
|
+
expect(accessKey.value).toBeUndefined()
|
|
377
|
+
expect(secretAccessKey.value).toBeUndefined()
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('auto-wires DATABASE_URL from the DatabaseContext (no manual connection string)', () => {
|
|
381
|
+
const result = renderEuroOffice({ host: 'docs.example.com' })
|
|
382
|
+
const app = result.resources.find(
|
|
383
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
|
|
384
|
+
) as any
|
|
385
|
+
const env = app.spec.template.spec.containers[0].env
|
|
386
|
+
const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
|
|
387
|
+
expect(databaseUrl.value).toBe(
|
|
388
|
+
'postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE)'
|
|
389
|
+
)
|
|
390
|
+
const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
391
|
+
expect(dbPassword.valueFrom.secretKeyRef.name).toBe('eurooffice-db-credentials')
|
|
392
|
+
expect(dbPassword.value).toBeUndefined()
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
396
|
+
const result = renderEuroOffice({
|
|
397
|
+
host: 'docs.example.com',
|
|
398
|
+
smtp: { host: 'smtp.example.com', port: 587, from: 'no-reply@docs.example.com' },
|
|
399
|
+
conversions: true,
|
|
400
|
+
conversionWorkers: 2,
|
|
401
|
+
})
|
|
402
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
403
|
+
if (!passed) {
|
|
404
|
+
console.error('Plaintext credential violations:', errors)
|
|
405
|
+
}
|
|
406
|
+
expect(passed).toBe(true)
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
describe('validation errors', () => {
|
|
411
|
+
it('throws when no secrets backend and no secrets name', () => {
|
|
412
|
+
expect(() =>
|
|
413
|
+
render(jsx(EuroOffice, { host: 'docs.example.com', objectStorage } as never))
|
|
414
|
+
).toThrow(/application secrets/)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('throws for unknown secrets backends', () => {
|
|
418
|
+
expect(() =>
|
|
419
|
+
render(
|
|
420
|
+
jsx(SecretContext.Provider, {
|
|
421
|
+
value: { backend: 'unknown' as never },
|
|
422
|
+
children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
|
|
423
|
+
})
|
|
424
|
+
)
|
|
425
|
+
).toThrow(/application secrets/)
|
|
426
|
+
})
|
|
427
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Platform } from '@r8s/recipes'
|
|
2
|
+
import { EuroOffice } from '@r8s/eurooffice'
|
|
3
|
+
|
|
4
|
+
export default (
|
|
5
|
+
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
+
<EuroOffice
|
|
7
|
+
name="docs"
|
|
8
|
+
host="docs.example.com"
|
|
9
|
+
objectStorage={{
|
|
10
|
+
endpoint: 'https://s3.internal.example.com',
|
|
11
|
+
bucket: 'docs-blobs',
|
|
12
|
+
credentialsSecret: 'docs-blobs-credentials',
|
|
13
|
+
}}
|
|
14
|
+
smtp={{ host: 'smtp.example.com', port: 587, from: 'no-reply@${env:MAIL_DOMAIN}' }}
|
|
15
|
+
/>
|
|
16
|
+
</Platform>
|
|
17
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/eurooffice",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "EuroOffice collaborative document suite — Postgres persistence, S3 blob storage, LibreOffice conversions, SMTP delivery and websocket collaboration",
|
|
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/eurooffice"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"eurooffice",
|
|
16
|
+
"libreoffice",
|
|
17
|
+
"documents",
|
|
18
|
+
"conversion"
|
|
19
|
+
],
|
|
20
|
+
"r8s": {
|
|
21
|
+
"category": "Productivity & Documents"
|
|
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,324 @@
|
|
|
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 EuroOfficeProps {
|
|
7
|
+
/** Resource name (defaults to 'eurooffice') */
|
|
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 document suite (required) */
|
|
14
|
+
host: string
|
|
15
|
+
/**
|
|
16
|
+
* Number of app replicas.
|
|
17
|
+
*
|
|
18
|
+
* With `websockets` enabled (the default) this defaults to **1**:
|
|
19
|
+
* websocket sessions are pinned to the pod that accepted the
|
|
20
|
+
* connection and the workload has no session affinity, so >1 replicas
|
|
21
|
+
* means collaborators on different pods stop seeing each other live.
|
|
22
|
+
* An explicitly set `replicas` is rendered as asked — pair it with a
|
|
23
|
+
* sticky-session (source-IP) ingress strategy for live co-editing.
|
|
24
|
+
* With `websockets: false` the default is 2 (the app is stateless —
|
|
25
|
+
* scale freely).
|
|
26
|
+
*/
|
|
27
|
+
replicas?: number
|
|
28
|
+
/**
|
|
29
|
+
* Collaborative editing over websockets (default: true). Disable only
|
|
30
|
+
* for single-user or read-only deployments — document presence,
|
|
31
|
+
* cursor sharing and live co-editing all rely on websockets.
|
|
32
|
+
*/
|
|
33
|
+
websockets?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* S3-compatible object storage for document blobs and attachments
|
|
36
|
+
* (RustFS in the platform). Required — reference a bucket whose
|
|
37
|
+
* credentials live in a Secret provisioned by the secrets backend
|
|
38
|
+
* (keys: accessKey, secretKey) — never plaintext.
|
|
39
|
+
*/
|
|
40
|
+
objectStorage: {
|
|
41
|
+
/** S3 endpoint URL, e.g. https://s3.internal.example.com */
|
|
42
|
+
endpoint: string
|
|
43
|
+
/** Bucket name for document blobs */
|
|
44
|
+
bucket: string
|
|
45
|
+
/** Name of the Secret holding accessKey / secretKey */
|
|
46
|
+
credentialsSecret: string
|
|
47
|
+
/** Region string for the S3 client (defaults to 'us-east-1') */
|
|
48
|
+
region?: string
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Outgoing SMTP for invitations and notifications. The SMTP password is
|
|
52
|
+
* delivered via secretKeyRef from the `${name}-secrets` bundle
|
|
53
|
+
* (key: smtpPassword) — never plaintext.
|
|
54
|
+
*/
|
|
55
|
+
smtp?: {
|
|
56
|
+
/** SMTP server hostname, e.g. smtp.example.com */
|
|
57
|
+
host: string
|
|
58
|
+
/** SMTP port (defaults to 587) */
|
|
59
|
+
port?: number
|
|
60
|
+
/** From address for outgoing mail, e.g. no-reply@example.com */
|
|
61
|
+
from?: string
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* LibreOffice headless document-conversion workers (same app image,
|
|
65
|
+
* which must include soffice). Adds a `${name}-soffice` Deployment +
|
|
66
|
+
* Service; the app reaches it at SOFFICE_HOST:SOFFICE_PORT. Workers
|
|
67
|
+
* serve the UNO socket (TCP), not HTTP — their probes probe the socket.
|
|
68
|
+
*/
|
|
69
|
+
conversions?: boolean
|
|
70
|
+
/** LibreOffice conversion worker replicas (defaults to 1, only used with conversions) */
|
|
71
|
+
conversionWorkers?: number
|
|
72
|
+
/**
|
|
73
|
+
* Name of an existing Secret holding `secretKey` and `smtpPassword`.
|
|
74
|
+
* Required unless a secrets backend (openbao/vault) is configured on
|
|
75
|
+
* the surrounding Platform — the backend then provisions them.
|
|
76
|
+
* Plaintext values are not supported.
|
|
77
|
+
*/
|
|
78
|
+
secretsName?: string
|
|
79
|
+
/** Requested app resources */
|
|
80
|
+
resources?: {
|
|
81
|
+
requests?: { cpu?: string; memory?: string }
|
|
82
|
+
limits?: { cpu?: string; memory?: string }
|
|
83
|
+
}
|
|
84
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
85
|
+
tls?: {
|
|
86
|
+
secretName: string
|
|
87
|
+
clusterIssuer: string
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* EuroOffice — collaborative document suite.
|
|
93
|
+
*
|
|
94
|
+
* @title EuroOffice
|
|
95
|
+
* @category Productivity & Documents
|
|
96
|
+
*
|
|
97
|
+
* Composes:
|
|
98
|
+
* - CNPG Postgres cluster (documents, revisions, users; DATABASE_URL is
|
|
99
|
+
* auto-wired from the DatabaseContext — no manual connection string)
|
|
100
|
+
* - EuroOffice Deployment + Service + Endpoint (websockets enabled by
|
|
101
|
+
* default for live co-editing)
|
|
102
|
+
* - Required S3/RustFS object storage for document blobs
|
|
103
|
+
* - Optional LibreOffice headless conversion workers (same image; probed
|
|
104
|
+
* on their UNO TCP socket)
|
|
105
|
+
* - Optional SMTP delivery; password via the `${name}-secrets` bundle
|
|
106
|
+
* - APP_SECRET (and SMTP_PASSWORD) provisioned through the Platform
|
|
107
|
+
* secrets backend (openbao / vault), or referenced from an existing
|
|
108
|
+
* Secret via `secretsName`
|
|
109
|
+
*
|
|
110
|
+
* The namespace is inherited from the surrounding `<Platform>` (via the
|
|
111
|
+
* Namespace context) unless set explicitly.
|
|
112
|
+
*
|
|
113
|
+
* Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
|
|
114
|
+
* the app secrets bundle is provisioned for you. Without a backend you
|
|
115
|
+
* must point `secretsName` at a pre-created Secret.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* import { Platform } from '@r8s/recipes'
|
|
119
|
+
* import { EuroOffice } from '@r8s/eurooffice'
|
|
120
|
+
*
|
|
121
|
+
* export default (
|
|
122
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
123
|
+
* <EuroOffice
|
|
124
|
+
* name="docs"
|
|
125
|
+
* host="docs.example.com"
|
|
126
|
+
* objectStorage={{
|
|
127
|
+
* endpoint: 'https://s3.internal.example.com',
|
|
128
|
+
* bucket: 'docs-blobs',
|
|
129
|
+
* credentialsSecret: 'docs-blobs-credentials',
|
|
130
|
+
* }}
|
|
131
|
+
* smtp={{ host: 'smtp.example.com', port: 587, from: 'no-reply@${env:MAIL_DOMAIN}' }}
|
|
132
|
+
* />
|
|
133
|
+
* </Platform>
|
|
134
|
+
* )
|
|
135
|
+
*/
|
|
136
|
+
export function EuroOffice(props: EuroOfficeProps) {
|
|
137
|
+
const {
|
|
138
|
+
name = 'eurooffice',
|
|
139
|
+
namespace: namespaceProp,
|
|
140
|
+
version = 'latest',
|
|
141
|
+
host,
|
|
142
|
+
replicas: replicasProp,
|
|
143
|
+
websockets = true,
|
|
144
|
+
objectStorage,
|
|
145
|
+
smtp,
|
|
146
|
+
conversions = false,
|
|
147
|
+
conversionWorkers = 1,
|
|
148
|
+
secretsName,
|
|
149
|
+
resources = {
|
|
150
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
151
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
152
|
+
},
|
|
153
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
154
|
+
} = props
|
|
155
|
+
|
|
156
|
+
// Inherit namespace from <Platform> context if not explicitly set
|
|
157
|
+
const contextNamespace = useContext(Namespace)
|
|
158
|
+
const namespace =
|
|
159
|
+
namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
|
|
160
|
+
|
|
161
|
+
// Live co-editing runs over websockets pinned to a single pod (no session
|
|
162
|
+
// affinity) — default to 1 replica unless the caller explicitly scales.
|
|
163
|
+
const replicas = replicasProp ?? (websockets ? 1 : 2)
|
|
164
|
+
|
|
165
|
+
const secretProvider = useContext(SecretContext)
|
|
166
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
167
|
+
|
|
168
|
+
const platformSecretsName = secretsName ?? `${name}-secrets`
|
|
169
|
+
|
|
170
|
+
// --- App secrets (APP_SECRET / SMTP_PASSWORD) -----------------------------
|
|
171
|
+
// The app secret signs sessions and collaborator tokens — never render
|
|
172
|
+
// it as plaintext. With a secrets backend the bundle is provisioned
|
|
173
|
+
// through the backend (keys: secretKey, smtpPassword); otherwise
|
|
174
|
+
// reference a pre-created Secret.
|
|
175
|
+
if (!secretsName) {
|
|
176
|
+
if (
|
|
177
|
+
!secretProvider ||
|
|
178
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`EuroOffice "${name}" requires application secrets (APP_SECRET, SMTP_PASSWORD).\n` +
|
|
182
|
+
`\n` +
|
|
183
|
+
`The app secret signs sessions and collaborator tokens — it must ` +
|
|
184
|
+
`not be rendered as plaintext.\n` +
|
|
185
|
+
`\n` +
|
|
186
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
187
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
188
|
+
` <EuroOffice name="${name}" host="${host}" />\n` +
|
|
189
|
+
` </Platform>\n` +
|
|
190
|
+
`\n` +
|
|
191
|
+
`Or reference a pre-created Secret (keys: secretKey, smtpPassword):\n` +
|
|
192
|
+
` <EuroOffice name="${name}" host="${host}" secretsName="${name}-secrets" />`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const spec = {
|
|
197
|
+
...(secretProvider.backend === 'vault'
|
|
198
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
199
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
200
|
+
mount: secretProvider.mount,
|
|
201
|
+
type: 'kv-v2' as const,
|
|
202
|
+
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
203
|
+
destination: { create: true, name: platformSecretsName },
|
|
204
|
+
}
|
|
205
|
+
resources_.push(
|
|
206
|
+
secretProvider.backend === 'vault'
|
|
207
|
+
? jsx('VaultStaticSecret', {
|
|
208
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
209
|
+
kind: 'VaultStaticSecret',
|
|
210
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
211
|
+
spec,
|
|
212
|
+
})
|
|
213
|
+
: jsx('OpenBaoStaticSecret', {
|
|
214
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
215
|
+
kind: 'OpenBaoStaticSecret',
|
|
216
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
217
|
+
spec,
|
|
218
|
+
})
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Env wiring -------------------------------------------------------------
|
|
223
|
+
// Every credential is delivered via secretKeyRef — no plaintext in the
|
|
224
|
+
// manifest. DATABASE_URL is auto-wired by WebService from the
|
|
225
|
+
// DatabaseContext (PG* vars + $(VAR) template), so it is not declared here.
|
|
226
|
+
const env: Record<string, string> = {
|
|
227
|
+
PORT: '3000',
|
|
228
|
+
APP_URL: `https://${host}`,
|
|
229
|
+
WEBSOCKETS_ENABLED: websockets ? 'true' : 'false',
|
|
230
|
+
AWS_REGION: objectStorage.region ?? 'us-east-1',
|
|
231
|
+
AWS_S3_UPLOAD_BUCKET_URL: `${objectStorage.endpoint}/${objectStorage.bucket}`,
|
|
232
|
+
AWS_S3_UPLOAD_BUCKET_NAME: objectStorage.bucket,
|
|
233
|
+
AWS_S3_ENDPOINT: objectStorage.endpoint,
|
|
234
|
+
AWS_S3_FORCE_PATH_STYLE: 'true',
|
|
235
|
+
...(smtp
|
|
236
|
+
? {
|
|
237
|
+
SMTP_HOST: smtp.host,
|
|
238
|
+
SMTP_PORT: String(smtp.port ?? 587),
|
|
239
|
+
...(smtp.from && { SMTP_FROM: smtp.from }),
|
|
240
|
+
}
|
|
241
|
+
: {}),
|
|
242
|
+
...(conversions
|
|
243
|
+
? {
|
|
244
|
+
SOFFICE_HOST: `${name}-soffice`,
|
|
245
|
+
SOFFICE_PORT: '2002',
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Credentials delivered via secretKeyRef (runtime injection)
|
|
251
|
+
const secrets: Record<string, SecretRef | string> = {
|
|
252
|
+
APP_SECRET: { secret: platformSecretsName, key: 'secretKey' },
|
|
253
|
+
...(smtp
|
|
254
|
+
? { SMTP_PASSWORD: { secret: platformSecretsName, key: 'smtpPassword' as const } }
|
|
255
|
+
: {}),
|
|
256
|
+
AWS_ACCESS_KEY_ID: { secret: objectStorage.credentialsSecret, key: 'accessKey' },
|
|
257
|
+
AWS_SECRET_ACCESS_KEY: { secret: objectStorage.credentialsSecret, key: 'secretKey' },
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Database + app + conversions + endpoint --------------------------------
|
|
261
|
+
// Database wraps the app so credentials stay consistent with the r8s
|
|
262
|
+
// Database recipe (CNPG dedicated cluster provisions the secret) and
|
|
263
|
+
// WebService auto-wires DATABASE_URL from the DatabaseContext.
|
|
264
|
+
resources_.push(
|
|
265
|
+
jsx(Database, {
|
|
266
|
+
name,
|
|
267
|
+
namespace,
|
|
268
|
+
storage: '10Gi',
|
|
269
|
+
children: (
|
|
270
|
+
<WebService
|
|
271
|
+
name={name}
|
|
272
|
+
namespace={namespace}
|
|
273
|
+
image={`ghcr.io/berget-ai/eurooffice:${version}`}
|
|
274
|
+
port={3000}
|
|
275
|
+
replicas={replicas}
|
|
276
|
+
resources={resources}
|
|
277
|
+
env={env}
|
|
278
|
+
secrets={secrets}
|
|
279
|
+
/>
|
|
280
|
+
),
|
|
281
|
+
})
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
// LibreOffice headless conversion workers — same app image (must include
|
|
285
|
+
// soffice), listening for UNO socket connections. The workers know HTTP
|
|
286
|
+
// health endpoints no better than we do, so probe the UNO TCP socket
|
|
287
|
+
// directly (httpGet /health would crash-loop them).
|
|
288
|
+
if (conversions) {
|
|
289
|
+
resources_.push(
|
|
290
|
+
<WebService
|
|
291
|
+
name={`${name}-soffice`}
|
|
292
|
+
namespace={namespace}
|
|
293
|
+
image={`ghcr.io/berget-ai/eurooffice:${version}`}
|
|
294
|
+
port={2002}
|
|
295
|
+
replicas={conversionWorkers}
|
|
296
|
+
command={[
|
|
297
|
+
'soffice',
|
|
298
|
+
'--headless',
|
|
299
|
+
'--norestore',
|
|
300
|
+
'--accept=socket,host=0.0.0.0,port=2002;urp;',
|
|
301
|
+
]}
|
|
302
|
+
env={{ SOFFICE_MODE: 'worker' }}
|
|
303
|
+
probes={{
|
|
304
|
+
liveness: { tcp: true, port: 2002 },
|
|
305
|
+
readiness: { tcp: true, port: 2002, initialDelaySeconds: 20 },
|
|
306
|
+
}}
|
|
307
|
+
/>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- Endpoint — collaborative editing shares the app host -------------------
|
|
312
|
+
resources_.push(
|
|
313
|
+
<Endpoint
|
|
314
|
+
name={`${name}-endpoint`}
|
|
315
|
+
namespace={namespace}
|
|
316
|
+
host={host}
|
|
317
|
+
serviceName={name}
|
|
318
|
+
servicePort={3000}
|
|
319
|
+
tls={tls}
|
|
320
|
+
/>
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return jsx(Fragment, { children: resources_ })
|
|
324
|
+
}
|
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
|
+
}
|