k6ctl 1.0.0 → 1.2.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/dist/cli.js +27 -3
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/delete.d.ts +10 -0
  4. package/dist/commands/delete.d.ts.map +1 -0
  5. package/dist/commands/delete.js +42 -0
  6. package/dist/commands/delete.js.map +1 -0
  7. package/dist/commands/list.js +6 -6
  8. package/dist/commands/list.js.map +1 -1
  9. package/dist/commands/logs.d.ts +7 -0
  10. package/dist/commands/logs.d.ts.map +1 -0
  11. package/dist/commands/logs.js +39 -0
  12. package/dist/commands/logs.js.map +1 -0
  13. package/dist/commands/run.d.ts +1 -0
  14. package/dist/commands/run.d.ts.map +1 -1
  15. package/dist/commands/run.js +64 -0
  16. package/dist/commands/run.js.map +1 -1
  17. package/dist/commands/status.d.ts +6 -0
  18. package/dist/commands/status.d.ts.map +1 -0
  19. package/dist/commands/status.js +26 -0
  20. package/dist/commands/status.js.map +1 -0
  21. package/dist/services/kubernetes.service.d.ts +8 -2
  22. package/dist/services/kubernetes.service.d.ts.map +1 -1
  23. package/dist/services/kubernetes.service.js +71 -23
  24. package/dist/services/kubernetes.service.js.map +1 -1
  25. package/dist/types/lastRun.types.d.ts +8 -0
  26. package/dist/types/lastRun.types.d.ts.map +1 -0
  27. package/dist/types/lastRun.types.js +3 -0
  28. package/dist/types/lastRun.types.js.map +1 -0
  29. package/dist/types/testRunManifest.types.d.ts +2 -4
  30. package/dist/types/testRunManifest.types.d.ts.map +1 -1
  31. package/dist/utils/lastRunStore.d.ts +5 -0
  32. package/dist/utils/lastRunStore.d.ts.map +1 -0
  33. package/dist/utils/lastRunStore.js +41 -0
  34. package/dist/utils/lastRunStore.js.map +1 -0
  35. package/dist/utils/testRunManifestBuilder.d.ts +12 -0
  36. package/dist/utils/testRunManifestBuilder.d.ts.map +1 -1
  37. package/dist/utils/testRunManifestBuilder.js +36 -14
  38. package/dist/utils/testRunManifestBuilder.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli.ts +30 -3
  41. package/src/commands/delete.ts +52 -0
  42. package/src/commands/list.ts +7 -7
  43. package/src/commands/logs.ts +41 -0
  44. package/src/commands/run.ts +69 -0
  45. package/src/commands/status.ts +28 -0
  46. package/src/services/kubernetes.service.ts +77 -26
  47. package/src/types/lastRun.types.ts +7 -0
  48. package/src/types/testRunManifest.types.ts +3 -4
  49. package/src/utils/lastRunStore.ts +36 -0
  50. package/src/utils/testRunManifestBuilder.ts +41 -17
  51. package/test/integration/kubernetes.service.test.ts +23 -20
  52. package/test/unit/kubernetes.service.test.ts +3 -0
@@ -38,6 +38,9 @@ export class KubernetesService {
38
38
 
39
39
  await this.k8sApi.createNamespacedConfigMap({ namespace, body: configMap });
40
40
  logger.info(`ConfigMap ${configMapName} created in namespace ${namespace}`);
41
+
42
+ // Clean up the archive file after creating the ConfigMap
43
+ await fs_promises.unlink(archiveFile.archivePath).catch(error => console.error(`Error deleting file ${archiveFile.archivePath}:`, error));
41
44
  return { namespace, configMapName };
42
45
  }
43
46
 
@@ -70,20 +73,7 @@ export class KubernetesService {
70
73
  }
71
74
 
72
75
  async deleteTestRun(testRunManifest: TestRunManifest): Promise<void> {
73
- try {
74
- const response = await this.k8sCustomApi.deleteNamespacedCustomObject({
75
- group: testRunManifest.apiVersion.split('/')[0],
76
- version: testRunManifest.apiVersion.split('/')[1],
77
- namespace: testRunManifest.metadata.namespace,
78
- plural: "testruns",
79
- name: testRunManifest.metadata.name,
80
- });
81
- logger.info(`TestRun ${testRunManifest.metadata.name} deleted from namespace ${testRunManifest.metadata.namespace}`);
82
- logger.debug(`Delete response: ${JSON.stringify(response)}`);
83
- } catch (error) {
84
- const errorMessage = (error as Error).message ?? 'Unknown error';
85
- throw new Error(`Failed to delete TestRun ${testRunManifest.metadata.name} from namespace ${testRunManifest.metadata.namespace}: ${errorMessage}`);
86
- }
76
+ await this.deleteTestRunByName(testRunManifest.metadata.name, testRunManifest.metadata.namespace);
87
77
  }
88
78
 
89
79
  async listTestRuns(namespace: string = "default"): Promise<any> {
@@ -94,38 +84,100 @@ export class KubernetesService {
94
84
  namespace: namespace,
95
85
  plural: "testruns",
96
86
  });
97
- // logger.debug(`List response: ${JSON.stringify(response)}`);
98
- printTestRunsTable((response as any).items ?? []);
99
- return response;
87
+ return response.items as TestRunManifest[];
100
88
  } catch (error) {
101
89
  const errorMessage = (error as Error).message ?? 'Unknown error';
102
90
  throw new Error(`Failed to list TestRuns in namespace ${namespace}: ${errorMessage}`);
103
91
  }
104
92
  }
105
93
 
106
- async listPods(namespace: string = "default"): Promise<any> {
94
+ async listPods(namespace: string = "default"): Promise<k8s.V1PodList> {
107
95
  try {
108
96
  const response = await this.k8sApi.listNamespacedPod({ namespace });
109
- printPodsTable(response);
110
- // logger.debug(`List Pods: ${JSON.stringify(response)}`);
111
- return response;
97
+ return response as k8s.V1PodList;
112
98
  } catch (error) {
113
99
  const errorMessage = (error as Error).message ?? 'Unknown error';
114
100
  throw new Error(`Failed to list Pods in namespace ${namespace}: ${errorMessage}`);
115
101
  }
116
102
  }
117
103
 
118
- async listConfigMaps(namespace: string = "default"): Promise<any> {
104
+ async listConfigMaps(namespace: string = "default"): Promise<k8s.V1ConfigMapList> {
119
105
  try {
120
106
  const response = await this.k8sApi.listNamespacedConfigMap({ namespace });
121
- printConfigMapsTable(response);
122
- // logger.debug(`List ConfigMaps: ${JSON.stringify(response)}`);
123
- return response;
107
+ response.items = response.items?.filter(cm => cm.metadata?.name?.startsWith('archive-'));
108
+ return response as k8s.V1ConfigMapList;
124
109
  } catch (error) {
125
110
  const errorMessage = (error as Error).message ?? 'Unknown error';
126
111
  throw new Error(`Failed to list ConfigMaps in namespace ${namespace}: ${errorMessage}`);
127
112
  }
128
113
  }
114
+
115
+ private getTestRunCustomObjectParams(testRunName: string, namespace: string = "default") {
116
+ return {
117
+ group: "k6.io",
118
+ version: "v1alpha1",
119
+ namespace,
120
+ plural: "testruns",
121
+ name: testRunName,
122
+ };
123
+ }
124
+
125
+ async deleteTestRunByName(testRunName: string, namespace: string = "default"): Promise<void> {
126
+ try {
127
+ await this.k8sCustomApi.deleteNamespacedCustomObject(this.getTestRunCustomObjectParams(testRunName, namespace));
128
+ logger.info(`TestRun ${testRunName} deleted from namespace ${namespace}`);
129
+ } catch (error) {
130
+ const errorMessage = (error as Error).message ?? 'Unknown error';
131
+ throw new Error(`Failed to delete TestRun ${testRunName} from namespace ${namespace}: ${errorMessage}`);
132
+ }
133
+ }
134
+
135
+ async deletePodByName(podName: string, namespace: string = "default"): Promise<void> {
136
+ try {
137
+ await this.k8sApi.deleteNamespacedPod({ name: podName, namespace });
138
+ logger.info(`Pod ${podName} deleted from namespace ${namespace}`);
139
+ } catch (error) {
140
+ const errorMessage = (error as Error).message ?? 'Unknown error';
141
+ throw new Error(`Failed to delete Pod ${podName} from namespace ${namespace}: ${errorMessage}`);
142
+ }
143
+ }
144
+
145
+ async getTestRun(testRunName: string, namespace: string = "default"): Promise<TestRunManifest> {
146
+ try {
147
+ const response = await this.k8sCustomApi.getNamespacedCustomObject(this.getTestRunCustomObjectParams(testRunName, namespace));
148
+ return response as TestRunManifest;
149
+ } catch (error) {
150
+ const errorMessage = (error as Error).message ?? 'Unknown error';
151
+ throw new Error(`Failed to get TestRun ${testRunName} in namespace ${namespace}: ${errorMessage}`);
152
+ }
153
+ }
154
+
155
+ async getPodsForTestRun(testRunName: string, namespace: string = "default"): Promise<k8s.V1PodList> {
156
+ try {
157
+ const response = await this.k8sApi.listNamespacedPod({
158
+ namespace,
159
+ labelSelector: `k6_cr=${testRunName}`,
160
+ });
161
+ return response as k8s.V1PodList;
162
+ } catch (error) {
163
+ const errorMessage = (error as Error).message ?? 'Unknown error';
164
+ throw new Error(`Failed to get pods for TestRun ${testRunName} in namespace ${namespace}: ${errorMessage}`);
165
+ }
166
+ }
167
+
168
+ async getPodLogs(podName: string, namespace: string = "default", container?: string): Promise<string> {
169
+ try {
170
+ const response = await this.k8sApi.readNamespacedPodLog({
171
+ name: podName,
172
+ namespace,
173
+ ...(container ? { container } : {}),
174
+ });
175
+ return response;
176
+ } catch (error) {
177
+ const errorMessage = (error as Error).message ?? 'Unknown error';
178
+ throw new Error(`Failed to get logs for pod ${podName} in namespace ${namespace}: ${errorMessage}`);
179
+ }
180
+ }
129
181
  }
130
182
 
131
183
  function fmt(v: any) {
@@ -231,7 +283,6 @@ export function printTestRunsTable(testRuns: TestRunManifest[]): void {
231
283
  const separate = tr.spec?.separate ?? "N/A";
232
284
  const quiet = tr.spec?.quiet ?? "N/A";
233
285
  const age = (tr as any)?.metadata?.creationTimestamp ?? "N/A";
234
- // const age = ageSince((tr as any)?.metadata?.creationTimestamp) || "N/A";
235
286
  return [[name, namespace, parallelism, cleanup, separate, quiet, age]];
236
287
  }
237
288
  });
@@ -0,0 +1,7 @@
1
+ export interface LastRunState {
2
+ testRunName: string;
3
+ namespace: string;
4
+ configMapName: string;
5
+ scriptPath: string;
6
+ createdAt: string;
7
+ }
@@ -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
@@ -0,0 +1,36 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import logger from './logger';
4
+ import type { LastRunState } from '../types/lastRun.types';
5
+
6
+ const LAST_RUN_FILE = '.k6ctl-last-run.json';
7
+
8
+ function getFilePath(): string {
9
+ return path.join(process.cwd(), LAST_RUN_FILE);
10
+ }
11
+
12
+ export async function saveLastRun(state: LastRunState): Promise<void> {
13
+ const filePath = getFilePath();
14
+ await fs.writeFile(filePath, JSON.stringify(state, null, 2), 'utf8');
15
+ logger.debug(`Last run state saved to ${filePath}`);
16
+ }
17
+
18
+ export async function loadLastRun(): Promise<LastRunState | null> {
19
+ const filePath = getFilePath();
20
+ try {
21
+ const content = await fs.readFile(filePath, 'utf8');
22
+ return JSON.parse(content) as LastRunState;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function clearLastRun(): Promise<void> {
29
+ const filePath = getFilePath();
30
+ try {
31
+ await fs.unlink(filePath);
32
+ logger.debug(`Last run state cleared (${filePath})`);
33
+ } catch {
34
+ logger.info('No last run state to clear.');
35
+ }
36
+ }
@@ -3,7 +3,9 @@ import type { ArchivedFile, ConfigMapResult } 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,35 @@ 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
+ }
@@ -1,7 +1,6 @@
1
- import { afterAll, describe, expect, test } from '@jest/globals';
1
+ import { describe, expect, test } from '@jest/globals';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
- import { unlink } from 'node:fs/promises';
5
4
  import { createDefaultKubernetesService } from '../../src/services/kubernetes.service';
6
5
  import { ScriptService } from '../../src/services/script.service';
7
6
  import { ConfigMapResult } from '../../src/types/kubernetes.types';
@@ -11,6 +10,11 @@ import { TestRunManifest } from '../../src/types/testRunManifest.types';
11
10
  import { K6Config } from '../../src/types/config.types';
12
11
  import { loadK6Config } from '../../src/utils/configLoader';
13
12
  import { buildTestRunManifest } from '../../src/utils/testRunManifestBuilder';
13
+ import { saveLastRun } from '../../src/utils/lastRunStore';
14
+ import { status } from '../../src/commands/status';
15
+ import { logs } from '../../src/commands/logs';
16
+ import { deleteLastRun } from '../../src/commands/delete';
17
+
14
18
 
15
19
  const samplesPath = resolve(__dirname, '..', 'samples');
16
20
  const scriptSample1 = join(samplesPath, 'k6_script_sample_1.js');
@@ -24,18 +28,6 @@ let archiveOutput: ArchiveResult;
24
28
  let configMapResult: ConfigMapResult;
25
29
 
26
30
  describe('KubernetesService integration tests', () => {
27
- afterAll(async () => {
28
- // Clean up archived files after tests
29
- await Promise.all(
30
- archivedFiles.map(file => unlink(file).catch(error => console.error(`Error deleting file ${file}:`, error)))
31
- );
32
-
33
- // Clean up created config maps in Kubernetes
34
- await Promise.all(
35
- configMaps.map(cm => kubernetesService.deleteConfigMap(cm.configMapName, cm.namespace)
36
- .catch(error => console.error(`Error deleting config map ${cm.configMapName}:`, error)))
37
- );
38
- });
39
31
 
40
32
  test('create config map from archived script', async () => {
41
33
  archiveOutput = await scriptService.archiveTest(scriptSample2);
@@ -56,15 +48,26 @@ describe('KubernetesService integration tests', () => {
56
48
  expect(response).toBeDefined();
57
49
  logger.info("TestRun result:", JSON.stringify(response));
58
50
 
51
+ // Persist last run state for use by logs/status/delete commands
52
+ await saveLastRun({
53
+ testRunName: testRunManifest.metadata.name,
54
+ namespace: testRunManifest.metadata.namespace,
55
+ configMapName: configMapResult.configMapName,
56
+ scriptPath: archiveOutput.archivePath,
57
+ createdAt: new Date().toISOString(),
58
+ });
59
+ logger.info(`Last run saved: ${testRunManifest.metadata.name} (namespace: ${testRunManifest.metadata.namespace})`);
60
+
59
61
  // Wait for some time to allow the TestRun to be created and start running
60
- await new Promise((resolve) => setTimeout(resolve, 40000));
62
+ await new Promise((resolve) => setTimeout(resolve, 60000));
63
+
64
+ // Check status command output
65
+ await status({ namespace: testRunManifest.metadata.namespace });
61
66
 
62
- // List TestRuns, Pods, and ConfigMaps to verify they are created and running
63
- await kubernetesService.listTestRuns();
64
- await kubernetesService.listPods();
65
- await kubernetesService.listConfigMaps();
67
+ // Check logs command output
68
+ await logs({ namespace: testRunManifest.metadata.namespace });
66
69
 
67
70
  // Clean up the TestRun after successful creation and execution
68
- await kubernetesService.deleteTestRun(testRunManifest);
71
+ await deleteLastRun({ namespace: testRunManifest.metadata.namespace });
69
72
  }, 120000);
70
73
  });
@@ -10,6 +10,7 @@ jest.mock('fs', () => ({
10
10
  promises: {
11
11
  stat: jest.fn(),
12
12
  readFile: jest.fn(),
13
+ unlink: jest.fn(),
13
14
  },
14
15
  }));
15
16
 
@@ -49,6 +50,7 @@ jest.mock('../../src/utils/logger', () => ({
49
50
  const mockedExistsSync = existsSync as unknown as jest.Mock;
50
51
  const mockedStat = fs_promises.stat as unknown as jest.Mock;
51
52
  const mockedReadFile = fs_promises.readFile as unknown as jest.Mock;
53
+ const mockedUnlink = fs_promises.unlink as unknown as jest.MockedFunction<typeof fs_promises.unlink>;
52
54
  const mockedLoggerInfo = logger.info as unknown as jest.Mock;
53
55
 
54
56
  const mockedK8sModule = k8s as unknown as {
@@ -119,6 +121,7 @@ describe('KubernetesService', () => {
119
121
  });
120
122
 
121
123
  test('creates configmap with binaryData and returns namespace/name', async () => {
124
+ mockedUnlink.mockResolvedValue(undefined);
122
125
  mockedExistsSync.mockReturnValue(true);
123
126
  mockedStat.mockImplementation(async () => ({ size: 512 }));
124
127
  mockedReadFile.mockImplementation(async () => 'YmFzZTY0');