@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 +75 -2
- package/example/dynamic-cluster.test.ts +157 -0
- package/package.json +1 -1
- package/ts/apis/index.ts +176 -6
- package/ts/kubectl/index.ts +37 -1
- package/ts/scenario/index.ts +62 -46
- package/ts/scenario/use-cluster.ts +187 -0
- package/example/example-report-with-duration.md +0 -142
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)`
|
|
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
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
|
|
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
|
-
|
|
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}
|
|
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
|
-
*
|
|
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
|
|
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
|
*
|
package/ts/kubectl/index.ts
CHANGED
|
@@ -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 {
|
package/ts/scenario/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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]
|