@appthrust/kest 0.1.2 → 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 -12
- 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 +14 -0
- package/ts/actions/kubectl-type.ts +19 -0
- package/ts/actions/label.ts +20 -0
- package/ts/apis/index.ts +302 -5
- package/ts/kubectl/index.ts +54 -0
- package/ts/scenario/index.ts +18 -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,21 +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
|
-
| `
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
343
|
-
| `
|
|
344
|
-
| `
|
|
345
|
-
| `
|
|
346
|
-
| `
|
|
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 |
|
|
347
412
|
|
|
348
413
|
### Namespace / Cluster
|
|
349
414
|
|
|
350
|
-
Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `applyStatus`, `get`, `assert`, `
|
|
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`.
|
|
351
416
|
|
|
352
417
|
### Action Options
|
|
353
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { K8sResource, K8sResourceReference } from "../apis";
|
|
2
|
+
import { toKubectlType } from "./kubectl-type";
|
|
3
|
+
import type { OneWayMutateDef } from "./types";
|
|
4
|
+
|
|
5
|
+
export const deleteResource = {
|
|
6
|
+
type: "oneWayMutate",
|
|
7
|
+
name: "Delete",
|
|
8
|
+
mutate:
|
|
9
|
+
({ kubectl }) =>
|
|
10
|
+
async <T extends K8sResource>(resource: K8sResourceReference<T>) => {
|
|
11
|
+
await kubectl.delete(toKubectlType(resource), resource.name);
|
|
12
|
+
return undefined;
|
|
13
|
+
},
|
|
14
|
+
} satisfies OneWayMutateDef<K8sResourceReference, void>;
|
|
@@ -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
|
@@ -13,7 +13,8 @@ import type { $ as BunDollar } from "bun";
|
|
|
13
13
|
* Some actions also register cleanup ("revert") handlers which run during
|
|
14
14
|
* scenario cleanup. For example, {@link Scenario.apply} registers a revert that
|
|
15
15
|
* deletes the applied resource. One-way mutations such as
|
|
16
|
-
* {@link Scenario.applyStatus} do not register a
|
|
16
|
+
* {@link Scenario.applyStatus} and {@link Scenario.delete} do not register a
|
|
17
|
+
* revert.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -88,6 +89,63 @@ export interface Scenario {
|
|
|
88
89
|
options?: undefined | ActionOptions
|
|
89
90
|
): Promise<void>;
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Deletes a Kubernetes resource using `kubectl delete`.
|
|
94
|
+
*
|
|
95
|
+
* This action is retried when it throws.
|
|
96
|
+
*
|
|
97
|
+
* Note: this is a one-way mutation and does not register a cleanup handler.
|
|
98
|
+
*
|
|
99
|
+
* @template T - The expected Kubernetes resource shape.
|
|
100
|
+
* @param resource - Group/version/kind and name of the resource to delete.
|
|
101
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* await s.delete({
|
|
106
|
+
* apiVersion: "v1",
|
|
107
|
+
* kind: "ConfigMap",
|
|
108
|
+
* name: "my-config",
|
|
109
|
+
* });
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
delete<T extends K8sResource>(
|
|
113
|
+
resource: K8sResourceReference<T>,
|
|
114
|
+
options?: undefined | ActionOptions
|
|
115
|
+
): Promise<void>;
|
|
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
|
+
|
|
91
149
|
/**
|
|
92
150
|
* Fetches a Kubernetes resource and returns it as a typed object.
|
|
93
151
|
*
|
|
@@ -150,6 +208,32 @@ export interface Scenario {
|
|
|
150
208
|
options?: undefined | ActionOptions
|
|
151
209
|
): Promise<T>;
|
|
152
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
|
+
|
|
153
237
|
/**
|
|
154
238
|
* Lists Kubernetes resources of a given type and runs a test function.
|
|
155
239
|
*
|
|
@@ -186,7 +270,11 @@ export interface Scenario {
|
|
|
186
270
|
* `kest-abc12`). The namespace creation is a mutating action that registers a
|
|
187
271
|
* cleanup handler; the namespace is deleted during scenario cleanup.
|
|
188
272
|
*
|
|
189
|
-
*
|
|
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.
|
|
190
278
|
* @param options - Retry options such as timeout and polling interval.
|
|
191
279
|
*
|
|
192
280
|
* @example
|
|
@@ -199,9 +287,15 @@ export interface Scenario {
|
|
|
199
287
|
* data: { mode: "namespaced" },
|
|
200
288
|
* });
|
|
201
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
|
+
* ```
|
|
202
296
|
*/
|
|
203
297
|
newNamespace(
|
|
204
|
-
name?: undefined | string,
|
|
298
|
+
name?: undefined | string | { readonly generateName: string },
|
|
205
299
|
options?: undefined | ActionOptions
|
|
206
300
|
): Promise<Namespace>;
|
|
207
301
|
|
|
@@ -370,6 +464,54 @@ export interface Cluster {
|
|
|
370
464
|
options?: undefined | ActionOptions
|
|
371
465
|
): Promise<void>;
|
|
372
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Deletes a Kubernetes resource in this cluster using `kubectl delete`.
|
|
469
|
+
*
|
|
470
|
+
* This action is retried when it throws.
|
|
471
|
+
*
|
|
472
|
+
* Note: this is a one-way mutation and does not register a cleanup handler.
|
|
473
|
+
*
|
|
474
|
+
* @template T - The expected Kubernetes resource shape.
|
|
475
|
+
* @param resource - Group/version/kind and name of the resource to delete.
|
|
476
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
477
|
+
*/
|
|
478
|
+
delete<T extends K8sResource>(
|
|
479
|
+
resource: K8sResourceReference<T>,
|
|
480
|
+
options?: undefined | ActionOptions
|
|
481
|
+
): Promise<void>;
|
|
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
|
+
|
|
373
515
|
/**
|
|
374
516
|
* Fetches a Kubernetes resource by GVK and name.
|
|
375
517
|
*
|
|
@@ -415,6 +557,27 @@ export interface Cluster {
|
|
|
415
557
|
options?: undefined | ActionOptions
|
|
416
558
|
): Promise<T>;
|
|
417
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
|
+
|
|
418
581
|
/**
|
|
419
582
|
* Lists Kubernetes resources of a given type and runs a test function.
|
|
420
583
|
*
|
|
@@ -441,7 +604,8 @@ export interface Cluster {
|
|
|
441
604
|
/**
|
|
442
605
|
* Creates a new namespace in this cluster and returns a namespaced API.
|
|
443
606
|
*
|
|
444
|
-
* @param name - Optional namespace name
|
|
607
|
+
* @param name - Optional namespace name, or `{ generateName }` for prefixed
|
|
608
|
+
* generation.
|
|
445
609
|
* @param options - Retry options such as timeout and polling interval.
|
|
446
610
|
*
|
|
447
611
|
* @example
|
|
@@ -454,9 +618,15 @@ export interface Cluster {
|
|
|
454
618
|
* data: { mode: "from-cluster" },
|
|
455
619
|
* });
|
|
456
620
|
* ```
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
* ```ts
|
|
624
|
+
* // Generate a namespace with a custom prefix
|
|
625
|
+
* const ns = await cluster.newNamespace({ generateName: "foo-" });
|
|
626
|
+
* ```
|
|
457
627
|
*/
|
|
458
628
|
newNamespace(
|
|
459
|
-
name?: undefined | string,
|
|
629
|
+
name?: undefined | string | { readonly generateName: string },
|
|
460
630
|
options?: undefined | ActionOptions
|
|
461
631
|
): Promise<Namespace>;
|
|
462
632
|
}
|
|
@@ -480,6 +650,11 @@ export interface Cluster {
|
|
|
480
650
|
* causes `kubectl` to fail.
|
|
481
651
|
*/
|
|
482
652
|
export interface Namespace {
|
|
653
|
+
/**
|
|
654
|
+
* The name of this namespace (e.g. `"kest-abc12"`).
|
|
655
|
+
*/
|
|
656
|
+
readonly name: string;
|
|
657
|
+
|
|
483
658
|
/**
|
|
484
659
|
* Applies a Kubernetes manifest in this namespace and registers cleanup.
|
|
485
660
|
*
|
|
@@ -534,6 +709,56 @@ export interface Namespace {
|
|
|
534
709
|
options?: undefined | ActionOptions
|
|
535
710
|
): Promise<void>;
|
|
536
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Deletes a Kubernetes resource in this namespace using `kubectl delete`.
|
|
714
|
+
*
|
|
715
|
+
* This action is retried when it throws.
|
|
716
|
+
*
|
|
717
|
+
* Note: this is a one-way mutation and does not register a cleanup handler.
|
|
718
|
+
*
|
|
719
|
+
* @template T - The expected Kubernetes resource shape.
|
|
720
|
+
* @param resource - Group/version/kind and name of the resource to delete.
|
|
721
|
+
* @param options - Retry options such as timeout and polling interval.
|
|
722
|
+
*/
|
|
723
|
+
delete<T extends K8sResource>(
|
|
724
|
+
resource: K8sResourceReference<T>,
|
|
725
|
+
options?: undefined | ActionOptions
|
|
726
|
+
): Promise<void>;
|
|
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
|
+
|
|
537
762
|
/**
|
|
538
763
|
* Fetches a namespaced Kubernetes resource by GVK and name.
|
|
539
764
|
*
|
|
@@ -579,6 +804,27 @@ export interface Namespace {
|
|
|
579
804
|
options?: undefined | ActionOptions
|
|
580
805
|
): Promise<T>;
|
|
581
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
|
+
|
|
582
828
|
/**
|
|
583
829
|
* Lists namespaced Kubernetes resources of a given type and runs a test.
|
|
584
830
|
*
|
|
@@ -679,6 +925,57 @@ export interface K8sResourceReference<T extends K8sResource = K8sResource> {
|
|
|
679
925
|
readonly name: string;
|
|
680
926
|
}
|
|
681
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
|
+
|
|
682
979
|
/**
|
|
683
980
|
* A test definition for {@link Scenario.assert}.
|
|
684
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,10 +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";
|
|
10
|
+
import { deleteResource } from "../actions/delete";
|
|
6
11
|
import { exec } from "../actions/exec";
|
|
7
12
|
import { get } from "../actions/get";
|
|
13
|
+
import { label } from "../actions/label";
|
|
8
14
|
import type { MutateDef, OneWayMutateDef, QueryDef } from "../actions/types";
|
|
9
15
|
import type {
|
|
10
16
|
ActionOptions,
|
|
@@ -31,9 +37,12 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
|
|
|
31
37
|
return {
|
|
32
38
|
apply: createMutateFn(deps, apply),
|
|
33
39
|
applyStatus: createOneWayMutateFn(deps, applyStatus),
|
|
40
|
+
delete: createOneWayMutateFn(deps, deleteResource),
|
|
41
|
+
label: createOneWayMutateFn(deps, label),
|
|
34
42
|
exec: createMutateFn(deps, exec),
|
|
35
43
|
get: createQueryFn(deps, get),
|
|
36
44
|
assert: createQueryFn(deps, assert),
|
|
45
|
+
assertAbsence: createQueryFn(deps, assertAbsence),
|
|
37
46
|
assertList: createQueryFn(deps, assertList),
|
|
38
47
|
given: bdd.given(deps),
|
|
39
48
|
when: bdd.when(deps),
|
|
@@ -180,7 +189,7 @@ const createQueryFn =
|
|
|
180
189
|
const createNewNamespaceFn =
|
|
181
190
|
(scenarioDeps: CreateScenarioOptions) =>
|
|
182
191
|
async (
|
|
183
|
-
name?:
|
|
192
|
+
name?: ApplyNamespaceInput,
|
|
184
193
|
options?: undefined | ActionOptions
|
|
185
194
|
): Promise<Namespace> => {
|
|
186
195
|
const namespaceName = await createMutateFn(scenarioDeps, applyNamespace)(
|
|
@@ -191,10 +200,14 @@ const createNewNamespaceFn =
|
|
|
191
200
|
const namespacedKubectl = kubectl.extends({ namespace: namespaceName });
|
|
192
201
|
const namespacedDeps = { ...scenarioDeps, kubectl: namespacedKubectl };
|
|
193
202
|
return {
|
|
203
|
+
name: namespaceName,
|
|
194
204
|
apply: createMutateFn(namespacedDeps, apply),
|
|
195
205
|
applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
|
|
206
|
+
delete: createOneWayMutateFn(namespacedDeps, deleteResource),
|
|
207
|
+
label: createOneWayMutateFn(namespacedDeps, label),
|
|
196
208
|
get: createQueryFn(namespacedDeps, get),
|
|
197
209
|
assert: createQueryFn(namespacedDeps, assert),
|
|
210
|
+
assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
|
|
198
211
|
assertList: createQueryFn(namespacedDeps, assertList),
|
|
199
212
|
};
|
|
200
213
|
};
|
|
@@ -212,8 +225,11 @@ const createUseClusterFn =
|
|
|
212
225
|
return {
|
|
213
226
|
apply: createMutateFn(clusterDeps, apply),
|
|
214
227
|
applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
|
|
228
|
+
delete: createOneWayMutateFn(clusterDeps, deleteResource),
|
|
229
|
+
label: createOneWayMutateFn(clusterDeps, label),
|
|
215
230
|
get: createQueryFn(clusterDeps, get),
|
|
216
231
|
assert: createQueryFn(clusterDeps, assert),
|
|
232
|
+
assertAbsence: createQueryFn(clusterDeps, assertAbsence),
|
|
217
233
|
assertList: createQueryFn(clusterDeps, assertList),
|
|
218
234
|
newNamespace: createNewNamespaceFn(clusterDeps),
|
|
219
235
|
};
|