mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-23 12:55:23 +02:00
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
import { K3sContainer, type StartedK3sContainer } from '@testcontainers/k3s';
|
|
import { execSync } from 'node:child_process';
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { setTimeout as wait } from 'node:timers/promises';
|
|
|
|
import { TEST_CONTAINER_IMAGES } from './test-containers';
|
|
|
|
const DEFAULT_K3S_IMAGE = 'rancher/k3s:v1.32.2-k3s1';
|
|
const DEFAULT_CHART_REPO = 'https://github.com/n8n-io/n8n-hosting.git';
|
|
const DEFAULT_CHART_REF = 'main';
|
|
const N8N_NODE_PORT = 30080;
|
|
const HEALTH_POLL_INTERVAL_MS = 2_000;
|
|
const CONTAINERD_READY_TIMEOUT_MS = 30_000;
|
|
const K3S_STARTUP_TIMEOUT_MS = 120_000;
|
|
const COMMAND_TIMEOUT_MS = 600_000;
|
|
|
|
export type HelmStackMode = 'standalone' | 'queue';
|
|
|
|
export interface HelmStackConfig {
|
|
/** n8n Docker image to deploy (default: TEST_CONTAINER_IMAGES.n8n) */
|
|
n8nImage?: string;
|
|
/** K3s image (default: rancher/k3s:v1.32.2-k3s1) */
|
|
k3sImage?: string;
|
|
/** Git ref for the n8n-hosting repo (default: main) */
|
|
helmChartRef?: string;
|
|
/** Git repo URL for the Helm chart (default: n8n-io/n8n-hosting) */
|
|
helmChartRepo?: string;
|
|
/** Total startup timeout in ms (default: 300_000) */
|
|
startupTimeoutMs?: number;
|
|
/** Deployment mode: standalone (SQLite) or queue (PostgreSQL + Redis + workers) */
|
|
mode?: HelmStackMode;
|
|
/** Additional environment variables to inject into n8n pods via Helm set-flags (merge-last-wins over defaults) */
|
|
env?: Record<string, string>;
|
|
}
|
|
|
|
export interface HelmStack {
|
|
/** Base URL to access n8n running inside K3s */
|
|
baseUrl: string;
|
|
/** Stop the K3s container and clean up */
|
|
stop: () => Promise<void>;
|
|
/** Path to kubeconfig file (use with kubectl/helm from your terminal) */
|
|
kubeConfigPath: string;
|
|
}
|
|
|
|
// -- Logging ------------------------------------------------------------------
|
|
|
|
function log(message: string) {
|
|
const timestamp = new Date().toISOString().slice(11, 19);
|
|
console.log(`[helm-stack ${timestamp}] ${message}`);
|
|
}
|
|
|
|
// -- Host command execution ---------------------------------------------------
|
|
|
|
/** Execute a shell command on the host with the given environment. Returns stdout or throws with stderr. */
|
|
function execOnHost(cmd: string, env: NodeJS.ProcessEnv, description: string): string {
|
|
try {
|
|
return execSync(cmd, { env, stdio: 'pipe', encoding: 'utf-8', timeout: COMMAND_TIMEOUT_MS });
|
|
} catch (error: unknown) {
|
|
const stderr = (error as { stderr?: string }).stderr ?? '';
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`${description} failed:\n${stderr || message}`);
|
|
}
|
|
}
|
|
|
|
// -- Image preloading (must run inside K3s containerd) ------------------------
|
|
|
|
async function preloadImage(container: StartedK3sContainer, imageName: string): Promise<void> {
|
|
// Try crictl pull first (fast for public registry images like GHCR).
|
|
// Falls back to docker save + ctr import for local-only images (e.g. n8nio/n8n:local).
|
|
log(`Pulling ${imageName} inside K3s...`);
|
|
const pullResult = await container.exec(['crictl', 'pull', imageName]);
|
|
if (pullResult.exitCode !== 0) {
|
|
log('Registry pull failed, importing from local Docker...');
|
|
const tarPath = `/tmp/n8n-helm-${Date.now()}.tar`;
|
|
try {
|
|
execSync(`docker save ${imageName} -o ${tarPath}`, { stdio: 'pipe' });
|
|
execSync(`docker cp ${tarPath} ${container.getId()}:/tmp/n8n-image.tar`, {
|
|
stdio: 'pipe',
|
|
});
|
|
|
|
const importResult = await container.exec([
|
|
'ctr',
|
|
'--namespace',
|
|
'k8s.io',
|
|
'images',
|
|
'import',
|
|
'/tmp/n8n-image.tar',
|
|
]);
|
|
if (importResult.exitCode !== 0) {
|
|
throw new Error(`ctr import failed: ${importResult.output}`);
|
|
}
|
|
await container.exec(['rm', '-f', '/tmp/n8n-image.tar']);
|
|
} finally {
|
|
try {
|
|
unlinkSync(tarPath);
|
|
} catch (cleanupError: unknown) {
|
|
log(
|
|
`Warning: failed to clean up temp file ${tarPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const { output } = await container.exec(['crictl', 'images']);
|
|
log(`Available images after preload:\n${output}`);
|
|
}
|
|
|
|
// -- Chart download -----------------------------------------------------------
|
|
|
|
function cloneChartToHost(repo: string, ref: string): string {
|
|
log(`Downloading chart from ${repo} @ ${ref}...`);
|
|
const dir = mkdtempSync(join(tmpdir(), 'n8n-chart-'));
|
|
const repoPath = repo.replace('https://github.com/', '').replace('.git', '');
|
|
const tarUrl = `https://github.com/${repoPath}/archive/${ref}.tar.gz`;
|
|
|
|
execSync(`curl -fsSL "${tarUrl}" | tar xz -C "${dir}" --strip-components=1`, { stdio: 'pipe' });
|
|
log('Chart downloaded');
|
|
return dir;
|
|
}
|
|
|
|
// -- Example values file selection --------------------------------------------
|
|
|
|
const EXAMPLE_VALUES_FILES: Record<HelmStackMode, string> = {
|
|
standalone: 'standalone.yaml',
|
|
queue: 'minimal.yaml',
|
|
};
|
|
|
|
function getExampleValuesFile(chartDir: string, mode: HelmStackMode): string {
|
|
return join(chartDir, 'charts', 'n8n', 'examples', EXAMPLE_VALUES_FILES[mode]);
|
|
}
|
|
|
|
// -- Helm install flags -------------------------------------------------------
|
|
|
|
function parseImageName(imageName: string): { repository: string; tag: string } {
|
|
// Split on last colon to handle registry ports (e.g. localhost:5000/repo:tag)
|
|
const lastColon = imageName.lastIndexOf(':');
|
|
const hasTag = lastColon > 0 && !imageName.substring(lastColon).includes('/');
|
|
return hasTag
|
|
? { repository: imageName.substring(0, lastColon), tag: imageName.substring(lastColon + 1) }
|
|
: { repository: imageName, tag: 'latest' };
|
|
}
|
|
|
|
function buildHelmSetFlags(
|
|
imageName: string,
|
|
mode: HelmStackMode,
|
|
baseUrl: string,
|
|
envOverrides?: Record<string, string>,
|
|
): string[] {
|
|
const { repository, tag } = parseImageName(imageName);
|
|
|
|
// Collect env vars as an array, then convert to indexed --set flags.
|
|
// This avoids fragile manual index tracking and makes it easy to add conditional entries.
|
|
const extraEnvs: Array<{ name: string; value: string }> = [
|
|
{ name: 'N8N_DIAGNOSTICS_ENABLED', value: 'false' },
|
|
{ name: 'N8N_DYNAMIC_BANNERS_ENABLED', value: 'false' },
|
|
// WEBHOOK_URL tells n8n its externally-accessible address (for invitation links, webhooks, etc.)
|
|
{ name: 'WEBHOOK_URL', value: baseUrl },
|
|
];
|
|
|
|
// License env vars from host (same pattern as testcontainers stack)
|
|
if (process.env.N8N_LICENSE_TENANT_ID) {
|
|
extraEnvs.push({ name: 'N8N_LICENSE_TENANT_ID', value: process.env.N8N_LICENSE_TENANT_ID });
|
|
}
|
|
if (process.env.N8N_LICENSE_ACTIVATION_KEY) {
|
|
extraEnvs.push({
|
|
name: 'N8N_LICENSE_ACTIVATION_KEY',
|
|
value: process.env.N8N_LICENSE_ACTIVATION_KEY,
|
|
});
|
|
}
|
|
if (process.env.N8N_LICENSE_CERT) {
|
|
extraEnvs.push({ name: 'N8N_LICENSE_CERT', value: process.env.N8N_LICENSE_CERT });
|
|
}
|
|
|
|
// Caller-provided env overrides (merge-last-wins)
|
|
if (envOverrides) {
|
|
for (const [name, value] of Object.entries(envOverrides)) {
|
|
extraEnvs.push({ name, value });
|
|
}
|
|
}
|
|
|
|
// Dynamic overrides on top of the example values file (-f).
|
|
// Mode-specific config (database type, queue settings, etc.) comes from the example file.
|
|
const flags = [
|
|
`--set image.repository=${repository}`,
|
|
`--set image.tag=${tag}`,
|
|
// --set-string because K8s env values must be strings
|
|
...extraEnvs.flatMap((env, i) => [
|
|
`--set config.extraEnv[${i}].name=${env.name}`,
|
|
`--set-string config.extraEnv[${i}].value=${env.value}`,
|
|
]),
|
|
];
|
|
|
|
if (mode === 'standalone') {
|
|
// Override persistence size (example uses 5Gi, we need less for tests)
|
|
flags.push('--set persistence.size=1Gi');
|
|
} else {
|
|
// Override placeholder hosts to point at our Bitnami services
|
|
flags.push(
|
|
'--set database.host=postgresql',
|
|
'--set redis.host=redis-master',
|
|
// Example file references n8n-core-secrets, we use n8n-secrets
|
|
'--set secretRefs.existingSecret=n8n-secrets',
|
|
);
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
|
|
// -- K8s secrets --------------------------------------------------------------
|
|
|
|
function createN8nSecret(env: NodeJS.ProcessEnv): void {
|
|
log('Creating n8n core secrets...');
|
|
execOnHost(
|
|
'kubectl create secret generic n8n-secrets --from-literal=N8N_ENCRYPTION_KEY=test-encryption-key-for-e2e-testing --from-literal=N8N_HOST=localhost --from-literal=N8N_PORT=5678 --from-literal=N8N_PROTOCOL=http',
|
|
env,
|
|
'Create n8n core secrets',
|
|
);
|
|
}
|
|
|
|
// -- Queue mode infrastructure ------------------------------------------------
|
|
|
|
function deployQueueInfrastructure(env: NodeJS.ProcessEnv): void {
|
|
log('Adding Bitnami Helm repo...');
|
|
execOnHost(
|
|
'helm repo add bitnami https://charts.bitnami.com/bitnami && helm repo update',
|
|
env,
|
|
'Add Bitnami repo',
|
|
);
|
|
|
|
log('Deploying PostgreSQL...');
|
|
execOnHost(
|
|
'helm install postgresql bitnami/postgresql --set auth.username=n8n --set auth.password=n8n-test-password --set auth.database=n8n --set primary.resources.requests.cpu=100m --set primary.resources.requests.memory=256Mi --set primary.resources.limits.cpu=500m --set primary.resources.limits.memory=512Mi --wait --timeout 3m',
|
|
env,
|
|
'Deploy PostgreSQL',
|
|
);
|
|
log('PostgreSQL deployed');
|
|
|
|
log('Deploying Redis...');
|
|
execOnHost(
|
|
"helm install redis bitnami/redis --set architecture=standalone --set auth.enabled=false --set-json 'master.disableCommands=[]' --set master.resources.requests.cpu=100m --set master.resources.requests.memory=128Mi --set master.resources.limits.cpu=250m --set master.resources.limits.memory=256Mi --wait --timeout 3m",
|
|
env,
|
|
'Deploy Redis',
|
|
);
|
|
log('Redis deployed');
|
|
|
|
execOnHost(
|
|
'kubectl create secret generic n8n-db-secret --from-literal=password=n8n-test-password',
|
|
env,
|
|
'Create DB password secret',
|
|
);
|
|
}
|
|
|
|
// -- Health check -------------------------------------------------------------
|
|
|
|
async function pollHealthEndpoint(baseUrl: string, timeoutMs: number): Promise<void> {
|
|
const url = `${baseUrl}/healthz/readiness`;
|
|
const startTime = Date.now();
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.status === 200) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Retry
|
|
}
|
|
await wait(HEALTH_POLL_INTERVAL_MS);
|
|
}
|
|
|
|
throw new Error(`n8n health check at ${url} did not return 200 within ${timeoutMs / 1000}s`);
|
|
}
|
|
|
|
// -- Main entry point ---------------------------------------------------------
|
|
|
|
export async function createHelmStack(config: HelmStackConfig = {}): Promise<HelmStack> {
|
|
const {
|
|
n8nImage = TEST_CONTAINER_IMAGES.n8n,
|
|
k3sImage = DEFAULT_K3S_IMAGE,
|
|
helmChartRef = DEFAULT_CHART_REF,
|
|
helmChartRepo = DEFAULT_CHART_REPO,
|
|
startupTimeoutMs = 300_000,
|
|
mode = 'standalone',
|
|
env: envOverrides,
|
|
} = config;
|
|
|
|
const containerName = `n8n-helm-${mode}-${Date.now().toString(36)}`;
|
|
|
|
log('Starting K3s + Helm stack');
|
|
log(` Mode: ${mode}`);
|
|
log(` Container: ${containerName}`);
|
|
log(` n8n image: ${n8nImage}`);
|
|
log(` K3s image: ${k3sImage}`);
|
|
log(` Chart: ${helmChartRepo} @ ${helmChartRef}`);
|
|
|
|
// Step 1: Start K3s with NodePort exposed (bypasses flaky kubectl port-forward)
|
|
// Ryuk is disabled so the container survives process exit — clean up via stack:helm:clean.
|
|
log('Starting K3s container (privileged)...');
|
|
const k3s = await new K3sContainer(k3sImage)
|
|
.withName(containerName)
|
|
.withLabels({ 'n8n.helm': 'true', 'n8n.helm.mode': mode })
|
|
.withExposedPorts(N8N_NODE_PORT)
|
|
.withStartupTimeout(K3S_STARTUP_TIMEOUT_MS)
|
|
.start();
|
|
const hostPort = k3s.getMappedPort(N8N_NODE_PORT);
|
|
const baseUrl = `http://localhost:${hostPort}`;
|
|
log(`K3s started (NodePort ${N8N_NODE_PORT} -> host ${hostPort})`);
|
|
|
|
// Step 2: Write kubeconfig to a stable path and merge into ~/.kube/config
|
|
const kubeDir = join(homedir(), '.kube');
|
|
mkdirSync(kubeDir, { recursive: true });
|
|
const contextName = `n8n-helm-${mode}`;
|
|
const kubeConfigPath = join(kubeDir, `${contextName}.yaml`);
|
|
|
|
// K3s names everything 'default' — rename to avoid clashing with existing contexts
|
|
const rawKubeconfig = k3s.getKubeConfig();
|
|
const namedKubeconfig = rawKubeconfig
|
|
.replace(/\bname: default\b/g, `name: ${contextName}`)
|
|
.replace(/\bcurrent-context: default\b/, `current-context: ${contextName}`)
|
|
.replace(/\bcluster: default\b/g, `cluster: ${contextName}`)
|
|
.replace(/\buser: default\b/g, `user: ${contextName}`);
|
|
writeFileSync(kubeConfigPath, namedKubeconfig);
|
|
|
|
// Merge into ~/.kube/config so kubectl works without KUBECONFIG env var
|
|
const defaultKubeconfig = join(kubeDir, 'config');
|
|
if (existsSync(defaultKubeconfig)) {
|
|
copyFileSync(defaultKubeconfig, `${defaultKubeconfig}.bak`);
|
|
}
|
|
try {
|
|
const merged = execSync('kubectl config view --flatten', {
|
|
env: {
|
|
...process.env,
|
|
KUBECONFIG: existsSync(defaultKubeconfig)
|
|
? `${kubeConfigPath}:${defaultKubeconfig}`
|
|
: kubeConfigPath,
|
|
},
|
|
stdio: 'pipe',
|
|
encoding: 'utf-8',
|
|
});
|
|
writeFileSync(defaultKubeconfig, merged);
|
|
execSync(`kubectl config use-context ${contextName}`, { stdio: 'pipe' });
|
|
log(`Kubeconfig merged into ~/.kube/config (context: ${contextName})`);
|
|
} catch {
|
|
log(`Warning: could not merge kubeconfig — use: export KUBECONFIG=${kubeConfigPath}`);
|
|
}
|
|
|
|
const env: NodeJS.ProcessEnv = { ...process.env, KUBECONFIG: kubeConfigPath };
|
|
|
|
let chartDir = '';
|
|
|
|
try {
|
|
// Step 3: Wait for containerd readiness
|
|
log('Waiting for containerd...');
|
|
const deadline = Date.now() + CONTAINERD_READY_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
const result = await k3s.exec(['crictl', 'images']);
|
|
if (result.exitCode === 0) break;
|
|
await wait(HEALTH_POLL_INTERVAL_MS);
|
|
}
|
|
|
|
// Step 4: Preload n8n image into K3s containerd
|
|
await preloadImage(k3s, n8nImage);
|
|
log('Image preloaded');
|
|
|
|
// Step 5: Download chart to host
|
|
chartDir = cloneChartToHost(helmChartRepo, helmChartRef);
|
|
|
|
// Step 6: Create n8n core secrets (encryption key, host config)
|
|
createN8nSecret(env);
|
|
|
|
// Step 7: Deploy queue infrastructure if needed
|
|
if (mode === 'queue') {
|
|
deployQueueInfrastructure(env);
|
|
}
|
|
|
|
// Step 8: Install n8n chart using published example values file + dynamic overrides
|
|
log('Installing Helm chart (this may take a few minutes)...');
|
|
const valuesFile = getExampleValuesFile(chartDir, mode);
|
|
const setFlags = buildHelmSetFlags(n8nImage, mode, baseUrl, envOverrides).join(' ');
|
|
log(`Using values file: ${valuesFile}`);
|
|
const helmOutput = execOnHost(
|
|
`helm install n8n "${chartDir}/charts/n8n" -f "${valuesFile}" ${setFlags} --wait --timeout 5m`,
|
|
env,
|
|
'Helm install',
|
|
);
|
|
log(`Helm install complete:\n${helmOutput.trim()}`);
|
|
|
|
// Step 9: Patch service to NodePort so traffic goes through K3s's exposed port
|
|
// (bypasses kubectl port-forward which silently breaks after many connections)
|
|
log(`Patching n8n service to NodePort ${N8N_NODE_PORT}...`);
|
|
execOnHost(
|
|
`kubectl patch svc n8n-main --type merge -p '{"spec":{"type":"NodePort","ports":[{"port":5678,"targetPort":5678,"nodePort":${N8N_NODE_PORT}}]}}'`,
|
|
env,
|
|
'Patch service to NodePort',
|
|
);
|
|
|
|
// Step 10: Poll health endpoint
|
|
log(`Polling ${baseUrl}/healthz/readiness...`);
|
|
await pollHealthEndpoint(baseUrl, Math.min(startupTimeoutMs, 120_000));
|
|
log(`n8n is ready at ${baseUrl}`);
|
|
|
|
return {
|
|
baseUrl,
|
|
kubeConfigPath,
|
|
stop: async () => {
|
|
log('Shutting down...');
|
|
try {
|
|
unlinkSync(kubeConfigPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
execSync(`rm -rf "${chartDir}"`, { stdio: 'pipe' });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
await k3s.stop();
|
|
log('K3s stopped');
|
|
},
|
|
};
|
|
} catch (error) {
|
|
// Dump debug info from host kubectl
|
|
try {
|
|
const podStatus = execOnHost('kubectl get pods -o wide 2>/dev/null || true', env, 'debug');
|
|
console.error('\n--- Pod Status ---');
|
|
console.error(podStatus);
|
|
|
|
const podLogs = execOnHost(
|
|
'kubectl logs -l app.kubernetes.io/name=n8n --tail=50 2>/dev/null || true',
|
|
env,
|
|
'debug',
|
|
);
|
|
if (podLogs.trim()) {
|
|
console.error('\n--- Pod Logs ---');
|
|
console.error(podLogs);
|
|
}
|
|
|
|
const events = execOnHost(
|
|
'kubectl get events --sort-by=.lastTimestamp 2>/dev/null || true',
|
|
env,
|
|
'debug',
|
|
);
|
|
console.error('\n--- Events ---');
|
|
console.error(events);
|
|
console.error('----------------\n');
|
|
} catch {
|
|
// Best-effort debugging output
|
|
}
|
|
|
|
try {
|
|
unlinkSync(kubeConfigPath);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (chartDir)
|
|
try {
|
|
execSync(`rm -rf "${chartDir}"`, { stdio: 'pipe' });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
await k3s.stop();
|
|
throw error;
|
|
}
|
|
}
|