@r8s/paperclip 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__/paperclip.test.ts +450 -0
- package/examples/basic.tsx +8 -0
- package/package.json +41 -0
- package/src/index.tsx +278 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,450 @@
|
|
|
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
|
+
// Paperclip recipe tests:
|
|
9
|
+
// 1. Operator declarations (cnpg via Database, deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, sandbox agents (hardened sandbox, TCP probes),
|
|
11
|
+
// all props, gateway/ingress adaptation
|
|
12
|
+
// 3. Namespace inheritance from the Platform context
|
|
13
|
+
// 4. Security: no plaintext credentials in rendered output
|
|
14
|
+
import { Paperclip } from '../src/index'
|
|
15
|
+
|
|
16
|
+
/** Render Paperclip inside a Platform-like secrets backend (OpenBao). */
|
|
17
|
+
function renderPaperclip(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
18
|
+
return render(
|
|
19
|
+
jsx(SecretContext.Provider, {
|
|
20
|
+
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
21
|
+
children: jsx(Paperclip, props as never),
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Render Paperclip inside a Platform-like Namespace context (no explicit namespace prop). */
|
|
27
|
+
function renderPaperclipInNamespace(
|
|
28
|
+
namespaceValue: string,
|
|
29
|
+
props: Record<string, unknown>
|
|
30
|
+
): ReturnType<typeof render> {
|
|
31
|
+
return render(
|
|
32
|
+
jsx(Namespace.Provider, {
|
|
33
|
+
value: namespaceValue,
|
|
34
|
+
children: jsx(SecretContext.Provider, {
|
|
35
|
+
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
36
|
+
children: jsx(Paperclip, props as never),
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Render Paperclip wrapped only in an OperatorContext (no secrets backend). */
|
|
43
|
+
function renderPaperclipWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
|
|
44
|
+
return jsx(OperatorContext.Provider, {
|
|
45
|
+
value: operators_,
|
|
46
|
+
children: jsx(Paperclip, props as never),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
51
|
+
|
|
52
|
+
describe('operator declarations', () => {
|
|
53
|
+
it('declares the cnpg operator via the Database recipe', () => {
|
|
54
|
+
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
55
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('declares cnpg exactly once even with sandbox agents (no redis for paperclip)', () => {
|
|
59
|
+
const result = renderPaperclip({
|
|
60
|
+
host: 'paperclip.example.com',
|
|
61
|
+
agents: { sandboxReplicas: 3 },
|
|
62
|
+
})
|
|
63
|
+
const names = result.operators.map((op) => op.name)
|
|
64
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
65
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
66
|
+
expect(kinds).not.toContain('RedisCluster')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('deduplicates operators provided via context', () => {
|
|
70
|
+
const result = render(
|
|
71
|
+
renderPaperclipWithContext([operators['cnpg']()], {
|
|
72
|
+
host: 'paperclip.example.com',
|
|
73
|
+
secretsName: 'existing-secrets',
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
const names = result.operators.map((op) => op.name)
|
|
77
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('allows version overrides through context operators', () => {
|
|
81
|
+
const result = render(
|
|
82
|
+
renderPaperclipWithContext([operators['cnpg']('1.25.0')], {
|
|
83
|
+
host: 'paperclip.example.com',
|
|
84
|
+
secretsName: 'existing-secrets',
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
const cnpg = result.operators.filter((op) => op.name === 'cnpg')
|
|
88
|
+
expect(cnpg).toHaveLength(1)
|
|
89
|
+
expect(cnpg[0].version).toBe('1.25.0')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('rendering defaults', () => {
|
|
94
|
+
it('renders main deployment, service, database and endpoint', () => {
|
|
95
|
+
const result = renderPaperclip({ host: 'paperclip.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('renders the main app with the paperclip image on port 3000', () => {
|
|
104
|
+
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
105
|
+
const main = result.resources.find(
|
|
106
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
107
|
+
) as any
|
|
108
|
+
expect(main).toBeDefined()
|
|
109
|
+
const container = main.spec.template.spec.containers[0]
|
|
110
|
+
expect(container.image).toBe('ghcr.io/berget-ai/paperclip:latest')
|
|
111
|
+
expect(container.ports[0].containerPort).toBe(3000)
|
|
112
|
+
expect(main.spec.replicas).toBe(2)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('probes both workloads with TCP sockets (unknown HTTP health contract)', () => {
|
|
116
|
+
const result = renderPaperclip({
|
|
117
|
+
host: 'paperclip.example.com',
|
|
118
|
+
agents: { sandboxReplicas: 2 },
|
|
119
|
+
})
|
|
120
|
+
for (const name of ['paperclip', 'paperclip-agent-sandbox']) {
|
|
121
|
+
const deployment = result.resources.find(
|
|
122
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === name
|
|
123
|
+
) as any
|
|
124
|
+
const container = deployment.spec.template.spec.containers[0]
|
|
125
|
+
expect(container.livenessProbe.tcpSocket).toEqual({ port: 3000 })
|
|
126
|
+
expect(container.readinessProbe.tcpSocket).toEqual({ port: 3000 })
|
|
127
|
+
expect(container.livenessProbe.httpGet).toBeUndefined()
|
|
128
|
+
expect(container.readinessProbe.httpGet).toBeUndefined()
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('renders no sandbox deployment when agents is not set', () => {
|
|
133
|
+
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
134
|
+
const names = result.resources
|
|
135
|
+
.filter((r) => r.kind === 'Deployment')
|
|
136
|
+
.map((d: any) => d.metadata.name)
|
|
137
|
+
expect(names).toEqual(['paperclip'])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('renders the sandbox agent deployment when agents is set', () => {
|
|
141
|
+
const result = renderPaperclip({
|
|
142
|
+
host: 'paperclip.example.com',
|
|
143
|
+
agents: { sandboxReplicas: 3 },
|
|
144
|
+
})
|
|
145
|
+
const sandbox = result.resources.find(
|
|
146
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
147
|
+
) as any
|
|
148
|
+
expect(sandbox).toBeDefined()
|
|
149
|
+
expect(sandbox.spec.replicas).toBe(3)
|
|
150
|
+
expect(sandbox.spec.template.spec.containers[0].image).toBe(
|
|
151
|
+
'ghcr.io/berget-ai/paperclip:latest'
|
|
152
|
+
)
|
|
153
|
+
expect(sandbox.spec.template.spec.containers[0].command).toEqual([
|
|
154
|
+
'paperclip',
|
|
155
|
+
'agent',
|
|
156
|
+
'--sandbox',
|
|
157
|
+
])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('hardens the sandbox with a locked-down securityContext', () => {
|
|
161
|
+
const result = renderPaperclip({
|
|
162
|
+
host: 'paperclip.example.com',
|
|
163
|
+
agents: {},
|
|
164
|
+
})
|
|
165
|
+
const sandbox = result.resources.find(
|
|
166
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
167
|
+
) as any
|
|
168
|
+
const podSpec = sandbox.spec.template.spec
|
|
169
|
+
expect(podSpec.securityContext).toEqual({ seccompProfile: { type: 'RuntimeDefault' } })
|
|
170
|
+
const securityContext = podSpec.containers[0].securityContext
|
|
171
|
+
expect(securityContext.runAsNonRoot).toBe(true)
|
|
172
|
+
expect(securityContext.allowPrivilegeEscalation).toBe(false)
|
|
173
|
+
expect(securityContext.seccompProfile).toEqual({ type: 'RuntimeDefault' })
|
|
174
|
+
expect(securityContext.capabilities.drop).toEqual(['ALL'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('gives the sandbox explicit resources by default and honours overrides', () => {
|
|
178
|
+
const defaults = renderPaperclip({ host: 'paperclip.example.com', agents: {} })
|
|
179
|
+
const defaultSandbox = defaults.resources.find(
|
|
180
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
181
|
+
) as any
|
|
182
|
+
const defaultContainer = defaultSandbox.spec.template.spec.containers[0]
|
|
183
|
+
expect(defaultContainer.resources.requests).toEqual({ cpu: '250m', memory: '256Mi' })
|
|
184
|
+
expect(defaultContainer.resources.limits).toEqual({ cpu: '1000m', memory: '2Gi' })
|
|
185
|
+
|
|
186
|
+
const result = renderPaperclip({
|
|
187
|
+
host: 'paperclip.example.com',
|
|
188
|
+
agents: {
|
|
189
|
+
sandboxReplicas: 1,
|
|
190
|
+
resources: {
|
|
191
|
+
requests: { cpu: '500m', memory: '1Gi' },
|
|
192
|
+
limits: { cpu: '2000m', memory: '4Gi' },
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
const sandbox = result.resources.find(
|
|
197
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
198
|
+
) as any
|
|
199
|
+
const container = sandbox.spec.template.spec.containers[0]
|
|
200
|
+
expect(container.resources.requests).toEqual({ cpu: '500m', memory: '1Gi' })
|
|
201
|
+
expect(container.resources.limits).toEqual({ cpu: '2000m', memory: '4Gi' })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('defaults sandbox replicas to 2', () => {
|
|
205
|
+
const result = renderPaperclip({ host: 'paperclip.example.com', agents: {} })
|
|
206
|
+
const sandbox = result.resources.find(
|
|
207
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
208
|
+
) as any
|
|
209
|
+
expect(sandbox.spec.replicas).toBe(2)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
213
|
+
const result = render(
|
|
214
|
+
jsx(RoutingContext.Provider, {
|
|
215
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
216
|
+
children: jsx(SecretContext.Provider, {
|
|
217
|
+
value: openbao as never,
|
|
218
|
+
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
219
|
+
}),
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
223
|
+
expect(kinds).toContain('HTTPRoute')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
227
|
+
const result = render(
|
|
228
|
+
jsx(RoutingContext.Provider, {
|
|
229
|
+
value: { mode: 'ingress' },
|
|
230
|
+
children: jsx(SecretContext.Provider, {
|
|
231
|
+
value: openbao as never,
|
|
232
|
+
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
233
|
+
}),
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
237
|
+
expect(ingress).toBeDefined()
|
|
238
|
+
expect(ingress.spec.rules[0].host).toBe('paperclip.example.com')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('passes resource validation', () => {
|
|
242
|
+
const result = renderPaperclip({
|
|
243
|
+
host: 'paperclip.example.com',
|
|
244
|
+
agents: { sandboxReplicas: 2 },
|
|
245
|
+
})
|
|
246
|
+
for (const resource of result.resources) {
|
|
247
|
+
expect(validateResource(resource)).toEqual([])
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('namespace inheritance', () => {
|
|
253
|
+
it('inherits namespace from the Platform context when namespace prop is not set', () => {
|
|
254
|
+
const result = renderPaperclipInNamespace('agents', {
|
|
255
|
+
host: 'paperclip.example.com',
|
|
256
|
+
agents: { sandboxReplicas: 1 },
|
|
257
|
+
})
|
|
258
|
+
for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
|
|
259
|
+
const resource = result.resources.find((r: any) => r.kind === kind)
|
|
260
|
+
expect(resource).toBeDefined()
|
|
261
|
+
expect(resource.metadata.namespace).toBe('agents')
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('explicit namespace prop wins over the Platform context', () => {
|
|
266
|
+
const result = renderPaperclipInNamespace('agents', {
|
|
267
|
+
host: 'paperclip.example.com',
|
|
268
|
+
namespace: 'paperclip-ns',
|
|
269
|
+
})
|
|
270
|
+
const main = result.resources.find(
|
|
271
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
272
|
+
) as any
|
|
273
|
+
expect(main.metadata.namespace).toBe('paperclip-ns')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('falls back to default when no Platform namespace is present', () => {
|
|
277
|
+
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
278
|
+
const main = result.resources.find(
|
|
279
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
280
|
+
) as any
|
|
281
|
+
expect(main.metadata.namespace).toBe('default')
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('rendering with all props', () => {
|
|
286
|
+
it('accepts the full prop surface', () => {
|
|
287
|
+
const result = renderPaperclip({
|
|
288
|
+
name: 'agents',
|
|
289
|
+
namespace: 'agents',
|
|
290
|
+
version: '0.4.0',
|
|
291
|
+
host: 'agents.example.com',
|
|
292
|
+
replicas: 3,
|
|
293
|
+
dbStorage: '20Gi',
|
|
294
|
+
websockets: true,
|
|
295
|
+
agents: { sandboxReplicas: 4 },
|
|
296
|
+
resources: {
|
|
297
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
298
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
299
|
+
},
|
|
300
|
+
tls: { secretName: 'agents-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
301
|
+
})
|
|
302
|
+
expect(result.resources.length).toBeGreaterThan(0)
|
|
303
|
+
|
|
304
|
+
const main = result.resources.find(
|
|
305
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'agents'
|
|
306
|
+
) as any
|
|
307
|
+
const container = main.spec.template.spec.containers[0]
|
|
308
|
+
expect(container.image).toBe('ghcr.io/berget-ai/paperclip:0.4.0')
|
|
309
|
+
expect(main.spec.replicas).toBe(3)
|
|
310
|
+
expect(container.resources.limits.memory).toBe('4Gi')
|
|
311
|
+
const websockets = container.env.find((e: any) => e.name === 'WEBSOCKETS_ENABLED')
|
|
312
|
+
expect(websockets.value).toBe('true')
|
|
313
|
+
|
|
314
|
+
const sandbox = result.resources.find(
|
|
315
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'agents-agent-sandbox'
|
|
316
|
+
) as any
|
|
317
|
+
expect(sandbox.spec.replicas).toBe(4)
|
|
318
|
+
|
|
319
|
+
const cluster = result.resources.find((r) => r.kind === 'Cluster') as any
|
|
320
|
+
expect(cluster.spec.storage.size).toBe('20Gi')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('renders unique env var names (k8s rejects duplicates)', () => {
|
|
324
|
+
const result = renderPaperclip({
|
|
325
|
+
host: 'paperclip.example.com',
|
|
326
|
+
agents: { sandboxReplicas: 1 },
|
|
327
|
+
})
|
|
328
|
+
for (const name of ['paperclip', 'paperclip-agent-sandbox']) {
|
|
329
|
+
const deployment = result.resources.find(
|
|
330
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === name
|
|
331
|
+
) as any
|
|
332
|
+
const env = deployment.spec.template.spec.containers[0].env as Array<{ name: string }>
|
|
333
|
+
const names = env.map((e) => e.name)
|
|
334
|
+
expect(new Set(names).size).toBe(names.length)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
describe('secrets handling', () => {
|
|
340
|
+
it('provisions the model API key through a secrets backend', () => {
|
|
341
|
+
const result = renderPaperclip({ host: 'paperclip.example.com' })
|
|
342
|
+
const bundle = result.resources.find(
|
|
343
|
+
(r: any) => r.kind === 'OpenBaoStaticSecret' && r.metadata.name === 'paperclip-secrets'
|
|
344
|
+
) as any
|
|
345
|
+
expect(bundle).toBeDefined()
|
|
346
|
+
expect(bundle.spec.path).toBe('test/paperclip/secrets')
|
|
347
|
+
expect(bundle.spec.destination).toEqual({
|
|
348
|
+
create: true,
|
|
349
|
+
name: 'paperclip-secrets',
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('provisions the model API key through Vault', () => {
|
|
354
|
+
const result = render(
|
|
355
|
+
jsx(SecretContext.Provider, {
|
|
356
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
357
|
+
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
358
|
+
})
|
|
359
|
+
)
|
|
360
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
361
|
+
expect(kinds).toContain('VaultStaticSecret')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('accepts an explicit secretsName without a backend', () => {
|
|
365
|
+
const result = render(
|
|
366
|
+
jsx(Paperclip, { host: 'paperclip.example.com', secretsName: 'existing-secrets' })
|
|
367
|
+
)
|
|
368
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
369
|
+
expect(kinds).not.toContain('OpenBaoStaticSecret')
|
|
370
|
+
expect(kinds).not.toContain('VaultStaticSecret')
|
|
371
|
+
expect(result.resources.length).toBeGreaterThan(0)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
375
|
+
const result = render(
|
|
376
|
+
jsx(Paperclip, { host: 'paperclip.example.com', secretsName: 'existing-secrets' })
|
|
377
|
+
)
|
|
378
|
+
const main = result.resources.find(
|
|
379
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
|
|
380
|
+
) as any
|
|
381
|
+
const env = main.spec.template.spec.containers[0].env
|
|
382
|
+
const modelApiKey = env.find((e: any) => e.name === 'MODEL_API_KEY')
|
|
383
|
+
const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
384
|
+
expect(modelApiKey.valueFrom.secretKeyRef).toEqual({
|
|
385
|
+
name: 'existing-secrets',
|
|
386
|
+
key: 'modelApiKey',
|
|
387
|
+
})
|
|
388
|
+
expect(dbPassword.valueFrom.secretKeyRef).toEqual({
|
|
389
|
+
name: 'paperclip-db-credentials',
|
|
390
|
+
key: 'password',
|
|
391
|
+
})
|
|
392
|
+
expect(modelApiKey.value).toBeUndefined()
|
|
393
|
+
expect(dbPassword.value).toBeUndefined()
|
|
394
|
+
const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
|
|
395
|
+
expect(databaseUrl.value).toContain('$(PGPASSWORD)')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('sandbox workers share the same secrets bundle', () => {
|
|
399
|
+
const result = render(
|
|
400
|
+
jsx(SecretContext.Provider, {
|
|
401
|
+
value: openbao as never,
|
|
402
|
+
children: jsx(Paperclip, {
|
|
403
|
+
host: 'paperclip.example.com',
|
|
404
|
+
agents: { sandboxReplicas: 2 },
|
|
405
|
+
}),
|
|
406
|
+
})
|
|
407
|
+
)
|
|
408
|
+
const sandbox = result.resources.find(
|
|
409
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
|
|
410
|
+
) as any
|
|
411
|
+
const env = sandbox.spec.template.spec.containers[0].env
|
|
412
|
+
const modelApiKey = env.find((e: any) => e.name === 'MODEL_API_KEY')
|
|
413
|
+
const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
414
|
+
expect(modelApiKey.valueFrom.secretKeyRef.name).toBe('paperclip-secrets')
|
|
415
|
+
expect(modelApiKey.valueFrom.secretKeyRef.key).toBe('modelApiKey')
|
|
416
|
+
expect(dbPassword.valueFrom.secretKeyRef.name).toBe('paperclip-db-credentials')
|
|
417
|
+
expect(modelApiKey.value).toBeUndefined()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
421
|
+
const result = renderPaperclip({
|
|
422
|
+
host: 'paperclip.example.com',
|
|
423
|
+
agents: { sandboxReplicas: 2 },
|
|
424
|
+
})
|
|
425
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
426
|
+
if (!passed) {
|
|
427
|
+
console.error('Plaintext credential violations:', errors)
|
|
428
|
+
}
|
|
429
|
+
expect(passed).toBe(true)
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
describe('validation errors', () => {
|
|
434
|
+
it('throws when no secrets backend and no model key secret', () => {
|
|
435
|
+
expect(() => render(jsx(Paperclip, { host: 'paperclip.example.com' }))).toThrow(
|
|
436
|
+
/model api key/i
|
|
437
|
+
)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('throws for unknown secrets backends', () => {
|
|
441
|
+
expect(() =>
|
|
442
|
+
render(
|
|
443
|
+
jsx(SecretContext.Provider, {
|
|
444
|
+
value: { backend: 'unknown' as never },
|
|
445
|
+
children: jsx(Paperclip, { host: 'paperclip.example.com' }),
|
|
446
|
+
})
|
|
447
|
+
)
|
|
448
|
+
).toThrow(/model api key/i)
|
|
449
|
+
})
|
|
450
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Platform } from '@r8s/recipes'
|
|
2
|
+
import { Paperclip } from '@r8s/paperclip'
|
|
3
|
+
|
|
4
|
+
export default (
|
|
5
|
+
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
+
<Paperclip name="paperclip" host="paperclip.example.com" agents={{ sandboxReplicas: 3 }} />
|
|
7
|
+
</Platform>
|
|
8
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/paperclip",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Paperclip — Berget's agent platform (tasks, documents, agent orchestration)",
|
|
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/paperclip"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"paperclip",
|
|
16
|
+
"agents",
|
|
17
|
+
"documents",
|
|
18
|
+
"automation"
|
|
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,278 @@
|
|
|
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 PaperclipProps {
|
|
7
|
+
/** Resource name (defaults to 'paperclip') */
|
|
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 web app and API (required) */
|
|
14
|
+
host: string
|
|
15
|
+
/** Number of app replicas (defaults to 2) */
|
|
16
|
+
replicas?: number
|
|
17
|
+
/** Storage size for the Postgres cluster (defaults to '10Gi') */
|
|
18
|
+
dbStorage?: string
|
|
19
|
+
/** Enable websockets for live task and agent updates (defaults to false) */
|
|
20
|
+
websockets?: boolean
|
|
21
|
+
/**
|
|
22
|
+
* Sandbox agent workers. Workers run the same image with a command
|
|
23
|
+
* override (paperclip agent --sandbox) and share the model API key
|
|
24
|
+
* and database credentials via secretKeyRef. They run with a hardened
|
|
25
|
+
* securityContext (non-root, no privilege escalation, RuntimeDefault
|
|
26
|
+
* seccomp, all capabilities dropped) by default.
|
|
27
|
+
*/
|
|
28
|
+
agents?: {
|
|
29
|
+
/** Number of sandbox agent replicas (defaults to 2) */
|
|
30
|
+
sandboxReplicas?: number
|
|
31
|
+
/** Sandbox container resources (defaults to requests 256Mi/250m, limits 2Gi/1000m) */
|
|
32
|
+
resources?: {
|
|
33
|
+
requests?: { cpu?: string; memory?: string }
|
|
34
|
+
limits?: { cpu?: string; memory?: string }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Name of an existing Secret containing key `modelApiKey`. Paperclip
|
|
39
|
+
* uses this key to call LLM providers on behalf of agents.
|
|
40
|
+
*
|
|
41
|
+
* Required unless a secrets backend (openbao/vault) is configured on
|
|
42
|
+
* the surrounding Platform — the backend then provisions the key
|
|
43
|
+
* automatically. Plaintext keys are not supported.
|
|
44
|
+
*/
|
|
45
|
+
secretsName?: string
|
|
46
|
+
/** Requested app resources */
|
|
47
|
+
resources?: {
|
|
48
|
+
requests?: { cpu?: string; memory?: string }
|
|
49
|
+
limits?: { cpu?: string; memory?: string }
|
|
50
|
+
}
|
|
51
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
52
|
+
tls?: {
|
|
53
|
+
secretName: string
|
|
54
|
+
clusterIssuer: string
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Paperclip — Berget's agent platform (tasks, documents, agent orchestration).
|
|
60
|
+
*
|
|
61
|
+
* @title Paperclip
|
|
62
|
+
* @category Agent Platforms
|
|
63
|
+
*
|
|
64
|
+
* Composes:
|
|
65
|
+
* - CNPG Postgres cluster (tasks, documents, agent state; size via
|
|
66
|
+
* `dbStorage`)
|
|
67
|
+
* - Paperclip Deployment + Service + Endpoint
|
|
68
|
+
* - Optional sandbox agent workers (same image, command override) sharing
|
|
69
|
+
* the model API key via secretKeyRef — hardened with a non-root,
|
|
70
|
+
* no-privilege-escalation securityContext, RuntimeDefault seccomp and
|
|
71
|
+
* all capabilities dropped, with explicit resources by default
|
|
72
|
+
* - Model API key provisioned through the Platform secrets backend
|
|
73
|
+
* (openbao / vault), or referenced from an existing Secret
|
|
74
|
+
* - Optional websockets support (WEBSOCKETS_ENABLED)
|
|
75
|
+
*
|
|
76
|
+
* Probing: the Paperclip image contract does not publish HTTP health
|
|
77
|
+
* endpoints, so both workloads default to TCP socket probes on the
|
|
78
|
+
* container port (3000) to avoid crash-looping on unknown /health /ready
|
|
79
|
+
* paths. If your image serves HTTP /health and /ready — or another
|
|
80
|
+
* readiness endpoint — override `probes` on the workload for stricter
|
|
81
|
+
* checks.
|
|
82
|
+
*
|
|
83
|
+
* The namespace is inherited from the surrounding `<Platform>` (via the
|
|
84
|
+
* Namespace context) unless set explicitly.
|
|
85
|
+
*
|
|
86
|
+
* Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
|
|
87
|
+
* the MODEL_API_KEY is provisioned for you. Without a backend you must
|
|
88
|
+
* point `secretsName` at a pre-created Secret.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* import { Platform } from '@r8s/recipes'
|
|
92
|
+
* import { Paperclip } from '@r8s/paperclip'
|
|
93
|
+
*
|
|
94
|
+
* export default (
|
|
95
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
96
|
+
* <Paperclip
|
|
97
|
+
* name="paperclip"
|
|
98
|
+
* host="paperclip.example.com"
|
|
99
|
+
* agents={{ sandboxReplicas: 3 }}
|
|
100
|
+
* />
|
|
101
|
+
* </Platform>
|
|
102
|
+
* )
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* // Existing Secret holding the model API key (key: modelApiKey)
|
|
106
|
+
* import { Paperclip } from '@r8s/paperclip'
|
|
107
|
+
*
|
|
108
|
+
* export default (
|
|
109
|
+
* <Paperclip name="paperclip" host="paperclip.example.com" secretsName="paperclip-secrets" />
|
|
110
|
+
* )
|
|
111
|
+
*/
|
|
112
|
+
export function Paperclip(props: PaperclipProps) {
|
|
113
|
+
const {
|
|
114
|
+
name = 'paperclip',
|
|
115
|
+
namespace: namespaceProp,
|
|
116
|
+
version = 'latest',
|
|
117
|
+
host,
|
|
118
|
+
replicas = 2,
|
|
119
|
+
dbStorage = '10Gi',
|
|
120
|
+
websockets = false,
|
|
121
|
+
agents,
|
|
122
|
+
secretsName,
|
|
123
|
+
resources = {
|
|
124
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
125
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
126
|
+
},
|
|
127
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
128
|
+
} = props
|
|
129
|
+
|
|
130
|
+
// Inherit namespace from <Platform> context if not explicitly set
|
|
131
|
+
const contextNamespace = useContext(Namespace)
|
|
132
|
+
const namespace =
|
|
133
|
+
namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
|
|
134
|
+
|
|
135
|
+
const secretProvider = useContext(SecretContext)
|
|
136
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
137
|
+
|
|
138
|
+
const image = `ghcr.io/berget-ai/paperclip:${version}`
|
|
139
|
+
const appSecretsName = secretsName ?? `${name}-secrets`
|
|
140
|
+
const sandboxReplicas = agents?.sandboxReplicas ?? 2
|
|
141
|
+
// Sandbox workers run untrusted code — never let them float without
|
|
142
|
+
// explicit container resources.
|
|
143
|
+
const sandboxResources = agents?.resources ?? {
|
|
144
|
+
requests: { cpu: '250m', memory: '256Mi' },
|
|
145
|
+
limits: { cpu: '1000m', memory: '2Gi' },
|
|
146
|
+
}
|
|
147
|
+
// The internal image's probe contract is unknown — probe the TCP port
|
|
148
|
+
// so pods don't crash-loop on missing HTTP /health /ready endpoints.
|
|
149
|
+
const tcpProbes = {
|
|
150
|
+
liveness: { tcp: true } as const,
|
|
151
|
+
readiness: { tcp: true } as const,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Secret provisioning -------------------------------------------------
|
|
155
|
+
// The model API key lets Paperclip call LLM providers — never render
|
|
156
|
+
// it as plaintext. With a secrets backend it is provisioned through the
|
|
157
|
+
// backend (key `modelApiKey`); otherwise reference a pre-created Secret.
|
|
158
|
+
if (!secretsName) {
|
|
159
|
+
if (
|
|
160
|
+
!secretProvider ||
|
|
161
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
162
|
+
) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Paperclip "${name}" requires a model API key (MODEL_API_KEY).\n` +
|
|
165
|
+
`\n` +
|
|
166
|
+
`Paperclip calls LLM providers with a model API key — ` +
|
|
167
|
+
`it must not be rendered as plaintext.\n` +
|
|
168
|
+
`\n` +
|
|
169
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
170
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
171
|
+
` <Paperclip name="${name}" host="${host}" />\n` +
|
|
172
|
+
` </Platform>\n` +
|
|
173
|
+
`\n` +
|
|
174
|
+
`Or reference a pre-created Secret (key: modelApiKey):\n` +
|
|
175
|
+
` <Paperclip name="${name}" host="${host}" secretsName="${name}-secrets" />`
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const spec = {
|
|
180
|
+
...(secretProvider.backend === 'vault'
|
|
181
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
182
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
183
|
+
mount: secretProvider.mount,
|
|
184
|
+
type: 'kv-v2' as const,
|
|
185
|
+
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
186
|
+
destination: { create: true, name: appSecretsName },
|
|
187
|
+
}
|
|
188
|
+
resources_.push(
|
|
189
|
+
secretProvider.backend === 'vault'
|
|
190
|
+
? jsx('VaultStaticSecret', {
|
|
191
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
192
|
+
kind: 'VaultStaticSecret',
|
|
193
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
194
|
+
spec,
|
|
195
|
+
})
|
|
196
|
+
: jsx('OpenBaoStaticSecret', {
|
|
197
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
198
|
+
kind: 'OpenBaoStaticSecret',
|
|
199
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
200
|
+
spec,
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Env wiring ------------------------------------------------------------
|
|
206
|
+
// Every credential is referenced via secretKeyRef (declared before plain
|
|
207
|
+
// env vars by WebService, so dependent $(VAR) expansion resolves).
|
|
208
|
+
// DATABASE_URL is auto-wired from DatabaseContext inside the wrapper.
|
|
209
|
+
const appEnv: Record<string, string> = {
|
|
210
|
+
PORT: '3000',
|
|
211
|
+
...(websockets ? { WEBSOCKETS_ENABLED: 'true' } : {}),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const appSecrets: Record<string, SecretRef> = {
|
|
215
|
+
MODEL_API_KEY: { secret: appSecretsName, key: 'modelApiKey' },
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- Database (CNPG) + app + sandbox agents --------------------------------
|
|
219
|
+
// Database wraps both the main app and the sandbox workers so database
|
|
220
|
+
// credentials stay consistent with the r8s Database recipe (CNPG dedicated
|
|
221
|
+
// cluster provisions the secret).
|
|
222
|
+
resources_.push(
|
|
223
|
+
jsx(Database, {
|
|
224
|
+
name,
|
|
225
|
+
namespace,
|
|
226
|
+
storage: dbStorage,
|
|
227
|
+
children: (
|
|
228
|
+
<>
|
|
229
|
+
<WebService
|
|
230
|
+
name={name}
|
|
231
|
+
namespace={namespace}
|
|
232
|
+
image={image}
|
|
233
|
+
port={3000}
|
|
234
|
+
replicas={replicas}
|
|
235
|
+
probes={tcpProbes}
|
|
236
|
+
resources={resources}
|
|
237
|
+
env={appEnv}
|
|
238
|
+
secrets={appSecrets}
|
|
239
|
+
/>
|
|
240
|
+
{agents ? (
|
|
241
|
+
<WebService
|
|
242
|
+
name={`${name}-agent-sandbox`}
|
|
243
|
+
namespace={namespace}
|
|
244
|
+
image={image}
|
|
245
|
+
port={3000}
|
|
246
|
+
replicas={sandboxReplicas}
|
|
247
|
+
command={['paperclip', 'agent', '--sandbox']}
|
|
248
|
+
probes={tcpProbes}
|
|
249
|
+
secrets={appSecrets}
|
|
250
|
+
securityContext={{
|
|
251
|
+
runAsNonRoot: true,
|
|
252
|
+
allowPrivilegeEscalation: false,
|
|
253
|
+
seccompProfile: { type: 'RuntimeDefault' },
|
|
254
|
+
capabilities: { drop: ['ALL'] },
|
|
255
|
+
}}
|
|
256
|
+
podSecurityContext={{ seccompProfile: { type: 'RuntimeDefault' } }}
|
|
257
|
+
resources={sandboxResources}
|
|
258
|
+
/>
|
|
259
|
+
) : null}
|
|
260
|
+
</>
|
|
261
|
+
),
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// --- Endpoint ---------------------------------------------------------------
|
|
266
|
+
resources_.push(
|
|
267
|
+
<Endpoint
|
|
268
|
+
name={`${name}-endpoint`}
|
|
269
|
+
namespace={namespace}
|
|
270
|
+
host={host}
|
|
271
|
+
serviceName={name}
|
|
272
|
+
servicePort={3000}
|
|
273
|
+
tls={tls}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return jsx(Fragment, { children: resources_ })
|
|
278
|
+
}
|
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
|
+
}
|