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.
- package/README.md +168 -4
- package/dist/cli.js +10 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/delete.d.ts +4 -1
- package/dist/commands/delete.d.ts.map +1 -1
- package/dist/commands/delete.js +26 -5
- package/dist/commands/delete.js.map +1 -1
- package/dist/commands/list.js +6 -6
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +191 -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 +12 -5
- package/dist/services/kubernetes.service.d.ts.map +1 -1
- package/dist/services/kubernetes.service.js +134 -6
- 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/types/testRunManifest.types.d.ts +2 -4
- package/dist/types/testRunManifest.types.d.ts.map +1 -1
- package/dist/utils/testRunManifestBuilder.d.ts +14 -1
- package/dist/utils/testRunManifestBuilder.d.ts.map +1 -1
- package/dist/utils/testRunManifestBuilder.js +70 -14
- package/dist/utils/testRunManifestBuilder.js.map +1 -1
- package/package.json +2 -1
- package/src/cli.ts +10 -5
- package/src/commands/delete.ts +35 -8
- package/src/commands/list.ts +7 -7
- package/src/commands/run.ts +216 -2
- package/src/commands/status.ts +4 -0
- package/src/services/kubernetes.service.ts +161 -16
- 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/types/testRunManifest.types.ts +3 -4
- package/src/utils/testRunManifestBuilder.ts +80 -18
- package/test/integration/kubernetes.service.test.ts +63 -9
- package/test/unit/kubernetes.service.test.ts +15 -7
- 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(
|
|
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<
|
|
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<
|
|
229
|
+
async listConfigMaps(namespace: string = "default"): Promise<k8s.V1ConfigMapList> {
|
|
106
230
|
try {
|
|
107
231
|
const response = await this.k8sApi.listNamespacedConfigMap({ namespace });
|
|
108
|
-
|
|
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
|
|
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 {
|
|
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 }>;
|
|
@@ -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?:
|
|
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 =
|
|
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,
|
|
66
|
+
const envMap = new Map<string, RunnerEnvVar>();
|
|
65
67
|
if (envFromLoader) {
|
|
66
|
-
for (const [key,
|
|
67
|
-
if (
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
+
}
|