@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 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
- | `get(resource, options?)` | Fetch a resource by API version, kind, and name |
341
- | `assert(resource, options?)` | Fetch a resource and run assertions with retries |
342
- | `assertList(resource, options?)` | Fetch a list of resources and run assertions |
343
- | `newNamespace(name?, options?)` | Create an ephemeral namespace |
344
- | `exec(input, options?)` | Execute shell commands with optional revert |
345
- | `useCluster(ref)` | Create a cluster-bound API surface |
346
- | `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 |
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`, `assertList`, `newNamespace`) scoped to their namespace or cluster context.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.1.2",
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
+ }
@@ -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 revert.
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
- * @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.
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 to create.
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
  */
@@ -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,10 +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";
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?: undefined | string,
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
  };