@r8s/n8n 0.2.0 → 0.2.1

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,129 @@
1
+ import { type DatabaseProps } from '@r8s/recipes';
2
+ export interface N8nProps {
3
+ /** Resource name (defaults to 'n8n') */
4
+ name?: string;
5
+ /** Kubernetes namespace (inherited from Platform context when omitted) */
6
+ namespace?: string;
7
+ /**
8
+ * Container image tag (defaults to a pinned release — see
9
+ * https://github.com/n8n-io/n8n/releases when upgrading). Use 'latest'
10
+ * only for throwaway environments.
11
+ */
12
+ version?: string;
13
+ /** Public hostname for the editor and webhooks (required) */
14
+ host: string;
15
+ /** Number of editor replicas when not running in queue mode (defaults to 1) */
16
+ replicas?: number;
17
+ /**
18
+ * Redis-backed queue mode. Adds a Redis master/replica set and a worker
19
+ * Deployment so webhook ingestion and heavy executions scale independently.
20
+ */
21
+ queueMode?: boolean;
22
+ /** Queue worker replicas (defaults to 2, only used with queueMode) */
23
+ workers?: number;
24
+ /** Storage request for the CNPG Postgres cluster (defaults to '10Gi') */
25
+ storage?: string;
26
+ /**
27
+ * Persistent volume for `/home/node/.n8n` (workflow-local state).
28
+ * Pass a size string ('5Gi', the facit default) or an object for more
29
+ * control. When set: a RWO PVC is created, an initContainer fixes
30
+ * ownership (1000:1000), the rollout strategy becomes Recreate, and
31
+ * binary data is stored on the filesystem instead of Postgres.
32
+ */
33
+ dataStorage?: string | {
34
+ size?: string;
35
+ storageClass?: string;
36
+ };
37
+ /**
38
+ * CNPG backup configuration passed through to the Database recipe
39
+ * (continuous WAL archiving + scheduled base backups).
40
+ */
41
+ backup?: DatabaseProps['backup'];
42
+ /**
43
+ * Number of trusted proxy hops for client IP / rate limiting
44
+ * (N8N_PROXY_HOPS). Defaults to 1 — trust the ingress one hop.
45
+ */
46
+ proxyHops?: number;
47
+ /**
48
+ * Execution-data pruning (EXECUTIONS_DATA_PRUNE /
49
+ * EXECUTIONS_DATA_MAX_AGE). Defaults to pruning data older than
50
+ * 168 h (7 days). Set to false to disable.
51
+ */
52
+ pruning?: {
53
+ maxAgeHours?: number;
54
+ } | false;
55
+ /**
56
+ * Opt-in value for NODE_FUNCTION_ALLOW_BUILTIN (e.g. '*'). Deliberately
57
+ * unset by default: granting workflows access to every builtin Node.js
58
+ * module is a security-sensitive choice.
59
+ */
60
+ allowBuiltinNodeFunctions?: string;
61
+ /**
62
+ * Tune the backend-provisioned encryption secret: vault path, key name,
63
+ * and refresh/restart behaviour (default: refreshAfter '1h', restarts
64
+ * the editor + workers on rotation).
65
+ */
66
+ encryptionSecret?: {
67
+ path?: string;
68
+ key?: string;
69
+ refreshAfter?: string;
70
+ rolloutRestartTargets?: string[];
71
+ };
72
+ /** Extra annotations merged onto the Endpoint's IngressRoute/Ingress */
73
+ endpointAnnotations?: Record<string, string>;
74
+ /**
75
+ * Name of an existing Secret containing key `encryptionKey`. n8n
76
+ * encrypts all workflow credentials with this key — lose it and every
77
+ * stored credential is unreadable.
78
+ *
79
+ * Required unless a secrets backend (openbao/vault) is configured on
80
+ * the surrounding Platform — the backend then provisions the key
81
+ * automatically. Plaintext keys are not supported.
82
+ */
83
+ encryptionKeySecretName?: string;
84
+ /** Requested editor resources */
85
+ resources?: {
86
+ requests?: {
87
+ cpu?: string;
88
+ memory?: string;
89
+ };
90
+ limits?: {
91
+ cpu?: string;
92
+ memory?: string;
93
+ };
94
+ };
95
+ /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
96
+ tls?: {
97
+ secretName: string;
98
+ clusterIssuer: string;
99
+ };
100
+ }
101
+ /**
102
+ * n8n — fair-code workflow automation.
103
+ *
104
+ * @title N8n
105
+ * @category Automation
106
+ *
107
+ * Composes:
108
+ * - CNPG Postgres cluster (workflows, executions, stored credentials)
109
+ * - n8n editor Deployment + Service + Endpoint (webhooks share the host)
110
+ * - Redis cluster + worker Deployment in queue mode
111
+ * - Encryption key provisioned through the Platform secrets backend
112
+ * (openbao / vault), or referenced from an existing Secret
113
+ *
114
+ * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
115
+ * the N8N_ENCRYPTION_KEY is provisioned for you. Without a backend you
116
+ * must point `encryptionKeySecretName` at a pre-created Secret.
117
+ *
118
+ * @example
119
+ * import { Platform } from '@r8s/recipes'
120
+ * import { N8n } from '@r8s/n8n'
121
+ *
122
+ * export default (
123
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
124
+ * <N8n name="n8n" host="n8n.example.com" queueMode workers={3} />
125
+ * </Platform>
126
+ * )
127
+ */
128
+ export declare function N8n(props: N8nProps): import("@r8s/core").r8sElement;
129
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EASL,KAAK,aAAa,EACnB,MAAM,cAAc,CAAA;AAGrB,MAAM,WAAW,QAAQ;IACvB,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAA;IACZ,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC/D;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAA;IAChC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IAC1C;;;;OAIG;IACH,yBAAyB,CAAC,EAAE,MAAM,CAAA;IAClC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE;QACjB,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAA;KACjC,CAAA;IACD,wEAAwE;IACxE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5C;;;;;;;;OAQG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAChC,iCAAiC;IACjC,SAAS,CAAC,EAAE;QACV,QAAQ,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;QAC5C,MAAM,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAC3C,CAAA;IACD,sEAAsE;IACtE,GAAG,CAAC,EAAE;QACJ,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;KACtB,CAAA;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,kCAkQlC"}
package/dist/index.js ADDED
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.N8n = N8n;
4
+ const jsx_runtime_1 = require("@r8s/core/jsx-runtime");
5
+ const core_1 = require("@r8s/core");
6
+ const defaults_1 = require("@r8s/core/defaults");
7
+ const recipes_1 = require("@r8s/recipes");
8
+ const redis_1 = require("@r8s/crds/redis");
9
+ /**
10
+ * n8n — fair-code workflow automation.
11
+ *
12
+ * @title N8n
13
+ * @category Automation
14
+ *
15
+ * Composes:
16
+ * - CNPG Postgres cluster (workflows, executions, stored credentials)
17
+ * - n8n editor Deployment + Service + Endpoint (webhooks share the host)
18
+ * - Redis cluster + worker Deployment in queue mode
19
+ * - Encryption key provisioned through the Platform secrets backend
20
+ * (openbao / vault), or referenced from an existing Secret
21
+ *
22
+ * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
23
+ * the N8N_ENCRYPTION_KEY is provisioned for you. Without a backend you
24
+ * must point `encryptionKeySecretName` at a pre-created Secret.
25
+ *
26
+ * @example
27
+ * import { Platform } from '@r8s/recipes'
28
+ * import { N8n } from '@r8s/n8n'
29
+ *
30
+ * export default (
31
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
32
+ * <N8n name="n8n" host="n8n.example.com" queueMode workers={3} />
33
+ * </Platform>
34
+ * )
35
+ */
36
+ function N8n(props) {
37
+ const { name = 'n8n', namespace: namespaceProp, version = '2.35.5', host, replicas = 1, queueMode = false, workers = 2, storage = '10Gi', dataStorage, backup, proxyHops = 1, pruning = { maxAgeHours: 168 }, allowBuiltinNodeFunctions, encryptionSecret, endpointAnnotations = {}, encryptionKeySecretName, resources = {
38
+ requests: { memory: '512Mi', cpu: '100m' },
39
+ limits: { memory: '1Gi', cpu: '1000m' },
40
+ }, tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' }, } = props;
41
+ const sharedOperators = (0, recipes_1.useOperators)();
42
+ const secretProvider = (0, core_1.useContext)(defaults_1.SecretContext);
43
+ const namespace = (0, defaults_1.useNamespace)(namespaceProp);
44
+ const resources_ = [];
45
+ const dbCredentialsName = `${name}-db-credentials`;
46
+ const encryptionSecretName = encryptionKeySecretName ?? `${name}-encryption-key`;
47
+ const encryptionKeyDestKey = encryptionSecret?.key ?? 'encryptionKey';
48
+ // --- Secret provisioning -------------------------------------------------
49
+ // The encryption key is the crown jewel of an n8n install — never render
50
+ // it as plaintext. With a secrets backend it is provisioned through the
51
+ // backend (key `encryptionKey`); otherwise reference a pre-created Secret.
52
+ if (!encryptionKeySecretName) {
53
+ if (!(0, recipes_1.canProvisionSecrets)(secretProvider)) {
54
+ throw (0, recipes_1.secretsRequiredError)('N8n', name, 'an encryption key — n8n encrypts all workflow credentials with N8N_ENCRYPTION_KEY', {
55
+ propName: 'encryptionKeySecretName',
56
+ exampleValue: `${name}-encryption-key`,
57
+ keys: ['encryptionKey'],
58
+ });
59
+ }
60
+ resources_.push((0, core_1.jsx)(recipes_1.StaticSecret, {
61
+ name: `${name}-encryption`,
62
+ namespace,
63
+ path: encryptionSecret?.path ?? `${secretProvider.path ?? name}/${name}/encryption`,
64
+ secretName: encryptionSecretName,
65
+ refreshAfter: encryptionSecret?.refreshAfter,
66
+ keys: { [encryptionKeyDestKey]: encryptionKeyDestKey },
67
+ // Rotate-in-place: restart pods on key changes so editors re-read
68
+ // the key (stale keys break credential decryption silently)
69
+ restart: encryptionSecret?.rolloutRestartTargets ?? [
70
+ { kind: 'Deployment', name },
71
+ ...(queueMode ? [{ kind: 'Deployment', name: `${name}-worker` }] : []),
72
+ ],
73
+ }));
74
+ }
75
+ // --- Operators ------------------------------------------------------------
76
+ if (queueMode) {
77
+ resources_.push(...(0, recipes_1.maybeOperator)('redis-operator', sharedOperators));
78
+ }
79
+ // --- Database (CNPG) — workflows, executions, stored credentials ----------
80
+ const dbHost = `${name}-rw`;
81
+ const dataVolumeName = `${name}-data`;
82
+ const dataVolumeDef = dataStorage
83
+ ? [{ name: dataVolumeName, persistentVolumeClaim: { claimName: dataVolumeName } }]
84
+ : [];
85
+ const dataInitContainer = {
86
+ name: 'fix-data-permissions',
87
+ image: 'busybox:1.36',
88
+ command: ['sh', '-c', 'chown -R 1000:1000 /data'],
89
+ volumeMounts: [{ name: dataVolumeName, mountPath: '/data' }],
90
+ };
91
+ if (dataStorage) {
92
+ const size = typeof dataStorage === 'string' ? dataStorage : (dataStorage.size ?? '5Gi');
93
+ const storageClass = typeof dataStorage === 'object' ? dataStorage.storageClass : undefined;
94
+ resources_.push((0, core_1.jsx)('PersistentVolumeClaim', {
95
+ apiVersion: 'v1',
96
+ kind: 'PersistentVolumeClaim',
97
+ metadata: { name: dataVolumeName, namespace },
98
+ spec: {
99
+ accessModes: ['ReadWriteOnce'],
100
+ ...(storageClass ? { storageClassName: storageClass } : {}),
101
+ resources: { requests: { storage: size } },
102
+ },
103
+ }));
104
+ }
105
+ const sharedEnv = {
106
+ DB_TYPE: 'postgresdb',
107
+ DB_POSTGRESDB_HOST: dbHost,
108
+ DB_POSTGRESDB_PORT: '5432',
109
+ DB_POSTGRESDB_DATABASE: name,
110
+ DB_POSTGRESDB_USER: name,
111
+ N8N_HOST: host,
112
+ N8N_PORT: '5678',
113
+ N8N_PROTOCOL: 'https',
114
+ WEBHOOK_URL: `https://${host}/`,
115
+ // Trust the ingress (Endpoint/TLS) for client IPs/rate limiting
116
+ N8N_PROXY_HOPS: String(proxyHops),
117
+ // Binary data in Postgres for queue mode (shared across editor+workers);
118
+ // filesystem when a data volume exists; Postgres otherwise (no volume
119
+ // means /home/node/.n8n is ephemeral)
120
+ N8N_DEFAULT_BINARY_DATA_MODE: queueMode ? 'default' : dataStorage ? 'filesystem' : 'default',
121
+ ...(pruning
122
+ ? {
123
+ EXECUTIONS_DATA_PRUNE: 'true',
124
+ EXECUTIONS_DATA_MAX_AGE: String(pruning.maxAgeHours ?? 168),
125
+ }
126
+ : {}),
127
+ ...(allowBuiltinNodeFunctions
128
+ ? { NODE_FUNCTION_ALLOW_BUILTIN: allowBuiltinNodeFunctions }
129
+ : {}),
130
+ };
131
+ const sharedSecrets = {
132
+ DB_POSTGRESDB_PASSWORD: { secret: dbCredentialsName, key: 'password' },
133
+ N8N_ENCRYPTION_KEY: { secret: encryptionSecretName, key: encryptionKeyDestKey },
134
+ };
135
+ if (queueMode) {
136
+ Object.assign(sharedEnv, {
137
+ EXECUTIONS_MODE: 'queue',
138
+ QUEUE_BULL_REDIS_HOST: `${name}-redis`,
139
+ QUEUE_BULL_REDIS_PORT: '6379',
140
+ });
141
+ }
142
+ if (!queueMode && replicas > 1) {
143
+ throw new Error(`N8n "${name}" requested replicas={${replicas}} without queue mode.\n` +
144
+ `\n` +
145
+ `Multiple main instances without Redis queue mode run every trigger ` +
146
+ `execution once per replica (duplicated webhooks and schedules).\n` +
147
+ `\n` +
148
+ `Fix: enable queue mode:\n` +
149
+ ` <N8n name="${name}" host="${host}" queueMode workers={${replicas}} />\n` +
150
+ `\n` +
151
+ `Or keep a single main instance (workers handle executions in queue mode).`);
152
+ }
153
+ // Database wraps the editor so credentials stay consistent with the
154
+ // r8s Database recipe (CNPG dedicated cluster provisions the secret).
155
+ resources_.push((0, core_1.jsx)(recipes_1.Database, {
156
+ name,
157
+ namespace,
158
+ storage,
159
+ ...(backup ? { backup } : {}),
160
+ children: ((0, jsx_runtime_1.jsx)(recipes_1.WebService, { name: name, namespace: namespace, image: `docker.n8n.io/n8nio/n8n:${version}`,
161
+ // Pinned tags are immutable — no need to re-pull on every start
162
+ imagePullPolicy: version === 'latest' ? 'Always' : 'IfNotPresent', port: 5678, replicas: queueMode ? 1 : replicas, resources: resources,
163
+ // RWO volume + in-place DB migrations on boot roll → Recreate
164
+ strategy: dataStorage ? 'Recreate' : undefined, volumes: dataVolumeDef.length > 0 ? dataVolumeDef : undefined, volumeMounts: dataVolumeDef.length > 0
165
+ ? [{ name: dataVolumeName, mountPath: '/home/node/.n8n' }]
166
+ : undefined, initContainers: dataVolumeDef.length > 0 ? [dataInitContainer] : undefined, probes: {
167
+ // n8n boots slowly after code upgrades (DB migrations); facit
168
+ // probe timing keeps the pod alive through them
169
+ liveness: { path: '/healthz', initialDelaySeconds: 60, periodSeconds: 30 },
170
+ readiness: { path: '/healthz', initialDelaySeconds: 15, failureThreshold: 6 },
171
+ }, env: { ...sharedEnv, ...(queueMode ? {} : { N8N_CONCURRENCY_PRODUCTION_LIMIT: '10' }) }, secrets: sharedSecrets })),
172
+ }));
173
+ // --- Queue mode: Redis + workers ------------------------------------------
174
+ if (queueMode) {
175
+ // Bull (n8n's queue backend) speaks plain Redis protocol — use a
176
+ // master/replica topology, not a Redis cluster (no MOVED-safe client)
177
+ resources_.push((0, redis_1.RedisReplicationComponent)({
178
+ metadata: { name: `${name}-redis`, namespace },
179
+ spec: {
180
+ clusterSize: 3,
181
+ kubernetesConfig: { image: 'redis:7.2-alpine' },
182
+ },
183
+ }));
184
+ resources_.push((0, jsx_runtime_1.jsx)(recipes_1.WebService, { name: `${name}-worker`, namespace: namespace, image: `docker.n8n.io/n8nio/n8n:${version}`, imagePullPolicy: version === 'latest' ? 'Always' : 'IfNotPresent', port: 5678, replicas: workers, command: ['n8n', 'worker', '--concurrency=10'], resources: {
185
+ requests: { memory: '512Mi', cpu: '250m' },
186
+ limits: { memory: '2Gi', cpu: '2000m' },
187
+ }, probes: {
188
+ liveness: { path: '/healthz' },
189
+ readiness: { path: '/healthz', initialDelaySeconds: 20 },
190
+ }, env: { ...sharedEnv, QUEUE_HEALTH_CHECK_ACTIVE: 'true' }, secrets: sharedSecrets }));
191
+ }
192
+ // --- Endpoint — webhooks share the editor host -----------------------------
193
+ resources_.push((0, jsx_runtime_1.jsx)(recipes_1.Endpoint, { name: `${name}-endpoint`, namespace: namespace, host: host, serviceName: name, servicePort: 5678, tls: tls, annotations: {
194
+ // Long-lived webhook/queue connections (editor SSE, waiting webhooks)
195
+ // die at the default 60 s proxy timeout — facit sets one hour
196
+ 'nginx.ingress.kubernetes.io/proxy-read-timeout': '3600',
197
+ 'nginx.ingress.kubernetes.io/proxy-send-timeout': '3600',
198
+ ...endpointAnnotations,
199
+ } }));
200
+ return (0, core_1.jsx)(core_1.Fragment, { children: resources_ });
201
+ }
202
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r8s/n8n",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "n8n workflow automation — editor, Postgres persistence, Redis queue mode, webhook endpoints",
5
5
  "license": "MIT",
6
6
  "author": "Berget AI AB",
@@ -37,5 +37,8 @@
37
37
  "devDependencies": {
38
38
  "typescript": "^5.3.0",
39
39
  "vitest": "^1.0.0"
40
- }
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ]
41
44
  }
@@ -1,267 +0,0 @@
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
- })
@@ -1,8 +0,0 @@
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/src/index.tsx DELETED
@@ -1,284 +0,0 @@
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 DELETED
@@ -1,15 +0,0 @@
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
- }