k6ctl 1.1.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 (52) hide show
  1. package/README.md +168 -4
  2. package/dist/cli.js +10 -5
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/delete.d.ts +4 -1
  5. package/dist/commands/delete.d.ts.map +1 -1
  6. package/dist/commands/delete.js +26 -5
  7. package/dist/commands/delete.js.map +1 -1
  8. package/dist/commands/list.js +6 -6
  9. package/dist/commands/list.js.map +1 -1
  10. package/dist/commands/run.d.ts +2 -0
  11. package/dist/commands/run.d.ts.map +1 -1
  12. package/dist/commands/run.js +191 -1
  13. package/dist/commands/run.js.map +1 -1
  14. package/dist/commands/status.d.ts.map +1 -1
  15. package/dist/commands/status.js +4 -0
  16. package/dist/commands/status.js.map +1 -1
  17. package/dist/services/kubernetes.service.d.ts +12 -5
  18. package/dist/services/kubernetes.service.d.ts.map +1 -1
  19. package/dist/services/kubernetes.service.js +134 -6
  20. package/dist/services/kubernetes.service.js.map +1 -1
  21. package/dist/services/script.service.d.ts +3 -1
  22. package/dist/services/script.service.d.ts.map +1 -1
  23. package/dist/services/script.service.js +115 -1
  24. package/dist/services/script.service.js.map +1 -1
  25. package/dist/types/kubernetes.types.d.ts +5 -0
  26. package/dist/types/kubernetes.types.d.ts.map +1 -1
  27. package/dist/types/lastRun.types.d.ts +2 -1
  28. package/dist/types/lastRun.types.d.ts.map +1 -1
  29. package/dist/types/script.types.d.ts +39 -0
  30. package/dist/types/script.types.d.ts.map +1 -1
  31. package/dist/types/testRunManifest.types.d.ts +2 -4
  32. package/dist/types/testRunManifest.types.d.ts.map +1 -1
  33. package/dist/utils/testRunManifestBuilder.d.ts +14 -1
  34. package/dist/utils/testRunManifestBuilder.d.ts.map +1 -1
  35. package/dist/utils/testRunManifestBuilder.js +70 -14
  36. package/dist/utils/testRunManifestBuilder.js.map +1 -1
  37. package/package.json +2 -1
  38. package/src/cli.ts +10 -5
  39. package/src/commands/delete.ts +35 -8
  40. package/src/commands/list.ts +7 -7
  41. package/src/commands/run.ts +216 -2
  42. package/src/commands/status.ts +4 -0
  43. package/src/services/kubernetes.service.ts +161 -16
  44. package/src/services/script.service.ts +102 -4
  45. package/src/types/kubernetes.types.ts +6 -0
  46. package/src/types/lastRun.types.ts +2 -1
  47. package/src/types/script.types.ts +40 -0
  48. package/src/types/testRunManifest.types.ts +3 -4
  49. package/src/utils/testRunManifestBuilder.ts +80 -18
  50. package/test/integration/kubernetes.service.test.ts +63 -9
  51. package/test/unit/kubernetes.service.test.ts +15 -7
  52. package/test/unit/script.service.test.ts +11 -4
@@ -1,14 +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
- import { unlink } from 'node:fs/promises';
9
8
 
10
9
  export class KubernetesService {
11
- 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
+ }
12
28
 
13
29
  async createConfigMap(archiveFile: ArchivedFile, namespace: string): Promise<ConfigMapResult> {
14
30
  // Check if the archive file exists
@@ -39,17 +55,125 @@ export class KubernetesService {
39
55
 
40
56
  await this.k8sApi.createNamespacedConfigMap({ namespace, body: configMap });
41
57
  logger.info(`ConfigMap ${configMapName} created in namespace ${namespace}`);
42
-
58
+
43
59
  // Clean up the archive file after creating the ConfigMap
44
- await unlink(archiveFile.archivePath).catch(error => console.error(`Error deleting file ${archiveFile.archivePath}:`, error));
60
+ await fs_promises.unlink(archiveFile.archivePath).catch(error => console.error(`Error deleting file ${archiveFile.archivePath}:`, error));
45
61
  return { namespace, configMapName };
46
62
  }
47
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
+
48
168
  async deleteConfigMap(configMapName: string, namespace: string): Promise<void> {
49
169
  try {
50
170
  await this.k8sApi.deleteNamespacedConfigMap({ name: configMapName, namespace });
51
171
  logger.info(`ConfigMap ${configMapName} deleted from namespace ${namespace}`);
52
172
  } catch (error) {
173
+ if (this.isNotFoundError(error)) {
174
+ logger.warn(`ConfigMap ${configMapName} not found in namespace ${namespace}, skipping deletion`);
175
+ return;
176
+ }
53
177
  const errorMessage = (error as Error).message ?? 'Unknown error';
54
178
  throw new Error(`Failed to delete ConfigMap ${configMapName} from namespace ${namespace}: ${errorMessage}`);
55
179
  }
@@ -85,27 +209,28 @@ export class KubernetesService {
85
209
  namespace: namespace,
86
210
  plural: "testruns",
87
211
  });
88
- return response;
212
+ return response.items as TestRunManifest[];
89
213
  } catch (error) {
90
214
  const errorMessage = (error as Error).message ?? 'Unknown error';
91
215
  throw new Error(`Failed to list TestRuns in namespace ${namespace}: ${errorMessage}`);
92
216
  }
93
217
  }
94
218
 
95
- async listPods(namespace: string = "default"): Promise<any> {
219
+ async listPods(namespace: string = "default"): Promise<k8s.V1PodList> {
96
220
  try {
97
221
  const response = await this.k8sApi.listNamespacedPod({ namespace });
98
- return response;
222
+ return response as k8s.V1PodList;
99
223
  } catch (error) {
100
224
  const errorMessage = (error as Error).message ?? 'Unknown error';
101
225
  throw new Error(`Failed to list Pods in namespace ${namespace}: ${errorMessage}`);
102
226
  }
103
227
  }
104
228
 
105
- async listConfigMaps(namespace: string = "default"): Promise<any> {
229
+ async listConfigMaps(namespace: string = "default"): Promise<k8s.V1ConfigMapList> {
106
230
  try {
107
231
  const response = await this.k8sApi.listNamespacedConfigMap({ namespace });
108
- return response;
232
+ response.items = response.items?.filter(cm => cm.metadata?.name?.startsWith('archive-'));
233
+ return response as k8s.V1ConfigMapList;
109
234
  } catch (error) {
110
235
  const errorMessage = (error as Error).message ?? 'Unknown error';
111
236
  throw new Error(`Failed to list ConfigMaps in namespace ${namespace}: ${errorMessage}`);
@@ -127,16 +252,37 @@ 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
  }
133
262
  }
134
263
 
135
- async getTestRun(testRunName: string, namespace: string = "default"): Promise<any> {
264
+ async deletePodByName(podName: string, namespace: string = "default"): Promise<void> {
265
+ try {
266
+ await this.k8sApi.deleteNamespacedPod({ name: podName, namespace });
267
+ logger.info(`Pod ${podName} deleted from namespace ${namespace}`);
268
+ } catch (error) {
269
+ if (this.isNotFoundError(error)) {
270
+ logger.warn(`Pod ${podName} not found in namespace ${namespace}, skipping deletion`);
271
+ return;
272
+ }
273
+ const errorMessage = (error as Error).message ?? 'Unknown error';
274
+ throw new Error(`Failed to delete Pod ${podName} from namespace ${namespace}: ${errorMessage}`);
275
+ }
276
+ }
277
+
278
+ async getTestRun(testRunName: string, namespace: string = "default"): Promise<TestRunManifest | null> {
136
279
  try {
137
280
  const response = await this.k8sCustomApi.getNamespacedCustomObject(this.getTestRunCustomObjectParams(testRunName, namespace));
138
- return response;
281
+ return response as TestRunManifest;
139
282
  } catch (error) {
283
+ if (this.isNotFoundError(error)) {
284
+ return null;
285
+ }
140
286
  const errorMessage = (error as Error).message ?? 'Unknown error';
141
287
  throw new Error(`Failed to get TestRun ${testRunName} in namespace ${namespace}: ${errorMessage}`);
142
288
  }
@@ -148,7 +294,7 @@ export class KubernetesService {
148
294
  namespace,
149
295
  labelSelector: `k6_cr=${testRunName}`,
150
296
  });
151
- return response;
297
+ return response as k8s.V1PodList;
152
298
  } catch (error) {
153
299
  const errorMessage = (error as Error).message ?? 'Unknown error';
154
300
  throw new Error(`Failed to get pods for TestRun ${testRunName} in namespace ${namespace}: ${errorMessage}`);
@@ -201,7 +347,7 @@ export function createDefaultKubernetesService(context?: string): KubernetesServ
201
347
  if (context) kc.setCurrentContext(context);
202
348
  const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
203
349
  const k8sCustomApi = kc.makeApiClient(k8s.CustomObjectsApi);
204
- return new KubernetesService(k8sApi, k8sCustomApi);
350
+ return new KubernetesService(k8sApi, k8sCustomApi, kc);
205
351
  }
206
352
 
207
353
  export function printPodsTable(data: k8s.V1PodList): void {
@@ -273,7 +419,6 @@ export function printTestRunsTable(testRuns: TestRunManifest[]): void {
273
419
  const separate = tr.spec?.separate ?? "N/A";
274
420
  const quiet = tr.spec?.quiet ?? "N/A";
275
421
  const age = (tr as any)?.metadata?.creationTimestamp ?? "N/A";
276
- // const age = ageSince((tr as any)?.metadata?.creationTimestamp) || "N/A";
277
422
  return [[name, namespace, parallelism, cleanup, separate, quiet, age]];
278
423
  }
279
424
  });
@@ -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 }>;
@@ -1,3 +1,5 @@
1
+ import { RunnerEnvVar } from "../utils/testRunManifestBuilder";
2
+
1
3
  export interface TestRunManifest {
2
4
  apiVersion: string;
3
5
  kind: string;
@@ -13,10 +15,7 @@ export interface TestRunManifest {
13
15
  separate?: boolean;
14
16
  runner?: {
15
17
  image?: string;
16
- env?: Array<{
17
- name: string;
18
- value: string;
19
- }>;
18
+ env?: RunnerEnvVar[];
20
19
  resources?: {
21
20
  limits: {
22
21
  cpu: string
@@ -1,9 +1,11 @@
1
1
  import logger from '../utils/logger';
2
- import type { ArchivedFile, ConfigMapResult } from '../types/kubernetes.types';
2
+ import type { ArchivedFile, ConfigMapResult, VolumeClaimResult } from '../types/kubernetes.types';
3
3
  import { K6Config } from '../types/config.types';
4
4
  import { TestRunManifest } from '../types/testRunManifest.types';
5
5
 
6
- type RunnerEnvVar = { name: string; value: string };
6
+ export type RunnerEnvVar =
7
+ | { name: string; value: string }
8
+ | { name: string; valueFrom: { secretKeyRef: { name: string; key: string } } };
7
9
  const K6_GROUP = "k6.io";
8
10
  const K6_VERSION = "v1alpha1";
9
11
  const K6_KIND = "TestRun";
@@ -61,24 +63,15 @@ function buildRunnerEnv(
61
63
  cfg: K6Config,
62
64
  envFromLoader?: Record<string, string>
63
65
  ): RunnerEnvVar[] | undefined {
64
- const envMap = new Map<string, string>();
66
+ const envMap = new Map<string, RunnerEnvVar>();
65
67
  if (envFromLoader) {
66
- for (const [key, value] of Object.entries(envFromLoader)) {
67
- if (value !== undefined && value !== null) {
68
- envMap.set(key, String(value));
69
- }
68
+ for (const [key, raw] of Object.entries(envFromLoader)) {
69
+ if (raw === undefined || raw === null) continue;
70
+ addEnvToMap(key, raw, envMap);
70
71
  }
71
72
  }
72
- if (cfg.prometheus?.serverUrl) {
73
- envMap.set(K6_PROMETHEUS_RW_SERVER_URL, cfg.prometheus.serverUrl);
74
- if (cfg.prometheus.trendStats?.length) {
75
- envMap.set(K6_PROMETHEUS_RW_TREND_STATS, cfg.prometheus.trendStats.join(","));
76
- }
77
- }
78
- const envArray: RunnerEnvVar[] = Array.from(envMap.entries()).map(([name, value]) => ({
79
- name,
80
- value,
81
- }));
73
+ addPrometheusEnvVars(cfg, envMap);
74
+ const envArray: RunnerEnvVar[] = Array.from(envMap.values());
82
75
  return envArray.length > 0 ? envArray : undefined;
83
76
  }
84
77
 
@@ -99,4 +92,73 @@ function buildArgumentsString(args: string[] | undefined, cfg: K6Config, name: s
99
92
  return undefined;
100
93
  }
101
94
  return finalArgs.join(" ");
102
- }
95
+ }
96
+
97
+ function parseSecretPlaceholder(value: string): { secretName: string; secretKey: string } | null {
98
+ const m = /^\{\{SECRETS\.([^.}]+)\.([^.}]+)\}\}$/.exec(value.trim());
99
+ if (!m) return null;
100
+ return { secretName: m[1], secretKey: m[2] };
101
+ }
102
+
103
+ function addEnvToMap(key: string, raw: string, envMap: Map<string, RunnerEnvVar>) {
104
+ const value = String(raw);
105
+ const secret = parseSecretPlaceholder(value);
106
+ if (secret) {
107
+ envMap.set(key, { name: key, valueFrom: { secretKeyRef: { name: secret.secretName, key: secret.secretKey } } });
108
+ } else {
109
+ envMap.set(key, { name: key, value });
110
+ }
111
+ }
112
+
113
+ function addPrometheusEnvVars(cfg: K6Config, envMap: Map<string, RunnerEnvVar>) {
114
+ if (cfg.prometheus?.serverUrl) {
115
+ envMap.set(K6_PROMETHEUS_RW_SERVER_URL, {
116
+ name: K6_PROMETHEUS_RW_SERVER_URL,
117
+ value: cfg.prometheus.serverUrl,
118
+ });
119
+ if (cfg.prometheus.trendStats?.length) {
120
+ envMap.set(K6_PROMETHEUS_RW_TREND_STATS, {
121
+ name: K6_PROMETHEUS_RW_TREND_STATS,
122
+ value: cfg.prometheus.trendStats.join(","),
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ export function buildTestRunManifestWithVolumeClaim(
129
+ volumeClaimResult: VolumeClaimResult,
130
+ cfg: K6Config,
131
+ envFromLoader?: Record<string, string>
132
+ ): TestRunManifest {
133
+ const testName = volumeClaimResult.volumeClaimName.replace('archive-', 'test-');
134
+ const argumentsString = buildArgumentsString(cfg.arguments, cfg, testName);
135
+ logger.debug('Constructed arguments string for TestRun manifest (volume):', argumentsString);
136
+ const testRun: TestRunManifest = {
137
+ apiVersion: `${K6_GROUP}/${K6_VERSION}`,
138
+ kind: K6_KIND,
139
+ metadata: {
140
+ name: testName,
141
+ namespace: volumeClaimResult.namespace,
142
+ },
143
+ spec: {
144
+ parallelism: cfg.parallelism,
145
+ arguments: argumentsString,
146
+ quiet: String(cfg.quiet),
147
+ ...(cfg.cleanup === true ? { cleanup: K6_CLEANUP_LABEL } : {}),
148
+ separate: cfg.separate,
149
+ runner: {
150
+ image: cfg.runner?.image,
151
+ env: buildRunnerEnv(cfg, envFromLoader),
152
+ ...(cfg.runner?.resources ? { resources: cfg.runner.resources } : {}),
153
+ },
154
+ script: {
155
+ volumeClaim: {
156
+ name: volumeClaimResult.volumeClaimName,
157
+ file: volumeClaimResult.archiveFilename,
158
+ },
159
+ },
160
+ },
161
+ };
162
+ logger.debug('Constructed TestRun manifest (volume):', JSON.stringify(testRun, null, 2));
163
+ return testRun;
164
+ }