k6ctl 1.2.0 → 1.3.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/README.md +168 -4
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/delete.d.ts +1 -1
- package/dist/commands/delete.d.ts.map +1 -1
- package/dist/commands/delete.js +10 -4
- package/dist/commands/delete.js.map +1 -1
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +137 -1
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +4 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/services/kubernetes.service.d.ts +9 -3
- package/dist/services/kubernetes.service.d.ts.map +1 -1
- package/dist/services/kubernetes.service.js +121 -2
- package/dist/services/kubernetes.service.js.map +1 -1
- package/dist/services/script.service.d.ts +3 -1
- package/dist/services/script.service.d.ts.map +1 -1
- package/dist/services/script.service.js +115 -1
- package/dist/services/script.service.js.map +1 -1
- package/dist/types/kubernetes.types.d.ts +5 -0
- package/dist/types/kubernetes.types.d.ts.map +1 -1
- package/dist/types/lastRun.types.d.ts +2 -1
- package/dist/types/lastRun.types.d.ts.map +1 -1
- package/dist/types/script.types.d.ts +39 -0
- package/dist/types/script.types.d.ts.map +1 -1
- package/dist/utils/testRunManifestBuilder.d.ts +2 -1
- package/dist/utils/testRunManifestBuilder.d.ts.map +1 -1
- package/dist/utils/testRunManifestBuilder.js +34 -0
- package/dist/utils/testRunManifestBuilder.js.map +1 -1
- package/package.json +2 -1
- package/src/cli.ts +3 -2
- package/src/commands/delete.ts +11 -5
- package/src/commands/run.ts +158 -2
- package/src/commands/status.ts +4 -0
- package/src/services/kubernetes.service.ts +141 -5
- package/src/services/script.service.ts +102 -4
- package/src/types/kubernetes.types.ts +6 -0
- package/src/types/lastRun.types.ts +2 -1
- package/src/types/script.types.ts +40 -0
- package/src/utils/testRunManifestBuilder.ts +39 -1
- package/test/integration/kubernetes.service.test.ts +61 -5
- package/test/unit/kubernetes.service.test.ts +13 -8
- package/test/unit/script.service.test.ts +11 -4
package/src/commands/delete.ts
CHANGED
|
@@ -4,7 +4,7 @@ import logger from '../utils/logger';
|
|
|
4
4
|
|
|
5
5
|
interface DeleteOptions {
|
|
6
6
|
namespace?: string;
|
|
7
|
-
|
|
7
|
+
keepScript?: boolean;
|
|
8
8
|
pod?: string;
|
|
9
9
|
testrun?: string;
|
|
10
10
|
configmap?: string;
|
|
@@ -13,7 +13,7 @@ interface DeleteOptions {
|
|
|
13
13
|
export async function deleteLastRun(options: DeleteOptions) {
|
|
14
14
|
const kubernetesService = createDefaultKubernetesService();
|
|
15
15
|
|
|
16
|
-
const lastRun =
|
|
16
|
+
const lastRun = await loadLastRun();
|
|
17
17
|
const namespace = options.namespace ?? lastRun?.namespace ?? 'default';
|
|
18
18
|
|
|
19
19
|
if (options.pod) {
|
|
@@ -42,9 +42,15 @@ export async function deleteLastRun(options: DeleteOptions) {
|
|
|
42
42
|
logger.info(`Deleting TestRun: ${lastRun.testRunName} (namespace: ${namespace})`);
|
|
43
43
|
await kubernetesService.deleteTestRunByName(lastRun.testRunName, namespace);
|
|
44
44
|
|
|
45
|
-
if (!options.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (!options.keepScript) {
|
|
46
|
+
if (lastRun.configMapName) {
|
|
47
|
+
logger.info(`Deleting ConfigMap: ${lastRun.configMapName} (namespace: ${namespace})`);
|
|
48
|
+
await kubernetesService.deleteConfigMap(lastRun.configMapName, namespace);
|
|
49
|
+
}
|
|
50
|
+
if (lastRun.volumeClaimName) {
|
|
51
|
+
logger.info(`Deleting PVC: ${lastRun.volumeClaimName} (namespace: ${namespace})`);
|
|
52
|
+
await kubernetesService.deleteVolumeClaimByName(lastRun.volumeClaimName, namespace);
|
|
53
|
+
}
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
await clearLastRun();
|
package/src/commands/run.ts
CHANGED
|
@@ -6,8 +6,13 @@ import { ScriptService } from '../services/script.service';
|
|
|
6
6
|
import { loadK6Config } from '../utils/configLoader';
|
|
7
7
|
import { loadAndValidateEnv } from '../utils/env';
|
|
8
8
|
import logger, { setLogLevel } from '../utils/logger';
|
|
9
|
-
import { buildTestRunManifest } from '../utils/testRunManifestBuilder';
|
|
9
|
+
import { buildTestRunManifest, buildTestRunManifestWithVolumeClaim } from '../utils/testRunManifestBuilder';
|
|
10
10
|
import { saveLastRun } from '../utils/lastRunStore';
|
|
11
|
+
import { K6StageMetrics, K6ScenarioOptions, K6ScenarioMetrics, K6InspectResult } from "../types/script.types";
|
|
12
|
+
import { ConfigMapResult } from '../types/kubernetes.types';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_VUS_PER_POD = process.env.DEFAULT_VUS_PER_POD ? parseInt(process.env.DEFAULT_VUS_PER_POD, 10) : 100;
|
|
15
|
+
const MAX_ITERATION_DURATION_SECONDS = process.env.MAX_ITERATION_DURATION_SECONDS ? parseInt(process.env.MAX_ITERATION_DURATION_SECONDS, 10) : 60;
|
|
11
16
|
|
|
12
17
|
function listTestFiles(dir: string): string[] {
|
|
13
18
|
if (!fs.existsSync(dir)) {
|
|
@@ -54,6 +59,7 @@ interface RunOptions {
|
|
|
54
59
|
parallelism?: number;
|
|
55
60
|
verbose?: boolean;
|
|
56
61
|
dir: string;
|
|
62
|
+
smart?: boolean;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export async function runTest(scriptPath: string, options: RunOptions) {
|
|
@@ -82,11 +88,45 @@ export async function runTest(scriptPath: string, options: RunOptions) {
|
|
|
82
88
|
const kubernetesService = createDefaultKubernetesService();
|
|
83
89
|
|
|
84
90
|
// Load test script
|
|
85
|
-
|
|
91
|
+
let archive = await scriptService.archiveTest(scriptPath);
|
|
86
92
|
|
|
87
93
|
// Load config
|
|
88
94
|
const config = loadK6Config(options.config);
|
|
89
95
|
|
|
96
|
+
|
|
97
|
+
// Analyze script if smart option is enabled
|
|
98
|
+
if (options.smart) {
|
|
99
|
+
logger.info(`Analyzing script: ${scriptPath}`);
|
|
100
|
+
const inspectResult = await scriptService.inspectScript(scriptPath);
|
|
101
|
+
const scenarioMetrics: K6ScenarioMetrics[] = await analyzeScript(inspectResult);
|
|
102
|
+
if (scenarioMetrics) {
|
|
103
|
+
const estimatedTotalIterations = scenarioMetrics.reduce((sum, metrics) => sum + (metrics?.totalIterations ?? 0), 0);
|
|
104
|
+
const totalRecommendedMaxVUs = scenarioMetrics.reduce((sum, metrics) => sum + (metrics?.recommendedMaxVUs ?? 0), 0);
|
|
105
|
+
const parallelism = Math.ceil(totalRecommendedMaxVUs / DEFAULT_VUS_PER_POD) || 1;
|
|
106
|
+
const peakTps = scenarioMetrics.reduce((max, metrics) => Math.max(max, metrics?.peakTps ?? 0), 0);
|
|
107
|
+
|
|
108
|
+
config.parallelism = parallelism;
|
|
109
|
+
|
|
110
|
+
logger.info(`Total recommendedMaxVUs: ${Math.ceil(totalRecommendedMaxVUs)}
|
|
111
|
+
based on scenario analysis and MAX_ITERATION_DURATION_SECONDS=${MAX_ITERATION_DURATION_SECONDS} seconds
|
|
112
|
+
per iteration assumption with a safety factor of 1.2 applied to account for variability in iteration duration and ensure we have enough VUs
|
|
113
|
+
to meet the target TPS. This is an estimate and actual resource needs may vary based on the specific workload and environment.
|
|
114
|
+
Consider monitoring the test run and adjusting resources as needed.`);
|
|
115
|
+
|
|
116
|
+
logger.info(`Calculated parallelism: ${parallelism} (based on DEFAULT_VUS_PER_POD=${DEFAULT_VUS_PER_POD})`);
|
|
117
|
+
logger.info(`Peak TPS: ${peakTps.toFixed(2)}`);
|
|
118
|
+
logger.info(`Estimated Total Iterations: ${Math.round(estimatedTotalIterations)}`);
|
|
119
|
+
logger.info(`Using MAX_ITERATION_DURATION_SECONDS=${MAX_ITERATION_DURATION_SECONDS} seconds`);
|
|
120
|
+
logger.info(`Using safety factor of 1.2 to calculate recommended VUs.`);
|
|
121
|
+
logger.info(`Using DEFAULT_VUS_PER_POD=${DEFAULT_VUS_PER_POD} to calculate parallelism.`);
|
|
122
|
+
|
|
123
|
+
// Extract, modify, and recompress the archive
|
|
124
|
+
archive = await scriptService.extractModifyAndRecompress(archive, scenarioMetrics);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
logger.info('Smart scenario analysis is disabled. Running test without previous analysis.');
|
|
128
|
+
}
|
|
129
|
+
|
|
90
130
|
// Load environment variables
|
|
91
131
|
let envVars;
|
|
92
132
|
try {
|
|
@@ -96,6 +136,25 @@ export async function runTest(scriptPath: string, options: RunOptions) {
|
|
|
96
136
|
logger.debug(`Error loading environment variables: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
137
|
}
|
|
98
138
|
|
|
139
|
+
if (archive.archiveSize > 1024 * 1024) {
|
|
140
|
+
// Volume flow
|
|
141
|
+
logger.info(`Test script archive size (${(archive.archiveSize / (1024 * 1024)).toFixed(2)} MB) exceeds 1 MB, using volume flow.`);
|
|
142
|
+
|
|
143
|
+
const volumeClaimResult = await kubernetesService.createPVCWithArchive(archive, config.namespace);
|
|
144
|
+
const testRunManifest = buildTestRunManifestWithVolumeClaim(volumeClaimResult, config, envVars);
|
|
145
|
+
await kubernetesService.createTestRun(testRunManifest);
|
|
146
|
+
|
|
147
|
+
await saveLastRun({
|
|
148
|
+
testRunName: testRunManifest.metadata.name,
|
|
149
|
+
namespace: testRunManifest.metadata.namespace,
|
|
150
|
+
volumeClaimName: volumeClaimResult.volumeClaimName,
|
|
151
|
+
scriptPath,
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
logger.info(`Last run saved: ${testRunManifest.metadata.name} (namespace: ${testRunManifest.metadata.namespace})`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
99
158
|
// Create configmap for test script
|
|
100
159
|
const configMap = await kubernetesService.createConfigMap(archive, config.namespace);
|
|
101
160
|
|
|
@@ -119,3 +178,100 @@ export async function runTest(scriptPath: string, options: RunOptions) {
|
|
|
119
178
|
logger.error(`Error running test: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
179
|
}
|
|
121
180
|
}
|
|
181
|
+
|
|
182
|
+
async function analyzeScript(inspectResult: K6InspectResult): Promise<K6ScenarioMetrics[]> {
|
|
183
|
+
if (!inspectResult.scenarios) {
|
|
184
|
+
logger.info('No scenarios found in the script. Smart analysis cannot be performed.');
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const allMetrics: K6ScenarioMetrics[] = Object.entries(inspectResult.scenarios).map(([name, scenario]) => {
|
|
188
|
+
return calculateScenarioMetrics(name, scenario) as K6ScenarioMetrics;
|
|
189
|
+
});
|
|
190
|
+
return allMetrics;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function calculateScenarioMetrics(
|
|
194
|
+
name: string,
|
|
195
|
+
scenario: K6ScenarioOptions,
|
|
196
|
+
avgIterationDurationSeconds: number = MAX_ITERATION_DURATION_SECONDS,
|
|
197
|
+
safetyFactor: number = 1.2
|
|
198
|
+
): K6ScenarioMetrics | null {
|
|
199
|
+
if (scenario.executor !== 'ramping-arrival-rate') {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (!scenario.stages || scenario.stages.length === 0) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const timeUnitSeconds = parseTimeUnitToSeconds(scenario.timeUnit);
|
|
206
|
+
let previousRate = scenario.startRate ?? 0;
|
|
207
|
+
let peakTps = previousRate / timeUnitSeconds;
|
|
208
|
+
let totalIterations = 0;
|
|
209
|
+
const stageMetrics: K6StageMetrics[] = scenario.stages.map((stage, index) => {
|
|
210
|
+
const durationSeconds = parseDurationToSeconds(stage.duration);
|
|
211
|
+
const fromTps = previousRate / timeUnitSeconds;
|
|
212
|
+
const toTps = stage.target / timeUnitSeconds;
|
|
213
|
+
const avgTps = (fromTps + toTps) / 2;
|
|
214
|
+
const estimatedIterations = avgTps * durationSeconds;
|
|
215
|
+
totalIterations += estimatedIterations;
|
|
216
|
+
peakTps = Math.max(peakTps, toTps);
|
|
217
|
+
const requiredVUsAtTarget = toTps * avgIterationDurationSeconds;
|
|
218
|
+
const recommendedVUsAtTarget = requiredVUsAtTarget * safetyFactor;
|
|
219
|
+
previousRate = stage.target;
|
|
220
|
+
return {
|
|
221
|
+
stageIndex: index + 1,
|
|
222
|
+
duration: stage.duration,
|
|
223
|
+
durationSeconds,
|
|
224
|
+
fromTps,
|
|
225
|
+
toTps,
|
|
226
|
+
avgTps,
|
|
227
|
+
estimatedIterations,
|
|
228
|
+
requiredVUsAtTarget,
|
|
229
|
+
recommendedVUsAtTarget,
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
const requiredMaxVUs = peakTps * avgIterationDurationSeconds;
|
|
233
|
+
const recommendedMaxVUs = requiredMaxVUs * safetyFactor;
|
|
234
|
+
return {
|
|
235
|
+
name,
|
|
236
|
+
peakTps,
|
|
237
|
+
totalIterations,
|
|
238
|
+
requiredMaxVUs,
|
|
239
|
+
recommendedMaxVUs,
|
|
240
|
+
stageMetrics,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseDurationToSeconds(duration: string): number {
|
|
245
|
+
const regex = /(\d+)(ms|s|m|h)/g;
|
|
246
|
+
let match: RegExpExecArray | null;
|
|
247
|
+
let totalMs = 0;
|
|
248
|
+
while ((match = regex.exec(duration)) !== null) {
|
|
249
|
+
const value = Number(match[1]);
|
|
250
|
+
const unit = match[2];
|
|
251
|
+
switch (unit) {
|
|
252
|
+
case 'h':
|
|
253
|
+
totalMs += value * 60 * 60 * 1000;
|
|
254
|
+
break;
|
|
255
|
+
case 'm':
|
|
256
|
+
totalMs += value * 60 * 1000;
|
|
257
|
+
break;
|
|
258
|
+
case 's':
|
|
259
|
+
totalMs += value * 1000;
|
|
260
|
+
break;
|
|
261
|
+
case 'ms':
|
|
262
|
+
totalMs += value;
|
|
263
|
+
break;
|
|
264
|
+
default:
|
|
265
|
+
throw new Error(`Unsupported duration unit: ${unit}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (totalMs === 0) {
|
|
269
|
+
throw new Error(`Invalid duration format: ${duration}`);
|
|
270
|
+
}
|
|
271
|
+
return totalMs / 1000;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseTimeUnitToSeconds(timeUnit?: string): number {
|
|
275
|
+
if (!timeUnit) return 1;
|
|
276
|
+
return parseDurationToSeconds(timeUnit);
|
|
277
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -21,6 +21,10 @@ export async function status(options: StatusOptions) {
|
|
|
21
21
|
const kubernetesService = createDefaultKubernetesService();
|
|
22
22
|
|
|
23
23
|
const testRun = await kubernetesService.getTestRun(lastRun.testRunName, namespace);
|
|
24
|
+
if (!testRun) {
|
|
25
|
+
logger.error(`TestRun ${lastRun.testRunName} not found in namespace ${namespace}. It may have been deleted externally.`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
24
28
|
printTestRunsTable([testRun]);
|
|
25
29
|
|
|
26
30
|
const podList = await kubernetesService.getPodsForTestRun(lastRun.testRunName, namespace);
|
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
import { existsSync, promises as fs_promises } from 'fs';
|
|
1
|
+
import { existsSync, promises as fs_promises, createReadStream } from 'fs';
|
|
2
2
|
import { parse } from 'node:path';
|
|
3
3
|
import * as k8s from '@kubernetes/client-node';
|
|
4
4
|
import logger from '../utils/logger';
|
|
5
|
-
import type { ArchivedFile, ConfigMapResult } from '../types/kubernetes.types';
|
|
5
|
+
import type { ArchivedFile, ConfigMapResult, VolumeClaimResult } from '../types/kubernetes.types';
|
|
6
6
|
import { TestRunManifest } from '../types/testRunManifest.types';
|
|
7
7
|
import { printTableGeneric } from '../utils/table.util';
|
|
8
8
|
|
|
9
9
|
export class KubernetesService {
|
|
10
|
-
constructor(
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly k8sApi: k8s.CoreV1Api,
|
|
12
|
+
private readonly k8sCustomApi: k8s.CustomObjectsApi,
|
|
13
|
+
private readonly kc?: k8s.KubeConfig,
|
|
14
|
+
) { }
|
|
15
|
+
|
|
16
|
+
private getKubeConfig(): k8s.KubeConfig {
|
|
17
|
+
if (this.kc) return this.kc;
|
|
18
|
+
const kc = new k8s.KubeConfig();
|
|
19
|
+
kc.loadFromDefault();
|
|
20
|
+
return kc;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private isNotFoundError(error: unknown): boolean {
|
|
24
|
+
if ((error as any)?.statusCode === 404) return true;
|
|
25
|
+
if ((error as Error)?.message?.includes('HTTP-Code: 404')) return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
11
28
|
|
|
12
29
|
async createConfigMap(archiveFile: ArchivedFile, namespace: string): Promise<ConfigMapResult> {
|
|
13
30
|
// Check if the archive file exists
|
|
@@ -44,11 +61,119 @@ export class KubernetesService {
|
|
|
44
61
|
return { namespace, configMapName };
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
async createPVCWithArchive(archiveFile: ArchivedFile, namespace: string): Promise<VolumeClaimResult> {
|
|
65
|
+
if (!existsSync(archiveFile.archivePath)) {
|
|
66
|
+
throw new Error(`Archive file not found at path: ${archiveFile.archivePath}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stats = await fs_promises.stat(archiveFile.archivePath);
|
|
70
|
+
const storageMi = Math.max(10, Math.ceil(stats.size * 2 / (1024 * 1024)));
|
|
71
|
+
const volumeClaimName = parse(archiveFile.archiveFilename).name;
|
|
72
|
+
|
|
73
|
+
// Create PVC
|
|
74
|
+
const pvc: k8s.V1PersistentVolumeClaim = {
|
|
75
|
+
apiVersion: 'v1',
|
|
76
|
+
kind: 'PersistentVolumeClaim',
|
|
77
|
+
metadata: { name: volumeClaimName, namespace },
|
|
78
|
+
spec: {
|
|
79
|
+
accessModes: ['ReadWriteOnce'],
|
|
80
|
+
resources: { requests: { storage: `${storageMi}Mi` } },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
await this.k8sApi.createNamespacedPersistentVolumeClaim({ namespace, body: pvc });
|
|
84
|
+
logger.info(`PVC ${volumeClaimName} created in namespace ${namespace} (${storageMi}Mi)`);
|
|
85
|
+
|
|
86
|
+
// Create helper pod mounting the PVC
|
|
87
|
+
const helperPodName = `archive-uploader-${Date.now()}`;
|
|
88
|
+
const helperPod: k8s.V1Pod = {
|
|
89
|
+
apiVersion: 'v1',
|
|
90
|
+
kind: 'Pod',
|
|
91
|
+
metadata: { name: helperPodName, namespace },
|
|
92
|
+
spec: {
|
|
93
|
+
restartPolicy: 'Never',
|
|
94
|
+
containers: [{
|
|
95
|
+
name: 'helper',
|
|
96
|
+
image: 'busybox',
|
|
97
|
+
command: ['sleep', 'infinity'],
|
|
98
|
+
volumeMounts: [{ name: 'data', mountPath: '/data' }],
|
|
99
|
+
}],
|
|
100
|
+
volumes: [{ name: 'data', persistentVolumeClaim: { claimName: volumeClaimName } }],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
await this.k8sApi.createNamespacedPod({ namespace, body: helperPod });
|
|
104
|
+
logger.info(`Helper pod ${helperPodName} created in namespace ${namespace}`);
|
|
105
|
+
|
|
106
|
+
// Wait for pod to reach Running state
|
|
107
|
+
await this.waitForPodRunning(helperPodName, namespace);
|
|
108
|
+
|
|
109
|
+
// Stream archive into pod via exec (like kubectl cp)
|
|
110
|
+
const exec = new k8s.Exec(this.getKubeConfig());
|
|
111
|
+
const stdin = createReadStream(archiveFile.archivePath);
|
|
112
|
+
await new Promise<void>((resolve, reject) => {
|
|
113
|
+
exec.exec(
|
|
114
|
+
namespace,
|
|
115
|
+
helperPodName,
|
|
116
|
+
'helper',
|
|
117
|
+
['tee', `/data/${archiveFile.archiveFilename}`],
|
|
118
|
+
null,
|
|
119
|
+
null,
|
|
120
|
+
stdin,
|
|
121
|
+
false,
|
|
122
|
+
(status: k8s.V1Status) => {
|
|
123
|
+
if (status.status === 'Success') resolve();
|
|
124
|
+
else reject(new Error(`Failed to upload archive to pod: ${status.message ?? JSON.stringify(status)}`));
|
|
125
|
+
},
|
|
126
|
+
).catch(reject);
|
|
127
|
+
});
|
|
128
|
+
logger.info(`Archive ${archiveFile.archiveFilename} uploaded to PVC ${volumeClaimName}`);
|
|
129
|
+
|
|
130
|
+
// Delete helper pod
|
|
131
|
+
await this.k8sApi.deleteNamespacedPod({ name: helperPodName, namespace })
|
|
132
|
+
.catch(err => logger.debug(`Error deleting helper pod ${helperPodName}: ${(err as Error).message}`));
|
|
133
|
+
logger.info(`Helper pod ${helperPodName} deleted`);
|
|
134
|
+
|
|
135
|
+
// Clean up local archive file
|
|
136
|
+
await fs_promises.unlink(archiveFile.archivePath)
|
|
137
|
+
.catch(err => logger.debug(`Error deleting archive file ${archiveFile.archivePath}: ${(err as Error).message}`));
|
|
138
|
+
|
|
139
|
+
return { namespace, volumeClaimName, archiveFilename: archiveFile.archiveFilename };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async waitForPodRunning(podName: string, namespace: string, maxWaitMs: number = 60_000): Promise<void> {
|
|
143
|
+
const pollInterval = 2_000;
|
|
144
|
+
const start = Date.now();
|
|
145
|
+
while (Date.now() - start < maxWaitMs) {
|
|
146
|
+
const pod = await this.k8sApi.readNamespacedPod({ name: podName, namespace });
|
|
147
|
+
if (pod.status?.phase === 'Running') return;
|
|
148
|
+
if (pod.status?.phase === 'Failed') throw new Error(`Helper pod ${podName} failed to start`);
|
|
149
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`Helper pod ${podName} did not reach Running state within ${maxWaitMs}ms`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async deleteVolumeClaimByName(volumeClaimName: string, namespace: string = "default"): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
await this.k8sApi.deleteNamespacedPersistentVolumeClaim({ name: volumeClaimName, namespace });
|
|
157
|
+
logger.info(`PVC ${volumeClaimName} deleted from namespace ${namespace}`);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (this.isNotFoundError(error)) {
|
|
160
|
+
logger.warn(`PVC ${volumeClaimName} not found in namespace ${namespace}, skipping deletion`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
164
|
+
throw new Error(`Failed to delete PVC ${volumeClaimName} from namespace ${namespace}: ${errorMessage}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
47
168
|
async deleteConfigMap(configMapName: string, namespace: string): Promise<void> {
|
|
48
169
|
try {
|
|
49
170
|
await this.k8sApi.deleteNamespacedConfigMap({ name: configMapName, namespace });
|
|
50
171
|
logger.info(`ConfigMap ${configMapName} deleted from namespace ${namespace}`);
|
|
51
172
|
} catch (error) {
|
|
173
|
+
if (this.isNotFoundError(error)) {
|
|
174
|
+
logger.warn(`ConfigMap ${configMapName} not found in namespace ${namespace}, skipping deletion`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
52
177
|
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
53
178
|
throw new Error(`Failed to delete ConfigMap ${configMapName} from namespace ${namespace}: ${errorMessage}`);
|
|
54
179
|
}
|
|
@@ -127,6 +252,10 @@ export class KubernetesService {
|
|
|
127
252
|
await this.k8sCustomApi.deleteNamespacedCustomObject(this.getTestRunCustomObjectParams(testRunName, namespace));
|
|
128
253
|
logger.info(`TestRun ${testRunName} deleted from namespace ${namespace}`);
|
|
129
254
|
} catch (error) {
|
|
255
|
+
if (this.isNotFoundError(error)) {
|
|
256
|
+
logger.warn(`TestRun ${testRunName} not found in namespace ${namespace}, skipping deletion`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
130
259
|
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
131
260
|
throw new Error(`Failed to delete TestRun ${testRunName} from namespace ${namespace}: ${errorMessage}`);
|
|
132
261
|
}
|
|
@@ -137,16 +266,23 @@ export class KubernetesService {
|
|
|
137
266
|
await this.k8sApi.deleteNamespacedPod({ name: podName, namespace });
|
|
138
267
|
logger.info(`Pod ${podName} deleted from namespace ${namespace}`);
|
|
139
268
|
} catch (error) {
|
|
269
|
+
if (this.isNotFoundError(error)) {
|
|
270
|
+
logger.warn(`Pod ${podName} not found in namespace ${namespace}, skipping deletion`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
140
273
|
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
141
274
|
throw new Error(`Failed to delete Pod ${podName} from namespace ${namespace}: ${errorMessage}`);
|
|
142
275
|
}
|
|
143
276
|
}
|
|
144
277
|
|
|
145
|
-
async getTestRun(testRunName: string, namespace: string = "default"): Promise<TestRunManifest> {
|
|
278
|
+
async getTestRun(testRunName: string, namespace: string = "default"): Promise<TestRunManifest | null> {
|
|
146
279
|
try {
|
|
147
280
|
const response = await this.k8sCustomApi.getNamespacedCustomObject(this.getTestRunCustomObjectParams(testRunName, namespace));
|
|
148
281
|
return response as TestRunManifest;
|
|
149
282
|
} catch (error) {
|
|
283
|
+
if (this.isNotFoundError(error)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
150
286
|
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
151
287
|
throw new Error(`Failed to get TestRun ${testRunName} in namespace ${namespace}: ${errorMessage}`);
|
|
152
288
|
}
|
|
@@ -211,7 +347,7 @@ export function createDefaultKubernetesService(context?: string): KubernetesServ
|
|
|
211
347
|
if (context) kc.setCurrentContext(context);
|
|
212
348
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
|
213
349
|
const k8sCustomApi = kc.makeApiClient(k8s.CustomObjectsApi);
|
|
214
|
-
return new KubernetesService(k8sApi, k8sCustomApi);
|
|
350
|
+
return new KubernetesService(k8sApi, k8sCustomApi, kc);
|
|
215
351
|
}
|
|
216
352
|
|
|
217
353
|
export function printPodsTable(data: k8s.V1PodList): void {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { exec } from 'child_process';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, statSync } from 'fs';
|
|
3
|
+
import { promises as fsPromises } from 'node:fs';
|
|
4
|
+
import { dirname, basename, join, parse } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
4
6
|
import { promisify } from 'util';
|
|
5
7
|
import logger from '../utils/logger';
|
|
8
|
+
import * as tar from 'tar';
|
|
6
9
|
|
|
7
|
-
import type { ArchiveResult, ExecFn } from '../types/script.types';
|
|
10
|
+
import type { ArchiveResult, ExecFn, K6InspectResult, K6ScenarioMetrics } from '../types/script.types';
|
|
8
11
|
|
|
9
12
|
const defaultExecAsync = promisify(exec);
|
|
10
13
|
|
|
@@ -45,10 +48,12 @@ export class ScriptService {
|
|
|
45
48
|
if (!existsSync(archiveOutput)) {
|
|
46
49
|
throw new Error(`Failed to create archive: ${stderr}`);
|
|
47
50
|
}
|
|
48
|
-
|
|
51
|
+
const archiveSize = statSync(archiveOutput).size;
|
|
52
|
+
logger.info(`Archive created successfully at: ${archiveOutput} (size: ${archiveSize} bytes)`);
|
|
49
53
|
return {
|
|
50
54
|
archivePath: archiveOutput,
|
|
51
55
|
archiveFilename: basename(archiveOutput),
|
|
56
|
+
archiveSize,
|
|
52
57
|
scriptPath: scriptPath,
|
|
53
58
|
scriptFilename: basename(scriptPath),
|
|
54
59
|
};
|
|
@@ -57,6 +62,69 @@ export class ScriptService {
|
|
|
57
62
|
throw new Error(`Failed to archive the script: ${errorMessage}`);
|
|
58
63
|
}
|
|
59
64
|
}
|
|
65
|
+
|
|
66
|
+
async inspectScript(scriptPath: string): Promise<K6InspectResult> {
|
|
67
|
+
const { stdout, stderr } = await this.execCmd(`k6 inspect ${scriptPath}`);
|
|
68
|
+
if (stderr) {
|
|
69
|
+
logger.error(`Error inspecting script: ${stderr}`);
|
|
70
|
+
throw new Error(`Failed to inspect the script: ${stderr}`);
|
|
71
|
+
}
|
|
72
|
+
return JSON.parse(stdout) as K6InspectResult;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async extractModifyAndRecompress(archive: ArchiveResult, scenarioMetrics: K6ScenarioMetrics[]): Promise<ArchiveResult> {
|
|
76
|
+
if (!existsSync(archive.archivePath)) {
|
|
77
|
+
throw new Error(`Archive file not found at path: ${archive.archivePath}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tempRoot = await fsPromises.mkdtemp(join(tmpdir(), 'k6ctl-archive-'));
|
|
81
|
+
const extractDir = join(tempRoot, 'extracted');
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await fsPromises.mkdir(extractDir, { recursive: true });
|
|
85
|
+
|
|
86
|
+
await tar.x({
|
|
87
|
+
file: archive.archivePath,
|
|
88
|
+
cwd: extractDir,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const metadataPath = join(extractDir, 'metadata.json');
|
|
92
|
+
if (!existsSync(metadataPath)) {
|
|
93
|
+
throw new Error(`metadata.json not found in extracted archive: ${archive.archiveFilename}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const metadataRaw = await fsPromises.readFile(metadataPath, 'utf8');
|
|
97
|
+
const metadata = JSON.parse(metadataRaw) as Record<string, unknown>;
|
|
98
|
+
const updatedMetadata = updateMetadata(metadata, scenarioMetrics);
|
|
99
|
+
await fsPromises.writeFile(metadataPath, `${JSON.stringify(updatedMetadata, null, 2)}\n`, 'utf8');
|
|
100
|
+
|
|
101
|
+
const outputArchivePath = join(dirname(archive.archivePath), `${parse(archive.archiveFilename).name}.tar`);
|
|
102
|
+
|
|
103
|
+
await tar.c({
|
|
104
|
+
file: outputArchivePath,
|
|
105
|
+
cwd: extractDir,
|
|
106
|
+
}, ['metadata.json', 'data', 'file']);
|
|
107
|
+
|
|
108
|
+
if (!existsSync(outputArchivePath)) {
|
|
109
|
+
throw new Error(`Failed to create modified archive at path: ${outputArchivePath}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.info(`Archive metadata updated successfully: ${outputArchivePath}`);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
archivePath: outputArchivePath,
|
|
116
|
+
archiveFilename: basename(outputArchivePath),
|
|
117
|
+
scriptPath: archive.scriptPath,
|
|
118
|
+
scriptFilename: archive.scriptFilename,
|
|
119
|
+
archiveSize: statSync(outputArchivePath).size,
|
|
120
|
+
};
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const errorMessage = (error as Error).message ?? 'Unknown error';
|
|
123
|
+
throw new Error(`Failed to extract, modify, and recompress archive: ${errorMessage}`);
|
|
124
|
+
} finally {
|
|
125
|
+
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
60
128
|
}
|
|
61
129
|
|
|
62
130
|
export function createDefaultScriptService(): ScriptService {
|
|
@@ -77,3 +145,33 @@ function sanitizeText(text: string): string {
|
|
|
77
145
|
sanitized = sanitized.replace(/^[.-]+|[.-]+$/g, '');
|
|
78
146
|
return sanitized;
|
|
79
147
|
}
|
|
148
|
+
|
|
149
|
+
function shellQuote(value: string): string {
|
|
150
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function updateMetadata(metadata: Record<string, unknown>, scenarioMetrics: K6ScenarioMetrics[]): Record<string, unknown> {
|
|
154
|
+
if (isRecord(metadata.options) && isRecord(metadata.options.scenarios)) {
|
|
155
|
+
applyScenarioMetricsToScenarios(metadata.options.scenarios, scenarioMetrics);
|
|
156
|
+
}
|
|
157
|
+
return metadata;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
161
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function applyScenarioMetricsToScenarios(
|
|
165
|
+
scenarios: Record<string, unknown>,
|
|
166
|
+
scenarioMetrics: K6ScenarioMetrics[]
|
|
167
|
+
) {
|
|
168
|
+
for (const metric of scenarioMetrics) {
|
|
169
|
+
const scenario = scenarios[metric.name];
|
|
170
|
+
if (!isRecord(scenario)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const recommendedMaxVUs = Math.ceil(metric.recommendedMaxVUs);
|
|
174
|
+
scenario.maxVUs = recommendedMaxVUs;
|
|
175
|
+
scenario.preAllocatedVUs = Math.max(1, Math.ceil(recommendedMaxVUs * 0.7));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -1,8 +1,48 @@
|
|
|
1
1
|
export interface ArchiveResult {
|
|
2
2
|
archivePath: string;
|
|
3
3
|
archiveFilename: string;
|
|
4
|
+
archiveSize: number;
|
|
4
5
|
scriptPath: string;
|
|
5
6
|
scriptFilename: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
export interface K6ScenarioOptions {
|
|
10
|
+
executor: string;
|
|
11
|
+
startTime?: string;
|
|
12
|
+
gracefulStop?: string;
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
exec?: string;
|
|
15
|
+
tags?: Record<string, string>;
|
|
16
|
+
startRate?: number;
|
|
17
|
+
timeUnit?: string; // e.g. "1s", "500ms"
|
|
18
|
+
stages?: Array<{ duration: string; target: number }>;
|
|
19
|
+
preAllocatedVUs?: number;
|
|
20
|
+
maxVUs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface K6InspectResult {
|
|
24
|
+
scenarios?: Record<string, K6ScenarioOptions>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface K6StageMetrics {
|
|
28
|
+
stageIndex: number;
|
|
29
|
+
duration: string;
|
|
30
|
+
durationSeconds: number;
|
|
31
|
+
fromTps: number;
|
|
32
|
+
toTps: number;
|
|
33
|
+
avgTps: number;
|
|
34
|
+
estimatedIterations: number;
|
|
35
|
+
requiredVUsAtTarget: number;
|
|
36
|
+
recommendedVUsAtTarget: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface K6ScenarioMetrics {
|
|
40
|
+
name: string;
|
|
41
|
+
peakTps: number;
|
|
42
|
+
totalIterations: number;
|
|
43
|
+
requiredMaxVUs: number;
|
|
44
|
+
recommendedMaxVUs: number;
|
|
45
|
+
stageMetrics: K6StageMetrics[];
|
|
46
|
+
}
|
|
47
|
+
|
|
8
48
|
export type ExecFn = (cmd: string) => Promise<{ stdout: string; stderr: string }>;
|