@appthrust/kest 0.2.0 → 0.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 +77 -13
- package/package.json +1 -1
- package/ts/actions/apply-namespace.ts +26 -3
- package/ts/actions/assert-absence.ts +36 -0
- package/ts/actions/delete.ts +1 -12
- package/ts/actions/kubectl-type.ts +19 -0
- package/ts/actions/label.ts +20 -0
- package/ts/apis/index.ts +243 -4
- package/ts/kubectl/index.ts +54 -0
- package/ts/scenario/index.ts +14 -2
package/README.md
CHANGED
|
@@ -71,6 +71,13 @@ const ns = await s.newNamespace();
|
|
|
71
71
|
// All resources applied through `ns` are scoped to this namespace.
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
You can also specify a custom prefix for the generated namespace name using `generateName`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const ns = await s.newNamespace({ generateName: "foo-" });
|
|
78
|
+
// Namespace name will be like "foo-d7kpn"
|
|
79
|
+
```
|
|
80
|
+
|
|
74
81
|
### Automatic Cleanup (Reverse-Order, Blocking)
|
|
75
82
|
|
|
76
83
|
Resources are deleted in the reverse order they were created (LIFO). Kest waits until each resource is fully removed before proceeding, preventing flaky failures caused by lingering resources or `Terminating` namespaces.
|
|
@@ -197,6 +204,61 @@ await ns.assertList<ConfigMap>({
|
|
|
197
204
|
});
|
|
198
205
|
```
|
|
199
206
|
|
|
207
|
+
### Absence Assertions
|
|
208
|
+
|
|
209
|
+
Assert that a resource does not exist (e.g. after deletion or to verify a controller hasn't created something):
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
await ns.assertAbsence({
|
|
213
|
+
apiVersion: "v1",
|
|
214
|
+
kind: "ConfigMap",
|
|
215
|
+
name: "deleted-config",
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
With retry-based polling to wait for a resource to disappear:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
await ns.assertAbsence(
|
|
223
|
+
{
|
|
224
|
+
apiVersion: "apps/v1",
|
|
225
|
+
kind: "Deployment",
|
|
226
|
+
name: "my-app",
|
|
227
|
+
},
|
|
228
|
+
{ timeout: "30s", interval: "1s" },
|
|
229
|
+
);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Label Resources
|
|
233
|
+
|
|
234
|
+
Add, update, or remove labels on Kubernetes resources using `kubectl label`:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
await ns.label({
|
|
238
|
+
apiVersion: "v1",
|
|
239
|
+
kind: "ConfigMap",
|
|
240
|
+
name: "my-config",
|
|
241
|
+
labels: {
|
|
242
|
+
env: "production", // add a label
|
|
243
|
+
deprecated: null, // remove a label
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
To overwrite an existing label, set `overwrite: true`:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
await ns.label({
|
|
252
|
+
apiVersion: "apps/v1",
|
|
253
|
+
kind: "Deployment",
|
|
254
|
+
name: "my-app",
|
|
255
|
+
labels: {
|
|
256
|
+
version: "v2",
|
|
257
|
+
},
|
|
258
|
+
overwrite: true,
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
200
262
|
### Shell Command Execution
|
|
201
263
|
|
|
202
264
|
Run arbitrary shell commands with optional revert handlers for cleanup:
|
|
@@ -333,22 +395,24 @@ Entry point for defining a test scenario. The callback receives a `Scenario` obj
|
|
|
333
395
|
|
|
334
396
|
The top-level API surface available in every test callback.
|
|
335
397
|
|
|
336
|
-
| Method | Description
|
|
337
|
-
| ----------------------------------------------------------------------- |
|
|
338
|
-
| `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup
|
|
339
|
-
| `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply)
|
|
340
|
-
| `delete(resource, options?)` | Delete a resource by API version, kind, and name
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
343
|
-
| `
|
|
344
|
-
| `
|
|
345
|
-
| `
|
|
346
|
-
| `
|
|
347
|
-
| `
|
|
398
|
+
| Method | Description |
|
|
399
|
+
| ----------------------------------------------------------------------- | ------------------------------------------------- |
|
|
400
|
+
| `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
|
|
401
|
+
| `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
|
|
402
|
+
| `delete(resource, options?)` | Delete a resource by API version, kind, and name |
|
|
403
|
+
| `label(input, options?)` | Add, update, or remove labels on a resource |
|
|
404
|
+
| `get(resource, options?)` | Fetch a resource by API version, kind, and name |
|
|
405
|
+
| `assert(resource, options?)` | Fetch a resource and run assertions with retries |
|
|
406
|
+
| `assertAbsence(resource, options?)` | Assert that a resource does not exist |
|
|
407
|
+
| `assertList(resource, options?)` | Fetch a list of resources and run assertions |
|
|
408
|
+
| `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
|
|
409
|
+
| `exec(input, options?)` | Execute shell commands with optional revert |
|
|
410
|
+
| `useCluster(ref)` | Create a cluster-bound API surface |
|
|
411
|
+
| `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
|
|
348
412
|
|
|
349
413
|
### Namespace / Cluster
|
|
350
414
|
|
|
351
|
-
Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `applyStatus`, `delete`, `get`, `assert`, `assertList`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
|
|
415
|
+
Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
|
|
352
416
|
|
|
353
417
|
### Action Options
|
|
354
418
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import { apply } from "./apply";
|
|
2
2
|
import type { MutateDef } from "./types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Input for namespace creation.
|
|
6
|
+
*
|
|
7
|
+
* - `undefined` -- auto-generate a name like `kest-{random}`.
|
|
8
|
+
* - `string` -- use the exact name provided.
|
|
9
|
+
* - `{ generateName: string }` -- use the string as a prefix followed by
|
|
10
|
+
* random characters (e.g. `{ generateName: "foo-" }` → `"foo-d7kpn"`).
|
|
11
|
+
*/
|
|
12
|
+
export type ApplyNamespaceInput =
|
|
13
|
+
| undefined
|
|
14
|
+
| string
|
|
15
|
+
| { readonly generateName: string };
|
|
16
|
+
|
|
4
17
|
export const applyNamespace = {
|
|
5
18
|
type: "mutate",
|
|
6
19
|
name: "ApplyNamespace",
|
|
7
20
|
mutate:
|
|
8
21
|
({ kubectl }) =>
|
|
9
|
-
async (
|
|
10
|
-
const name =
|
|
22
|
+
async (input) => {
|
|
23
|
+
const name = resolveNamespaceName(input);
|
|
11
24
|
const { revert } = await apply.mutate({ kubectl })({
|
|
12
25
|
apiVersion: "v1",
|
|
13
26
|
kind: "Namespace",
|
|
@@ -17,7 +30,17 @@ export const applyNamespace = {
|
|
|
17
30
|
});
|
|
18
31
|
return { revert, output: name };
|
|
19
32
|
},
|
|
20
|
-
} satisfies MutateDef<
|
|
33
|
+
} satisfies MutateDef<ApplyNamespaceInput, string>;
|
|
34
|
+
|
|
35
|
+
function resolveNamespaceName(input: ApplyNamespaceInput): string {
|
|
36
|
+
if (input === undefined) {
|
|
37
|
+
return `kest-${randomConsonantDigits(5)}`;
|
|
38
|
+
}
|
|
39
|
+
if (typeof input === "string") {
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
return `${input.generateName}${randomConsonantDigits(5)}`;
|
|
43
|
+
}
|
|
21
44
|
|
|
22
45
|
function randomConsonantDigits(length = 8): string {
|
|
23
46
|
const chars = "bcdfghjklmnpqrstvwxyz0123456789";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { K8sResource, K8sResourceReference } from "../apis";
|
|
2
|
+
import { toKubectlType } from "./kubectl-type";
|
|
3
|
+
import type { Deps, QueryDef } from "./types";
|
|
4
|
+
|
|
5
|
+
export const assertAbsence = {
|
|
6
|
+
type: "query",
|
|
7
|
+
name: "AssertAbsence",
|
|
8
|
+
query:
|
|
9
|
+
({ kubectl }: Deps) =>
|
|
10
|
+
async <T extends K8sResource>(
|
|
11
|
+
resource: K8sResourceReference<T>
|
|
12
|
+
): Promise<void> => {
|
|
13
|
+
try {
|
|
14
|
+
await kubectl.get(toKubectlType(resource), resource.name);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (isNotFoundError(error)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Expected ${resource.kind} "${resource.name}" to be absent, but it exists`
|
|
23
|
+
);
|
|
24
|
+
},
|
|
25
|
+
} satisfies QueryDef<K8sResourceReference, void>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Checks whether a kubectl error is a "NotFound" error.
|
|
29
|
+
*
|
|
30
|
+
* kubectl outputs `Error from server (NotFound):` when the resource does not
|
|
31
|
+
* exist, and the {@link RealKubectl} wrapper embeds that message in the
|
|
32
|
+
* thrown `Error`.
|
|
33
|
+
*/
|
|
34
|
+
function isNotFoundError(error: unknown): boolean {
|
|
35
|
+
return error instanceof Error && error.message.includes("(NotFound)");
|
|
36
|
+
}
|
package/ts/actions/delete.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { K8sResource, K8sResourceReference } from "../apis";
|
|
2
|
+
import { toKubectlType } from "./kubectl-type";
|
|
2
3
|
import type { OneWayMutateDef } from "./types";
|
|
3
4
|
|
|
4
5
|
export const deleteResource = {
|
|
@@ -11,15 +12,3 @@ export const deleteResource = {
|
|
|
11
12
|
return undefined;
|
|
12
13
|
},
|
|
13
14
|
} satisfies OneWayMutateDef<K8sResourceReference, void>;
|
|
14
|
-
|
|
15
|
-
function toKubectlType<T extends K8sResource>(
|
|
16
|
-
resource: K8sResourceReference<T>
|
|
17
|
-
): string {
|
|
18
|
-
const { kind, apiVersion } = resource;
|
|
19
|
-
const [group, version] = apiVersion.split("/");
|
|
20
|
-
if (version === undefined) {
|
|
21
|
-
// core group cannot include version in the type
|
|
22
|
-
return kind;
|
|
23
|
-
}
|
|
24
|
-
return [kind, version, group].filter(Boolean).join(".");
|
|
25
|
-
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts an `apiVersion` + `kind` pair into the resource type string
|
|
3
|
+
* expected by kubectl subcommands (e.g. `get`, `delete`, `label`).
|
|
4
|
+
*
|
|
5
|
+
* - Core-group resources (`apiVersion: "v1"`) → `"ConfigMap"`
|
|
6
|
+
* - Non-core resources (`apiVersion: "apps/v1"`) → `"Deployment.v1.apps"`
|
|
7
|
+
*/
|
|
8
|
+
export function toKubectlType(resource: {
|
|
9
|
+
readonly apiVersion: string;
|
|
10
|
+
readonly kind: string;
|
|
11
|
+
}): string {
|
|
12
|
+
const { kind, apiVersion } = resource;
|
|
13
|
+
const [group, version] = apiVersion.split("/");
|
|
14
|
+
if (version === undefined) {
|
|
15
|
+
// core group cannot include version in the type
|
|
16
|
+
return kind;
|
|
17
|
+
}
|
|
18
|
+
return [kind, version, group].filter(Boolean).join(".");
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { K8sResource, LabelInput } from "../apis";
|
|
2
|
+
import { toKubectlType } from "./kubectl-type";
|
|
3
|
+
import type { OneWayMutateDef } from "./types";
|
|
4
|
+
|
|
5
|
+
export const label = {
|
|
6
|
+
type: "oneWayMutate",
|
|
7
|
+
name: "Label",
|
|
8
|
+
mutate:
|
|
9
|
+
({ kubectl }) =>
|
|
10
|
+
async <T extends K8sResource>(input: LabelInput<T>) => {
|
|
11
|
+
const overrideContext = input.namespace
|
|
12
|
+
? { namespace: input.namespace }
|
|
13
|
+
: undefined;
|
|
14
|
+
await kubectl.label(toKubectlType(input), input.name, input.labels, {
|
|
15
|
+
overwrite: input.overwrite,
|
|
16
|
+
context: overrideContext,
|
|
17
|
+
});
|
|
18
|
+
return undefined;
|
|
19
|
+
},
|
|
20
|
+
} satisfies OneWayMutateDef<LabelInput, void>;
|
package/ts/apis/index.ts
CHANGED
|
@@ -114,6 +114,38 @@ export interface Scenario {
|
|
|
114
114
|
options?: undefined | ActionOptions
|
|
115
115
|
): Promise<void>;
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Adds, updates, or removes labels on a Kubernetes resource using
|
|
119
|
+
* `kubectl label`.
|
|
120
|
+
*
|
|
121
|
+
* Set a label value to a string to add/update it, or to `null` to remove it.
|
|
122
|
+
*
|
|
123
|
+
* This action is retried when it throws.
|
|
124
|
+
*
|
|
125
|
+
* Note: this is a one-way mutation and does not register a cleanup handler.
|
|
126
|
+
*
|
|
127
|
+
* @template T - The expected Kubernetes resource shape.
|
|
128
|
+
* @param input - Resource reference and label changes.
|
|
129
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* await s.label({
|
|
134
|
+
* apiVersion: "v1",
|
|
135
|
+
* kind: "ConfigMap",
|
|
136
|
+
* name: "my-config",
|
|
137
|
+
* labels: {
|
|
138
|
+
* env: "production", // add or update
|
|
139
|
+
* deprecated: null, // remove
|
|
140
|
+
* },
|
|
141
|
+
* });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
label<T extends K8sResource>(
|
|
145
|
+
input: LabelInput<T>,
|
|
146
|
+
options?: undefined | ActionOptions
|
|
147
|
+
): Promise<void>;
|
|
148
|
+
|
|
117
149
|
/**
|
|
118
150
|
* Fetches a Kubernetes resource and returns it as a typed object.
|
|
119
151
|
*
|
|
@@ -176,6 +208,32 @@ export interface Scenario {
|
|
|
176
208
|
options?: undefined | ActionOptions
|
|
177
209
|
): Promise<T>;
|
|
178
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Asserts that a Kubernetes resource does not exist.
|
|
213
|
+
*
|
|
214
|
+
* Internally, this uses `kubectl get` and expects it to fail with a
|
|
215
|
+
* "not found" error. If the resource exists, the assertion fails.
|
|
216
|
+
*
|
|
217
|
+
* This action is retried until the resource is absent or a timeout expires.
|
|
218
|
+
*
|
|
219
|
+
* @template T - The expected Kubernetes resource shape.
|
|
220
|
+
* @param resource - Group/version/kind and name of the resource.
|
|
221
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```ts
|
|
225
|
+
* await s.assertAbsence({
|
|
226
|
+
* apiVersion: "v1",
|
|
227
|
+
* kind: "ConfigMap",
|
|
228
|
+
* name: "deleted-config",
|
|
229
|
+
* });
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
assertAbsence<T extends K8sResource>(
|
|
233
|
+
resource: K8sResourceReference<T>,
|
|
234
|
+
options?: undefined | ActionOptions
|
|
235
|
+
): Promise<void>;
|
|
236
|
+
|
|
179
237
|
/**
|
|
180
238
|
* Lists Kubernetes resources of a given type and runs a test function.
|
|
181
239
|
*
|
|
@@ -212,7 +270,11 @@ export interface Scenario {
|
|
|
212
270
|
* `kest-abc12`). The namespace creation is a mutating action that registers a
|
|
213
271
|
* cleanup handler; the namespace is deleted during scenario cleanup.
|
|
214
272
|
*
|
|
215
|
-
*
|
|
273
|
+
* You can also pass `{ generateName: "prefix-" }` to generate a name with a
|
|
274
|
+
* custom prefix (e.g. `"prefix-d7kpn"`).
|
|
275
|
+
*
|
|
276
|
+
* @param name - Optional namespace name, or `{ generateName }` for prefixed
|
|
277
|
+
* generation.
|
|
216
278
|
* @param options - Retry options such as timeout and polling interval.
|
|
217
279
|
*
|
|
218
280
|
* @example
|
|
@@ -225,9 +287,15 @@ export interface Scenario {
|
|
|
225
287
|
* data: { mode: "namespaced" },
|
|
226
288
|
* });
|
|
227
289
|
* ```
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* // Generate a namespace with a custom prefix (e.g. "foo-d7kpn")
|
|
294
|
+
* const ns = await s.newNamespace({ generateName: "foo-" });
|
|
295
|
+
* ```
|
|
228
296
|
*/
|
|
229
297
|
newNamespace(
|
|
230
|
-
name?: undefined | string,
|
|
298
|
+
name?: undefined | string | { readonly generateName: string },
|
|
231
299
|
options?: undefined | ActionOptions
|
|
232
300
|
): Promise<Namespace>;
|
|
233
301
|
|
|
@@ -412,6 +480,38 @@ export interface Cluster {
|
|
|
412
480
|
options?: undefined | ActionOptions
|
|
413
481
|
): Promise<void>;
|
|
414
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Adds, updates, or removes labels on a Kubernetes resource in this cluster
|
|
485
|
+
* using `kubectl label`.
|
|
486
|
+
*
|
|
487
|
+
* Set a label value to a string to add/update it, or to `null` to remove it.
|
|
488
|
+
*
|
|
489
|
+
* This action is retried when it throws.
|
|
490
|
+
*
|
|
491
|
+
* Note: this is a one-way mutation and does not register a cleanup handler.
|
|
492
|
+
*
|
|
493
|
+
* @template T - The expected Kubernetes resource shape.
|
|
494
|
+
* @param input - Resource reference and label changes.
|
|
495
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* ```ts
|
|
499
|
+
* await cluster.label({
|
|
500
|
+
* apiVersion: "v1",
|
|
501
|
+
* kind: "Namespace",
|
|
502
|
+
* name: "my-team",
|
|
503
|
+
* labels: {
|
|
504
|
+
* team: "backend",
|
|
505
|
+
* deprecated: null,
|
|
506
|
+
* },
|
|
507
|
+
* });
|
|
508
|
+
* ```
|
|
509
|
+
*/
|
|
510
|
+
label<T extends K8sResource>(
|
|
511
|
+
input: LabelInput<T>,
|
|
512
|
+
options?: undefined | ActionOptions
|
|
513
|
+
): Promise<void>;
|
|
514
|
+
|
|
415
515
|
/**
|
|
416
516
|
* Fetches a Kubernetes resource by GVK and name.
|
|
417
517
|
*
|
|
@@ -457,6 +557,27 @@ export interface Cluster {
|
|
|
457
557
|
options?: undefined | ActionOptions
|
|
458
558
|
): Promise<T>;
|
|
459
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Asserts that a Kubernetes resource does not exist in this cluster.
|
|
562
|
+
*
|
|
563
|
+
* @template T - The expected Kubernetes resource shape.
|
|
564
|
+
* @param resource - Group/version/kind and name of the resource.
|
|
565
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* ```ts
|
|
569
|
+
* await cluster.assertAbsence({
|
|
570
|
+
* apiVersion: "v1",
|
|
571
|
+
* kind: "Namespace",
|
|
572
|
+
* name: "deleted-ns",
|
|
573
|
+
* });
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
assertAbsence<T extends K8sResource>(
|
|
577
|
+
resource: K8sResourceReference<T>,
|
|
578
|
+
options?: undefined | ActionOptions
|
|
579
|
+
): Promise<void>;
|
|
580
|
+
|
|
460
581
|
/**
|
|
461
582
|
* Lists Kubernetes resources of a given type and runs a test function.
|
|
462
583
|
*
|
|
@@ -483,7 +604,8 @@ export interface Cluster {
|
|
|
483
604
|
/**
|
|
484
605
|
* Creates a new namespace in this cluster and returns a namespaced API.
|
|
485
606
|
*
|
|
486
|
-
* @param name - Optional namespace name
|
|
607
|
+
* @param name - Optional namespace name, or `{ generateName }` for prefixed
|
|
608
|
+
* generation.
|
|
487
609
|
* @param options - Retry options such as timeout and polling interval.
|
|
488
610
|
*
|
|
489
611
|
* @example
|
|
@@ -496,9 +618,15 @@ export interface Cluster {
|
|
|
496
618
|
* data: { mode: "from-cluster" },
|
|
497
619
|
* });
|
|
498
620
|
* ```
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
* ```ts
|
|
624
|
+
* // Generate a namespace with a custom prefix
|
|
625
|
+
* const ns = await cluster.newNamespace({ generateName: "foo-" });
|
|
626
|
+
* ```
|
|
499
627
|
*/
|
|
500
628
|
newNamespace(
|
|
501
|
-
name?: undefined | string,
|
|
629
|
+
name?: undefined | string | { readonly generateName: string },
|
|
502
630
|
options?: undefined | ActionOptions
|
|
503
631
|
): Promise<Namespace>;
|
|
504
632
|
}
|
|
@@ -522,6 +650,11 @@ export interface Cluster {
|
|
|
522
650
|
* causes `kubectl` to fail.
|
|
523
651
|
*/
|
|
524
652
|
export interface Namespace {
|
|
653
|
+
/**
|
|
654
|
+
* The name of this namespace (e.g. `"kest-abc12"`).
|
|
655
|
+
*/
|
|
656
|
+
readonly name: string;
|
|
657
|
+
|
|
525
658
|
/**
|
|
526
659
|
* Applies a Kubernetes manifest in this namespace and registers cleanup.
|
|
527
660
|
*
|
|
@@ -592,6 +725,40 @@ export interface Namespace {
|
|
|
592
725
|
options?: undefined | ActionOptions
|
|
593
726
|
): Promise<void>;
|
|
594
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Adds, updates, or removes labels on a namespaced Kubernetes resource using
|
|
730
|
+
* `kubectl label`.
|
|
731
|
+
*
|
|
732
|
+
* The target namespace is controlled by this {@link Namespace} instance.
|
|
733
|
+
*
|
|
734
|
+
* Set a label value to a string to add/update it, or to `null` to remove it.
|
|
735
|
+
*
|
|
736
|
+
* This action is retried when it throws.
|
|
737
|
+
*
|
|
738
|
+
* Note: this is a one-way mutation and does not register a cleanup handler.
|
|
739
|
+
*
|
|
740
|
+
* @template T - The expected Kubernetes resource shape.
|
|
741
|
+
* @param input - Resource reference and label changes.
|
|
742
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
743
|
+
*
|
|
744
|
+
* @example
|
|
745
|
+
* ```ts
|
|
746
|
+
* await ns.label({
|
|
747
|
+
* apiVersion: "v1",
|
|
748
|
+
* kind: "ConfigMap",
|
|
749
|
+
* name: "my-config",
|
|
750
|
+
* labels: {
|
|
751
|
+
* env: "production",
|
|
752
|
+
* deprecated: null,
|
|
753
|
+
* },
|
|
754
|
+
* });
|
|
755
|
+
* ```
|
|
756
|
+
*/
|
|
757
|
+
label<T extends K8sResource>(
|
|
758
|
+
input: LabelInput<T>,
|
|
759
|
+
options?: undefined | ActionOptions
|
|
760
|
+
): Promise<void>;
|
|
761
|
+
|
|
595
762
|
/**
|
|
596
763
|
* Fetches a namespaced Kubernetes resource by GVK and name.
|
|
597
764
|
*
|
|
@@ -637,6 +804,27 @@ export interface Namespace {
|
|
|
637
804
|
options?: undefined | ActionOptions
|
|
638
805
|
): Promise<T>;
|
|
639
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Asserts that a namespaced Kubernetes resource does not exist.
|
|
809
|
+
*
|
|
810
|
+
* @template T - The expected Kubernetes resource shape.
|
|
811
|
+
* @param resource - Group/version/kind and name of the resource.
|
|
812
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
813
|
+
*
|
|
814
|
+
* @example
|
|
815
|
+
* ```ts
|
|
816
|
+
* await ns.assertAbsence({
|
|
817
|
+
* apiVersion: "v1",
|
|
818
|
+
* kind: "ConfigMap",
|
|
819
|
+
* name: "deleted-config",
|
|
820
|
+
* });
|
|
821
|
+
* ```
|
|
822
|
+
*/
|
|
823
|
+
assertAbsence<T extends K8sResource>(
|
|
824
|
+
resource: K8sResourceReference<T>,
|
|
825
|
+
options?: undefined | ActionOptions
|
|
826
|
+
): Promise<void>;
|
|
827
|
+
|
|
640
828
|
/**
|
|
641
829
|
* Lists namespaced Kubernetes resources of a given type and runs a test.
|
|
642
830
|
*
|
|
@@ -737,6 +925,57 @@ export interface K8sResourceReference<T extends K8sResource = K8sResource> {
|
|
|
737
925
|
readonly name: string;
|
|
738
926
|
}
|
|
739
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Input for {@link Scenario.label}, {@link Cluster.label}, and
|
|
930
|
+
* {@link Namespace.label}.
|
|
931
|
+
*
|
|
932
|
+
* Identifies a Kubernetes resource and the label changes to apply.
|
|
933
|
+
*
|
|
934
|
+
* - A label value of `string` adds or updates the label.
|
|
935
|
+
* - A label value of `null` removes the label.
|
|
936
|
+
*/
|
|
937
|
+
export interface LabelInput<T extends K8sResource = K8sResource> {
|
|
938
|
+
/**
|
|
939
|
+
* Kubernetes API version (e.g. `"v1"`, `"apps/v1"`).
|
|
940
|
+
*/
|
|
941
|
+
readonly apiVersion: T["apiVersion"];
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Kubernetes kind (e.g. `"ConfigMap"`, `"Deployment"`).
|
|
945
|
+
*/
|
|
946
|
+
readonly kind: T["kind"];
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* `metadata.name` of the target resource.
|
|
950
|
+
*/
|
|
951
|
+
readonly name: string;
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Optional namespace override.
|
|
955
|
+
*
|
|
956
|
+
* When used on a {@link Namespace}-scoped API surface the namespace is
|
|
957
|
+
* already set; this field is mainly useful at the {@link Scenario} or
|
|
958
|
+
* {@link Cluster} level for namespaced resources.
|
|
959
|
+
*/
|
|
960
|
+
readonly namespace?: undefined | string;
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Label mutations to apply.
|
|
964
|
+
*
|
|
965
|
+
* - `"value"` -- add or update the label to the given value.
|
|
966
|
+
* - `null` -- remove the label.
|
|
967
|
+
*/
|
|
968
|
+
readonly labels: Readonly<Record<string, string | null>>;
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* When `true`, passes `--overwrite` to allow updating labels that already
|
|
972
|
+
* exist on the resource.
|
|
973
|
+
*
|
|
974
|
+
* @default false
|
|
975
|
+
*/
|
|
976
|
+
readonly overwrite?: undefined | boolean;
|
|
977
|
+
}
|
|
978
|
+
|
|
740
979
|
/**
|
|
741
980
|
* A test definition for {@link Scenario.assert}.
|
|
742
981
|
*/
|
package/ts/kubectl/index.ts
CHANGED
|
@@ -135,6 +135,26 @@ export interface Kubectl {
|
|
|
135
135
|
name: string,
|
|
136
136
|
options?: KubectlDeleteOptions
|
|
137
137
|
): Promise<string>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Adds, updates, or removes labels on a Kubernetes resource using
|
|
141
|
+
* `kubectl label <resource>/<name> key=value ... [--overwrite]`.
|
|
142
|
+
*
|
|
143
|
+
* Labels with a `null` value are removed (emitted as `key-`).
|
|
144
|
+
*
|
|
145
|
+
* @param resource - The resource type (e.g., "configmap", "deployment.v1.apps")
|
|
146
|
+
* @param name - The name of the resource to label
|
|
147
|
+
* @param labels - Label mutations (string to set, null to remove)
|
|
148
|
+
* @param options - Optional label options (overwrite, context)
|
|
149
|
+
* @returns The trimmed stdout from kubectl
|
|
150
|
+
* @throws Error if kubectl exits with non-zero code
|
|
151
|
+
*/
|
|
152
|
+
label(
|
|
153
|
+
resource: string,
|
|
154
|
+
name: string,
|
|
155
|
+
labels: Readonly<Record<string, string | null>>,
|
|
156
|
+
options?: KubectlLabelOptions
|
|
157
|
+
): Promise<string>;
|
|
138
158
|
}
|
|
139
159
|
|
|
140
160
|
export interface KubectlDeleteOptions {
|
|
@@ -146,6 +166,15 @@ export interface KubectlDeleteOptions {
|
|
|
146
166
|
readonly context?: undefined | KubectlContext;
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
export interface KubectlLabelOptions {
|
|
170
|
+
/**
|
|
171
|
+
* If true, adds `--overwrite` to allow updating labels that already
|
|
172
|
+
* exist on the resource.
|
|
173
|
+
*/
|
|
174
|
+
readonly overwrite?: undefined | boolean;
|
|
175
|
+
readonly context?: undefined | KubectlContext;
|
|
176
|
+
}
|
|
177
|
+
|
|
149
178
|
export class RealKubectl implements Kubectl {
|
|
150
179
|
private readonly recorder: Recorder;
|
|
151
180
|
private readonly cwd: undefined | string;
|
|
@@ -283,6 +312,31 @@ export class RealKubectl implements Kubectl {
|
|
|
283
312
|
});
|
|
284
313
|
}
|
|
285
314
|
|
|
315
|
+
async label(
|
|
316
|
+
resource: string,
|
|
317
|
+
name: string,
|
|
318
|
+
labels: Readonly<Record<string, string | null>>,
|
|
319
|
+
options?: KubectlLabelOptions
|
|
320
|
+
): Promise<string> {
|
|
321
|
+
const args: [string, ...Array<string>] = ["label", `${resource}/${name}`];
|
|
322
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
323
|
+
if (value === null) {
|
|
324
|
+
args.push(`${key}-`);
|
|
325
|
+
} else {
|
|
326
|
+
args.push(`${key}=${value}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (options?.overwrite) {
|
|
330
|
+
args.push("--overwrite");
|
|
331
|
+
}
|
|
332
|
+
return await this.runKubectl({
|
|
333
|
+
args,
|
|
334
|
+
stdoutLanguage: "text",
|
|
335
|
+
stderrLanguage: "text",
|
|
336
|
+
overrideContext: options?.context,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
286
340
|
private async runKubectl(params: ExecParams): Promise<string> {
|
|
287
341
|
const cmd = "kubectl";
|
|
288
342
|
const ctx = { ...this.defaultContext, ...params.overrideContext };
|
package/ts/scenario/index.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { apply } from "../actions/apply";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ApplyNamespaceInput,
|
|
4
|
+
applyNamespace,
|
|
5
|
+
} from "../actions/apply-namespace";
|
|
3
6
|
import { applyStatus } from "../actions/apply-status";
|
|
4
7
|
import { assert } from "../actions/assert";
|
|
8
|
+
import { assertAbsence } from "../actions/assert-absence";
|
|
5
9
|
import { assertList } from "../actions/assert-list";
|
|
6
10
|
import { deleteResource } from "../actions/delete";
|
|
7
11
|
import { exec } from "../actions/exec";
|
|
8
12
|
import { get } from "../actions/get";
|
|
13
|
+
import { label } from "../actions/label";
|
|
9
14
|
import type { MutateDef, OneWayMutateDef, QueryDef } from "../actions/types";
|
|
10
15
|
import type {
|
|
11
16
|
ActionOptions,
|
|
@@ -33,9 +38,11 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
|
|
|
33
38
|
apply: createMutateFn(deps, apply),
|
|
34
39
|
applyStatus: createOneWayMutateFn(deps, applyStatus),
|
|
35
40
|
delete: createOneWayMutateFn(deps, deleteResource),
|
|
41
|
+
label: createOneWayMutateFn(deps, label),
|
|
36
42
|
exec: createMutateFn(deps, exec),
|
|
37
43
|
get: createQueryFn(deps, get),
|
|
38
44
|
assert: createQueryFn(deps, assert),
|
|
45
|
+
assertAbsence: createQueryFn(deps, assertAbsence),
|
|
39
46
|
assertList: createQueryFn(deps, assertList),
|
|
40
47
|
given: bdd.given(deps),
|
|
41
48
|
when: bdd.when(deps),
|
|
@@ -182,7 +189,7 @@ const createQueryFn =
|
|
|
182
189
|
const createNewNamespaceFn =
|
|
183
190
|
(scenarioDeps: CreateScenarioOptions) =>
|
|
184
191
|
async (
|
|
185
|
-
name?:
|
|
192
|
+
name?: ApplyNamespaceInput,
|
|
186
193
|
options?: undefined | ActionOptions
|
|
187
194
|
): Promise<Namespace> => {
|
|
188
195
|
const namespaceName = await createMutateFn(scenarioDeps, applyNamespace)(
|
|
@@ -193,11 +200,14 @@ const createNewNamespaceFn =
|
|
|
193
200
|
const namespacedKubectl = kubectl.extends({ namespace: namespaceName });
|
|
194
201
|
const namespacedDeps = { ...scenarioDeps, kubectl: namespacedKubectl };
|
|
195
202
|
return {
|
|
203
|
+
name: namespaceName,
|
|
196
204
|
apply: createMutateFn(namespacedDeps, apply),
|
|
197
205
|
applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
|
|
198
206
|
delete: createOneWayMutateFn(namespacedDeps, deleteResource),
|
|
207
|
+
label: createOneWayMutateFn(namespacedDeps, label),
|
|
199
208
|
get: createQueryFn(namespacedDeps, get),
|
|
200
209
|
assert: createQueryFn(namespacedDeps, assert),
|
|
210
|
+
assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
|
|
201
211
|
assertList: createQueryFn(namespacedDeps, assertList),
|
|
202
212
|
};
|
|
203
213
|
};
|
|
@@ -216,8 +226,10 @@ const createUseClusterFn =
|
|
|
216
226
|
apply: createMutateFn(clusterDeps, apply),
|
|
217
227
|
applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
|
|
218
228
|
delete: createOneWayMutateFn(clusterDeps, deleteResource),
|
|
229
|
+
label: createOneWayMutateFn(clusterDeps, label),
|
|
219
230
|
get: createQueryFn(clusterDeps, get),
|
|
220
231
|
assert: createQueryFn(clusterDeps, assert),
|
|
232
|
+
assertAbsence: createQueryFn(clusterDeps, assertAbsence),
|
|
221
233
|
assertList: createQueryFn(clusterDeps, assertList),
|
|
222
234
|
newNamespace: createNewNamespaceFn(clusterDeps),
|
|
223
235
|
};
|