ci: Test container enhancements (#17008)

This commit is contained in:
shortstacked
2025-07-10 11:50:03 +01:00
committed by GitHub
parent 2294c3d71b
commit be3e75dbee
23 changed files with 408 additions and 154 deletions

View File

@@ -3,47 +3,48 @@
* This file provides a complete n8n container stack for testing with support for:
* - Single instances (SQLite or PostgreSQL)
* - Queue mode with Redis
* - Multi-main instances with nginx load balancing
* - Multi-main instances with load balancing
* - Parallel execution (multiple stacks running simultaneously)
*
* Key features for parallel execution:
* - Dynamic port allocation to avoid conflicts (handled by testcontainers)
* - WebSocket support through nginx load balancer
* - Dynamic port allocation to avoid conflicts (handled by testcontainers or get-port)
*/
import getPort from 'get-port';
import assert from 'node:assert';
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Network, Wait } from 'testcontainers';
import {
setupNginxLoadBalancer,
setupPostgres,
setupRedis,
} from './n8n-test-container-dependencies';
import { DockerImageNotFoundError } from './docker-image-not-found-error';
import { N8nImagePullPolicy } from './n8n-image-pull-policy';
import {
setupPostgres,
setupRedis,
setupCaddyLoadBalancer,
pollContainerHttpEndpoint,
} from './n8n-test-container-dependencies';
import { createSilentLogConsumer } from './n8n-test-container-utils';
// --- Constants ---
const POSTGRES_IMAGE = 'postgres:16-alpine';
const REDIS_IMAGE = 'redis:7-alpine';
const NGINX_IMAGE = 'nginx:stable';
const CADDY_IMAGE = 'caddy:2-alpine';
const N8N_E2E_IMAGE = 'n8nio/n8n:local';
// Default n8n image (can be overridden via N8N_DOCKER_IMAGE env var)
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE || N8N_E2E_IMAGE;
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE ?? N8N_E2E_IMAGE;
// Base environment for all n8n instances
const BASE_ENV: Record<string, string> = {
N8N_LOG_LEVEL: 'debug',
N8N_ENCRYPTION_KEY: 'test-encryption-key',
E2E_TESTS: 'true',
E2E_TESTS: 'false',
QUEUE_HEALTH_CHECK_ACTIVE: 'true',
N8N_DIAGNOSTICS_ENABLED: 'false',
N8N_RUNNERS_ENABLED: 'true',
NODE_ENV: 'development', // If this is set to test, the n8n container will not start, insights module is not found??
};
const MULTI_MAIN_LICENSE = {
N8N_LICENSE_TENANT_ID: '1001',
N8N_LICENSE_TENANT_ID: process.env.N8N_LICENSE_TENANT_ID ?? '1001',
N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '',
};
@@ -89,7 +90,7 @@ export interface N8NStack {
* const stack = await createN8NStack({ queueMode: true });
*
* @example
* // Custom scaling
* // Custom scaling (uses load balancer for multiple mains)
* const stack = await createN8NStack({
* queueMode: { mains: 3, workers: 5 },
* env: { N8N_ENABLED_MODULES: 'insights' }
@@ -99,22 +100,34 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
const { postgres = false, queueMode = false, env = {}, projectName } = config;
const queueConfig = normalizeQueueConfig(queueMode);
const usePostgres = postgres || !!queueConfig;
const uniqueProjectName = projectName ?? `n8n-${Math.random().toString(36).substring(7)}`;
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
const containers: StartedTestContainer[] = [];
const mainCount = queueConfig?.mains ?? 1;
const needsLoadBalancer = mainCount > 1;
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer;
let network: StartedNetwork | undefined;
let nginxContainer: StartedTestContainer | undefined;
let environment: Record<string, string> = { ...BASE_ENV, ...env };
if (usePostgres || queueConfig) {
if (needsNetwork) {
network = await new Network().start();
}
let environment: Record<string, string> = {
...BASE_ENV,
...env,
};
// Add proxy hops only if using load balancer
if (needsLoadBalancer) {
environment.N8N_PROXY_HOPS = '1';
}
if (usePostgres) {
assert(network, 'Network should be created for postgres');
const postgresContainer = await setupPostgres({
postgresImage: POSTGRES_IMAGE,
projectName: uniqueProjectName,
network: network!,
network,
});
containers.push(postgresContainer.container);
environment = {
@@ -131,10 +144,11 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
}
if (queueConfig) {
assert(network, 'Network should be created for queue mode');
const redis = await setupRedis({
redisImage: REDIS_IMAGE,
projectName: uniqueProjectName,
network: network!,
network,
});
containers.push(redis);
environment = {
@@ -142,6 +156,7 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
EXECUTIONS_MODE: 'queue',
QUEUE_BULL_REDIS_HOST: 'redis',
QUEUE_BULL_REDIS_PORT: '6379',
OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: 'true',
};
if (queueConfig.mains > 1) {
@@ -150,35 +165,59 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
}
environment = {
...environment,
N8N_PROXY_HOPS: '1',
N8N_MULTI_MAIN_SETUP_ENABLED: 'true',
...MULTI_MAIN_LICENSE,
};
}
}
let baseUrl: string;
const instances = await createN8NInstances({
mainCount: queueConfig?.mains ?? 1,
workerCount: queueConfig?.workers ?? 0,
uniqueProjectName: uniqueProjectName,
environment,
network,
});
containers.push(...instances);
if (queueConfig && queueConfig.mains > 1) {
nginxContainer = await setupNginxLoadBalancer({
nginxImage: NGINX_IMAGE,
if (needsLoadBalancer) {
assert(network, 'Network should be created for load balancer');
const loadBalancerContainer = await setupCaddyLoadBalancer({
caddyImage: CADDY_IMAGE,
projectName: uniqueProjectName,
mainInstances: instances.slice(0, queueConfig.mains),
network: network!,
mainCount,
network,
});
containers.push(nginxContainer);
baseUrl = `http://localhost:${nginxContainer.getMappedPort(80)}`;
containers.push(loadBalancerContainer);
const loadBalancerPort = loadBalancerContainer.getMappedPort(80);
baseUrl = `http://localhost:${loadBalancerPort}`;
environment = {
...environment,
WEBHOOK_URL: baseUrl,
};
const instances = await createN8NInstances({
mainCount,
workerCount: queueConfig?.workers ?? 0,
uniqueProjectName,
environment,
network,
});
containers.push(...instances);
// Wait for all containers to be ready behind the load balancer
await pollContainerHttpEndpoint(loadBalancerContainer, '/healthz/readiness');
} else {
baseUrl = `http://localhost:${instances[0].getMappedPort(5678)}`;
const assignedPort = await getPort();
baseUrl = `http://localhost:${assignedPort}`;
environment = {
...environment,
WEBHOOK_URL: baseUrl,
N8N_PORT: '5678', // Internal port
};
const instances = await createN8NInstances({
mainCount: 1,
workerCount: queueConfig?.workers ?? 0,
uniqueProjectName,
environment,
network,
directPort: assignedPort,
});
containers.push(...instances);
}
return {
@@ -245,6 +284,7 @@ interface CreateInstancesOptions {
uniqueProjectName: string;
environment: Record<string, string>;
network?: StartedNetwork;
directPort?: number;
}
async function createN8NInstances({
@@ -253,11 +293,15 @@ async function createN8NInstances({
uniqueProjectName,
environment,
network,
/** The host port to use for the main instance */
directPort,
}: CreateInstancesOptions): Promise<StartedTestContainer[]> {
const instances: StartedTestContainer[] = [];
// Create main instances
for (let i = 1; i <= mainCount; i++) {
const name = mainCount > 1 ? `${uniqueProjectName}-n8n-main-${i}` : `${uniqueProjectName}-n8n`;
const networkAlias = mainCount > 1 ? name : `${uniqueProjectName}-n8n-main-1`;
const container = await createN8NContainer({
name,
uniqueProjectName,
@@ -265,18 +309,20 @@ async function createN8NInstances({
network,
isWorker: false,
instanceNumber: i,
networkAlias: mainCount > 1 ? name : undefined,
networkAlias,
directPort: i === 1 ? directPort : undefined, // Only first main gets direct port
});
instances.push(container);
}
// Create worker instances
for (let i = 1; i <= workerCount; i++) {
const name = `${uniqueProjectName}-n8n-worker-${i}`;
const container = await createN8NContainer({
name,
uniqueProjectName,
environment,
network: network!,
network,
isWorker: true,
instanceNumber: i,
});
@@ -294,6 +340,7 @@ interface CreateContainerOptions {
isWorker: boolean;
instanceNumber: number;
networkAlias?: string;
directPort?: number;
}
async function createN8NContainer({
@@ -304,7 +351,10 @@ async function createN8NContainer({
isWorker,
instanceNumber,
networkAlias,
directPort,
}: CreateContainerOptions): Promise<StartedTestContainer> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
let container = new GenericContainer(N8N_IMAGE);
container = container
@@ -316,14 +366,10 @@ async function createN8NContainer({
})
.withPullPolicy(new N8nImagePullPolicy(N8N_IMAGE))
.withName(name)
.withLogConsumer(consumer)
.withName(name)
.withReuse();
if (isWorker) {
container = container.withCommand(['worker']);
} else {
container = container.withExposedPorts(5678).withWaitStrategy(N8N_WAIT_STRATEGY);
}
if (network) {
container = container.withNetwork(network);
if (networkAlias) {
@@ -331,12 +377,30 @@ async function createN8NContainer({
}
}
if (isWorker) {
container = container.withCommand(['worker']);
} else {
container = container.withExposedPorts(5678).withWaitStrategy(N8N_WAIT_STRATEGY);
if (directPort) {
container = container.withExposedPorts({ container: 5678, host: directPort });
}
}
try {
return await container.start();
} catch (error) {
if (error instanceof Error && 'statusCode' in error && error.statusCode === 404) {
} catch (error: unknown) {
if (
error instanceof Error &&
'statusCode' in error &&
(error as Error & { statusCode: number }).statusCode === 404
) {
throw new DockerImageNotFoundError(name, error);
}
throw error;
console.error(`Container "${name}" failed to start!`);
console.error('Original error:', error instanceof Error ? error.message : String(error));
return throwWithLogs(error);
}
}