@appthrust/kest 0.15.0 → 0.17.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
@@ -185,6 +185,77 @@ test("resources sync across clusters", async (s) => {
185
185
  });
186
186
  ```
187
187
 
188
+ ### Existing Namespaces
189
+
190
+ When testing resources in namespaces that kest did not create (e.g. system namespaces or namespaces installed by Helm charts), use `useNamespace` to obtain the same namespace-scoped DSL without creating or cleaning up the namespace:
191
+
192
+ ```ts
193
+ test("istio root cert exists", async (s) => {
194
+ const istio = await s.useNamespace("istio-system");
195
+ await istio.assert({
196
+ apiVersion: "v1",
197
+ kind: "ConfigMap",
198
+ name: "istio-ca-root-cert",
199
+ test() {
200
+ expect(this.data["root-cert.pem"]).toBeDefined();
201
+ },
202
+ });
203
+ });
204
+ ```
205
+
206
+ `useNamespace` is also available on `Cluster`:
207
+
208
+ ```ts
209
+ const cluster = await s.useCluster({ context: "kind-kind" });
210
+ const kubeSystem = await cluster.useNamespace("kube-system");
211
+ ```
212
+
213
+ #### CAPI Dynamic Clusters
214
+
215
+ Kest can resolve [Cluster API](https://cluster-api.sigs.k8s.io/) (CAPI) provisioned clusters automatically. Pass a `ClusterResourceReference` to `useCluster` and Kest will:
216
+
217
+ 1. Poll the CAPI `Cluster` resource until it reports ready
218
+ 2. Fetch the kubeconfig from the `<name>-kubeconfig` Secret (data key `value`)
219
+ 3. Write a temporary kubeconfig file and bind kubectl to it
220
+ 4. Register cleanup to delete the temp file when the test ends
221
+
222
+ ```ts
223
+ test("CAPI multi-hop: management → child → workload", async (s) => {
224
+ // 1. Connect to the permanent management cluster
225
+ const pmc = await s.useCluster({ context: "kind-pmc" });
226
+
227
+ // 2. Wait for the child cluster to become ready, then connect
228
+ const cc = await pmc.useCluster({
229
+ apiVersion: "cluster.x-k8s.io/v1beta1",
230
+ kind: "Cluster",
231
+ name: "child-cluster",
232
+ namespace: "capi-system",
233
+ });
234
+
235
+ // 3. Chain further — the child cluster manages a workload cluster
236
+ const wlc = await cc.useCluster({
237
+ apiVersion: "cluster.x-k8s.io/v1beta1",
238
+ kind: "Cluster",
239
+ name: "workload-cluster",
240
+ namespace: "default",
241
+ });
242
+
243
+ const ns = await wlc.newNamespace();
244
+ await ns.apply(/* ... */);
245
+ });
246
+ ```
247
+
248
+ **Readiness conditions:**
249
+
250
+ | API version | Condition type |
251
+ | ------------------------------- | -------------- |
252
+ | `cluster.x-k8s.io/v1beta1` | `Ready` |
253
+ | `cluster.x-k8s.io/v1beta2` | `Available` |
254
+
255
+ **Secret convention:** Kest reads the kubeconfig from a Secret named `${clusterName}-kubeconfig` with the data key `value`. This follows the default CAPI convention. Your RBAC must grant `get` on Secrets in the namespace where the Cluster resource lives.
256
+
257
+ **Temp kubeconfig cleanup:** The temporary kubeconfig file is deleted automatically during test cleanup. Set `KEST_PRESERVE_ON_FAILURE=1` to skip cleanup on failure, which keeps the temp file for debugging.
258
+
188
259
  ### Status Subresource Support
189
260
 
190
261
  Simulate controller behavior by applying status subresources via server-side apply:
@@ -533,14 +604,15 @@ The top-level API surface available in every test callback.
533
604
  | `assertList(resource, options?)` | Fetch a list of resources and run assertions |
534
605
  | `assertOne(resource, options?)` | Assert exactly one resource matches, then run assertions |
535
606
  | `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
607
+ | `useNamespace(name, options?)` | Obtain a `Namespace` for an existing namespace (no cleanup) |
536
608
  | `generateName(prefix)` | Generate a random-suffix name (statistical uniqueness) |
537
609
  | `exec(input, options?)` | Execute shell commands with optional revert |
538
- | `useCluster(ref)` | Create a cluster-bound API surface |
610
+ | `useCluster(ref, options?)` | Create a cluster-bound API surface |
539
611
  | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
540
612
 
541
613
  ### Namespace / Cluster
542
614
 
543
- Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `assertApplyError`, `assertCreateError`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
615
+ Returned by `newNamespace()`, `useNamespace()`, and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `assertApplyError`, `assertCreateError`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`, `useNamespace`, and `useCluster`.
544
616
 
545
617
  `Namespace` also exposes a `name` property:
546
618
 
@@ -993,6 +1065,7 @@ await ns.apply({
993
1065
  | Situation | Approach |
994
1066
  | ------------------------------------------------------ | ----------------------------------------- |
995
1067
  | Namespaced resource inside `newNamespace()` | Use a fixed name (default) |
1068
+ | Resource in pre-existing namespace (`useNamespace()`) | Use a fixed name (default) |
996
1069
  | Same-kind resources created multiple times in one test | `s.generateName` or numbered fixed names |
997
1070
  | Cluster-scoped resource (e.g. `ClusterRole`, `CRD`) | `s.generateName` (no namespace isolation) |
998
1071
  | Fixed name causes unintended upsert / side effects | `s.generateName` |
@@ -0,0 +1,157 @@
1
+ /** biome-ignore-all lint/style/noDoneCallback: that's not the done callback */
2
+ import { expect } from "bun:test";
3
+ import { type K8sResource, test } from "@appthrust/kest";
4
+
5
+ interface ConfigMap extends K8sResource {
6
+ apiVersion: "v1";
7
+ kind: "ConfigMap";
8
+ metadata: {
9
+ name: string;
10
+ };
11
+ data: {
12
+ [key: string]: string;
13
+ };
14
+ }
15
+
16
+ test(
17
+ "Example: provision a CAPI child cluster, connect, and create resources",
18
+ async (s) => {
19
+ s.given("a management cluster with CAPI and k0smotron installed");
20
+ const mgmt = await s.useCluster({ context: "kind-kest-test-cluster-1" });
21
+
22
+ s.when("I provision a child cluster via CAPI");
23
+ await mgmt.apply({
24
+ apiVersion: "infrastructure.cluster.x-k8s.io/v1beta1",
25
+ kind: "DockerMachineTemplate",
26
+ metadata: { name: "child-cluster-worker-mt", namespace: "default" },
27
+ spec: { template: { spec: {} } },
28
+ });
29
+ await mgmt.apply({
30
+ apiVersion: "bootstrap.cluster.x-k8s.io/v1beta1",
31
+ kind: "K0sWorkerConfigTemplate",
32
+ metadata: { name: "child-cluster-worker-config", namespace: "default" },
33
+ spec: { template: { spec: { version: "v1.31.2+k0s.0" } } },
34
+ });
35
+ await mgmt.apply({
36
+ apiVersion: "infrastructure.cluster.x-k8s.io/v1beta1",
37
+ kind: "DockerCluster",
38
+ metadata: {
39
+ name: "child-cluster",
40
+ namespace: "default",
41
+ annotations: { "cluster.x-k8s.io/managed-by": "k0smotron" },
42
+ },
43
+ spec: {},
44
+ });
45
+ await mgmt.apply({
46
+ apiVersion: "controlplane.cluster.x-k8s.io/v1beta1",
47
+ kind: "K0smotronControlPlane",
48
+ metadata: { name: "child-cluster-cp", namespace: "default" },
49
+ spec: {
50
+ version: "v1.31.2-k0s.0",
51
+ persistence: { type: "emptyDir" },
52
+ service: {
53
+ type: "LoadBalancer",
54
+ apiPort: 6443,
55
+ konnectivityPort: 8132,
56
+ },
57
+ k0sConfig: {
58
+ apiVersion: "k0s.k0sproject.io/v1beta1",
59
+ kind: "ClusterConfig",
60
+ spec: { telemetry: { enabled: false } },
61
+ },
62
+ },
63
+ });
64
+ await mgmt.apply({
65
+ apiVersion: "cluster.x-k8s.io/v1beta1",
66
+ kind: "MachineDeployment",
67
+ metadata: { name: "child-cluster-workers", namespace: "default" },
68
+ spec: {
69
+ clusterName: "child-cluster",
70
+ replicas: 1,
71
+ selector: {
72
+ matchLabels: {
73
+ "cluster.x-k8s.io/cluster-name": "child-cluster",
74
+ pool: "worker-pool",
75
+ },
76
+ },
77
+ template: {
78
+ metadata: {
79
+ labels: {
80
+ "cluster.x-k8s.io/cluster-name": "child-cluster",
81
+ pool: "worker-pool",
82
+ },
83
+ },
84
+ spec: {
85
+ clusterName: "child-cluster",
86
+ version: "v1.31.2",
87
+ bootstrap: {
88
+ configRef: {
89
+ apiVersion: "bootstrap.cluster.x-k8s.io/v1beta1",
90
+ kind: "K0sWorkerConfigTemplate",
91
+ name: "child-cluster-worker-config",
92
+ },
93
+ },
94
+ infrastructureRef: {
95
+ apiVersion: "infrastructure.cluster.x-k8s.io/v1beta1",
96
+ kind: "DockerMachineTemplate",
97
+ name: "child-cluster-worker-mt",
98
+ },
99
+ },
100
+ },
101
+ },
102
+ });
103
+ await mgmt.apply({
104
+ apiVersion: "cluster.x-k8s.io/v1beta1",
105
+ kind: "Cluster",
106
+ metadata: { name: "child-cluster", namespace: "default" },
107
+ spec: {
108
+ clusterNetwork: {
109
+ pods: { cidrBlocks: ["192.168.0.0/16"] },
110
+ serviceDomain: "cluster.local",
111
+ services: { cidrBlocks: ["10.128.0.0/12"] },
112
+ },
113
+ controlPlaneRef: {
114
+ apiVersion: "controlplane.cluster.x-k8s.io/v1beta1",
115
+ kind: "K0smotronControlPlane",
116
+ name: "child-cluster-cp",
117
+ },
118
+ infrastructureRef: {
119
+ apiVersion: "infrastructure.cluster.x-k8s.io/v1beta1",
120
+ kind: "DockerCluster",
121
+ name: "child-cluster",
122
+ },
123
+ },
124
+ });
125
+
126
+ s.and("the child cluster becomes ready");
127
+ const child = await mgmt.useCluster(
128
+ {
129
+ apiVersion: "cluster.x-k8s.io/v1beta1",
130
+ kind: "Cluster",
131
+ name: "child-cluster",
132
+ namespace: "default",
133
+ },
134
+ { timeout: "10m", interval: "5s" }
135
+ );
136
+
137
+ s.and("I create a namespace and apply a ConfigMap on the child cluster");
138
+ const ns = await child.newNamespace();
139
+ await ns.apply<ConfigMap>({
140
+ apiVersion: "v1",
141
+ kind: "ConfigMap",
142
+ metadata: { name: "hello-from-capi" },
143
+ data: { source: "management-cluster" },
144
+ });
145
+
146
+ s.then("the ConfigMap should exist on the child cluster");
147
+ await ns.assert<ConfigMap>({
148
+ apiVersion: "v1",
149
+ kind: "ConfigMap",
150
+ name: "hello-from-capi",
151
+ test() {
152
+ expect(this.data["source"]).toBe("management-cluster");
153
+ },
154
+ });
155
+ },
156
+ { timeout: "15m" }
157
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
package/ts/apis/index.ts CHANGED
@@ -518,10 +518,12 @@ export interface Scenario {
518
518
  * The returned {@link Cluster} uses the provided kubeconfig/context for all
519
519
  * actions. It does not create any resources by itself.
520
520
  *
521
- * @param cluster - Target kubeconfig and/or context.
521
+ * @param cluster - Target cluster reference (static kubeconfig/context or CAPI resource).
522
+ * @param options - Retry options such as timeout and polling interval.
522
523
  *
523
524
  * @example
524
525
  * ```ts
526
+ * // Static cluster reference
525
527
  * const c = await s.useCluster({ context: "kind-kind" });
526
528
  * const ns = await c.newNamespace("my-ns");
527
529
  * await ns.apply({
@@ -531,8 +533,54 @@ export interface Scenario {
531
533
  * data: { mode: "cluster" },
532
534
  * });
533
535
  * ```
536
+ *
537
+ * @example
538
+ * ```ts
539
+ * // CAPI cluster resource reference
540
+ * const c = await s.useCluster({
541
+ * apiVersion: "cluster.x-k8s.io/v1beta1",
542
+ * kind: "Cluster",
543
+ * name: "workload-1",
544
+ * namespace: "default",
545
+ * });
546
+ * ```
547
+ */
548
+ useCluster(
549
+ cluster: ClusterReference,
550
+ options?: undefined | ActionOptions
551
+ ): Promise<Cluster>;
552
+
553
+ /**
554
+ * Obtains a {@link Namespace} interface for an existing namespace.
555
+ *
556
+ * Unlike {@link Scenario.newNamespace}, this does **not** create the namespace
557
+ * and does **not** register a cleanup handler. It verifies the namespace exists
558
+ * (via `kubectl get namespace <name>`) and returns the same namespace-scoped
559
+ * DSL surface.
560
+ *
561
+ * Use this when testing resources in namespaces that kest did not create, such
562
+ * as system namespaces or namespaces provisioned by controllers or Helm charts.
563
+ *
564
+ * @param name - The name of the existing namespace.
565
+ * @param options - Retry options such as timeout and polling interval.
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * const istio = await s.useNamespace("istio-system");
570
+ * await istio.assert({
571
+ * apiVersion: "v1",
572
+ * kind: "ConfigMap",
573
+ * name: "istio-ca-root-cert",
574
+ * test() {
575
+ * expect(this.data["root-cert.pem"]).toBeDefined();
576
+ * },
577
+ * });
578
+ * ```
534
579
  */
535
- useCluster(cluster: ClusterReference): Promise<Cluster>;
580
+ useNamespace(
581
+ name: string,
582
+ options?: undefined | ActionOptions
583
+ ): Promise<Namespace>;
536
584
 
537
585
  // BDD(behavior-driven development) actions
538
586
 
@@ -879,13 +927,73 @@ export interface Cluster {
879
927
  name?: undefined | string | { readonly generateName: string },
880
928
  options?: undefined | ActionOptions
881
929
  ): Promise<Namespace>;
930
+
931
+ // Multi-cluster actions
932
+
933
+ /**
934
+ * Creates a cluster-bound API surface from this cluster.
935
+ *
936
+ * This enables multi-hop cluster access — for example, using a management
937
+ * cluster to reach a CAPI-provisioned workload cluster.
938
+ *
939
+ * @param cluster - Target cluster reference (static or CAPI resource).
940
+ * @param options - Retry options such as timeout and polling interval.
941
+ *
942
+ * @example
943
+ * ```ts
944
+ * // From a management cluster, obtain a workload cluster handle
945
+ * const mgmt = await s.useCluster({ context: "kind-mgmt" });
946
+ * const workload = await mgmt.useCluster({
947
+ * apiVersion: "cluster.x-k8s.io/v1beta1",
948
+ * kind: "Cluster",
949
+ * name: "workload-1",
950
+ * namespace: "default",
951
+ * });
952
+ * const ns = await workload.newNamespace("test");
953
+ * ```
954
+ */
955
+ useCluster(
956
+ cluster: ClusterReference,
957
+ options?: undefined | ActionOptions
958
+ ): Promise<Cluster>;
959
+
960
+ /**
961
+ * Obtains a {@link Namespace} interface for an existing namespace in this cluster.
962
+ *
963
+ * Unlike {@link Cluster.newNamespace}, this does **not** create the namespace
964
+ * and does **not** register a cleanup handler. It verifies the namespace exists
965
+ * (via `kubectl get namespace <name>`) and returns the same namespace-scoped
966
+ * DSL surface.
967
+ *
968
+ * @param name - The name of the existing namespace.
969
+ * @param options - Retry options such as timeout and polling interval.
970
+ *
971
+ * @example
972
+ * ```ts
973
+ * const cluster = await s.useCluster({ context: "kind-kind" });
974
+ * const kubeSystem = await cluster.useNamespace("kube-system");
975
+ * await kubeSystem.assert({
976
+ * apiVersion: "v1",
977
+ * kind: "ConfigMap",
978
+ * name: "kube-root-ca.crt",
979
+ * test() {
980
+ * expect(this.data["ca.crt"]).toBeDefined();
981
+ * },
982
+ * });
983
+ * ```
984
+ */
985
+ useNamespace(
986
+ name: string,
987
+ options?: undefined | ActionOptions
988
+ ): Promise<Namespace>;
882
989
  }
883
990
 
884
991
  /**
885
992
  * Namespace-bound API surface.
886
993
  *
887
- * A {@link Namespace} is typically obtained via {@link Scenario.newNamespace} or
888
- * {@link Cluster.newNamespace}.
994
+ * A {@link Namespace} is typically obtained via {@link Scenario.newNamespace},
995
+ * {@link Scenario.useNamespace}, {@link Cluster.newNamespace}, or
996
+ * {@link Cluster.useNamespace}.
889
997
  *
890
998
  * Operations are scoped by setting the kubectl namespace context (equivalent to
891
999
  * passing `kubectl -n <namespace>`).
@@ -1483,9 +1591,14 @@ export interface AssertCreateErrorInput<T extends K8sResource = K8sResource> {
1483
1591
  }
1484
1592
 
1485
1593
  /**
1486
- * Kubernetes cluster selector for {@link Scenario.useCluster}.
1594
+ * Static cluster selector that points to an existing kubeconfig/context.
1595
+ *
1596
+ * Use this when the cluster is already provisioned and you have direct access
1597
+ * to its kubeconfig file or context name.
1598
+ *
1599
+ * @see {@link ClusterReference} — the union accepted by {@link Scenario.useCluster}.
1487
1600
  */
1488
- export interface ClusterReference {
1601
+ export interface StaticClusterReference {
1489
1602
  /**
1490
1603
  * Path to a kubeconfig file to use for this cluster.
1491
1604
  */
@@ -1497,6 +1610,63 @@ export interface ClusterReference {
1497
1610
  readonly context?: undefined | string;
1498
1611
  }
1499
1612
 
1613
+ /**
1614
+ * Supported Cluster API versions for CAPI-provisioned clusters.
1615
+ */
1616
+ export type CapiClusterApiVersion =
1617
+ | "cluster.x-k8s.io/v1beta1"
1618
+ | "cluster.x-k8s.io/v1beta2";
1619
+
1620
+ /**
1621
+ * Reference to a CAPI-provisioned cluster resource.
1622
+ *
1623
+ * Kest resolves the cluster's kubeconfig by reading the corresponding
1624
+ * `<name>-kubeconfig` Secret from the management cluster and passes it to
1625
+ * kubectl automatically.
1626
+ *
1627
+ * @example
1628
+ * ```ts
1629
+ * const c = await s.useCluster({
1630
+ * apiVersion: "cluster.x-k8s.io/v1beta1",
1631
+ * kind: "Cluster",
1632
+ * name: "workload-1",
1633
+ * namespace: "default",
1634
+ * });
1635
+ * ```
1636
+ */
1637
+ export interface CapiClusterResourceReference {
1638
+ /** Cluster API group version. */
1639
+ readonly apiVersion: CapiClusterApiVersion;
1640
+
1641
+ /** Must be `"Cluster"`. */
1642
+ readonly kind: "Cluster";
1643
+
1644
+ /** Name of the CAPI Cluster resource. */
1645
+ readonly name: string;
1646
+
1647
+ /** Namespace where the CAPI Cluster resource lives. */
1648
+ readonly namespace: string;
1649
+ }
1650
+
1651
+ /**
1652
+ * Union of cluster resource reference types.
1653
+ *
1654
+ * Currently only CAPI clusters are supported, but this alias leaves room for
1655
+ * additional providers in the future.
1656
+ */
1657
+ export type ClusterResourceReference = CapiClusterResourceReference;
1658
+
1659
+ /**
1660
+ * Kubernetes cluster selector for {@link Scenario.useCluster} and
1661
+ * {@link Cluster.useCluster}.
1662
+ *
1663
+ * - {@link StaticClusterReference} — points to an existing kubeconfig/context.
1664
+ * - {@link ClusterResourceReference} — references a CAPI Cluster resource.
1665
+ */
1666
+ export type ClusterReference =
1667
+ | StaticClusterReference
1668
+ | ClusterResourceReference;
1669
+
1500
1670
  /**
1501
1671
  * A Kubernetes manifest accepted by Kest actions.
1502
1672
  *
@@ -155,6 +155,26 @@ export interface Kubectl {
155
155
  labels: Readonly<Record<string, string | null>>,
156
156
  options?: KubectlLabelOptions
157
157
  ): Promise<string>;
158
+
159
+ /**
160
+ * Retrieves and base64-decodes a single key from a Kubernetes Secret.
161
+ *
162
+ * Runs: `kubectl get secret <name> -o jsonpath='{.data.<key>}'`
163
+ *
164
+ * The raw secret value is **not** recorded; the recorded stdout is replaced
165
+ * with `"<redacted>"` to avoid leaking sensitive data into reports/logs.
166
+ *
167
+ * @param name - The name of the Secret resource
168
+ * @param key - The data key to retrieve (e.g., "password", "kubeconfig")
169
+ * @param context - Optional context overrides for this call
170
+ * @returns The base64-decoded value of the requested key
171
+ * @throws Error if kubectl exits with non-zero code
172
+ */
173
+ getSecretData(
174
+ name: string,
175
+ key: string,
176
+ context?: KubectlContext
177
+ ): Promise<string>;
158
178
  }
159
179
 
160
180
  export interface KubectlDeleteOptions {
@@ -317,6 +337,21 @@ export class RealKubectl implements Kubectl {
317
337
  });
318
338
  }
319
339
 
340
+ async getSecretData(
341
+ name: string,
342
+ key: string,
343
+ context?: KubectlContext
344
+ ): Promise<string> {
345
+ const stdout = await this.runKubectl({
346
+ args: ["get", "secret", name, "-o", `jsonpath={.data.${key}}`],
347
+ stdoutLanguage: "text",
348
+ stderrLanguage: "text",
349
+ overrideContext: context,
350
+ redactStdout: true,
351
+ });
352
+ return Buffer.from(stdout, "base64").toString("utf-8");
353
+ }
354
+
320
355
  async label(
321
356
  resource: string,
322
357
  name: string,
@@ -377,7 +412,7 @@ export class RealKubectl implements Kubectl {
377
412
  };
378
413
  this.recorder.record("CommandResult", {
379
414
  exitCode,
380
- stdout,
415
+ stdout: params.redactStdout ? "<redacted>" : stdout,
381
416
  stderr,
382
417
  stdoutLanguage: params.stdoutLanguage,
383
418
  stderrLanguage: params.stderrLanguage,
@@ -406,6 +441,7 @@ interface ExecParams {
406
441
  readonly stdoutLanguage?: undefined | string;
407
442
  readonly stderrLanguage?: undefined | string;
408
443
  readonly overrideContext?: undefined | KubectlContext;
444
+ readonly redactStdout?: undefined | boolean;
409
445
  }
410
446
 
411
447
  function stringifyPatch(patch: KubectlPatch): string {
@@ -30,6 +30,7 @@ import type { Recorder } from "../recording";
30
30
  import type { Reporter } from "../reporter/interface";
31
31
  import { retryUntil } from "../retry";
32
32
  import type { Reverting } from "../reverting";
33
+ import { resolveCluster } from "./use-cluster";
33
34
 
34
35
  export interface InternalScenario extends Scenario {
35
36
  cleanup(options?: { skip?: boolean }): Promise<void>;
@@ -60,6 +61,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
60
61
  but: bdd.but(deps),
61
62
  generateName: (prefix: string) => generateRandomName(prefix),
62
63
  newNamespace: createNewNamespaceFn(deps),
64
+ useNamespace: createUseNamespaceFn(deps),
63
65
  useCluster: createUseClusterFn(deps),
64
66
  async cleanup(options?: { skip?: boolean }) {
65
67
  if (options?.skip) {
@@ -74,7 +76,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
74
76
  };
75
77
  }
76
78
 
77
- interface CreateScenarioOptions {
79
+ export interface CreateScenarioOptions {
78
80
  readonly name: string;
79
81
  readonly recorder: Recorder;
80
82
  readonly kubectl: Kubectl;
@@ -82,7 +84,7 @@ interface CreateScenarioOptions {
82
84
  readonly reporter: Reporter;
83
85
  }
84
86
 
85
- const createMutateFn =
87
+ export const createMutateFn =
86
88
  <
87
89
  const Action extends MutateDef<Input, Output>,
88
90
  Input = Action extends MutateDef<infer I, infer _> ? I : never,
@@ -134,7 +136,7 @@ const createMutateFn =
134
136
  }
135
137
  };
136
138
 
137
- const createOneWayMutateFn =
139
+ export const createOneWayMutateFn =
138
140
  <
139
141
  const Action extends OneWayMutateDef<Input, Output>,
140
142
  Input = Action extends OneWayMutateDef<infer I, infer _> ? I : never,
@@ -165,7 +167,7 @@ const createOneWayMutateFn =
165
167
  }
166
168
  };
167
169
 
168
- const createQueryFn =
170
+ export const createQueryFn =
169
171
  <
170
172
  const Action extends QueryDef<Input, Output>,
171
173
  Input = Action extends QueryDef<infer I, infer _> ? I : never,
@@ -196,7 +198,32 @@ const createQueryFn =
196
198
  }
197
199
  };
198
200
 
199
- const createNewNamespaceFn =
201
+ export function buildNamespaceSurface(
202
+ scenarioDeps: CreateScenarioOptions,
203
+ namespaceName: string
204
+ ): Namespace {
205
+ const namespacedKubectl = scenarioDeps.kubectl.extends({
206
+ namespace: namespaceName,
207
+ });
208
+ const namespacedDeps = { ...scenarioDeps, kubectl: namespacedKubectl };
209
+ return {
210
+ name: namespaceName,
211
+ apply: createMutateFn(namespacedDeps, apply),
212
+ create: createMutateFn(namespacedDeps, create),
213
+ assertApplyError: createMutateFn(namespacedDeps, assertApplyError),
214
+ assertCreateError: createMutateFn(namespacedDeps, assertCreateError),
215
+ applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
216
+ delete: createOneWayMutateFn(namespacedDeps, deleteResource),
217
+ label: createOneWayMutateFn(namespacedDeps, label),
218
+ get: createQueryFn(namespacedDeps, get),
219
+ assert: createQueryFn(namespacedDeps, assert),
220
+ assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
221
+ assertList: createQueryFn(namespacedDeps, assertList),
222
+ assertOne: createQueryFn(namespacedDeps, assertOne),
223
+ };
224
+ }
225
+
226
+ export const createNewNamespaceFn =
200
227
  (scenarioDeps: CreateScenarioOptions) =>
201
228
  async (
202
229
  name?: CreateNamespaceInput,
@@ -206,49 +233,38 @@ const createNewNamespaceFn =
206
233
  name,
207
234
  options
208
235
  );
209
- const { kubectl } = scenarioDeps;
210
- const namespacedKubectl = kubectl.extends({ namespace: namespaceName });
211
- const namespacedDeps = { ...scenarioDeps, kubectl: namespacedKubectl };
212
- return {
213
- name: namespaceName,
214
- apply: createMutateFn(namespacedDeps, apply),
215
- create: createMutateFn(namespacedDeps, create),
216
- assertApplyError: createMutateFn(namespacedDeps, assertApplyError),
217
- assertCreateError: createMutateFn(namespacedDeps, assertCreateError),
218
- applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
219
- delete: createOneWayMutateFn(namespacedDeps, deleteResource),
220
- label: createOneWayMutateFn(namespacedDeps, label),
221
- get: createQueryFn(namespacedDeps, get),
222
- assert: createQueryFn(namespacedDeps, assert),
223
- assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
224
- assertList: createQueryFn(namespacedDeps, assertList),
225
- assertOne: createQueryFn(namespacedDeps, assertOne),
226
- };
236
+ return buildNamespaceSurface(scenarioDeps, namespaceName);
237
+ };
238
+
239
+ export const createUseNamespaceFn =
240
+ (scenarioDeps: CreateScenarioOptions) =>
241
+ async (
242
+ name: string,
243
+ options?: undefined | ActionOptions
244
+ ): Promise<Namespace> => {
245
+ const { kubectl, recorder } = scenarioDeps;
246
+ const description = `useNamespace("${name}")`;
247
+ recorder.record("ActionStart", { description });
248
+ let actionErr: undefined | Error;
249
+ try {
250
+ await retryUntil(() => kubectl.get("Namespace", name), {
251
+ ...options,
252
+ recorder,
253
+ });
254
+ return buildNamespaceSurface(scenarioDeps, name);
255
+ } catch (error) {
256
+ actionErr = error as Error;
257
+ throw error;
258
+ } finally {
259
+ recorder.record("ActionEnd", {
260
+ ok: actionErr === undefined,
261
+ error: actionErr,
262
+ });
263
+ }
227
264
  };
228
265
 
229
266
  const createUseClusterFn =
230
267
  (scenarioDeps: CreateScenarioOptions) =>
231
- // biome-ignore lint/suspicious/useAwait: 将来的にクラスターの接続確認などを行うため、今から async を使用する
232
- async (cluster: ClusterReference): Promise<Cluster> => {
233
- const { kubectl } = scenarioDeps;
234
- const clusterKubectl = kubectl.extends({
235
- context: cluster.context,
236
- kubeconfig: cluster.kubeconfig,
237
- });
238
- const clusterDeps = { ...scenarioDeps, kubectl: clusterKubectl };
239
- return {
240
- apply: createMutateFn(clusterDeps, apply),
241
- create: createMutateFn(clusterDeps, create),
242
- assertApplyError: createMutateFn(clusterDeps, assertApplyError),
243
- assertCreateError: createMutateFn(clusterDeps, assertCreateError),
244
- applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
245
- delete: createOneWayMutateFn(clusterDeps, deleteResource),
246
- label: createOneWayMutateFn(clusterDeps, label),
247
- get: createQueryFn(clusterDeps, get),
248
- assert: createQueryFn(clusterDeps, assert),
249
- assertAbsence: createQueryFn(clusterDeps, assertAbsence),
250
- assertList: createQueryFn(clusterDeps, assertList),
251
- assertOne: createQueryFn(clusterDeps, assertOne),
252
- newNamespace: createNewNamespaceFn(clusterDeps),
253
- };
268
+ (cluster: ClusterReference, options?: ActionOptions): Promise<Cluster> => {
269
+ return resolveCluster(scenarioDeps, cluster, options);
254
270
  };
@@ -0,0 +1,187 @@
1
+ import { mkdir, unlink } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { apply } from "../actions/apply";
4
+ import { applyStatus } from "../actions/apply-status";
5
+ import { assert } from "../actions/assert";
6
+ import { assertAbsence } from "../actions/assert-absence";
7
+ import { assertApplyError } from "../actions/assert-apply-error";
8
+ import { assertCreateError } from "../actions/assert-create-error";
9
+ import { assertList } from "../actions/assert-list";
10
+ import { assertOne } from "../actions/assert-one";
11
+ import { create } from "../actions/create";
12
+ import { deleteResource } from "../actions/delete";
13
+ import { get } from "../actions/get";
14
+ import { label } from "../actions/label";
15
+ import type {
16
+ ActionOptions,
17
+ Cluster,
18
+ ClusterReference,
19
+ ClusterResourceReference,
20
+ } from "../apis";
21
+ import { retryUntil } from "../retry";
22
+ import { parseYaml } from "../yaml";
23
+ import {
24
+ type CreateScenarioOptions,
25
+ createMutateFn,
26
+ createNewNamespaceFn,
27
+ createOneWayMutateFn,
28
+ createQueryFn,
29
+ createUseNamespaceFn,
30
+ } from "./index";
31
+
32
+ function isClusterResourceReference(
33
+ ref: ClusterReference
34
+ ): ref is ClusterResourceReference {
35
+ return (
36
+ "apiVersion" in ref &&
37
+ "kind" in ref &&
38
+ (ref as unknown as Record<string, unknown>)["kind"] === "Cluster"
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Builds a {@link Cluster} object with all action methods wired to the given deps.
44
+ */
45
+ export function buildClusterSurface(deps: CreateScenarioOptions): Cluster {
46
+ return {
47
+ apply: createMutateFn(deps, apply),
48
+ create: createMutateFn(deps, create),
49
+ assertApplyError: createMutateFn(deps, assertApplyError),
50
+ assertCreateError: createMutateFn(deps, assertCreateError),
51
+ applyStatus: createOneWayMutateFn(deps, applyStatus),
52
+ delete: createOneWayMutateFn(deps, deleteResource),
53
+ label: createOneWayMutateFn(deps, label),
54
+ get: createQueryFn(deps, get),
55
+ assert: createQueryFn(deps, assert),
56
+ assertAbsence: createQueryFn(deps, assertAbsence),
57
+ assertList: createQueryFn(deps, assertList),
58
+ assertOne: createQueryFn(deps, assertOne),
59
+ newNamespace: createNewNamespaceFn(deps),
60
+ useNamespace: createUseNamespaceFn(deps),
61
+ useCluster: (
62
+ cluster: ClusterReference,
63
+ options?: ActionOptions
64
+ ): Promise<Cluster> => {
65
+ return resolveCluster(deps, cluster, options);
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolves a {@link ClusterReference} to a fully-wired {@link Cluster} surface.
72
+ *
73
+ * - For {@link StaticClusterReference}: extends kubectl with context/kubeconfig.
74
+ * - For {@link ClusterResourceReference}: polls CAPI until the cluster is ready,
75
+ * fetches the kubeconfig secret, writes a temp file, and returns a Cluster
76
+ * bound to that kubeconfig.
77
+ */
78
+ export async function resolveCluster(
79
+ deps: CreateScenarioOptions,
80
+ cluster: ClusterReference,
81
+ options?: ActionOptions
82
+ ): Promise<Cluster> {
83
+ if (!isClusterResourceReference(cluster)) {
84
+ // Static cluster reference — same as the original implementation.
85
+ const clusterKubectl = deps.kubectl.extends({
86
+ context: cluster.context,
87
+ kubeconfig: cluster.kubeconfig,
88
+ });
89
+ return buildClusterSurface({ ...deps, kubectl: clusterKubectl });
90
+ }
91
+
92
+ // CAPI cluster resource reference
93
+ const { apiVersion, name, namespace } = cluster;
94
+ const { recorder, kubectl, reverting } = deps;
95
+
96
+ const resourceType = `Cluster.${apiVersion.split("/")[1]}.${apiVersion.split("/")[0]}`;
97
+ const description = `useCluster(${apiVersion} Cluster "${name}" in "${namespace}")`;
98
+
99
+ recorder.record("ActionStart", { description });
100
+
101
+ let actionErr: undefined | Error;
102
+ try {
103
+ // Poll until the CAPI Cluster is ready.
104
+ await retryUntil(
105
+ async () => {
106
+ const yaml = await kubectl.get(resourceType, name, { namespace });
107
+ const resource = parseYaml(yaml) as Record<string, unknown>;
108
+ const status = resource["status"] as
109
+ | Record<string, unknown>
110
+ | undefined;
111
+ if (!status) {
112
+ throw new Error(
113
+ `Cluster "${name}" in "${namespace}" has no status yet`
114
+ );
115
+ }
116
+
117
+ const version = apiVersion.split("/")[1]; // "v1beta1" or "v1beta2"
118
+ let conditions: Array<Record<string, unknown>> | undefined;
119
+ let targetType: string;
120
+
121
+ if (version === "v1beta1") {
122
+ conditions = status["conditions"] as
123
+ | Array<Record<string, unknown>>
124
+ | undefined;
125
+ targetType = "Ready";
126
+ } else if (version === "v1beta2") {
127
+ const v1beta2 = status["v1beta2"] as
128
+ | Record<string, unknown>
129
+ | undefined;
130
+ conditions = v1beta2?.["conditions"] as
131
+ | Array<Record<string, unknown>>
132
+ | undefined;
133
+ targetType = "Available";
134
+ } else {
135
+ throw new Error(`Unsupported CAPI API version: ${apiVersion}`);
136
+ }
137
+
138
+ if (!conditions) {
139
+ throw new Error(
140
+ `Cluster "${name}" in "${namespace}" has no conditions`
141
+ );
142
+ }
143
+
144
+ const condition = conditions.find((c) => c["type"] === targetType);
145
+ if (!condition || condition["status"] !== "True") {
146
+ throw new Error(
147
+ `Cluster "${name}" in "${namespace}" is not ready (${targetType} != True)`
148
+ );
149
+ }
150
+ },
151
+ { ...options, recorder }
152
+ );
153
+
154
+ // Fetch kubeconfig from the cluster's secret.
155
+ const kubeconfigData = await retryUntil(
156
+ () => kubectl.getSecretData(`${name}-kubeconfig`, "value", { namespace }),
157
+ { timeout: "30s", interval: "1s", recorder }
158
+ );
159
+
160
+ // Write kubeconfig to a temp file.
161
+ const dir = `${tmpdir()}/kest/kubeconfigs`;
162
+ await mkdir(dir, { recursive: true });
163
+ const tempPath = `${dir}/capi-${namespace}-${name}-${crypto.randomUUID()}.yaml`;
164
+ await Bun.write(tempPath, kubeconfigData);
165
+
166
+ // Register cleanup to delete the temp file.
167
+ reverting.add(async () => {
168
+ await unlink(tempPath);
169
+ });
170
+
171
+ // Clear the parent's context so kubectl uses the current-context from the
172
+ // child kubeconfig file rather than the management cluster's context name.
173
+ const clusterKubectl = kubectl.extends({
174
+ kubeconfig: tempPath,
175
+ context: undefined,
176
+ });
177
+ return buildClusterSurface({ ...deps, kubectl: clusterKubectl });
178
+ } catch (error) {
179
+ actionErr = error as Error;
180
+ throw error;
181
+ } finally {
182
+ recorder.record("ActionEnd", {
183
+ ok: actionErr === undefined,
184
+ error: actionErr,
185
+ });
186
+ }
187
+ }
@@ -1,142 +0,0 @@
1
- bun test v1.3.8 (b64edcb4)
2
-
3
- example/example.test.ts:
4
- # Example: applies ConfigMap using YAML, file import, and object literal
5
-
6
- ## Scenario Overview
7
-
8
- | # | Action | Status | Duration |
9
- |---|--------|--------|----------|
10
- | 1 | Create `Namespace` with auto-generated name | ✅ | 186ms |
11
- | 2 | Apply `ConfigMap` "my-config-1" | ✅ | 55ms |
12
- | 3 | Apply a resource | ✅ | 117ms |
13
- | 4 | Apply `ConfigMap` "my-config-3" | ✅ | 135ms |
14
- | 5 | Assert `ConfigMap` "my-config-1" | ✅ | 63ms |
15
-
16
- ## Scenario Details
17
-
18
- ### Given: a new namespace exists
19
-
20
- **✅ Create `Namespace` with auto-generated name**
21
-
22
- ```shell
23
- kubectl create -f - <<EOF
24
- apiVersion: v1
25
- kind: Namespace
26
- metadata:
27
- name: kest-x173q
28
- EOF
29
- ```
30
-
31
- ```text title="stdout"
32
- namespace/kest-x173q created
33
- ```
34
-
35
- ### When: I apply ConfigMaps using different formats
36
-
37
- **✅ Apply `ConfigMap` "my-config-1"**
38
-
39
- ```shell
40
- kubectl apply -f - -n kest-x173q <<EOF
41
- apiVersion: v1
42
- kind: ConfigMap
43
- metadata:
44
- name: my-config-1
45
- data:
46
- mode: demo-1
47
- EOF
48
- ```
49
-
50
- ```text title="stdout"
51
- configmap/my-config-1 created
52
- ```
53
-
54
- **✅ Apply a resource**
55
-
56
- ```shell
57
- kubectl apply -f - -n kest-x173q <<EOF
58
- apiVersion: v1
59
- kind: ConfigMap
60
- metadata:
61
- name: my-config-2
62
- data:
63
- mode: demo-2
64
- EOF
65
- ```
66
-
67
- ```text title="stdout"
68
- configmap/my-config-2 created
69
- ```
70
-
71
- **✅ Apply `ConfigMap` "my-config-3"**
72
-
73
- ```shell
74
- kubectl apply -f - -n kest-x173q <<EOF
75
- apiVersion: v1
76
- kind: ConfigMap
77
- metadata:
78
- name: my-config-3
79
- data:
80
- mode: demo-3
81
- EOF
82
- ```
83
-
84
- ```text title="stdout"
85
- configmap/my-config-3 created
86
- ```
87
-
88
- ### Then: the ConfigMap should have the expected data
89
-
90
- **✅ Assert `ConfigMap` "my-config-1"**
91
-
92
- ```shell
93
- kubectl get ConfigMap my-config-1 -o yaml -n kest-x173q
94
- ```
95
-
96
- ```yaml title="stdout"
97
- apiVersion: v1
98
- data:
99
- mode: demo-1
100
- kind: ConfigMap
101
- metadata:
102
- annotations:
103
- kubectl.kubernetes.io/last-applied-configuration: |
104
- {"apiVersion":"v1","data":{"mode":"demo-1"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"my-config-1","namespace":"kest-x173q"}}
105
- creationTimestamp: "2026-03-02T02:39:15Z"
106
- name: my-config-1
107
- namespace: kest-x173q
108
- resourceVersion: "585"
109
- uid: 25339d11-cf6b-44a5-a680-1548d1e80252
110
- ```
111
-
112
- ### Cleanup
113
-
114
- | # | Action | Status | Duration |
115
- |---|--------|--------|----------|
116
- | 1 | Apply `ConfigMap` "my-config-3" | ✅ | 75ms |
117
- | 2 | Apply a resource | ✅ | 41ms |
118
- | 3 | Apply `ConfigMap` "my-config-1" | ✅ | 36ms |
119
- | 4 | Create `Namespace` with auto-generated name | ✅ | 5.103s |
120
-
121
- ```shellsession
122
- $ kubectl delete ConfigMap/my-config-3 --ignore-not-found -n kest-x173q
123
- configmap "my-config-3" deleted
124
-
125
- $ kubectl delete ConfigMap/my-config-2 --ignore-not-found -n kest-x173q
126
- configmap "my-config-2" deleted
127
-
128
- $ kubectl delete ConfigMap/my-config-1 --ignore-not-found -n kest-x173q
129
- configmap "my-config-1" deleted
130
-
131
- $ kubectl delete Namespace/kest-x173q --ignore-not-found
132
- namespace "kest-x173q" deleted
133
- ```
134
-
135
- **Total: 5.815s** (Actions: 556ms, Cleanup: 5.255s)
136
-
137
-
138
- 1 pass
139
- 7 filtered out
140
- 0 fail
141
- 1 expect() calls
142
- Ran 1 test across 1 file. [6.04s]