k6ctl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +61 -0
- package/README.md +45 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +28 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/list.d.ts +4 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/run.d.ts +9 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +76 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/services/kubernetes.service.d.ts +20 -0
- package/dist/services/kubernetes.service.d.ts.map +1 -0
- package/dist/services/kubernetes.service.js +278 -0
- package/dist/services/kubernetes.service.js.map +1 -0
- package/dist/services/script.service.d.ts +8 -0
- package/dist/services/script.service.d.ts.map +1 -0
- package/dist/services/script.service.js +82 -0
- package/dist/services/script.service.js.map +1 -0
- package/dist/types/config.types.d.ts +26 -0
- package/dist/types/config.types.d.ts.map +1 -0
- package/dist/types/config.types.js +3 -0
- package/dist/types/config.types.js.map +1 -0
- package/dist/types/kubernetes.types.d.ts +9 -0
- package/dist/types/kubernetes.types.d.ts.map +1 -0
- package/dist/types/kubernetes.types.js +3 -0
- package/dist/types/kubernetes.types.js.map +1 -0
- package/dist/types/script.types.d.ts +11 -0
- package/dist/types/script.types.d.ts.map +1 -0
- package/dist/types/script.types.js +3 -0
- package/dist/types/script.types.js.map +1 -0
- package/dist/types/testRunManifest.types.d.ts +43 -0
- package/dist/types/testRunManifest.types.d.ts.map +1 -0
- package/dist/types/testRunManifest.types.js +3 -0
- package/dist/types/testRunManifest.types.js.map +1 -0
- package/dist/utils/configLoader.d.ts +30 -0
- package/dist/utils/configLoader.d.ts.map +1 -0
- package/dist/utils/configLoader.js +63 -0
- package/dist/utils/configLoader.js.map +1 -0
- package/dist/utils/env.d.ts +17 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +111 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/table.util.d.ts +10 -0
- package/dist/utils/table.util.d.ts.map +1 -0
- package/dist/utils/table.util.js +24 -0
- package/dist/utils/table.util.js.map +1 -0
- package/dist/utils/testRunManifestBuilder.d.ts +5 -0
- package/dist/utils/testRunManifestBuilder.d.ts.map +1 -0
- package/dist/utils/testRunManifestBuilder.js +89 -0
- package/dist/utils/testRunManifestBuilder.js.map +1 -0
- package/package.json +44 -0
- package/src/cli.ts +31 -0
- package/src/commands/list.ts +29 -0
- package/src/commands/run.ts +52 -0
- package/src/services/kubernetes.service.ts +238 -0
- package/src/services/script.service.ts +79 -0
- package/src/types/config.types.ts +25 -0
- package/src/types/kubernetes.types.ts +9 -0
- package/src/types/script.types.ts +8 -0
- package/src/types/testRunManifest.types.ts +42 -0
- package/src/utils/configLoader.ts +63 -0
- package/src/utils/env.ts +90 -0
- package/src/utils/logger.ts +17 -0
- package/src/utils/table.util.ts +23 -0
- package/src/utils/testRunManifestBuilder.ts +102 -0
- package/test/integration/env.test.ts +33 -0
- package/test/integration/kubernetes.service.test.ts +70 -0
- package/test/integration/script.service.test.ts +64 -0
- package/test/jest.config.integration.js +20 -0
- package/test/jest.config.unit.js +14 -0
- package/test/samples/data_sample_1.csv +4 -0
- package/test/samples/k6_script_sample_1.js +9 -0
- package/test/samples/k6_script_sample_2.js +18 -0
- package/test/unit/configLoader.test.ts +53 -0
- package/test/unit/env.test.ts +1029 -0
- package/test/unit/kubernetes.service.test.ts +197 -0
- package/test/unit/script.service.test.ts +112 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildTestRunManifest = buildTestRunManifest;
|
|
7
|
+
const logger_1 = __importDefault(require("../utils/logger"));
|
|
8
|
+
const K6_GROUP = "k6.io";
|
|
9
|
+
const K6_VERSION = "v1alpha1";
|
|
10
|
+
const K6_KIND = "TestRun";
|
|
11
|
+
const K6_CLEANUP_LABEL = "post";
|
|
12
|
+
const ARG_OUT = "--out";
|
|
13
|
+
const ARG_TAG = "--tag";
|
|
14
|
+
const ARG_OUT_PROMETHEUS = "experimental-prometheus-rw";
|
|
15
|
+
const K6_PROMETHEUS_RW_SERVER_URL = "K6_PROMETHEUS_RW_SERVER_URL";
|
|
16
|
+
const K6_PROMETHEUS_RW_TREND_STATS = "K6_PROMETHEUS_RW_TREND_STATS";
|
|
17
|
+
function buildTestRunManifest(configMapResult, archiveOutput, cfg, envFromLoader) {
|
|
18
|
+
const testName = configMapResult.configMapName.replace("archive-", "test-");
|
|
19
|
+
const argumentsString = buildArgumentsString(cfg.arguments, cfg, testName);
|
|
20
|
+
logger_1.default.debug("Constructed arguments string for TestRun manifest:", argumentsString);
|
|
21
|
+
const testRun = {
|
|
22
|
+
apiVersion: `${K6_GROUP}/${K6_VERSION}`,
|
|
23
|
+
kind: K6_KIND,
|
|
24
|
+
metadata: {
|
|
25
|
+
name: testName,
|
|
26
|
+
namespace: configMapResult.namespace,
|
|
27
|
+
},
|
|
28
|
+
spec: {
|
|
29
|
+
parallelism: cfg.parallelism,
|
|
30
|
+
arguments: argumentsString,
|
|
31
|
+
quiet: String(cfg.quiet),
|
|
32
|
+
...(cfg.cleanup === true ? { cleanup: K6_CLEANUP_LABEL } : {}),
|
|
33
|
+
separate: cfg.separate,
|
|
34
|
+
runner: {
|
|
35
|
+
image: cfg.runner?.image,
|
|
36
|
+
env: buildRunnerEnv(cfg, envFromLoader),
|
|
37
|
+
...(cfg.runner?.resources ? { resources: cfg.runner.resources } : {}),
|
|
38
|
+
},
|
|
39
|
+
script: {
|
|
40
|
+
configMap: {
|
|
41
|
+
name: configMapResult.configMapName,
|
|
42
|
+
file: archiveOutput.archiveFilename,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
logger_1.default.debug("Constructed TestRun manifest:", JSON.stringify(testRun, null, 2));
|
|
48
|
+
return testRun;
|
|
49
|
+
}
|
|
50
|
+
function buildRunnerEnv(cfg, envFromLoader) {
|
|
51
|
+
const envMap = new Map();
|
|
52
|
+
if (envFromLoader) {
|
|
53
|
+
for (const [key, value] of Object.entries(envFromLoader)) {
|
|
54
|
+
if (value !== undefined && value !== null) {
|
|
55
|
+
envMap.set(key, String(value));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (cfg.prometheus?.serverUrl) {
|
|
60
|
+
envMap.set(K6_PROMETHEUS_RW_SERVER_URL, cfg.prometheus.serverUrl);
|
|
61
|
+
if (cfg.prometheus.trendStats?.length) {
|
|
62
|
+
envMap.set(K6_PROMETHEUS_RW_TREND_STATS, cfg.prometheus.trendStats.join(","));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const envArray = Array.from(envMap.entries()).map(([name, value]) => ({
|
|
66
|
+
name,
|
|
67
|
+
value,
|
|
68
|
+
}));
|
|
69
|
+
return envArray.length > 0 ? envArray : undefined;
|
|
70
|
+
}
|
|
71
|
+
function buildArgumentsString(args, cfg, name) {
|
|
72
|
+
const finalArgs = [...(args ?? [])];
|
|
73
|
+
if (cfg.prometheus?.serverUrl) {
|
|
74
|
+
const hasOut = finalArgs.includes(ARG_OUT);
|
|
75
|
+
if (!hasOut) {
|
|
76
|
+
finalArgs.push(ARG_OUT);
|
|
77
|
+
}
|
|
78
|
+
const hasPrometheusOut = finalArgs.includes(ARG_OUT_PROMETHEUS);
|
|
79
|
+
if (!hasPrometheusOut) {
|
|
80
|
+
finalArgs.push(ARG_OUT_PROMETHEUS);
|
|
81
|
+
}
|
|
82
|
+
finalArgs.push(ARG_TAG, `testid=${name}`);
|
|
83
|
+
}
|
|
84
|
+
if (finalArgs.length === 0) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return finalArgs.join(" ");
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=testRunManifestBuilder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testRunManifestBuilder.js","sourceRoot":"","sources":["../../src/utils/testRunManifestBuilder.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "k6ctl",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to run k6 tests on Kubernetes using k6-operator",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"k6ctl": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"test:unit": "jest -c test/jest.config.unit.js",
|
|
13
|
+
"test:integration": "jest -c test/jest.config.integration.js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"k6",
|
|
17
|
+
"kubernetes",
|
|
18
|
+
"k6-operator",
|
|
19
|
+
"load-testing",
|
|
20
|
+
"performance-testing",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@kubernetes/client-node": "^1.4.0",
|
|
27
|
+
"cli-table3": "^0.6.5",
|
|
28
|
+
"commander": "^14.0.3",
|
|
29
|
+
"dotenv": "^17.3.1",
|
|
30
|
+
"js-yaml": "^4.1.1",
|
|
31
|
+
"winston": "^3.19.0",
|
|
32
|
+
"zod": "^4.3.6"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@jest/globals": "^30.2.0",
|
|
36
|
+
"@types/js-yaml": "^4.0.9",
|
|
37
|
+
"@types/node": "^25.2.3",
|
|
38
|
+
"ts-jest": "^29.4.6",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Argument, Command } from 'commander';
|
|
4
|
+
import { runTest } from './commands/run';
|
|
5
|
+
import { list } from './commands/list';
|
|
6
|
+
import { version } from '../package.json';
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('k6ctl')
|
|
12
|
+
.description('CLI tool to run k6 tests on Kubernetes using k6-operator')
|
|
13
|
+
.version(version);
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command('run <script>')
|
|
17
|
+
.description('Run a k6 test script')
|
|
18
|
+
.option('-c, --config <path>', 'Path to config file', 'k6ctl.config.js')
|
|
19
|
+
.option('-n, --namespace <namespace>', 'Kubernetes namespace')
|
|
20
|
+
.option('-p, --parallelism <number>', 'Number of parallel test pods')
|
|
21
|
+
.option('-v, --verbose', 'enable debug logging')
|
|
22
|
+
.action(runTest);
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command('list')
|
|
26
|
+
.description('List all resources')
|
|
27
|
+
.addArgument(new Argument('[type]', 'Resource type').choices(['pods', 'testruns', 'configmaps']).default('all'))
|
|
28
|
+
.option('-n, --namespace <namespace>', 'Kubernetes namespace', 'default')
|
|
29
|
+
.action(list);
|
|
30
|
+
|
|
31
|
+
program.parse();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logger, { setLogLevel } from '../utils/logger';
|
|
2
|
+
import { createDefaultKubernetesService } from '../services/kubernetes.service';
|
|
3
|
+
|
|
4
|
+
export async function list(type: string, options: { namespace?: string }) {
|
|
5
|
+
const kubernetesService = createDefaultKubernetesService();
|
|
6
|
+
switch (type) {
|
|
7
|
+
case 'all':
|
|
8
|
+
logger.debug(`Listing all resources in namespace: ${options.namespace}`);
|
|
9
|
+
await kubernetesService.listTestRuns(options.namespace);
|
|
10
|
+
await kubernetesService.listPods(options.namespace);
|
|
11
|
+
await kubernetesService.listConfigMaps(options.namespace);
|
|
12
|
+
break;
|
|
13
|
+
case 'pods':
|
|
14
|
+
logger.debug(`Listing Pods in namespace: ${options.namespace}`);
|
|
15
|
+
await kubernetesService.listPods(options.namespace);
|
|
16
|
+
break;
|
|
17
|
+
case 'testruns':
|
|
18
|
+
logger.debug(`Listing TestRuns in namespace: ${options.namespace}`);
|
|
19
|
+
await kubernetesService.listTestRuns(options.namespace);
|
|
20
|
+
break;
|
|
21
|
+
case 'configmaps':
|
|
22
|
+
logger.debug(`Listing ConfigMaps in namespace: ${options.namespace}`);
|
|
23
|
+
await kubernetesService.listConfigMaps(options.namespace);
|
|
24
|
+
break;
|
|
25
|
+
default:
|
|
26
|
+
logger.debug(`Listing ${type} in namespace: ${options.namespace}`);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createDefaultKubernetesService } from '../services/kubernetes.service';
|
|
2
|
+
import { ScriptService } from '../services/script.service';
|
|
3
|
+
import { loadK6Config } from '../utils/configLoader';
|
|
4
|
+
import { loadAndValidateEnv } from '../utils/env';
|
|
5
|
+
import logger, { setLogLevel } from '../utils/logger';
|
|
6
|
+
import { buildTestRunManifest } from '../utils/testRunManifestBuilder';
|
|
7
|
+
|
|
8
|
+
interface RunOptions {
|
|
9
|
+
config: string;
|
|
10
|
+
namespace?: string;
|
|
11
|
+
parallelism?: number;
|
|
12
|
+
verbose?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runTest(scriptPath: string, options: RunOptions) {
|
|
16
|
+
if (options.verbose) setLogLevel('debug');
|
|
17
|
+
logger.debug(`Running k6 test: ${scriptPath}`);
|
|
18
|
+
logger.debug(`Using config: ${JSON.stringify(options.config, null, 2)}`);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// Initialize services
|
|
22
|
+
const scriptService = new ScriptService();
|
|
23
|
+
const kubernetesService = createDefaultKubernetesService();
|
|
24
|
+
|
|
25
|
+
// Load test script
|
|
26
|
+
const archive = await scriptService.archiveTest(scriptPath);
|
|
27
|
+
|
|
28
|
+
// Load config
|
|
29
|
+
const config = loadK6Config(options.config);
|
|
30
|
+
|
|
31
|
+
// Load environment variables
|
|
32
|
+
let envVars;
|
|
33
|
+
try {
|
|
34
|
+
envVars = loadAndValidateEnv();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.warn("Warning: no environment variables loaded, continuing anyway.");
|
|
37
|
+
logger.debug(`Error loading environment variables: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create configmap for test script
|
|
41
|
+
const configMap = await kubernetesService.createConfigMap(archive, config.namespace);
|
|
42
|
+
|
|
43
|
+
// Build testrun
|
|
44
|
+
const testRunManifest = buildTestRunManifest(configMap, archive, config, envVars);
|
|
45
|
+
|
|
46
|
+
// Create testrun resource
|
|
47
|
+
const testRunResult = await kubernetesService.createTestRun(testRunManifest);
|
|
48
|
+
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.error(`Error running test: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { existsSync, promises as fs_promises } from 'fs';
|
|
2
|
+
import { parse } from 'node:path';
|
|
3
|
+
import * as k8s from '@kubernetes/client-node';
|
|
4
|
+
import logger from '../utils/logger';
|
|
5
|
+
import type { ArchivedFile, ConfigMapResult } from '../types/kubernetes.types';
|
|
6
|
+
import { TestRunManifest } from '../types/testRunManifest.types';
|
|
7
|
+
import { printTableGeneric } from '../utils/table.util';
|
|
8
|
+
|
|
9
|
+
export class KubernetesService {
|
|
10
|
+
constructor(private readonly k8sApi: k8s.CoreV1Api, private readonly k8sCustomApi: k8s.CustomObjectsApi) { }
|
|
11
|
+
|
|
12
|
+
async createConfigMap(archiveFile: ArchivedFile, namespace: string): Promise<ConfigMapResult> {
|
|
13
|
+
// Check if the archive file exists
|
|
14
|
+
if (!existsSync(archiveFile.archivePath)) {
|
|
15
|
+
throw new Error(`Archive file not found at path: ${archiveFile.archivePath}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Check archive file size (should be less than 1MB for k8s configmap)
|
|
19
|
+
const stats = await fs_promises.stat(archiveFile.archivePath);
|
|
20
|
+
if (stats.size > 1024 * 1024) {
|
|
21
|
+
throw new Error(`Archive file is too large to be stored in a configmap (size: ${stats.size} bytes)`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const configMapName = parse(archiveFile.archiveFilename).name;
|
|
25
|
+
const fileContent = await fs_promises.readFile(archiveFile.archivePath, 'base64');
|
|
26
|
+
|
|
27
|
+
const configMap: k8s.V1ConfigMap = {
|
|
28
|
+
apiVersion: 'v1',
|
|
29
|
+
kind: 'ConfigMap',
|
|
30
|
+
metadata: {
|
|
31
|
+
name: configMapName,
|
|
32
|
+
namespace: namespace,
|
|
33
|
+
},
|
|
34
|
+
binaryData: {
|
|
35
|
+
[archiveFile.archiveFilename]: fileContent,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
await this.k8sApi.createNamespacedConfigMap({ namespace, body: configMap });
|
|
40
|
+
logger.info(`ConfigMap ${configMapName} created in namespace ${namespace}`);
|
|
41
|
+
return { namespace, configMapName };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async deleteConfigMap(configMapName: string, namespace: string): Promise<void> {
|
|
45
|
+
try {
|
|
46
|
+
await this.k8sApi.deleteNamespacedConfigMap({ name: configMapName, namespace });
|
|
47
|
+
logger.info(`ConfigMap ${configMapName} deleted from namespace ${namespace}`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
50
|
+
throw new Error(`Failed to delete ConfigMap ${configMapName} from namespace ${namespace}: ${errorMessage}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async createTestRun(testRunManifest: TestRunManifest): Promise<any> {
|
|
55
|
+
try {
|
|
56
|
+
const response = await this.k8sCustomApi.createNamespacedCustomObject({
|
|
57
|
+
group: testRunManifest.apiVersion.split('/')[0],
|
|
58
|
+
version: testRunManifest.apiVersion.split('/')[1],
|
|
59
|
+
namespace: testRunManifest.metadata.namespace,
|
|
60
|
+
plural: "testruns",
|
|
61
|
+
body: testRunManifest,
|
|
62
|
+
});
|
|
63
|
+
logger.info(`TestRun ${testRunManifest.metadata.name} created in namespace ${testRunManifest.metadata.namespace}`);
|
|
64
|
+
logger.debug(`Create response: ${JSON.stringify(response)}`);
|
|
65
|
+
return response;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
68
|
+
throw new Error(`Failed to create TestRun ${testRunManifest.metadata.name} in namespace ${testRunManifest.metadata.namespace}: ${errorMessage}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async deleteTestRun(testRunManifest: TestRunManifest): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.k8sCustomApi.deleteNamespacedCustomObject({
|
|
75
|
+
group: testRunManifest.apiVersion.split('/')[0],
|
|
76
|
+
version: testRunManifest.apiVersion.split('/')[1],
|
|
77
|
+
namespace: testRunManifest.metadata.namespace,
|
|
78
|
+
plural: "testruns",
|
|
79
|
+
name: testRunManifest.metadata.name,
|
|
80
|
+
});
|
|
81
|
+
logger.info(`TestRun ${testRunManifest.metadata.name} deleted from namespace ${testRunManifest.metadata.namespace}`);
|
|
82
|
+
logger.debug(`Delete response: ${JSON.stringify(response)}`);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
85
|
+
throw new Error(`Failed to delete TestRun ${testRunManifest.metadata.name} from namespace ${testRunManifest.metadata.namespace}: ${errorMessage}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async listTestRuns(namespace: string = "default"): Promise<any> {
|
|
90
|
+
try {
|
|
91
|
+
const response = await this.k8sCustomApi.listNamespacedCustomObject({
|
|
92
|
+
group: "k6.io",
|
|
93
|
+
version: "v1alpha1",
|
|
94
|
+
namespace: namespace,
|
|
95
|
+
plural: "testruns",
|
|
96
|
+
});
|
|
97
|
+
// logger.debug(`List response: ${JSON.stringify(response)}`);
|
|
98
|
+
printTestRunsTable((response as any).items ?? []);
|
|
99
|
+
return response;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
102
|
+
throw new Error(`Failed to list TestRuns in namespace ${namespace}: ${errorMessage}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async listPods(namespace: string = "default"): Promise<any> {
|
|
107
|
+
try {
|
|
108
|
+
const response = await this.k8sApi.listNamespacedPod({ namespace });
|
|
109
|
+
printPodsTable(response);
|
|
110
|
+
// logger.debug(`List Pods: ${JSON.stringify(response)}`);
|
|
111
|
+
return response;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
114
|
+
throw new Error(`Failed to list Pods in namespace ${namespace}: ${errorMessage}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async listConfigMaps(namespace: string = "default"): Promise<any> {
|
|
119
|
+
try {
|
|
120
|
+
const response = await this.k8sApi.listNamespacedConfigMap({ namespace });
|
|
121
|
+
printConfigMapsTable(response);
|
|
122
|
+
// logger.debug(`List ConfigMaps: ${JSON.stringify(response)}`);
|
|
123
|
+
return response;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
126
|
+
throw new Error(`Failed to list ConfigMaps in namespace ${namespace}: ${errorMessage}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fmt(v: any) {
|
|
132
|
+
return v ?? "N/A";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getNamespace(obj: any) {
|
|
136
|
+
return obj?.metadata?.namespace ?? "default";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getName(obj: any) {
|
|
140
|
+
return obj?.metadata?.name ?? "unknown";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function ageSince(isoDate: any): string {
|
|
144
|
+
if (!isoDate) return "N/A";
|
|
145
|
+
const created = new Date(isoDate).getTime();
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
let s = Math.max(0, Math.floor((now - created) / 1000));
|
|
148
|
+
const days = Math.floor(s / 86400); s %= 86400;
|
|
149
|
+
const hrs = Math.floor(s / 3600); s %= 3600;
|
|
150
|
+
const mins = Math.floor(s / 60);
|
|
151
|
+
if (days > 0) return `${days}d${hrs}h`;
|
|
152
|
+
if (hrs > 0) return `${hrs}h${mins}m`;
|
|
153
|
+
return `${mins}m`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createDefaultKubernetesService(context?: string): KubernetesService {
|
|
157
|
+
const kc = new k8s.KubeConfig();
|
|
158
|
+
kc.loadFromDefault();
|
|
159
|
+
if (context) kc.setCurrentContext(context);
|
|
160
|
+
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
|
161
|
+
const k8sCustomApi = kc.makeApiClient(k8s.CustomObjectsApi);
|
|
162
|
+
return new KubernetesService(k8sApi, k8sCustomApi);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function printPodsTable(data: k8s.V1PodList): void {
|
|
166
|
+
const pods = data.items ?? [];
|
|
167
|
+
const ns = pods[0]?.metadata?.namespace ?? "unknown";
|
|
168
|
+
printTableGeneric({
|
|
169
|
+
title: "Pods",
|
|
170
|
+
headers: ["Pod", "Namespace", "Container", "Node Name", "Req CPU", "Req Mem", "Lim CPU", "Lim Mem", "Status"],
|
|
171
|
+
items: pods,
|
|
172
|
+
toRows: (pod) => {
|
|
173
|
+
const podName = getName(pod);
|
|
174
|
+
const namespace = getNamespace(pod);
|
|
175
|
+
const status = pod.status?.phase ?? "unknown";
|
|
176
|
+
const node = pod.spec?.nodeName ?? "unknown";
|
|
177
|
+
const containers = pod.spec?.containers ?? [];
|
|
178
|
+
if (containers.length === 0) {
|
|
179
|
+
return [[podName, namespace, "-", "-", "-", "-", "-", status, node]];
|
|
180
|
+
}
|
|
181
|
+
return containers.map((c) => {
|
|
182
|
+
const r = c.resources || {};
|
|
183
|
+
const req = r.requests || {};
|
|
184
|
+
const lim = r.limits || {};
|
|
185
|
+
const statusEmoji = status === 'Succeeded' ? '🟢' : status === 'Failed' ? '🔴' : '🟡';
|
|
186
|
+
return [
|
|
187
|
+
podName,
|
|
188
|
+
namespace,
|
|
189
|
+
c.name,
|
|
190
|
+
node,
|
|
191
|
+
fmt(req.cpu),
|
|
192
|
+
fmt(req.memory),
|
|
193
|
+
fmt(lim.cpu),
|
|
194
|
+
fmt(lim.memory),
|
|
195
|
+
`${status} ${statusEmoji}`,
|
|
196
|
+
];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function printConfigMapsTable(data: k8s.V1ConfigMapList): void {
|
|
203
|
+
const cms = data.items ?? [];
|
|
204
|
+
const ns = cms[0]?.metadata?.namespace ?? "unknown";
|
|
205
|
+
printTableGeneric({
|
|
206
|
+
title: "ConfigMaps",
|
|
207
|
+
headers: ["Name", "Namespace", "Data keys", "Binary keys", "Age"],
|
|
208
|
+
items: cms,
|
|
209
|
+
toRows: (cm) => {
|
|
210
|
+
const name = getName(cm);
|
|
211
|
+
const namespace = getNamespace(cm);
|
|
212
|
+
const dataKeys = Object.keys(cm.data ?? {}).length ? Object.keys(cm.data ?? {}).join(", ") : "0";
|
|
213
|
+
const binKeys = Object.keys(cm.binaryData ?? {}).length ? Object.keys(cm.binaryData ?? {}).join(", ") : "0";
|
|
214
|
+
const age = ageSince(cm.metadata?.creationTimestamp);
|
|
215
|
+
return [[name, namespace, dataKeys, binKeys, age]];
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function printTestRunsTable(testRuns: TestRunManifest[]): void {
|
|
221
|
+
const ns = testRuns[0]?.metadata?.namespace ?? "unknown";
|
|
222
|
+
printTableGeneric({
|
|
223
|
+
title: "TestRuns",
|
|
224
|
+
headers: ["Name", "Namespace", "Parallelism", "Cleanup", "Separate", "Quiet", "Age"],
|
|
225
|
+
items: testRuns,
|
|
226
|
+
toRows: (tr) => {
|
|
227
|
+
const name = tr.metadata?.name ?? "unknown";
|
|
228
|
+
const namespace = tr.metadata?.namespace ?? "unknown";
|
|
229
|
+
const parallelism = tr.spec?.parallelism ?? "N/A";
|
|
230
|
+
const cleanup = tr.spec?.cleanup ?? "N/A";
|
|
231
|
+
const separate = tr.spec?.separate ?? "N/A";
|
|
232
|
+
const quiet = tr.spec?.quiet ?? "N/A";
|
|
233
|
+
const age = (tr as any)?.metadata?.creationTimestamp ?? "N/A";
|
|
234
|
+
// const age = ageSince((tr as any)?.metadata?.creationTimestamp) || "N/A";
|
|
235
|
+
return [[name, namespace, parallelism, cleanup, separate, quiet, age]];
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { basename, join, parse } from 'node:path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import logger from '../utils/logger';
|
|
6
|
+
|
|
7
|
+
import type { ArchiveResult, ExecFn } from '../types/script.types';
|
|
8
|
+
|
|
9
|
+
const defaultExecAsync = promisify(exec);
|
|
10
|
+
|
|
11
|
+
export class ScriptService {
|
|
12
|
+
constructor(private readonly execCmd: ExecFn = defaultExecAsync) { }
|
|
13
|
+
|
|
14
|
+
async archiveTest(scriptPath: string, outputDirectory?: string): Promise<ArchiveResult> {
|
|
15
|
+
// Check if the script file exists
|
|
16
|
+
if (!existsSync(scriptPath)) throw new Error(`Script file not found at path: ${scriptPath}`);
|
|
17
|
+
|
|
18
|
+
if (outputDirectory) {
|
|
19
|
+
// Check if the output directory exists
|
|
20
|
+
if (!existsSync(outputDirectory)) {
|
|
21
|
+
throw new Error(`Output directory does not exist at path: ${outputDirectory}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check if k6 is installed
|
|
26
|
+
try {
|
|
27
|
+
await this.execCmd('k6 version');
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error('k6 is not installed. Please install k6 to archive scripts.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Archive the script using k6
|
|
33
|
+
try {
|
|
34
|
+
// Get the script name without extension to use as prefix for the archive file
|
|
35
|
+
const scriptName = parse(scriptPath).name;
|
|
36
|
+
const sanitizedScriptName = sanitizeText(scriptName);
|
|
37
|
+
const archiveOutput = join(outputDirectory ?? '.', `archive-${sanitizedScriptName}-${Date.now()}.tar`);
|
|
38
|
+
const archiveCommand = `k6 archive -v -O ${archiveOutput} ${scriptPath}`;
|
|
39
|
+
logger.debug(`Archiving script with command: ${archiveCommand}`);
|
|
40
|
+
const { stdout, stderr } = await this.execCmd(archiveCommand);
|
|
41
|
+
logger.debug(`Standard Output: ${stdout}`);
|
|
42
|
+
logger.debug(`Standard Error: ${stderr}`);
|
|
43
|
+
|
|
44
|
+
// Check if the file was created successfully
|
|
45
|
+
if (!existsSync(archiveOutput)) {
|
|
46
|
+
throw new Error(`Failed to create archive: ${stderr}`);
|
|
47
|
+
}
|
|
48
|
+
logger.info(`Archive created successfully at: ${archiveOutput}`);
|
|
49
|
+
return {
|
|
50
|
+
archivePath: archiveOutput,
|
|
51
|
+
archiveFilename: basename(archiveOutput),
|
|
52
|
+
scriptPath: scriptPath,
|
|
53
|
+
scriptFilename: basename(scriptPath),
|
|
54
|
+
};
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
57
|
+
throw new Error(`Failed to archive the script: ${errorMessage}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createDefaultScriptService(): ScriptService {
|
|
63
|
+
return new ScriptService();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Private helpers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function sanitizeText(text: string): string {
|
|
71
|
+
let sanitized = text.toLowerCase();
|
|
72
|
+
sanitized = sanitized.replace(/_/g, '-');
|
|
73
|
+
sanitized = sanitized.replace(/[^a-z0-9.-]/g, '-');
|
|
74
|
+
sanitized = sanitized.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
|
|
75
|
+
sanitized = sanitized.replace(/[-]+/g, '-');
|
|
76
|
+
sanitized = sanitized.replace(/[.]+/g, '-');
|
|
77
|
+
sanitized = sanitized.replace(/^[.-]+|[.-]+$/g, '');
|
|
78
|
+
return sanitized;
|
|
79
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface K6Config {
|
|
2
|
+
namespace?: string;
|
|
3
|
+
parallelism?: number;
|
|
4
|
+
arguments?: string[] | undefined;
|
|
5
|
+
cleanup?: boolean;
|
|
6
|
+
quiet?: boolean;
|
|
7
|
+
separate?: boolean;
|
|
8
|
+
runner?: {
|
|
9
|
+
image?: string;
|
|
10
|
+
resources?: {
|
|
11
|
+
limits: {
|
|
12
|
+
cpu: string;
|
|
13
|
+
memory: string;
|
|
14
|
+
};
|
|
15
|
+
requests: {
|
|
16
|
+
cpu: string;
|
|
17
|
+
memory: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
prometheus?: {
|
|
22
|
+
serverUrl: string;
|
|
23
|
+
trendStats?: string[];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface TestRunManifest {
|
|
2
|
+
apiVersion: string;
|
|
3
|
+
kind: string;
|
|
4
|
+
metadata: {
|
|
5
|
+
name: string;
|
|
6
|
+
namespace: string;
|
|
7
|
+
};
|
|
8
|
+
spec: {
|
|
9
|
+
parallelism?: number;
|
|
10
|
+
arguments?: string;
|
|
11
|
+
quiet?: string;
|
|
12
|
+
cleanup?: string;
|
|
13
|
+
separate?: boolean;
|
|
14
|
+
runner?: {
|
|
15
|
+
image?: string;
|
|
16
|
+
env?: Array<{
|
|
17
|
+
name: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}>;
|
|
20
|
+
resources?: {
|
|
21
|
+
limits: {
|
|
22
|
+
cpu: string
|
|
23
|
+
memory: string
|
|
24
|
+
}
|
|
25
|
+
requests: {
|
|
26
|
+
cpu: string
|
|
27
|
+
memory: string
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
script: {
|
|
32
|
+
configMap?: {
|
|
33
|
+
name: string;
|
|
34
|
+
file: string;
|
|
35
|
+
};
|
|
36
|
+
volumeClaim?: {
|
|
37
|
+
name: string;
|
|
38
|
+
file: string;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
}
|