first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# Helm Chart E2E Testing
Test n8n Helm chart deployments using K3s (lightweight Kubernetes) inside Docker, powered by `@testcontainers/k3s`.
## Prerequisites
### Required
- **Docker Desktop** (macOS/Windows) or **Docker Engine** (Linux)
- **Privileged container support** — K3s runs as a privileged container
- **helm** CLI — runs on your host machine ([install](https://helm.sh/docs/intro/install/))
- **kubectl** CLI — runs on your host machine ([install](https://kubernetes.io/docs/tasks/tools/))
- **n8n Docker image** — built locally (`pnpm build:docker`) or pulled from Docker Hub
### Not Required
- Kind, Minikube, or any other K8s distribution
- Kubernetes cluster access
- Any K8s tooling beyond helm + kubectl
### Privileged Container Compatibility
| Environment | Supported | Notes |
|---|---|---|
| Docker Desktop (macOS/Windows) | Yes | Privileged enabled by default |
| Docker Engine (Linux) | Yes | Standard daemon supports it |
| GitHub Actions (ubuntu runners) | Yes | Docker socket available |
| Blacksmith runners | Yes | Standard Docker-capable VMs |
| Rootless Docker | **No** | K3s requires privileged mode |
| Docker-in-Docker | Depends | Outer container needs `--privileged` |
| Podman | **No** | K3s requires Docker-compatible runtime |
## How It Works
```
Host Machine (helm, kubectl)
├── KUBECONFIG=/tmp/helm-kubeconfig-*.yaml
└── Docker
└── K3s Container (privileged, NodePort 30080 → host random port)
├── containerd (K3s runtime)
│ └── n8n image (preloaded from host Docker)
└── Kubernetes control plane
├── n8n Pod (Helm-deployed)
└── Service (NodePort 30080 → Pod 5678)
Playwright ──── http://localhost:<host-port> ──→ NodePort ──→ n8n Pod
```
1. `@testcontainers/k3s` starts K3s inside a Docker container with NodePort 30080 exposed
2. The n8n Docker image is exported from host Docker and imported into K3s's containerd
3. Host `helm` installs the n8n chart using a kubeconfig pointing at the K3s API
4. The n8n service is patched to NodePort, routing traffic through K3s's exposed port
5. Playwright tests connect via `N8N_BASE_URL=http://localhost:<host-port>`
## Local Usage
```bash
# 1. Build the n8n Docker image (or use a Docker Hub image)
pnpm build:docker
# 2. Start the Helm stack (takes ~60-120s)
cd packages/testing/containers
pnpm stack:helm
# 3. In another terminal, use the printed KUBECONFIG for debugging
export KUBECONFIG=/tmp/helm-kubeconfig-*.yaml
kubectl get pods
kubectl logs -l app.kubernetes.io/name=n8n
# 4. Run tests
N8N_BASE_URL=http://localhost:<port> RESET_E2E_DB=true \
npx playwright test tests/e2e/building-blocks/ --workers=1
# 5. Cleanup
pnpm stack:helm:clean
```
### Test a Specific Version Matrix
```bash
# Test n8n 1.80.0 against chart version v1.2.0
pnpm stack:helm --image n8nio/n8n:1.80.0 --chart-ref v1.2.0
# Test latest n8n against a chart PR branch
pnpm stack:helm --chart-ref fix/pvc-permissions
# Test a GHCR image (e.g., from CI)
pnpm stack:helm --image ghcr.io/n8n-io/n8n:ci-12345
```
### CLI Options
```
--mode <mode> standalone (SQLite, default) or queue (PostgreSQL + Redis + workers)
--image <image> n8n Docker image (default: n8nio/n8n:local)
--chart-ref <ref> Git branch/tag for n8n-hosting (default: main)
--chart-repo <url> Git repo URL (default: https://github.com/n8n-io/n8n-hosting.git)
--k3s-image <image> K3s image (default: rancher/k3s:v1.32.2-k3s1)
--url-file <path> Write URL to file when ready (for CI automation)
--help Show help
```
## CI Usage
The `test-e2e-helm.yml` workflow handles everything:
- **Trigger:** Push to `helm-container-test` branch, or manual dispatch
- **Build:** Creates n8n Docker image, pushes to GHCR
- **Test:** Starts K3s, installs Helm chart, runs building-blocks E2E tests
- **Cleanup:** Removes ephemeral GHCR image
Manual dispatch also accepts `helm-chart-ref` to test a specific chart version.
## Troubleshooting
### K3s won't start
Check that Docker supports privileged containers:
```bash
docker run --rm --privileged alpine echo "privileged works"
```
### Image not found in K3s
Ensure the n8n Docker image exists locally:
```bash
docker images | grep n8nio/n8n
```
If empty, run `pnpm build:docker` first.
### Helm install times out
Use kubectl to inspect the cluster:
```bash
export KUBECONFIG=/tmp/helm-kubeconfig-*.yaml
kubectl get pods -o wide
kubectl describe pod -l app.kubernetes.io/name=n8n
kubectl get events --sort-by=.lastTimestamp
```
### Port not accessible
NodePort routing is stateless (kube-proxy), so connectivity issues typically indicate the pod is unhealthy. Check pod status:
```bash
export KUBECONFIG=/tmp/helm-kubeconfig-*.yaml
kubectl get pods -o wide
kubectl describe pod -l app.kubernetes.io/name=n8n
kubectl version --client
helm version
```
### Slow startup
First run is slower due to K3s image pull. Typical timings:
| Phase | First Run | Subsequent |
|---|---|---|
| K3s start | ~15-30s | ~5s (reuse) |
| Image preload | ~10-20s | ~10-20s |
| Helm install | ~5-10s | ~5-10s |
| n8n boot | ~15-30s | ~15-30s |
| **Total** | **~60-120s** | **~40-80s** |
## Comparison: Testcontainers vs K3s + Helm
| | Testcontainers (Stream 1) | K3s + Helm (Stream 2) |
|---|---|---|
| **Purpose** | Feature testing | Deployment validation |
| **Speed** | 5-15s startup | 60-120s startup |
| **K8s features** | None | PVC, RBAC, securityContext, NetworkPolicy |
| **Custom services** | Kafka, Mailpit, OIDC, etc. | Only what the Helm chart defines |
| **What it proves** | "n8n works with X" | "this chart config deploys correctly" |
| **When to run** | Every PR | On demand, nightly, pre-release |
| **Prerequisites** | Docker | Docker + helm + kubectl |
+438
View File
@@ -0,0 +1,438 @@
# n8n Test Containers
A composable container stack for n8n testing. Describe what you need, it builds the environment.
## Quick Start
```bash
#build the container
pnpm build:docker
```
alternatively, you can set `N8N_DOCKER_IMAGE=n8nio/n8n:latest`
```bash
# Basic n8n (SQLite)
pnpm stack
# With PostgreSQL
pnpm stack --postgres
# Queue mode (Redis + PostgreSQL + worker)
pnpm stack --queue
# Multi-main cluster
pnpm stack --mains 2 --workers 1
# Cloud plan simulation
pnpm stack --plan starter
# Public tunnel for webhook testing
pnpm stack --tunnel
```
When started, you'll see the URL: `http://localhost:[port]`
## Using in Playwright Tests
### Basic Test
```typescript
import { test, expect } from '../fixtures/base';
test('my test', async ({ n8n }) => {
await n8n.page.goto('/workflow/new');
// ...
});
```
### Enabling Services
Use `test.use()` to request services:
```typescript
// Single service
test.use({
capability: {
services: ['mailpit'],
},
});
// Multiple services
test.use({
capability: {
services: ['mailpit', 'keycloak', 'victoriaLogs', 'victoriaMetrics', 'vector'],
},
});
// Queue mode with services
test.use({
capability: {
mains: 2,
workers: 1,
services: ['victoriaLogs', 'victoriaMetrics', 'vector'],
},
});
```
### Using Service Helpers
Services provide type-safe helpers via `n8nContainer.services.*`:
```typescript
test('email test', async ({ n8nContainer }) => {
// Wait for email
const email = await n8nContainer.services.mailpit.waitForMessage({
to: 'test@example.com',
});
expect(email.subject).toBe('Welcome');
});
test('source control', async ({ n8nContainer }) => {
// Create git repo
const repo = await n8nContainer.services.gitea.createRepo('my-repo');
await repo.createBranch('develop');
});
test('metrics', async ({ n8nContainer }) => {
// Query Prometheus metrics
const result = await n8nContainer.services.observability.metrics.query('up');
expect(result[0].value).toBe(1);
});
```
### Capability Shortcuts
Common combinations have shortcuts in `fixtures/capabilities.ts`:
```typescript
// Instead of: { services: ['mailpit'] }
test.use({ capability: 'email' });
// Instead of: { services: ['keycloak'] }
test.use({ capability: 'oidc' });
// Instead of: { services: ['gitea'] }
test.use({ capability: 'source-control' });
```
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Test Code │
│ n8nContainer.services.gitea.createRepo('my-repo') │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ N8NStack │
│ services: ServiceHelpers ← Proxy with lazy instantiation │
│ baseUrl, stop(), findContainers() │
└─────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ GiteaHelper │ │MailpitHelper│ │ Observability│
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Container │ │ Container │ │ Container │
└─────────────┘ └─────────────┘ └─────────────┘
```
### Key Concepts
| Concept | Description |
|---------|-------------|
| **Service** | Container definition with `start()`, optional `env()`, optional helper |
| **Registry** | Central manifest of all services (`services/registry.ts`) |
| **Stack** | Orchestrator that builds the environment from config |
| **Helper** | Type-safe API for interacting with a service in tests |
### Service Activation
Services activate in two ways:
| Mode | When | Example |
|------|------|---------|
| **Auto-start** | Service has `shouldStart()` returning true | Redis auto-starts in queue mode |
| **User-enabled** | Listed in `services: []` array | `services: ['mailpit']` |
## Adding a New Service
### Do I Need a Helper?
Helpers let tests interact with a service **outside of the n8n UI**. Ask yourself:
> "Will tests need to arrange or assert data in this service directly?"
| Scenario | Helper Needed? | Example |
|----------|---------------|---------|
| **Test arrangement** - Set up data before test | Yes | Create a git repo before testing source control sync |
| **Test assertion** - Verify side effects | Yes | Check an email was sent after workflow execution |
| **Infrastructure only** - n8n connects, tests don't | No | PostgreSQL, Redis - n8n uses them, tests don't touch them |
| **Observability** - Query metrics/logs | Yes | Assert memory usage, check for error logs |
**Examples:**
```typescript
// Mailpit helper - ARRANGE: no emails exist, ASSERT: email was sent
const emails = await n8nContainer.services.mailpit.getMessages();
expect(emails).toHaveLength(1);
// Gitea helper - ARRANGE: create repo before test
const repo = await n8nContainer.services.gitea.createRepo('test-repo');
// Now test source control connection via UI
// Observability helper - ASSERT: check metrics after load test
const memory = await n8nContainer.services.observability.metrics.query('process_resident_memory_bytes');
expect(memory[0].value).toBeLessThan(500_000_000);
// Redis/Postgres - no helper needed, n8n connects automatically
// Tests don't need to interact with these directly
```
**Rule of thumb:** If you'd otherwise need `docker exec` or raw HTTP calls in your test, you need a helper.
### Minimal Service (No Helper)
**1. Create `services/my-service.ts`:**
```typescript
import { GenericContainer, Wait } from 'testcontainers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'myservice';
const PORT = 8080;
export interface MyServiceMeta {
host: string;
port: number;
}
export type MyServiceResult = ServiceResult<MyServiceMeta>;
export const myService: Service<MyServiceResult> = {
description: 'My service description',
async start(network, projectName) {
const container = await new GenericContainer('myimage:latest')
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(PORT)
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start();
return {
container,
meta: { host: HOSTNAME, port: PORT },
};
},
// Optional: env vars for n8n
env(result) {
return {
MY_SERVICE_HOST: result.meta.host,
MY_SERVICE_PORT: String(result.meta.port),
};
},
};
```
**2. Register in `services/types.ts` and `services/registry.ts`:**
```typescript
// types.ts - add to SERVICE_NAMES array
export const SERVICE_NAMES = [
// ...existing
'myService',
] as const;
// registry.ts - add to services object
import { myService } from './my-service';
export const services: Record<ServiceName, Service<ServiceResult>> = {
// ...existing
myService,
};
```
**Done.** Use with `services: ['myService']` in tests.
> **Note:** The `ServiceName` type is derived from `SERVICE_NAMES`, and `Record<ServiceName, ...>` ensures the registry includes all services. TypeScript will error if they're out of sync.
### Service With Helper
Add a helper class and factory to the service file:
```typescript
// ... service definition from above ...
// Helper class
export class MyServiceHelper {
constructor(
private readonly container: StartedTestContainer,
private readonly meta: MyServiceMeta,
) {}
async doSomething(): Promise<string> {
// Interact with the service
const response = await fetch(`http://${this.container.getHost()}:${this.container.getMappedPort(PORT)}/api`);
return response.text();
}
}
// Factory function
export function createMyServiceHelper(ctx: HelperContext): MyServiceHelper {
const result = ctx.serviceResults.myService;
if (!result) {
throw new Error('MyService not running. Add services: ["myService"] to test.use()');
}
return new MyServiceHelper(result.container, result.meta as MyServiceMeta);
}
// Type registration (enables autocomplete)
declare module './types' {
interface ServiceHelpers {
myService: MyServiceHelper;
}
}
```
**Register in `services/types.ts` and `services/registry.ts`:**
```typescript
// types.ts - add to SERVICE_NAMES array
export const SERVICE_NAMES = [
// ...existing
'myService',
] as const;
// registry.ts - add service and helper factory
import { myService, createMyServiceHelper } from './my-service';
export const services = { ...existing, myService };
export const helperFactories = { ...existing, myService: createMyServiceHelper };
```
**Use in tests:**
```typescript
test('my test', async ({ n8nContainer }) => {
const result = await n8nContainer.services.myService.doSomething();
});
```
### Optional: Add Capability Shortcut
In `fixtures/capabilities.ts`:
```typescript
export const CAPABILITIES = {
// ...existing
'my-capability': { services: ['myService'] },
};
```
Now usable as `test.use({ capability: 'my-capability' })`.
## Available Services
| Service | Helper | Description |
|---------|--------|-------------|
| `postgres` | - | PostgreSQL database |
| `redis` | - | Redis for queue mode |
| `mailpit` | ✓ | Email testing (SMTP + UI) |
| `gitea` | ✓ | Git server for source control |
| `keycloak` | ✓ | OIDC/SSO provider |
| `victoriaLogs` | - | VictoriaLogs for log storage |
| `victoriaMetrics` | - | VictoriaMetrics for metrics |
| `vector` | - | Vector log collector (depends on victoriaLogs) |
| `tracing` | ✓ | Jaeger for distributed tracing |
| `kafka` | ✓ | Kafka broker for message queue testing |
| `proxy` | - | HTTP proxy (MockServer) |
| `taskRunner` | - | External task runner |
| `loadBalancer` | - | Caddy for multi-main |
| `cloudflared` | - | Cloudflare Tunnel for public webhook URLs |
**Note:** For observability (logs + metrics), enable all three: `['victoriaLogs', 'victoriaMetrics', 'vector']`.
The `observability` capability shortcut handles this automatically: `test.use({ capability: 'observability' })`.
## CLI Options
| Option | Description |
|--------|-------------|
| `--postgres` | Use PostgreSQL instead of SQLite |
| `--queue` | Enable queue mode (adds Redis + PostgreSQL) |
| `--mains <n>` | Number of main instances |
| `--workers <n>` | Number of worker instances |
| `--plan <name>` | Cloud plan preset (trial, starter, pro-1, pro-2, enterprise) |
| `--name <name>` | Custom project name for parallel runs |
| `--env KEY=VALUE` | Set environment variables |
| `--observability` | Enable metrics/logs stack |
| `--tracing` | Enable tracing stack (Jaeger) |
| `--tunnel` | Enable Cloudflare Tunnel for public webhook URLs |
| `--oidc` | Enable Keycloak |
| `--source-control` | Enable Gitea |
| `--mailpit` | Enable email testing (Mailpit) |
## Telemetry
Container stack telemetry tracks startup timing, configuration, and runner info. Useful for monitoring CI performance and debugging slow stacks.
### Environment Variables
| Variable | Description |
|----------|-------------|
| `CONTAINER_TELEMETRY_WEBHOOK` | POST telemetry JSON to this URL |
| `CONTAINER_TELEMETRY_VERBOSE` | Set to `1` for JSON output to console |
### What's Collected
```typescript
{
timestamp: string; // ISO timestamp
git: { sha, branch, pr? }; // Git context from CI env vars
ci: { runId, job, workflow }; // GitHub Actions context
runner: { provider, cpuCores, memoryGb }; // github | blacksmith | local
stack: { type, mains, workers, postgres, services };
timing: { total, network, n8nStartup, services: Record<string, number> };
containers: { total, services, n8n };
success: boolean;
errorMessage?: string;
}
```
### Usage
```bash
# Verbose output locally
CONTAINER_TELEMETRY_VERBOSE=1 pnpm stack
# Send to webhook (CI)
CONTAINER_TELEMETRY_WEBHOOK=https://n8n.example.com/webhook/telemetry
```
## Cleanup
```bash
# Remove all containers and networks
pnpm stack:clean:all
```
## Tips
- **Container Reuse**: Set `TESTCONTAINERS_REUSE_ENABLE=true` for faster restarts
- **Parallel Testing**: Use `--name` to run multiple stacks without conflicts
- **Custom Image**: Set `TEST_IMAGE_N8N=n8nio/n8n:dev` to use a different image
- **Multi-Main**: Requires queue mode and license key in `N8N_LICENSE_ACTIVATION_KEY`
- **Using podman**: This does not work with podman out of the box - you need to ensure testcontainers is set correctly [https://podman-desktop.io/tutorial/testcontainers-with-podman](https://podman-desktop.io/tutorial/testcontainers-with-podman)
@@ -0,0 +1,24 @@
import { TEST_CONTAINER_IMAGES } from './test-containers';
// Custom error class for when the Docker image is not found locally/remotely
// This can happen when using the "n8nio/n8n:local" image, which is not available on Docker Hub
// This image is available after running `pnpm build:docker` at the root of the repository
export class DockerImageNotFoundError extends Error {
constructor(containerName: string, originalError?: Error) {
const dockerImage = TEST_CONTAINER_IMAGES.n8n;
const message = `Failed to start container ${containerName}: Docker image '${dockerImage}' not found locally!
This is likely because the image is not available locally.
To fix this, you can either:
1. Build the image by running: pnpm build:docker at the root
2. Use a different image by setting: TEST_IMAGE_N8N=<image-tag>
Example with different image:
TEST_IMAGE_N8N=n8nio/n8n:latest npm run stack`;
super(message);
this.name = 'DockerImageNotFoundError';
this.cause = originalError;
}
}
@@ -0,0 +1,9 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir kent flask-cors
EXPOSE 8000
COPY kent_cors.py /app/kent_cors.py
CMD ["python", "/app/kent_cors.py"]
@@ -0,0 +1,9 @@
"""Kent server wrapper with CORS support for browser-based testing."""
from flask_cors import CORS
from kent.app import create_app
app = create_app()
CORS(app) # Enable CORS for all origins
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
@@ -0,0 +1,19 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig, {
rules: {
'@typescript-eslint/naming-convention': [
'error',
// Add exception for Docker Compose labels
{
selector: 'objectLiteralProperty',
format: null, // Allow any format
filter: {
regex: '^com\\.docker\\.',
match: true,
},
},
],
},
});
+473
View File
@@ -0,0 +1,473 @@
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;
}
}
@@ -0,0 +1,153 @@
#!/usr/bin/env tsx
import { writeFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { createHelmStack, type HelmStackMode } from './helm-stack';
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
const log = {
info: (msg: string) => console.log(`${colors.blue}${colors.reset} ${msg}`),
success: (msg: string) => console.log(`${colors.green}${colors.reset} ${msg}`),
error: (msg: string) => console.error(`${colors.red}${colors.reset} ${msg}`),
header: (msg: string) => console.log(`\n${colors.bright}${colors.cyan}${msg}${colors.reset}\n`),
};
function showHelp() {
console.log(`
${colors.bright}n8n Helm Stack${colors.reset}
Start n8n via Helm chart in a K3s (lightweight Kubernetes) container.
${colors.yellow}Usage:${colors.reset}
pnpm stack:helm [options]
${colors.yellow}Options:${colors.reset}
--mode <mode> standalone (SQLite, default) or queue (PostgreSQL + Redis + workers)
--image <image> n8n Docker image (default: n8nio/n8n:local)
--chart-ref <ref> Git branch/tag for n8n-hosting repo (default: main)
--chart-repo <url> Git repo URL (default: https://github.com/n8n-io/n8n-hosting.git)
--k3s-image <image> K3s image (default: rancher/k3s:v1.32.2-k3s1)
--env <KEY=VALUE> Set environment variable in n8n pods (repeatable)
--url-file <path> Write URL to file when ready (for CI)
--help, -h Show this help
${colors.yellow}Examples:${colors.reset}
${colors.bright}# Start with default local image${colors.reset}
pnpm stack:helm
${colors.bright}# Test specific n8n version against specific chart${colors.reset}
pnpm stack:helm --image n8nio/n8n:1.80.0 --chart-ref v1.2.0
${colors.bright}# Queue mode (PostgreSQL + Redis + workers)${colors.reset}
pnpm stack:helm --mode queue --image n8nio/n8n:latest
${colors.bright}# E2E test mode (requires INCLUDE_TEST_CONTROLLER image)${colors.reset}
pnpm stack:helm --env E2E_TESTS=true --env NODE_ENV=development
${colors.bright}# CI mode (writes URL to file)${colors.reset}
pnpm stack:helm --url-file /tmp/n8n-url.txt &
${colors.yellow}Prerequisites:${colors.reset}
• Docker with privileged container support
• helm and kubectl CLIs installed locally
• n8n Docker image built locally (pnpm build:docker) or available on Docker Hub
• See HELM-TESTING.md for full requirements
${colors.yellow}Notes:${colors.reset}
• helm and kubectl run on your host (not inside K3s)
• Startup takes ~60-120s (K3s boot + image load + Helm install)
• After startup, use kubectl with KUBECONFIG printed below
• Press Ctrl+C to stop
`);
}
async function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
help: { type: 'boolean', short: 'h' },
mode: { type: 'string' },
image: { type: 'string' },
'chart-ref': { type: 'string' },
'chart-repo': { type: 'string' },
'k3s-image': { type: 'string' },
env: { type: 'string', multiple: true },
'url-file': { type: 'string' },
'kubeconfig-file': { type: 'string' },
},
allowPositionals: false,
});
if (values.help) {
showHelp();
process.exit(0);
}
log.header('Starting n8n Helm Stack');
const mode = (values.mode as HelmStackMode) || undefined;
// Parse --env KEY=VALUE pairs into a record
const envOverrides: Record<string, string> = {};
for (const entry of values.env ?? []) {
const eqIndex = entry.indexOf('=');
if (eqIndex === -1) {
log.error(`Invalid --env format: "${entry}" (expected KEY=VALUE)`);
process.exit(1);
}
envOverrides[entry.slice(0, eqIndex)] = entry.slice(eqIndex + 1);
}
const stack = await createHelmStack({
n8nImage: values.image,
helmChartRef: values['chart-ref'],
helmChartRepo: values['chart-repo'],
k3sImage: values['k3s-image'],
mode,
env: Object.keys(envOverrides).length > 0 ? envOverrides : undefined,
});
log.header('Stack Ready');
log.success(`n8n URL: ${colors.bright}${colors.green}${stack.baseUrl}${colors.reset}`);
log.info(`Kubeconfig: ${colors.bright}${stack.kubeConfigPath}${colors.reset}`);
if (values['url-file']) {
writeFileSync(values['url-file'], stack.baseUrl);
log.info(`URL written to ${values['url-file']}`);
}
if (values['kubeconfig-file']) {
writeFileSync(values['kubeconfig-file'], stack.kubeConfigPath);
log.info(`Kubeconfig path written to ${values['kubeconfig-file']}`);
}
console.log('');
log.info('Debug with kubectl (context already active):');
log.info(` ${colors.bright}kubectl get pods${colors.reset}`);
log.info(` ${colors.bright}kubectl logs -l app.kubernetes.io/name=n8n${colors.reset}`);
console.log('');
log.info(`Cleanup: ${colors.bright}pnpm --filter n8n-containers stack:helm:clean${colors.reset}`);
console.log('');
if (envOverrides.E2E_TESTS === 'true') {
log.info('Run tests against this instance:');
log.info(
` ${colors.bright}N8N_BASE_URL=${stack.baseUrl} RESET_E2E_DB=true npx playwright test tests/e2e/building-blocks/ --workers=1${colors.reset}`,
);
}
// Container stays alive in Docker (Ryuk disabled) — clean up via stack:helm:clean.
}
main().catch((error) => {
log.error(`Failed to start: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
@@ -0,0 +1,80 @@
import { setTimeout as wait } from 'node:timers/promises';
import type { Readable } from 'stream';
import type { StartedTestContainer } from 'testcontainers';
/**
* Create a logger that prefixes messages with elapsed time since creation.
* Only outputs when CONTAINER_TELEMETRY_VERBOSE=1 is set.
*/
export function createElapsedLogger(prefix: string) {
const startTime = Date.now();
const isVerbose = process.env.CONTAINER_TELEMETRY_VERBOSE === '1';
return (message: string) => {
if (!isVerbose) return;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`[${prefix} +${elapsed}s] ${message}`);
};
}
/**
* Create a log consumer that does not log to the console.
* Logs are collected in memory and can be output on error.
*/
export function createSilentLogConsumer() {
const logs: string[] = [];
const consumer = (stream: Readable) => {
stream.on('data', (chunk: Buffer | string) => {
logs.push(chunk.toString().trim());
});
};
const throwWithLogs = (error: unknown): never => {
if (logs.length > 0) {
console.error('\n--- Container Logs ---');
console.error(logs.join('\n'));
console.error('---------------------\n');
}
throw error;
};
return { consumer, throwWithLogs };
}
/**
* Polls a container's HTTP endpoint until it returns a 200 status.
* Logs a warning if the endpoint does not return 200 within the specified timeout.
*
* @param container The started container.
* @param endpoint The HTTP health check endpoint (e.g., '/healthz/readiness').
* @param timeoutMs Total timeout in milliseconds (default: 60,000ms).
*/
export async function pollContainerHttpEndpoint(
container: StartedTestContainer,
endpoint: string,
timeoutMs: number = 60000,
): Promise<void> {
const startTime = Date.now();
const url = `http://${container.getHost()}:${container.getFirstMappedPort()}${endpoint}`;
const retryIntervalMs = 1000;
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(url);
if (response.status === 200) {
return;
}
} catch {
// Don't log errors, just retry
}
await wait(retryIntervalMs);
}
console.error(
`WARNING: HTTP endpoint at ${url} did not return 200 within ${
timeoutMs / 1000
} seconds. Proceeding with caution.`,
);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* n8n Test Containers
*
* This package provides container management utilities for n8n testing.
* Services are accessed via n8nContainer.services.* in tests.
*/
// Stack orchestration - primary public API
export { createN8NStack } from './stack';
export type { N8NConfig, N8NStack } from './stack';
// K3s + Helm chart stack - for Kubernetes deployment validation
export { createHelmStack } from './helm-stack';
export type { HelmStack, HelmStackConfig, HelmStackMode } from './helm-stack';
// Service-only stack (no n8n containers) - for integration tests
export { createServiceStack } from './service-stack';
export type { StackTelemetryRecord } from './telemetry';
// Performance plans (CLI-only)
export * from './performance-plans';
// Types used externally by tests
export { type LogEntry, type MetricsHelper } from './services/observability';
export { type GiteaHelper } from './services/gitea';
export { KafkaHelper } from './services/kafka';
@@ -0,0 +1,19 @@
import type { ImagePullPolicy } from 'testcontainers';
import { PullPolicy } from 'testcontainers';
/**
* Custom pull policy for n8n images:
* - Never try to pull the local image
* - Otherwise, use the default pull policy (pull only if not present)
*/
export class N8nImagePullPolicy implements ImagePullPolicy {
constructor(private readonly image: string) {}
shouldPull(): boolean {
if (this.image === 'n8nio/n8n:local') {
return false;
}
return PullPolicy.defaultPolicy().shouldPull();
}
}
@@ -0,0 +1,534 @@
#!/usr/bin/env tsx
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
import { DockerImageNotFoundError } from './docker-image-not-found-error';
import { BASE_PERFORMANCE_PLANS, isValidPerformancePlan } from './performance-plans';
import { createServiceStack } from './service-stack';
import type { CloudflaredResult } from './services/cloudflared';
import type { KentResult } from './services/kent';
import type { KeycloakResult } from './services/keycloak';
import type { MailpitResult } from './services/mailpit';
import type { NgrokResult } from './services/ngrok';
import { services as SERVICE_REGISTRY } from './services/registry';
import type { TracingResult } from './services/tracing';
import type { ServiceName } from './services/types';
import type { VictoriaLogsResult } from './services/victoria-logs';
import type { VictoriaMetricsResult } from './services/victoria-metrics';
import type { N8NConfig, N8NStack } from './stack';
import { createN8NStack } from './stack';
import { TEST_CONTAINER_IMAGES } from './test-containers';
// ANSI colors for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
const log = {
info: (msg: string) => console.log(`${colors.blue}${colors.reset} ${msg}`),
success: (msg: string) => console.log(`${colors.green}${colors.reset} ${msg}`),
error: (msg: string) => console.error(`${colors.red}${colors.reset} ${msg}`),
warn: (msg: string) => console.warn(`${colors.yellow}${colors.reset} ${msg}`),
header: (msg: string) => console.log(`\n${colors.bright}${colors.cyan}${msg}${colors.reset}\n`),
};
function showHelp() {
console.log(`
${colors.bright}n8n Stack Manager${colors.reset}
Start n8n containers for development and testing.
${colors.yellow}Usage:${colors.reset}
npm run stack [options]
${colors.yellow}Options:${colors.reset}
--services-only Start services only (no n8n containers), write .env for local dev
--services <list> Comma-separated services (e.g. postgres,redis,mailpit,proxy,kafka)
--postgres Use PostgreSQL instead of SQLite
--queue Enable queue mode (requires PostgreSQL)
--source-control Enable source control (Git) container for testing
--oidc Enable OIDC testing with Keycloak (requires PostgreSQL)
--observability Enable observability stack (VictoriaLogs + VictoriaMetrics + Vector)
--tracing Enable tracing stack (n8n-tracer + Jaeger) for workflow visualization
--kafka Enable Kafka broker for message queue trigger testing
--tunnel Enable Cloudflare Tunnel for public URL (via trycloudflare.com)
--ngrok Enable ngrok tunnel for public URL (requires NGROK_AUTHTOKEN env var)
--mailpit Enable Mailpit for email testing
--kent Enable Kent (Sentry mock) for error tracking testing
--mains <n> Number of main instances (default: 1)
--workers <n> Number of worker instances (default: 1)
--name <name> Project name for parallel runs
--env KEY=VALUE Set environment variables
--plan <plan> Use performance plan preset (${Object.keys(BASE_PERFORMANCE_PLANS).join(', ')})
--help, -h Show this help
${colors.yellow}Performance Plans:${colors.reset}
${Object.entries(BASE_PERFORMANCE_PLANS)
.map(
([name, plan]) =>
` ${name.padEnd(12)} ${plan.memory}GB RAM, ${plan.cpu} CPU cores - SQLite only`,
)
.join('\n')}
${colors.yellow}Environment Variables:${colors.reset}
• TEST_IMAGE_N8N=<image> Use a custom Docker image (default: n8nio/n8n:local)
${colors.yellow}Examples:${colors.reset}
${colors.bright}# Simple SQLite instance${colors.reset}
npm run stack
${colors.bright}# PostgreSQL database${colors.reset}
npm run stack --postgres
${colors.bright}# Queue mode (automatically uses PostgreSQL)${colors.reset}
npm run stack --queue
${colors.bright}# With source control (Git) testing${colors.reset}
npm run stack --postgres --source-control
${colors.bright}# With OIDC (Keycloak) for SSO testing${colors.reset}
npm run stack --postgres --oidc
${colors.bright}# With observability stack (logs + metrics persist even after terminal closes)${colors.reset}
npm run stack --observability
${colors.bright}# With tracing stack (Jaeger UI for workflow execution visualization)${colors.reset}
npm run stack --queue --tracing
${colors.bright}# With public tunnel (webhooks accessible from internet)${colors.reset}
npm run stack --tunnel
${colors.bright}# Custom scaling${colors.reset}
npm run stack --queue --mains 3 --workers 5
${colors.bright}# With environment variables${colors.reset}
npm run stack --postgres --env N8N_LOG_LEVEL=info --env N8N_ENABLED_MODULES=insights
${colors.bright}# Performance plan presets${colors.reset}
${Object.keys(BASE_PERFORMANCE_PLANS)
.map((name) => ` npm run stack --plan ${name}`)
.join('\n')}
${colors.bright}# Services only (local dev — writes .env for pnpm start)${colors.reset}
pnpm services --services postgres
pnpm services --services postgres,redis
pnpm services --services postgres,mailpit,proxy
${colors.bright}# Parallel instances${colors.reset}
npm run stack --name test-1
npm run stack --name test-2
${colors.yellow}Notes:${colors.reset}
• SQLite is the default database (no external dependencies)
• Task runner is always enabled (mirrors production)
• Queue mode requires PostgreSQL and enables horizontal scaling
• Use --name for running multiple instances in parallel
• Performance plans simulate cloud constraints (SQLite only, resource-limited)
• Press Ctrl+C to stop all containers
`);
}
async function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
help: { type: 'boolean', short: 'h' },
'services-only': { type: 'boolean' },
postgres: { type: 'boolean' },
queue: { type: 'boolean' },
services: { type: 'string' },
'source-control': { type: 'boolean' },
oidc: { type: 'boolean' },
observability: { type: 'boolean' },
tracing: { type: 'boolean' },
kafka: { type: 'boolean' },
tunnel: { type: 'boolean' },
ngrok: { type: 'boolean' },
mailpit: { type: 'boolean' },
kent: { type: 'boolean' },
mains: { type: 'string' },
workers: { type: 'string' },
name: { type: 'string' },
env: { type: 'string', multiple: true },
plan: { type: 'string' },
},
allowPositionals: false,
});
// Show help if requested
if (values.help) {
showHelp();
process.exit(0);
}
const servicesOnly = values['services-only'] ?? false;
// Build services array from CLI flags
const validServiceNames = new Set(Object.keys(SERVICE_REGISTRY));
const services: ServiceName[] = [];
if (values.services) {
for (const name of values.services.split(',').map((s) => s.trim())) {
if (!validServiceNames.has(name)) {
log.error(`Unknown service: '${name}'. Available: ${[...validServiceNames].join(', ')}`);
process.exit(1);
}
services.push(name as ServiceName);
}
}
if (values['source-control']) services.push('gitea');
if (values.oidc) services.push('keycloak');
if (values.observability) services.push('victoriaLogs', 'victoriaMetrics', 'vector');
if (values.tracing) services.push('tracing');
if (values.kafka) services.push('kafka');
if (values.tunnel) services.push('cloudflared');
if (values.ngrok) services.push('ngrok');
if (values.mailpit) services.push('mailpit');
if (values.kent) services.push('kent');
// Build configuration
const config: N8NConfig = {
postgres: values.postgres ?? false,
services,
projectName:
values.name ??
(servicesOnly
? `n8n-svc-${Math.random().toString(36).substring(7)}`
: `n8n-stack-${Math.random().toString(36).substring(7)}`),
};
// Handle queue mode (mains > 1 or workers > 0)
if (values.queue ?? values.mains ?? values.workers) {
const mains = parseInt(values.mains ?? '1', 10);
const workers = parseInt(values.workers ?? '1', 10);
if (isNaN(mains) || isNaN(workers) || mains < 1 || workers < 0) {
log.error('Invalid mains or workers count');
process.exit(1);
}
config.mains = mains;
config.workers = workers;
}
if (values.plan) {
const planName = values.plan;
if (!isValidPerformancePlan(planName)) {
log.error(`Invalid performance plan: ${values.plan}`);
log.error(`Available plans: ${Object.keys(BASE_PERFORMANCE_PLANS).join(', ')}`);
process.exit(1);
}
const plan = BASE_PERFORMANCE_PLANS[planName];
if (values.postgres) {
log.warn('Performance plans use SQLite only. PostgreSQL option ignored.');
}
if (values.queue || values.mains || values.workers) {
log.warn('Performance plans use SQLite only. Queue mode ignored.');
}
config.resourceQuota = plan;
config.postgres = false; // Force SQLite for performance plans
config.mains = 1; // Force single instance for performance plans
config.workers = 0;
log.info(
`Using ${planName} performance plan: ${plan.memory}GB RAM, ${plan.cpu} CPU cores (SQLite only)`,
);
}
// Parse environment variables
if (values.env && values.env.length > 0) {
config.env = {};
for (const envStr of values.env) {
const [key, ...valueParts] = envStr.split('=');
const value = valueParts.join('='); // Handle values with = in them
if (key && value) {
config.env[key] = value;
} else {
log.warn(`Invalid env format: ${envStr} (expected KEY=VALUE)`);
}
}
}
// Services-only mode: start containers, write .env, no n8n
if (servicesOnly) {
if (services.length === 0) {
log.error('No services specified. Use flags like --postgres, --redis, --mailpit, etc.');
process.exit(1);
}
log.header('Starting service containers');
log.info(`Project: ${config.projectName}`);
log.info(`Services: ${services.join(', ')}`);
try {
const stack = await createServiceStack({
services,
projectName: config.projectName,
});
// Collect host-compatible env vars from each service
const envVars: Record<string, string> = {};
for (const name of services) {
const result = stack.serviceResults[name];
if (!result) continue;
const service = SERVICE_REGISTRY[name];
Object.assign(
envVars,
service.env?.(result, true) ?? {},
service.extraEnv?.(result, true) ?? {},
);
}
// Write .env to packages/cli/bin/ because `pnpm start` runs os-normalize.mjs
// which does `cd packages/cli/bin` before launching n8n, and dotenv loads from cwd.
if (Object.keys(envVars).length > 0) {
const repoRoot = resolve(__dirname, '../../..');
const envPath = resolve(repoRoot, 'packages/cli/bin/.env');
const lines = [
'# Generated by pnpm services — do not edit',
`# Project: ${stack.projectName}`,
'# Stop with: pnpm --filter n8n-containers services:clean',
'',
...Object.entries(envVars).map(([key, value]) => `${key}=${value}`),
'',
];
writeFileSync(envPath, lines.join('\n'));
log.success(`Wrote ${Object.keys(envVars).length} env vars to packages/cli/bin/.env`);
}
// Print summary
log.header('Services running');
for (const name of services) {
const result = stack.serviceResults[name];
if (!result) continue;
const service = SERVICE_REGISTRY[name];
const vars = {
...(service.env?.(result, true) ?? {}),
...(service.extraEnv?.(result, true) ?? {}),
};
const varSummary = Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
log.success(`${name}${varSummary ? `: ${varSummary}` : ''}`);
}
// Print mailpit UI URL if running
const mailpitResult = stack.serviceResults.mailpit as MailpitResult | undefined;
if (mailpitResult) {
console.log('');
log.info(`Mailpit UI: ${colors.cyan}${mailpitResult.meta.apiBaseUrl}${colors.reset}`);
}
console.log('');
log.info('Containers are running in the background');
log.info(`Run ${colors.bright}pnpm dev${colors.reset} in another terminal to start n8n`);
log.info(
`Cleanup: ${colors.bright}pnpm --filter n8n-containers services:clean${colors.reset}`,
);
console.log('');
} catch (error) {
log.error(
`Failed to start services: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
return;
}
log.header('Starting n8n Stack');
log.info(`Project name: ${config.projectName}`);
displayConfig(config);
let stack: N8NStack;
try {
try {
stack = await createN8NStack(config);
} catch (error) {
if (error instanceof DockerImageNotFoundError) {
log.error(error.message);
process.exit(1);
}
throw error;
}
console.log('');
log.info(`n8n URL: ${colors.bright}${colors.green}${stack.baseUrl}${colors.reset}`);
// Display OIDC configuration if enabled
const keycloakResult = stack.serviceResults.keycloak as KeycloakResult | undefined;
if (keycloakResult) {
const { meta } = keycloakResult;
console.log('');
log.header('OIDC Configuration (Keycloak)');
log.info(`Discovery URL: ${colors.cyan}${meta.discoveryUrl}${colors.reset}`);
log.info(`Client ID: ${colors.cyan}${meta.clientId}${colors.reset}`);
log.info(`Client Secret: ${colors.cyan}${meta.clientSecret}${colors.reset}`);
console.log('');
log.header('Test User Credentials');
log.info(`Email: ${colors.cyan}${meta.testUser.email}${colors.reset}`);
log.info(`Password: ${colors.cyan}${meta.testUser.password}${colors.reset}`);
}
// Display observability configuration if enabled
const logsResult = stack.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
const metricsResult = stack.serviceResults.victoriaMetrics as VictoriaMetricsResult | undefined;
if (logsResult || metricsResult) {
console.log('');
log.header('Observability Stack (VictoriaObs)');
if (logsResult) {
log.info(
`VictoriaLogs UI: ${colors.cyan}${logsResult.meta.queryEndpoint}/select/vmui${colors.reset}`,
);
}
if (metricsResult) {
log.info(
`VictoriaMetrics UI: ${colors.cyan}${metricsResult.meta.queryEndpoint}/vmui${colors.reset}`,
);
}
// Vector is always started when observability is enabled in the new stack
log.success('Container logs collected by Vector (runs in background)');
}
const tracingResult = stack.serviceResults.tracing as TracingResult | undefined;
if (tracingResult) {
console.log('');
log.header('Tracing Stack (n8n-tracer + Jaeger)');
log.info(`Jaeger UI: ${colors.cyan}${tracingResult.meta.jaeger.uiUrl}${colors.reset}`);
}
const cloudflaredResult = stack.serviceResults.cloudflared as CloudflaredResult | undefined;
if (cloudflaredResult) {
console.log('');
log.header('Cloudflare Tunnel');
log.info(`Public URL: ${colors.cyan}${cloudflaredResult.meta.publicUrl}${colors.reset}`);
log.info('Webhooks are accessible from the internet via this URL');
}
const ngrokResult = stack.serviceResults.ngrok as NgrokResult | undefined;
if (ngrokResult) {
console.log('');
log.header('ngrok Tunnel');
log.info(`Public URL: ${colors.cyan}${ngrokResult.meta.publicUrl}${colors.reset}`);
log.info('Webhooks are accessible from the internet via this URL');
}
const mailpitResult = stack.serviceResults.mailpit as MailpitResult | undefined;
if (mailpitResult) {
console.log('');
log.header('Email Testing (Mailpit)');
log.info(`Mailpit UI: ${colors.cyan}${mailpitResult.meta.apiBaseUrl}${colors.reset}`);
}
const kentResult = stack.serviceResults.kent as KentResult | undefined;
if (kentResult) {
console.log('');
log.header('Sentry Mock (Kent)');
log.info(`Kent UI: ${colors.cyan}${kentResult.meta.apiUrl}${colors.reset}`);
log.info(`Backend DSN: ${colors.cyan}${kentResult.meta.sentryDsn}${colors.reset}`);
log.info(`Frontend DSN: ${colors.cyan}${kentResult.meta.frontendDsn}${colors.reset}`);
}
console.log('');
log.info('Containers are running in the background');
log.info(
'Cleanup with: pnpm --filter n8n-containers stack:clean:all (stops containers and removes networks)',
);
console.log('');
} catch (error) {
log.error(`Failed to start: ${error as string}`);
process.exit(1);
}
}
function displayConfig(config: N8NConfig) {
const dockerImage = TEST_CONTAINER_IMAGES.n8n;
const mains = config.mains ?? 1;
const workers = config.workers ?? 0;
const isQueueMode = mains > 1 || workers > 0;
const services = config.services ?? [];
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const usePostgres = config.postgres || isQueueMode || services.includes('keycloak');
let modeStr: string;
if (isQueueMode) {
const parts = [`${mains}M/${workers}W`, usePostgres ? 'PostgreSQL' : 'SQLite'];
if (mains > 1) parts.push('load-balanced');
modeStr = parts.join(', ');
} else {
modeStr = usePostgres ? 'single, PostgreSQL' : 'single, SQLite';
}
log.info(`Image: ${dockerImage}`);
log.info(`Mode: ${modeStr}`);
const enabledFeatures: string[] = [];
if (services.includes('gitea')) enabledFeatures.push('Source Control (Gitea)');
if (services.includes('keycloak')) enabledFeatures.push('OIDC (Keycloak)');
if (services.includes('victoriaLogs')) enabledFeatures.push('Observability');
if (services.includes('tracing')) enabledFeatures.push('Tracing (Jaeger)');
if (services.includes('mailpit')) enabledFeatures.push('Email (Mailpit)');
if (services.includes('kent')) enabledFeatures.push('Sentry Mock (Kent)');
if (enabledFeatures.length > 0) {
log.info(`Services: ${enabledFeatures.join(', ')}`);
}
// Display observability status
if (services.includes('victoriaLogs')) {
log.info('Observability: enabled (VictoriaLogs + VictoriaMetrics + Vector)');
} else {
log.info('Observability: disabled');
}
// Display tracing status
if (services.includes('tracing')) {
log.info('Tracing: enabled (n8n-tracer + Jaeger)');
} else {
log.info('Tracing: disabled');
}
// Display tunnel status
if (services.includes('cloudflared')) {
log.info('Tunnel: enabled (Cloudflare Quick Tunnel)');
} else if (services.includes('ngrok')) {
log.info('Tunnel: enabled (ngrok)');
} else {
log.info('Tunnel: disabled');
}
if (config.resourceQuota) {
log.info(`Resources: ${config.resourceQuota.memory}GB RAM, ${config.resourceQuota.cpu} CPU`);
}
if (config.env && Object.keys(config.env).length > 0) {
log.info(`Custom env: ${Object.keys(config.env).join(', ')}`);
}
}
// Run if executed directly
if (require.main === module) {
// Keep the event loop alive while main() runs. Without this, the process
// can exit between async Docker API calls (e.g. after exposeHostPorts
// resolves but before Network.start() creates new I/O handles).
const keepAlive = setInterval(() => {}, 30_000);
main()
.catch((error) => {
log.error(`Unexpected error: ${error}`);
process.exit(1);
})
.finally(() => clearInterval(keepAlive));
}
@@ -0,0 +1,83 @@
import { spawn } from 'child_process';
import os from 'os';
/**
* Wait for the Linux network stack to become quiet (no netlink events).
* This monitors actual kernel network events rather than guessing with fixed delays.
*
* Only runs in CI on Linux. On other platforms or local dev, resolves immediately.
*
* @param quietDurationMs - How long the network must be quiet before resolving (default: 2000ms)
* @param maxWaitMs - Maximum time to wait before giving up (default: 10000ms)
*/
export async function waitForNetworkQuiet(
quietDurationMs = 1000,
maxWaitMs = 10000,
): Promise<void> {
// Only run in CI on Linux
if (!process.env.CI || os.platform() !== 'linux') {
return;
}
return await new Promise((resolve) => {
let lastEventTime = Date.now();
let checkInterval: NodeJS.Timeout | null = null;
let maxTimeout: NodeJS.Timeout | null = null;
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
if (checkInterval) clearInterval(checkInterval);
if (maxTimeout) clearTimeout(maxTimeout);
monitor.kill();
};
// Monitor network events using `ip monitor`
// Watches: link (interfaces), address (IP assignments), route (routing table)
const monitor = spawn('ip', ['monitor', 'link', 'address', 'route'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
monitor.on('error', (error: Error) => {
// ip command not available - fall back to no-op
console.warn(`[network-stabilization] ip monitor not available: ${error.message}`);
cleanup();
resolve();
});
monitor.stdout.on('data', () => {
// Network change detected, reset timer
lastEventTime = Date.now();
});
monitor.stderr.on('data', (data) => {
console.warn(`[network-stabilization] ip monitor stderr: ${data}`);
});
// Check periodically if network has been quiet long enough
checkInterval = setInterval(() => {
const quietDuration = Date.now() - lastEventTime;
if (quietDuration >= quietDurationMs) {
console.log(`[network-stabilization] Network quiet for ${quietDuration}ms, proceeding`);
cleanup();
resolve();
}
}, 100);
// Maximum wait timeout
maxTimeout = setTimeout(() => {
console.warn(`[network-stabilization] Max wait (${maxWaitMs}ms) exceeded, proceeding anyway`);
cleanup();
resolve();
}, maxWaitMs);
// Handle process exit
monitor.on('close', () => {
if (!resolved) {
cleanup();
resolve();
}
});
});
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "n8n-containers",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"stack": "tsx ./n8n-start-stack.ts",
"stack:help": "tsx ./n8n-start-stack.ts --help",
"stack:sqlite": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack",
"stack:postgres": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --postgres",
"stack:queue": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --queue",
"stack:multi-main": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --mains 2 --workers 1",
"stack:starter": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --plan starter",
"stack:enterprise": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --plan enterprise",
"stack:observability": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --observability",
"stack:kafka": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --kafka",
"stack:clean:containers": "docker ps -aq --filter 'name=n8n-stack-*' | xargs -r docker rm -f 2>/dev/null",
"stack:clean:networks": "docker network ls --filter 'label=org.testcontainers=true' -q | xargs -r docker network rm 2>/dev/null",
"stack:clean:all": "pnpm run stack:clean:containers && pnpm run stack:clean:networks",
"stack:helm": "TESTCONTAINERS_RYUK_DISABLED=true tsx ./helm-start-stack.ts",
"stack:helm:clean": "docker ps -aq --filter 'label=n8n.helm=true' | xargs -r docker rm -f 2>/dev/null; rm -f ~/.kube/n8n-helm-*.yaml; kubectl config delete-context n8n-helm-standalone 2>/dev/null; kubectl config delete-context n8n-helm-queue 2>/dev/null; true",
"services": "tsx ./n8n-start-stack.ts --services-only",
"services:clean": "docker ps -aq --filter 'name=n8n-svc-*' | xargs docker rm -f 2>/dev/null; rm -f ../../../packages/cli/bin/.env",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@aws-sdk/client-secrets-manager": "3.808.0",
"@testcontainers/k3s": "^11.11.0",
"@testcontainers/kafka": "^11.11.0",
"@testcontainers/mysql": "^11.11.0",
"@testcontainers/postgresql": "^11.0.3",
"@testcontainers/redis": "^11.0.3",
"get-port": "^7.1.0",
"mockserver-client": "^5.15.0",
"kafkajs": "catalog:",
"testcontainers": "^11.11.0"
}
}
@@ -0,0 +1,28 @@
/**
* Shared Performance Plan Types and Configurations
*
* This file provides the base performance plan definitions that can be used by:
* - CLI tools (n8n-start-stack.ts)
* - Playwright tests (cloud-only.ts)
*
*/
// Base performance plan configuration (resource constraints only)
export interface BasePerformancePlan {
memory: number; // in GB
cpu: number; // in cores
}
export const BASE_PERFORMANCE_PLANS: Record<string, BasePerformancePlan> = {
trial: { memory: 0.75, cpu: 1 }, // 768MB RAM, 1000 millicore CPU
starter: { memory: 0.75, cpu: 1 }, // 768MB RAM, 1000 millicore CPU
pro1: { memory: 1.25, cpu: 1 }, // 1.25GB RAM, 1000 millicore CPU
pro2: { memory: 2.5, cpu: 1.5 }, // 2.5GB RAM, 1500 millicore CPU
enterprise: { memory: 8.0, cpu: 2.0 }, // 8GB RAM, 2.0 CPU core
} as const;
export type PerformancePlanName = keyof typeof BASE_PERFORMANCE_PLANS;
export function isValidPerformancePlan(name: string): name is PerformancePlanName {
return name in BASE_PERFORMANCE_PLANS;
}
@@ -0,0 +1,113 @@
#!/usr/bin/env tsx
/**
* Pre-pull test container images in parallel.
*
* Usage:
* npx tsx pull-test-images.ts # Pull all images
* npx tsx pull-test-images.ts postgres redis mailpit # Pull specific images
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { TEST_CONTAINER_IMAGES } from './test-containers';
const execAsync = promisify(exec);
type ImageKey = keyof typeof TEST_CONTAINER_IMAGES;
interface PullResult {
image: string;
duration: string;
success: boolean;
cached: number;
pulled: number;
}
async function pullImage(image: string): Promise<PullResult> {
const imageStart = Date.now();
try {
const { stdout, stderr } = await execAsync(`docker pull ${image}`);
const output = stdout + stderr;
// Count cached vs pulled layers
const cached = (output.match(/Already exists/g) ?? []).length;
const pulled = (output.match(/Pull complete/g) ?? []).length;
const duration = ((Date.now() - imageStart) / 1000).toFixed(1);
return { image, duration, success: true, cached, pulled };
} catch (error) {
const duration = ((Date.now() - imageStart) / 1000).toFixed(1);
const message = error instanceof Error ? error.message : String(error);
console.error(` ⚠️ Pull failed for ${image}: ${message}`);
return { image, duration, success: false, cached: 0, pulled: 0 };
}
}
function isValidImageKey(key: string): key is ImageKey {
return key in TEST_CONTAINER_IMAGES;
}
function getRequestedImages(args: string[]): string[] {
if (args.length === 0) {
return Object.values(TEST_CONTAINER_IMAGES);
}
const invalid = args.filter((arg) => !isValidImageKey(arg));
if (invalid.length > 0) {
console.error(`❌ Unknown image(s): ${invalid.join(', ')}`);
console.error(` Valid: ${Object.keys(TEST_CONTAINER_IMAGES).join(', ')}`);
process.exit(1);
}
return args.filter(isValidImageKey).map((key) => TEST_CONTAINER_IMAGES[key]);
}
async function main() {
const args = process.argv.slice(2);
const images = getRequestedImages(args);
const mode = args.length > 0 ? `${args.length} specified` : 'all';
console.log(`🐳 Pre-pulling test container images (${mode})...`);
const startTime = Date.now();
// Filter out local images and start all pulls in parallel
const imagesToPull = images.filter((image) => {
if (image.endsWith(':local')) {
console.log(`⏭️ Skipping ${image} (local build)`);
return false;
}
return true;
});
for (const image of imagesToPull) {
console.log(`🔄 Starting pull: ${image}`);
}
const pullPromises = imagesToPull.map(pullImage);
const results = await Promise.all(pullPromises);
// Check for failures
const failures = results.filter((r) => !r.success);
if (failures.length > 0 && process.env.STRICT_IMAGE_PULL === 'true') {
console.error(`❌ Failed to pull ${failures.length} image(s)`);
process.exit(1);
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
const totalCached = results.reduce((sum, r) => sum + r.cached, 0);
const totalPulled = results.reduce((sum, r) => sum + r.pulled, 0);
console.log('\n' + '='.repeat(60));
console.log('📊 Pull Summary:');
results.forEach(({ image, duration, success, cached, pulled }) => {
const layers = cached + pulled > 0 ? ` (${cached} cached, ${pulled} pulled)` : '';
console.log(` ${success ? '✅' : '❌'} ${image}: ${duration}s${layers}`);
});
console.log('='.repeat(60));
console.log(`📦 Layers: ${totalCached} cached, ${totalPulled} pulled`);
console.log(`✅ Total time: ${totalTime}s (parallel)`);
}
void main();
@@ -0,0 +1,31 @@
import type { ServiceName } from './services/types';
import { createN8NStack, type N8NStack } from './stack';
export interface ServiceStackOptions {
services: ServiceName[];
projectName?: string;
}
/**
* Creates a stack with only services (no n8n containers).
* Useful for integration tests that need databases/services but not full n8n.
*
* @example
* const stack = await createServiceStack({ services: ['postgres'] });
* const pgContainer = stack.serviceResults.postgres?.container;
* const host = pgContainer.getHost();
* const port = pgContainer.getMappedPort(5432);
* await stack.stop();
*/
export async function createServiceStack(options: ServiceStackOptions): Promise<N8NStack> {
const { services, projectName } = options;
return await createN8NStack({
mains: 0,
workers: 0,
postgres: services.includes('postgres'),
services,
projectName,
external: true,
});
}
@@ -0,0 +1,95 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import { EXTERNAL_HOST, type Service, type ServiceResult, type StartContext } from './types';
export interface CloudflaredMeta {
publicUrl: string;
proxyHops: number;
}
export type CloudflaredResult = ServiceResult<CloudflaredMeta>;
const METRICS_PORT = 2000;
function getTunnelTarget(ctx: StartContext): string {
if (ctx.external) {
return `${EXTERNAL_HOST}:5678`;
}
if (ctx.needsLoadBalancer) {
return `${ctx.projectName}-caddy-lb:80`;
}
return `${ctx.projectName}-n8n:5678`;
}
export const cloudflared: Service<CloudflaredResult> = {
description: 'Cloudflare Tunnel',
dependsOn: ['loadBalancer'],
shouldStart: (ctx) => ctx.config.services?.includes('cloudflared') ?? false,
getOptions(ctx) {
const proxyHops = ctx.needsLoadBalancer ? 2 : 1;
return { tunnelTarget: getTunnelTarget(ctx), proxyHops };
},
env(result) {
return {
WEBHOOK_URL: result.meta.publicUrl,
N8N_PROXY_HOPS: String(result.meta.proxyHops),
};
},
async start(
network,
projectName,
config?: unknown,
ctx?: StartContext,
): Promise<CloudflaredResult> {
const { tunnelTarget, proxyHops } = config as { tunnelTarget: string; proxyHops: number };
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
let builder = new GenericContainer(TEST_CONTAINER_IMAGES.cloudflared)
.withNetwork(network)
.withNetworkAliases('cloudflared')
.withName(`${projectName}-cloudflared`)
.withExposedPorts(METRICS_PORT)
.withCommand([
'tunnel',
'--url',
`http://${tunnelTarget}`,
'--metrics',
`0.0.0.0:${METRICS_PORT}`,
'--no-autoupdate',
])
.withWaitStrategy(Wait.forHttp('/quicktunnel', METRICS_PORT).forStatusCode(200))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'cloudflared',
})
.withReuse()
.withLogConsumer(consumer);
// On Linux, host.docker.internal is not available without explicit mapping
if (ctx?.external) {
builder = builder.withExtraHosts([{ host: EXTERNAL_HOST, ipAddress: 'host-gateway' }]);
}
const container = await builder.start();
const hostPort = container.getMappedPort(METRICS_PORT);
const response = await fetch(`http://${container.getHost()}:${hostPort}/quicktunnel`);
const data = (await response.json()) as { hostname: string };
const publicUrl = `https://${data.hostname}`;
return {
container,
meta: { publicUrl, proxyHops },
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,258 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'gitea';
const HTTP_PORT = 3000;
const SSH_PORT = 22;
const DEFAULT_ADMIN = 'giteaadmin';
const DEFAULT_PASSWORD = 'giteapassword';
const DEFAULT_EMAIL = 'admin@example.com';
const DEFAULT_REPO = 'n8n-test-repo';
const DEFAULT_BRANCHES = ['development', 'staging', 'production'];
export interface GiteaMeta {
apiUrl: string;
adminUsername: string;
adminPassword: string;
defaultRepo: string;
}
export type GiteaResult = ServiceResult<GiteaMeta>;
export const gitea: Service<GiteaResult> = {
description: 'Git server (Gitea)',
async start(network: StartedNetwork, projectName: string): Promise<GiteaResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.gitea)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(HTTP_PORT, SSH_PORT)
.withEnvironment({
GITEA__database__DB_TYPE: 'sqlite3',
GITEA__server__DOMAIN: HOSTNAME,
GITEA__server__ROOT_URL: `http://${HOSTNAME}:${HTTP_PORT}/`,
GITEA__server__SSH_DOMAIN: HOSTNAME,
GITEA__security__INSTALL_LOCK: 'true',
GITEA__security__SECRET_KEY: 'gitea-test-secret-key',
GITEA__service__DISABLE_REGISTRATION: 'true',
})
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
// Setup admin user and default repo
await addUser(container, DEFAULT_ADMIN, DEFAULT_PASSWORD, DEFAULT_EMAIL, true);
await addRepo(container, DEFAULT_REPO, DEFAULT_ADMIN, DEFAULT_PASSWORD);
// Create default branches
for (const branch of DEFAULT_BRANCHES) {
await addBranch(container, DEFAULT_REPO, branch, DEFAULT_ADMIN, DEFAULT_PASSWORD);
}
return {
container,
meta: {
apiUrl: `http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`,
adminUsername: DEFAULT_ADMIN,
adminPassword: DEFAULT_PASSWORD,
defaultRepo: DEFAULT_REPO,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: GiteaResult, external?: boolean): Record<string, string> {
return {
N8N_SOURCECONTROL_HOST: external ? result.meta.apiUrl : `http://${HOSTNAME}:${HTTP_PORT}`,
};
},
};
async function addUser(
container: StartedTestContainer,
username: string,
password: string,
email: string,
admin = false,
): Promise<void> {
const adminFlag = admin ? '--admin' : '';
await container.exec([
'bash',
'-c',
`cd /data/gitea && su git -c "/usr/local/bin/gitea admin user create --config /data/gitea/conf/app.ini --username ${username} --password ${password} --email ${email} ${adminFlag} --must-change-password=false"`,
]);
}
async function addRepo(
container: StartedTestContainer,
repoName: string,
username: string,
password: string,
): Promise<void> {
await container.exec([
'curl',
'-X',
'POST',
`http://localhost:${HTTP_PORT}/api/v1/user/repos`,
'-H',
'Content-Type: application/json',
'-u',
`${username}:${password}`,
'-d',
`{"name":"${repoName}","private":false,"auto_init":true}`,
]);
}
async function addBranch(
container: StartedTestContainer,
repoName: string,
branchName: string,
username: string,
password: string,
fromBranch = 'main',
): Promise<void> {
await container.exec([
'curl',
'-X',
'POST',
`http://localhost:${HTTP_PORT}/api/v1/repos/${username}/${repoName}/branches`,
'-H',
'Content-Type: application/json',
'-u',
`${username}:${password}`,
'-d',
`{"new_branch_name":"${branchName}","old_branch_name":"${fromBranch}"}`,
]);
}
export class GiteaHelper {
private readonly container: StartedTestContainer;
private readonly meta: GiteaMeta;
constructor(container: StartedTestContainer, meta: GiteaMeta) {
this.container = container;
this.meta = meta;
}
get apiUrl(): string {
return this.meta.apiUrl;
}
get adminUsername(): string {
return this.meta.adminUsername;
}
get adminPassword(): string {
return this.meta.adminPassword;
}
get defaultRepo(): string {
return this.meta.defaultRepo;
}
async createUser(
username: string,
password: string,
email: string,
admin = false,
): Promise<void> {
await addUser(this.container, username, password, email, admin);
}
async createRepo(repoName: string, username?: string, password?: string): Promise<void> {
await addRepo(
this.container,
repoName,
username ?? this.meta.adminUsername,
password ?? this.meta.adminPassword,
);
}
async createBranch(
repoName: string,
branchName: string,
username?: string,
password?: string,
fromBranch = 'main',
): Promise<void> {
await addBranch(
this.container,
repoName,
branchName,
username ?? this.meta.adminUsername,
password ?? this.meta.adminPassword,
fromBranch,
);
}
async addSSHKey(
keyTitle: string,
publicKey: string,
username?: string,
password?: string,
): Promise<void> {
await this.container.exec([
'curl',
'-X',
'POST',
`http://localhost:${HTTP_PORT}/api/v1/user/keys`,
'-H',
'Content-Type: application/json',
'-u',
`${username ?? this.meta.adminUsername}:${password ?? this.meta.adminPassword}`,
'-d',
`{"title":"${keyTitle}","key":"${publicKey}","read_only":false}`,
]);
}
async commitExists(
repoName: string,
commitHash: string,
username?: string,
password?: string,
): Promise<boolean> {
const result = await this.container.exec([
'curl',
'-s',
'-o',
'/dev/null',
'-w',
'%{http_code}',
`http://localhost:${HTTP_PORT}/api/v1/repos/${username ?? this.meta.adminUsername}/${repoName}/git/commits/${commitHash}`,
'-u',
`${username ?? this.meta.adminUsername}:${password ?? this.meta.adminPassword}`,
]);
// curl writes HTTP status code to stdout, 200 means commit exists
const statusCode = result.output.trim();
return statusCode === '200';
}
}
export function createGiteaHelper(ctx: HelperContext): GiteaHelper {
const result = ctx.serviceResults.gitea as GiteaResult | undefined;
if (!result) {
throw new Error('Gitea service not found in context');
}
return new GiteaHelper(result.container, result.meta);
}
declare module './types' {
interface ServiceHelpers {
gitea: GiteaHelper;
}
}
@@ -0,0 +1,187 @@
import { KafkaContainer, type StartedKafkaContainer } from '@testcontainers/kafka';
import { Kafka, type Producer, type EachMessagePayload } from 'kafkajs';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'kafka';
export interface KafkaMeta {
internalBroker: string;
externalBroker: string;
}
export type KafkaResult = ServiceResult<KafkaMeta> & {
container: StartedKafkaContainer;
};
export const kafka: Service<KafkaResult> = {
description: 'Apache Kafka broker for message queue testing',
async start(network: StartedNetwork, projectName: string): Promise<KafkaResult> {
const container = await new KafkaContainer(TEST_CONTAINER_IMAGES.kafka)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withKraft()
.withReuse()
.start();
return {
container,
meta: {
internalBroker: `${HOSTNAME}:9092`,
externalBroker: `${container.getHost()}:${container.getMappedPort(9093)}`,
},
};
},
env(result: KafkaResult, external?: boolean): Record<string, string> {
if (!external) return {};
return {
KAFKA_BROKER: result.meta.externalBroker,
};
},
};
export class KafkaHelper {
private readonly kafka: Kafka;
private producer: Producer | null = null;
constructor(broker: string) {
this.kafka = new Kafka({
clientId: 'n8n-test-helper',
brokers: [broker],
});
}
async createTopic(topic: string, numPartitions = 1): Promise<void> {
const admin = this.kafka.admin();
try {
await admin.connect();
await admin.createTopics({
topics: [{ topic, numPartitions }],
});
} finally {
await admin.disconnect();
}
}
async waitForConsumerGroup(
groupId: string,
options: { timeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<void> {
const { timeoutMs = 10000, pollIntervalMs = 500 } = options;
const admin = this.kafka.admin();
const deadline = Date.now() + timeoutMs;
try {
await admin.connect();
while (Date.now() < deadline) {
const groups = await admin.describeGroups([groupId]);
const group = groups.groups[0];
if (group && group.state === 'Stable' && group.members.length > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Consumer group '${groupId}' did not become active within ${timeoutMs}ms`);
} finally {
await admin.disconnect();
}
}
async publish(topic: string, message: string | object, key?: string): Promise<void> {
if (!this.producer) {
this.producer = this.kafka.producer();
await this.producer.connect();
}
const value = typeof message === 'string' ? message : JSON.stringify(message);
await this.producer.send({
topic,
messages: [{ key, value }],
});
}
async consume(
topic: string,
options: {
groupId?: string;
maxMessages?: number;
timeoutMs?: number;
fromBeginning?: boolean;
} = {},
): Promise<Array<{ key: string | null; value: string; partition: number; offset: string }>> {
const {
groupId = `test-consumer-${Date.now()}`,
maxMessages = 10,
timeoutMs = 5000,
fromBeginning = true,
} = options;
const consumer = this.kafka.consumer({ groupId });
const messages: Array<{
key: string | null;
value: string;
partition: number;
offset: string;
}> = [];
try {
await consumer.connect();
await consumer.subscribe({ topic, fromBeginning });
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs);
void consumer.run({
// kafkajs requires async handler signature, but we don't need to await anything
// eslint-disable-next-line @typescript-eslint/require-await
eachMessage: async ({ message, partition }: EachMessagePayload) => {
messages.push({
key: message.key?.toString() ?? null,
value: message.value?.toString() ?? '',
partition,
offset: message.offset,
});
if (messages.length >= maxMessages) {
clearTimeout(timeout);
resolve();
}
},
});
});
} finally {
await consumer.disconnect();
}
return messages;
}
}
export function createKafkaHelper(ctx: HelperContext): KafkaHelper {
const result = ctx.serviceResults.kafka as KafkaResult | undefined;
if (!result) {
throw new Error('Kafka service not found in context');
}
return new KafkaHelper(result.meta.externalBroker);
}
declare module './types' {
interface ServiceHelpers {
kafka: KafkaHelper;
}
}
@@ -0,0 +1,176 @@
/**
* Kent - Sentry's mock server for testing SDK integrations
* @see https://github.com/getsentry/kent
*/
import { resolve } from 'node:path';
import { GenericContainer, Wait } from 'testcontainers';
import type { StartedNetwork } from 'testcontainers';
import type { HelperContext, Service, ServiceResult, ServiceMeta } from './types';
const HOSTNAME = 'kent';
const PORT = 8000;
const DOCKERFILE_PATH = resolve(__dirname, '../dockerfiles/kent');
export interface KentMeta extends ServiceMeta {
host: string;
port: number;
apiUrl: string;
sentryDsn: string;
frontendDsn: string;
}
export type KentResult = ServiceResult<KentMeta>;
export const kent: Service<KentResult> = {
description: 'Sentry mock server for testing',
async start(network: StartedNetwork, projectName: string): Promise<KentResult> {
const container = await GenericContainer.fromDockerfile(DOCKERFILE_PATH)
.build('n8n-kent:local', { deleteOnExit: false })
.then(
async (image) =>
await image
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(PORT)
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start(),
);
const mappedPort = container.getMappedPort(PORT);
const host = container.getHost();
return {
container,
meta: {
host: HOSTNAME,
port: PORT,
apiUrl: `http://${host}:${mappedPort}`,
sentryDsn: `http://testkey@${HOSTNAME}:${PORT}/1`,
frontendDsn: `http://testkey@${host}:${mappedPort}/1`,
},
};
},
env(result: KentResult): Record<string, string> {
return {
N8N_SENTRY_DSN: result.meta.sentryDsn,
N8N_FRONTEND_SENTRY_DSN: result.meta.frontendDsn,
N8N_SENTRY_TRACES_SAMPLE_RATE: '1.0',
ENVIRONMENT: 'test',
DEPLOYMENT_NAME: 'e2e-test-deployment',
};
},
};
// ==================== Types ====================
export type EventSource = 'backend' | 'frontend' | 'task_runner' | 'unknown';
export type EventType = 'error' | 'transaction' | 'session' | 'unknown';
export interface KentEventFilter {
source?: EventSource;
type?: EventType;
messageContains?: string;
}
export interface KentEvent {
event_id: string;
project_id: number;
payload: {
body: {
sdk?: { name: string; version: string };
platform?: string;
type?: string;
transaction?: string;
tags?: Record<string, string>;
user?: { id?: string; email?: string; username?: string; ip_address?: string };
exception?: { values: Array<{ type: string; value: string }> };
spans?: unknown[];
[key: string]: unknown;
};
};
}
// ==================== Helper ====================
export class KentHelper {
constructor(private readonly apiUrl: string) {}
async clear(): Promise<void> {
const res = await fetch(`${this.apiUrl}/api/flush/`, { method: 'POST' });
if (!res.ok) throw new Error(`Kent API error: ${res.status}`);
}
async getEvents(filter?: KentEventFilter): Promise<KentEvent[]> {
const res = await fetch(`${this.apiUrl}/api/eventlist/`);
if (!res.ok) throw new Error(`Kent API error: ${res.status}`);
const { events } = (await res.json()) as { events: Array<{ event_id: string }> };
const allEvents = await Promise.all(events.map(async (e) => await this.getEvent(e.event_id)));
if (!filter) return allEvents;
return allEvents.filter((event) => {
if (filter.source && this.getSource(event) !== filter.source) return false;
if (filter.type && this.getType(event) !== filter.type) return false;
if (filter.messageContains && !this.getErrorMessage(event).includes(filter.messageContains))
return false;
return true;
});
}
getSource(event: KentEvent): EventSource {
const sdk = event.payload.body.sdk?.name ?? '';
if (sdk.includes('vue') || sdk.includes('browser')) return 'frontend';
if (sdk === 'sentry.javascript.node' || event.payload.body.platform === 'node') {
return event.payload.body.tags?.server_type === 'task_runner' ? 'task_runner' : 'backend';
}
if ('sid' in event.payload.body) return 'frontend';
return 'unknown';
}
getType(event: KentEvent): EventType {
const body = event.payload.body;
if ('sid' in body) return 'session';
if (body.exception) return 'error';
if (body.type === 'transaction' || Array.isArray(body.spans)) return 'transaction';
return 'unknown';
}
getErrorMessage(event: KentEvent): string {
return event.payload.body.exception?.values?.[0]?.value ?? '';
}
getTags(event: KentEvent): Record<string, string> | undefined {
return event.payload.body.tags;
}
getUser(event: KentEvent): KentEvent['payload']['body']['user'] {
return event.payload.body.user;
}
private async getEvent(eventId: string): Promise<KentEvent> {
const res = await fetch(`${this.apiUrl}/api/event/${eventId}`);
if (!res.ok) throw new Error(`Kent API error: ${res.status}`);
return (await res.json()) as KentEvent;
}
}
export function createKentHelper(ctx: HelperContext): KentHelper {
const result = ctx.serviceResults.kent as KentResult | undefined;
if (!result) throw new Error('Kent service not found. Add "kent" to your services array.');
return new KentHelper(result.meta.apiUrl);
}
declare module './types' {
interface ServiceHelpers {
kent: KentHelper;
}
}
@@ -0,0 +1,540 @@
import getPort from 'get-port';
import { setTimeout as wait } from 'node:timers/promises';
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { Agent, request as undiciRequest } from 'undici';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { FileToMount, HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'keycloak';
const HTTPS_PORT = 8443;
const KEYCLOAK_TEST_REALM = 'test';
const KEYCLOAK_TEST_CLIENT_ID = 'n8n-e2e';
const KEYCLOAK_TEST_CLIENT_SECRET = 'n8n-test-secret';
const KEYCLOAK_TEST_USER_EMAIL = 'test@n8n.io';
const KEYCLOAK_TEST_USER_PASSWORD = 'testpassword';
const KEYCLOAK_TEST_USER_FIRSTNAME = 'Test';
const KEYCLOAK_TEST_USER_LASTNAME = 'User';
const KEYCLOAK_ADMIN_USER = 'admin';
const KEYCLOAK_ADMIN_PASSWORD = 'admin';
const KEYCLOAK_CERT_PATH = '/tmp/keycloak-ca.pem';
const N8N_KEYCLOAK_CERT_PATH = '/tmp/keycloak-ca.pem';
export interface KeycloakConfig {
n8nCallbackUrl: string;
}
export interface KeycloakMeta {
discoveryUrl: string;
internalDiscoveryUrl: string;
certPem: string;
hostPort: number;
clientId: string;
clientSecret: string;
testUser: {
email: string;
password: string;
firstName: string;
lastName: string;
};
n8nFilesToMount: FileToMount[];
}
export type KeycloakResult = ServiceResult<KeycloakMeta>;
function generateRealmJson(callbackUrl: string): string {
// Derive the n8n base URL from the OIDC callback URL
const n8nBaseUrl = callbackUrl.split('/rest/')[0];
return JSON.stringify({
realm: KEYCLOAK_TEST_REALM,
enabled: true,
sslRequired: 'none',
registrationAllowed: false,
loginWithEmailAllowed: true,
duplicateEmailsAllowed: false,
resetPasswordAllowed: false,
editUsernameAllowed: false,
bruteForceProtected: false,
clients: [
{
clientId: KEYCLOAK_TEST_CLIENT_ID,
enabled: true,
clientAuthenticatorType: 'client-secret',
secret: KEYCLOAK_TEST_CLIENT_SECRET,
redirectUris: [
callbackUrl,
`${callbackUrl}/*`,
// Allow the n8n OAuth2 credential callback for dynamic credential authorization flow
`${n8nBaseUrl}/rest/oauth2-credential/callback`,
],
webOrigins: ['*'],
standardFlowEnabled: true,
directAccessGrantsEnabled: true,
publicClient: false,
protocol: 'openid-connect',
},
],
users: [
{
username: 'testuser',
enabled: true,
email: KEYCLOAK_TEST_USER_EMAIL,
emailVerified: true,
firstName: KEYCLOAK_TEST_USER_FIRSTNAME,
lastName: KEYCLOAK_TEST_USER_LASTNAME,
credentials: [
{
type: 'password',
value: KEYCLOAK_TEST_USER_PASSWORD,
temporary: false,
},
],
},
],
});
}
/**
* Generates a shell script that creates a keystore with self-signed cert using Java keytool,
* exports the certificate to PEM format, and starts Keycloak with HTTPS.
*/
function generateStartupScript(): string {
return `#!/bin/bash
set -e
# Generate self-signed certificate using Java keytool (available in Keycloak image)
keytool -genkeypair \\
-storepass password \\
-storetype PKCS12 \\
-keyalg RSA \\
-keysize 2048 \\
-dname "CN=localhost" \\
-alias server \\
-ext "SAN=DNS:localhost,DNS:keycloak,IP:127.0.0.1" \\
-keystore /opt/keycloak/conf/server.keystore
# Export the certificate to PEM format for Node.js NODE_EXTRA_CA_CERTS
keytool -exportcert \\
-alias server \\
-keystore /opt/keycloak/conf/server.keystore \\
-rfc \\
-file ${KEYCLOAK_CERT_PATH} \\
-storepass password
exec /opt/keycloak/bin/kc.sh start-dev \\
--import-realm \\
--https-key-store-file=/opt/keycloak/conf/server.keystore \\
--https-key-store-password=password \\
--hostname=https://localhost:\${KEYCLOAK_HOST_PORT} \\
--hostname-backchannel-dynamic=true
`;
}
async function extractCertificate(
container: StartedTestContainer,
timeoutMs: number = 30000,
): Promise<string> {
const startTime = Date.now();
const retryIntervalMs = 500;
while (Date.now() - startTime < timeoutMs) {
try {
const certResult = await container.exec(['cat', KEYCLOAK_CERT_PATH]);
if (certResult.exitCode === 0 && certResult.output.includes('BEGIN CERTIFICATE')) {
return certResult.output;
}
} catch {
// Retry on error
}
await wait(retryIntervalMs);
}
throw new Error(
`Failed to extract Keycloak certificate from ${KEYCLOAK_CERT_PATH} within ${timeoutMs}ms`,
);
}
async function waitForKeycloakReady(
port: number,
certPem: string,
timeoutMs: number = 60000,
): Promise<void> {
const startTime = Date.now();
const url = `https://localhost:${port}/realms/${KEYCLOAK_TEST_REALM}/.well-known/openid-configuration`;
const retryIntervalMs = 2000;
const agent = new Agent({
connect: { ca: certPem },
});
try {
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(url, {
// @ts-expect-error - dispatcher is an undici-specific option
dispatcher: agent,
});
if (response.ok) {
return;
}
} catch {
// Retry on connection errors
}
await wait(retryIntervalMs);
}
throw new Error(
`Keycloak discovery endpoint at ${url} did not become ready within ${timeoutMs / 1000} seconds`,
);
} finally {
await agent.close();
}
}
export const keycloak: Service<KeycloakResult> = {
description: 'Keycloak OIDC provider',
getOptions(ctx) {
const port = ctx.allocatedPorts.loadBalancer ?? ctx.allocatedPorts.main;
return { n8nCallbackUrl: `http://localhost:${port}/rest/sso/oidc/callback` } as KeycloakConfig;
},
async verifyFromN8n(result, n8nContainers) {
const { setTimeout: wait } = await import('node:timers/promises');
const timeoutMs = 30000;
const retryIntervalMs = 1000;
for (const container of n8nContainers) {
const startTime = Date.now();
let verified = false;
while (Date.now() - startTime < timeoutMs) {
try {
const execResult = await container.exec([
'wget',
'--no-check-certificate',
'-q',
'-O',
'-',
result.meta.internalDiscoveryUrl,
]);
if (execResult.exitCode === 0) {
verified = true;
break;
}
} catch {
// Retry
}
await wait(retryIntervalMs);
}
if (!verified) {
throw new Error(
`Keycloak verification failed: ${container.getName()} could not reach ${result.meta.internalDiscoveryUrl} within ${timeoutMs}ms`,
);
}
}
},
async start(
network: StartedNetwork,
projectName: string,
config?: unknown,
): Promise<KeycloakResult> {
const { n8nCallbackUrl } = config as KeycloakConfig;
const { consumer, throwWithLogs } = createSilentLogConsumer();
// Allocate a fixed host port for Keycloak
const allocatedHostPort = await getPort();
const realmJson = generateRealmJson(n8nCallbackUrl);
const startupScript = generateStartupScript();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.keycloak)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts({ container: HTTPS_PORT, host: allocatedHostPort })
.withEnvironment({
KEYCLOAK_ADMIN: KEYCLOAK_ADMIN_USER,
KEYCLOAK_ADMIN_PASSWORD,
KC_HEALTH_ENABLED: 'true',
KC_METRICS_ENABLED: 'false',
KEYCLOAK_HOST_PORT: String(allocatedHostPort),
})
.withCopyContentToContainer([
{ content: realmJson, target: '/opt/keycloak/data/import/realm.json' },
{ content: startupScript, target: '/startup.sh', mode: 0o755 },
])
.withEntrypoint(['/bin/bash', '/startup.sh'])
.withWaitStrategy(
Wait.forLogMessage(/Running the server in development mode/).withStartupTimeout(120000),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withLogConsumer(consumer)
.withReuse()
.start();
const discoveryUrl = `https://localhost:${allocatedHostPort}/realms/${KEYCLOAK_TEST_REALM}/.well-known/openid-configuration`;
const internalDiscoveryUrl = `https://${HOSTNAME}:${HTTPS_PORT}/realms/${KEYCLOAK_TEST_REALM}/.well-known/openid-configuration`;
const certPem = await extractCertificate(container);
await waitForKeycloakReady(allocatedHostPort, certPem);
return {
container,
meta: {
discoveryUrl,
internalDiscoveryUrl,
certPem,
hostPort: allocatedHostPort,
clientId: KEYCLOAK_TEST_CLIENT_ID,
clientSecret: KEYCLOAK_TEST_CLIENT_SECRET,
testUser: {
email: KEYCLOAK_TEST_USER_EMAIL,
password: KEYCLOAK_TEST_USER_PASSWORD,
firstName: KEYCLOAK_TEST_USER_FIRSTNAME,
lastName: KEYCLOAK_TEST_USER_LASTNAME,
},
n8nFilesToMount: [{ content: certPem, target: N8N_KEYCLOAK_CERT_PATH }],
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: KeycloakResult, external?: boolean): Record<string, string> {
if (external) {
return {
N8N_OIDC_DISCOVERY_URL: result.meta.discoveryUrl,
N8N_OIDC_CLIENT_ID: result.meta.clientId,
N8N_OIDC_CLIENT_SECRET: result.meta.clientSecret,
};
}
return {
NODE_EXTRA_CA_CERTS: N8N_KEYCLOAK_CERT_PATH,
NO_PROXY: `localhost,127.0.0.1,${HOSTNAME},host.docker.internal`,
};
},
};
export class KeycloakHelper {
private readonly meta: KeycloakMeta;
constructor(_container: StartedTestContainer, meta: KeycloakMeta) {
this.meta = meta;
}
get discoveryUrl(): string {
return this.meta.discoveryUrl;
}
get internalDiscoveryUrl(): string {
return this.meta.internalDiscoveryUrl;
}
get certPem(): string {
return this.meta.certPem;
}
get hostPort(): number {
return this.meta.hostPort;
}
get realm(): string {
return KEYCLOAK_TEST_REALM;
}
get clientId(): string {
return this.meta.clientId;
}
get clientSecret(): string {
return this.meta.clientSecret;
}
get testUser() {
return this.meta.testUser;
}
/**
* Obtain an access token for a user via the Resource Owner Password Credentials (ROPC) grant.
* Keycloak's test realm has directAccessGrantsEnabled=true, so no browser redirect is needed.
*/
async getAccessToken(email: string, password: string): Promise<string> {
const tokenEndpoint = `https://localhost:${this.meta.hostPort}/realms/${KEYCLOAK_TEST_REALM}/protocol/openid-connect/token`;
const agent = new Agent({ connect: { ca: this.meta.certPem } });
const body = new URLSearchParams({
grant_type: 'password',
client_id: KEYCLOAK_TEST_CLIENT_ID,
client_secret: KEYCLOAK_TEST_CLIENT_SECRET,
username: email,
password,
scope: 'openid',
});
try {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
// @ts-expect-error - dispatcher is an undici-specific option
dispatcher: agent,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Keycloak token request failed (${response.status}): ${text}`);
}
const data = (await response.json()) as { access_token: string };
return data.access_token;
} finally {
await agent.close();
}
}
/**
* Programmatically completes the OAuth2 authorization code flow for the test user.
* Uses undici (with the Keycloak CA cert) to:
* 1. GET the Keycloak authorization page
* 2. Extract the login form action URL
* 3. POST test user credentials to Keycloak
*
* Returns the n8n OAuth2 callback URL (with `code` and `state` query params).
* The caller should then GET this URL using the n8n API request context (which holds
* the n8n session cookie) so that n8n exchanges the code for tokens and stores them.
*/
async completeAuthorizationCodeFlow(authorizationUrl: string): Promise<string> {
const agent = new Agent({ connect: { ca: this.meta.certPem } });
try {
// Step 1: GET the Keycloak authorization page (HTML with login form).
// Use undiciRequest to access raw set-cookie headers for forwarding.
const authPageResult = await undiciRequest(authorizationUrl, {
method: 'GET',
dispatcher: agent,
});
if (authPageResult.statusCode < 200 || authPageResult.statusCode >= 300) {
await authPageResult.body.text();
throw new Error(
`Failed to load Keycloak authorization page: HTTP ${authPageResult.statusCode}`,
);
}
// Extract session cookies from the response to forward with the login POST.
// Keycloak sets cookies like AUTH_SESSION_ID, KC_RESTART that are required
// for the login form submission to succeed.
const rawCookies = authPageResult.headers['set-cookie'];
const cookieHeader = (Array.isArray(rawCookies) ? rawCookies : [rawCookies])
.filter(Boolean)
.map((c) => (c as string).split(';')[0])
.join('; ');
const html = await authPageResult.body.text();
// Step 2: Extract the Keycloak login form action URL.
// Keycloak's login form action always contains 'login-actions/authenticate'.
const rawFormAction = html.match(/action="([^"]*login-actions\/authenticate[^"]*)"/)?.[1];
if (!rawFormAction) {
throw new Error('Could not find Keycloak login form action in authorization page HTML');
}
const formAction = rawFormAction.replace(/&amp;/g, '&');
// Step 3: POST credentials with session cookies — Keycloak responds with 302
// to the n8n callback URL. undiciRequest does NOT follow redirects by default.
const loginBody = new URLSearchParams({
username: this.meta.testUser.email,
password: this.meta.testUser.password,
});
const loginHeaders: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
};
if (cookieHeader) {
loginHeaders['Cookie'] = cookieHeader;
}
const { headers, body } = await undiciRequest(formAction, {
method: 'POST',
headers: loginHeaders,
body: loginBody.toString(),
dispatcher: agent,
});
// Consume the body to prevent resource leaks
await body.text();
const location = headers.location;
const redirectUrl = Array.isArray(location) ? location[0] : location;
if (!redirectUrl) {
throw new Error(
'Keycloak did not redirect after login. ' +
'Ensure the OAuth2 credential callback URL is registered in Keycloak redirectUris.',
);
}
return redirectUrl; // e.g. http://localhost:{n8n_port}/rest/oauth2-credential/callback?code=...&state=...
} finally {
await agent.close();
}
}
async waitForFromContainer(
n8nContainer: StartedTestContainer,
timeoutMs: number = 30000,
): Promise<void> {
const startTime = Date.now();
const retryIntervalMs = 1000;
while (Date.now() - startTime < timeoutMs) {
try {
const result = await n8nContainer.exec([
'wget',
'--no-check-certificate',
'-q',
'-O',
'-',
this.meta.internalDiscoveryUrl,
]);
if (result.exitCode === 0) {
return;
}
} catch {
// Retry on error
}
await wait(retryIntervalMs);
}
throw new Error(
`Keycloak discovery endpoint not reachable from n8n container within ${timeoutMs}ms: ${this.meta.internalDiscoveryUrl}`,
);
}
}
export function createKeycloakHelper(ctx: HelperContext): KeycloakHelper {
const result = ctx.serviceResults.keycloak as KeycloakResult | undefined;
if (!result) {
throw new Error('Keycloak service not found in context');
}
return new KeycloakHelper(result.container, result.meta);
}
declare module './types' {
interface ServiceHelpers {
keycloak: KeycloakHelper;
}
}
@@ -0,0 +1,105 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
export interface LoadBalancerConfig {
mainCount: number;
hostPort?: number;
}
export interface LoadBalancerMeta {
hostPort: number;
baseUrl: string;
}
export type LoadBalancerResult = ServiceResult<LoadBalancerMeta>;
function buildCaddyConfig(upstreamServers: string[]): string {
const backends = upstreamServers.join(' ');
return `
:80 {
# Reverse proxy with load balancing
reverse_proxy ${backends} {
# Use first available backend for simpler debugging
lb_policy first
# Health check
health_uri /healthz
health_interval 10s
# Timeouts
transport http {
dial_timeout 60s
read_timeout 60s
write_timeout 60s
}
}
# Set max request body size
request_body {
max_size 50MB
}
}`;
}
export const loadBalancer: Service<LoadBalancerResult> = {
description: 'Caddy load balancer',
shouldStart: (ctx) => ctx.needsLoadBalancer,
getOptions(ctx) {
return {
mainCount: ctx.mains,
hostPort: ctx.allocatedPorts.loadBalancer,
} as LoadBalancerConfig;
},
env(result) {
return {
WEBHOOK_URL: result.meta.baseUrl,
N8N_PROXY_HOPS: '1',
};
},
async start(network, projectName, config?: unknown): Promise<LoadBalancerResult> {
const { mainCount, hostPort } = config as LoadBalancerConfig;
const { consumer, throwWithLogs } = createSilentLogConsumer();
// Generate upstream server addresses
const upstreamServers = Array.from(
{ length: mainCount },
(_, index) => `${projectName}-n8n-main-${index + 1}:5678`,
);
const caddyConfig = buildCaddyConfig(upstreamServers);
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.caddy)
.withNetwork(network)
.withExposedPorts(hostPort ? { container: 80, host: hostPort } : 80)
.withCopyContentToContainer([{ content: caddyConfig, target: '/etc/caddy/Caddyfile' }])
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'caddy-lb',
})
.withName(`${projectName}-caddy-lb`)
.withReuse()
.withLogConsumer(consumer)
.start();
const actualHostPort = container.getMappedPort(80);
return {
container,
meta: {
hostPort: actualHostPort,
baseUrl: `http://localhost:${actualHostPort}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,257 @@
import {
CreateSecretCommand,
DeleteSecretCommand,
GetSecretValueCommand,
ListSecretsCommand,
SecretsManagerClient as AwsSecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'localstack';
const EDGE_PORT = 4566;
const DEFAULT_REGION = 'us-east-1';
export interface LocalStackMeta {
endpoint: string;
internalEndpoint: string;
}
export type LocalStackResult = ServiceResult<LocalStackMeta>;
interface LocalStackHealthResponse {
services?: Record<string, string>;
}
export const localstack: Service<LocalStackResult> = {
description: 'AWS service emulator (LocalStack)',
async start(network: StartedNetwork, projectName: string): Promise<LocalStackResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.localstack)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(EDGE_PORT)
.withEnvironment({
SERVICES: 'secretsmanager',
DEFAULT_REGION,
// Disable LocalStack Pro features we don't need
SKIP_SSL_CERT_DOWNLOAD: '1',
})
.withWaitStrategy(
Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/_localstack/health', EDGE_PORT)
.forStatusCode(200)
.forResponsePredicate((body: string) => {
try {
const health = JSON.parse(body) as LocalStackHealthResponse;
// LocalStack returns 'available' for ready services
return health.services?.secretsmanager === 'available';
} catch {
return false;
}
})
.withStartupTimeout(120000),
]),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
const hostPort = container.getMappedPort(EDGE_PORT);
return {
container,
meta: {
endpoint: `http://${container.getHost()}:${hostPort}`,
internalEndpoint: `http://${HOSTNAME}:${EDGE_PORT}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: LocalStackResult, external?: boolean): Record<string, string> {
return {
AWS_ENDPOINT_URL: external ? result.meta.endpoint : result.meta.internalEndpoint,
AWS_ACCESS_KEY_ID: 'test',
AWS_SECRET_ACCESS_KEY: 'test',
AWS_DEFAULT_REGION: DEFAULT_REGION,
};
},
};
/**
* Client for interacting with AWS Secrets Manager via LocalStack.
* Uses the official AWS SDK for proper API compatibility.
*/
export class SecretsManagerClient {
private readonly client: AwsSecretsManagerClient;
constructor(endpoint: string) {
this.client = new AwsSecretsManagerClient({
endpoint,
region: DEFAULT_REGION,
credentials: {
accessKeyId: 'test',
secretAccessKey: 'test',
},
});
}
/**
* Create a secret.
* @param name - Secret name
* @param value - Secret value (string or object that will be JSON-serialized)
*/
async createSecret(name: string, value: string | Record<string, unknown>): Promise<void> {
const secretString = typeof value === 'string' ? value : JSON.stringify(value);
await this.client.send(
new CreateSecretCommand({
Name: name,
SecretString: secretString,
}),
);
}
/**
* Get a secret value.
* @param name - Secret name
* @returns The secret string value
*/
async getSecret(name: string): Promise<string> {
const response = await this.client.send(
new GetSecretValueCommand({
SecretId: name,
}),
);
if (!response.SecretString) {
throw new Error(`Secret '${name}' has no string value`);
}
return response.SecretString;
}
/**
* Delete a secret.
* @param name - Secret name
* @param forceDelete - If true, deletes immediately without recovery window
*/
async deleteSecret(name: string, forceDelete = true): Promise<void> {
try {
await this.client.send(
new DeleteSecretCommand({
SecretId: name,
ForceDeleteWithoutRecovery: forceDelete,
}),
);
} catch (error) {
// Ignore "not found" errors during cleanup
if (error instanceof Error && error.name !== 'ResourceNotFoundException') {
throw error;
}
}
}
/**
* List all secret names.
* @returns Array of secret names
*/
async listSecrets(): Promise<string[]> {
const names: string[] = [];
let nextToken: string | undefined;
do {
const response = await this.client.send(
new ListSecretsCommand({
NextToken: nextToken,
}),
);
for (const secret of response.SecretList ?? []) {
if (secret.Name) names.push(secret.Name);
}
nextToken = response.NextToken;
} while (nextToken);
return names;
}
/**
* Delete all secrets. Useful for cleanup between tests.
*/
async clear(): Promise<void> {
const secrets = await this.listSecrets();
await Promise.all(secrets.map(async (name) => await this.deleteSecret(name)));
}
/**
* Wait for a secret to exist. Useful for eventual consistency scenarios.
* @param name - Secret name to wait for
* @param options - Timeout and polling options
* @returns The secret value once it exists
*/
async waitForSecret(
name: string,
options: { timeoutMs?: number; pollMs?: number } = {},
): Promise<string> {
const { timeoutMs = 10000, pollMs = 200 } = options;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
return await this.getSecret(name);
} catch {
// Secret doesn't exist yet, keep polling
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`Secret '${name}' not found within ${timeoutMs}ms`);
}
}
/**
* Helper for interacting with LocalStack AWS services in tests.
*
* Access individual services via properties:
* - `localstack.secretsManager` - AWS Secrets Manager operations
*
* Future services can be added as needed (S3, SQS, etc.)
*/
export class LocalStackHelper {
readonly secretsManager: SecretsManagerClient;
constructor(endpoint: string) {
this.secretsManager = new SecretsManagerClient(endpoint);
}
}
export function createLocalStackHelper(ctx: HelperContext): LocalStackHelper {
const result = ctx.serviceResults.localstack as LocalStackResult | undefined;
if (!result) {
throw new Error('LocalStack service not found in context');
}
return new LocalStackHelper(result.meta.endpoint);
}
declare module './types' {
interface ServiceHelpers {
localstack: LocalStackHelper;
}
}
@@ -0,0 +1,230 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'mailpit';
const SMTP_PORT = 1025;
const HTTP_PORT = 8025;
type MailpitAddress = {
Address: string;
Name?: string;
};
export type MailpitMessageSummary = {
ID: string;
MessageID: string;
Read: boolean;
From: MailpitAddress;
To: MailpitAddress[];
Cc: MailpitAddress[] | null;
Bcc: MailpitAddress[] | null;
ReplyTo: MailpitAddress[];
Subject: string;
Created: string;
Username: string;
Tags: string[];
Size: number;
Attachments: number;
Snippet: string;
};
export type MailpitMessage = MailpitMessageSummary & {
Text?: string;
HTML?: string;
Inline?: Array<{
PartID: string;
FileName: string;
ContentType: string;
ContentID: string;
Size: number;
}>;
Attachments?: Array<{
PartID: string;
FileName: string;
ContentType: string;
ContentID: string;
Size: number;
}>;
};
export type MailpitQuery = {
to?: string | RegExp;
subject?: string | RegExp;
};
type MailpitListResponse = {
total: number;
unread: number;
count: number;
messages_count: number;
messages_unread: number;
start: number;
tags: string[];
messages: MailpitMessageSummary[];
};
export interface MailpitMeta {
apiBaseUrl: string;
}
export type MailpitResult = ServiceResult<MailpitMeta>;
export const mailpit: Service<MailpitResult> = {
description: 'Email testing server',
async start(network: StartedNetwork, projectName: string): Promise<MailpitResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.mailpit)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(SMTP_PORT, HTTP_PORT)
.withEnvironment({
MP_UI_BIND_ADDR: `0.0.0.0:${HTTP_PORT}`,
MP_SMTP_BIND_ADDR: `0.0.0.0:${SMTP_PORT}`,
})
.withWaitStrategy(
Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/api/v1/info', HTTP_PORT).forStatusCode(200).withStartupTimeout(30000),
]),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
return {
container,
meta: {
apiBaseUrl: `http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: MailpitResult, external?: boolean): Record<string, string> {
return {
N8N_EMAIL_MODE: 'smtp',
N8N_SMTP_HOST: external ? result.container.getHost() : HOSTNAME,
N8N_SMTP_PORT: external
? String(result.container.getMappedPort(SMTP_PORT))
: String(SMTP_PORT),
N8N_SMTP_SSL: 'false',
N8N_SMTP_SENDER: 'test@n8n.local',
};
},
};
export class MailpitHelper {
private readonly apiBaseUrl: string;
/** SMTP host that n8n should use to send email (internal hostname in container mode, localhost in local mode) */
readonly smtpHost: string;
/** SMTP port that n8n should use to send email (1025 in container mode, mapped port in local mode) */
readonly smtpPort: number;
constructor(apiBaseUrl: string, smtpHost = HOSTNAME, smtpPort = SMTP_PORT) {
this.apiBaseUrl = apiBaseUrl;
this.smtpHost = smtpHost;
this.smtpPort = smtpPort;
}
async clear(): Promise<void> {
const res = await fetch(`${this.apiBaseUrl}/api/v1/messages`, { method: 'DELETE' });
if (!res.ok) {
throw new Error(`Mailpit clear failed: ${res.status} ${res.statusText}`);
}
}
async list(): Promise<MailpitMessageSummary[]> {
const res = await fetch(`${this.apiBaseUrl}/api/v1/messages`);
if (!res.ok) {
throw new Error(`Mailpit list failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as MailpitListResponse;
return data.messages || [];
}
async get(id: string): Promise<MailpitMessage> {
const res = await fetch(`${this.apiBaseUrl}/api/v1/message/${id}`);
if (!res.ok) {
throw new Error(`Mailpit get failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as MailpitMessage;
}
async waitForMessage(
query: MailpitQuery,
options: { timeoutMs?: number; pollMs?: number } = {},
): Promise<MailpitMessageSummary> {
const { timeoutMs = 10000, pollMs = 200 } = options;
const deadline = Date.now() + timeoutMs;
const messageMatches = (message: MailpitMessageSummary): boolean => {
if (query.to) {
const hasMatchingRecipient = message.To.some((recipient) =>
typeof query.to === 'string'
? recipient.Address === query.to
: query.to!.test(recipient.Address),
);
if (!hasMatchingRecipient) return false;
}
if (query.subject) {
const subjectMatches =
typeof query.subject === 'string'
? message.Subject === query.subject
: query.subject.test(message.Subject);
if (!subjectMatches) return false;
}
return true;
};
while (Date.now() < deadline) {
const messages = await this.list();
const match = messages.find(messageMatches);
if (match) {
return match;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
const queryParts = [];
if (query.to) queryParts.push(`to: ${query.to}`);
if (query.subject) queryParts.push(`subject: ${query.subject}`);
throw new Error(`Mail not received within ${timeoutMs}ms. Query: ${queryParts.join(', ')}`);
}
}
export function createMailpitHelper(ctx: HelperContext): MailpitHelper {
const result = ctx.serviceResults.mailpit as MailpitResult | undefined;
if (!result) {
throw new Error('Mailpit service not found in context');
}
return new MailpitHelper(result.meta.apiBaseUrl);
}
declare module './types' {
interface ServiceHelpers {
mailpit: MailpitHelper;
}
}
@@ -0,0 +1,68 @@
import { MySqlContainer, type StartedMySqlContainer } from '@testcontainers/mysql';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'mysql';
export interface MySqlMeta {
database: string;
username: string;
password: string;
port: number;
internalHost: string;
externalHost: string;
externalPort: number;
}
export type MySqlResult = ServiceResult<MySqlMeta> & {
container: StartedMySqlContainer;
};
export const mysqlService: Service<MySqlResult> = {
description: 'MySQL database for integration testing',
async start(network: StartedNetwork, projectName: string): Promise<MySqlResult> {
const container = await new MySqlContainer(TEST_CONTAINER_IMAGES.mysql)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withDatabase('n8n_test')
.withUsername('n8n_user')
.withRootPassword('root_password')
.withUserPassword('test_password')
.withStartupTimeout(60_000)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start();
return {
container,
meta: {
database: container.getDatabase(),
username: container.getUsername(),
password: container.getUserPassword(),
port: 3306,
internalHost: HOSTNAME,
externalHost: container.getHost(),
externalPort: container.getPort(),
},
};
},
env(result: MySqlResult, external?: boolean): Record<string, string> {
if (!external) return {};
return {
DB_TYPE: 'mysqldb',
DB_MYSQLDB_HOST: result.meta.externalHost,
DB_MYSQLDB_PORT: String(result.meta.externalPort),
DB_MYSQLDB_DATABASE: result.meta.database,
DB_MYSQLDB_USER: result.meta.username,
DB_MYSQLDB_PASSWORD: result.meta.password,
};
},
};
+242
View File
@@ -0,0 +1,242 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { DockerImageNotFoundError } from '../docker-image-not-found-error';
import { createElapsedLogger, createSilentLogConsumer } from '../helpers/utils';
import { N8nImagePullPolicy } from '../n8n-image-pull-policy';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { FileToMount } from './types';
const N8N_IMAGE = TEST_CONTAINER_IMAGES.n8n;
const BASE_ENV: Record<string, string> = {
N8N_LOG_LEVEL: 'debug',
N8N_ENCRYPTION_KEY: process.env.N8N_ENCRYPTION_KEY ?? 'test-encryption-key',
E2E_TESTS: 'false',
QUEUE_HEALTH_CHECK_ACTIVE: 'true',
N8N_DIAGNOSTICS_ENABLED: 'false',
N8N_METRICS: 'true',
NODE_ENV: 'development',
N8N_DYNAMIC_BANNERS_ENABLED: 'false',
N8N_LICENSE_TENANT_ID: process.env.N8N_LICENSE_TENANT_ID ?? '1001',
N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '',
N8N_LICENSE_CERT: process.env.N8N_LICENSE_CERT ?? '',
N8N_RUNNERS_MODE: 'external',
N8N_RUNNERS_AUTH_TOKEN: 'test',
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: '0.0.0.0',
// Expose V8 garbage collector for memory profiling in performance tests
NODE_OPTIONS: '--expose-gc',
};
const MAIN_WAIT_STRATEGY = Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/healthz/readiness', 5678).forStatusCode(200).withStartupTimeout(30000),
Wait.forLogMessage('Editor is now accessible via').withStartupTimeout(30000),
]);
const WORKER_WAIT_STRATEGY = Wait.forAll([
Wait.forListeningPorts(),
Wait.forLogMessage('n8n worker is now ready').withStartupTimeout(30000),
]);
export interface N8NInstancesOptions {
mains: number;
workers: number;
projectName: string;
network: StartedNetwork;
serviceEnvironment: Record<string, string>;
userEnvironment?: Record<string, string>;
usePostgres: boolean;
baseUrl?: string;
allocatedPort?: number;
resourceQuota?: { memory?: number; cpu?: number };
filesToMount?: FileToMount[];
}
export interface N8NInstancesResult {
containers: StartedTestContainer[];
environment: Record<string, string>;
}
function computeEnvironment(options: N8NInstancesOptions): Record<string, string> {
const {
mains,
workers,
usePostgres,
baseUrl,
serviceEnvironment,
userEnvironment = {},
} = options;
const isQueueMode = mains > 1 || workers > 0;
const env: Record<string, string> = {
...BASE_ENV,
...serviceEnvironment,
...userEnvironment,
};
if (!usePostgres) {
env.DB_TYPE = 'sqlite';
}
if (isQueueMode) {
env.EXECUTIONS_MODE = 'queue';
env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS = 'true';
if (mains > 1) {
if (!process.env.N8N_LICENSE_ACTIVATION_KEY && !process.env.N8N_LICENSE_CERT) {
throw new Error(
'N8N_LICENSE_ACTIVATION_KEY or N8N_LICENSE_CERT is required for multi-main instances',
);
}
env.N8N_MULTI_MAIN_SETUP_ENABLED = 'true';
}
}
if (mains === 1 && baseUrl && !serviceEnvironment.WEBHOOK_URL) {
env.WEBHOOK_URL = baseUrl;
env.N8N_PORT = '5678';
}
return env;
}
interface InstanceConfig {
name: string;
isWorker: boolean;
instanceNumber: number;
networkAlias?: string;
hostPort?: number;
}
interface SharedConfig {
projectName: string;
environment: Record<string, string>;
network: StartedNetwork;
resourceQuota?: { memory?: number; cpu?: number };
filesToMount?: FileToMount[];
}
async function createContainer(
instance: InstanceConfig,
shared: SharedConfig,
): Promise<StartedTestContainer> {
const { name, isWorker, instanceNumber, networkAlias, hostPort } = instance;
const { projectName, environment, network, resourceQuota, filesToMount } = shared;
const { consumer, throwWithLogs } = createSilentLogConsumer();
let container = new GenericContainer(N8N_IMAGE)
.withEnvironment(environment)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': isWorker ? 'n8n-worker' : 'n8n-main',
instance: instanceNumber.toString(),
})
.withPullPolicy(new N8nImagePullPolicy(N8N_IMAGE))
.withName(name)
.withLogConsumer(consumer)
.withReuse()
.withNetwork(network);
if (filesToMount?.length) {
container = container.withCopyContentToContainer(filesToMount);
}
if (resourceQuota) {
container = container.withResourcesQuota(resourceQuota);
}
if (networkAlias) {
container = container.withNetworkAliases(networkAlias);
}
const waitStrategy = isWorker ? WORKER_WAIT_STRATEGY : MAIN_WAIT_STRATEGY;
const ports = hostPort ? [{ container: 5678, host: hostPort }, 5679] : [5678, 5679];
container = container.withExposedPorts(...ports).withWaitStrategy(waitStrategy);
if (isWorker) {
container = container.withCommand(['worker']);
}
try {
return await container.start();
} catch (error: unknown) {
if (error instanceof Error && 'statusCode' in error) {
const statusCode = (error as Error & { statusCode: number }).statusCode;
if (statusCode === 404) {
throw new DockerImageNotFoundError(name, error);
}
}
console.error(`Container "${name}" failed to start:`, error);
return throwWithLogs(error);
}
}
export async function createN8NInstances(
options: N8NInstancesOptions,
): Promise<N8NInstancesResult> {
const { mains, workers, projectName, network, allocatedPort, resourceQuota, filesToMount } =
options;
const log = createElapsedLogger('n8n-instances');
const environment = computeEnvironment(options);
const containers: StartedTestContainer[] = [];
const shared: SharedConfig = {
projectName,
environment,
network,
resourceQuota,
filesToMount,
};
const instances: InstanceConfig[] = [
...Array.from({ length: mains }, (_, i) => {
const num = i + 1;
const name = mains > 1 ? `${projectName}-n8n-main-${num}` : `${projectName}-n8n`;
return {
name,
isWorker: false,
instanceNumber: num,
networkAlias: name,
hostPort: num === 1 ? allocatedPort : undefined,
};
}),
...Array.from({ length: workers }, (_, i) => ({
name: `${projectName}-n8n-worker-${i + 1}`,
isWorker: true,
instanceNumber: i + 1,
})),
];
// Service-only mode: no n8n containers needed
if (instances.length === 0) {
log('No n8n instances requested (service-only mode)');
return { containers, environment };
}
// Start main 1 first (handles DB migrations/setup)
const [main1, ...remaining] = instances;
log(`Starting main 1: ${main1.name} (DB setup)`);
containers.push(await createContainer(main1, shared));
log('main 1 ready');
// Start remaining instances in parallel
if (remaining.length > 0) {
log(`Starting ${remaining.length} remaining instances in parallel...`);
const parallelContainers = await Promise.all(
remaining.map(async (instance) => {
const type = instance.isWorker ? 'worker' : 'main';
log(`Starting ${type} ${instance.instanceNumber}: ${instance.name}`);
const container = await createContainer(instance, shared);
log(`${type} ${instance.instanceNumber} ready`);
return container;
}),
);
containers.push(...parallelContainers);
}
return { containers, environment };
}
@@ -0,0 +1,106 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import { EXTERNAL_HOST, type Service, type ServiceResult, type StartContext } from './types';
export interface NgrokMeta {
publicUrl: string;
proxyHops: number;
}
export type NgrokResult = ServiceResult<NgrokMeta>;
const API_PORT = 4040;
function getTunnelTarget(ctx: StartContext): string {
if (ctx.external) {
return `${EXTERNAL_HOST}:5678`;
}
if (ctx.needsLoadBalancer) {
return `${ctx.projectName}-caddy-lb:80`;
}
return `${ctx.projectName}-n8n:5678`;
}
export const ngrok: Service<NgrokResult> = {
description: 'ngrok Tunnel',
dependsOn: ['loadBalancer'],
shouldStart: (ctx) => ctx.config.services?.includes('ngrok') ?? false,
getOptions(ctx) {
const proxyHops = ctx.needsLoadBalancer ? 2 : 1;
return { tunnelTarget: getTunnelTarget(ctx), proxyHops };
},
env(result) {
return {
WEBHOOK_URL: result.meta.publicUrl,
N8N_PROXY_HOPS: String(result.meta.proxyHops),
};
},
async start(network, projectName, config?: unknown, ctx?: StartContext): Promise<NgrokResult> {
const { tunnelTarget, proxyHops } = config as { tunnelTarget: string; proxyHops: number };
const { consumer, throwWithLogs } = createSilentLogConsumer();
const authToken = process.env.NGROK_AUTHTOKEN;
if (!authToken) {
throw new Error(
'NGROK_AUTHTOKEN environment variable is required. ' +
'Get a free token at https://dashboard.ngrok.com/get-started/your-authtoken',
);
}
try {
let builder = new GenericContainer(TEST_CONTAINER_IMAGES.ngrok)
.withNetwork(network)
.withNetworkAliases('ngrok')
.withName(`${projectName}-ngrok`)
.withExposedPorts(API_PORT)
.withEnvironment({
NGROK_AUTHTOKEN: authToken,
})
.withCommand(['http', `http://${tunnelTarget}`, '--log', 'stdout'])
.withWaitStrategy(Wait.forLogMessage(/started tunnel/i))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'ngrok',
})
.withReuse()
.withLogConsumer(consumer);
// On Linux, host.docker.internal is not available without explicit mapping
if (ctx?.external) {
builder = builder.withExtraHosts([{ host: EXTERNAL_HOST, ipAddress: 'host-gateway' }]);
}
const container = await builder.start();
const hostPort = container.getMappedPort(API_PORT);
const host = container.getHost();
// ngrok API returns tunnel info at /api/tunnels
const response = await fetch(`http://${host}:${hostPort}/api/tunnels`);
const data = (await response.json()) as {
tunnels: Array<{ public_url: string; proto: string }>;
};
// Find the https tunnel
const httpsTunnel = data.tunnels.find((t) => t.proto === 'https');
const publicUrl = httpsTunnel?.public_url ?? data.tunnels[0]?.public_url;
if (!publicUrl) {
throw new Error('Failed to get ngrok public URL from API');
}
return {
container,
meta: { publicUrl, proxyHops },
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,48 @@
/**
* Combined observability helper that provides unified access to logs and metrics.
* The actual services are in victoria-logs.ts and victoria-metrics.ts.
*/
import type { HelperContext } from './types';
import { LogsHelper, type VictoriaLogsResult, escapeLogsQL } from './victoria-logs';
import { MetricsHelper, type VictoriaMetricsResult } from './victoria-metrics';
export { escapeLogsQL };
export { LogsHelper, type LogEntry, type LogQueryOptions } from './victoria-logs';
export {
MetricsHelper,
type MetricResult,
type WaitForMetricOptions,
type ScrapeTarget,
} from './victoria-metrics';
export class ObservabilityHelper {
readonly logs: LogsHelper;
readonly metrics: MetricsHelper;
readonly syslog: VictoriaLogsResult['meta']['syslog'];
constructor(logsMeta: VictoriaLogsResult['meta'], metricsMeta: VictoriaMetricsResult['meta']) {
this.logs = new LogsHelper(logsMeta.queryEndpoint);
this.metrics = new MetricsHelper(metricsMeta.queryEndpoint);
this.syslog = logsMeta.syslog;
}
}
export function createObservabilityHelper(ctx: HelperContext): ObservabilityHelper {
const logsResult = ctx.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
const metricsResult = ctx.serviceResults.victoriaMetrics as VictoriaMetricsResult | undefined;
if (!logsResult) {
throw new Error('VictoriaLogs service not found in context');
}
if (!metricsResult) {
throw new Error('VictoriaMetrics service not found in context');
}
return new ObservabilityHelper(logsResult.meta, metricsResult.meta);
}
declare module './types' {
interface ServiceHelpers {
observability: ObservabilityHelper;
}
}
@@ -0,0 +1,68 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'postgres';
export interface PostgresMeta {
database: string;
username: string;
password: string;
}
export type PostgresResult = ServiceResult<PostgresMeta>;
export const postgres: Service<PostgresResult> = {
description: 'PostgreSQL database',
shouldStart: (ctx) => ctx.usePostgres,
async start(network: StartedNetwork, projectName: string): Promise<PostgresResult> {
const container = await new PostgreSqlContainer(TEST_CONTAINER_IMAGES.postgres)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withDatabase('n8n_db')
.withUsername('n8n_user')
.withPassword('test_password')
.withStartupTimeout(30000)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withAddedCapabilities('NET_ADMIN') // Allows us to drop IP tables and block traffic
.withTmpFs({ '/var/lib/postgresql': 'rw' })
.withCommand([
'postgres',
'-c',
'fsync=off',
'-c',
'synchronous_commit=off',
'-c',
'full_page_writes=off',
])
.withReuse()
.start();
return {
container,
meta: {
database: container.getDatabase(),
username: container.getUsername(),
password: container.getPassword(),
},
};
},
env(result: PostgresResult, external?: boolean): Record<string, string> {
return {
DB_TYPE: 'postgresdb',
DB_POSTGRESDB_HOST: external ? result.container.getHost() : HOSTNAME,
DB_POSTGRESDB_PORT: external ? String(result.container.getMappedPort(5432)) : '5432',
DB_POSTGRESDB_DATABASE: result.meta.database,
DB_POSTGRESDB_USER: result.meta.username,
DB_POSTGRESDB_PASSWORD: result.meta.password,
};
},
};
@@ -0,0 +1,372 @@
import crypto from 'crypto';
import { promises as fs } from 'fs';
import type { Expectation, RequestDefinition } from 'mockserver-client';
import { mockServerClient } from 'mockserver-client';
import type { HttpRequest, HttpResponse } from 'mockserver-client/mockServer';
import type {
MockServerClient,
PathOrRequestDefinition,
RequestResponse,
} from 'mockserver-client/mockServerClient';
import { join } from 'path';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'proxyserver';
const PORT = 1080;
export interface ProxyMeta {
host: string;
port: number;
internalUrl: string;
}
export type ProxyResult = ServiceResult<ProxyMeta>;
export const proxy: Service<ProxyResult> = {
description: 'HTTP proxy server',
extraEnv(result: ProxyResult, external?: boolean): Record<string, string> {
const url = external
? `http://${result.container.getHost()}:${result.container.getMappedPort(PORT)}`
: result.meta.internalUrl;
return {
HTTP_PROXY: url,
HTTPS_PROXY: url,
NODE_TLS_REJECT_UNAUTHORIZED: '0',
};
},
async start(network, projectName): Promise<ProxyResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.mockserver)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(PORT)
.withWaitStrategy(Wait.forLogMessage(`INFO ${PORT} started on port: ${PORT}`))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
return {
container,
meta: {
host: HOSTNAME,
port: PORT,
internalUrl: `http://${HOSTNAME}:${PORT}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: ProxyResult, external?: boolean): Record<string, string> {
return {
N8N_PROXY_HOST: external ? result.container.getHost() : result.meta.host,
N8N_PROXY_PORT: external
? String(result.container.getMappedPort(PORT))
: String(result.meta.port),
};
},
};
// --- ProxyServer helper (MockServer API client) ---
export type RequestMade = {
httpRequest?: HttpRequest;
httpResponse?: HttpResponse;
timestamp?: string;
};
export interface ProxyServerRequest {
method: string;
path: string;
queryStringParameters?: Record<string, string[]>;
headers?: Record<string, string[]>;
body?: string | { type?: string; [key: string]: unknown };
}
export interface ProxyServerResponse {
statusCode: number;
headers?: Record<string, string[]>;
body?: string;
delay?: {
timeUnit: 'MICROSECONDS' | 'MILLISECONDS' | 'SECONDS' | 'MINUTES';
value: number;
};
}
export interface ProxyServerExpectation {
httpRequest: ProxyServerRequest;
httpResponse: ProxyServerResponse;
times?: {
remainingTimes?: number;
unlimited?: boolean;
};
}
export interface RequestLog {
method: string;
path: string;
headers: Record<string, string[]>;
queryStringParameters?: Record<string, string[]>;
body?: string;
timestamp: string;
}
export class ProxyServer {
private client: MockServerClient;
url: string;
private expectationsDir: string;
constructor(proxyServerUrl: string, expectationsDir = './expectations') {
this.url = proxyServerUrl;
this.expectationsDir = expectationsDir;
const parsedURL = new URL(proxyServerUrl);
this.client = mockServerClient(parsedURL.hostname, parseInt(parsedURL.port, 10));
}
async loadExpectations(
folderName: string,
options: { strictBodyMatching?: boolean } = {},
): Promise<void> {
try {
const targetDir = join(this.expectationsDir, folderName);
const files = await fs.readdir(targetDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const expectations: Expectation[] = [];
for (const file of jsonFiles) {
try {
const filePath = join(targetDir, file);
const fileContent = await fs.readFile(filePath, 'utf8');
const expectation = JSON.parse(fileContent) as Expectation;
if (
options.strictBodyMatching &&
expectation.httpRequest &&
'body' in expectation.httpRequest
) {
(expectation.httpRequest as { body: { matchType: string } }).body.matchType = 'STRICT';
}
expectations.push(expectation);
} catch (parseError) {
console.log(`Error parsing expectation from ${file}:`, parseError);
}
}
if (expectations.length > 0) {
console.log('Loading expectations:', expectations.length);
await this.client.mockAnyResponse(expectations);
}
} catch (error) {
console.log('Error loading expectations:', error);
}
}
async createExpectation(expectation: ProxyServerExpectation): Promise<RequestResponse> {
try {
return await this.client.mockAnyResponse({
httpRequest: expectation.httpRequest,
httpResponse: expectation.httpResponse,
times: expectation.times,
});
} catch (error) {
throw new Error(
`Failed to create expectation: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async verifyRequest(request: RequestDefinition, numberOfRequests: number): Promise<boolean> {
try {
await this.client.verify(request, numberOfRequests, numberOfRequests);
return true;
} catch (error) {
console.log('error', error);
return false;
}
}
async clearAllExpectations(): Promise<void> {
try {
await this.client.clear('', 'ALL');
} catch (error) {
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
}
}
async createGetExpectation(
path: string,
responseBody: unknown,
queryParams?: Record<string, string>,
statusCode: number = 200,
): Promise<RequestResponse> {
const queryStringParameters = queryParams
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
acc[key] = [value];
return acc;
}, {})
: undefined;
return await this.createExpectation({
httpRequest: {
method: 'GET',
path,
...(queryStringParameters && { queryStringParameters }),
},
httpResponse: {
statusCode,
headers: {
'Content-Type': ['application/json'],
},
body: JSON.stringify(responseBody),
},
});
}
async wasRequestMade(request: RequestDefinition, numberOfRequests = 1): Promise<boolean> {
return await this.verifyRequest(request, numberOfRequests);
}
async getAllRequestsMade(): Promise<RequestMade[]> {
// @ts-expect-error mockserver types seem to be messed up
return await this.client.retrieveRecordedRequestsAndResponses('');
}
async recordExpectations(
folderName: string,
options?: {
pathOrRequestDefinition?: PathOrRequestDefinition;
host?: string;
dedupe?: boolean;
raw?: boolean;
transform?: (expectation: Expectation) => Expectation;
},
): Promise<void> {
try {
const recordedExpectations = await this.client.retrieveRecordedExpectations(
options?.pathOrRequestDefinition,
);
const targetDir = join(this.expectationsDir, folderName);
await fs.mkdir(targetDir, { recursive: true });
const seenRequests = new Set<string>();
for (const expectation of recordedExpectations) {
if (
!expectation.httpRequest ||
!(
'method' in expectation.httpRequest &&
typeof expectation.httpRequest.method === 'string' &&
typeof expectation.httpRequest.path === 'string'
)
) {
continue;
}
const headers = (expectation.httpRequest.headers ?? {}) as Record<string, unknown>;
const hostHeader = 'Host' in headers ? (headers.Host as string | string[]) : undefined;
const hostName = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? 'unknown-host');
if (options?.host && typeof hostName === 'string' && !hostName.includes(options.host)) {
continue;
}
const method = expectation.httpRequest.method;
let requestForProcessing: Record<string, unknown> | HttpRequest;
if (options?.raw) {
requestForProcessing = expectation.httpRequest;
} else {
const cleanedRequest: Record<string, unknown> = {
method: expectation.httpRequest.method,
path: expectation.httpRequest.path,
};
if (method === 'GET') {
if (expectation.httpRequest.queryStringParameters) {
cleanedRequest.queryStringParameters = expectation.httpRequest.queryStringParameters;
}
} else if (method === 'POST' || method === 'PUT') {
if (expectation.httpRequest.body) {
cleanedRequest.body = expectation.httpRequest.body;
}
}
requestForProcessing = cleanedRequest;
}
if (options?.dedupe) {
const dedupeKey = JSON.stringify(requestForProcessing);
if (seenRequests.has(dedupeKey)) {
continue;
}
seenRequests.add(dedupeKey);
}
let processedExpectation: Expectation = {
...expectation,
httpRequest: requestForProcessing,
times: {
unlimited: true,
},
};
if (options?.transform) {
processedExpectation = options.transform(processedExpectation);
}
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(requestForProcessing))
.digest('hex')
.substring(0, 8);
const filename = `${Date.now()}-${hostName}-${method}-${expectation.httpRequest.path.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
processedExpectation.id = filename;
const filePath = join(targetDir, filename);
await fs.writeFile(filePath, JSON.stringify(processedExpectation, null, 2));
}
} catch (error) {
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
}
}
async getActiveExpectations() {
return await this.client.retrieveActiveExpectations({ method: 'GET' });
}
}
export function createProxyHelper(ctx: HelperContext): ProxyServer {
const result = ctx.serviceResults.proxy as ProxyResult | undefined;
if (!result) {
throw new Error('Proxy service not found in context');
}
const url = `http://${result.container.getHost()}:${result.container.getMappedPort(PORT)}`;
return new ProxyServer(url);
}
declare module './types' {
interface ServiceHelpers {
proxy: ProxyServer;
}
}
@@ -0,0 +1,56 @@
import { RedisContainer } from '@testcontainers/redis';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'redis';
export interface RedisMeta {
host: string;
port: number;
}
export type RedisResult = ServiceResult<RedisMeta>;
export const redis: Service<RedisResult> = {
description: 'Redis',
shouldStart: (ctx) => ctx.isQueueMode,
async start(network: StartedNetwork, projectName: string): Promise<RedisResult> {
const container = await new RedisContainer(TEST_CONTAINER_IMAGES.redis)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start();
return {
container,
meta: {
host: HOSTNAME,
port: 6379,
},
};
},
env(result: RedisResult, external?: boolean): Record<string, string> {
const host = external ? result.container.getHost() : HOSTNAME;
const port = external ? String(result.container.getMappedPort(6379)) : '6379';
return {
// In container mode, EXECUTIONS_MODE is set by the stack based on worker count.
// In external/local mode, redis implies the user wants queue mode.
...(external ? { EXECUTIONS_MODE: 'queue' } : {}),
QUEUE_BULL_REDIS_HOST: host,
QUEUE_BULL_REDIS_PORT: port,
N8N_CACHE_ENABLED: 'true',
N8N_CACHE_BACKEND: 'redis',
N8N_CACHE_REDIS_HOST: host,
N8N_CACHE_REDIS_PORT: port,
};
},
};
@@ -0,0 +1,54 @@
import { cloudflared } from './cloudflared';
import { gitea, createGiteaHelper } from './gitea';
import { kafka, createKafkaHelper } from './kafka';
import { kent, createKentHelper } from './kent';
import { keycloak, createKeycloakHelper } from './keycloak';
import { loadBalancer } from './load-balancer';
import { localstack, createLocalStackHelper } from './localstack';
import { mailpit, createMailpitHelper } from './mailpit';
import { mysqlService } from './mysql';
import { ngrok } from './ngrok';
import { createObservabilityHelper } from './observability';
import { postgres } from './postgres';
import { proxy, createProxyHelper } from './proxy';
import { redis } from './redis';
import { taskRunner } from './task-runner';
import { tracing, createTracingHelper } from './tracing';
import type { Service, ServiceName, ServiceResult, HelperFactories } from './types';
import { vector } from './vector';
import { victoriaLogs } from './victoria-logs';
import { victoriaMetrics } from './victoria-metrics';
/** Service registry - must include all ServiceName entries */
export const services: Record<ServiceName, Service<ServiceResult>> = {
postgres,
redis,
mailpit,
gitea,
keycloak,
victoriaLogs,
victoriaMetrics,
vector,
tracing,
proxy,
taskRunner,
loadBalancer,
cloudflared,
ngrok,
kafka,
mysql: mysqlService,
localstack,
kent,
};
export const helperFactories: Partial<HelperFactories> = {
mailpit: createMailpitHelper,
gitea: createGiteaHelper,
keycloak: createKeycloakHelper,
observability: createObservabilityHelper,
tracing: createTracingHelper,
proxy: createProxyHelper,
kafka: createKafkaHelper,
localstack: createLocalStackHelper,
kent: createKentHelper,
};
@@ -0,0 +1,71 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import { EXTERNAL_HOST, type Service, type ServiceResult } from './types';
export interface TaskRunnerConfig {
taskBrokerUri: string;
}
export interface TaskRunnerMeta {
taskBrokerUri: string;
}
export type TaskRunnerResult = ServiceResult<TaskRunnerMeta>;
export const taskRunner: Service<TaskRunnerResult> = {
description: 'Task Runner',
shouldStart: (ctx) => ctx.mains > 0 || ctx.workers > 0,
getOptions(ctx) {
if (ctx.external) {
return { taskBrokerUri: `http://${EXTERNAL_HOST}:5679` } as TaskRunnerConfig;
}
const { workers, mains, projectName } = ctx;
const taskBrokerHost =
workers > 0
? `${projectName}-n8n-worker-1`
: mains > 1
? `${projectName}-n8n-main-1`
: `${projectName}-n8n`;
return { taskBrokerUri: `http://${taskBrokerHost}:5679` } as TaskRunnerConfig;
},
async start(network, projectName, config?: unknown): Promise<TaskRunnerResult> {
const { taskBrokerUri } = config as TaskRunnerConfig;
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.taskRunner)
.withNetwork(network)
.withNetworkAliases(`${projectName}-task-runner`)
.withExposedPorts(5680)
.withEnvironment({
N8N_RUNNERS_AUTH_TOKEN: 'test',
N8N_RUNNERS_LAUNCHER_LOG_LEVEL: 'debug',
N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri,
N8N_RUNNERS_MAX_CONCURRENCY: '5',
N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: '0', // Disabled in tests to prevent cold-start delays
})
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'task-runner',
})
.withName(`${projectName}-task-runner`)
.withReuse()
.withLogConsumer(consumer)
.start();
return {
container,
meta: {
taskBrokerUri,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,167 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const JAEGER_OTLP_PORT = 4318;
const JAEGER_UI_PORT = 16686;
const N8N_TRACER_INGEST_PORT = 8889;
const N8N_TRACER_HEALTH_PORT = 8888;
const JAEGER_HOSTNAME = 'jaeger';
const N8N_TRACER_HOSTNAME = 'n8n-tracer';
export interface TracingConfig {
deploymentMode?: 'scaling';
}
export interface TracingMeta {
jaeger: {
uiUrl: string;
internalOtlpEndpoint: string;
};
tracer: {
internalIngestEndpoint: string;
ingestUrl: string;
};
}
export type TracingResult = ServiceResult<TracingMeta> & {
containers: StartedTestContainer[];
};
export interface TracerWebhookConfig {
url: string;
method: 'POST';
label: string;
subscribedEvents: string[];
}
export const tracing: Service<TracingResult> = {
description: 'Tracing stack (Jaeger + n8n-tracer)',
async start(
network: StartedNetwork,
projectName: string,
config?: unknown,
): Promise<TracingResult> {
const { deploymentMode = 'scaling' } = (config as TracingConfig) ?? {};
// Start Jaeger first (OTLP receiver)
const jaegerContainer = await new GenericContainer(TEST_CONTAINER_IMAGES.jaeger)
.withName(`${projectName}-jaeger`)
.withNetwork(network)
.withNetworkAliases(JAEGER_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'jaeger',
})
.withExposedPorts(JAEGER_UI_PORT, JAEGER_OTLP_PORT)
.withEnvironment({
COLLECTOR_OTLP_ENABLED: 'true',
COLLECTOR_OTLP_HTTP_HOST_PORT: '0.0.0.0:4318',
})
.withWaitStrategy(
Wait.forHttp('/', JAEGER_UI_PORT).forStatusCode(200).withStartupTimeout(60000),
)
.withReuse()
.start();
const jaegerUiPort = jaegerContainer.getMappedPort(JAEGER_UI_PORT);
const internalOtlpEndpoint = `http://${JAEGER_HOSTNAME}:${JAEGER_OTLP_PORT}`;
// Start n8n-tracer pointing to Jaeger
const tracerContainer = await new GenericContainer(TEST_CONTAINER_IMAGES.n8nTracer)
.withName(`${projectName}-n8n-tracer`)
.withNetwork(network)
.withNetworkAliases(N8N_TRACER_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'n8n-tracer',
})
.withExposedPorts(N8N_TRACER_INGEST_PORT, N8N_TRACER_HEALTH_PORT)
.withEnvironment({
N8N_DEPLOYMENT_MODE: deploymentMode,
OTEL_EXPORTER_OTLP_ENDPOINT: internalOtlpEndpoint,
HTTP_INGEST_PORT: String(N8N_TRACER_INGEST_PORT),
HEALTH_PORT: String(N8N_TRACER_HEALTH_PORT),
})
.withWaitStrategy(
Wait.forHttp('/health', N8N_TRACER_HEALTH_PORT)
.forStatusCode(200)
.withStartupTimeout(60000),
)
.withReuse()
.start();
const internalIngestEndpoint = `http://${N8N_TRACER_HOSTNAME}:${N8N_TRACER_INGEST_PORT}`;
return {
container: jaegerContainer, // Primary container
containers: [jaegerContainer, tracerContainer],
meta: {
jaeger: {
uiUrl: `http://localhost:${jaegerUiPort}`,
internalOtlpEndpoint,
},
tracer: {
internalIngestEndpoint,
ingestUrl: `${internalIngestEndpoint}/ingest`,
},
},
};
},
env(): Record<string, string> {
return {
N8N_LOG_OUTPUT: 'console',
};
},
};
export class TracingHelper {
private readonly meta: TracingMeta;
constructor(meta: TracingMeta) {
this.meta = meta;
}
get jaegerUiUrl(): string {
return this.meta.jaeger.uiUrl;
}
get internalOtlpEndpoint(): string {
return this.meta.jaeger.internalOtlpEndpoint;
}
get internalIngestEndpoint(): string {
return this.meta.tracer.internalIngestEndpoint;
}
get ingestUrl(): string {
return this.meta.tracer.ingestUrl;
}
getWebhookConfig(label = 'n8n-tracer'): TracerWebhookConfig {
return {
url: this.meta.tracer.ingestUrl,
method: 'POST',
label,
subscribedEvents: ['*'],
};
}
}
export function createTracingHelper(ctx: HelperContext): TracingHelper {
const result = ctx.serviceResults.tracing as TracingResult | undefined;
if (!result) {
throw new Error('Tracing service not found in context');
}
return new TracingHelper(result.meta);
}
declare module './types' {
interface ServiceHelpers {
tracing: TracingHelper;
}
}
@@ -0,0 +1,113 @@
import type { StartedTestContainer, StartedNetwork } from 'testcontainers';
/** Hostname that containers use to reach the host machine (Docker Desktop built-in) */
export const EXTERNAL_HOST = 'host.docker.internal';
export const SERVICE_NAMES = [
'postgres',
'redis',
'mailpit',
'gitea',
'keycloak',
'victoriaLogs',
'victoriaMetrics',
'vector',
'tracing',
'proxy',
'taskRunner',
'loadBalancer',
'cloudflared',
'kafka',
'ngrok',
'mysql',
'localstack',
'kent',
] as const;
export type ServiceName = (typeof SERVICE_NAMES)[number];
export interface FileToMount {
content: string;
target: string;
}
export interface ServiceMeta {
/**
* Files to mount into n8n containers. Use when n8n needs files that can't
* be passed via environment (e.g., NODE_EXTRA_CA_CERTS requires a file path).
* See keycloak.ts for usage example.
*/
n8nFilesToMount?: FileToMount[];
}
export interface ServiceResult<TMeta = unknown> {
container: StartedTestContainer;
meta: TMeta;
}
export interface StartContext {
config: StackConfig;
projectName: string;
mains: number;
workers: number;
isQueueMode: boolean;
usePostgres: boolean;
needsLoadBalancer: boolean;
/** When true, services should target host.testcontainers.internal instead of Docker-internal hostnames */
external: boolean;
environment: Record<string, string>;
serviceResults: Partial<Record<ServiceName, ServiceResult>>;
allocatedPorts: { main?: number; loadBalancer?: number };
baseUrl?: string;
}
export interface StackConfig {
mains?: number;
workers?: number;
postgres?: boolean;
env?: Record<string, string>;
projectName?: string;
resourceQuota?: { memory?: number; cpu?: number };
services?: readonly ServiceName[];
/** When true, services target host machine instead of Docker-internal n8n */
external?: boolean;
}
export interface Service<TResult extends ServiceResult = ServiceResult> {
/** @example 'Redis' */
readonly description: string;
/** @example ['victoriaLogs'] // vector depends on victoriaLogs */
readonly dependsOn?: readonly ServiceName[];
/** @example (ctx) => ctx.isQueueMode // redis auto-starts in queue mode */
shouldStart?(ctx: StartContext): boolean;
/** @example (ctx) => ({ taskBrokerUri: `http://${ctx.projectName}-n8n:5679` }) */
getOptions?(ctx: StartContext): unknown;
/** Starts container, returns connection details for env() */
start(
network: StartedNetwork,
projectName: string,
options?: unknown,
ctx?: StartContext,
): Promise<TResult>;
/** @param external When true, returns host-compatible values using mapped ports (for local dev) */
env?(result: TResult, external?: boolean): Record<string, string>;
/** @param external When true, returns host-compatible values using mapped ports (for local dev) */
extraEnv?(result: TResult, external?: boolean): Record<string, string>;
/** Verifies service is reachable from inside n8n containers */
verifyFromN8n?(result: TResult, n8nContainers: StartedTestContainer[]): Promise<void>;
}
export interface HelperContext {
containers: StartedTestContainer[];
findContainer(pattern: RegExp): StartedTestContainer | undefined;
serviceResults: Partial<Record<ServiceName, ServiceResult>>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServiceHelpers {}
export type HelperFactory<T> = (ctx: HelperContext) => T;
export type HelperFactories = {
[K in keyof ServiceHelpers]: HelperFactory<ServiceHelpers[K]>;
};
@@ -0,0 +1,99 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult, StartContext } from './types';
import type { VictoriaLogsResult } from './victoria-logs';
const VICTORIA_LOGS_HOSTNAME = 'victoria-logs';
const VICTORIA_LOGS_HTTP_PORT = 9428;
function generateVectorConfig(projectName: string, victoriaLogsEndpoint: string): string {
return `
# Disable healthcheck to allow Vector to start while Docker socket becomes available
[healthchecks]
enabled = false
[sources.docker_logs]
type = "docker_logs"
include_labels = ["com.docker.compose.project=${projectName}"]
[transforms.format_for_victorialogs]
type = "remap"
inputs = ["docker_logs"]
source = '''
._msg = .message
._time = .timestamp
.project = "${projectName}"
.service = .label."com.docker.compose.service" || "unknown"
.container = .container_name || "unknown"
._stream = .stream || "unknown"
del(.message)
del(.timestamp)
del(.label)
del(.source_type)
del(.stream)
'''
[sinks.victoria_logs]
type = "http"
inputs = ["format_for_victorialogs"]
uri = "${victoriaLogsEndpoint}/insert/jsonline"
method = "post"
framing.method = "newline_delimited"
encoding.codec = "json"
`;
}
export type VectorResult = ServiceResult<Record<string, never>>;
export const vector: Service<VectorResult> = {
description: 'Vector log collector',
dependsOn: ['victoriaLogs'],
async start(
network: StartedNetwork,
projectName: string,
_config?: unknown,
ctx?: StartContext,
): Promise<VectorResult> {
// Get the VictoriaLogs internal endpoint from the already-started service
const victoriaLogsResult = ctx?.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
const logsInternalEndpoint =
victoriaLogsResult?.meta.internalEndpoint ??
`http://${VICTORIA_LOGS_HOSTNAME}:${VICTORIA_LOGS_HTTP_PORT}`;
const vectorConfig = generateVectorConfig(projectName, logsInternalEndpoint);
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.vector)
.withName(`${projectName}-vector`)
.withNetwork(network)
.withNetworkAliases('vector')
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'vector',
})
.withBindMounts([
{
source: '/var/run/docker.sock',
target: '/var/run/docker.sock',
mode: 'ro',
},
])
.withCopyContentToContainer([
{
content: vectorConfig,
target: '/etc/vector/vector.toml',
},
])
.withCommand(['--config', '/etc/vector/vector.toml'])
.withWaitStrategy(Wait.forLogMessage(/Vector has started/, 1).withStartupTimeout(60000))
.withReuse()
.start();
return {
container,
meta: {},
};
},
};
@@ -0,0 +1,156 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const VICTORIA_LOGS_HTTP_PORT = 9428;
const VICTORIA_LOGS_SYSLOG_PORT = 514;
const VICTORIA_LOGS_HOSTNAME = 'victoria-logs';
const SYSLOG_FACILITY_LOCAL0 = 16; // RFC 5424
export interface VictoriaLogsMeta {
queryEndpoint: string;
internalEndpoint: string;
syslog: {
host: string;
port: number;
protocol: 'tcp' | 'udp';
facility: number;
appName: string;
};
}
export type VictoriaLogsResult = ServiceResult<VictoriaLogsMeta>;
export const victoriaLogs: Service<VictoriaLogsResult> = {
description: 'VictoriaLogs',
async start(network: StartedNetwork, projectName: string): Promise<VictoriaLogsResult> {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.victoriaLogs)
.withName(`${projectName}-victoria-logs`)
.withNetwork(network)
.withNetworkAliases(VICTORIA_LOGS_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'victoria-logs',
})
.withExposedPorts(VICTORIA_LOGS_HTTP_PORT, VICTORIA_LOGS_SYSLOG_PORT)
.withCommand([
'-storageDataPath=/victoria-logs-data',
'-retentionPeriod=1d',
`-syslog.listenAddr.tcp=:${VICTORIA_LOGS_SYSLOG_PORT}`,
])
.withWaitStrategy(
Wait.forHttp('/health', VICTORIA_LOGS_HTTP_PORT)
.forStatusCode(200)
.withStartupTimeout(60000),
)
.withReuse()
.start();
const httpPort = container.getMappedPort(VICTORIA_LOGS_HTTP_PORT);
return {
container,
meta: {
queryEndpoint: `http://localhost:${httpPort}`,
internalEndpoint: `http://${VICTORIA_LOGS_HOSTNAME}:${VICTORIA_LOGS_HTTP_PORT}`,
syslog: {
host: VICTORIA_LOGS_HOSTNAME,
port: VICTORIA_LOGS_SYSLOG_PORT,
protocol: 'tcp',
facility: SYSLOG_FACILITY_LOCAL0,
appName: 'n8n',
},
},
};
},
env(): Record<string, string> {
return {
N8N_LOG_OUTPUT: 'console',
};
},
};
export interface LogEntry {
_time: string;
_msg: string;
message: string;
[key: string]: string | undefined;
}
export interface LogQueryOptions {
limit?: number;
start?: string;
end?: string;
timeoutMs?: number;
intervalMs?: number;
}
export class LogsHelper {
constructor(private readonly endpoint: string) {}
async exportAll(options: LogQueryOptions = {}): Promise<string> {
const logs = await this.query('*', { limit: 10000, ...options });
return logs.map((log) => JSON.stringify(log)).join('\n');
}
async query(query: string, options: LogQueryOptions = {}): Promise<LogEntry[]> {
const params = new URLSearchParams({ query });
if (options.limit) params.set('limit', String(options.limit));
if (options.start) params.set('start', options.start);
if (options.end) params.set('end', options.end);
const response = await fetch(`${this.endpoint}/select/logsql/query?${params}`);
if (!response.ok) {
throw new Error(`VictoriaLogs query failed: ${response.status}`);
}
const text = await response.text();
if (!text.trim()) return [];
return text
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
try {
const entry = JSON.parse(line) as LogEntry;
entry.message = entry._msg;
return entry;
} catch {
throw new Error(`Failed to parse VictoriaLogs line: ${line}`);
}
});
}
async waitForLog(query: string, options: LogQueryOptions = {}): Promise<LogEntry | null> {
const { setTimeout: wait } = await import('node:timers/promises');
const deadline = Date.now() + (options.timeoutMs ?? 30000);
const interval = options.intervalMs ?? 1000;
while (Date.now() < deadline) {
const logs = await this.query(query, options);
if (logs.length > 0) return logs[0];
await wait(interval);
}
return null;
}
}
export function createLogsHelper(ctx: HelperContext): LogsHelper {
const result = ctx.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
if (!result) {
throw new Error('VictoriaLogs service not found in context');
}
return new LogsHelper(result.meta.queryEndpoint);
}
/**
* Escape special characters in LogsQL queries.
*/
export function escapeLogsQL(str: string): string {
return str.replace(/["\\]/g, '\\$&');
}
@@ -0,0 +1,223 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult, StartContext } from './types';
const VICTORIA_METRICS_HTTP_PORT = 8428;
const VICTORIA_METRICS_HOSTNAME = 'victoria-metrics';
export interface ScrapeTarget {
job: string;
instance: string;
host: string;
port: number;
}
export interface VictoriaMetricsConfig {
scrapeTargets: ScrapeTarget[];
}
export interface VictoriaMetricsMeta {
queryEndpoint: string;
internalEndpoint: string;
}
export type VictoriaMetricsResult = ServiceResult<VictoriaMetricsMeta>;
function generateScrapeConfig(targets: ScrapeTarget[]): string {
const jobGroups = new Map<string, ScrapeTarget[]>();
for (const target of targets) {
const existing = jobGroups.get(target.job) ?? [];
existing.push(target);
jobGroups.set(target.job, existing);
}
const scrapeConfigs: string[] = [];
for (const [jobName, jobTargets] of jobGroups) {
const targetConfigs = jobTargets
.map(
(t) => ` - targets: ['${t.host}:${t.port}']
labels:
instance: '${t.instance}'`,
)
.join('\n');
scrapeConfigs.push(` - job_name: '${jobName}'
static_configs:
${targetConfigs}
metrics_path: '/metrics'
scrape_interval: '5s'`);
}
return `
global:
scrape_interval: 15s
scrape_configs:
${scrapeConfigs.join('\n')}
`;
}
export const victoriaMetrics: Service<VictoriaMetricsResult> = {
description: 'VictoriaMetrics',
getOptions(ctx: StartContext): VictoriaMetricsConfig {
const { mains, workers, projectName } = ctx;
const scrapeTargets: ScrapeTarget[] = [];
for (let i = 1; i <= mains; i++) {
const hostname = mains > 1 ? `${projectName}-n8n-main-${i}` : `${projectName}-n8n`;
scrapeTargets.push({
job: 'n8n-main',
instance: `n8n-main-${i}`,
host: hostname,
port: 5678,
});
}
for (let i = 1; i <= workers; i++) {
scrapeTargets.push({
job: 'n8n-worker',
instance: `n8n-worker-${i}`,
host: `${projectName}-n8n-worker-${i}`,
port: 5678,
});
}
return { scrapeTargets };
},
async start(
network: StartedNetwork,
projectName: string,
config?: unknown,
): Promise<VictoriaMetricsResult> {
const { scrapeTargets = [] } = (config as VictoriaMetricsConfig) ?? {};
const scrapeConfig = generateScrapeConfig(scrapeTargets);
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.victoriaMetrics)
.withName(`${projectName}-victoria-metrics`)
.withNetwork(network)
.withNetworkAliases(VICTORIA_METRICS_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'victoria-metrics',
})
.withExposedPorts(VICTORIA_METRICS_HTTP_PORT)
.withCommand([
'-storageDataPath=/victoria-metrics-data',
'-retentionPeriod=1d',
'-promscrape.config=/etc/prometheus/prometheus.yml',
])
.withCopyContentToContainer([
{
content: scrapeConfig,
target: '/etc/prometheus/prometheus.yml',
},
])
.withWaitStrategy(
Wait.forHttp('/health', VICTORIA_METRICS_HTTP_PORT)
.forStatusCode(200)
.withStartupTimeout(60000),
)
.withReuse()
.start();
const httpPort = container.getMappedPort(VICTORIA_METRICS_HTTP_PORT);
return {
container,
meta: {
queryEndpoint: `http://localhost:${httpPort}`,
internalEndpoint: `http://${VICTORIA_METRICS_HOSTNAME}:${VICTORIA_METRICS_HTTP_PORT}`,
},
};
},
env(): Record<string, string> {
return {
N8N_METRICS_ENABLED: 'true',
};
},
};
export interface MetricResult {
labels: Record<string, string>;
value: number;
}
export interface WaitForMetricOptions {
timeoutMs?: number;
intervalMs?: number;
predicate?: (values: MetricResult[]) => boolean;
}
export class MetricsHelper {
constructor(private readonly endpoint: string) {}
async exportAll(options: { start?: string; end?: string } = {}): Promise<string> {
const params = new URLSearchParams({
'match[]': '{__name__=~".+"}',
});
if (options.start) params.set('start', options.start);
if (options.end) params.set('end', options.end);
const response = await fetch(`${this.endpoint}/api/v1/export?${params}`);
if (!response.ok) {
throw new Error(`VictoriaMetrics export failed: ${response.status}`);
}
return await response.text();
}
async query(query: string): Promise<MetricResult[]> {
const response = await fetch(`${this.endpoint}/api/v1/query?${new URLSearchParams({ query })}`);
if (!response.ok) {
throw new Error(`VictoriaMetrics query failed: ${response.status}`);
}
const data = (await response.json()) as {
status: string;
data?: { result: Array<{ metric: Record<string, string>; value: [number, string] }> };
error?: string;
};
if (data.status !== 'success') {
throw new Error(`VictoriaMetrics error: ${data.error}`);
}
return (data.data?.result ?? []).map((r) => ({
labels: r.metric,
value: parseFloat(r.value[1]),
}));
}
async waitForMetric(
query: string,
options: WaitForMetricOptions = {},
): Promise<MetricResult | null> {
const { setTimeout: wait } = await import('node:timers/promises');
const deadline = Date.now() + (options.timeoutMs ?? 30000);
const interval = options.intervalMs ?? 1000;
const predicate = options.predicate ?? ((v) => v.length > 0);
while (Date.now() < deadline) {
try {
const values = await this.query(query);
if (predicate(values)) return values[0] ?? null;
} catch {
// Ignore transient errors during polling
}
await wait(interval);
}
return null;
}
}
export function createMetricsHelper(ctx: HelperContext): MetricsHelper {
const result = ctx.serviceResults.victoriaMetrics as VictoriaMetricsResult | undefined;
if (!result) {
throw new Error('VictoriaMetrics service not found in context');
}
return new MetricsHelper(result.meta.queryEndpoint);
}
+372
View File
@@ -0,0 +1,372 @@
import getPort from 'get-port';
import type { StartedNetwork, StartedTestContainer, StoppedTestContainer } from 'testcontainers';
import { Network } from 'testcontainers';
import { createElapsedLogger, pollContainerHttpEndpoint } from './helpers/utils';
import { waitForNetworkQuiet } from './network-stabilization';
import type { LoadBalancerResult } from './services/load-balancer';
import { createN8NInstances } from './services/n8n';
import { helperFactories, services } from './services/registry';
import type {
FileToMount,
HelperContext,
HelperFactories,
Service,
ServiceHelpers,
ServiceName,
ServiceResult,
StackConfig,
StartContext,
} from './services/types';
import { createTelemetryRecorder } from './telemetry';
const SERVICE_REGISTRY: Record<ServiceName, Service> = services;
export type N8NConfig = StackConfig;
export interface N8NStack {
baseUrl: string;
projectName: string;
stop: () => Promise<void>;
containers: StartedTestContainer[];
serviceResults: Partial<Record<ServiceName, ServiceResult>>;
services: ServiceHelpers;
logs: ServiceHelpers['observability']['logs'];
metrics: ServiceHelpers['observability']['metrics'];
findContainers: (namePattern: string | RegExp) => StartedTestContainer[];
stopContainer: (namePattern: string | RegExp) => Promise<StoppedTestContainer | null>;
/** Direct URLs to each main instance (bypasses load balancer). Index 0 = main-1, etc. */
mainUrls: string[];
}
function shouldServiceStart(name: ServiceName, service: Service, ctx: StartContext): boolean {
// Explicitly requested services always start
if (ctx.config.services?.includes(name)) return true;
if (service.shouldStart) {
return service.shouldStart(ctx);
}
return false;
}
function groupByDependencyLevel(serviceNames: ServiceName[]): ServiceName[][] {
const levels: ServiceName[][] = [];
const assigned = new Set<ServiceName>();
while (assigned.size < serviceNames.length) {
const currentLevel: ServiceName[] = [];
for (const name of serviceNames) {
if (assigned.has(name)) continue;
const service = SERVICE_REGISTRY[name];
const deps = service?.dependsOn ?? [];
if (deps.every((dep) => !serviceNames.includes(dep) || assigned.has(dep))) {
currentLevel.push(name);
}
}
if (currentLevel.length === 0) {
throw new Error('Circular dependency detected in services');
}
levels.push(currentLevel);
currentLevel.forEach((name) => assigned.add(name));
}
return levels;
}
export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack> {
const {
mains = 1,
workers = 0,
postgres: usePostgresConfig = false,
env = {},
projectName,
resourceQuota,
services: enabledServices = [],
external = false,
} = config;
const log = createElapsedLogger('stack');
const isQueueMode = mains > 1 || workers > 0;
const needsLoadBalancer = mains > 1;
const usePostgres = usePostgresConfig || isQueueMode || enabledServices.includes('keycloak');
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
let allocatedMainPort: number | undefined;
let allocatedLbPort: number | undefined;
if (needsLoadBalancer) {
allocatedLbPort = await getPort();
} else {
allocatedMainPort = await getPort();
}
const containers: StartedTestContainer[] = [];
const serviceResults: Record<string, ServiceResult> = {};
let environment: Record<string, string> = {};
log(`Starting: ${uniqueProjectName}`);
const telemetry = createTelemetryRecorder(config);
let network: StartedNetwork;
try {
const networkStart = performance.now();
network = await new Network().start();
telemetry.recordNetwork(Math.round(performance.now() - networkStart));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
telemetry.flush(false, `Network creation failed: ${message}`);
throw error;
}
try {
const ctx: StartContext = {
config: { ...config, postgres: usePostgres },
projectName: uniqueProjectName,
mains,
workers,
isQueueMode,
usePostgres,
needsLoadBalancer,
external,
environment,
serviceResults,
allocatedPorts: {
main: allocatedMainPort,
loadBalancer: allocatedLbPort,
},
};
// Step 1: Start services (parallel by dependency level)
const allServiceNames = Object.keys(SERVICE_REGISTRY) as ServiceName[];
const servicesToStart = allServiceNames.filter((name) =>
shouldServiceStart(name, SERVICE_REGISTRY[name], ctx),
);
const dependencyLevels = groupByDependencyLevel(servicesToStart);
for (const level of dependencyLevels) {
const levelNames = level.map((name) => SERVICE_REGISTRY[name].description).join(', ');
const levelPromises = level.map(async (name) => {
const service = SERVICE_REGISTRY[name];
const options = service.getOptions?.(ctx);
const serviceStart = performance.now();
try {
const result = await service.start(network, uniqueProjectName, options, ctx);
telemetry.recordService(name, Math.round(performance.now() - serviceStart));
return { name, service, result };
} catch (error) {
telemetry.recordService(name, Math.round(performance.now() - serviceStart));
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Service "${service.description}" (${name}) failed to start: ${message}`);
}
});
const results = await Promise.all(levelPromises);
for (const { name, service, result } of results) {
// Some services (e.g., tracing) return multiple containers
const serviceContainers =
'containers' in result && Array.isArray(result.containers)
? (result.containers as StartedTestContainer[])
: [result.container];
containers.push(...serviceContainers);
serviceResults[name] = result;
if (service.env) {
environment = { ...environment, ...service.env(result) };
}
if (service.extraEnv) {
environment = { ...environment, ...service.extraEnv(result) };
}
}
ctx.environment = environment;
ctx.serviceResults = serviceResults;
log(`Services ready: ${levelNames}`);
}
// Step 2: Start n8n (main 1 first for DB setup, then rest in parallel)
const lbResult = serviceResults.loadBalancer as LoadBalancerResult | undefined;
const baseUrl = lbResult?.meta.baseUrl ?? `http://localhost:${allocatedMainPort}`;
const filesToMount: FileToMount[] = Object.values(serviceResults).flatMap((result) => {
const meta = result.meta as { n8nFilesToMount?: FileToMount[] } | undefined;
return meta?.n8nFilesToMount ?? [];
});
const n8nStartupStart = performance.now();
const n8nResult = await createN8NInstances({
mains,
workers,
projectName: uniqueProjectName,
network,
serviceEnvironment: environment,
userEnvironment: env,
usePostgres,
baseUrl: needsLoadBalancer ? undefined : baseUrl,
allocatedPort: needsLoadBalancer ? undefined : allocatedMainPort,
resourceQuota,
filesToMount,
});
containers.push(...n8nResult.containers);
telemetry.recordN8nStartup(
Math.round(performance.now() - n8nStartupStart),
n8nResult.containers.length,
);
log(`n8n ready: ${mains} main(s), ${workers} worker(s)`);
if (lbResult) {
await pollContainerHttpEndpoint(lbResult.container, '/healthz/readiness');
log('Load balancer ready');
}
ctx.baseUrl = baseUrl;
// Build direct main URLs (bypassing load balancer)
const mainUrls: string[] = [];
for (let i = 1; i <= mains; i++) {
const mainNamePattern = mains > 1 ? `-n8n-main-${i}` : '-n8n';
const mainContainer = containers.find((c) => c.getName().endsWith(mainNamePattern));
if (mainContainer) {
const mainPort = mainContainer.getMappedPort(5678);
mainUrls.push(`http://localhost:${mainPort}`);
}
}
log(`Direct main URLs: ${mainUrls.join(', ')}`);
// Run verification hooks (e.g. keycloak connectivity check)
const n8nContainers = containers.filter((c) => {
const name = c.getName();
return name.includes('-n8n-main-') || name.endsWith('-n8n');
});
const verifications: string[] = [];
for (const name of servicesToStart) {
const service = SERVICE_REGISTRY[name];
if (service.verifyFromN8n && serviceResults[name]) {
await service.verifyFromN8n(serviceResults[name], n8nContainers);
verifications.push(service.description);
}
}
if (verifications.length > 0) {
log(`Verified: ${verifications.join(', ')}`);
}
await waitForNetworkQuiet();
telemetry.flush(true);
const helperCtx: HelperContext = {
containers,
findContainer: (pattern: RegExp) => containers.find((c) => pattern.test(c.getName())),
serviceResults,
};
const helperCache: Partial<ServiceHelpers> = {};
const servicesProxy = new Proxy({} as ServiceHelpers, {
get: <K extends keyof ServiceHelpers>(
_target: ServiceHelpers,
prop: K,
): ServiceHelpers[K] => {
if (prop in helperCache) {
return helperCache[prop]!;
}
const factory = (helperFactories as HelperFactories)[prop];
if (!factory) {
throw new Error(
`No helper factory found for service: ${String(prop)}. ` +
`Available helpers: ${Object.keys(helperFactories).join(', ')}`,
);
}
const helper = factory(helperCtx);
helperCache[prop] = helper;
return helper;
},
has: (_target, prop) => prop in helperFactories,
ownKeys: () => Object.keys(helperFactories),
getOwnPropertyDescriptor: (_target, prop) => {
if (prop in helperFactories) {
return { enumerable: true, configurable: true };
}
return undefined;
},
});
return {
baseUrl,
projectName: uniqueProjectName,
stop: async () => await stopN8NStack(containers, network, uniqueProjectName),
containers,
serviceResults,
services: servicesProxy,
get logs() {
return servicesProxy.observability.logs;
},
get metrics() {
return servicesProxy.observability.metrics;
},
findContainers(namePattern: string | RegExp): StartedTestContainer[] {
const regex = typeof namePattern === 'string' ? new RegExp(namePattern) : namePattern;
return containers.filter((container) => regex.test(container.getName()));
},
async stopContainer(namePattern: string | RegExp): Promise<StoppedTestContainer | null> {
const regex = typeof namePattern === 'string' ? new RegExp(namePattern) : namePattern;
const container = containers.find((c) => regex.test(c.getName()));
return container ? await container.stop() : null;
},
mainUrls,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
telemetry.flush(false, message);
throw error;
}
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
async function stopN8NStack(
containers: StartedTestContainer[],
network: StartedNetwork,
uniqueProjectName: string,
): Promise<void> {
const errors: Error[] = [];
try {
const stopPromises = containers.reverse().map(async (container) => {
try {
await container.stop();
} catch (error) {
errors.push(
new Error(`Failed to stop container ${container.getId()}: ${getErrorMessage(error)}`),
);
}
});
await Promise.allSettled(stopPromises);
try {
await network.stop();
} catch (error) {
errors.push(
new Error(`Failed to stop network ${network.getName()}: ${getErrorMessage(error)}`),
);
}
if (errors.length > 0) {
console.warn(
`Some cleanup operations failed for stack ${uniqueProjectName}:`,
errors.map((e) => e.message).join(', '),
);
}
} catch (error) {
console.error(`Critical error during cleanup for stack ${uniqueProjectName}:`, error);
throw error;
}
}
+239
View File
@@ -0,0 +1,239 @@
import { spawn } from 'child_process';
import * as os from 'os';
import type { StackConfig } from './services/types';
export interface StackTelemetryRecord {
/** ISO timestamp when stack creation started */
timestamp: string;
/** Git context from environment */
git: {
sha: string;
branch: string;
pr?: number;
};
/** CI context from GitHub Actions environment */
ci: {
runId?: string;
job?: string;
workflow?: string;
attempt?: number;
};
/** Runner info - provider detected from env vars, specs from runtime */
runner: {
provider: 'github' | 'blacksmith' | 'local';
cpuCores: number;
memoryGb: number;
};
/** Stack configuration */
stack: {
type: 'single' | 'queue' | 'multi-main';
mains: number;
workers: number;
postgres: boolean;
services: string[];
};
/** Timing metrics in milliseconds */
timing: {
total: number;
network: number;
n8nStartup: number;
services: Record<string, number>;
};
/** Container counts */
containers: {
total: number;
services: number;
n8n: number;
};
/** Outcome */
success: boolean;
errorMessage?: string;
}
function getGitContext(): StackTelemetryRecord['git'] {
const ref = process.env.GITHUB_REF ?? '';
const prMatch = ref.match(/refs\/pull\/(\d+)/);
return {
sha: process.env.GITHUB_SHA?.slice(0, 8) ?? 'local',
branch: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? 'local',
pr: prMatch ? parseInt(prMatch[1], 10) : undefined,
};
}
function getCIContext(): StackTelemetryRecord['ci'] {
return {
runId: process.env.GITHUB_RUN_ID,
job: process.env.GITHUB_JOB,
workflow: process.env.GITHUB_WORKFLOW,
attempt: process.env.GITHUB_RUN_ATTEMPT
? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10)
: undefined,
};
}
function getRunnerProvider(): StackTelemetryRecord['runner']['provider'] {
// Not in CI = local development
if (!process.env.CI) {
return 'local';
}
// GitHub-hosted runners explicitly identify themselves
if (process.env.RUNNER_ENVIRONMENT === 'github-hosted') {
return 'github';
}
// Everything else in CI is Blacksmith
return 'blacksmith';
}
function getRunnerInfo(): StackTelemetryRecord['runner'] {
const cpus = os.cpus();
return {
provider: getRunnerProvider(),
cpuCores: cpus.length,
memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10,
};
}
function inferStackType(config: StackConfig): 'single' | 'queue' | 'multi-main' {
if ((config.mains ?? 1) > 1) return 'multi-main';
if ((config.workers ?? 0) > 0) return 'queue';
return 'single';
}
function inferPostgres(config: StackConfig): boolean {
const isQueueMode = (config.mains ?? 1) > 1 || (config.workers ?? 0) > 0;
const hasKeycloak = config.services?.includes('keycloak') ?? false;
return (config.postgres ?? false) || isQueueMode || hasKeycloak;
}
export class TelemetryRecorder {
private startTimestamp = Date.now();
private startPerf = performance.now();
private networkTime = 0;
private n8nStartupTime = 0;
private serviceTimings: Record<string, number> = {};
private serviceCount = 0;
private n8nCount = 0;
constructor(private config: StackConfig) {}
recordNetwork(durationMs: number): void {
this.networkTime = durationMs;
}
recordService(name: string, durationMs: number): void {
this.serviceTimings[name] = durationMs;
this.serviceCount++;
}
recordN8nStartup(durationMs: number, count: number): void {
this.n8nStartupTime = durationMs;
this.n8nCount = count;
}
private buildRecord(success: boolean, errorMessage?: string): StackTelemetryRecord {
return {
timestamp: new Date(this.startTimestamp).toISOString(),
git: getGitContext(),
ci: getCIContext(),
runner: getRunnerInfo(),
stack: {
type: inferStackType(this.config),
mains: this.config.mains ?? 1,
workers: this.config.workers ?? 0,
postgres: inferPostgres(this.config),
services: [...(this.config.services ?? [])],
},
timing: {
total: Math.round(performance.now() - this.startPerf),
network: this.networkTime,
n8nStartup: this.n8nStartupTime,
services: { ...this.serviceTimings },
},
containers: {
total: this.serviceCount + this.n8nCount,
services: this.serviceCount,
n8n: this.n8nCount,
},
success,
errorMessage,
};
}
/**
* Flush telemetry - outputs based on environment configuration
*
* Output modes:
* - CONTAINER_TELEMETRY_WEBHOOK: Send via detached process (non-blocking, survives parent exit)
* - CONTAINER_TELEMETRY_VERBOSE=1: Full breakdown + elapsed logs (via utils.ts)
* - Default: One-liner summary only
*/
flush(success: boolean, errorMessage?: string): void {
const record = this.buildRecord(success, errorMessage);
const isVerbose = process.env.CONTAINER_TELEMETRY_VERBOSE === '1';
const webhookUrl = process.env.CONTAINER_TELEMETRY_WEBHOOK;
if (webhookUrl) {
this.sendToWebhook(record, webhookUrl);
}
if (isVerbose) {
console.log(JSON.stringify(record, null, 2));
} else {
this.printSummaryLine(record);
}
}
private printSummaryLine(record: StackTelemetryRecord): void {
const time = this.formatMs(record.timing.total);
const containers = record.containers.total;
const services = record.stack.services.length;
if (record.success) {
const serviceInfo = services > 0 ? `, ${services} services` : '';
console.log(`\x1b[32m✓\x1b[0m Stack ready (${time}, ${containers} containers${serviceInfo})`);
} else {
console.log(`\x1b[31m✗\x1b[0m Stack failed: ${record.errorMessage} (${time})`);
}
}
/**
* Send telemetry via a detached child process.
* The process runs independently and survives parent exit, ensuring delivery
* even when the main process throws/exits immediately after flush().
*/
private sendToWebhook(record: StackTelemetryRecord, webhookUrl: string): void {
const payload = JSON.stringify(record);
const script = `
fetch(process.argv[1], {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: process.argv[2]
}).catch(() => process.exit(1));
`;
const child = spawn(process.execPath, ['-e', script, webhookUrl, payload], {
detached: true,
stdio: 'ignore',
});
child.unref();
}
private formatMs(ms: number): string {
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
}
export function createTelemetryRecorder(config: StackConfig): TelemetryRecorder {
return new TelemetryRecorder(config);
}
@@ -0,0 +1,120 @@
/**
* Single source of truth for all test container images.
*
* All images can be overridden via environment variables:
* TEST_IMAGE_<KEY> where KEY is the SCREAMING_SNAKE_CASE version of the image key
* e.g., TEST_IMAGE_POSTGRES=postgres:16 overrides the postgres image
* e.g., TEST_IMAGE_N8N=n8nio/n8n:latest overrides the n8n image
* e.g., TEST_IMAGE_TASK_RUNNER=n8nio/runners:latest overrides the task runner image
*
* For n8n image, shorthand syntax is supported:
* TEST_IMAGE_N8N=stable → n8nio/n8n:stable
* TEST_IMAGE_N8N=n8n:stable → n8nio/n8n:stable
* TEST_IMAGE_N8N=n8nio/n8n:stable → n8nio/n8n:stable
*
* Task runner image derivation:
* When TEST_IMAGE_TASK_RUNNER is not set, the image is derived from the n8n image:
* TEST_IMAGE_N8N=n8nio/n8n:nightly → taskRunner=n8nio/runners:nightly
* TEST_IMAGE_N8N=ghcr.io/n8n-io/n8n:pr-123 → taskRunner=ghcr.io/n8n-io/runners:pr-123
*
* N8N_DOCKER_IMAGE is also supported for backwards compatibility.
*/
/** Default images - override via TEST_IMAGE_<KEY> env vars */
const DEFAULT_IMAGES = {
postgres: 'postgres:18-alpine',
redis: 'redis:alpine',
caddy: 'caddy:alpine',
n8n: 'n8nio/n8n:local',
taskRunner: 'n8nio/runners:local',
mailpit: 'axllent/mailpit:latest',
mockserver: 'mockserver/mockserver:5.15.0',
gitea: 'gitea/gitea:1.25.1',
keycloak: 'keycloak/keycloak:26.4',
victoriaLogs: 'victoriametrics/victoria-logs:v1.21.0-victorialogs',
victoriaMetrics: 'victoriametrics/victoria-metrics:v1.115.0',
vector: 'timberio/vector:0.52.0-alpine',
n8nTracer: 'ghcr.io/ivov/n8n-tracer:0.1.0',
jaeger: 'jaegertracing/all-in-one:1.76.0',
cloudflared: 'cloudflare/cloudflared:2025.1.1',
ngrok: 'ngrok/ngrok:alpine',
kafka: 'confluentinc/cp-kafka:8.0.3',
mysql: 'mysql:9.6.0',
localstack: 'localstack/localstack:4.13.1',
} as const;
/** Convert camelCase to SCREAMING_SNAKE_CASE for env var names */
function toEnvVarName(key: string): string {
return key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
}
/** Normalize n8n image shorthand: "stable" → "n8nio/n8n:stable" */
function normalizeN8nImage(image: string): string {
if (image.includes('/')) return image;
if (image.includes(':')) return `n8nio/${image}`;
return `n8nio/n8n:${image}`;
}
/** Parse "ghcr.io/n8n-io/n8n:pr-123" or "n8nio/n8n:nightly" into components */
function parseImage(image: string): { registry?: string; org: string; tag: string } {
const [imagePath, tag = 'latest'] = image.split(':');
const parts = imagePath.split('/');
if (parts.length === 3) {
return { registry: parts[0], org: parts[1], tag };
}
return { org: parts[0], tag };
}
/** Derive runners image from n8n image components */
function buildRunnersImage({ registry, org, tag }: ReturnType<typeof parseImage>): string {
return registry ? `${registry}/${org}/runners:${tag}` : `${org}/runners:${tag}`;
}
let resolvedN8nImage: string | undefined;
/** Get image with TEST_IMAGE_<KEY> env var override support */
function getImage<K extends keyof typeof DEFAULT_IMAGES>(key: K): string {
const envVar = `TEST_IMAGE_${toEnvVarName(key)}`;
let value = process.env[envVar];
if (key === 'n8n' && !value) {
value = process.env.N8N_DOCKER_IMAGE;
}
if (key === 'taskRunner' && !value) {
resolvedN8nImage ??= getImage('n8n');
return buildRunnersImage(parseImage(resolvedN8nImage));
}
value = value ?? DEFAULT_IMAGES[key];
if (key === 'n8n') {
resolvedN8nImage = normalizeN8nImage(value);
return resolvedN8nImage;
}
return value;
}
export const TEST_CONTAINER_IMAGES = {
postgres: getImage('postgres'),
redis: getImage('redis'),
caddy: getImage('caddy'),
n8n: getImage('n8n'),
taskRunner: getImage('taskRunner'),
mailpit: getImage('mailpit'),
mockserver: getImage('mockserver'),
gitea: getImage('gitea'),
keycloak: getImage('keycloak'),
victoriaLogs: getImage('victoriaLogs'),
victoriaMetrics: getImage('victoriaMetrics'),
vector: getImage('vector'),
n8nTracer: getImage('n8nTracer'),
jaeger: getImage('jaeger'),
cloudflared: getImage('cloudflared'),
kafka: getImage('kafka'),
mysql: getImage('mysql'),
ngrok: getImage('ngrok'),
localstack: getImage('localstack'),
} as const;
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": false,
"lib": ["esnext", "dom"],
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["**/dist/**/*", "**/node_modules/**/*"],
"references": [{ "path": "../../workflow/tsconfig.build.esm.json" }]
}