diff --git a/.env b/.env index 3400123..261db7b 100644 --- a/.env +++ b/.env @@ -1,7 +1,8 @@ # jmeter runner variables and arguments -BASE_URL=http://localhost:9000 -TEST_FOLDER=./tests -SILENT=true +# BASE_URL=http://localhost:9000 +# TEST_FOLDER=./tests +# TEMP_FOLDER=./temp +# SILENT=false # MAX_RUNNING=1 # REFRESH_TIME=30 # RUN_TEST_API_KEY= diff --git a/.gitignore b/.gitignore index 7ad1031..5717d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ dist node_modules tests/** tests -logs +temp diff --git a/Dockerfile b/Dockerfile index 2f999dd..5ec7851 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN cd ./jmeter-runner && npm ci --omit=dev ENV BASE_URL= ENV PORT= ENV TEST_FOLDER_BASE= +ENV TEMP_FOLDER_BASE= ENV SILENT= ENV MAX_RUNNING= ENV REFRESH_TIME= @@ -58,4 +59,4 @@ RUN echo "jmeter.reportgenerator.temp_dir=/tmp/jmeter" >> /home/node/apache-jmet RUN chown node:node -R /home/node/* WORKDIR /home/node/jmeter-runner USER node -CMD ["sh", "-c", "node ./server.js --host=0.0.0.0 --port=${PORT} --base-url=${BASE_URL} --test-folder-base=${TEST_FOLDER_BASE} --silent=${SILENT} --max-running=${MAX_RUNNING} --refresh-time=${REFRESH_TIME} --run-test-api-key=${RUN_TEST_API_KEY} --check-test-api-key=${CHECK_TEST_API_KEY} --delete-test-api-key=${DELETE_TEST_API_KEY} --custom-labels=\"${CUSTOM_LABELS}\""] +CMD ["sh", "-c", "node ./server.js --host=0.0.0.0 --port=${PORT} --base-url=${BASE_URL} --test-folder-base=${TEST_FOLDER_BASE} --temp-folder-base=${TEMP_FOLDER_BASE} --silent=${SILENT} --max-running=${MAX_RUNNING} --refresh-time=${REFRESH_TIME} --run-test-api-key=${RUN_TEST_API_KEY} --check-test-api-key=${CHECK_TEST_API_KEY} --delete-test-api-key=${DELETE_TEST_API_KEY} --custom-labels=\"${CUSTOM_LABELS}\""] diff --git a/README.md b/README.md index b034b5c..6fbf62e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ docker build --tag vsds/jmeter-runner . To run the jmeter runner Docker image mapped on port 9000 and storing the test data on the host system, you can use: ```bash -docker run -d -p 9000:80 -v ./tests:/home/node/jmeter-runner/tests:rw -e BASE_URL=http://localhost:9000 vsds/jmeter-runner +docker run -d -p 9000:80 -v ./tests:/home/node/jmeter-runner/tests:rw -v ./temp:/home/node/jmeter-runner/temp:rw -e BASE_URL=http://localhost:9000 vsds/jmeter-runner ``` The Docker run command will return a container ID (e.g. `e2267325aad52663fef226aad49e729acc92f2f3936bec47a354d015e47c33d6`), which you need to stop the container. @@ -48,10 +48,13 @@ The jmeter runner uses the file system as permanent storage to allow for keeping ```bash mkdir -p ./tests chmod 0777 ./tests +mkdir -p ./temp +chmod 0777 ./temp ``` The jmeter runner takes the following command line arguments: -* `--test-folder-base` the base directory to store all test related data, defaults to `./tests` +* `--test-folder-base` the test directory to store all test related (result) data, defaults to `./tests` +* `--temp-folder-base` the temp directory to store test run data, defaults to `./temp` * `--base-url` sets the 'external' base URL, used to refer to a test status and the test results, default to the host and port (using scheme HTTP, e.g. http://localhost:80) * `--silent=` prevents any console debug output if true, defaults to false (not silent, logging all debug info) * `--port=` allows to set the port, defaults to `80` @@ -61,6 +64,7 @@ The jmeter runner takes the following command line arguments: * `--run-test-api-key` the API key to protect the run test endpoint, defaults to no API key checking * `--check-test-api-key` the API key to protect the test status and results endpoints, defaults to no API key checking * `--delete-test-api-key` the API key to protect the delete test endpoint, defaults to no API key checking +* `--custom-labels` collection of custom labels (separated by a blank) for prometheus, defaults to `` > **Note** that you can pass these API keys using the header `x-api-key`. @@ -121,20 +125,35 @@ or if you want to display everything, you need to add a zero limit: curl http://localhost:9000/c47a3487-2f9f-433c-ab5a-82b196fff7e1?limit=0 ``` -### `GET //results` -- Get Test Run Results +### `GET /test//results` -- Get Test Run Results Returns a HTML page with the results for the test run with the given ID. ```bash -curl "http://localhost:9000/c47a3487-2f9f-433c-ab5a-82b196fff7e1/results" +curl "http://localhost:9000/test/c47a3487-2f9f-433c-ab5a-82b196fff7e1/results" +``` +> **Note** that you can also get the `jmeter.log` and related files (`test.jmx`, `report.jtl`, etc.), e.g. +```bash +curl "http://localhost:9000/test/c47a3487-2f9f-433c-ab5a-82b196fff7e1/jmeter.log" ``` -### `DELETE /` -- Remove Test Run +### `DELETE /test/` -- Remove Test Run Removes the test run with the given ID and its related data including resuls, so use with caution. ```bash -curl -X DELETE http://localhost:9000/c47a3487-2f9f-433c-ab5a-82b196fff7e1 +curl -X DELETE http://localhost:9000/test/c47a3487-2f9f-433c-ab5a-82b196fff7e1 +``` +> **Note** that if the test is still running, you need to confirm the deletion by adding `?confirm=true`, e.g. +```bash +curl -X DELETE http://localhost:9000/test/c47a3487-2f9f-433c-ab5a-82b196fff7e1?confirm=true ``` ### `DELETE /` -- Remove All Test Runs Removes all tests and their related data including resuls, so use with extreme caution. ```bash -curl -X DELETE http://localhost:9000/ +curl -X DELETE http://localhost:9000/test ``` +> **Note** that if a test is still running, you need to confirm the deletion by adding `?confirm=true`, e.g. +```bash +curl -X DELETE http://localhost:9000/test?confirm=true +``` + +### `GET /prometheus` -- Get Metrics +Exposes the metrics using [Prometheus](https://prometheus.io/) format. diff --git a/docker-compose.yml b/docker-compose.yml index ca82526..c661192 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,12 +11,14 @@ services: networks: - performance_testing volumes: - - ${TEST_FOLDER}:/home/node/jmeter-runner/tests:rw + - ${TEST_FOLDER:-./tests}:/home/node/jmeter-runner/tests:rw + - ${TEMP_FOLDER:-./temp}:/home/node/jmeter-runner/temp:rw ports: - 9000:80 environment: - # - TEST_FOLDER_BASE=/home/node/jmeter-runner/tests - - BASE_URL=${BASE_URL} + - TEST_FOLDER_BASE=/home/node/jmeter-runner/tests + - TEMP_FOLDER_BASE=/home/node/jmeter-runner/temp + - BASE_URL=${BASE_URL:-http://localhost:9000} - SILENT=${SILENT:-false} - MAX_RUNNING=${MAX_RUNNING:-1} - REFRESH_TIME=${REFRESH_TIME:-30} diff --git a/src/controller.ts b/src/controller.ts index d211032..8851208 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -14,6 +14,7 @@ import { Gauge } from 'prom-client'; export const metadataName = 'metadata.json'; const testName = 'test.jmx'; const reportName = 'report.jtl'; +const outputName = 'output.log'; const resultsFolder = 'results'; const statusTemplate = '\ @@ -71,16 +72,40 @@ export class Controller { return (await fsp.readdir(source, { withFileTypes: true })).filter(x => x.isDirectory()).map(x => x.name); } - private _cancelRunningTests() { - Object.values(this._testsById).forEach(test => { - if (test.run.status === TestRunStatus.running) { - test.run.status = TestRunStatus.cancelled; + private _importTest(id: string) { + const metadata = path.join(this._config.testFolder, id, metadataName); + if (fs.existsSync(metadata)) { + const fd = fs.openSync(metadata, 'r'); + try { + const content = fs.readFileSync(fd, { encoding: 'utf8' }); + const run = JSON.parse(content) as TestRun; + if (run.status === TestRunStatus.running) { + run.status = TestRunStatus.cancelled; // just in case + } + this._upsertTest({ run: run, process: undefined } as Test); + } finally { + fs.closeSync(fd); } - }); + } + } + + private _exportRun(id: string) { + const run = this._getTest(id)!.run; + this._writeMetadata({ ...run, status: TestRunStatus.cancelled }); + this._moveToResults(id); + } + + private _cancelTest(id: string) { + const test = this._getTest(id)!; + console.warn(`[WARN] Test ${id} is running...`); + const process = test.process; + console.warn(`[WARN] Killing pid ${process?.pid}...`); + const killed = process?.kill; + console.warn(killed ? `[WARN] Test ${id} was cancelled.` : `Failed to kill test ${id} (pid: ${process?.pid}).`); } private _writeMetadata(run: TestRun) { - const metadata = path.join(this._config.baseFolder, run.id, metadataName); + const metadata = path.join(this._config.tempFolder, run.id, metadataName); this._write(metadata, JSON.stringify(run)); } @@ -92,11 +117,20 @@ export class Controller { fs.writeFileSync(fullPathName, data, { encoding: 'utf8', flush: true }); } + private _moveToResults(id: string) { + const tempPath = path.join(this._config.tempFolder, id); + const testPath = path.join(this._config.testFolder, id); + + fs.cpSync(tempPath, testPath, { preserveTimestamps: true, recursive: true }); + fs.rmSync(tempPath, { recursive: true }); + } + constructor(private _config: ControllerConfig) { + const defaultLabels = ['category', 'name']; this._testDuration = new Gauge({ name: 'jmeter_test_duration', help: 'jmeter test duration (in seconds)', - labelNames: [...this._config.customLabels, 'category', 'name'], + labelNames: this._config.customLabels.length ? [...this._config.customLabels, ...defaultLabels] : defaultLabels, }); _config.register.registerMetric(this._testDuration); } @@ -105,27 +139,20 @@ export class Controller { return Object.values(this._testsById).filter(x => x.run.status == TestRunStatus.running).length; } - public async importTestRuns() { - const folders = await this._getSubDirectories(this._config.baseFolder); - folders.forEach(id => { - const metadata = path.join(this._config.baseFolder, id, metadataName); - if (fs.existsSync(metadata)) { - const fd = fs.openSync(metadata, 'r'); - try { - const content = fs.readFileSync(fd, { encoding: 'utf8' }); - const run = JSON.parse(content); - this._upsertTest({ run: run, process: undefined } as Test); - } finally { - fs.closeSync(fd); - } - } - }) - this._cancelRunningTests(); + public async importTestsAndRuns() { + const runs = await this._getSubDirectories(this._config.tempFolder); + runs.forEach(id => this._moveToResults(id)); + + const tests = await this._getSubDirectories(this._config.testFolder); + tests.forEach(id => this._importTest(id)); } public async exportTestRuns() { - this._cancelRunningTests(); - this._tests.forEach(test => this._writeMetadata(test.run)); + const runs = await this._getSubDirectories(this._config.tempFolder); + runs.forEach(id => { + this._cancelTest(id); + this._exportRun(id); + }); } public testExists(id: string): boolean { @@ -138,38 +165,34 @@ export class Controller { } public deleteTest(id: string) { - const testRunData = path.join(this._config.baseFolder, id); - const logFile = path.join(this._config.logFolder, `${id}.log`); + const testData = path.join(this._config.testFolder, id); + const runData = path.join(this._config.tempFolder, id); const exists = this.testExists(id); if (exists) { if (this.testRunning(id)) { - console.warn(`[WARN] Test ${id} is running...`); - const process = this._testsById[id]?.process; - console.warn(`[WARN] Killing pid ${process?.pid}...`); - const killed = process?.kill; - console.warn(killed ? `[WARN] Test ${id} was cancelled.` : `Failed to kill test ${id} (pid: ${process?.pid}).`); + this._cancelTest(id); } delete this._testsById[id]; } else { - console.warn(`[WARN] Test ${id} does not exist (in memory DB) but trying to remove test data (${testRunData}) and log file (${logFile}).`); + console.warn(`[WARN] Test ${id} does not exist (in memory DB) but trying to remove test data (${testData}).`); } - const testDataExists = fs.existsSync(testRunData); + const testDataExists = fs.existsSync(testData); if (testDataExists) { - if (!this._config.silent) console.info(`[INFO] Deleting test data at ${testRunData}...`); - fs.rmSync(testRunData, { recursive: true, force: true }); - console.warn(`[WARN] Deleted test data at ${testRunData}.`); + if (!this._config.silent) console.info(`[INFO] Deleting test data at ${testData}...`); + fs.rmSync(testData, { recursive: true, force: true }); + console.warn(`[WARN] Deleted test data at ${testData}.`); } - const logFileExists = fs.existsSync(logFile); - if (logFileExists) { - if (!this._config.silent) console.info(`[INFO] Deleting log file at ${logFile}...`); - fs.rmSync(logFile); - console.warn(`[WARN] Deleted log file at ${logFile}.`); + const runDataExists = fs.existsSync(runData); + if (testDataExists) { + if (!this._config.silent) console.info(`[INFO] Deleting test run data at ${runData}...`); + fs.rmSync(runData, { recursive: true, force: true }); + console.warn(`[WARN] Deleted test run data at ${runData}.`); } - return exists || testDataExists || logFileExists; + return exists || testDataExists || runDataExists; } public deleteAllTestRuns() { @@ -180,7 +203,7 @@ export class Controller { const test = this._getTest(id); if (!test) throw new Error(`Test ${id} does not exist.`); - const logs = path.join(this._config.logFolder, `${id}.log`); + const logs = path.join(this._config.tempFolder, id, outputName); const output = limit ? await read(logs, limit) : this._read(logs); const data = { ...test.run, @@ -203,9 +226,9 @@ export class Controller { .map(test => { switch (test.status) { case TestRunStatus.done: - return { ...test, link: `${this._config.baseUrl}/${test.id}/results/`, text: 'results' }; + return { ...test, link: `${this._config.baseUrl}/test/${test.id}/results/`, text: 'results' }; case TestRunStatus.cancelled: - return { ...test, link: `${this._config.baseUrl}/${test.id}`, text: 'output' }; + return { ...test, link: `${this._config.baseUrl}/test/${test.id}/jmeter.log`, text: 'output' }; case TestRunStatus.running: return { ...test, link: `${this._config.baseUrl}/${test.id}`, text: 'status' }; default: @@ -229,7 +252,7 @@ export class Controller { public async scheduleTestRun(body: string, category: string | undefined) { const id = uuidv4(); - const folder = path.join(this._config.baseFolder, id); + const folder = path.join(this._config.tempFolder, id); fs.mkdirSync(folder); await fsp.writeFile(path.join(folder, testName), body); @@ -239,12 +262,12 @@ export class Controller { const args = parsed.jmeterTestPlan.hashTree.hashTree.Arguments; const elements = Array.isArray(args) && args?.find(x => x._testname === 'Labels')?.collectionProp?.elementProp; const labels = Array.isArray(elements) && elements - .map(x => ({ - key: x._name, - value: Array.isArray(x.stringProp) + .map(x => ({ + key: x._name, + value: Array.isArray(x.stringProp) ? x.stringProp.find(s => s._name === 'Argument.value')?._text : (x.stringProp._name === 'Argument.value' ? x.stringProp._text : undefined) - })) + })) .reduce((a, x) => (a[x.key] = x.value?.toString(), a), {}); const timestamp = new Date().toISOString(); @@ -266,17 +289,19 @@ export class Controller { try { const duration = endTimer && endTimer({ ...labels, category: run.category, name: run.name }); const updatedTest = { run: { ...run, status: TestRunStatus.done, code: code, duration: duration }, process: jmeter } as Test; - this._writeMetadata(this._upsertTest(updatedTest).run); + const updatedRun = this._upsertTest(updatedTest).run; + this._writeMetadata(updatedRun); + this._moveToResults(updatedRun.id); } catch (error) { console.error('Failed to write metadata because: ', error); } }); - const logs = path.join(this._config.logFolder, `${id}.log`); + const logs = path.join(folder, outputName); jmeter.stdout.pipe(fs.createWriteStream(logs, { encoding: 'utf8', flags: 'a', flush: true, autoClose: true, emitClose: false })); const statusUrl = `${this._config.baseUrl}/${id}`; - const resultsUrl = `${statusUrl}/results/`; + const resultsUrl = `${this._config.baseUrl}/test/${id}/results`; return { id: id, status: statusUrl, results: resultsUrl }; } diff --git a/src/interfaces.ts b/src/interfaces.ts index fef162e..eb2539d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -49,9 +49,9 @@ export interface TestRun { } export interface ControllerConfig { - baseFolder: string, + testFolder: string, + tempFolder: string, baseUrl: string, - logFolder: string, refreshTimeInSeconds: number, silent: boolean, register: Registry, diff --git a/src/server.ts b/src/server.ts index ffd43a1..4321a26 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import minimist from 'minimist' import fs from 'node:fs'; import { Registry, collectDefaultMetrics } from 'prom-client'; -import { Controller, metadataName } from './controller'; +import { Controller } from './controller'; import { ControllerConfig } from './interfaces'; const megabyte = 1048576; @@ -38,38 +38,37 @@ const apiKeyRunTest = args['run-test-api-key'] || ''; const apiKeyCheckTest = args['check-test-api-key'] || ''; const apiKeyDeleteTest = args['delete-test-api-key'] || ''; const refreshTimeInSeconds = args['refresh-time'] || 30; -const labels: string = args['custom-labels']; +const labels: string = args['custom-labels'] || undefined; const customLabels = labels?.split(' ') || []; const testFolderBase = args['test-folder-base'] || './tests'; -const baseFolder = fs.realpathSync(testFolderBase); -if (!fs.existsSync(baseFolder)) { - fs.mkdirSync(baseFolder); +const testFolder = fs.realpathSync(testFolderBase); +if (!fs.existsSync(testFolder)) { + fs.mkdirSync(testFolder); } -console.info("Storing data in: ", baseFolder); +console.info("Storing test data (test, logs, results, etc) in: ", testFolder); -let logFolder = './logs'; -if (!fs.existsSync(logFolder)) { - fs.mkdirSync(logFolder); +const tempFolderBase = args['temp-folder-base'] || './temp'; +const tempFolder = fs.realpathSync(tempFolderBase); +if (!fs.existsSync(tempFolder)) { + fs.mkdirSync(tempFolder); } -logFolder = fs.realpathSync(logFolder); -console.info("Storing logs in: ", logFolder); +console.info("Storing temporary data (during test run) in: ", tempFolder); -const controller = new Controller({ baseFolder, baseUrl, refreshTimeInSeconds, logFolder, silent, register, customLabels } as ControllerConfig); +const controller = new Controller({ testFolder, tempFolder, baseUrl, refreshTimeInSeconds, silent, register, customLabels } as ControllerConfig); function checkApiKey(request: any, apiKey: string): boolean { return !apiKey || request.headers['x-api-key'] === apiKey; } server.register(fastifyStatic, { - root: baseFolder, - prefix: '/', - allowedPath: (pathName) => !pathName.endsWith(metadataName) + root: testFolder, + prefix: '/test' }); server.addHook('onReady', async () => { try { - await controller.importTestRuns(); + await controller.importTestsAndRuns(); } catch (error) { console.error('Failed to import metadata because: ', error); } @@ -111,10 +110,10 @@ server.post('/', { schema: { querystring: { category: { type: 'string' } } } }, const parameters = request.query as { category?: string }; const body = request.body as string; const response = await controller.scheduleTestRun(body, parameters.category); - reply.status(201).send(response); + return reply.status(201).send(response); } catch (error: any) { console.error('[ERROR] ', error); - reply.status(500); + return reply.status(500); } }); @@ -126,9 +125,9 @@ server.get('/', async (request, reply) => { try { const body = controller.getTestRunsOverview(); - reply.header('content-type', 'text/html').send(body); + return reply.header('content-type', 'text/html').send(body); } catch (error) { - reply.send({ msg: 'Cannot display test runs overview\n', error: error }); + return reply.send({ msg: 'Cannot display test runs overview\n', error: error }); } }); @@ -143,16 +142,16 @@ server.get('/:id', { schema: { querystring: { limit: { type: 'number' } } } }, a try { if (controller.testExists(id)) { const body = await controller.getTestRunStatus(id, parameters.limit); - reply.header('content-type', 'text/html').send(body); + return reply.header('content-type', 'text/html').send(body); } else { - reply.status(404).send(''); + return reply.status(404).send(''); } } catch (error) { - reply.send({ msg: `Cannot display status for test run ${id}\n`, error: error }); + return reply.send({ msg: `Cannot display status for test run ${id}\n`, error: error }); } }); -server.delete('/', { schema: { querystring: { confirm: { type: 'boolean' } } } }, async (request, reply) => { +server.delete('/test', { schema: { querystring: { confirm: { type: 'boolean' } } } }, async (request, reply) => { if (!checkApiKey(request, apiKeyDeleteTest)) { return reply.status(401).send(''); } @@ -161,17 +160,17 @@ server.delete('/', { schema: { querystring: { confirm: { type: 'boolean' } } } } try { if (controller.runningCount > 0 && !parameters.confirm) { - reply.status(405).send("Cannot delete all tests as some are still running.\nHint:pass query parameter '?confirm=true'.\n"); + return reply.status(405).send("Cannot delete all tests as some are still running.\nHint:pass query parameter '?confirm=true'.\n"); } else { controller.deleteAllTestRuns(); - reply.send('All tests deleted\n'); + return reply.send('All tests deleted\n'); } } catch (error) { - reply.send({ msg: 'Cannot delete all tests\n', error: error }); + return reply.send({ msg: 'Cannot delete all tests\n', error: error }); } }); -server.delete('/:id', { schema: { querystring: { confirm: { type: 'boolean' } } } }, async (request, reply) => { +server.delete('/test/:id', { schema: { querystring: { confirm: { type: 'boolean' } } } }, async (request, reply) => { if (!checkApiKey(request, apiKeyDeleteTest)) { return reply.status(401).send(''); } @@ -181,17 +180,17 @@ server.delete('/:id', { schema: { querystring: { confirm: { type: 'boolean' } } try { if (controller.testRunning(id) && !parameters.confirm) { - reply.status(405).send(`Test ${id} is still running.\nHint:pass query parameter '?confirm=true'.\n`); + return reply.status(405).send(`Test ${id} is still running.\nHint:pass query parameter '?confirm=true'.\n`); } else { const deleted = controller.deleteTest(id); if (deleted) { - reply.send(`Test ${id} deleted\n`); + return reply.send(`Test ${id} deleted\n`); } else { - reply.status(404).send(`Test ${id} not found\n`); + return reply.status(404).send(`Test ${id} not found\n`); } } } catch (error) { - reply.send({ msg: `Cannot delete test ${id}\n`, error: error }); + return reply.send({ msg: `Cannot delete test ${id}\n`, error: error }); } });