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.
Files changed (46) hide show
  1. package/README.md +168 -4
  2. package/dist/cli.js +3 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/delete.d.ts +1 -1
  5. package/dist/commands/delete.d.ts.map +1 -1
  6. package/dist/commands/delete.js +10 -4
  7. package/dist/commands/delete.js.map +1 -1
  8. package/dist/commands/run.d.ts +1 -0
  9. package/dist/commands/run.d.ts.map +1 -1
  10. package/dist/commands/run.js +137 -1
  11. package/dist/commands/run.js.map +1 -1
  12. package/dist/commands/status.d.ts.map +1 -1
  13. package/dist/commands/status.js +4 -0
  14. package/dist/commands/status.js.map +1 -1
  15. package/dist/services/kubernetes.service.d.ts +9 -3
  16. package/dist/services/kubernetes.service.d.ts.map +1 -1
  17. package/dist/services/kubernetes.service.js +121 -2
  18. package/dist/services/kubernetes.service.js.map +1 -1
  19. package/dist/services/script.service.d.ts +3 -1
  20. package/dist/services/script.service.d.ts.map +1 -1
  21. package/dist/services/script.service.js +115 -1
  22. package/dist/services/script.service.js.map +1 -1
  23. package/dist/types/kubernetes.types.d.ts +5 -0
  24. package/dist/types/kubernetes.types.d.ts.map +1 -1
  25. package/dist/types/lastRun.types.d.ts +2 -1
  26. package/dist/types/lastRun.types.d.ts.map +1 -1
  27. package/dist/types/script.types.d.ts +39 -0
  28. package/dist/types/script.types.d.ts.map +1 -1
  29. package/dist/utils/testRunManifestBuilder.d.ts +2 -1
  30. package/dist/utils/testRunManifestBuilder.d.ts.map +1 -1
  31. package/dist/utils/testRunManifestBuilder.js +34 -0
  32. package/dist/utils/testRunManifestBuilder.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/cli.ts +3 -2
  35. package/src/commands/delete.ts +11 -5
  36. package/src/commands/run.ts +158 -2
  37. package/src/commands/status.ts +4 -0
  38. package/src/services/kubernetes.service.ts +141 -5
  39. package/src/services/script.service.ts +102 -4
  40. package/src/types/kubernetes.types.ts +6 -0
  41. package/src/types/lastRun.types.ts +2 -1
  42. package/src/types/script.types.ts +40 -0
  43. package/src/utils/testRunManifestBuilder.ts +39 -1
  44. package/test/integration/kubernetes.service.test.ts +61 -5
  45. package/test/unit/kubernetes.service.test.ts +13 -8
  46. package/test/unit/script.service.test.ts +11 -4
@@ -4,7 +4,7 @@ import logger from '../utils/logger';
4
4
 
5
5
  interface DeleteOptions {
6
6
  namespace?: string;
7
- keepConfigmap?: boolean;
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 = !options.namespace ? await loadLastRun() : null;
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.keepConfigmap) {
46
- logger.info(`Deleting ConfigMap: ${lastRun.configMapName} (namespace: ${namespace})`);
47
- await kubernetesService.deleteConfigMap(lastRun.configMapName, namespace);
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();
@@ -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
- const archive = await scriptService.archiveTest(scriptPath);
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
+ }
@@ -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(private readonly k8sApi: k8s.CoreV1Api, private readonly k8sCustomApi: k8s.CustomObjectsApi) { }
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 { basename, join, parse } from 'node:path';
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
- logger.info(`Archive created successfully at: ${archiveOutput}`);
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
+ }
@@ -7,3 +7,9 @@ export interface ConfigMapResult {
7
7
  namespace: string;
8
8
  configMapName: string;
9
9
  }
10
+
11
+ export interface VolumeClaimResult {
12
+ namespace: string;
13
+ volumeClaimName: string;
14
+ archiveFilename: string;
15
+ }
@@ -1,7 +1,8 @@
1
1
  export interface LastRunState {
2
2
  testRunName: string;
3
3
  namespace: string;
4
- configMapName: string;
4
+ configMapName?: string;
5
+ volumeClaimName?: string;
5
6
  scriptPath: string;
6
7
  createdAt: string;
7
8
  }
@@ -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 }>;