Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add n8n-benchmark cli (no-changelog) #10410

Merged
merged 19 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/benchmark/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# NOTE:
tomi marked this conversation as resolved.
Show resolved Hide resolved
# This Dockerfile needs to be built in the root of the repository
# `docker build -t n8n-benchmark -f packages/benchmark/Dockerfile .`
tomi marked this conversation as resolved.
Show resolved Hide resolved
tomi marked this conversation as resolved.
Show resolved Hide resolved
FROM node:20.16.0 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 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/benchmark/package.json /app/packages/benchmark/package.json
COPY --chown=node:node ./patches /app/patches
COPY --chown=node:node ./scripts /app/scripts

RUN pnpm install --frozen-lockfile

# Package.json
COPY --chown=node:node ./packages/benchmark/package.json /app/packages/benchmark/package.json
tomi marked this conversation as resolved.
Show resolved Hide resolved

# TS config files
COPY --chown=node:node ./tsconfig.json /app/tsconfig.json
COPY --chown=node:node ./tsconfig.build.json /app/tsconfig.build.json
COPY --chown=node:node ./tsconfig.backend.json /app/tsconfig.backend.json
COPY --chown=node:node ./packages/benchmark/tsconfig.json /app/packages/benchmark/tsconfig.json
COPY --chown=node:node ./packages/benchmark/tsconfig.build.json /app/packages/benchmark/tsconfig.build.json

# Source files
COPY --chown=node:node ./packages/benchmark/src /app/packages/benchmark/src
COPY --chown=node:node ./packages/benchmark/bin /app/packages/benchmark/bin
COPY --chown=node:node ./packages/benchmark/testScenarios /app/packages/benchmark/testScenarios

WORKDIR /app/packages/benchmark
RUN pnpm build

#
# Runner
FROM base AS runner

COPY --from=builder /app /app

WORKDIR /app/packages/benchmark
USER node

ENTRYPOINT [ "/app/packages/benchmark/bin/n8n-benchmark" ]
33 changes: 33 additions & 0 deletions packages/benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# n8n benchmarking tool

Tool for executing benchmarks against an n8n instance.

## Requirements

- [k6](https://grafana.com/docs/k6/latest/)
tomi marked this conversation as resolved.
Show resolved Hide resolved
- Node.js v20 or higher

## Running locally

```sh
pnpm build

# Run tests against http://localhost:5678 with specified email and password
[email protected] N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
tomi marked this conversation as resolved.
Show resolved Hide resolved

# If you installed k6 using brew, you might have to specify it explicitly
K6_PATH=/opt/homebrew/bin/k6 [email protected] N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
tomi marked this conversation as resolved.
Show resolved Hide resolved
```

## Configuration

The configuration options the cli accepts can be seen from [config.ts](./src/config/config.ts)

## Docker build

Because k6 doesn't have an arm64 build available for linux, we need to build against amd64

```sh
# In the repository root
docker build --platform linux/amd64 -f packages/benchmark/Dockerfile -t n8n-benchmark:latest .
```
13 changes: 13 additions & 0 deletions packages/benchmark/bin/n8n-benchmark
Original file line number Diff line number Diff line change
@@ -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 });
tomi marked this conversation as resolved.
Show resolved Hide resolved
})();
6 changes: 6 additions & 0 deletions packages/benchmark/nodemon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"ignore": ["**/*.spec.ts", ".git", "node_modules"],
tomi marked this conversation as resolved.
Show resolved Hide resolved
"watch": ["commands", "index.ts", "src"],
tomi marked this conversation as resolved.
Show resolved Hide resolved
"exec": "npm start",
tomi marked this conversation as resolved.
Show resolved Hide resolved
"ext": "ts"
}
41 changes: 41 additions & 0 deletions packages/benchmark/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "n8n-benchmark",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we ignore this package when doing a release? In the same way we don't ship test code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we don't need to publish this package at the moment. The only thing we need is the docker image, which is built from master. So the benchmark cli has its own release cycle

"version": "1.0.0",
"description": "",
tomi marked this conversation as resolved.
Show resolved Hide resolved
"main": "dist/index",
"scripts": {
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"start": "./bin/n8n-benchmark",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we adding prettier and the .vscode folder to make sure prettier is used to format and to format on save by default?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow. Do we have some prettier config in .vscode? We have the .prettierrc.js in the repo root, which AFAIK should apply to all packages?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant the formatter configuration in vscode as in here -> https://github.com/n8n-io/ai-assistant-service/blob/main/.vscode/settings.json

Copy link
Contributor Author

@tomi tomi Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we have those already. I think it's a better pattern to provide "default" settings for the repository, but allow devs to customize them however they want. Should probably do it the same way in the AI Assistant service

"test": "echo \"Error: no test specified\" && exit 1",
"typecheck": "tsc --noEmit",
"watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
tomi marked this conversation as resolved.
Show resolved Hide resolved
},
"engines": {
"node": ">=20.10"
},
"keywords": [],
"author": "",
"license": "ISC",
tomi marked this conversation as resolved.
Show resolved Hide resolved
"dependencies": {
"@oclif/core": "4.0.7",
"axios": "catalog:",
"convict": "6.2.4",
"dotenv": "8.6.0",
"zx": "^8.1.4"
},
"devDependencies": {
"@types/convict": "^6.1.1",
"@types/k6": "^0.52.0",
"@types/node": "^20.14.8",
"tsc-alias": "^1.8.7",
"typescript": "^5.5.2"
},
"bin": {
"n8n-benchmark": "./bin/n8n-benchmark"
},
"oclif": {
"bin": "n8n-benchmark",
"commands": "./dist/commands",
"topicSeparator": " "
}
}
21 changes: 21 additions & 0 deletions packages/benchmark/src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Command } from '@oclif/core';
import { TestScenarioLoader } from '@/testScenario/testScenarioLoader';
import { loadConfig } from '@/config/config';

export default class ListCommand extends Command {
static description = 'List all available test scenarios';
tomi marked this conversation as resolved.
Show resolved Hide resolved

async run() {
const config = loadConfig();
const testScenarioLoader = new TestScenarioLoader();

const allScenarios = testScenarioLoader.loadAllTestScenarios(config.get('testScenariosPath'));

console.log('Available test scenarios:');
tomi marked this conversation as resolved.
Show resolved Hide resolved
console.log('');
tomi marked this conversation as resolved.
Show resolved Hide resolved

for (const testCase of allScenarios) {
console.log('\t', testCase.name, ':', testCase.description);
}
}
}
39 changes: 39 additions & 0 deletions packages/benchmark/src/commands/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Command, Flags } from '@oclif/core';
import { loadConfig } from '@/config/config';
import { TestScenarioLoader } from '@/testScenario/testScenarioLoader';
import { TestScenarioRunner } from '@/testExecution/testScenarioRunner';
import { N8nApiClient } from '@/n8nApiClient/n8nApiClient';
import { TestDataFileLoader } from '@/testScenario/testDataLoader';
import { K6Executor } from '@/testExecution/k6Executor';

export default class RunCommand extends Command {
static description = 'Run all (default) or specified test scenarios';

// TODO: Add support for filtering scenarios
static flags = {
scenarios: Flags.string({
char: 't',
description: 'Comma-separated list of test scenarios to run',
required: false,
}),
};

async run() {
const config = loadConfig();
const testScenarioLoader = new TestScenarioLoader();

const testScenarioRunner = new TestScenarioRunner(
new N8nApiClient(config.get('n8n.baseUrl')),
new TestDataFileLoader(),
new K6Executor(config.get('k6ExecutablePath'), config.get('n8n.baseUrl')),
{
email: config.get('n8n.user.email'),
password: config.get('n8n.user.password'),
},
);

const allScenarios = testScenarioLoader.loadAllTestScenarios(config.get('testScenariosPath'));

await testScenarioRunner.runManyTestScenarios(allScenarios);
tomi marked this conversation as resolved.
Show resolved Hide resolved
}
}
50 changes: 50 additions & 0 deletions packages/benchmark/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import convict from 'convict';
tomi marked this conversation as resolved.
Show resolved Hide resolved
import dotenv from 'dotenv';

dotenv.config();

const configSchema = {
testScenariosPath: {
doc: 'The path to the test scenarios',
format: String,
default: 'testScenarios',
},
n8n: {
baseUrl: {
doc: 'The base URL for the n8n instance',
format: String,
default: 'http://localhost:5678',
env: 'N8N_BASE_URL',
},
user: {
email: {
doc: 'The email address of the n8n user',
format: String,
default: '[email protected]',
env: 'N8N_USER_EMAIL',
},
password: {
doc: 'The password of the n8n user',
format: String,
default: 'VerySecret!123',
env: 'N8N_USER_PASSWORD',
},
},
},
k6ExecutablePath: {
doc: 'The path to the k6 binary',
format: String,
default: 'k6',
env: 'K6_PATH',
},
};

export type Config = ReturnType<typeof loadConfig>;

export function loadConfig() {
const config = convict(configSchema);

config.validate({ allowed: 'strict' });

return config;
}
68 changes: 68 additions & 0 deletions packages/benchmark/src/n8nApiClient/authenticatedN8nApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { strict as assert } from 'node:assert';
import { N8nApiClient } from './n8nApiClient';
import { AxiosRequestConfig } from 'axios';

export class AuthenticatedN8nApiClient extends N8nApiClient {
constructor(
apiBaseUrl: string,
private readonly authCookie: string,
) {
super(apiBaseUrl);
}

static async createUsingUsernameAndPassword(
apiClient: N8nApiClient,
loginDetails: {
email: string;
password: string;
},
) {
const response = await apiClient.restApiRequest('/login', {
method: 'POST',
data: loginDetails,
});

const cookieHeader = response.headers['set-cookie'];
const authCookie = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader;
assert(authCookie);

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, data?: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'DELETE',
data,
});
}
tomi marked this conversation as resolved.
Show resolved Hide resolved

protected async authenticatedRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
return await this.restApiRequest<T>(endpoint, {
...init,
headers: {
...init.headers,
cookie: this.authCookie,
},
});
}
}
Loading