@r8s/n8n 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__/n8n.test.ts +267 -0
- package/examples/basic.tsx +8 -0
- package/package.json +41 -0
- package/src/index.tsx +284 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, jsx } from '@r8s/core'
|
|
3
|
+
import { OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
|
|
4
|
+
import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
|
|
5
|
+
import { operators } from '@r8s/crds'
|
|
6
|
+
import type { r8sElement } from '@r8s/core'
|
|
7
|
+
|
|
8
|
+
// N8n recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, all props, gateway/ingress adaptation
|
|
11
|
+
// 3. Security: no plaintext credentials in rendered output
|
|
12
|
+
import { N8n } from '../src/index'
|
|
13
|
+
|
|
14
|
+
/** Render N8n inside a Platform-like secrets backend (OpenBao). */
|
|
15
|
+
function renderN8n(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
16
|
+
return render(
|
|
17
|
+
jsx(SecretContext.Provider, {
|
|
18
|
+
value: { backend: 'openbao', mount: 'kv', path: 'test' },
|
|
19
|
+
children: jsx(N8n, props as never),
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Render N8n wrapped only in an OperatorContext (no secrets backend). */
|
|
25
|
+
function renderN8nWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
|
|
26
|
+
return jsx(OperatorContext.Provider, {
|
|
27
|
+
value: operators_,
|
|
28
|
+
children: jsx(N8n, props as never),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
33
|
+
|
|
34
|
+
describe('operator declarations', () => {
|
|
35
|
+
it('declares the redis operator when queue mode is enabled', () => {
|
|
36
|
+
const result = renderN8n({
|
|
37
|
+
host: 'n8n.example.com',
|
|
38
|
+
queueMode: true,
|
|
39
|
+
encryptionKeySecretName: 'enc',
|
|
40
|
+
})
|
|
41
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not declare the redis operator without queue mode', () => {
|
|
45
|
+
const result = renderN8n({ host: 'n8n.example.com' })
|
|
46
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('declares the cnpg operator via the Database recipe', () => {
|
|
50
|
+
const result = renderN8n({ host: 'n8n.example.com' })
|
|
51
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('deduplicates operators provided via context', () => {
|
|
55
|
+
const result = render(
|
|
56
|
+
renderN8nWithContext([operators['redis-operator'](), operators['cnpg']()], {
|
|
57
|
+
host: 'n8n.example.com',
|
|
58
|
+
encryptionKeySecretName: 'existing-encryption',
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
const names = result.operators.map((op) => op.name)
|
|
62
|
+
expect(names.filter((n) => n === 'redis-operator')).toHaveLength(1)
|
|
63
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('allows version overrides through context operators', () => {
|
|
67
|
+
const result = render(
|
|
68
|
+
renderN8nWithContext([operators['redis-operator']('1.0.0')], {
|
|
69
|
+
host: 'n8n.example.com',
|
|
70
|
+
queueMode: true,
|
|
71
|
+
encryptionKeySecretName: 'existing-encryption',
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
const redis = result.operators.filter((op) => op.name === 'redis-operator')
|
|
75
|
+
expect(redis).toHaveLength(1)
|
|
76
|
+
expect(redis[0].version).toBe('1.0.0')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('rendering defaults', () => {
|
|
81
|
+
it('renders editor deployment, service, database and endpoint', () => {
|
|
82
|
+
const result = renderN8n({ host: 'n8n.example.com' })
|
|
83
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
84
|
+
expect(kinds).toContain('Deployment')
|
|
85
|
+
expect(kinds).toContain('Service')
|
|
86
|
+
expect(kinds).toContain('Ingress')
|
|
87
|
+
expect(kinds).toContain('Cluster')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders queue mode with Redis and workers', () => {
|
|
91
|
+
const result = renderN8n({ host: 'n8n.example.com', queueMode: true, workers: 3 })
|
|
92
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
93
|
+
expect(kinds).toContain('RedisReplication')
|
|
94
|
+
const deployments = result.resources.filter((r) => r.kind === 'Deployment')
|
|
95
|
+
expect(deployments.map((d: any) => d.metadata.name)).toContain('n8n-worker')
|
|
96
|
+
expect(deployments.map((d: any) => d.metadata.name)).toContain('n8n')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('wires the worker to the RedisReplication service and health checks', () => {
|
|
100
|
+
const result = renderN8n({ host: 'n8n.example.com', queueMode: true })
|
|
101
|
+
const worker = result.resources.find(
|
|
102
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'n8n-worker'
|
|
103
|
+
) as any
|
|
104
|
+
const env = worker.spec.template.spec.containers[0].env
|
|
105
|
+
expect(env.find((e: any) => e.name === 'QUEUE_BULL_REDIS_HOST').value).toBe('n8n-redis')
|
|
106
|
+
expect(env.find((e: any) => e.name === 'QUEUE_HEALTH_CHECK_ACTIVE').value).toBe('true')
|
|
107
|
+
const probes = worker.spec.template.spec.containers[0]
|
|
108
|
+
expect(probes.livenessProbe.httpGet.path).toBe('/healthz')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('throws on multiple editor replicas without queue mode', () => {
|
|
112
|
+
expect(() => renderN8n({ host: 'n8n.example.com', replicas: 3 })).toThrow(/queue mode/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('propagates the storage prop to the CNPG cluster', () => {
|
|
116
|
+
const result = renderN8n({ host: 'n8n.example.com', storage: '30Gi' })
|
|
117
|
+
const cluster = result.resources.find((r: any) => r.kind === 'Cluster') as any
|
|
118
|
+
expect(cluster.spec.storage.size).toBe('30Gi')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
122
|
+
const result = render(
|
|
123
|
+
jsx(RoutingContext.Provider, {
|
|
124
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
125
|
+
children: jsx(SecretContext.Provider, {
|
|
126
|
+
value: openbao as never,
|
|
127
|
+
children: jsx(N8n, { host: 'n8n.example.com' }),
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
132
|
+
expect(kinds).toContain('HTTPRoute')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
136
|
+
const result = render(
|
|
137
|
+
jsx(RoutingContext.Provider, {
|
|
138
|
+
value: { mode: 'ingress' },
|
|
139
|
+
children: jsx(SecretContext.Provider, {
|
|
140
|
+
value: openbao as never,
|
|
141
|
+
children: jsx(N8n, { host: 'n8n.example.com' }),
|
|
142
|
+
}),
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
146
|
+
expect(ingress).toBeDefined()
|
|
147
|
+
expect(ingress.spec.rules[0].host).toBe('n8n.example.com')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('passes resource validation', () => {
|
|
151
|
+
const result = renderN8n({ host: 'n8n.example.com', queueMode: true })
|
|
152
|
+
for (const resource of result.resources) {
|
|
153
|
+
expect(validateResource(resource)).toEqual([])
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('rendering with all props', () => {
|
|
159
|
+
it('accepts the full prop surface', () => {
|
|
160
|
+
const result = renderN8n({
|
|
161
|
+
name: 'automation',
|
|
162
|
+
namespace: 'automation',
|
|
163
|
+
version: '1.60.0',
|
|
164
|
+
host: 'automation.example.com',
|
|
165
|
+
replicas: 2,
|
|
166
|
+
queueMode: true,
|
|
167
|
+
workers: 4,
|
|
168
|
+
resources: {
|
|
169
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
170
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
171
|
+
},
|
|
172
|
+
tls: { secretName: 'automation-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
173
|
+
})
|
|
174
|
+
expect(result.resources.length).toBeGreaterThan(0)
|
|
175
|
+
const editor = result.resources.find(
|
|
176
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'automation'
|
|
177
|
+
) as any
|
|
178
|
+
expect(editor.spec.template.spec.containers[0].image).toContain('1.60.0')
|
|
179
|
+
expect(editor.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('secrets handling', () => {
|
|
184
|
+
it('accepts an explicit encryptionKeySecretName without a backend', () => {
|
|
185
|
+
expect(() =>
|
|
186
|
+
render(jsx(N8n, { host: 'n8n.example.com', encryptionKeySecretName: 'existing-encryption' }))
|
|
187
|
+
).not.toThrow()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('provisions the encryption key through a secrets backend', () => {
|
|
191
|
+
const result = renderN8n({ host: 'n8n.example.com' })
|
|
192
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
193
|
+
expect(kinds).toContain('OpenBaoStaticSecret')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('provisions the encryption key through Vault', () => {
|
|
197
|
+
const result = render(
|
|
198
|
+
jsx(SecretContext.Provider, {
|
|
199
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
200
|
+
children: jsx(N8n, { host: 'n8n.example.com' }),
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
204
|
+
expect(kinds).toContain('VaultStaticSecret')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
208
|
+
const result = renderN8n({
|
|
209
|
+
host: 'n8n.example.com',
|
|
210
|
+
encryptionKeySecretName: 'existing-encryption',
|
|
211
|
+
})
|
|
212
|
+
const editor = result.resources.find(
|
|
213
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'n8n'
|
|
214
|
+
) as any
|
|
215
|
+
const env = editor.spec.template.spec.containers[0].env
|
|
216
|
+
const encryption = env.find((e: any) => e.name === 'N8N_ENCRYPTION_KEY')
|
|
217
|
+
const dbPassword = env.find((e: any) => e.name === 'DB_POSTGRESDB_PASSWORD')
|
|
218
|
+
expect(encryption.valueFrom.secretKeyRef.name).toBe('existing-encryption')
|
|
219
|
+
expect(dbPassword.valueFrom.secretKeyRef.name).toBe('n8n-db-credentials')
|
|
220
|
+
expect(encryption.value).toBeUndefined()
|
|
221
|
+
expect(dbPassword.value).toBeUndefined()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
225
|
+
const result = renderN8n({ host: 'n8n.example.com', queueMode: true })
|
|
226
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
227
|
+
if (!passed) {
|
|
228
|
+
console.error('Plaintext credential violations:', errors)
|
|
229
|
+
}
|
|
230
|
+
expect(passed).toBe(true)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('validation errors', () => {
|
|
235
|
+
it('throws when no secrets backend and no encryption key secret', () => {
|
|
236
|
+
expect(() => render(jsx(N8n, { host: 'n8n.example.com' }))).toThrow(/encryption key/)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('throws for unknown secrets backends', () => {
|
|
240
|
+
expect(() =>
|
|
241
|
+
render(
|
|
242
|
+
jsx(SecretContext.Provider, {
|
|
243
|
+
value: { backend: 'unknown' as never },
|
|
244
|
+
children: jsx(N8n, { host: 'n8n.example.com' }),
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
).toThrow(/encryption key/)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('secretKeyRef wiring in queue mode', () => {
|
|
252
|
+
it('worker deployment uses the same credentials secret', () => {
|
|
253
|
+
const result = renderN8n({
|
|
254
|
+
host: 'n8n.example.com',
|
|
255
|
+
queueMode: true,
|
|
256
|
+
encryptionKeySecretName: 'existing-encryption',
|
|
257
|
+
})
|
|
258
|
+
const worker = result.resources.find(
|
|
259
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'n8n-worker'
|
|
260
|
+
) as any
|
|
261
|
+
expect(worker).toBeDefined()
|
|
262
|
+
const env = worker.spec.template.spec.containers[0].env
|
|
263
|
+
const dbPassword = env.find((e: any) => e.name === 'DB_POSTGRESDB_PASSWORD')
|
|
264
|
+
expect(dbPassword.valueFrom.secretKeyRef.name).toBe('n8n-db-credentials')
|
|
265
|
+
expect(dbPassword.value).toBeUndefined()
|
|
266
|
+
})
|
|
267
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/n8n",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "n8n workflow automation — editor, Postgres persistence, Redis queue mode, webhook endpoints",
|
|
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/n8n"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"n8n",
|
|
16
|
+
"automation",
|
|
17
|
+
"workflows",
|
|
18
|
+
"integrations"
|
|
19
|
+
],
|
|
20
|
+
"r8s": {
|
|
21
|
+
"category": "Automation"
|
|
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": "Apps & Automation",
|
|
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,284 @@
|
|
|
1
|
+
import { jsx, Fragment, useContext, declareOperator } from '@r8s/core'
|
|
2
|
+
import { Namespace, OperatorContext, SecretContext } from '@r8s/core/defaults'
|
|
3
|
+
import { operators } from '@r8s/crds'
|
|
4
|
+
import { Database, WebService, Endpoint } from '@r8s/recipes'
|
|
5
|
+
import { RedisReplicationComponent } from '@r8s/crds/redis'
|
|
6
|
+
|
|
7
|
+
export interface N8nProps {
|
|
8
|
+
/** Resource name (defaults to 'n8n') */
|
|
9
|
+
name?: string
|
|
10
|
+
/** Kubernetes namespace (inherited from Platform context when omitted) */
|
|
11
|
+
namespace?: string
|
|
12
|
+
/** Container image tag (defaults to 'latest' — pin a version in production) */
|
|
13
|
+
version?: string
|
|
14
|
+
/** Public hostname for the editor and webhooks (required) */
|
|
15
|
+
host: string
|
|
16
|
+
/** Number of editor replicas when not running in queue mode (defaults to 1) */
|
|
17
|
+
replicas?: number
|
|
18
|
+
/**
|
|
19
|
+
* Redis-backed queue mode. Adds a Redis master/replica set and a worker
|
|
20
|
+
* Deployment so webhook ingestion and heavy executions scale independently.
|
|
21
|
+
*/
|
|
22
|
+
queueMode?: boolean
|
|
23
|
+
/** Queue worker replicas (defaults to 2, only used with queueMode) */
|
|
24
|
+
workers?: number
|
|
25
|
+
/** Storage request for the CNPG Postgres cluster (defaults to '10Gi') */
|
|
26
|
+
storage?: string
|
|
27
|
+
/**
|
|
28
|
+
* Name of an existing Secret containing key `encryptionKey`. n8n
|
|
29
|
+
* encrypts all workflow credentials with this key — lose it and every
|
|
30
|
+
* stored credential is unreadable.
|
|
31
|
+
*
|
|
32
|
+
* Required unless a secrets backend (openbao/vault) is configured on
|
|
33
|
+
* the surrounding Platform — the backend then provisions the key
|
|
34
|
+
* automatically. Plaintext keys are not supported.
|
|
35
|
+
*/
|
|
36
|
+
encryptionKeySecretName?: string
|
|
37
|
+
/** Requested editor resources */
|
|
38
|
+
resources?: {
|
|
39
|
+
requests?: { cpu?: string; memory?: string }
|
|
40
|
+
limits?: { cpu?: string; memory?: string }
|
|
41
|
+
}
|
|
42
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
43
|
+
tls?: {
|
|
44
|
+
secretName: string
|
|
45
|
+
clusterIssuer: string
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const OPERATOR_REDIS = 'redis-operator'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* n8n — fair-code workflow automation.
|
|
53
|
+
*
|
|
54
|
+
* @title N8n
|
|
55
|
+
* @category Automation
|
|
56
|
+
*
|
|
57
|
+
* Composes:
|
|
58
|
+
* - CNPG Postgres cluster (workflows, executions, stored credentials)
|
|
59
|
+
* - n8n editor Deployment + Service + Endpoint (webhooks share the host)
|
|
60
|
+
* - Redis cluster + worker Deployment in queue mode
|
|
61
|
+
* - Encryption key provisioned through the Platform secrets backend
|
|
62
|
+
* (openbao / vault), or referenced from an existing Secret
|
|
63
|
+
*
|
|
64
|
+
* Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
|
|
65
|
+
* the N8N_ENCRYPTION_KEY is provisioned for you. Without a backend you
|
|
66
|
+
* must point `encryptionKeySecretName` at a pre-created Secret.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* import { Platform } from '@r8s/recipes'
|
|
70
|
+
* import { N8n } from '@r8s/n8n'
|
|
71
|
+
*
|
|
72
|
+
* export default (
|
|
73
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
74
|
+
* <N8n name="n8n" host="n8n.example.com" queueMode workers={3} />
|
|
75
|
+
* </Platform>
|
|
76
|
+
* )
|
|
77
|
+
*/
|
|
78
|
+
export function N8n(props: N8nProps) {
|
|
79
|
+
const {
|
|
80
|
+
name = 'n8n',
|
|
81
|
+
namespace: namespaceProp,
|
|
82
|
+
version = 'latest',
|
|
83
|
+
host,
|
|
84
|
+
replicas = 1,
|
|
85
|
+
queueMode = false,
|
|
86
|
+
workers = 2,
|
|
87
|
+
storage = '10Gi',
|
|
88
|
+
encryptionKeySecretName,
|
|
89
|
+
resources = {
|
|
90
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
91
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
92
|
+
},
|
|
93
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
94
|
+
} = props
|
|
95
|
+
|
|
96
|
+
const sharedOperators = useContext(OperatorContext)
|
|
97
|
+
const secretProvider = useContext(SecretContext)
|
|
98
|
+
// Inherit namespace from <Platform> context — mirrors recipes/database.tsx
|
|
99
|
+
const contextNamespace = useContext(Namespace)
|
|
100
|
+
const namespace =
|
|
101
|
+
namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
|
|
102
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
103
|
+
|
|
104
|
+
const dbCredentialsName = `${name}-db-credentials`
|
|
105
|
+
const encryptionSecretName = encryptionKeySecretName ?? `${name}-encryption-key`
|
|
106
|
+
|
|
107
|
+
// --- Secret provisioning -------------------------------------------------
|
|
108
|
+
// The encryption key is the crown jewel of an n8n install — never render
|
|
109
|
+
// it as plaintext. With a secrets backend it is provisioned through the
|
|
110
|
+
// backend (key `encryptionKey`); otherwise reference a pre-created Secret.
|
|
111
|
+
if (!encryptionKeySecretName) {
|
|
112
|
+
if (
|
|
113
|
+
!secretProvider ||
|
|
114
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
115
|
+
) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`N8n "${name}" requires an encryption key.\n` +
|
|
118
|
+
`\n` +
|
|
119
|
+
`n8n encrypts all workflow credentials with N8N_ENCRYPTION_KEY — ` +
|
|
120
|
+
`it must not be rendered as plaintext.\n` +
|
|
121
|
+
`\n` +
|
|
122
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
123
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'n8n' }}>\n` +
|
|
124
|
+
` <N8n name="${name}" host="${host}" />\n` +
|
|
125
|
+
` </Platform>\n` +
|
|
126
|
+
`\n` +
|
|
127
|
+
`Or reference a pre-created Secret (key: encryptionKey):\n` +
|
|
128
|
+
` <N8n name="${name}" host="${host}" encryptionKeySecretName="${name}-encryption-key" />`
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const spec = {
|
|
133
|
+
...(secretProvider.backend === 'vault'
|
|
134
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
135
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
136
|
+
mount: secretProvider.mount,
|
|
137
|
+
type: 'kv-v2' as const,
|
|
138
|
+
path: `${secretProvider.path ?? name}/${name}/encryption`,
|
|
139
|
+
destination: { create: true, name: encryptionSecretName },
|
|
140
|
+
}
|
|
141
|
+
resources_.push(
|
|
142
|
+
secretProvider.backend === 'vault'
|
|
143
|
+
? jsx('VaultStaticSecret', {
|
|
144
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
145
|
+
kind: 'VaultStaticSecret',
|
|
146
|
+
metadata: { name: `${name}-encryption`, namespace },
|
|
147
|
+
spec,
|
|
148
|
+
})
|
|
149
|
+
: jsx('OpenBaoStaticSecret', {
|
|
150
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
151
|
+
kind: 'OpenBaoStaticSecret',
|
|
152
|
+
metadata: { name: `${name}-encryption`, namespace },
|
|
153
|
+
spec,
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- Operators ------------------------------------------------------------
|
|
159
|
+
if (queueMode && !sharedOperators.some((op) => op.name === OPERATOR_REDIS)) {
|
|
160
|
+
resources_.push(declareOperator(operators[OPERATOR_REDIS]()))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Database (CNPG) — workflows, executions, stored credentials ----------
|
|
164
|
+
const dbHost = `${name}-rw`
|
|
165
|
+
|
|
166
|
+
const sharedEnv = {
|
|
167
|
+
DB_TYPE: 'postgresdb',
|
|
168
|
+
DB_POSTGRESDB_HOST: dbHost,
|
|
169
|
+
DB_POSTGRESDB_PORT: '5432',
|
|
170
|
+
DB_POSTGRESDB_DATABASE: name,
|
|
171
|
+
DB_POSTGRESDB_USER: name,
|
|
172
|
+
N8N_HOST: host,
|
|
173
|
+
N8N_PORT: '5678',
|
|
174
|
+
N8N_PROTOCOL: 'https',
|
|
175
|
+
WEBHOOK_URL: `https://${host}/`,
|
|
176
|
+
NODE_FUNCTION_ALLOW_BUILTIN: '*',
|
|
177
|
+
// Binary data must live in Postgres in queue mode — the filesystem
|
|
178
|
+
// backend is not shared across editors/workers and is unsupported.
|
|
179
|
+
N8N_DEFAULT_BINARY_DATA_MODE: 'default',
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sharedSecrets = {
|
|
183
|
+
DB_POSTGRESDB_PASSWORD: { secret: dbCredentialsName, key: 'password' },
|
|
184
|
+
N8N_ENCRYPTION_KEY: { secret: encryptionSecretName, key: 'encryptionKey' },
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (queueMode) {
|
|
188
|
+
Object.assign(sharedEnv, {
|
|
189
|
+
EXECUTIONS_MODE: 'queue',
|
|
190
|
+
QUEUE_BULL_REDIS_HOST: `${name}-redis`,
|
|
191
|
+
QUEUE_BULL_REDIS_PORT: '6379',
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!queueMode && replicas > 1) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`N8n "${name}" requested replicas={${replicas}} without queue mode.\n` +
|
|
198
|
+
`\n` +
|
|
199
|
+
`Multiple main instances without Redis queue mode run every trigger ` +
|
|
200
|
+
`execution once per replica (duplicated webhooks and schedules).\n` +
|
|
201
|
+
`\n` +
|
|
202
|
+
`Fix: enable queue mode:\n` +
|
|
203
|
+
` <N8n name="${name}" host="${host}" queueMode workers={${replicas}} />\n` +
|
|
204
|
+
`\n` +
|
|
205
|
+
`Or keep a single main instance (workers handle executions in queue mode).`
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Database wraps the editor so credentials stay consistent with the
|
|
210
|
+
// r8s Database recipe (CNPG dedicated cluster provisions the secret).
|
|
211
|
+
resources_.push(
|
|
212
|
+
jsx(Database, {
|
|
213
|
+
name,
|
|
214
|
+
namespace,
|
|
215
|
+
storage,
|
|
216
|
+
children: (
|
|
217
|
+
<WebService
|
|
218
|
+
name={name}
|
|
219
|
+
namespace={namespace}
|
|
220
|
+
image={`docker.n8n.io/n8nio/n8n:${version}`}
|
|
221
|
+
port={5678}
|
|
222
|
+
replicas={queueMode ? 1 : replicas}
|
|
223
|
+
resources={resources}
|
|
224
|
+
probes={{
|
|
225
|
+
liveness: { path: '/healthz' },
|
|
226
|
+
readiness: { path: '/healthz', initialDelaySeconds: 15 },
|
|
227
|
+
}}
|
|
228
|
+
env={{ ...sharedEnv, ...(queueMode ? {} : { N8N_CONCURRENCY_PRODUCTION_LIMIT: '10' }) }}
|
|
229
|
+
secrets={sharedSecrets}
|
|
230
|
+
/>
|
|
231
|
+
),
|
|
232
|
+
})
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// --- Queue mode: Redis + workers ------------------------------------------
|
|
236
|
+
if (queueMode) {
|
|
237
|
+
// Bull (n8n's queue backend) speaks plain Redis protocol — use a
|
|
238
|
+
// master/replica topology, not a Redis cluster (no MOVED-safe client)
|
|
239
|
+
resources_.push(
|
|
240
|
+
RedisReplicationComponent({
|
|
241
|
+
metadata: { name: `${name}-redis`, namespace },
|
|
242
|
+
spec: {
|
|
243
|
+
clusterSize: 3,
|
|
244
|
+
kubernetesConfig: { image: 'redis:7.2-alpine' },
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
resources_.push(
|
|
250
|
+
<WebService
|
|
251
|
+
name={`${name}-worker`}
|
|
252
|
+
namespace={namespace}
|
|
253
|
+
image={`docker.n8n.io/n8nio/n8n:${version}`}
|
|
254
|
+
port={5678}
|
|
255
|
+
replicas={workers}
|
|
256
|
+
command={['n8n', 'worker', '--concurrency=10']}
|
|
257
|
+
resources={{
|
|
258
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
259
|
+
limits: { memory: '2Gi', cpu: '2000m' },
|
|
260
|
+
}}
|
|
261
|
+
probes={{
|
|
262
|
+
liveness: { path: '/healthz' },
|
|
263
|
+
readiness: { path: '/healthz', initialDelaySeconds: 20 },
|
|
264
|
+
}}
|
|
265
|
+
env={{ ...sharedEnv, QUEUE_HEALTH_CHECK_ACTIVE: 'true' }}
|
|
266
|
+
secrets={sharedSecrets}
|
|
267
|
+
/>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- Endpoint — webhooks share the editor host -----------------------------
|
|
272
|
+
resources_.push(
|
|
273
|
+
<Endpoint
|
|
274
|
+
name={`${name}-endpoint`}
|
|
275
|
+
namespace={namespace}
|
|
276
|
+
host={host}
|
|
277
|
+
serviceName={name}
|
|
278
|
+
servicePort={5678}
|
|
279
|
+
tls={tls}
|
|
280
|
+
/>
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return jsx(Fragment, { children: resources_ })
|
|
284
|
+
}
|
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
|
+
}
|