Skip to content

Commit

Permalink
Merge pull request #3 from Informatievlaanderen/chore/s3-friendly
Browse files Browse the repository at this point in the history
Chore/s3 friendly
  • Loading branch information
rorlic authored Apr 2, 2024
2 parents 2d1b7db + ccc0181 commit 1f7c610
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 103 deletions.
7 changes: 4 additions & 3 deletions .env
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ dist
node_modules
tests/**
tests
logs
temp
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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}\""]
33 changes: 26 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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=<true|false>` prevents any console debug output if true, defaults to false (not silent, logging all debug info)
* `--port=<port-number>` allows to set the port, defaults to `80`
Expand All @@ -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`.
Expand Down Expand Up @@ -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 /<test-run-id>/results` -- Get Test Run Results
### `GET /test/<test-run-id>/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 /<test-run-id>` -- Remove Test Run
### `DELETE /test/<test-run-id>` -- 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.
8 changes: 5 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
133 changes: 79 additions & 54 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!DOCTYPE html><html>\
Expand Down Expand Up @@ -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));
}

Expand All @@ -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);
}
Expand All @@ -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 {
Expand All @@ -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() {
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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);
Expand All @@ -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<Labels>((a, x) => (a[x.key] = x.value?.toString(), a), {});

const timestamp = new Date().toISOString();
Expand All @@ -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 };
}

Expand Down
4 changes: 2 additions & 2 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrometheusContentType>,
Expand Down
Loading

0 comments on commit 1f7c610

Please sign in to comment.