@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 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
- | `get(resource, options?)` | Fetch a resource by API version, kind, and name |
342
- | `assert(resource, options?)` | Fetch a resource and run assertions with retries |
343
- | `assertList(resource, options?)` | Fetch a list of resources and run assertions |
344
- | `newNamespace(name?, options?)` | Create an ephemeral namespace |
345
- | `exec(input, options?)` | Execute shell commands with optional revert |
346
- | `useCluster(ref)` | Create a cluster-bound API surface |
347
- | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
@@ -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 (namespaceName) => {
10
- const name = namespaceName ?? `kest-${randomConsonantDigits(5)}`;
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<undefined | string, string>;
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
+ }
@@ -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
- * @param name - Optional namespace name to create.
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 to create.
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
  */
@@ -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 };
@@ -1,11 +1,16 @@
1
1
  import { apply } from "../actions/apply";
2
- import { applyNamespace } from "../actions/apply-namespace";
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?: undefined | string,
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
  };