@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.
@@ -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
+ })
@@ -0,0 +1,8 @@
1
+ import { Platform } from '@r8s/recipes'
2
+ import { N8n } from '@r8s/n8n'
3
+
4
+ export default (
5
+ <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
6
+ <N8n name="n8n" host="n8n.example.com" queueMode workers={3} />
7
+ </Platform>
8
+ )
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
+ }