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
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:
@@ -0,0 +1,4 @@
|
||||
**/.terraform/*
|
||||
**/*.tfstate*
|
||||
**/*.tfvars
|
||||
privatekey.pem
|
||||
@@ -0,0 +1,63 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:24.13.1 AS base
|
||||
|
||||
# Install required dependencies
|
||||
RUN apt-get update && apt-get install -y gnupg2 curl
|
||||
|
||||
# Add k6 GPG key and repository
|
||||
RUN mkdir -p /etc/apt/keyrings && \
|
||||
curl -sS https://dl.k6.io/key.gpg | gpg --dearmor --yes -o /etc/apt/keyrings/k6.gpg && \
|
||||
chmod a+x /etc/apt/keyrings/k6.gpg && \
|
||||
echo "deb [signed-by=/etc/apt/keyrings/k6.gpg] https://dl.k6.io/deb stable main" | tee /etc/apt/sources.list.d/k6.list
|
||||
|
||||
# Update and install k6
|
||||
RUN apt-get update && \
|
||||
apt-get install -y k6 tini && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g corepack@0.33 && corepack enable
|
||||
|
||||
#
|
||||
# Builder
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node:node ./pnpm-lock.yaml /app/pnpm-lock.yaml
|
||||
COPY --chown=node:node ./pnpm-workspace.yaml /app/pnpm-workspace.yaml
|
||||
COPY --chown=node:node ./package.json /app/package.json
|
||||
COPY --chown=node:node ./packages/@n8n/benchmark/package.json /app/packages/@n8n/benchmark/package.json
|
||||
COPY --chown=node:node ./patches /app/patches
|
||||
COPY --chown=node:node ./scripts /app/scripts
|
||||
|
||||
ENV DOCKER_BUILD=true
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# TS config files
|
||||
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.common.json /app/packages/@n8n/typescript-config/tsconfig.common.json
|
||||
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.build.json /app/packages/@n8n/typescript-config/tsconfig.build.json
|
||||
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.backend.json /app/packages/@n8n/typescript-config/tsconfig.backend.json
|
||||
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json
|
||||
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json
|
||||
|
||||
# Source files
|
||||
COPY --chown=node:node ./packages/@n8n/benchmark/src /app/packages/@n8n/benchmark/src
|
||||
COPY --chown=node:node ./packages/@n8n/benchmark/bin /app/packages/@n8n/benchmark/bin
|
||||
COPY --chown=node:node ./packages/@n8n/benchmark/scenarios /app/packages/@n8n/benchmark/scenarios
|
||||
|
||||
WORKDIR /app/packages/@n8n/benchmark
|
||||
RUN pnpm build
|
||||
|
||||
#
|
||||
# Runner
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=builder /app /app
|
||||
|
||||
WORKDIR /app/packages/@n8n/benchmark
|
||||
USER node
|
||||
|
||||
ENTRYPOINT [ "/app/packages/@n8n/benchmark/bin/n8n-benchmark" ]
|
||||
@@ -0,0 +1,122 @@
|
||||
# n8n benchmarking tool
|
||||
|
||||
Tool for executing benchmarks against an n8n instance.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```text
|
||||
packages/@n8n/benchmark
|
||||
├── scenarios Benchmark scenarios
|
||||
├── src Source code for the n8n-benchmark cli
|
||||
├── Dockerfile Dockerfile for the n8n-benchmark cli
|
||||
├── scripts Orchestration scripts
|
||||
```
|
||||
|
||||
## Benchmarking an existing n8n instance
|
||||
|
||||
The easiest way to run the existing benchmark scenarios is to use the benchmark docker image:
|
||||
|
||||
```sh
|
||||
docker pull ghcr.io/n8n-io/n8n-benchmark:latest
|
||||
# Print the help to list all available flags
|
||||
docker run ghcr.io/n8n-io/n8n-benchmark:latest run --help
|
||||
# Run all available benchmark scenarios for 1 minute with 5 concurrent requests
|
||||
docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
|
||||
--n8nBaseUrl=https://instance.url \
|
||||
--n8nUserEmail=InstanceOwner@email.com \
|
||||
--n8nUserPassword=InstanceOwnerPassword \
|
||||
--vus=5 \
|
||||
--duration=1m \
|
||||
--scenarioFilter=single-webhook
|
||||
```
|
||||
|
||||
### Using custom scenarios with the Docker image
|
||||
|
||||
It is also possible to create your own [benchmark scenarios](#benchmark-scenarios) and load them using the `--testScenariosPath` flag:
|
||||
|
||||
```sh
|
||||
# Assuming your scenarios are located in `./scenarios`, mount them into `/scenarios` in the container
|
||||
docker run -v ./scenarios:/scenarios ghcr.io/n8n-io/n8n-benchmark:latest run \
|
||||
--n8nBaseUrl=https://instance.url \
|
||||
--n8nUserEmail=InstanceOwner@email.com \
|
||||
--n8nUserPassword=InstanceOwnerPassword \
|
||||
--vus=5 \
|
||||
--duration=1m \
|
||||
--testScenariosPath=/scenarios
|
||||
```
|
||||
|
||||
## Running the entire benchmark suite
|
||||
|
||||
The benchmark suite consists of [benchmark scenarios](#benchmark-scenarios) and different [n8n setups](#n8n-setups).
|
||||
|
||||
### locally
|
||||
|
||||
```sh
|
||||
pnpm benchmark-locally
|
||||
```
|
||||
|
||||
You can filter to a specific scenario and setup:
|
||||
|
||||
```sh
|
||||
# Run only the http-node scenario with the sqlite setup
|
||||
pnpm benchmark-locally --runDir /tmp/n8n-data --scenarioFilter http-node sqlite
|
||||
```
|
||||
|
||||
### In the cloud
|
||||
|
||||
```sh
|
||||
pnpm benchmark-in-cloud
|
||||
```
|
||||
|
||||
## Running the `n8n-benchmark` cli
|
||||
|
||||
The `n8n-benchmark` cli is a node.js program that runs one or more scenarios against a single n8n instance.
|
||||
|
||||
### Locally with Docker
|
||||
|
||||
Build the Docker image:
|
||||
|
||||
```sh
|
||||
# Must be run in the repository root
|
||||
# k6 doesn't have an arm64 build available for linux, we need to build against amd64
|
||||
docker build --platform linux/amd64 -t n8n-benchmark -f packages/@n8n/benchmark/Dockerfile .
|
||||
```
|
||||
|
||||
Run the image
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
-e N8N_USER_EMAIL=user@n8n.io \
|
||||
-e N8N_USER_PASSWORD=password \
|
||||
# For macos, n8n running outside docker
|
||||
-e N8N_BASE_URL=http://host.docker.internal:5678 \
|
||||
n8n-benchmark
|
||||
```
|
||||
|
||||
### Locally without Docker
|
||||
|
||||
Requirements:
|
||||
|
||||
- [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)
|
||||
- Node.js v20 or higher
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
|
||||
# Run tests against http://localhost:5678 with specified email and password
|
||||
N8N_USER_EMAIL=user@n8n.io N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
|
||||
```
|
||||
|
||||
## Benchmark scenarios
|
||||
|
||||
A benchmark scenario defines one or multiple steps to execute and measure. It consists of:
|
||||
|
||||
- Manifest file which describes and configures the scenario
|
||||
- Any test data that is imported before the scenario is run
|
||||
- A [`k6`](https://grafana.com/docs/k6/latest/using-k6/http-requests/) script which executes the steps and receives `API_BASE_URL` environment variable in runtime.
|
||||
|
||||
Available scenarios are located in [`./scenarios`](./scenarios/).
|
||||
|
||||
## n8n setups
|
||||
|
||||
A n8n setup defines a single n8n runtime configuration using Docker compose. Different n8n setups are located in [`./scripts/n8nSetups`](./scripts/n8nSetups).
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Check if version should be displayed
|
||||
const versionFlags = ['-v', '-V', '--version'];
|
||||
if (versionFlags.includes(process.argv.slice(-1)[0])) {
|
||||
console.log(require('../package').version);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const oclif = require('@oclif/core');
|
||||
await oclif.execute({ dir: __dirname });
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../biome.jsonc"],
|
||||
"files": {
|
||||
"ignore": ["scripts/mock-api/**"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
|
||||
export default defineConfig(
|
||||
nodeConfig,
|
||||
globalIgnores(['scenarios/**', 'scripts/**']),
|
||||
{
|
||||
rules: {
|
||||
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
|
||||
'n8n-local-rules/no-plain-errors': 'off',
|
||||
complexity: 'error',
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
'no-empty': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./src/commands/*.ts'],
|
||||
rules: {
|
||||
'import-x/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/azurerm" {
|
||||
version = "3.115.0"
|
||||
constraints = "~> 3.115.0"
|
||||
hashes = [
|
||||
"h1:O7C3Xb+MSOc9C/eAJ5C/CiJ4vuvUsYxxIzr9ZurmHNI=",
|
||||
"zh:0ea93abd53cb872691bad6d5625bda88b5d9619ea813c208b36e0ee236308589",
|
||||
"zh:26703cb9c2c38bc43e97bc83af03559d065750856ea85834b71fbcb2ef9d935c",
|
||||
"zh:316255a3391c49fe9bd7c5b6aa53b56dd490e1083d19b722e7b8f956a2dfe004",
|
||||
"zh:431637ae90c592126fb1ec813fee6390604275438a0d5e15904c65b0a6a0f826",
|
||||
"zh:4cee0fa2e84f89853723c0bc72b7debf8ea2ffffc7ae34ff28d8a69269d3a879",
|
||||
"zh:64a3a3c78ea877515365ed336bd0f3abbe71db7c99b3d2837915fbca168d429c",
|
||||
"zh:7380d7b503b5a87fd71a31360c3eeab504f78e4f314824e3ceda724d9dc74cf0",
|
||||
"zh:974213e05708037a6d2d8c58cc84981819138f44fe40e344034eb80e16ca6012",
|
||||
"zh:9a91614de0476074e9c62bbf08d3bb9c64adbd1d3a4a2b5a3e8e41d9d6d5672f",
|
||||
"zh:a438471c85b8788ab21bdef4cd5ca391a46cbae33bd0262668a80f5e6c4610e1",
|
||||
"zh:bf823f2c941b336a1208f015466212b1a8fdf6da28abacf59bea708377709d9e",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.6.2"
|
||||
hashes = [
|
||||
"h1:VavG5unYCa3SYISMKF9pzc3718M0bhPlcbUZZGl7wuo=",
|
||||
"zh:0ef01a4f81147b32c1bea3429974d4d104bbc4be2ba3cfa667031a8183ef88ec",
|
||||
"zh:1bcd2d8161e89e39886119965ef0f37fcce2da9c1aca34263dd3002ba05fcb53",
|
||||
"zh:37c75d15e9514556a5f4ed02e1548aaa95c0ecd6ff9af1119ac905144c70c114",
|
||||
"zh:4210550a767226976bc7e57d988b9ce48f4411fa8a60cd74a6b246baf7589dad",
|
||||
"zh:562007382520cd4baa7320f35e1370ffe84e46ed4e2071fdc7e4b1a9b1f8ae9b",
|
||||
"zh:5efb9da90f665e43f22c2e13e0ce48e86cae2d960aaf1abf721b497f32025916",
|
||||
"zh:6f71257a6b1218d02a573fc9bff0657410404fb2ef23bc66ae8cd968f98d5ff6",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:9647e18f221380a85f2f0ab387c68fdafd58af6193a932417299cdcae4710150",
|
||||
"zh:bb6297ce412c3c2fa9fec726114e5e0508dd2638cad6a0cb433194930c97a544",
|
||||
"zh:f83e925ed73ff8a5ef6e3608ad9225baa5376446349572c2449c0c0b3cf184b7",
|
||||
"zh:fbef0781cb64de76b1df1ca11078aecba7800d82fd4a956302734999cfd9a4af",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.0.5"
|
||||
hashes = [
|
||||
"h1:zeG5RmggBZW/8JWIVrdaeSJa0OG62uFX5HY1eE8SjzY=",
|
||||
"zh:01cfb11cb74654c003f6d4e32bbef8f5969ee2856394a96d127da4949c65153e",
|
||||
"zh:0472ea1574026aa1e8ca82bb6df2c40cd0478e9336b7a8a64e652119a2fa4f32",
|
||||
"zh:1a8ddba2b1550c5d02003ea5d6cdda2eef6870ece86c5619f33edd699c9dc14b",
|
||||
"zh:1e3bb505c000adb12cdf60af5b08f0ed68bc3955b0d4d4a126db5ca4d429eb4a",
|
||||
"zh:6636401b2463c25e03e68a6b786acf91a311c78444b1dc4f97c539f9f78de22a",
|
||||
"zh:76858f9d8b460e7b2a338c477671d07286b0d287fd2d2e3214030ae8f61dd56e",
|
||||
"zh:a13b69fb43cb8746793b3069c4d897bb18f454290b496f19d03c3387d1c9a2dc",
|
||||
"zh:a90ca81bb9bb509063b736842250ecff0f886a91baae8de65c8430168001dad9",
|
||||
"zh:c4de401395936e41234f1956ebadbd2ed9f414e6908f27d578614aaa529870d4",
|
||||
"zh:c657e121af8fde19964482997f0de2d5173217274f6997e16389e7707ed8ece8",
|
||||
"zh:d68b07a67fbd604c38ec9733069fbf23441436fecf554de6c75c032f82e1ef19",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
data "azurerm_resource_group" "main" {
|
||||
name = var.resource_group_name
|
||||
}
|
||||
|
||||
# Random prefix for the resources
|
||||
resource "random_string" "prefix" {
|
||||
length = 8
|
||||
special = false
|
||||
}
|
||||
|
||||
# SSH key pair
|
||||
resource "tls_private_key" "ssh_key" {
|
||||
algorithm = "RSA"
|
||||
rsa_bits = 4096
|
||||
}
|
||||
|
||||
# Dedicated Host Group & Hosts
|
||||
|
||||
resource "azurerm_dedicated_host_group" "main" {
|
||||
name = "${random_string.prefix.result}-hostgroup"
|
||||
location = var.location
|
||||
resource_group_name = data.azurerm_resource_group.main.name
|
||||
platform_fault_domain_count = 1
|
||||
automatic_placement_enabled = false
|
||||
zone = 1
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "azurerm_dedicated_host" "hosts" {
|
||||
name = "${random_string.prefix.result}-host"
|
||||
location = var.location
|
||||
dedicated_host_group_id = azurerm_dedicated_host_group.main.id
|
||||
sku_name = var.host_size_family
|
||||
platform_fault_domain = 0
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
# VM
|
||||
|
||||
module "test_vm" {
|
||||
source = "./modules/benchmark-vm"
|
||||
|
||||
location = var.location
|
||||
resource_group_name = data.azurerm_resource_group.main.name
|
||||
prefix = random_string.prefix.result
|
||||
dedicated_host_id = azurerm_dedicated_host.hosts.id
|
||||
ssh_public_key = tls_private_key.ssh_key.public_key_openssh
|
||||
vm_size = var.vm_size
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
output "vm_name" {
|
||||
value = azurerm_linux_virtual_machine.main.name
|
||||
}
|
||||
|
||||
output "ip" {
|
||||
value = azurerm_public_ip.main.ip_address
|
||||
}
|
||||
|
||||
output "ssh_username" {
|
||||
value = azurerm_linux_virtual_machine.main.admin_username
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
variable "location" {
|
||||
description = "Region to deploy resources"
|
||||
default = "East US"
|
||||
}
|
||||
|
||||
variable "resource_group_name" {
|
||||
description = "Name of the resource group"
|
||||
}
|
||||
|
||||
variable "prefix" {
|
||||
description = "Prefix to append to resources"
|
||||
}
|
||||
|
||||
variable "dedicated_host_id" {
|
||||
description = "Dedicated Host ID"
|
||||
}
|
||||
|
||||
variable "ssh_public_key" {
|
||||
description = "SSH Public Key"
|
||||
}
|
||||
|
||||
variable "vm_size" {
|
||||
description = "VM Size"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
description = "Tags to apply to all resources created by this module"
|
||||
type = map(string)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# Network
|
||||
|
||||
resource "azurerm_virtual_network" "main" {
|
||||
name = "${var.prefix}-vnet"
|
||||
location = var.location
|
||||
resource_group_name = var.resource_group_name
|
||||
address_space = ["10.0.0.0/16"]
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "azurerm_subnet" "main" {
|
||||
name = "${var.prefix}-subnet"
|
||||
resource_group_name = var.resource_group_name
|
||||
virtual_network_name = azurerm_virtual_network.main.name
|
||||
address_prefixes = ["10.0.0.0/24"]
|
||||
}
|
||||
|
||||
resource "azurerm_network_security_group" "ssh" {
|
||||
name = "${var.prefix}-nsg"
|
||||
location = var.location
|
||||
resource_group_name = var.resource_group_name
|
||||
|
||||
security_rule {
|
||||
name = "AllowSSH"
|
||||
priority = 1001
|
||||
direction = "Inbound"
|
||||
access = "Allow"
|
||||
protocol = "Tcp"
|
||||
source_port_range = "*"
|
||||
destination_port_range = "22"
|
||||
source_address_prefix = "*"
|
||||
destination_address_prefix = "*"
|
||||
}
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "azurerm_public_ip" "main" {
|
||||
name = "${var.prefix}-pip"
|
||||
location = var.location
|
||||
resource_group_name = var.resource_group_name
|
||||
allocation_method = "Static"
|
||||
sku = "Standard"
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "azurerm_network_interface" "main" {
|
||||
name = "${var.prefix}-nic"
|
||||
location = var.location
|
||||
resource_group_name = var.resource_group_name
|
||||
|
||||
ip_configuration {
|
||||
name = "${var.prefix}-ipconfig"
|
||||
subnet_id = azurerm_subnet.main.id
|
||||
private_ip_address_allocation = "Dynamic"
|
||||
public_ip_address_id = azurerm_public_ip.main.id
|
||||
}
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "azurerm_network_interface_security_group_association" "ssh" {
|
||||
network_interface_id = azurerm_network_interface.main.id
|
||||
network_security_group_id = azurerm_network_security_group.ssh.id
|
||||
}
|
||||
|
||||
# Disk
|
||||
|
||||
resource "azurerm_managed_disk" "data" {
|
||||
name = "${var.prefix}-disk"
|
||||
location = var.location
|
||||
resource_group_name = var.resource_group_name
|
||||
storage_account_type = "PremiumV2_LRS"
|
||||
create_option = "Empty"
|
||||
disk_size_gb = "32"
|
||||
zone = 1
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
|
||||
resource "azurerm_virtual_machine_data_disk_attachment" "data" {
|
||||
managed_disk_id = azurerm_managed_disk.data.id
|
||||
virtual_machine_id = azurerm_linux_virtual_machine.main.id
|
||||
lun = "1"
|
||||
caching = "None"
|
||||
}
|
||||
|
||||
# VM
|
||||
|
||||
resource "azurerm_linux_virtual_machine" "main" {
|
||||
name = "${var.prefix}-vm"
|
||||
location = var.location
|
||||
resource_group_name = var.resource_group_name
|
||||
network_interface_ids = [azurerm_network_interface.main.id]
|
||||
dedicated_host_id = var.dedicated_host_id
|
||||
zone = 1
|
||||
|
||||
size = var.vm_size
|
||||
|
||||
admin_username = "benchmark"
|
||||
|
||||
admin_ssh_key {
|
||||
username = "benchmark"
|
||||
public_key = var.ssh_public_key
|
||||
}
|
||||
|
||||
os_disk {
|
||||
caching = "ReadWrite"
|
||||
storage_account_type = "Premium_LRS"
|
||||
}
|
||||
|
||||
source_image_reference {
|
||||
publisher = "Canonical"
|
||||
offer = "0001-com-ubuntu-server-jammy"
|
||||
sku = "22_04-lts-gen2"
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
identity {
|
||||
type = "SystemAssigned"
|
||||
}
|
||||
|
||||
tags = var.tags
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
output "vm_name" {
|
||||
value = module.test_vm.vm_name
|
||||
}
|
||||
|
||||
output "ip" {
|
||||
value = module.test_vm.ip
|
||||
}
|
||||
|
||||
output "ssh_username" {
|
||||
value = module.test_vm.ssh_username
|
||||
}
|
||||
|
||||
output "ssh_private_key" {
|
||||
value = tls_private_key.ssh_key.private_key_pem
|
||||
sensitive = true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
azurerm = {
|
||||
source = "hashicorp/azurerm"
|
||||
version = "~> 3.115.0"
|
||||
}
|
||||
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
}
|
||||
}
|
||||
|
||||
required_version = "~> 1.8.5"
|
||||
}
|
||||
|
||||
provider "azurerm" {
|
||||
features {}
|
||||
|
||||
skip_provider_registration = true
|
||||
}
|
||||
|
||||
provider "random" {}
|
||||
@@ -0,0 +1,34 @@
|
||||
variable "location" {
|
||||
description = "Region to deploy resources"
|
||||
default = "East US"
|
||||
}
|
||||
|
||||
variable "resource_group_name" {
|
||||
description = "Name of the resource group"
|
||||
default = "n8n-benchmarking"
|
||||
}
|
||||
|
||||
variable "host_size_family" {
|
||||
description = "Size Family for the Host Group"
|
||||
default = "DCSv2-Type1"
|
||||
}
|
||||
|
||||
variable "vm_size" {
|
||||
description = "VM Size"
|
||||
# 8 vCPUs, 32 GiB memory
|
||||
default = "Standard_DC8_v2"
|
||||
}
|
||||
|
||||
variable "number_of_vms" {
|
||||
description = "Number of VMs to create"
|
||||
default = 1
|
||||
}
|
||||
|
||||
locals {
|
||||
common_tags = {
|
||||
Id = "N8nBenchmark"
|
||||
Terraform = "true"
|
||||
Owner = "Catalysts"
|
||||
CreatedAt = timestamp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@n8n/n8n-benchmark",
|
||||
"version": "2.4.0",
|
||||
"description": "Cli for running benchmark tests for n8n",
|
||||
"main": "dist/index",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"start": "./bin/n8n-benchmark",
|
||||
"test": "echo \"WARNING: no test specified\" && exit 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"benchmark": "zx scripts/run.mjs",
|
||||
"benchmark-in-cloud": "pnpm benchmark --env cloud",
|
||||
"benchmark-locally": "pnpm benchmark --env local",
|
||||
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
|
||||
"destroy-cloud-env": "zx scripts/destroy-cloud-env.mjs",
|
||||
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.16"
|
||||
},
|
||||
"keywords": [
|
||||
"automate",
|
||||
"automation",
|
||||
"IaaS",
|
||||
"iPaaS",
|
||||
"n8n",
|
||||
"workflow",
|
||||
"benchmark",
|
||||
"performance"
|
||||
],
|
||||
"dependencies": {
|
||||
"@oclif/core": "4.0.7",
|
||||
"axios": "catalog:",
|
||||
"dotenv": "17.2.3",
|
||||
"nanoid": "catalog:",
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@types/convict": "^6.1.1",
|
||||
"@types/k6": "^0.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"n8n-benchmark": "./bin/n8n-benchmark"
|
||||
},
|
||||
"oclif": {
|
||||
"bin": "n8n-benchmark",
|
||||
"commands": "./dist/commands",
|
||||
"topicSeparator": " "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"createdAt": "2024-09-03T11:51:56.540Z",
|
||||
"updatedAt": "2024-09-03T12:22:21.000Z",
|
||||
"name": "Binary Data",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "binary-files-benchmark",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [0, 0],
|
||||
"id": "bfe19f12-3655-440f-be5c-8d71665c6353",
|
||||
"name": "Webhook",
|
||||
"webhookId": "109d7b13-93ad-42b0-a9ce-ca49e1817b35"
|
||||
},
|
||||
{
|
||||
"parameters": { "respondWith": "binary", "options": {} },
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [740, 0],
|
||||
"id": "cd957c9b-6b7a-4423-aac3-6df4d8bb571e",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "write",
|
||||
"fileName": "=file-{{ Date.now() }}-{{ Math.random() }}.js",
|
||||
"dataPropertyName": "file",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.readWriteFile",
|
||||
"typeVersion": 1,
|
||||
"position": [260, 0],
|
||||
"id": "f2ce4709-7697-4bc6-8eca-6c222485297a",
|
||||
"name": "Write File to Disk"
|
||||
},
|
||||
{
|
||||
"parameters": { "fileSelector": "={{ $json.fileName }}", "options": {} },
|
||||
"type": "n8n-nodes-base.readWriteFile",
|
||||
"typeVersion": 1,
|
||||
"position": [500, 0],
|
||||
"id": "198e8a6c-81a3-4b34-b099-501961a02006",
|
||||
"name": "Read File from Disk"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": { "main": [[{ "node": "Write File to Disk", "type": "main", "index": 0 }]] },
|
||||
"Write File to Disk": {
|
||||
"main": [[{ "node": "Read File from Disk", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Read File from Disk": {
|
||||
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": null,
|
||||
"pinData": {},
|
||||
"versionId": "8dd197c0-d1ea-43c3-9f88-9d11e7b081a0",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "BinaryData",
|
||||
"description": "Send a binary file to a webhook, write it to FS, read it from FS and receive it back",
|
||||
"scenarioData": { "workflowFiles": ["binary-data.json"] },
|
||||
"scriptPath": "binary-data.script.js"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
// This creates a 2MB file (16 * 128 * 1024 = 2 * 1024 * 1024 = 2MB)
|
||||
const file = Array.from({ length: 128 * 1024 }, () => Math.random().toString().slice(2)).join('');
|
||||
const filename = 'test.bin';
|
||||
|
||||
export default function () {
|
||||
const data = {
|
||||
filename,
|
||||
file: http.file(file, filename, 'application/javascript'),
|
||||
};
|
||||
|
||||
const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data);
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
'has correct content type': (r) =>
|
||||
r.headers['Content-Type'] === 'application/javascript; charset=utf-8',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "httpBearerAuth",
|
||||
"name": "Dummy HTTP credential",
|
||||
"data": {
|
||||
"token": "dummy token"
|
||||
},
|
||||
"id": "0fqzOReozl2aQvtl"
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"name": "Credential HTTP Request",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "benchmark-credential-http-node",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [-64, 32],
|
||||
"id": "7dd66dcc-03c7-4898-ab3d-d2765730e3f3",
|
||||
"name": "Webhook",
|
||||
"webhookId": "92b141cd-6e59-4425-9c0a-e2ee0f4faad2"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "allIncomingItems",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [1072, 32],
|
||||
"id": "e074e6a7-2417-4fde-8b6b-bc68069e833b",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://mockapi:8080/users/clair.bahringer/received_events/public",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpBearerAuth",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [304, -176],
|
||||
"id": "28ddbbf9-56a5-431e-afe9-3c44b21aa676",
|
||||
"name": "Mock public received events",
|
||||
"credentials": {
|
||||
"httpBearerAuth": {
|
||||
"id": "0fqzOReozl2aQvtl",
|
||||
"name": "Dummy HTTP credential"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://mockapi:8080/repos/udke6pujoywnagxkcvab2riw23khzn2tibo2vincws32qexb50ey7h97d42vnzyol0rxypgsg4pomsf7sgnmdaihstljw8edcijrwmy7mfi76yif19c4/47i31dh737el215j62ts2f2782nw3ss26rul3s8jw13u3vu0xm349a5hyay5asmwnlnf7nx8p9h4g62so6s1cis7xv9puj5j98t4m980sbe2455fn1obccjl/events",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpBearerAuth",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [304, 32],
|
||||
"id": "3ce8827c-6226-467e-a4da-9891c1acd863",
|
||||
"name": "Mock repository events",
|
||||
"credentials": {
|
||||
"httpBearerAuth": {
|
||||
"id": "0fqzOReozl2aQvtl",
|
||||
"name": "Dummy HTTP credential"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://mockapi:8080/orgs/g02pp066qoyithcjevhd6m1wfii3c4x51k39n9apybljhx69/events",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpBearerAuth",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [304, 224],
|
||||
"id": "a8e416ab-50cc-4e50-8d9a-9fcf5d4bbdc8",
|
||||
"name": "Mock organization events",
|
||||
"credentials": {
|
||||
"httpBearerAuth": {
|
||||
"id": "0fqzOReozl2aQvtl",
|
||||
"name": "Dummy HTTP credential"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"numberInputs": 3
|
||||
},
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3,
|
||||
"position": [608, 32],
|
||||
"id": "542a27d4-3a03-4c22-a79a-7266050c519e",
|
||||
"name": "Merge"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "89608adb-f487-416f-a7d8-3ebb1f7b50e5",
|
||||
"name": "statusCode",
|
||||
"value": "={{ $json.statusCode }}",
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "35d4bfbb-d4be-49f4-a5dd-bd5c67a48200",
|
||||
"name": "Select statusCode",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [832, 32]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Webhook": [
|
||||
{
|
||||
"json": {
|
||||
"headers": {
|
||||
"host": "localhost:5678",
|
||||
"user-agent": "curl/8.6.0",
|
||||
"accept": "*/*"
|
||||
},
|
||||
"params": {},
|
||||
"query": {},
|
||||
"body": {},
|
||||
"webhookUrl": "http://localhost:5678/webhook-test/benchmark-credential-http-node",
|
||||
"executionMode": "test"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Mock public received events",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Mock repository events",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Mock organization events",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock public received events": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock repository events": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock organization events": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 2
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Merge": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Select statusCode",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Select statusCode": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "96bfc5ef-9421-47f5-9fdd-432dbd4bc4ca",
|
||||
"meta": {
|
||||
"instanceId": "4141065f11bd5ed73fef4f9b1d91842ded0ec4058e6640a98aa14384e269204b"
|
||||
},
|
||||
"id": "6V8rTiIDqZOniAs1",
|
||||
"tags": []
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "CredentialHttpNode",
|
||||
"description": "Webhook -> 3x HTTP request to a mock API -> Merge -> Respond to Webhook. Requires a mock API running at http://mockapi:8080",
|
||||
"scenarioData": {
|
||||
"workflowFiles": ["credential-http-node.json"],
|
||||
"credentialFiles": ["credential-bearer.json"]
|
||||
},
|
||||
"scriptPath": "credential-http-node.script.js"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const res = http.post(`${apiBaseUrl}/webhook/benchmark-credential-http-node`);
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
'http requests were OK': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
|
||||
try {
|
||||
// Response body is an array of the request status codes made with HttpNodes
|
||||
const body = JSON.parse(r.body);
|
||||
return Array.isArray(body) ? body.every((request) => request.statusCode === 200) : false;
|
||||
} catch (error) {
|
||||
console.error('Error parsing response body: ', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"createdAt": "2025-09-24T12:19:51.268Z",
|
||||
"updatedAt": "2025-09-24T12:20:45.000Z",
|
||||
"name": "Data table node",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "allIncomingItems",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.4,
|
||||
"position": [688, 0],
|
||||
"id": "a7eeb256-a47e-4fe9-a0e1-b33015912b15",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "data-table-node-benchmark",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [-288, 0],
|
||||
"id": "b54681a1-6cfc-4970-ad33-25cb8fec6936",
|
||||
"name": "Webhook",
|
||||
"webhookId": "4748ca75-3b3c-4e4a-8913-3590fdc1867d"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"fieldToSplitOut": "body.items",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.splitOut",
|
||||
"typeVersion": 1,
|
||||
"position": [96, 0],
|
||||
"id": "019d451f-604f-4ed6-8f40-1beb86445061",
|
||||
"name": "Split Out"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"dataTableId": {
|
||||
"__rl": true,
|
||||
"value": "={{ $('Webhook').item.json.body.dataTableId }}",
|
||||
"mode": "list"
|
||||
},
|
||||
"columns": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"isActive": "={{ $json.isActive }}",
|
||||
"firstName": "={{ $json.firstName }}",
|
||||
"birthDate": "={{ $json.birthDate }}",
|
||||
"age": "={{ $json.age }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [
|
||||
{
|
||||
"id": "firstName",
|
||||
"displayName": "firstName",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"readOnly": false,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "age",
|
||||
"displayName": "age",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "number",
|
||||
"readOnly": false,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "birthDate",
|
||||
"displayName": "birthDate",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "dateTime",
|
||||
"readOnly": false,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "isActive",
|
||||
"displayName": "isActive",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "boolean",
|
||||
"readOnly": false,
|
||||
"removed": false
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"displayName": "empty",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"type": "string",
|
||||
"readOnly": false,
|
||||
"removed": false
|
||||
}
|
||||
],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.dataTable",
|
||||
"typeVersion": 1,
|
||||
"position": [448, 0],
|
||||
"id": "05a47403-6c7d-4aad-b0eb-ca726e7594c9",
|
||||
"name": "Insert row"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Split Out",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Insert row": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Split Out": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Insert row",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"versionId": "cdd59544-4b5d-42c0-a07f-8ab48a9d849c",
|
||||
"triggerCount": 1,
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"responseMode": "lastNode",
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "DataTableNode",
|
||||
"description": "A Data table node that first inserts 100 items and then reads them from the table. The data is returned with RespondToWebhook Node.",
|
||||
"scenarioData": {
|
||||
"workflowFiles": ["data-table-node.json"],
|
||||
"dataTableFile": "data-table.json"
|
||||
},
|
||||
"scriptPath": "data-table-node.script.js"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
const dataTableId = __ENV.DATA_TABLE_ID;
|
||||
|
||||
export default function () {
|
||||
const res = http.post(`${apiBaseUrl}/webhook/data-table-node-benchmark`, {
|
||||
dataTableId: dataTableId,
|
||||
items: Array.from({ length: 100 }, (_, i) => ({
|
||||
firstName: `Item ${i + 1}`,
|
||||
age: Math.floor(Math.random() * 100),
|
||||
birthDate: new Date(),
|
||||
isActive: i % 2 === 0,
|
||||
})),
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
'has items in response': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
|
||||
try {
|
||||
const body = JSON.parse(r.body);
|
||||
return Array.isArray(body) ? body.length === 100 : false;
|
||||
} catch (error) {
|
||||
console.error('Error parsing response body: ', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "data-table-node-benchmark",
|
||||
"columns": [
|
||||
{
|
||||
"name": "firstName",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "age",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"name": "birthDate",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"name": "isActive",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "empty",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"createdAt": "2024-09-04T07:18:29.011Z",
|
||||
"updatedAt": "2024-09-04T07:27:58.000Z",
|
||||
"id": "rUXzWNGsUDUmgaFS",
|
||||
"name": "HTTP Request",
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "benchmark-http-node",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [-60, 20],
|
||||
"id": "f11378b4-5f28-4a6c-9332-5878342cd3cf",
|
||||
"name": "Webhook",
|
||||
"webhookId": "c40014cc-4d64-4fcf-8c13-9e94b6792756"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "allIncomingItems",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [1060, 20],
|
||||
"id": "f42552c7-9c6e-4616-b9d5-ac79445ef4ed",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://mockapi:8080/users/clair.bahringer/received_events/public",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [300, -180],
|
||||
"id": "20de816e-0fbe-4e28-bc53-f508a2dda117",
|
||||
"name": "Mock public received events"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://mockapi:8080/repos/udke6pujoywnagxkcvab2riw23khzn2tibo2vincws32qexb50ey7h97d42vnzyol0rxypgsg4pomsf7sgnmdaihstljw8edcijrwmy7mfi76yif19c4/47i31dh737el215j62ts2f2782nw3ss26rul3s8jw13u3vu0xm349a5hyay5asmwnlnf7nx8p9h4g62so6s1cis7xv9puj5j98t4m980sbe2455fn1obccjl/events",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [300, 20],
|
||||
"id": "083e02b3-a257-49a8-8f7d-42222cb9194c",
|
||||
"name": "Mock repository events"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://mockapi:8080/orgs/g02pp066qoyithcjevhd6m1wfii3c4x51k39n9apybljhx69/events",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [300, 220],
|
||||
"id": "f4c3b5d2-0257-4883-a585-ade4c3a1082c",
|
||||
"name": "Mock organization events"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"numberInputs": 3
|
||||
},
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3,
|
||||
"position": [600, 20],
|
||||
"id": "273985b7-b0ae-4cde-bbe9-7b3e4b29fe61",
|
||||
"name": "Merge"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "89608adb-f487-416f-a7d8-3ebb1f7b50e5",
|
||||
"name": "statusCode",
|
||||
"value": "={{ $json.statusCode }}",
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "231275a7-44e7-47bb-8ccf-fe62dc48356b",
|
||||
"name": "Select statusCode",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [820, 20]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Mock public received events",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Mock repository events",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Mock organization events",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock public received events": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock repository events": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Mock organization events": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 2
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Merge": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Select statusCode",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Select statusCode": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": null,
|
||||
"pinData": {
|
||||
"Webhook": [
|
||||
{
|
||||
"json": {
|
||||
"headers": { "host": "localhost:5678", "user-agent": "curl/8.6.0", "accept": "*/*" },
|
||||
"params": {},
|
||||
"query": {},
|
||||
"body": {},
|
||||
"webhookUrl": "http://localhost:5678/webhook-test/benchmark-http-node",
|
||||
"executionMode": "test"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"versionId": "9fa91e54-e73a-4a34-b781-d64f2b02f333",
|
||||
"triggerCount": 0,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "HttpNode",
|
||||
"description": "Webhook -> 3x HTTP request to a mock API -> Merge -> Respond to Webhook. Requires a mock API running at http://mockapi:8080",
|
||||
"scenarioData": { "workflowFiles": ["http-node.json"] },
|
||||
"scriptPath": "http-node.script.js"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`);
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
'http requests were OK': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
|
||||
try {
|
||||
// Response body is an array of the request status codes made with HttpNodes
|
||||
const body = JSON.parse(r.body);
|
||||
return Array.isArray(body) ? body.every((request) => request.statusCode === 200) : false;
|
||||
} catch (error) {
|
||||
console.error('Error parsing response body: ', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "JS Code Node",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "allIncomingItems",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [1280, 460],
|
||||
"id": "0067e317-09b8-478a-8c50-e19b4c9e294c",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"jsCode": "// Add new field\n$input.item.json.age = 10 + Math.floor(Math.random() * 30);\n// Mutate existing field\n$input.item.json.password = $input.item.json.password.split('').map(() => '*').join(\"\")\n// Remove field\ndelete $input.item.json.lastname\n// New object field\nconst emailParts = $input.item.json.email.split(\"@\")\n$input.item.json.emailData = {\n user: emailParts[0],\n domain: emailParts[1]\n}\n\nreturn $input.item;"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1040, 460],
|
||||
"id": "56d751c0-0d30-43c3-89fa-bebf3a9d436f",
|
||||
"name": "OnceForEachItemJSCode"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "code-node-benchmark",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [580, 460],
|
||||
"id": "417d749d-156c-4ffe-86ea-336f702dc5da",
|
||||
"name": "Webhook",
|
||||
"webhookId": "34ca1895-ccf4-4a4a-8bb8-a042f5edb567"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const digits = '0123456789';\nconst uppercaseLetters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';\nconst lowercaseLetters = uppercaseLetters.toLowerCase();\nconst alphabet = [digits, uppercaseLetters, lowercaseLetters].join('').split('')\n\nconst randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;\nconst randomItem = (arr) => arr.at(randomInt(0, arr.length - 1))\nconst randomString = (len) => Array.from({ length: len }).map(() => randomItem(alphabet)).join('')\n\nconst randomUid = () => [8,4,4,4,8].map(len => randomString(len)).join(\"-\")\nconst randomEmail = () => `${randomString(8)}@${randomString(10)}.com`\n\nconst randomPerson = () => ({\n uid: randomUid(),\n email: randomEmail(),\n firstname: randomString(5),\n lastname: randomString(12),\n password: randomString(10)\n})\n\nreturn Array.from({ length: 100 }).map(() => ({\n json: randomPerson()\n}))"
|
||||
},
|
||||
"id": "c30db155-73ca-48b9-8860-c3fe7a0926fb",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [820, 460]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"OnceForEachItemJSCode": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "OnceForEachItemJSCode",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "CodeNodeJs",
|
||||
"description": "A JS Code Node that first generates 100 items and then runs once for each item and adds, modifies and removes properties. The data is returned with RespondToWebhook Node.",
|
||||
"scenarioData": { "workflowFiles": ["js-code-node.json"] },
|
||||
"scriptPath": "js-code-node.script.js"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const res = http.post(`${apiBaseUrl}/webhook/code-node-benchmark`, {});
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
'has items in response': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
|
||||
try {
|
||||
const body = JSON.parse(r.body);
|
||||
return Array.isArray(body) ? body.length === 100 : false;
|
||||
} catch (error) {
|
||||
console.error('Error parsing response body: ', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "MultipleWebhooks",
|
||||
"description": "10 simple webhooks that respond with a 200 status code",
|
||||
"scenarioData": {
|
||||
"workflowFiles": [
|
||||
"multiple-webhooks1.json",
|
||||
"multiple-webhooks2.json",
|
||||
"multiple-webhooks3.json",
|
||||
"multiple-webhooks4.json",
|
||||
"multiple-webhooks5.json",
|
||||
"multiple-webhooks6.json",
|
||||
"multiple-webhooks7.json",
|
||||
"multiple-webhooks8.json",
|
||||
"multiple-webhooks9.json",
|
||||
"multiple-webhooks10.json"
|
||||
]
|
||||
},
|
||||
"scriptPath": "multiple-webhooks.script.js"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const urls = Array(10)
|
||||
.fill()
|
||||
.map((_, i) => `${apiBaseUrl}/webhook/multiple-webhook${i + 1}`);
|
||||
|
||||
const res = http.batch(urls);
|
||||
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
// Check if the response status is 200
|
||||
check(res[i], {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 1",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook1", "options": {} },
|
||||
"id": "b239bc6c-ea40-438d-bb14-5bce03599685",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "14226baf-ea56-43d7-bc0d-eea841746441"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "0322386c-ec84-4d38-b895-c206b574f31c",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 10",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook10", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 2",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook2", "options": {} },
|
||||
"id": "c44ce610-086c-45a6-b347-c7b5df5365ed",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "edc1994b-7cde-4aed-b077-fbc7f21b0f48"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "b4579dbf-d9c5-4b57-8e1f-883d91e8726b",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 3",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook3", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 4",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook4", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 5",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook5", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 6",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook6", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 7",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook7", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 8",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook8", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Multiple Webhook 9",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "multiple-webhook9", "options": {} },
|
||||
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Python Code Node",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "allIncomingItems",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [1280, 460],
|
||||
"id": "0067e317-09b8-478a-8c50-e19b4c9e294c",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"language": "pythonNative",
|
||||
"mode": "runOnceForEachItem",
|
||||
"pythonCode": "def pseudo_random(seed_str, max_val):\n return hash(seed_str) % max_val\n\n# Add new field\n_item['json']['age'] = 10 + pseudo_random(str(_item['json']['email']), 30)\n\n# Mutate existing field\n_item['json']['password'] = '*' * len(_item['json']['password'])\n\n# Remove field\nif 'lastname' in _item['json']:\n del _item['json']['lastname']\n\n# New object field\nemail_parts = _item['json']['email'].split('@')\n_item['json']['emailData'] = {\n 'user': email_parts[0],\n 'domain': email_parts[1]\n}\n\nreturn _item"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1040, 460],
|
||||
"id": "56d751c0-0d30-43c3-89fa-bebf3a9d436f",
|
||||
"name": "OnceForEachItemPythonCode"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "py-code-node-benchmark",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [580, 460],
|
||||
"id": "417d749d-156c-4ffe-86ea-336f702dc5da",
|
||||
"name": "Webhook",
|
||||
"webhookId": "34ca1895-ccf4-4a4a-8bb8-a042f5edb567"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"language": "pythonNative",
|
||||
"pythonCode": "def pseudo_random_string(length):\n characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\n result = ''\n seed = hash(str(length)) % 1000\n for i in range(length):\n index = (hash(str(seed + i)) % len(characters))\n result += characters[index]\n seed = (seed * 31 + i) % 1000\n return result\n\ndef random_uid():\n lengths = [8, 4, 4, 4, 8]\n parts = [pseudo_random_string(length) for length in lengths]\n return '-'.join(parts)\n\ndef random_email():\n return f\"{pseudo_random_string(8)}@{pseudo_random_string(10)}.com\"\n\ndef random_person():\n return {\n 'uid': random_uid(),\n 'email': random_email(),\n 'firstname': pseudo_random_string(5),\n 'lastname': pseudo_random_string(12),\n 'password': pseudo_random_string(10)\n }\n\nreturn [{'json': random_person()} for _ in range(100)]"
|
||||
},
|
||||
"id": "c30db155-73ca-48b9-8860-c3fe7a0926fb",
|
||||
"name": "Code",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [820, 460]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"OnceForEachItemPythonCode": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Code": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "OnceForEachItemPythonCode",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "CodeNodePython",
|
||||
"description": "A Python Code Node that first generates 100 items and then runs once for each item and adds, modifies and removes properties. The data is returned with RespondToWebhook Node.",
|
||||
"scenarioData": { "workflowFiles": ["py-code-node.json"] },
|
||||
"scriptPath": "py-code-node.script.js"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const res = http.post(`${apiBaseUrl}/webhook/py-code-node-benchmark`, {});
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
'has items in response': (r) => {
|
||||
if (r.status !== 200) return false;
|
||||
|
||||
try {
|
||||
const body = JSON.parse(r.body);
|
||||
return Array.isArray(body) ? body.length === 100 : false;
|
||||
} catch (error) {
|
||||
console.error('Error parsing response body: ', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"definitions": {
|
||||
"ScenarioData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflowFiles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"credentialFiles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"dataTableFile": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "The JSON schema to validate this file"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the scenario"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A longer description of the scenario"
|
||||
},
|
||||
"scriptPath": {
|
||||
"type": "string",
|
||||
"description": "Relative path to the k6 test script"
|
||||
},
|
||||
"scenarioData": {
|
||||
"$ref": "#/definitions/ScenarioData",
|
||||
"description": "Data to import before running the scenario"
|
||||
}
|
||||
},
|
||||
"required": ["name", "description", "scriptPath", "scenarioData"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"createdAt": "2024-09-03T11:30:26.333Z",
|
||||
"updatedAt": "2024-09-03T11:42:52.000Z",
|
||||
"name": "Set Node Expressions",
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "set-expressions-benchmark",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [40, 0],
|
||||
"id": "5babc228-2b89-48cb-8337-28416e867874",
|
||||
"name": "Webhook",
|
||||
"webhookId": "f6f1750d-b734-496f-afe8-26e8e393ca87"
|
||||
},
|
||||
{
|
||||
"parameters": { "respondWith": "allIncomingItems", "options": {} },
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [640, 0],
|
||||
"id": "4146a3fb-403c-4cfc-9d38-8af4d16a8440",
|
||||
"name": "Respond to Webhook"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "48c46098-f411-41f7-8f0a-1da372340a4e",
|
||||
"name": "oneToOneCopy",
|
||||
"value": "={{ $json.headers.host }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "5d90808b-1c1a-4065-ac51-6d61bd03e564",
|
||||
"name": "={{ $json.headers['user-agent'].slice(0, 4) }}",
|
||||
"value": "Set key with expression",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "8a74ac24-1f43-43ba-969d-87bfd2f401ce",
|
||||
"name": "Multiple variables",
|
||||
"value": "={{ $json.executionMode + ' ' + $json.webhookUrl }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "93eba201-79d9-4305-a246-f9c8ec50ebab",
|
||||
"name": "Static value",
|
||||
"value": 42,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"id": "0470a712-c795-44ab-9dcc-05a3f67698bb",
|
||||
"name": "Object",
|
||||
"value": "={{ $json.headers }}",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"id": "eb671167-da14-4b55-8eea-31ab7bedae10",
|
||||
"name": "Array",
|
||||
"value": "={{ Object.values($json.headers) }}",
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [360, 0],
|
||||
"id": "0cb5e82d-f61e-4d91-8fa9-365e382a4d75",
|
||||
"name": "Edit Fields"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": { "main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]] },
|
||||
"Edit Fields": { "main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]] }
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": null,
|
||||
"pinData": {},
|
||||
"versionId": "04fd543e-3923-4092-8c2b-2b4262ccbb38",
|
||||
"triggerCount": 0,
|
||||
"tags": []
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "SetNodeExpressions",
|
||||
"description": "Expressions in a Set node",
|
||||
"scenarioData": { "workflowFiles": ["set-node-expressions.json"] },
|
||||
"scriptPath": "set-node-expressions.script.js"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const res = http.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {});
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"createdAt": "2024-08-06T12:19:51.268Z",
|
||||
"updatedAt": "2024-08-06T12:20:45.000Z",
|
||||
"name": "Single Webhook",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": { "path": "single-webhook", "options": {} },
|
||||
"id": "7587ab0e-cc15-424f-83c0-c887a0eb97fb",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [760, 400],
|
||||
"webhookId": "fa563fc2-c73f-4631-99a1-39c16f1f858f"
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"staticData": null,
|
||||
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
|
||||
"pinData": {},
|
||||
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
|
||||
"triggerCount": 1,
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../scenario.schema.json",
|
||||
"name": "SingleWebhook",
|
||||
"description": "A single webhook trigger that responds with a 200 status code",
|
||||
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
|
||||
"scriptPath": "single-webhook.script.js"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
|
||||
const apiBaseUrl = __ENV.API_BASE_URL;
|
||||
|
||||
export default function () {
|
||||
const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
check(res, {
|
||||
'is status 200': (r) => r.status === 200,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Script to initialize the benchmark environment on a VM
|
||||
#
|
||||
|
||||
set -euo pipefail;
|
||||
|
||||
CURRENT_USER=$(whoami)
|
||||
|
||||
# Mount the data disk
|
||||
# First wait for the disk to become available
|
||||
WAIT_TIME=0
|
||||
MAX_WAIT_TIME=60
|
||||
|
||||
while [ ! -e /dev/sdc ]; do
|
||||
if [ $WAIT_TIME -ge $MAX_WAIT_TIME ]; then
|
||||
echo "Error: /dev/sdc did not become available within $MAX_WAIT_TIME seconds."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for /dev/sdc to be available... ($WAIT_TIME/$MAX_WAIT_TIME)"
|
||||
sleep 1
|
||||
WAIT_TIME=$((WAIT_TIME + 1))
|
||||
done
|
||||
|
||||
# Then mount it
|
||||
if [ -d "/n8n" ]; then
|
||||
echo "Data disk already mounted. Clearing it..."
|
||||
sudo rm -rf /n8n/*
|
||||
sudo rm -rf /n8n/.[!.]*
|
||||
else
|
||||
sudo mkdir -p /n8n
|
||||
sudo parted /dev/sdc --script mklabel gpt mkpart xfspart xfs 0% 100%
|
||||
sudo mkfs.xfs /dev/sdc1
|
||||
sudo partprobe /dev/sdc1
|
||||
sudo mount /dev/sdc1 /n8n
|
||||
sudo chown -R "$CURRENT_USER":"$CURRENT_USER" /n8n
|
||||
fi
|
||||
|
||||
### Remove unneeded dependencies
|
||||
# TTY
|
||||
sudo systemctl disable getty@tty1.service
|
||||
sudo systemctl disable serial-getty@ttyS0.service
|
||||
# Snap
|
||||
sudo systemctl disable snapd.service
|
||||
# Unattended upgrades
|
||||
sudo systemctl disable unattended-upgrades.service
|
||||
# Cron
|
||||
sudo systemctl disable cron.service
|
||||
|
||||
# Include nodejs v20 repository
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
||||
sudo -E bash nodesource_setup.sh
|
||||
|
||||
# Install docker, docker compose, nodejs
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get update -yq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yq docker.io docker-compose nodejs
|
||||
|
||||
# Add the current user to the docker group
|
||||
sudo usermod -aG docker "$CURRENT_USER"
|
||||
|
||||
# Install zx
|
||||
npm install zx
|
||||
@@ -0,0 +1,45 @@
|
||||
import { which } from 'zx';
|
||||
|
||||
export class DockerComposeClient {
|
||||
/**
|
||||
*
|
||||
* @param {{ $: Shell; verbose?: boolean }} opts
|
||||
*/
|
||||
constructor({ $ }) {
|
||||
this.$$ = $;
|
||||
}
|
||||
|
||||
async $(...args) {
|
||||
await this.resolveExecutableIfNeeded();
|
||||
|
||||
if (this.isCompose) {
|
||||
return await this.$$`docker-compose ${args}`;
|
||||
} else {
|
||||
return await this.$$`docker compose ${args}`;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveExecutableIfNeeded() {
|
||||
if (this.isResolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The VM deployment doesn't have `docker compose` available,
|
||||
// so try to resolve the `docker-compose` first
|
||||
const compose = await which('docker-compose', { nothrow: true });
|
||||
if (compose) {
|
||||
this.isResolved = true;
|
||||
this.isCompose = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const docker = await which('docker', { nothrow: true });
|
||||
if (docker) {
|
||||
this.isResolved = true;
|
||||
this.isCompose = false;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Could not resolve docker-compose or docker');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-check
|
||||
import { $ } from 'zx';
|
||||
|
||||
export class SshClient {
|
||||
/**
|
||||
*
|
||||
* @param {{ privateKeyPath: string; ip: string; username: string; verbose?: boolean }} param0
|
||||
*/
|
||||
constructor({ privateKeyPath, ip, username, verbose = false }) {
|
||||
this.verbose = verbose;
|
||||
this.privateKeyPath = privateKeyPath;
|
||||
this.ip = ip;
|
||||
this.username = username;
|
||||
|
||||
this.$$ = $({
|
||||
verbose,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} command
|
||||
* @param {{ verbose?: boolean }} [options]
|
||||
*/
|
||||
async ssh(command, options = {}) {
|
||||
const $$ = options?.verbose ? $({ verbose: true }) : this.$$;
|
||||
|
||||
const target = `${this.username}@${this.ip}`;
|
||||
|
||||
await $$`ssh -i ${this.privateKeyPath} -o StrictHostKeyChecking=accept-new ${target} ${command}`;
|
||||
}
|
||||
|
||||
async scp(source, destination) {
|
||||
const target = `${this.username}@${this.ip}:${destination}`;
|
||||
await this
|
||||
.$$`scp -i ${this.privateKeyPath} -o StrictHostKeyChecking=accept-new ${source} ${target}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// @ts-check
|
||||
|
||||
import path from 'path';
|
||||
import { $, fs } from 'zx';
|
||||
|
||||
const paths = {
|
||||
infraCodeDir: path.resolve('infra'),
|
||||
terraformStateFile: path.join(path.resolve('infra'), 'terraform.tfstate'),
|
||||
};
|
||||
|
||||
export class TerraformClient {
|
||||
constructor({ isVerbose = false }) {
|
||||
this.isVerbose = isVerbose;
|
||||
this.$$ = $({
|
||||
cwd: paths.infraCodeDir,
|
||||
verbose: isVerbose,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisions the environment
|
||||
*/
|
||||
async provisionEnvironment() {
|
||||
console.log('Provisioning cloud environment...');
|
||||
|
||||
await this.$$`terraform init`;
|
||||
await this.$$`terraform apply -input=false -auto-approve`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} BenchmarkEnv
|
||||
* @property {string} vmName
|
||||
* @property {string} ip
|
||||
* @property {string} sshUsername
|
||||
* @property {string} sshPrivateKeyPath
|
||||
*
|
||||
* @returns {Promise<BenchmarkEnv>}
|
||||
*/
|
||||
async getTerraformOutputs() {
|
||||
const privateKeyName = await this.extractPrivateKey();
|
||||
|
||||
return {
|
||||
ip: await this.getTerraformOutput('ip'),
|
||||
sshUsername: await this.getTerraformOutput('ssh_username'),
|
||||
sshPrivateKeyPath: path.join(paths.infraCodeDir, privateKeyName),
|
||||
vmName: await this.getTerraformOutput('vm_name'),
|
||||
};
|
||||
}
|
||||
|
||||
hasTerraformState() {
|
||||
return fs.existsSync(paths.terraformStateFile);
|
||||
}
|
||||
|
||||
async destroyEnvironment() {
|
||||
console.log('Destroying cloud environment...');
|
||||
|
||||
await this.$$`terraform destroy -input=false -auto-approve`;
|
||||
}
|
||||
|
||||
async getTerraformOutput(key) {
|
||||
const output = await this.$$`terraform output -raw ${key}`;
|
||||
return output.stdout.trim();
|
||||
}
|
||||
|
||||
async extractPrivateKey() {
|
||||
await this.$$`terraform output -raw ssh_private_key > privatekey.pem`;
|
||||
await this.$$`chmod 600 privatekey.pem`;
|
||||
|
||||
return 'privatekey.pem';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env zx
|
||||
/**
|
||||
* Script that deletes all resources created by the benchmark environment.
|
||||
*
|
||||
* This scripts tries to delete resources created by Terraform. If Terraform
|
||||
* state file is not found, it will try to delete resources using Azure CLI.
|
||||
* The terraform state is not persisted, so we want to support both cases.
|
||||
*/
|
||||
// @ts-check
|
||||
import { $, minimist } from 'zx';
|
||||
import { TerraformClient } from './clients/terraform-client.mjs';
|
||||
|
||||
const RESOURCE_GROUP_NAME = 'n8n-benchmarking';
|
||||
|
||||
const args = minimist(process.argv.slice(3), {
|
||||
boolean: ['debug'],
|
||||
});
|
||||
|
||||
const isVerbose = !!args.debug;
|
||||
|
||||
async function main() {
|
||||
const terraformClient = new TerraformClient({ isVerbose });
|
||||
|
||||
if (terraformClient.hasTerraformState()) {
|
||||
await terraformClient.destroyEnvironment();
|
||||
} else {
|
||||
await destroyUsingAz();
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyUsingAz() {
|
||||
const resourcesResult =
|
||||
await $`az resource list --resource-group ${RESOURCE_GROUP_NAME} --query "[?tags.Id == 'N8nBenchmark'].{id:id, createdAt:tags.CreatedAt}" -o json`;
|
||||
|
||||
const resources = JSON.parse(resourcesResult.stdout);
|
||||
|
||||
const resourcesToDelete = resources.map((resource) => resource.id);
|
||||
|
||||
if (resourcesToDelete.length === 0) {
|
||||
console.log('No resources found in the resource group.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteResources(resourcesToDelete);
|
||||
}
|
||||
|
||||
async function deleteResources(resourceIds) {
|
||||
// We don't know the order in which resource should be deleted.
|
||||
// Here's a poor person's approach to try deletion until all complete
|
||||
const MAX_ITERATIONS = 100;
|
||||
let i = 0;
|
||||
const toDelete = [...resourceIds];
|
||||
|
||||
console.log(`Deleting ${resourceIds.length} resources...`);
|
||||
while (toDelete.length > 0) {
|
||||
const resourceId = toDelete.shift();
|
||||
const deleted = await deleteById(resourceId);
|
||||
if (!deleted) {
|
||||
toDelete.push(resourceId);
|
||||
}
|
||||
|
||||
if (i++ > MAX_ITERATIONS) {
|
||||
console.log(
|
||||
`Max iterations reached. Exiting. Could not delete ${toDelete.length} resources.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteById(id) {
|
||||
try {
|
||||
await $`az resource delete --ids ${id}`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('An error occurred destroying cloud env:');
|
||||
console.error(error);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
|
||||
services:
|
||||
mockapi:
|
||||
image: wiremock/wiremock:3.9.1
|
||||
ports:
|
||||
- '8088:8080'
|
||||
volumes:
|
||||
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
|
||||
|
||||
postgres:
|
||||
image: postgres:16.4
|
||||
restart: always
|
||||
user: root:root
|
||||
environment:
|
||||
- POSTGRES_DB=n8n
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=password
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
n8n:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
user: root:root
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_MODE=external
|
||||
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- N8N_METRICS=true
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n:/n8n
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mockapi:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
runners:
|
||||
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- NO_COLOR=1
|
||||
depends_on:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
|
||||
benchmark:
|
||||
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
|
||||
depends_on:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- N8N_BASE_URL=http://n8n:5678
|
||||
- K6_API_TOKEN=${K6_API_TOKEN}
|
||||
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
|
||||
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
|
||||
- COLLECT_APP_METRICS=true
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env zx
|
||||
|
||||
import path from 'path';
|
||||
import { fs } from 'zx';
|
||||
|
||||
/**
|
||||
* Creates the needed directories so the permissions get set correctly.
|
||||
*/
|
||||
export function setup({ runDir }) {
|
||||
const neededDirs = ['n8n', 'postgres'];
|
||||
|
||||
for (const dir of neededDirs) {
|
||||
fs.ensureDirSync(path.join(runDir, dir));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
services:
|
||||
mockapi:
|
||||
image: wiremock/wiremock:3.9.1
|
||||
ports:
|
||||
- '8088:8080'
|
||||
volumes:
|
||||
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
|
||||
|
||||
redis:
|
||||
image: redis:6.2.14-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- 6379:6379
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
postgres:
|
||||
image: postgres:16.4
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_DB=n8n
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=password
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_worker1:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n/worker1
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
|
||||
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
|
||||
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
|
||||
# Scaling mode config
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- QUEUE_HEALTH_CHECK_ACTIVE=true
|
||||
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_MODE=external
|
||||
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker1:/n8n
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://localhost:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_worker1_runners:
|
||||
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker1:5679
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- NO_COLOR=1
|
||||
depends_on:
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
|
||||
n8n_worker2:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n/worker2
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
|
||||
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
|
||||
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
|
||||
# Scaling mode config
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- QUEUE_HEALTH_CHECK_ACTIVE=true
|
||||
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_MODE=external
|
||||
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker2:/n8n
|
||||
depends_on:
|
||||
# We let the worker 1 start first so it can run the DB migrations
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://localhost:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_worker2_runners:
|
||||
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker2:5679
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- NO_COLOR=1
|
||||
depends_on:
|
||||
n8n_worker2:
|
||||
condition: service_healthy
|
||||
|
||||
n8n_main2:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
|
||||
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
|
||||
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
|
||||
# Scaling mode config
|
||||
- N8N_PROXY_HOPS=1
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- N8N_MULTI_MAIN_SETUP_ENABLED=true
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
- N8N_METRICS=true
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-main2:/n8n
|
||||
depends_on:
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
n8n_worker2:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mockapi:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n_main2:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_main1:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
|
||||
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
|
||||
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
|
||||
# Scaling mode config
|
||||
- N8N_PROXY_HOPS=1
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- N8N_MULTI_MAIN_SETUP_ENABLED=true
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
- N8N_METRICS=true
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-main1:/n8n
|
||||
depends_on:
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
n8n_worker2:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mockapi:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n_main1:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Load balancer that acts as an entry point for n8n
|
||||
n8n:
|
||||
image: nginx:1.27.2
|
||||
ports:
|
||||
- '5678:80'
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
depends_on:
|
||||
n8n_main1:
|
||||
condition: service_healthy
|
||||
n8n_main2:
|
||||
condition: service_healthy
|
||||
|
||||
benchmark:
|
||||
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
|
||||
depends_on:
|
||||
- n8n
|
||||
environment:
|
||||
- N8N_BASE_URL=http://n8n:80
|
||||
- K6_API_TOKEN=${K6_API_TOKEN}
|
||||
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
|
||||
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
|
||||
- COLLECT_APP_METRICS=true
|
||||
@@ -0,0 +1,24 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
client_max_body_size 50M;
|
||||
access_log off;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
upstream backend {
|
||||
server n8n_main1:5678;
|
||||
server n8n_main2:5678;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env zx
|
||||
|
||||
import path from 'path';
|
||||
import { fs } from 'zx';
|
||||
|
||||
/**
|
||||
* Creates the needed directories so the permissions get set correctly.
|
||||
*/
|
||||
export function setup({ runDir }) {
|
||||
const neededDirs = ['n8n-worker1', 'n8n-worker2', 'n8n-main1', 'n8n-main2', 'postgres'];
|
||||
|
||||
for (const dir of neededDirs) {
|
||||
fs.ensureDirSync(path.join(runDir, dir));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
services:
|
||||
mockapi:
|
||||
image: wiremock/wiremock:3.9.1
|
||||
ports:
|
||||
- '8088:8080'
|
||||
volumes:
|
||||
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
|
||||
|
||||
redis:
|
||||
image: redis:6.2.14-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
postgres:
|
||||
image: postgres:16.4
|
||||
user: root:root
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_DB=n8n
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=password
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_worker1:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
user: root:root
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n/worker1
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
# Queue mode config
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- QUEUE_HEALTH_CHECK_ACTIVE=true
|
||||
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_MODE=external
|
||||
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker1:/n8n
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n_worker1:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_worker1_runners:
|
||||
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker1:5679
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- NO_COLOR=1
|
||||
depends_on:
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
|
||||
n8n_worker2:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
user: root:root
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n/worker2
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
# Queue mode config
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
- QUEUE_HEALTH_CHECK_ACTIVE=true
|
||||
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_MODE=external
|
||||
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker2:/n8n
|
||||
depends_on:
|
||||
# We let the worker 1 start first so it can run the DB migrations
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n_worker2:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
n8n_worker2_runners:
|
||||
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker2:5679
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- NO_COLOR=1
|
||||
depends_on:
|
||||
n8n_worker2:
|
||||
condition: service_healthy
|
||||
|
||||
n8n:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
user: root:root
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n/main
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
# Queue mode config
|
||||
- EXECUTIONS_MODE=queue
|
||||
- QUEUE_BULL_REDIS_HOST=redis
|
||||
# DB config
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
- N8N_METRICS=true
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-main:/n8n
|
||||
depends_on:
|
||||
n8n_worker1:
|
||||
condition: service_healthy
|
||||
n8n_worker2:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mockapi:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
benchmark:
|
||||
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
|
||||
depends_on:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- N8N_BASE_URL=http://n8n:5678
|
||||
- K6_API_TOKEN=${K6_API_TOKEN}
|
||||
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
|
||||
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
|
||||
- COLLECT_APP_METRICS=true
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env zx
|
||||
|
||||
import path from 'path';
|
||||
import { fs } from 'zx';
|
||||
|
||||
/**
|
||||
* Creates the needed directories so the permissions get set correctly.
|
||||
*/
|
||||
export function setup({ runDir }) {
|
||||
const neededDirs = ['n8n-worker1', 'n8n-worker2', 'n8n-main', 'postgres'];
|
||||
|
||||
for (const dir of neededDirs) {
|
||||
fs.ensureDirSync(path.join(runDir, dir));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
mockapi:
|
||||
image: wiremock/wiremock:3.9.1
|
||||
ports:
|
||||
- '8088:8080'
|
||||
volumes:
|
||||
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
|
||||
|
||||
n8n:
|
||||
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
|
||||
user: root:root
|
||||
environment:
|
||||
- N8N_DIAGNOSTICS_ENABLED=false
|
||||
- N8N_USER_FOLDER=/n8n
|
||||
- DB_SQLITE_POOL_SIZE=3
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_MODE=external
|
||||
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- N8N_METRICS=true
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
- ${RUN_DIR}:/n8n
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
depends_on:
|
||||
mockapi:
|
||||
condition: service_started
|
||||
|
||||
runners:
|
||||
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
|
||||
environment:
|
||||
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
|
||||
- N8N_RUNNERS_AUTH_TOKEN=test
|
||||
- NO_COLOR=1
|
||||
depends_on:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
|
||||
benchmark:
|
||||
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
|
||||
depends_on:
|
||||
n8n:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- N8N_BASE_URL=http://n8n:5678
|
||||
- K6_API_TOKEN=${K6_API_TOKEN}
|
||||
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
|
||||
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env zx
|
||||
|
||||
import path from 'path';
|
||||
import { fs } from 'zx';
|
||||
|
||||
/**
|
||||
* Creates the needed directories so the permissions get set correctly.
|
||||
*/
|
||||
export function setup({ runDir }) {
|
||||
const neededDirs = ['n8n'];
|
||||
|
||||
for (const dir of neededDirs) {
|
||||
fs.ensureDirSync(path.join(runDir, dir));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env zx
|
||||
/**
|
||||
* Provisions the cloud benchmark environment
|
||||
*
|
||||
* NOTE: Must be run in the root of the package.
|
||||
*/
|
||||
// @ts-check
|
||||
import { which, minimist } from 'zx';
|
||||
import { TerraformClient } from './clients/terraform-client.mjs';
|
||||
|
||||
const args = minimist(process.argv.slice(3), {
|
||||
boolean: ['debug'],
|
||||
});
|
||||
|
||||
const isVerbose = !!args.debug;
|
||||
|
||||
export async function provision() {
|
||||
await ensureDependencies();
|
||||
|
||||
const terraformClient = new TerraformClient({
|
||||
isVerbose,
|
||||
});
|
||||
|
||||
await terraformClient.provisionEnvironment();
|
||||
}
|
||||
|
||||
async function ensureDependencies() {
|
||||
await which('terraform');
|
||||
}
|
||||
|
||||
provision().catch((error) => {
|
||||
console.error('An error occurred while provisioning cloud env:');
|
||||
console.error(error);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env zx
|
||||
/**
|
||||
* This script runs the benchmarks for the given n8n setup.
|
||||
*/
|
||||
// @ts-check
|
||||
import path from 'path';
|
||||
import { $, argv, fs } from 'zx';
|
||||
import { DockerComposeClient } from './clients/docker-compose-client.mjs';
|
||||
import { flagsObjectToCliArgs } from './utils/flags.mjs';
|
||||
import { EOL } from 'os';
|
||||
|
||||
const paths = {
|
||||
n8nSetupsDir: path.join(__dirname, 'n8n-setups'),
|
||||
mockApiDataPath: path.join(__dirname, 'mock-api'),
|
||||
};
|
||||
|
||||
const N8N_ENCRYPTION_KEY = 'very-secret-encryption-key';
|
||||
|
||||
/**
|
||||
* Discovers runner services in the docker-compose setup, where the name matches exactly `runners` or ends with `_runners`
|
||||
*/
|
||||
async function discoverRunnerServices(dockerComposeClient) {
|
||||
const result = await dockerComposeClient.$('config', '--services');
|
||||
|
||||
return result.stdout
|
||||
.trim()
|
||||
.split(EOL)
|
||||
.filter((service) => service === 'runners' || service.endsWith('_runners'));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [n8nSetupToUse] = argv._;
|
||||
validateN8nSetup(n8nSetupToUse);
|
||||
|
||||
const composeFilePath = path.join(paths.n8nSetupsDir, n8nSetupToUse);
|
||||
const setupScriptPath = path.join(paths.n8nSetupsDir, n8nSetupToUse, 'setup.mjs');
|
||||
const n8nTag = argv.n8nDockerTag || process.env.N8N_DOCKER_TAG || 'latest';
|
||||
const benchmarkTag = argv.benchmarkDockerTag || process.env.BENCHMARK_DOCKER_TAG || 'latest';
|
||||
const k6ApiToken = argv.k6ApiToken || process.env.K6_API_TOKEN || undefined;
|
||||
const resultWebhookUrl =
|
||||
argv.resultWebhookUrl || process.env.BENCHMARK_RESULT_WEBHOOK_URL || undefined;
|
||||
const resultWebhookAuthHeader =
|
||||
argv.resultWebhookAuthHeader || process.env.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER || undefined;
|
||||
const baseRunDir = argv.runDir || process.env.RUN_DIR || '/n8n';
|
||||
const n8nLicenseCert = argv.n8nLicenseCert || process.env.N8N_LICENSE_CERT || undefined;
|
||||
const n8nLicenseActivationKey = process.env.N8N_LICENSE_ACTIVATION_KEY || undefined;
|
||||
const n8nLicenseTenantId = argv.n8nLicenseTenantId || process.env.N8N_LICENSE_TENANT_ID || '1';
|
||||
const envTag = argv.env || 'local';
|
||||
const vus = argv.vus;
|
||||
const duration = argv.duration;
|
||||
const scenarioFilter = argv.scenarioFilter;
|
||||
|
||||
const hasN8nLicense = !!n8nLicenseCert || !!n8nLicenseActivationKey;
|
||||
if (n8nSetupToUse === 'scaling-multi-main' && !hasN8nLicense) {
|
||||
console.error(
|
||||
'n8n license is required to run the multi-main scaling setup. Please provide N8N_LICENSE_CERT or N8N_LICENSE_ACTIVATION_KEY (and N8N_LICENSE_TENANT_ID if needed)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(baseRunDir)) {
|
||||
console.error(
|
||||
`The run directory "${baseRunDir}" does not exist. Please specify a valid directory using --runDir`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runDir = path.join(baseRunDir, n8nSetupToUse);
|
||||
fs.emptyDirSync(runDir);
|
||||
|
||||
const dockerComposeClient = new DockerComposeClient({
|
||||
$: $({
|
||||
cwd: composeFilePath,
|
||||
verbose: true,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
N8N_VERSION: n8nTag,
|
||||
N8N_LICENSE_CERT: n8nLicenseCert,
|
||||
N8N_LICENSE_ACTIVATION_KEY: n8nLicenseActivationKey,
|
||||
N8N_LICENSE_TENANT_ID: n8nLicenseTenantId,
|
||||
N8N_ENCRYPTION_KEY,
|
||||
BENCHMARK_VERSION: benchmarkTag,
|
||||
K6_API_TOKEN: k6ApiToken,
|
||||
BENCHMARK_RESULT_WEBHOOK_URL: resultWebhookUrl,
|
||||
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: resultWebhookAuthHeader,
|
||||
RUN_DIR: runDir,
|
||||
MOCK_API_DATA_PATH: paths.mockApiDataPath,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Run the setup script if it exists
|
||||
if (fs.existsSync(setupScriptPath)) {
|
||||
const setupScript = await import(setupScriptPath);
|
||||
await setupScript.setup({ runDir });
|
||||
}
|
||||
|
||||
try {
|
||||
const runnerServices = await discoverRunnerServices(dockerComposeClient);
|
||||
await dockerComposeClient.$('up', '-d', '--remove-orphans', 'n8n', ...runnerServices);
|
||||
|
||||
const tags = Object.entries({
|
||||
Env: envTag,
|
||||
N8nVersion: n8nTag,
|
||||
N8nSetup: n8nSetupToUse,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(',');
|
||||
|
||||
const cliArgs = flagsObjectToCliArgs({
|
||||
scenarioNamePrefix: n8nSetupToUse,
|
||||
scenarioFilter,
|
||||
vus,
|
||||
duration,
|
||||
tags,
|
||||
});
|
||||
|
||||
await dockerComposeClient.$('run', 'benchmark', 'run', ...cliArgs);
|
||||
} catch (error) {
|
||||
console.error('An error occurred while running the benchmarks:');
|
||||
console.error(error.message);
|
||||
console.error('');
|
||||
await printContainerStatus(dockerComposeClient);
|
||||
throw error;
|
||||
} finally {
|
||||
await dumpLogs(dockerComposeClient);
|
||||
await dockerComposeClient.$('down');
|
||||
}
|
||||
}
|
||||
|
||||
async function printContainerStatus(dockerComposeClient) {
|
||||
console.error('Container statuses:');
|
||||
await dockerComposeClient.$('ps', '-a');
|
||||
}
|
||||
|
||||
async function dumpLogs(dockerComposeClient) {
|
||||
console.info('Container logs:');
|
||||
await dockerComposeClient.$('logs');
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
const availableSetups = getAllN8nSetups();
|
||||
console.log('Usage: zx runForN8nSetup.mjs --runDir /path/for/n8n/data <n8n setup to use>');
|
||||
console.log(` eg: zx runForN8nSetup.mjs --runDir /path/for/n8n/data ${availableSetups[0]}`);
|
||||
console.log('');
|
||||
console.log('Flags:');
|
||||
console.log(
|
||||
' --runDir <path> Directory to share with the n8n container for storing data. Default is /n8n',
|
||||
);
|
||||
console.log(' --n8nDockerTag <tag> Docker tag for n8n image. Default is latest');
|
||||
console.log(
|
||||
' --benchmarkDockerTag <tag> Docker tag for benchmark cli image. Default is latest',
|
||||
);
|
||||
console.log(' --k6ApiToken <token> K6 API token to upload the results');
|
||||
console.log('');
|
||||
console.log('Available setups:');
|
||||
console.log(availableSetups.join(', '));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getAllN8nSetups() {
|
||||
return fs.readdirSync(paths.n8nSetupsDir);
|
||||
}
|
||||
|
||||
function validateN8nSetup(givenSetup) {
|
||||
const availableSetups = getAllN8nSetups();
|
||||
if (!availableSetups.includes(givenSetup)) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env zx
|
||||
/**
|
||||
* Script to run benchmarks on the cloud benchmark environment.
|
||||
* This script will:
|
||||
* 1. Provision a benchmark environment using Terraform.
|
||||
* 2. Run the benchmarks on the VM.
|
||||
* 3. Destroy the cloud environment.
|
||||
*
|
||||
* NOTE: Must be run in the root of the package.
|
||||
*/
|
||||
// @ts-check
|
||||
import { sleep, which, $, tmpdir } from 'zx';
|
||||
import path from 'path';
|
||||
import { SshClient } from './clients/ssh-client.mjs';
|
||||
import { TerraformClient } from './clients/terraform-client.mjs';
|
||||
import { flagsObjectToCliArgs } from './utils/flags.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BenchmarkEnv
|
||||
* @property {string} vmName
|
||||
* @property {string} ip
|
||||
* @property {string} sshUsername
|
||||
* @property {string} sshPrivateKeyPath
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @property {boolean} isVerbose
|
||||
* @property {string[]} n8nSetupsToUse
|
||||
* @property {string} n8nTag
|
||||
* @property {string} benchmarkTag
|
||||
* @property {string} [k6ApiToken]
|
||||
* @property {string} [resultWebhookUrl]
|
||||
* @property {string} [resultWebhookAuthHeader]
|
||||
* @property {string} [n8nLicenseCert]
|
||||
* @property {string} [vus]
|
||||
* @property {string} [duration]
|
||||
* @property {string} [scenarioFilter]
|
||||
*
|
||||
* @param {Config} config
|
||||
*/
|
||||
export async function runInCloud(config) {
|
||||
await ensureDependencies();
|
||||
|
||||
const terraformClient = new TerraformClient({
|
||||
isVerbose: config.isVerbose,
|
||||
});
|
||||
|
||||
const benchmarkEnv = await terraformClient.getTerraformOutputs();
|
||||
|
||||
await runBenchmarksOnVm(config, benchmarkEnv);
|
||||
}
|
||||
|
||||
async function ensureDependencies() {
|
||||
await which('terraform');
|
||||
await which('az');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Config} config
|
||||
* @param {BenchmarkEnv} benchmarkEnv
|
||||
*/
|
||||
async function runBenchmarksOnVm(config, benchmarkEnv) {
|
||||
console.log(`Setting up the environment...`);
|
||||
|
||||
const sshClient = new SshClient({
|
||||
ip: benchmarkEnv.ip,
|
||||
username: benchmarkEnv.sshUsername,
|
||||
privateKeyPath: benchmarkEnv.sshPrivateKeyPath,
|
||||
verbose: config.isVerbose,
|
||||
});
|
||||
|
||||
await ensureVmIsReachable(sshClient);
|
||||
|
||||
const scriptsDir = await transferScriptsToVm(sshClient, config);
|
||||
|
||||
// Bootstrap the environment with dependencies
|
||||
console.log('Running bootstrap script...');
|
||||
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
|
||||
await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`);
|
||||
|
||||
// Give some time for the VM to be ready
|
||||
await sleep(1000);
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const n8nSetup of config.n8nSetupsToUse) {
|
||||
try {
|
||||
await runBenchmarkForN8nSetup({
|
||||
config,
|
||||
sshClient,
|
||||
scriptsDir,
|
||||
n8nSetup,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Benchmark failed for ${n8nSetup}:`, error.message);
|
||||
failures.push(n8nSetup);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Benchmarks failed for setups: ${failures.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ config: Config; sshClient: any; scriptsDir: string; n8nSetup: string; }} opts
|
||||
*/
|
||||
async function runBenchmarkForN8nSetup({ config, sshClient, scriptsDir, n8nSetup }) {
|
||||
console.log(`Running benchmarks for ${n8nSetup}...`);
|
||||
const runScriptPath = path.join(scriptsDir, 'run-for-n8n-setup.mjs');
|
||||
|
||||
const cliArgs = flagsObjectToCliArgs({
|
||||
n8nDockerTag: config.n8nTag,
|
||||
benchmarkDockerTag: config.benchmarkTag,
|
||||
k6ApiToken: config.k6ApiToken,
|
||||
resultWebhookUrl: config.resultWebhookUrl,
|
||||
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
|
||||
n8nLicenseCert: config.n8nLicenseCert,
|
||||
scenarioFilter: config.scenarioFilter,
|
||||
vus: config.vus,
|
||||
duration: config.duration,
|
||||
env: 'cloud',
|
||||
});
|
||||
|
||||
const flagsString = cliArgs.join(' ');
|
||||
|
||||
await sshClient.ssh(`npx zx ${runScriptPath} ${flagsString} ${n8nSetup}`, {
|
||||
// Test run should always log its output
|
||||
verbose: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureVmIsReachable(sshClient) {
|
||||
try {
|
||||
await sshClient.ssh('echo "VM is reachable"');
|
||||
} catch (error) {
|
||||
console.error(`VM is not reachable: ${error.message}`);
|
||||
console.error(
|
||||
`Did you provision the cloud environment first with 'pnpm provision-cloud-env'? You can also run the benchmarks locally with 'pnpm run benchmark-locally'.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Path where the scripts are located on the VM
|
||||
*/
|
||||
async function transferScriptsToVm(sshClient, config) {
|
||||
const cwd = process.cwd();
|
||||
const scriptsDir = path.resolve(cwd, './scripts');
|
||||
const tarFilename = 'scripts.tar.gz';
|
||||
const scriptsTarPath = path.join(tmpdir('n8n-benchmark'), tarFilename);
|
||||
|
||||
const $$ = $({ verbose: config.isVerbose });
|
||||
|
||||
// Compress the scripts folder
|
||||
await $$`tar -czf ${scriptsTarPath} ${scriptsDir} -C ${cwd} ./scripts`;
|
||||
|
||||
// Transfer the scripts to the VM
|
||||
await sshClient.scp(scriptsTarPath, `~/${tarFilename}`);
|
||||
|
||||
// Extract the scripts on the VM
|
||||
await sshClient.ssh(`tar -xzf ~/${tarFilename}`);
|
||||
|
||||
return '~/scripts';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env zx
|
||||
/**
|
||||
* Script to run benchmarks on the cloud benchmark environment.
|
||||
* This script will:
|
||||
* 1. Provision a benchmark environment using Terraform.
|
||||
* 2. Run the benchmarks on the VM.
|
||||
* 3. Destroy the cloud environment.
|
||||
*
|
||||
* NOTE: Must be run in the root of the package.
|
||||
*/
|
||||
// @ts-check
|
||||
import { $ } from 'zx';
|
||||
import path from 'path';
|
||||
import { flagsObjectToCliArgs } from './utils/flags.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BenchmarkEnv
|
||||
* @property {string} vmName
|
||||
*/
|
||||
|
||||
const paths = {
|
||||
scriptsDir: path.join(path.resolve('scripts')),
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @property {boolean} isVerbose
|
||||
* @property {string[]} n8nSetupsToUse
|
||||
* @property {string} n8nTag
|
||||
* @property {string} benchmarkTag
|
||||
* @property {string} [runDir]
|
||||
* @property {string} [k6ApiToken]
|
||||
* @property {string} [resultWebhookUrl]
|
||||
* @property {string} [resultWebhookAuthHeader]
|
||||
* @property {string} [n8nLicenseCert]
|
||||
* @property {string} [vus]
|
||||
* @property {string} [duration]
|
||||
* @property {string} [scenarioFilter]
|
||||
*
|
||||
* @param {Config} config
|
||||
*/
|
||||
export async function runLocally(config) {
|
||||
const runScriptPath = path.join(paths.scriptsDir, 'run-for-n8n-setup.mjs');
|
||||
|
||||
const cliArgs = flagsObjectToCliArgs({
|
||||
n8nDockerTag: config.n8nTag,
|
||||
benchmarkDockerTag: config.benchmarkTag,
|
||||
runDir: config.runDir,
|
||||
vus: config.vus,
|
||||
duration: config.duration,
|
||||
scenarioFilter: config.scenarioFilter,
|
||||
env: 'local',
|
||||
});
|
||||
|
||||
try {
|
||||
for (const n8nSetup of config.n8nSetupsToUse) {
|
||||
console.log(`Running benchmarks for n8n setup: ${n8nSetup}`);
|
||||
|
||||
await $({
|
||||
env: {
|
||||
...process.env,
|
||||
K6_API_TOKEN: config.k6ApiToken,
|
||||
BENCHMARK_RESULT_WEBHOOK_URL: config.resultWebhookUrl,
|
||||
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: config.resultWebhookAuthHeader,
|
||||
N8N_LICENSE_CERT: config.n8nLicenseCert,
|
||||
},
|
||||
})`npx ${runScriptPath} ${cliArgs} ${n8nSetup}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('An error occurred while running the benchmarks:');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env zx
|
||||
/**
|
||||
* Script to run benchmarks either on the cloud benchmark environment or locally.
|
||||
* The cloud environment needs to be provisioned using Terraform before running the benchmarks.
|
||||
*
|
||||
* NOTE: Must be run in the root of the package.
|
||||
*/
|
||||
// @ts-check
|
||||
import fs from 'fs';
|
||||
import minimist from 'minimist';
|
||||
import path from 'path';
|
||||
import { runInCloud } from './run-in-cloud.mjs';
|
||||
import { runLocally } from './run-locally.mjs';
|
||||
|
||||
const paths = {
|
||||
n8nSetupsDir: path.join(path.resolve('scripts'), 'n8n-setups'),
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const config = await parseAndValidateConfig();
|
||||
|
||||
const n8nSetupsToUse =
|
||||
config.n8nSetupToUse === 'all' ? readAvailableN8nSetups() : [config.n8nSetupToUse];
|
||||
|
||||
console.log('Using n8n tag', config.n8nTag);
|
||||
console.log('Using benchmark cli tag', config.benchmarkTag);
|
||||
console.log('Using environment', config.env);
|
||||
console.log('Using n8n setups', n8nSetupsToUse.join(', '));
|
||||
console.log('');
|
||||
|
||||
if (config.env === 'cloud') {
|
||||
await runInCloud({
|
||||
benchmarkTag: config.benchmarkTag,
|
||||
isVerbose: config.isVerbose,
|
||||
k6ApiToken: config.k6ApiToken,
|
||||
resultWebhookUrl: config.resultWebhookUrl,
|
||||
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
|
||||
n8nLicenseCert: config.n8nLicenseCert,
|
||||
n8nTag: config.n8nTag,
|
||||
n8nSetupsToUse,
|
||||
vus: config.vus,
|
||||
duration: config.duration,
|
||||
scenarioFilter: config.scenarioFilter,
|
||||
});
|
||||
} else if (config.env === 'local') {
|
||||
await runLocally({
|
||||
benchmarkTag: config.benchmarkTag,
|
||||
isVerbose: config.isVerbose,
|
||||
k6ApiToken: config.k6ApiToken,
|
||||
resultWebhookUrl: config.resultWebhookUrl,
|
||||
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
|
||||
n8nLicenseCert: config.n8nLicenseCert,
|
||||
n8nTag: config.n8nTag,
|
||||
runDir: config.runDir,
|
||||
n8nSetupsToUse,
|
||||
vus: config.vus,
|
||||
duration: config.duration,
|
||||
scenarioFilter: config.scenarioFilter,
|
||||
});
|
||||
} else {
|
||||
console.error('Invalid env:', config.env);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function readAvailableN8nSetups() {
|
||||
const setups = fs.readdirSync(paths.n8nSetupsDir);
|
||||
|
||||
return setups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @property {boolean} isVerbose
|
||||
* @property {'cloud' | 'local'} env
|
||||
* @property {string} n8nSetupToUse
|
||||
* @property {string} n8nTag
|
||||
* @property {string} benchmarkTag
|
||||
* @property {string} [k6ApiToken]
|
||||
* @property {string} [resultWebhookUrl]
|
||||
* @property {string} [resultWebhookAuthHeader]
|
||||
* @property {string} [n8nLicenseCert]
|
||||
* @property {string} [runDir]
|
||||
* @property {string} [vus]
|
||||
* @property {string} [duration]
|
||||
* @property {string} [scenarioFilter]
|
||||
*
|
||||
* @returns {Promise<Config>}
|
||||
*/
|
||||
async function parseAndValidateConfig() {
|
||||
const args = minimist(process.argv.slice(3), {
|
||||
boolean: ['debug', 'help'],
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const n8nSetupToUse = await getAndValidateN8nSetup(args);
|
||||
const isVerbose = args.debug || false;
|
||||
const n8nTag = args.n8nTag || process.env.N8N_DOCKER_TAG || 'latest';
|
||||
const benchmarkTag = args.benchmarkTag || process.env.BENCHMARK_DOCKER_TAG || 'latest';
|
||||
const k6ApiToken = args.k6ApiToken || process.env.K6_API_TOKEN || undefined;
|
||||
const resultWebhookUrl =
|
||||
args.resultWebhookUrl || process.env.BENCHMARK_RESULT_WEBHOOK_URL || undefined;
|
||||
const resultWebhookAuthHeader =
|
||||
args.resultWebhookAuthHeader || process.env.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER || undefined;
|
||||
const n8nLicenseCert = args.n8nLicenseCert || process.env.N8N_LICENSE_CERT || undefined;
|
||||
const runDir = args.runDir || undefined;
|
||||
const env = args.env || 'local';
|
||||
const vus = args.vus;
|
||||
const duration = args.duration;
|
||||
const scenarioFilter = args.scenarioFilter;
|
||||
|
||||
if (!env) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
isVerbose,
|
||||
env,
|
||||
n8nSetupToUse,
|
||||
n8nTag,
|
||||
benchmarkTag,
|
||||
k6ApiToken,
|
||||
resultWebhookUrl,
|
||||
resultWebhookAuthHeader,
|
||||
n8nLicenseCert,
|
||||
runDir,
|
||||
vus,
|
||||
duration,
|
||||
scenarioFilter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {minimist.ParsedArgs} args
|
||||
*/
|
||||
async function getAndValidateN8nSetup(args) {
|
||||
// Last parameter is the n8n setup to use
|
||||
const n8nSetupToUse = args._[args._.length - 1];
|
||||
if (!n8nSetupToUse || n8nSetupToUse === 'all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const availableSetups = readAvailableN8nSetups();
|
||||
|
||||
if (!availableSetups.includes(n8nSetupToUse)) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return n8nSetupToUse;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
const availableSetups = readAvailableN8nSetups();
|
||||
|
||||
console.log(`Usage: zx scripts/${path.basename(__filename)} [n8n setup name]`);
|
||||
console.log(` eg: zx scripts/${path.basename(__filename)}`);
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(
|
||||
` [n8n setup name] Against which n8n setup to run the benchmarks. One of: ${['all', ...availableSetups].join(', ')}. Default is all`,
|
||||
);
|
||||
console.log(
|
||||
' --env Env where to run the benchmarks. Either cloud or local. Default is local.',
|
||||
);
|
||||
console.log(' --debug Enable verbose output');
|
||||
console.log(' --n8nTag Docker tag for n8n image. Default is latest');
|
||||
console.log(' --benchmarkTag Docker tag for benchmark cli image. Default is latest');
|
||||
console.log(' --scenarioFilter Filter scenarios by name (case-insensitive)');
|
||||
console.log(' --vus How many concurrent requests to make');
|
||||
console.log(' --duration Test duration, e.g. 1m or 30s');
|
||||
console.log(
|
||||
' --k6ApiToken API token for k6 cloud. Default is read from K6_API_TOKEN env var. If omitted, k6 cloud will not be used',
|
||||
);
|
||||
console.log(
|
||||
' --runDir Directory to share with the n8n container for storing data. Needed only for local runs.',
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('An error occurred while running the benchmarks:');
|
||||
console.error(error);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Converts an object of flags to an array of CLI arguments.
|
||||
*
|
||||
* @param {Record<string, string | undefined>} flags
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function flagsObjectToCliArgs(flags) {
|
||||
return Object.entries(flags)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'string' && value.includes(' ')) {
|
||||
return `--${key}="${value}"`;
|
||||
} else {
|
||||
return `--${key}=${value}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Command } from '@oclif/core';
|
||||
|
||||
import { testScenariosPath } from '@/config/common-flags';
|
||||
import { ScenarioLoader } from '@/scenario/scenario-loader';
|
||||
|
||||
export default class ListCommand extends Command {
|
||||
static description = 'List all available scenarios';
|
||||
|
||||
static flags = {
|
||||
testScenariosPath,
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { flags } = await this.parse(ListCommand);
|
||||
const scenarioLoader = new ScenarioLoader();
|
||||
|
||||
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath);
|
||||
|
||||
console.log('Available test scenarios:');
|
||||
console.log('');
|
||||
|
||||
for (const scenario of allScenarios) {
|
||||
console.log('\t', scenario.name, ':', scenario.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Command, Flags } from '@oclif/core';
|
||||
|
||||
import { testScenariosPath } from '@/config/common-flags';
|
||||
import { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
|
||||
import { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
|
||||
import { ScenarioLoader } from '@/scenario/scenario-loader';
|
||||
import type { K6Tag } from '@/test-execution/k6-executor';
|
||||
import { K6Executor } from '@/test-execution/k6-executor';
|
||||
import { ScenarioRunner } from '@/test-execution/scenario-runner';
|
||||
|
||||
export default class RunCommand extends Command {
|
||||
static description = 'Run all (default) or specified test scenarios';
|
||||
|
||||
static flags = {
|
||||
testScenariosPath,
|
||||
scenarioFilter: Flags.string({
|
||||
char: 'f',
|
||||
description: 'Filter scenarios by name',
|
||||
}),
|
||||
scenarioNamePrefix: Flags.string({
|
||||
description: 'Prefix for the scenario name',
|
||||
default: 'Unnamed',
|
||||
}),
|
||||
n8nBaseUrl: Flags.string({
|
||||
description: 'The base URL for the n8n instance',
|
||||
default: 'http://localhost:5678',
|
||||
env: 'N8N_BASE_URL',
|
||||
}),
|
||||
n8nUserEmail: Flags.string({
|
||||
description: 'The email address of the n8n user',
|
||||
default: 'benchmark-user@n8n.io',
|
||||
env: 'N8N_USER_EMAIL',
|
||||
}),
|
||||
k6ExecutablePath: Flags.string({
|
||||
doc: 'The path to the k6 binary',
|
||||
default: 'k6',
|
||||
env: 'K6_PATH',
|
||||
}),
|
||||
k6ApiToken: Flags.string({
|
||||
doc: 'The API token for k6 cloud',
|
||||
default: undefined,
|
||||
env: 'K6_API_TOKEN',
|
||||
}),
|
||||
out: Flags.string({
|
||||
description: 'The --out flag for k6',
|
||||
default: undefined,
|
||||
env: 'K6_OUT',
|
||||
}),
|
||||
resultWebhookUrl: Flags.string({
|
||||
doc: 'The URL where the benchmark results should be sent to',
|
||||
default: undefined,
|
||||
env: 'BENCHMARK_RESULT_WEBHOOK_URL',
|
||||
}),
|
||||
resultWebhookAuthHeader: Flags.string({
|
||||
doc: 'The Authorization header value for the benchmark results webhook',
|
||||
default: undefined,
|
||||
env: 'BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER',
|
||||
}),
|
||||
n8nUserPassword: Flags.string({
|
||||
description: 'The password of the n8n user',
|
||||
default: 'VerySecret!123',
|
||||
env: 'N8N_USER_PASSWORD',
|
||||
}),
|
||||
tags: Flags.string({
|
||||
char: 't',
|
||||
description: 'Tags to attach to the run. Comma separated list of key=value pairs',
|
||||
}),
|
||||
vus: Flags.integer({
|
||||
description: 'Number of concurrent requests to make',
|
||||
default: 5,
|
||||
}),
|
||||
duration: Flags.string({
|
||||
description: 'Duration of the test with a unit, e.g. 1m',
|
||||
default: '1m',
|
||||
}),
|
||||
collectAppMetrics: Flags.boolean({
|
||||
description: 'Collect app metrics from the /metrics endpoint during test runs',
|
||||
default: false,
|
||||
env: 'COLLECT_APP_METRICS',
|
||||
}),
|
||||
appMetricsPollInterval: Flags.integer({
|
||||
description: 'App metrics polling interval in milliseconds',
|
||||
default: 5000,
|
||||
env: 'APP_METRICS_POLL_INTERVAL',
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { flags } = await this.parse(RunCommand);
|
||||
const tags = await this.parseTags();
|
||||
const scenarioLoader = new ScenarioLoader();
|
||||
|
||||
const scenarioRunner = new ScenarioRunner(
|
||||
new N8nApiClient(flags.n8nBaseUrl),
|
||||
new ScenarioDataFileLoader(),
|
||||
new K6Executor({
|
||||
duration: flags.duration,
|
||||
vus: flags.vus,
|
||||
k6Out: flags.out,
|
||||
k6ExecutablePath: flags.k6ExecutablePath,
|
||||
k6ApiToken: flags.k6ApiToken,
|
||||
n8nApiBaseUrl: flags.n8nBaseUrl,
|
||||
tags,
|
||||
resultsWebhook: flags.resultWebhookUrl
|
||||
? {
|
||||
url: flags.resultWebhookUrl,
|
||||
authHeader: flags.resultWebhookAuthHeader,
|
||||
}
|
||||
: undefined,
|
||||
appMetricsPolling: flags.collectAppMetrics
|
||||
? {
|
||||
enabled: true,
|
||||
intervalMs: flags.appMetricsPollInterval,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
{
|
||||
email: flags.n8nUserEmail,
|
||||
password: flags.n8nUserPassword,
|
||||
},
|
||||
flags.scenarioNamePrefix,
|
||||
);
|
||||
|
||||
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath, flags.scenarioFilter);
|
||||
|
||||
await scenarioRunner.runManyScenarios(allScenarios);
|
||||
}
|
||||
|
||||
private async parseTags(): Promise<K6Tag[]> {
|
||||
const { flags } = await this.parse(RunCommand);
|
||||
if (!flags.tags) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return flags.tags.split(',').map((tag) => {
|
||||
const [name, value] = tag.split('=');
|
||||
return { name, value };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
export const testScenariosPath = Flags.string({
|
||||
description: 'The path to the scenarios',
|
||||
default: 'scenarios',
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { N8nApiClient } from './n8n-api-client';
|
||||
|
||||
export class AuthenticatedN8nApiClient extends N8nApiClient {
|
||||
constructor(
|
||||
apiBaseUrl: string,
|
||||
private readonly authCookie: string,
|
||||
) {
|
||||
super(apiBaseUrl);
|
||||
}
|
||||
|
||||
static async createUsingUsernameAndPassword(
|
||||
apiClient: N8nApiClient,
|
||||
loginDetails: {
|
||||
email: string;
|
||||
password: string;
|
||||
},
|
||||
): Promise<AuthenticatedN8nApiClient> {
|
||||
const response = await apiClient.restApiRequest('/login', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
emailOrLdapLoginId: loginDetails.email,
|
||||
password: loginDetails.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data === 'n8n is starting up. Please wait') {
|
||||
await apiClient.delay(1000);
|
||||
return await this.createUsingUsernameAndPassword(apiClient, loginDetails);
|
||||
}
|
||||
|
||||
const cookieHeader = response.headers['set-cookie'];
|
||||
const authCookie = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader;
|
||||
if (!authCookie) {
|
||||
throw new Error(
|
||||
'Did not receive authentication cookie even tho login succeeded: ' +
|
||||
JSON.stringify(
|
||||
{
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
data: response.data,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new AuthenticatedN8nApiClient(apiClient.apiBaseUrl, authCookie);
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string) {
|
||||
return await this.authenticatedRequest<T>(endpoint, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data: unknown) {
|
||||
return await this.authenticatedRequest<T>(endpoint, {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async patch<T>(endpoint: string, data: unknown) {
|
||||
return await this.authenticatedRequest<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string) {
|
||||
return await this.authenticatedRequest<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
protected async authenticatedRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
|
||||
return await this.restApiRequest<T>(endpoint, {
|
||||
...init,
|
||||
headers: {
|
||||
...init.headers,
|
||||
cookie: this.authCookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Credential } from '@/n8n-api-client/n8n-api-client.types';
|
||||
|
||||
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
||||
|
||||
export class CredentialApiClient {
|
||||
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
||||
|
||||
async getAllCredentials(): Promise<Credential[]> {
|
||||
const response = await this.apiClient.get<{ count: number; data: Credential[] }>(
|
||||
'/credentials',
|
||||
);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async createCredential(credential: Credential): Promise<Credential> {
|
||||
const response = await this.apiClient.post<{ data: Credential }>('/credentials', {
|
||||
...credential,
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async deleteCredential(credentialId: Credential['id']): Promise<void> {
|
||||
await this.apiClient.delete(`/credentials/${credentialId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { DataTable } from '@/n8n-api-client/n8n-api-client.types';
|
||||
|
||||
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
||||
|
||||
export class DataTableApiClient {
|
||||
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
||||
|
||||
async getAllDataTables(): Promise<DataTable[]> {
|
||||
const response = await this.apiClient.get<{ data: { count: number; data: DataTable[] } }>(
|
||||
'/data-tables-global',
|
||||
);
|
||||
|
||||
return response.data.data.data;
|
||||
}
|
||||
|
||||
async deleteDataTable(projectId: string, dataTableId: DataTable['id']): Promise<void> {
|
||||
await this.apiClient.delete(`/projects/${projectId}/data-tables/${dataTableId}`);
|
||||
}
|
||||
|
||||
async createDataTable(projectId: string, dataTable: DataTable): Promise<DataTable> {
|
||||
const response = await this.apiClient.post<{ data: DataTable }>(
|
||||
`/projects/${projectId}/data-tables`,
|
||||
{
|
||||
...dataTable,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
|
||||
export class N8nApiClient {
|
||||
constructor(
|
||||
readonly apiBaseUrl: string,
|
||||
private readonly healthEndpoint: string = '/healthz',
|
||||
) {}
|
||||
|
||||
async waitForInstanceToBecomeOnline(): Promise<void> {
|
||||
const START_TIME = Date.now();
|
||||
const INTERVAL_MS = 1000;
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
while (Date.now() - START_TIME < TIMEOUT_MS) {
|
||||
try {
|
||||
const response = await axios.request<{ status: 'ok' }>({
|
||||
url: `${this.apiBaseUrl}${this.healthEndpoint}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data.status === 'ok') {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log(`n8n instance not online yet, retrying in ${INTERVAL_MS / 1000} seconds...`);
|
||||
await this.delay(INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`n8n instance did not come online within ${TIMEOUT_MS / 1000} seconds`);
|
||||
}
|
||||
|
||||
async setupOwnerIfNeeded(loginDetails: { email: string; password: string }) {
|
||||
const response = await this.restApiRequest<{ message: string }>('/owner/setup', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
email: loginDetails.email,
|
||||
password: loginDetails.password,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
},
|
||||
// Don't throw on non-2xx responses
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
const responsePayload = response.data;
|
||||
|
||||
if (response.status === 200) {
|
||||
console.log('Owner setup successful');
|
||||
} else if (response.status === 400) {
|
||||
if (responsePayload.message === 'Instance owner already setup')
|
||||
console.log('Owner already set up');
|
||||
} else if (response.status === 404) {
|
||||
// The n8n instance setup owner endpoint not be available yet even tho
|
||||
// the health endpoint returns ok. In this case we simply retry.
|
||||
console.log('Owner setup endpoint not available yet, retrying in 1s...');
|
||||
await this.delay(1000);
|
||||
await this.setupOwnerIfNeeded(loginDetails);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Owner setup failed with status ${response.status}: ${responsePayload.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async restApiRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
|
||||
try {
|
||||
return await axios.request<T>({
|
||||
...init,
|
||||
url: this.getRestEndpointUrl(endpoint),
|
||||
});
|
||||
} catch (e) {
|
||||
const error = e as AxiosError;
|
||||
console.error(`[ERROR] Request failed ${init.method} ${endpoint}`, error?.response?.data);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delay(ms: number): Promise<void> {
|
||||
return await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
protected getRestEndpointUrl(endpoint: string) {
|
||||
return `${this.apiBaseUrl}/rest${endpoint}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* n8n workflow. This is a simplified version of the actual workflow object.
|
||||
*/
|
||||
export type Workflow = {
|
||||
id: string;
|
||||
name: string;
|
||||
versionId: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type Credential = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type DataTableColumn = {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'date';
|
||||
};
|
||||
|
||||
export type DataTable = {
|
||||
id?: string;
|
||||
projectId?: number;
|
||||
name: string;
|
||||
columns: DataTableColumn[];
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
||||
|
||||
export class ProjectApiClient {
|
||||
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
||||
|
||||
async getPersonalProject(): Promise<string> {
|
||||
const response = await this.apiClient.get<{ data: { id: string } }>('/projects/personal');
|
||||
|
||||
return response.data.data.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Workflow } from '@/n8n-api-client/n8n-api-client.types';
|
||||
|
||||
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
||||
|
||||
export class WorkflowApiClient {
|
||||
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
||||
|
||||
async getAllWorkflows(): Promise<Workflow[]> {
|
||||
const response = await this.apiClient.get<{ count: number; data: Workflow[] }>('/workflows');
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async createWorkflow(workflow: unknown): Promise<Workflow> {
|
||||
const response = await this.apiClient.post<{ data: Workflow }>('/workflows', workflow);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async activateWorkflow(workflow: Workflow): Promise<Workflow> {
|
||||
const response = await this.apiClient.post<{ data: Workflow }>(
|
||||
`/workflows/${workflow.id}/activate`,
|
||||
{
|
||||
versionId: workflow.versionId,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async archiveWorkflow(workflowId: Workflow['id']): Promise<void> {
|
||||
await this.apiClient.post(`/workflows/${workflowId}/archive`, {});
|
||||
}
|
||||
|
||||
async deleteWorkflow(workflowId: Workflow['id']): Promise<void> {
|
||||
await this.apiClient.delete(`/workflows/${workflowId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { Workflow, Credential, DataTable } from '@/n8n-api-client/n8n-api-client.types';
|
||||
import type { Scenario } from '@/types/scenario';
|
||||
|
||||
export type LoadableScenarioData = {
|
||||
workflows: Workflow[];
|
||||
credentials: Credential[];
|
||||
dataTable: DataTable | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads scenario data files from FS
|
||||
*/
|
||||
export class ScenarioDataFileLoader {
|
||||
async loadDataForScenario(scenario: Scenario): Promise<LoadableScenarioData> {
|
||||
const workflows = await Promise.all(
|
||||
scenario.scenarioData.workflowFiles?.map((workflowFilePath) =>
|
||||
this.loadSingleWorkflowFromFile(path.join(scenario.scenarioDirPath, workflowFilePath)),
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const credentials = await Promise.all(
|
||||
scenario.scenarioData.credentialFiles?.map((credentialFilePath) =>
|
||||
this.loadSingleCredentialFromFile(path.join(scenario.scenarioDirPath, credentialFilePath)),
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const dataTable = scenario.scenarioData.dataTableFile
|
||||
? this.loadSingleDataTableFromFile(
|
||||
path.join(scenario.scenarioDirPath, scenario.scenarioData.dataTableFile),
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
workflows,
|
||||
credentials,
|
||||
dataTable,
|
||||
};
|
||||
}
|
||||
|
||||
private loadSingleCredentialFromFile(credentialFilePath: string): Credential {
|
||||
const fileContent = fs.readFileSync(credentialFilePath, 'utf8');
|
||||
|
||||
try {
|
||||
return JSON.parse(fileContent) as Credential;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
throw new Error(`Failed to parse credential file ${credentialFilePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private loadSingleWorkflowFromFile(workflowFilePath: string): Workflow {
|
||||
const fileContent = fs.readFileSync(workflowFilePath, 'utf8');
|
||||
|
||||
try {
|
||||
return JSON.parse(fileContent) as Workflow;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
throw new Error(`Failed to parse workflow file ${workflowFilePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private loadSingleDataTableFromFile(dataTableFilePath: string): DataTable {
|
||||
const fileContent = fs.readFileSync(dataTableFilePath, 'utf8');
|
||||
|
||||
try {
|
||||
return JSON.parse(fileContent) as DataTable;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
throw new Error(`Failed to parse data table file ${dataTableFilePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { Scenario, ScenarioManifest } from '@/types/scenario';
|
||||
|
||||
export class ScenarioLoader {
|
||||
/**
|
||||
* Loads all scenarios from the given path
|
||||
*/
|
||||
loadAll(pathToScenarios: string, filter?: string): Scenario[] {
|
||||
pathToScenarios = path.resolve(pathToScenarios);
|
||||
const scenarioFolders = fs
|
||||
.readdirSync(pathToScenarios, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name);
|
||||
|
||||
const scenarios: Scenario[] = [];
|
||||
|
||||
for (const folder of scenarioFolders) {
|
||||
if (filter && folder.toLowerCase() !== filter.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
const scenarioPath = path.join(pathToScenarios, folder);
|
||||
const manifestFileName = `${folder}.manifest.json`;
|
||||
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
|
||||
if (!fs.existsSync(scenarioManifestPath)) {
|
||||
console.warn(`Scenario at ${scenarioPath} is missing the ${manifestFileName} file`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the scenario manifest file
|
||||
const [scenario, validationErrors] =
|
||||
this.loadAndValidateScenarioManifest(scenarioManifestPath);
|
||||
if (validationErrors) {
|
||||
console.warn(
|
||||
`Scenario at ${scenarioPath} has the following validation errors: ${validationErrors.join(', ')}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
scenarios.push({
|
||||
...scenario,
|
||||
id: this.formScenarioId(scenarioPath),
|
||||
scenarioDirPath: scenarioPath,
|
||||
});
|
||||
}
|
||||
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
private loadAndValidateScenarioManifest(
|
||||
scenarioManifestPath: string,
|
||||
): [ScenarioManifest, null] | [null, string[]] {
|
||||
const [scenario, error] = this.loadScenarioManifest(scenarioManifestPath);
|
||||
if (!scenario) {
|
||||
return [null, [error]];
|
||||
}
|
||||
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
if (!scenario.name) {
|
||||
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a name`);
|
||||
}
|
||||
if (!scenario.description) {
|
||||
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a description`);
|
||||
}
|
||||
|
||||
return validationErrors.length === 0 ? [scenario, null] : [null, validationErrors];
|
||||
}
|
||||
|
||||
private loadScenarioManifest(
|
||||
scenarioManifestPath: string,
|
||||
): [ScenarioManifest, null] | [null, string] {
|
||||
try {
|
||||
const scenario = JSON.parse(
|
||||
fs.readFileSync(scenarioManifestPath, 'utf8'),
|
||||
) as ScenarioManifest;
|
||||
|
||||
return [scenario, null];
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
return [null, `Failed to parse manifest ${scenarioManifestPath}: ${message}`];
|
||||
}
|
||||
}
|
||||
|
||||
private formScenarioId(scenarioPath: string): string {
|
||||
return createHash('sha256').update(scenarioPath).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Polls the /metrics endpoint from an n8n instance to collect application metrics
|
||||
* during benchmark test runs.
|
||||
*
|
||||
* A Poller can be started and stopped once. After it's stopped, it cannot be restarted.
|
||||
* Instead, a new poller instance should be created.
|
||||
*/
|
||||
export class AppMetricsPoller {
|
||||
private intervalId: NodeJS.Timeout | undefined = undefined;
|
||||
private metricsData: string[] = [];
|
||||
private isRunning = false;
|
||||
private isStopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly metricsUrl: string,
|
||||
private readonly pollIntervalMs: number = 5000,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Starts polling the metrics endpoint
|
||||
*/
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Metrics poller is already running');
|
||||
}
|
||||
if (this.isStopped) {
|
||||
throw new Error('Metrics poller has been stopped and cannot be restarted');
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.metricsData = [];
|
||||
|
||||
// Immediately poll once to get initial metrics
|
||||
void this.pollMetrics();
|
||||
|
||||
// Set up interval polling
|
||||
this.intervalId = setInterval(() => {
|
||||
void this.pollMetrics();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops polling the metrics endpoint
|
||||
*/
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.isStopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all collected metrics data
|
||||
*/
|
||||
getMetricsData(): string[] {
|
||||
return this.metricsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the metrics endpoint once
|
||||
*/
|
||||
private async pollMetrics() {
|
||||
try {
|
||||
const response = await fetch(this.metricsUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to poll metrics: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const metricsText = await response.text();
|
||||
this.metricsData.push(metricsText);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Error polling metrics: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from 'fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'path';
|
||||
import { $, which, tmpfile } from 'zx';
|
||||
|
||||
import { AppMetricsPoller } from '@/test-execution/app-metrics-poller';
|
||||
import { buildTestReport, type K6Tag } from '@/test-execution/test-report';
|
||||
import type { Scenario } from '@/types/scenario';
|
||||
export type { K6Tag };
|
||||
|
||||
export type K6ExecutorOpts = {
|
||||
k6ExecutablePath: string;
|
||||
/** How many concurrent requests to make */
|
||||
vus: number;
|
||||
/** Test duration, e.g. 1m or 30s */
|
||||
duration: string;
|
||||
k6Out?: string;
|
||||
k6ApiToken?: string;
|
||||
n8nApiBaseUrl: string;
|
||||
tags?: K6Tag[];
|
||||
resultsWebhook?: {
|
||||
url: string;
|
||||
authHeader: string;
|
||||
};
|
||||
/** Configuration for polling app metrics during test runs */
|
||||
appMetricsPolling?: {
|
||||
enabled: boolean;
|
||||
/** Poll interval in milliseconds. Defaults to 5000ms (5 seconds) */
|
||||
intervalMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type K6RunOpts = {
|
||||
/** Name of the scenario run. Used e.g. when the run is reported to k6 cloud */
|
||||
scenarioRunName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flag for the k6 CLI.
|
||||
* @example ['--duration', '1m']
|
||||
* @example ['--quiet']
|
||||
*/
|
||||
type K6CliFlag = [string | number] | [string, string | number];
|
||||
|
||||
/**
|
||||
* Executes test scenarios using k6
|
||||
*/
|
||||
export class K6Executor {
|
||||
/**
|
||||
* This script is dynamically injected into the k6 test script to generate
|
||||
* a summary report of the test execution.
|
||||
*/
|
||||
private readonly handleSummaryScript = `
|
||||
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
|
||||
export function handleSummary(data) {
|
||||
return {
|
||||
stdout: textSummary(data),
|
||||
'{{scenarioName}}.summary.json': JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
constructor(private readonly opts: K6ExecutorOpts) {}
|
||||
|
||||
async executeTestScenario(scenario: Scenario, { scenarioRunName }: K6RunOpts) {
|
||||
const augmentedTestScriptPath = this.augmentSummaryScript(scenario, scenarioRunName);
|
||||
const runDirPath = path.dirname(augmentedTestScriptPath);
|
||||
|
||||
const flags: K6CliFlag[] = [
|
||||
['--quiet'],
|
||||
['--duration', this.opts.duration],
|
||||
['--vus', this.opts.vus],
|
||||
];
|
||||
|
||||
if (this.opts.k6Out) {
|
||||
flags.push(['--out', this.opts.k6Out]);
|
||||
} else if (!this.opts.resultsWebhook && this.opts.k6ApiToken) {
|
||||
flags.push(['--out', 'cloud']);
|
||||
}
|
||||
|
||||
const flattedFlags = flags.flat(2);
|
||||
|
||||
const k6ExecutablePath = await this.resolveK6ExecutablePath();
|
||||
|
||||
// Start app metrics polling if enabled
|
||||
let metricsPoller: AppMetricsPoller | undefined;
|
||||
if (this.opts.appMetricsPolling?.enabled) {
|
||||
const metricsUrl = `${this.opts.n8nApiBaseUrl}/metrics`;
|
||||
const intervalMs = this.opts.appMetricsPolling.intervalMs ?? 5000;
|
||||
metricsPoller = new AppMetricsPoller(metricsUrl, intervalMs);
|
||||
metricsPoller.start();
|
||||
console.log(`Started polling app metrics from ${metricsUrl} every ${intervalMs}ms`);
|
||||
}
|
||||
|
||||
try {
|
||||
await $({
|
||||
cwd: runDirPath,
|
||||
env: {
|
||||
API_BASE_URL: this.opts.n8nApiBaseUrl,
|
||||
DATA_TABLE_ID: scenario.dataTableId,
|
||||
K6_CLOUD_TOKEN: this.opts.k6ApiToken,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
})`${k6ExecutablePath} run ${flattedFlags} ${augmentedTestScriptPath}`;
|
||||
} finally {
|
||||
// Stop metrics polling regardless of test outcome
|
||||
if (metricsPoller) {
|
||||
metricsPoller.stop();
|
||||
console.log('Stopped polling app metrics');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
if (this.opts.resultsWebhook) {
|
||||
const endOfTestSummary = this.loadEndOfTestSummary(runDirPath, scenarioRunName);
|
||||
const appMetricsData = metricsPoller?.getMetricsData();
|
||||
|
||||
const testReport = buildTestReport(
|
||||
scenario,
|
||||
endOfTestSummary,
|
||||
[
|
||||
...(this.opts.tags ?? []),
|
||||
{ name: 'Vus', value: this.opts.vus.toString() },
|
||||
{ name: 'Duration', value: this.opts.duration.toString() },
|
||||
],
|
||||
appMetricsData,
|
||||
);
|
||||
|
||||
await this.sendTestReport(testReport);
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestReport(testReport: unknown) {
|
||||
assert(this.opts.resultsWebhook);
|
||||
|
||||
const response = await fetch(this.opts.resultsWebhook.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(testReport),
|
||||
headers: {
|
||||
Authorization: this.opts.resultsWebhook.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to send test summary: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Augments the test script with a summary script
|
||||
*
|
||||
* @returns Absolute path to the augmented test script
|
||||
*/
|
||||
private augmentSummaryScript(scenario: Scenario, scenarioRunName: string) {
|
||||
const fullTestScriptPath = path.join(scenario.scenarioDirPath, scenario.scriptPath);
|
||||
const testScript = fs.readFileSync(fullTestScriptPath, 'utf8');
|
||||
const summaryScript = this.handleSummaryScript.replace('{{scenarioName}}', scenarioRunName);
|
||||
|
||||
const augmentedTestScript = `${testScript}\n\n${summaryScript}`;
|
||||
|
||||
const tempFilePath = tmpfile(`${scenarioRunName}.js`, augmentedTestScript);
|
||||
|
||||
return tempFilePath;
|
||||
}
|
||||
|
||||
private loadEndOfTestSummary(dir: string, scenarioRunName: string): K6EndOfTestSummary {
|
||||
const summaryReportPath = path.join(dir, `${scenarioRunName}.summary.json`);
|
||||
const summaryReport = fs.readFileSync(summaryReportPath, 'utf8');
|
||||
|
||||
try {
|
||||
return JSON.parse(summaryReport) as K6EndOfTestSummary;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse the summary report at ${summaryReportPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Resolved path to the k6 executable
|
||||
*/
|
||||
private async resolveK6ExecutablePath(): Promise<string> {
|
||||
const k6ExecutablePath = await which(this.opts.k6ExecutablePath, { nothrow: true });
|
||||
if (!k6ExecutablePath) {
|
||||
throw new Error(
|
||||
'Could not find k6 executable based on your `PATH`. Please ensure k6 is available in your system and add it to your `PATH` or specify the path to the k6 executable using the `K6_PATH` environment variable.',
|
||||
);
|
||||
}
|
||||
|
||||
return k6ExecutablePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
Example JSON:
|
||||
|
||||
{
|
||||
"options": {
|
||||
"summaryTrendStats": ["avg", "min", "med", "max", "p(90)", "p(95)"],
|
||||
"summaryTimeUnit": "",
|
||||
"noColor": false
|
||||
},
|
||||
"state": { "isStdOutTTY": false, "isStdErrTTY": false, "testRunDurationMs": 23.374 },
|
||||
"metrics": {
|
||||
"http_req_tls_handshaking": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": { "avg": 0, "min": 0, "med": 0, "max": 0, "p(90)": 0, "p(95)": 0 }
|
||||
},
|
||||
"checks": {
|
||||
"type": "rate",
|
||||
"contains": "default",
|
||||
"values": { "rate": 1, "passes": 1, "fails": 0 }
|
||||
},
|
||||
"http_req_sending": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"p(90)": 0.512,
|
||||
"p(95)": 0.512,
|
||||
"avg": 0.512,
|
||||
"min": 0.512,
|
||||
"med": 0.512,
|
||||
"max": 0.512
|
||||
}
|
||||
},
|
||||
"http_reqs": {
|
||||
"contains": "default",
|
||||
"values": { "count": 1, "rate": 42.78257893385813 },
|
||||
"type": "counter"
|
||||
},
|
||||
"http_req_blocked": {
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"avg": 1.496,
|
||||
"min": 1.496,
|
||||
"med": 1.496,
|
||||
"max": 1.496,
|
||||
"p(90)": 1.496,
|
||||
"p(95)": 1.496
|
||||
},
|
||||
"type": "trend"
|
||||
},
|
||||
"data_received": {
|
||||
"type": "counter",
|
||||
"contains": "data",
|
||||
"values": { "count": 269, "rate": 11508.513733207838 }
|
||||
},
|
||||
"iterations": {
|
||||
"type": "counter",
|
||||
"contains": "default",
|
||||
"values": { "count": 1, "rate": 42.78257893385813 }
|
||||
},
|
||||
"http_req_waiting": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"p(95)": 18.443,
|
||||
"avg": 18.443,
|
||||
"min": 18.443,
|
||||
"med": 18.443,
|
||||
"max": 18.443,
|
||||
"p(90)": 18.443
|
||||
}
|
||||
},
|
||||
"http_req_receiving": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"avg": 0.186,
|
||||
"min": 0.186,
|
||||
"med": 0.186,
|
||||
"max": 0.186,
|
||||
"p(90)": 0.186,
|
||||
"p(95)": 0.186
|
||||
}
|
||||
},
|
||||
"http_req_duration{expected_response:true}": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"max": 19.141,
|
||||
"p(90)": 19.141,
|
||||
"p(95)": 19.141,
|
||||
"avg": 19.141,
|
||||
"min": 19.141,
|
||||
"med": 19.141
|
||||
}
|
||||
},
|
||||
"iteration_duration": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"avg": 22.577833,
|
||||
"min": 22.577833,
|
||||
"med": 22.577833,
|
||||
"max": 22.577833,
|
||||
"p(90)": 22.577833,
|
||||
"p(95)": 22.577833
|
||||
}
|
||||
},
|
||||
"http_req_connecting": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"avg": 0.673,
|
||||
"min": 0.673,
|
||||
"med": 0.673,
|
||||
"max": 0.673,
|
||||
"p(90)": 0.673,
|
||||
"p(95)": 0.673
|
||||
}
|
||||
},
|
||||
"http_req_failed": {
|
||||
"type": "rate",
|
||||
"contains": "default",
|
||||
"values": { "rate": 0, "passes": 0, "fails": 1 }
|
||||
},
|
||||
"http_req_duration": {
|
||||
"type": "trend",
|
||||
"contains": "time",
|
||||
"values": {
|
||||
"p(90)": 19.141,
|
||||
"p(95)": 19.141,
|
||||
"avg": 19.141,
|
||||
"min": 19.141,
|
||||
"med": 19.141,
|
||||
"max": 19.141
|
||||
}
|
||||
},
|
||||
"data_sent": {
|
||||
"type": "counter",
|
||||
"contains": "data",
|
||||
"values": { "count": 102, "rate": 4363.82305125353 }
|
||||
}
|
||||
},
|
||||
"root_group": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"id": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"groups": [],
|
||||
"checks": [
|
||||
{
|
||||
"name": "is status 200",
|
||||
"path": "::is status 200",
|
||||
"id": "548d37ca5f33793206f7832e7cea54fb",
|
||||
"passes": 1,
|
||||
"fails": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
type TrendStat = 'avg' | 'min' | 'med' | 'max' | 'p(90)' | 'p(95)';
|
||||
type MetricType = 'trend' | 'rate' | 'counter';
|
||||
type MetricContains = 'time' | 'default' | 'data';
|
||||
|
||||
interface TrendValues {
|
||||
avg: number;
|
||||
min: number;
|
||||
med: number;
|
||||
max: number;
|
||||
'p(90)': number;
|
||||
'p(95)': number;
|
||||
}
|
||||
|
||||
interface RateValues {
|
||||
rate: number;
|
||||
passes: number;
|
||||
fails: number;
|
||||
}
|
||||
|
||||
interface CounterValues {
|
||||
count: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
interface K6TrendMetric {
|
||||
type: 'trend';
|
||||
contains: 'time';
|
||||
values: TrendValues;
|
||||
}
|
||||
|
||||
interface RateMetric {
|
||||
type: 'rate';
|
||||
contains: 'default';
|
||||
values: RateValues;
|
||||
}
|
||||
|
||||
interface K6CounterMetric {
|
||||
type: 'counter';
|
||||
contains: MetricContains;
|
||||
values: CounterValues;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
summaryTrendStats: TrendStat[];
|
||||
summaryTimeUnit: string;
|
||||
noColor: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isStdOutTTY: boolean;
|
||||
isStdErrTTY: boolean;
|
||||
testRunDurationMs: number;
|
||||
}
|
||||
|
||||
interface Metrics {
|
||||
http_req_tls_handshaking: K6TrendMetric;
|
||||
checks: RateMetric;
|
||||
http_req_sending: K6TrendMetric;
|
||||
http_reqs: K6CounterMetric;
|
||||
http_req_blocked: K6TrendMetric;
|
||||
data_received: K6CounterMetric;
|
||||
iterations: K6CounterMetric;
|
||||
http_req_waiting: K6TrendMetric;
|
||||
http_req_receiving: K6TrendMetric;
|
||||
'http_req_duration{expected_response:true}': K6TrendMetric;
|
||||
iteration_duration: K6TrendMetric;
|
||||
http_req_connecting: K6TrendMetric;
|
||||
http_req_failed: RateMetric;
|
||||
http_req_duration: K6TrendMetric;
|
||||
data_sent: K6CounterMetric;
|
||||
}
|
||||
|
||||
interface K6Check {
|
||||
name: string;
|
||||
path: string;
|
||||
id: string;
|
||||
passes: number;
|
||||
fails: number;
|
||||
}
|
||||
|
||||
interface RootGroup {
|
||||
name: string;
|
||||
path: string;
|
||||
id: string;
|
||||
groups: unknown[];
|
||||
checks: K6Check[];
|
||||
}
|
||||
|
||||
interface K6EndOfTestSummary {
|
||||
options: Options;
|
||||
state: State;
|
||||
metrics: Metrics;
|
||||
root_group: RootGroup;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Parses Prometheus metrics text format and extracts time series data
|
||||
*/
|
||||
export class PrometheusMetricsParser {
|
||||
/**
|
||||
* Extracts all values for a specific metric from collected metrics data
|
||||
*/
|
||||
static extractMetricValues(metricsData: string[], metricName: string): number[] {
|
||||
const values: number[] = [];
|
||||
|
||||
for (const metricsText of metricsData) {
|
||||
const lines = metricsText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.startsWith('#') || line.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse metric line: metric_name{labels} value
|
||||
// For example: n8n_nodejs_heap_size_total_bytes 149159936
|
||||
// For simplicity, we'll just check if the line starts with the metric name
|
||||
if (line.startsWith(metricName)) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const value = parseFloat(parts[parts.length - 1]);
|
||||
if (!isNaN(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates statistics for a metric from collected data
|
||||
*/
|
||||
static calculateMetricStats(
|
||||
metricsData: string[],
|
||||
metricName: string,
|
||||
): { max: number; avg: number; min: number; count: number } | null {
|
||||
const values = this.extractMetricValues(metricsData, metricName);
|
||||
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
const avg = sum / values.length;
|
||||
|
||||
return {
|
||||
max,
|
||||
min,
|
||||
avg,
|
||||
count: values.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
|
||||
import { CredentialApiClient } from '@/n8n-api-client/credentials-api-client';
|
||||
import { DataTableApiClient } from '@/n8n-api-client/data-table-api-client';
|
||||
import type { Workflow, Credential, DataTable } from '@/n8n-api-client/n8n-api-client.types';
|
||||
import { ProjectApiClient } from '@/n8n-api-client/project-api-client';
|
||||
import { WorkflowApiClient } from '@/n8n-api-client/workflows-api-client';
|
||||
import type { LoadableScenarioData } from '@/scenario/scenario-data-loader';
|
||||
|
||||
/**
|
||||
* Imports scenario data into an n8n instance
|
||||
*/
|
||||
export class ScenarioDataImporter {
|
||||
private readonly workflowApiClient: WorkflowApiClient;
|
||||
private readonly credentialApiClient: CredentialApiClient;
|
||||
private readonly dataTableApiClient: DataTableApiClient;
|
||||
private readonly projectApiClient: ProjectApiClient;
|
||||
|
||||
constructor(n8nApiClient: AuthenticatedN8nApiClient) {
|
||||
this.workflowApiClient = new WorkflowApiClient(n8nApiClient);
|
||||
this.credentialApiClient = new CredentialApiClient(n8nApiClient);
|
||||
this.dataTableApiClient = new DataTableApiClient(n8nApiClient);
|
||||
this.projectApiClient = new ProjectApiClient(n8nApiClient);
|
||||
}
|
||||
|
||||
private replaceValuesInObject(obj: unknown, searchText: string, targetText: string) {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.map((item) => this.replaceValuesInObject(item, searchText, targetText));
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string' && value === searchText) {
|
||||
(obj as Record<string, unknown>)[key] = targetText;
|
||||
} else {
|
||||
this.replaceValuesInObject(value, searchText, targetText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async importTestScenarioData(data: LoadableScenarioData) {
|
||||
const existingWorkflows = await this.workflowApiClient.getAllWorkflows();
|
||||
const existingCredentials = await this.credentialApiClient.getAllCredentials();
|
||||
const existingDataTables = await this.dataTableApiClient.getAllDataTables();
|
||||
|
||||
for (const credential of data.credentials) {
|
||||
const createdCredential = await this.importCredentials({ existingCredentials, credential });
|
||||
|
||||
// We need to update the id and name of the credential in the workflows
|
||||
for (const workflow of data.workflows) {
|
||||
this.replaceValuesInObject(workflow, credential.id, createdCredential.id);
|
||||
this.replaceValuesInObject(workflow, credential.name, createdCredential.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of data.workflows) {
|
||||
await this.importWorkflow({ existingWorkflows, workflow });
|
||||
}
|
||||
|
||||
let dataTableId: string | undefined;
|
||||
if (data.dataTable) {
|
||||
dataTableId = await this.importDataTable({ existingDataTables, dataTable: data.dataTable });
|
||||
}
|
||||
|
||||
return { dataTableId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a single credential into n8n removing any existing credentials with the same name
|
||||
* @param opts
|
||||
* @returns
|
||||
*/
|
||||
private async importCredentials(opts: {
|
||||
existingCredentials: Credential[];
|
||||
credential: Credential;
|
||||
}) {
|
||||
const existingCredentials = this.findExistingCredentials(
|
||||
opts.existingCredentials,
|
||||
opts.credential,
|
||||
);
|
||||
if (existingCredentials.length > 0) {
|
||||
for (const toDelete of existingCredentials) {
|
||||
await this.credentialApiClient.deleteCredential(toDelete.id);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.credentialApiClient.createCredential({
|
||||
...opts.credential,
|
||||
name: this.getBenchmarkCredentialName(opts.credential),
|
||||
});
|
||||
}
|
||||
|
||||
private async importDataTable(opts: { existingDataTables: DataTable[]; dataTable: DataTable }) {
|
||||
const { existingDataTables, dataTable } = opts;
|
||||
|
||||
const projectId = await this.projectApiClient.getPersonalProject();
|
||||
|
||||
const existingTable = existingDataTables.find(
|
||||
(dt) => dt.name === this.getBenchmarkDataTableName(dataTable),
|
||||
);
|
||||
|
||||
if (existingTable) {
|
||||
await this.dataTableApiClient.deleteDataTable(projectId, existingTable.id);
|
||||
}
|
||||
|
||||
const { id } = await this.dataTableApiClient.createDataTable(projectId, {
|
||||
...dataTable,
|
||||
name: this.getBenchmarkDataTableName(dataTable),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a single workflow into n8n removing any existing workflows with the same name
|
||||
*/
|
||||
private async importWorkflow(opts: { existingWorkflows: Workflow[]; workflow: Workflow }) {
|
||||
const existingWorkflows = this.findExistingWorkflows(opts.existingWorkflows, opts.workflow);
|
||||
if (existingWorkflows.length > 0) {
|
||||
for (const toDelete of existingWorkflows) {
|
||||
await this.workflowApiClient.archiveWorkflow(toDelete.id);
|
||||
await this.workflowApiClient.deleteWorkflow(toDelete.id);
|
||||
}
|
||||
}
|
||||
|
||||
const createdWorkflow = await this.workflowApiClient.createWorkflow({
|
||||
...opts.workflow,
|
||||
name: this.getBenchmarkWorkflowName(opts.workflow),
|
||||
});
|
||||
|
||||
return await this.workflowApiClient.activateWorkflow(createdWorkflow);
|
||||
}
|
||||
|
||||
private findExistingCredentials(
|
||||
existingCredentials: Credential[],
|
||||
credentialToImport: Credential,
|
||||
): Credential[] {
|
||||
const benchmarkCredentialName = this.getBenchmarkCredentialName(credentialToImport);
|
||||
|
||||
return existingCredentials.filter(
|
||||
(existingCredential) => existingCredential.name === benchmarkCredentialName,
|
||||
);
|
||||
}
|
||||
|
||||
private findExistingWorkflows(
|
||||
existingWorkflows: Workflow[],
|
||||
workflowToImport: Workflow,
|
||||
): Workflow[] {
|
||||
const benchmarkWorkflowName = this.getBenchmarkWorkflowName(workflowToImport);
|
||||
|
||||
return existingWorkflows.filter(
|
||||
(existingWorkflow) => existingWorkflow.name === benchmarkWorkflowName,
|
||||
);
|
||||
}
|
||||
|
||||
private getBenchmarkCredentialName(credential: Credential) {
|
||||
return `[BENCHMARK] ${credential.name}`;
|
||||
}
|
||||
|
||||
private getBenchmarkWorkflowName(workflow: Workflow) {
|
||||
return `[BENCHMARK] ${workflow.name}`;
|
||||
}
|
||||
|
||||
private getBenchmarkDataTableName(dataTable: DataTable) {
|
||||
return `[BENCHMARK] ${dataTable.name}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { sleep } from 'zx';
|
||||
|
||||
import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
|
||||
import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
|
||||
import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
|
||||
import { ScenarioDataImporter } from '@/test-execution/scenario-data-importer';
|
||||
import type { Scenario } from '@/types/scenario';
|
||||
|
||||
import type { K6Executor } from './k6-executor';
|
||||
|
||||
/**
|
||||
* Runs scenarios
|
||||
*/
|
||||
export class ScenarioRunner {
|
||||
constructor(
|
||||
private readonly n8nClient: N8nApiClient,
|
||||
private readonly dataLoader: ScenarioDataFileLoader,
|
||||
private readonly k6Executor: K6Executor,
|
||||
private readonly ownerConfig: {
|
||||
email: string;
|
||||
password: string;
|
||||
},
|
||||
private readonly scenarioPrefix: string,
|
||||
) {}
|
||||
|
||||
async runManyScenarios(scenarios: Scenario[]) {
|
||||
console.log(`Waiting for n8n ${this.n8nClient.apiBaseUrl} to become online`);
|
||||
await this.n8nClient.waitForInstanceToBecomeOnline();
|
||||
|
||||
console.log('Setting up owner');
|
||||
await this.n8nClient.setupOwnerIfNeeded(this.ownerConfig);
|
||||
|
||||
const authenticatedN8nClient = await AuthenticatedN8nApiClient.createUsingUsernameAndPassword(
|
||||
this.n8nClient,
|
||||
this.ownerConfig,
|
||||
);
|
||||
const testDataImporter = new ScenarioDataImporter(authenticatedN8nClient);
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await this.runSingleTestScenario(testDataImporter, scenario);
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingleTestScenario(testDataImporter: ScenarioDataImporter, scenario: Scenario) {
|
||||
const scenarioRunName = this.formTestScenarioRunName(scenario);
|
||||
console.log('Running scenario:', scenarioRunName);
|
||||
|
||||
console.log('Loading and importing data');
|
||||
const testData = await this.dataLoader.loadDataForScenario(scenario);
|
||||
const { dataTableId } = await testDataImporter.importTestScenarioData(testData);
|
||||
|
||||
// Wait for 1s before executing the scenario to ensure that the workflows are activated.
|
||||
// In multi-main mode it can take some time before the workflow becomes active.
|
||||
await sleep(1000);
|
||||
|
||||
console.log('Executing scenario script');
|
||||
await this.k6Executor.executeTestScenario(
|
||||
{
|
||||
...scenario,
|
||||
dataTableId,
|
||||
},
|
||||
{
|
||||
scenarioRunName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forms a name for the scenario by combining prefix and scenario name.
|
||||
* The benchmarks are ran against different n8n setups, so we use the
|
||||
* prefix to differentiate between them.
|
||||
*/
|
||||
private formTestScenarioRunName(scenario: Scenario) {
|
||||
return `${this.scenarioPrefix}-${scenario.name}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { PrometheusMetricsParser } from '@/test-execution/prometheus-metrics-parser';
|
||||
import type { Scenario } from '@/types/scenario';
|
||||
|
||||
export type K6Tag = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Check = {
|
||||
name: string;
|
||||
passes: number;
|
||||
fails: number;
|
||||
};
|
||||
|
||||
export type CounterMetric = {
|
||||
type: 'counter';
|
||||
count: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
export type TrendMetric = {
|
||||
type: 'trend';
|
||||
'p(95)': number;
|
||||
avg: number;
|
||||
min: number;
|
||||
med: number;
|
||||
max: number;
|
||||
'p(90)': number;
|
||||
};
|
||||
|
||||
export type AppMetricStats = {
|
||||
max: number;
|
||||
avg: number;
|
||||
min: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type AppMetricsReport = {
|
||||
heapSizeTotal?: AppMetricStats;
|
||||
heapSizeUsed?: AppMetricStats;
|
||||
externalMemory?: AppMetricStats;
|
||||
eventLoopLag?: AppMetricStats;
|
||||
};
|
||||
|
||||
export type TestReport = {
|
||||
runId: string;
|
||||
ts: string; // ISO8601
|
||||
scenarioName: string;
|
||||
tags: K6Tag[];
|
||||
metrics: {
|
||||
iterations: CounterMetric;
|
||||
dataReceived: CounterMetric;
|
||||
dataSent: CounterMetric;
|
||||
httpRequests: CounterMetric;
|
||||
httpRequestDuration: TrendMetric;
|
||||
httpRequestSending: TrendMetric;
|
||||
httpRequestReceiving: TrendMetric;
|
||||
httpRequestWaiting: TrendMetric;
|
||||
};
|
||||
checks: Check[];
|
||||
appMetrics?: AppMetricsReport;
|
||||
};
|
||||
|
||||
function k6CheckToCheck(check: K6Check): Check {
|
||||
return {
|
||||
name: check.name,
|
||||
passes: check.passes,
|
||||
fails: check.fails,
|
||||
};
|
||||
}
|
||||
|
||||
function k6CounterToCounter(counter: K6CounterMetric): CounterMetric {
|
||||
return {
|
||||
type: 'counter',
|
||||
count: counter.values.count,
|
||||
rate: counter.values.rate,
|
||||
};
|
||||
}
|
||||
|
||||
function k6TrendToTrend(trend: K6TrendMetric): TrendMetric {
|
||||
return {
|
||||
type: 'trend',
|
||||
'p(90)': trend.values['p(90)'],
|
||||
avg: trend.values.avg,
|
||||
min: trend.values.min,
|
||||
med: trend.values.med,
|
||||
max: trend.values.max,
|
||||
'p(95)': trend.values['p(95)'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an app metrics report from collected Prometheus metrics data
|
||||
*/
|
||||
export function buildAppMetricsReport(metricsData: string[]): AppMetricsReport {
|
||||
const heapSizeTotal = PrometheusMetricsParser.calculateMetricStats(
|
||||
metricsData,
|
||||
'n8n_nodejs_heap_size_total_bytes',
|
||||
);
|
||||
const heapSizeUsed = PrometheusMetricsParser.calculateMetricStats(
|
||||
metricsData,
|
||||
'n8n_nodejs_heap_size_used_bytes',
|
||||
);
|
||||
const externalMemory = PrometheusMetricsParser.calculateMetricStats(
|
||||
metricsData,
|
||||
'n8n_nodejs_external_memory_bytes',
|
||||
);
|
||||
const eventLoopLag = PrometheusMetricsParser.calculateMetricStats(
|
||||
metricsData,
|
||||
'n8n_nodejs_eventloop_lag_seconds',
|
||||
);
|
||||
|
||||
return {
|
||||
...(heapSizeTotal && { heapSizeTotal }),
|
||||
...(heapSizeUsed && { heapSizeUsed }),
|
||||
...(externalMemory && { externalMemory }),
|
||||
...(eventLoopLag && { eventLoopLag }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the k6 test summary to a test report
|
||||
*/
|
||||
export function buildTestReport(
|
||||
scenario: Scenario,
|
||||
endOfTestSummary: K6EndOfTestSummary,
|
||||
tags: K6Tag[],
|
||||
appMetricsData?: string[],
|
||||
): TestReport {
|
||||
const appMetrics = appMetricsData ? buildAppMetricsReport(appMetricsData) : undefined;
|
||||
|
||||
return {
|
||||
runId: nanoid(),
|
||||
ts: new Date().toISOString(),
|
||||
scenarioName: scenario.name,
|
||||
tags,
|
||||
checks: endOfTestSummary.root_group.checks.map(k6CheckToCheck),
|
||||
metrics: {
|
||||
dataReceived: k6CounterToCounter(endOfTestSummary.metrics.data_received),
|
||||
dataSent: k6CounterToCounter(endOfTestSummary.metrics.data_sent),
|
||||
httpRequests: k6CounterToCounter(endOfTestSummary.metrics.http_reqs),
|
||||
httpRequestDuration: k6TrendToTrend(endOfTestSummary.metrics.http_req_duration),
|
||||
httpRequestSending: k6TrendToTrend(endOfTestSummary.metrics.http_req_sending),
|
||||
httpRequestReceiving: k6TrendToTrend(endOfTestSummary.metrics.http_req_receiving),
|
||||
httpRequestWaiting: k6TrendToTrend(endOfTestSummary.metrics.http_req_waiting),
|
||||
iterations: k6CounterToCounter(endOfTestSummary.metrics.iterations),
|
||||
},
|
||||
...(appMetrics && { appMetrics }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type ScenarioData = {
|
||||
/** Relative paths to the workflow files */
|
||||
workflowFiles?: string[];
|
||||
/** Relative paths to the credential files */
|
||||
credentialFiles?: string[];
|
||||
/** Relative paths to the data table files */
|
||||
dataTableFile?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration that defines the benchmark scenario
|
||||
*/
|
||||
export type ScenarioManifest = {
|
||||
/** The name of the scenario */
|
||||
name: string;
|
||||
/** A longer description of the scenario */
|
||||
description: string;
|
||||
/** Relative path to the k6 script */
|
||||
scriptPath: string;
|
||||
/** Data to import before running the scenario */
|
||||
scenarioData: ScenarioData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario with additional metadata
|
||||
*/
|
||||
export type Scenario = ScenarioManifest & {
|
||||
id: string;
|
||||
/** Path to the directory containing the scenario */
|
||||
scenarioDirPath: string;
|
||||
/** ID of the data table created for the scenario, if any */
|
||||
dataTableId?: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": [
|
||||
"@n8n/typescript-config/tsconfig.common.json",
|
||||
"@n8n/typescript-config/tsconfig.backend.json"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user