@appthrust/kest 0.14.0 → 0.16.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,52 @@ test("resources sync across clusters", async (s) => {
185
185
  });
186
186
  ```
187
187
 
188
+ #### CAPI Dynamic Clusters
189
+
190
+ Kest can resolve [Cluster API](https://cluster-api.sigs.k8s.io/) (CAPI) provisioned clusters automatically. Pass a `ClusterResourceReference` to `useCluster` and Kest will:
191
+
192
+ 1. Poll the CAPI `Cluster` resource until it reports ready
193
+ 2. Fetch the kubeconfig from the `<name>-kubeconfig` Secret (data key `value`)
194
+ 3. Write a temporary kubeconfig file and bind kubectl to it
195
+ 4. Register cleanup to delete the temp file when the test ends
196
+
197
+ ```ts
198
+ test("CAPI multi-hop: management → child → workload", async (s) => {
199
+ // 1. Connect to the permanent management cluster
200
+ const pmc = await s.useCluster({ context: "kind-pmc" });
201
+
202
+ // 2. Wait for the child cluster to become ready, then connect
203
+ const cc = await pmc.useCluster({
204
+ apiVersion: "cluster.x-k8s.io/v1beta1",
205
+ kind: "Cluster",
206
+ name: "child-cluster",
207
+ namespace: "capi-system",
208
+ });
209
+
210
+ // 3. Chain further — the child cluster manages a workload cluster
211
+ const wlc = await cc.useCluster({
212
+ apiVersion: "cluster.x-k8s.io/v1beta1",
213
+ kind: "Cluster",
214
+ name: "workload-cluster",
215
+ namespace: "default",
216
+ });
217
+
218
+ const ns = await wlc.newNamespace();
219
+ await ns.apply(/* ... */);
220
+ });
221
+ ```
222
+
223
+ **Readiness conditions:**
224
+
225
+ | API version | Condition type |
226
+ | ------------------------------- | -------------- |
227
+ | `cluster.x-k8s.io/v1beta1` | `Ready` |
228
+ | `cluster.x-k8s.io/v1beta2` | `Available` |
229
+
230
+ **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.
231
+
232
+ **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.
233
+
188
234
  ### Status Subresource Support
189
235
 
190
236
  Simulate controller behavior by applying status subresources via server-side apply:
@@ -535,12 +581,12 @@ The top-level API surface available in every test callback.
535
581
  | `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
536
582
  | `generateName(prefix)` | Generate a random-suffix name (statistical uniqueness) |
537
583
  | `exec(input, options?)` | Execute shell commands with optional revert |
538
- | `useCluster(ref)` | Create a cluster-bound API surface |
584
+ | `useCluster(ref, options?)` | Create a cluster-bound API surface |
539
585
  | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
540
586
 
541
587
  ### Namespace / Cluster
542
588
 
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`.
589
+ 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` and `useCluster`.
544
590
 
545
591
  `Namespace` also exposes a `name` property:
546
592
 
@@ -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.14.0",
3
+ "version": "0.16.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,5 +1,4 @@
1
1
  import { generateName } from "../naming";
2
- import { create } from "./create";
3
2
  import type { MutateDef } from "./types";
4
3
 
5
4
  /**
@@ -22,14 +21,20 @@ export const createNamespace = {
22
21
  ({ kubectl }) =>
23
22
  async (input) => {
24
23
  const name = resolveNamespaceName(input);
25
- const { revert } = await create.mutate({ kubectl })({
24
+ await kubectl.create({
26
25
  apiVersion: "v1",
27
26
  kind: "Namespace",
28
- metadata: {
29
- name,
30
- },
27
+ metadata: { name },
31
28
  });
32
- return { revert, output: name };
29
+ return {
30
+ async revert() {
31
+ await kubectl.delete("Namespace", name, {
32
+ ignoreNotFound: true,
33
+ wait: false,
34
+ });
35
+ },
36
+ output: name,
37
+ };
33
38
  },
34
39
  describe: (input) => {
35
40
  if (input === undefined) {
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,22 @@ 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
+ * ```
534
547
  */
535
- useCluster(cluster: ClusterReference): Promise<Cluster>;
548
+ useCluster(
549
+ cluster: ClusterReference,
550
+ options?: undefined | ActionOptions
551
+ ): Promise<Cluster>;
536
552
 
537
553
  // BDD(behavior-driven development) actions
538
554
 
@@ -879,6 +895,35 @@ export interface Cluster {
879
895
  name?: undefined | string | { readonly generateName: string },
880
896
  options?: undefined | ActionOptions
881
897
  ): Promise<Namespace>;
898
+
899
+ // Multi-cluster actions
900
+
901
+ /**
902
+ * Creates a cluster-bound API surface from this cluster.
903
+ *
904
+ * This enables multi-hop cluster access — for example, using a management
905
+ * cluster to reach a CAPI-provisioned workload cluster.
906
+ *
907
+ * @param cluster - Target cluster reference (static or CAPI resource).
908
+ * @param options - Retry options such as timeout and polling interval.
909
+ *
910
+ * @example
911
+ * ```ts
912
+ * // From a management cluster, obtain a workload cluster handle
913
+ * const mgmt = await s.useCluster({ context: "kind-mgmt" });
914
+ * const workload = await mgmt.useCluster({
915
+ * apiVersion: "cluster.x-k8s.io/v1beta1",
916
+ * kind: "Cluster",
917
+ * name: "workload-1",
918
+ * namespace: "default",
919
+ * });
920
+ * const ns = await workload.newNamespace("test");
921
+ * ```
922
+ */
923
+ useCluster(
924
+ cluster: ClusterReference,
925
+ options?: undefined | ActionOptions
926
+ ): Promise<Cluster>;
882
927
  }
883
928
 
884
929
  /**
@@ -1483,9 +1528,14 @@ export interface AssertCreateErrorInput<T extends K8sResource = K8sResource> {
1483
1528
  }
1484
1529
 
1485
1530
  /**
1486
- * Kubernetes cluster selector for {@link Scenario.useCluster}.
1531
+ * Static cluster selector that points to an existing kubeconfig/context.
1532
+ *
1533
+ * Use this when the cluster is already provisioned and you have direct access
1534
+ * to its kubeconfig file or context name.
1535
+ *
1536
+ * @see {@link ClusterReference} — the union accepted by {@link Scenario.useCluster}.
1487
1537
  */
1488
- export interface ClusterReference {
1538
+ export interface StaticClusterReference {
1489
1539
  /**
1490
1540
  * Path to a kubeconfig file to use for this cluster.
1491
1541
  */
@@ -1497,6 +1547,63 @@ export interface ClusterReference {
1497
1547
  readonly context?: undefined | string;
1498
1548
  }
1499
1549
 
1550
+ /**
1551
+ * Supported Cluster API versions for CAPI-provisioned clusters.
1552
+ */
1553
+ export type CapiClusterApiVersion =
1554
+ | "cluster.x-k8s.io/v1beta1"
1555
+ | "cluster.x-k8s.io/v1beta2";
1556
+
1557
+ /**
1558
+ * Reference to a CAPI-provisioned cluster resource.
1559
+ *
1560
+ * Kest resolves the cluster's kubeconfig by reading the corresponding
1561
+ * `<name>-kubeconfig` Secret from the management cluster and passes it to
1562
+ * kubectl automatically.
1563
+ *
1564
+ * @example
1565
+ * ```ts
1566
+ * const c = await s.useCluster({
1567
+ * apiVersion: "cluster.x-k8s.io/v1beta1",
1568
+ * kind: "Cluster",
1569
+ * name: "workload-1",
1570
+ * namespace: "default",
1571
+ * });
1572
+ * ```
1573
+ */
1574
+ export interface CapiClusterResourceReference {
1575
+ /** Cluster API group version. */
1576
+ readonly apiVersion: CapiClusterApiVersion;
1577
+
1578
+ /** Must be `"Cluster"`. */
1579
+ readonly kind: "Cluster";
1580
+
1581
+ /** Name of the CAPI Cluster resource. */
1582
+ readonly name: string;
1583
+
1584
+ /** Namespace where the CAPI Cluster resource lives. */
1585
+ readonly namespace: string;
1586
+ }
1587
+
1588
+ /**
1589
+ * Union of cluster resource reference types.
1590
+ *
1591
+ * Currently only CAPI clusters are supported, but this alias leaves room for
1592
+ * additional providers in the future.
1593
+ */
1594
+ export type ClusterResourceReference = CapiClusterResourceReference;
1595
+
1596
+ /**
1597
+ * Kubernetes cluster selector for {@link Scenario.useCluster} and
1598
+ * {@link Cluster.useCluster}.
1599
+ *
1600
+ * - {@link StaticClusterReference} — points to an existing kubeconfig/context.
1601
+ * - {@link ClusterResourceReference} — references a CAPI Cluster resource.
1602
+ */
1603
+ export type ClusterReference =
1604
+ | StaticClusterReference
1605
+ | ClusterResourceReference;
1606
+
1500
1607
  /**
1501
1608
  * A Kubernetes manifest accepted by Kest actions.
1502
1609
  *
@@ -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 {
@@ -163,6 +183,8 @@ export interface KubectlDeleteOptions {
163
183
  * that does not exist succeeds silently instead of failing.
164
184
  */
165
185
  readonly ignoreNotFound?: undefined | boolean;
186
+ /** When false, adds --wait=false so delete returns immediately. */
187
+ readonly wait?: undefined | boolean;
166
188
  readonly context?: undefined | KubectlContext;
167
189
  }
168
190
 
@@ -304,6 +326,9 @@ export class RealKubectl implements Kubectl {
304
326
  if (options?.ignoreNotFound) {
305
327
  args.push("--ignore-not-found");
306
328
  }
329
+ if (options?.wait === false) {
330
+ args.push("--wait=false");
331
+ }
307
332
  return await this.runKubectl({
308
333
  args,
309
334
  stdoutLanguage: "text",
@@ -312,6 +337,21 @@ export class RealKubectl implements Kubectl {
312
337
  });
313
338
  }
314
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
+
315
355
  async label(
316
356
  resource: string,
317
357
  name: string,
@@ -372,7 +412,7 @@ export class RealKubectl implements Kubectl {
372
412
  };
373
413
  this.recorder.record("CommandResult", {
374
414
  exitCode,
375
- stdout,
415
+ stdout: params.redactStdout ? "<redacted>" : stdout,
376
416
  stderr,
377
417
  stdoutLanguage: params.stdoutLanguage,
378
418
  stderrLanguage: params.stderrLanguage,
@@ -401,6 +441,7 @@ interface ExecParams {
401
441
  readonly stdoutLanguage?: undefined | string;
402
442
  readonly stderrLanguage?: undefined | string;
403
443
  readonly overrideContext?: undefined | KubectlContext;
444
+ readonly redactStdout?: undefined | boolean;
404
445
  }
405
446
 
406
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>;
@@ -74,7 +75,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
74
75
  };
75
76
  }
76
77
 
77
- interface CreateScenarioOptions {
78
+ export interface CreateScenarioOptions {
78
79
  readonly name: string;
79
80
  readonly recorder: Recorder;
80
81
  readonly kubectl: Kubectl;
@@ -82,7 +83,7 @@ interface CreateScenarioOptions {
82
83
  readonly reporter: Reporter;
83
84
  }
84
85
 
85
- const createMutateFn =
86
+ export const createMutateFn =
86
87
  <
87
88
  const Action extends MutateDef<Input, Output>,
88
89
  Input = Action extends MutateDef<infer I, infer _> ? I : never,
@@ -134,7 +135,7 @@ const createMutateFn =
134
135
  }
135
136
  };
136
137
 
137
- const createOneWayMutateFn =
138
+ export const createOneWayMutateFn =
138
139
  <
139
140
  const Action extends OneWayMutateDef<Input, Output>,
140
141
  Input = Action extends OneWayMutateDef<infer I, infer _> ? I : never,
@@ -165,7 +166,7 @@ const createOneWayMutateFn =
165
166
  }
166
167
  };
167
168
 
168
- const createQueryFn =
169
+ export const createQueryFn =
169
170
  <
170
171
  const Action extends QueryDef<Input, Output>,
171
172
  Input = Action extends QueryDef<infer I, infer _> ? I : never,
@@ -196,7 +197,7 @@ const createQueryFn =
196
197
  }
197
198
  };
198
199
 
199
- const createNewNamespaceFn =
200
+ export const createNewNamespaceFn =
200
201
  (scenarioDeps: CreateScenarioOptions) =>
201
202
  async (
202
203
  name?: CreateNamespaceInput,
@@ -228,27 +229,6 @@ const createNewNamespaceFn =
228
229
 
229
230
  const createUseClusterFn =
230
231
  (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
- };
232
+ (cluster: ClusterReference, options?: ActionOptions): Promise<Cluster> => {
233
+ return resolveCluster(scenarioDeps, cluster, options);
254
234
  };
@@ -0,0 +1,185 @@
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
+ } from "./index";
30
+
31
+ function isClusterResourceReference(
32
+ ref: ClusterReference
33
+ ): ref is ClusterResourceReference {
34
+ return (
35
+ "apiVersion" in ref &&
36
+ "kind" in ref &&
37
+ (ref as unknown as Record<string, unknown>)["kind"] === "Cluster"
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Builds a {@link Cluster} object with all action methods wired to the given deps.
43
+ */
44
+ export function buildClusterSurface(deps: CreateScenarioOptions): Cluster {
45
+ return {
46
+ apply: createMutateFn(deps, apply),
47
+ create: createMutateFn(deps, create),
48
+ assertApplyError: createMutateFn(deps, assertApplyError),
49
+ assertCreateError: createMutateFn(deps, assertCreateError),
50
+ applyStatus: createOneWayMutateFn(deps, applyStatus),
51
+ delete: createOneWayMutateFn(deps, deleteResource),
52
+ label: createOneWayMutateFn(deps, label),
53
+ get: createQueryFn(deps, get),
54
+ assert: createQueryFn(deps, assert),
55
+ assertAbsence: createQueryFn(deps, assertAbsence),
56
+ assertList: createQueryFn(deps, assertList),
57
+ assertOne: createQueryFn(deps, assertOne),
58
+ newNamespace: createNewNamespaceFn(deps),
59
+ useCluster: (
60
+ cluster: ClusterReference,
61
+ options?: ActionOptions
62
+ ): Promise<Cluster> => {
63
+ return resolveCluster(deps, cluster, options);
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Resolves a {@link ClusterReference} to a fully-wired {@link Cluster} surface.
70
+ *
71
+ * - For {@link StaticClusterReference}: extends kubectl with context/kubeconfig.
72
+ * - For {@link ClusterResourceReference}: polls CAPI until the cluster is ready,
73
+ * fetches the kubeconfig secret, writes a temp file, and returns a Cluster
74
+ * bound to that kubeconfig.
75
+ */
76
+ export async function resolveCluster(
77
+ deps: CreateScenarioOptions,
78
+ cluster: ClusterReference,
79
+ options?: ActionOptions
80
+ ): Promise<Cluster> {
81
+ if (!isClusterResourceReference(cluster)) {
82
+ // Static cluster reference — same as the original implementation.
83
+ const clusterKubectl = deps.kubectl.extends({
84
+ context: cluster.context,
85
+ kubeconfig: cluster.kubeconfig,
86
+ });
87
+ return buildClusterSurface({ ...deps, kubectl: clusterKubectl });
88
+ }
89
+
90
+ // CAPI cluster resource reference
91
+ const { apiVersion, name, namespace } = cluster;
92
+ const { recorder, kubectl, reverting } = deps;
93
+
94
+ const resourceType = `Cluster.${apiVersion.split("/")[1]}.${apiVersion.split("/")[0]}`;
95
+ const description = `useCluster(${apiVersion} Cluster "${name}" in "${namespace}")`;
96
+
97
+ recorder.record("ActionStart", { description });
98
+
99
+ let actionErr: undefined | Error;
100
+ try {
101
+ // Poll until the CAPI Cluster is ready.
102
+ await retryUntil(
103
+ async () => {
104
+ const yaml = await kubectl.get(resourceType, name, { namespace });
105
+ const resource = parseYaml(yaml) as Record<string, unknown>;
106
+ const status = resource["status"] as
107
+ | Record<string, unknown>
108
+ | undefined;
109
+ if (!status) {
110
+ throw new Error(
111
+ `Cluster "${name}" in "${namespace}" has no status yet`
112
+ );
113
+ }
114
+
115
+ const version = apiVersion.split("/")[1]; // "v1beta1" or "v1beta2"
116
+ let conditions: Array<Record<string, unknown>> | undefined;
117
+ let targetType: string;
118
+
119
+ if (version === "v1beta1") {
120
+ conditions = status["conditions"] as
121
+ | Array<Record<string, unknown>>
122
+ | undefined;
123
+ targetType = "Ready";
124
+ } else if (version === "v1beta2") {
125
+ const v1beta2 = status["v1beta2"] as
126
+ | Record<string, unknown>
127
+ | undefined;
128
+ conditions = v1beta2?.["conditions"] as
129
+ | Array<Record<string, unknown>>
130
+ | undefined;
131
+ targetType = "Available";
132
+ } else {
133
+ throw new Error(`Unsupported CAPI API version: ${apiVersion}`);
134
+ }
135
+
136
+ if (!conditions) {
137
+ throw new Error(
138
+ `Cluster "${name}" in "${namespace}" has no conditions`
139
+ );
140
+ }
141
+
142
+ const condition = conditions.find((c) => c["type"] === targetType);
143
+ if (!condition || condition["status"] !== "True") {
144
+ throw new Error(
145
+ `Cluster "${name}" in "${namespace}" is not ready (${targetType} != True)`
146
+ );
147
+ }
148
+ },
149
+ { ...options, recorder }
150
+ );
151
+
152
+ // Fetch kubeconfig from the cluster's secret.
153
+ const kubeconfigData = await retryUntil(
154
+ () => kubectl.getSecretData(`${name}-kubeconfig`, "value", { namespace }),
155
+ { timeout: "30s", interval: "1s", recorder }
156
+ );
157
+
158
+ // Write kubeconfig to a temp file.
159
+ const dir = `${tmpdir()}/kest/kubeconfigs`;
160
+ await mkdir(dir, { recursive: true });
161
+ const tempPath = `${dir}/capi-${namespace}-${name}-${crypto.randomUUID()}.yaml`;
162
+ await Bun.write(tempPath, kubeconfigData);
163
+
164
+ // Register cleanup to delete the temp file.
165
+ reverting.add(async () => {
166
+ await unlink(tempPath);
167
+ });
168
+
169
+ // Clear the parent's context so kubectl uses the current-context from the
170
+ // child kubeconfig file rather than the management cluster's context name.
171
+ const clusterKubectl = kubectl.extends({
172
+ kubeconfig: tempPath,
173
+ context: undefined,
174
+ });
175
+ return buildClusterSurface({ ...deps, kubectl: clusterKubectl });
176
+ } catch (error) {
177
+ actionErr = error as Error;
178
+ throw error;
179
+ } finally {
180
+ recorder.record("ActionEnd", {
181
+ ok: actionErr === undefined,
182
+ error: actionErr,
183
+ });
184
+ }
185
+ }
@@ -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]