@appthrust/kest 0.1.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/LICENSE +21 -0
- package/README.md +412 -0
- package/example/config-map.yaml +6 -0
- package/example/example-report.md +744 -0
- package/example/example.test.ts +225 -0
- package/example/hello-world-crd.yaml +145 -0
- package/package.json +62 -0
- package/ts/actions/apply-namespace.ts +29 -0
- package/ts/actions/apply-status.ts +23 -0
- package/ts/actions/apply.ts +25 -0
- package/ts/actions/assert-list.ts +63 -0
- package/ts/actions/assert.ts +34 -0
- package/ts/actions/exec.ts +21 -0
- package/ts/actions/get.ts +40 -0
- package/ts/actions/types.ts +48 -0
- package/ts/apis/index.ts +788 -0
- package/ts/bdd/index.ts +30 -0
- package/ts/duration/index.ts +171 -0
- package/ts/index.ts +3 -0
- package/ts/k8s-resource/index.ts +120 -0
- package/ts/kubectl/index.ts +351 -0
- package/ts/recording/index.ts +134 -0
- package/ts/reporter/index.ts +0 -0
- package/ts/reporter/interface.ts +5 -0
- package/ts/reporter/markdown.ts +962 -0
- package/ts/retry.ts +112 -0
- package/ts/reverting/index.ts +36 -0
- package/ts/scenario/index.ts +220 -0
- package/ts/test.ts +127 -0
- package/ts/workspace/find-up.ts +20 -0
- package/ts/workspace/index.ts +25 -0
- package/ts/yaml/index.ts +14 -0
|
@@ -0,0 +1,225 @@
|
|
|
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("Example: applies ConfigMap using YAML, file import, and object literal", async (s) => {
|
|
17
|
+
s.given("a new namespace exists");
|
|
18
|
+
// A random namespace is created
|
|
19
|
+
const ns = await s.newNamespace();
|
|
20
|
+
|
|
21
|
+
s.when("I apply ConfigMaps using different formats");
|
|
22
|
+
// Example of applying YAML
|
|
23
|
+
await ns.apply(`
|
|
24
|
+
apiVersion: v1
|
|
25
|
+
kind: ConfigMap
|
|
26
|
+
metadata:
|
|
27
|
+
name: my-config-1
|
|
28
|
+
data:
|
|
29
|
+
mode: demo-1
|
|
30
|
+
`);
|
|
31
|
+
// Example of applying from a file
|
|
32
|
+
await ns.apply(import("./config-map.yaml"));
|
|
33
|
+
// Example of applying with an object literal
|
|
34
|
+
await ns.apply<ConfigMap>({
|
|
35
|
+
apiVersion: "v1",
|
|
36
|
+
kind: "ConfigMap",
|
|
37
|
+
metadata: { name: "my-config-3" },
|
|
38
|
+
data: { mode: "demo-3" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
s.then("the ConfigMap should have the expected data");
|
|
42
|
+
await ns.assert<ConfigMap>({
|
|
43
|
+
apiVersion: "v1",
|
|
44
|
+
kind: "ConfigMap",
|
|
45
|
+
name: "my-config-1",
|
|
46
|
+
// Retries until the following condition is satisfied
|
|
47
|
+
test() {
|
|
48
|
+
expect(this).toMatchObject({
|
|
49
|
+
data: {
|
|
50
|
+
mode: "demo-1",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Resources created during the test are deleted in reverse order of creation.
|
|
57
|
+
// 1. ConfigMap/my-config-3
|
|
58
|
+
// 2. ConfigMap/my-config-2
|
|
59
|
+
// 3. ConfigMap/my-config-1
|
|
60
|
+
// 4. Namespace
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("Example: asserts a non-existent ConfigMap (expected to fail)", async (s) => {
|
|
64
|
+
s.given("a new namespace exists");
|
|
65
|
+
const ns = await s.newNamespace();
|
|
66
|
+
|
|
67
|
+
s.then("asserting a non-existent ConfigMap should fail");
|
|
68
|
+
// This will fail because no ConfigMap named "non-existent-config" has been created
|
|
69
|
+
await ns.assert<ConfigMap>({
|
|
70
|
+
apiVersion: "v1",
|
|
71
|
+
kind: "ConfigMap",
|
|
72
|
+
name: "non-existent-config",
|
|
73
|
+
test() {
|
|
74
|
+
expect(this).toMatchObject({
|
|
75
|
+
data: {
|
|
76
|
+
mode: "should-not-exist",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("Example: manages resources across multiple clusters", async (s) => {
|
|
84
|
+
s.given("two clusters are configured");
|
|
85
|
+
// Use cluster 1
|
|
86
|
+
const cluster1 = await s.useCluster({
|
|
87
|
+
kubeconfig: ".kubeconfig.yaml", // optional
|
|
88
|
+
context: "kind-kest-test-cluster-1", // optional
|
|
89
|
+
});
|
|
90
|
+
// Use cluster 2
|
|
91
|
+
const cluster2 = await s.useCluster({
|
|
92
|
+
context: "kind-kest-test-cluster-2",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
s.when("I apply ConfigMaps to each cluster");
|
|
96
|
+
// Apply ConfigMap using cluster 1
|
|
97
|
+
await cluster1.apply<ConfigMap>({
|
|
98
|
+
apiVersion: "v1",
|
|
99
|
+
kind: "ConfigMap",
|
|
100
|
+
metadata: { name: "my-config-1" },
|
|
101
|
+
data: { mode: "demo-1" },
|
|
102
|
+
});
|
|
103
|
+
// Apply ConfigMap using cluster 2
|
|
104
|
+
await cluster2.apply<ConfigMap>({
|
|
105
|
+
apiVersion: "v1",
|
|
106
|
+
kind: "ConfigMap",
|
|
107
|
+
metadata: { name: "my-config-2" },
|
|
108
|
+
data: { mode: "demo-2" },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
s.then("each cluster should have its ConfigMap");
|
|
112
|
+
// Verify ConfigMap using cluster 1
|
|
113
|
+
await cluster1.assert<ConfigMap>({
|
|
114
|
+
apiVersion: "v1",
|
|
115
|
+
kind: "ConfigMap",
|
|
116
|
+
name: "my-config-1",
|
|
117
|
+
test() {
|
|
118
|
+
expect(this.data["mode"]).toBe("demo-1");
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
// Verify ConfigMap using cluster 2
|
|
122
|
+
await cluster2.assert<ConfigMap>({
|
|
123
|
+
apiVersion: "v1",
|
|
124
|
+
kind: "ConfigMap",
|
|
125
|
+
name: "my-config-2",
|
|
126
|
+
test() {
|
|
127
|
+
expect(this.data["mode"]).toBe("demo-2");
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("Example: executes shell commands with revert cleanup", async (s) => {
|
|
133
|
+
const name = await s.exec({
|
|
134
|
+
do: async ({ $ }) => {
|
|
135
|
+
const name = "my-secret";
|
|
136
|
+
await $`kubectl create secret generic ${name} --from-literal=username=admin --from-literal=password=123456`.quiet();
|
|
137
|
+
return name;
|
|
138
|
+
},
|
|
139
|
+
// revert is optional. If specified, it runs during revert. Useful for cleaning up resources created by do.
|
|
140
|
+
revert: async ({ $ }) => {
|
|
141
|
+
await $`kubectl delete secret my-secret`.quiet();
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
expect(name).toBe("my-secret");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("Example: asserts resource presence and absence in a list", async (s) => {
|
|
148
|
+
s.given("a new namespace exists");
|
|
149
|
+
const ns = await s.newNamespace();
|
|
150
|
+
|
|
151
|
+
s.when("I apply a single ConfigMap");
|
|
152
|
+
await ns.apply<ConfigMap>({
|
|
153
|
+
apiVersion: "v1",
|
|
154
|
+
kind: "ConfigMap",
|
|
155
|
+
metadata: { name: "my-config-1" },
|
|
156
|
+
data: { mode: "demo-1" },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
s.then("the list should contain only the applied ConfigMap");
|
|
160
|
+
await ns.assertList<ConfigMap>({
|
|
161
|
+
apiVersion: "v1",
|
|
162
|
+
kind: "ConfigMap",
|
|
163
|
+
test() {
|
|
164
|
+
// Verify existence
|
|
165
|
+
expect(this.some((c) => c.metadata.name === "my-config-1")).toBe(true);
|
|
166
|
+
// Verify non-existence
|
|
167
|
+
expect(this.some((c) => c.metadata.name === "my-config-2")).toBe(false);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("Example: applies status subresource to custom resource", async (s) => {
|
|
173
|
+
s.given("a HelloWorld custom resource definition exists");
|
|
174
|
+
await s.apply(import("./hello-world-crd.yaml"));
|
|
175
|
+
|
|
176
|
+
s.given("a new namespace exists");
|
|
177
|
+
const ns = await s.newNamespace();
|
|
178
|
+
|
|
179
|
+
s.given("a HelloWorld custom resource is created");
|
|
180
|
+
await ns.apply({
|
|
181
|
+
apiVersion: "example.com/v2",
|
|
182
|
+
kind: "HelloWorld",
|
|
183
|
+
metadata: { name: "my-hello-world" },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
s.when("I apply a status with Ready condition");
|
|
187
|
+
await ns.applyStatus({
|
|
188
|
+
apiVersion: "example.com/v2",
|
|
189
|
+
kind: "HelloWorld",
|
|
190
|
+
metadata: { name: "my-hello-world" },
|
|
191
|
+
status: {
|
|
192
|
+
conditions: [
|
|
193
|
+
{
|
|
194
|
+
type: "Ready",
|
|
195
|
+
status: "True",
|
|
196
|
+
lastTransitionTime: "2026-02-05T00:00:00Z",
|
|
197
|
+
reason: "ManuallySet",
|
|
198
|
+
message: "Ready condition set to True via server-side apply.",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
s.then("the HelloWorld should have the Ready status");
|
|
205
|
+
await ns.assert({
|
|
206
|
+
apiVersion: "example.com/v2",
|
|
207
|
+
kind: "HelloWorld",
|
|
208
|
+
name: "my-hello-world",
|
|
209
|
+
test() {
|
|
210
|
+
expect(this).toMatchObject({
|
|
211
|
+
status: {
|
|
212
|
+
conditions: [
|
|
213
|
+
{
|
|
214
|
+
type: "Ready",
|
|
215
|
+
status: "True",
|
|
216
|
+
lastTransitionTime: "2026-02-05T00:00:00Z",
|
|
217
|
+
reason: "ManuallySet",
|
|
218
|
+
message: "Ready condition set to True via server-side apply.",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
apiVersion: apiextensions.k8s.io/v1
|
|
2
|
+
kind: CustomResourceDefinition
|
|
3
|
+
metadata:
|
|
4
|
+
name: helloworlds.example.com
|
|
5
|
+
spec:
|
|
6
|
+
group: example.com
|
|
7
|
+
names:
|
|
8
|
+
kind: HelloWorld
|
|
9
|
+
listKind: HelloWorldList
|
|
10
|
+
plural: helloworlds
|
|
11
|
+
singular: helloworld
|
|
12
|
+
scope: Namespaced
|
|
13
|
+
versions:
|
|
14
|
+
- name: v1
|
|
15
|
+
served: true
|
|
16
|
+
storage: true
|
|
17
|
+
subresources:
|
|
18
|
+
status: {}
|
|
19
|
+
schema:
|
|
20
|
+
openAPIV3Schema:
|
|
21
|
+
type: object
|
|
22
|
+
properties:
|
|
23
|
+
apiVersion:
|
|
24
|
+
type: string
|
|
25
|
+
kind:
|
|
26
|
+
type: string
|
|
27
|
+
metadata:
|
|
28
|
+
type: object
|
|
29
|
+
status:
|
|
30
|
+
description: HelloWorldStatus defines the observed state of HelloWorld.
|
|
31
|
+
type: object
|
|
32
|
+
properties:
|
|
33
|
+
conditions:
|
|
34
|
+
description: Conditions represent the latest available observations of an object's state.
|
|
35
|
+
type: array
|
|
36
|
+
x-kubernetes-list-type: map
|
|
37
|
+
x-kubernetes-list-map-keys:
|
|
38
|
+
- type
|
|
39
|
+
items:
|
|
40
|
+
description: Condition contains details for one aspect of the current state of this API Resource.
|
|
41
|
+
type: object
|
|
42
|
+
required:
|
|
43
|
+
- type
|
|
44
|
+
- status
|
|
45
|
+
- lastTransitionTime
|
|
46
|
+
- reason
|
|
47
|
+
- message
|
|
48
|
+
properties:
|
|
49
|
+
type:
|
|
50
|
+
description: Type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
51
|
+
type: string
|
|
52
|
+
maxLength: 316
|
|
53
|
+
pattern: ^[A-Za-z0-9]([A-Za-z0-9_.-]*[A-Za-z0-9])?$
|
|
54
|
+
status:
|
|
55
|
+
description: Status of the condition, one of True, False, Unknown.
|
|
56
|
+
type: string
|
|
57
|
+
enum:
|
|
58
|
+
- "True"
|
|
59
|
+
- "False"
|
|
60
|
+
- "Unknown"
|
|
61
|
+
observedGeneration:
|
|
62
|
+
description: observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
63
|
+
type: integer
|
|
64
|
+
format: int64
|
|
65
|
+
minimum: 0
|
|
66
|
+
lastTransitionTime:
|
|
67
|
+
description: lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
68
|
+
type: string
|
|
69
|
+
format: date-time
|
|
70
|
+
reason:
|
|
71
|
+
description: reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
72
|
+
type: string
|
|
73
|
+
minLength: 1
|
|
74
|
+
maxLength: 1024
|
|
75
|
+
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
76
|
+
message:
|
|
77
|
+
description: message is a human readable message indicating details about the transition.
|
|
78
|
+
type: string
|
|
79
|
+
maxLength: 32768
|
|
80
|
+
- name: v2
|
|
81
|
+
served: true
|
|
82
|
+
storage: false
|
|
83
|
+
subresources:
|
|
84
|
+
status: {}
|
|
85
|
+
schema:
|
|
86
|
+
openAPIV3Schema:
|
|
87
|
+
type: object
|
|
88
|
+
properties:
|
|
89
|
+
apiVersion:
|
|
90
|
+
type: string
|
|
91
|
+
kind:
|
|
92
|
+
type: string
|
|
93
|
+
metadata:
|
|
94
|
+
type: object
|
|
95
|
+
status:
|
|
96
|
+
description: HelloWorldStatus defines the observed state of HelloWorld.
|
|
97
|
+
type: object
|
|
98
|
+
properties:
|
|
99
|
+
conditions:
|
|
100
|
+
description: Conditions represent the latest available observations of an object's state.
|
|
101
|
+
type: array
|
|
102
|
+
x-kubernetes-list-type: map
|
|
103
|
+
x-kubernetes-list-map-keys:
|
|
104
|
+
- type
|
|
105
|
+
items:
|
|
106
|
+
description: Condition contains details for one aspect of the current state of this API Resource.
|
|
107
|
+
type: object
|
|
108
|
+
required:
|
|
109
|
+
- type
|
|
110
|
+
- status
|
|
111
|
+
- lastTransitionTime
|
|
112
|
+
- reason
|
|
113
|
+
- message
|
|
114
|
+
properties:
|
|
115
|
+
type:
|
|
116
|
+
description: Type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
117
|
+
type: string
|
|
118
|
+
maxLength: 316
|
|
119
|
+
pattern: ^[A-Za-z0-9]([A-Za-z0-9_.-]*[A-Za-z0-9])?$
|
|
120
|
+
status:
|
|
121
|
+
description: Status of the condition, one of True, False, Unknown.
|
|
122
|
+
type: string
|
|
123
|
+
enum:
|
|
124
|
+
- "True"
|
|
125
|
+
- "False"
|
|
126
|
+
- "Unknown"
|
|
127
|
+
observedGeneration:
|
|
128
|
+
description: observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
129
|
+
type: integer
|
|
130
|
+
format: int64
|
|
131
|
+
minimum: 0
|
|
132
|
+
lastTransitionTime:
|
|
133
|
+
description: lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
134
|
+
type: string
|
|
135
|
+
format: date-time
|
|
136
|
+
reason:
|
|
137
|
+
description: reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
138
|
+
type: string
|
|
139
|
+
minLength: 1
|
|
140
|
+
maxLength: 1024
|
|
141
|
+
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
142
|
+
message:
|
|
143
|
+
description: message is a human readable message indicating details about the transition.
|
|
144
|
+
type: string
|
|
145
|
+
maxLength: 32768
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@appthrust/kest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript-first E2E testing framework for Kubernetes controllers, operators, and webhooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "ts/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./ts/index.ts",
|
|
10
|
+
"default": "./ts/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"ts/**/*.ts",
|
|
15
|
+
"!ts/**/*.test.ts",
|
|
16
|
+
"example/",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"kubernetes",
|
|
22
|
+
"k8s",
|
|
23
|
+
"e2e",
|
|
24
|
+
"testing",
|
|
25
|
+
"bun",
|
|
26
|
+
"kubectl",
|
|
27
|
+
"operator",
|
|
28
|
+
"controller",
|
|
29
|
+
"webhook",
|
|
30
|
+
"typescript"
|
|
31
|
+
],
|
|
32
|
+
"author": "appthrust",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/appthrust/kest.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/appthrust/kest#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/appthrust/kest/issues"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.3.8"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@shikijs/cli": "^3.21.0",
|
|
50
|
+
"@suin/shell-escape-arg": "^0.1.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@biomejs/biome": "^2.3.13",
|
|
54
|
+
"@suin/biome.json": "^0.1.0",
|
|
55
|
+
"@tsconfig/bun": "^1.0.10",
|
|
56
|
+
"@tsconfig/strictest": "^2.0.8",
|
|
57
|
+
"@types/bun": "^1.3.7",
|
|
58
|
+
"bun": "^1.3.8",
|
|
59
|
+
"outdent": "^0.8.0",
|
|
60
|
+
"oxfmt": "^0.28.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { apply } from "./apply";
|
|
2
|
+
import type { MutateDef } from "./types";
|
|
3
|
+
|
|
4
|
+
export const applyNamespace = {
|
|
5
|
+
type: "mutate",
|
|
6
|
+
name: "ApplyNamespace",
|
|
7
|
+
mutate:
|
|
8
|
+
({ kubectl }) =>
|
|
9
|
+
async (namespaceName) => {
|
|
10
|
+
const name = namespaceName ?? `kest-${randomConsonantDigits(5)}`;
|
|
11
|
+
const { revert } = await apply.mutate({ kubectl })({
|
|
12
|
+
apiVersion: "v1",
|
|
13
|
+
kind: "Namespace",
|
|
14
|
+
metadata: {
|
|
15
|
+
name,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
return { revert, output: name };
|
|
19
|
+
},
|
|
20
|
+
} satisfies MutateDef<undefined | string, string>;
|
|
21
|
+
|
|
22
|
+
function randomConsonantDigits(length = 8): string {
|
|
23
|
+
const chars = "bcdfghjklmnpqrstvwxyz0123456789";
|
|
24
|
+
let result = "";
|
|
25
|
+
for (let i = 0; i < length; i++) {
|
|
26
|
+
result += chars[Math.floor(Math.random() * chars.length)];
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ApplyingManifest } from "../apis";
|
|
2
|
+
import { parseK8sResourceAny } from "../k8s-resource";
|
|
3
|
+
import type { OneWayMutateDef } from "./types";
|
|
4
|
+
|
|
5
|
+
export const applyStatus = {
|
|
6
|
+
type: "oneWayMutate",
|
|
7
|
+
name: "ApplyStatus",
|
|
8
|
+
mutate:
|
|
9
|
+
({ kubectl }) =>
|
|
10
|
+
async (manifest) => {
|
|
11
|
+
const result = await parseK8sResourceAny(manifest);
|
|
12
|
+
if (!result.ok) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Invalid Kubernetes resource: ${result.violations.join(", ")}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (result.value["status"] === undefined) {
|
|
18
|
+
throw new Error("Invalid Kubernetes resource: status is required");
|
|
19
|
+
}
|
|
20
|
+
await kubectl.applyStatus(result.value);
|
|
21
|
+
return undefined;
|
|
22
|
+
},
|
|
23
|
+
} satisfies OneWayMutateDef<ApplyingManifest, void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ApplyingManifest } from "../apis";
|
|
2
|
+
import { parseK8sResourceAny } from "../k8s-resource";
|
|
3
|
+
import type { MutateDef } from "./types";
|
|
4
|
+
|
|
5
|
+
export const apply = {
|
|
6
|
+
type: "mutate",
|
|
7
|
+
name: "Apply",
|
|
8
|
+
mutate:
|
|
9
|
+
({ kubectl }) =>
|
|
10
|
+
async (manifest) => {
|
|
11
|
+
const result = await parseK8sResourceAny(manifest);
|
|
12
|
+
if (!result.ok) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Invalid Kubernetes resource: ${result.violations.join(", ")}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
await kubectl.apply(result.value);
|
|
18
|
+
return {
|
|
19
|
+
async revert() {
|
|
20
|
+
await kubectl.delete(result.value.kind, result.value.metadata.name);
|
|
21
|
+
},
|
|
22
|
+
output: undefined,
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
} satisfies MutateDef<ApplyingManifest, void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { YAML } from "bun";
|
|
2
|
+
import type { K8sResource, ResourceListTest } from "../apis";
|
|
3
|
+
import { parseK8sResourceListYaml } from "../k8s-resource";
|
|
4
|
+
import type { Deps, QueryDef } from "./types";
|
|
5
|
+
|
|
6
|
+
export const assertList = {
|
|
7
|
+
type: "query",
|
|
8
|
+
name: "AssertList",
|
|
9
|
+
query:
|
|
10
|
+
({ kubectl }: Deps) =>
|
|
11
|
+
async <T extends K8sResource>(
|
|
12
|
+
condition: ResourceListTest<T>
|
|
13
|
+
): Promise<Array<T>> => {
|
|
14
|
+
const yaml = await kubectl.list(toKubectlType(condition));
|
|
15
|
+
const result = parseK8sResourceListYaml(yaml);
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Invalid Kubernetes resource list: ${result.violations.join(", ")}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fetched = result.value;
|
|
23
|
+
for (const item of fetched) {
|
|
24
|
+
assertSameGVK(condition, item);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const typed = fetched as Array<T>;
|
|
28
|
+
await condition.test.call(typed, typed);
|
|
29
|
+
return typed;
|
|
30
|
+
},
|
|
31
|
+
} satisfies QueryDef<ResourceListTest, Array<K8sResource>>;
|
|
32
|
+
|
|
33
|
+
function isSameGVK<T extends K8sResource>(
|
|
34
|
+
finding: Pick<ResourceListTest<T>, "apiVersion" | "kind">,
|
|
35
|
+
fetched: K8sResource
|
|
36
|
+
): fetched is T {
|
|
37
|
+
return (
|
|
38
|
+
finding.apiVersion === fetched.apiVersion && finding.kind === fetched.kind
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertSameGVK<T extends K8sResource>(
|
|
43
|
+
finding: Pick<ResourceListTest<T>, "apiVersion" | "kind">,
|
|
44
|
+
fetched: K8sResource
|
|
45
|
+
): void {
|
|
46
|
+
if (!isSameGVK(finding, fetched)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Fetched Kubernetes resource: ${YAML.stringify(fetched)} is not expected: ${YAML.stringify(finding)}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toKubectlType<T extends K8sResource>(
|
|
54
|
+
condition: Pick<ResourceListTest<T>, "apiVersion" | "kind">
|
|
55
|
+
): string {
|
|
56
|
+
const { kind, apiVersion } = condition;
|
|
57
|
+
const [group, version] = apiVersion.split("/");
|
|
58
|
+
if (version === undefined) {
|
|
59
|
+
// core group cannot include version in the type
|
|
60
|
+
return kind;
|
|
61
|
+
}
|
|
62
|
+
return [kind, version, group].filter(Boolean).join(".");
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { K8sResource, ResourceTest } from "../apis";
|
|
2
|
+
import { parseK8sResourceYaml } from "../k8s-resource";
|
|
3
|
+
import type { Deps, QueryDef } from "./types";
|
|
4
|
+
|
|
5
|
+
export const assert = {
|
|
6
|
+
type: "query",
|
|
7
|
+
name: "Assert",
|
|
8
|
+
query:
|
|
9
|
+
({ kubectl }: Deps) =>
|
|
10
|
+
async <T extends K8sResource>(condition: ResourceTest<T>): Promise<T> => {
|
|
11
|
+
const yaml = await kubectl.get(toKubectlType(condition), condition.name);
|
|
12
|
+
const result = parseK8sResourceYaml(yaml);
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Invalid Kubernetes resource: ${result.violations.join(", ")}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const fetched = result.value as T;
|
|
19
|
+
await condition.test.call(fetched, fetched);
|
|
20
|
+
return fetched;
|
|
21
|
+
},
|
|
22
|
+
} satisfies QueryDef<ResourceTest, K8sResource>;
|
|
23
|
+
|
|
24
|
+
function toKubectlType<T extends K8sResource>(
|
|
25
|
+
condition: ResourceTest<T>
|
|
26
|
+
): string {
|
|
27
|
+
const { kind, apiVersion } = condition;
|
|
28
|
+
const [group, version] = apiVersion.split("/");
|
|
29
|
+
if (version === undefined) {
|
|
30
|
+
// core group cannot include version in the type
|
|
31
|
+
return kind;
|
|
32
|
+
}
|
|
33
|
+
return [kind, version, group].filter(Boolean).join(".");
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { $ as bunShell } from "bun";
|
|
2
|
+
import type { ExecContext, ExecInput } from "../apis";
|
|
3
|
+
import type { MutateDef } from "./types";
|
|
4
|
+
|
|
5
|
+
const noopRevert = (): Promise<void> => Promise.resolve();
|
|
6
|
+
|
|
7
|
+
/** Executes arbitrary processing and registers optional revert. */
|
|
8
|
+
export const exec = {
|
|
9
|
+
type: "mutate",
|
|
10
|
+
name: "Exec",
|
|
11
|
+
mutate: () => async (input) => {
|
|
12
|
+
const context: ExecContext = { $: bunShell };
|
|
13
|
+
const output = await input.do(context);
|
|
14
|
+
const revert = input.revert;
|
|
15
|
+
return {
|
|
16
|
+
output,
|
|
17
|
+
revert: revert ? () => revert(context) : noopRevert,
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: 本当はunknownにしたいが、createMutateFnとの噛み合せが難しいためanyにしている
|
|
21
|
+
} satisfies MutateDef<ExecInput<any>, unknown>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { YAML } from "bun";
|
|
2
|
+
import type { K8sResource, K8sResourceReference } from "../apis";
|
|
3
|
+
import { assert } from "./assert";
|
|
4
|
+
import type { QueryDef } from "./types";
|
|
5
|
+
|
|
6
|
+
export const get = {
|
|
7
|
+
type: "query",
|
|
8
|
+
name: "Get",
|
|
9
|
+
query:
|
|
10
|
+
({ kubectl }) =>
|
|
11
|
+
async <T extends K8sResource>(
|
|
12
|
+
finding: K8sResourceReference<T>
|
|
13
|
+
): Promise<T> =>
|
|
14
|
+
assert.query({ kubectl })({
|
|
15
|
+
...finding,
|
|
16
|
+
test: (fetched) => assertSameGVK<T>(finding, fetched),
|
|
17
|
+
}),
|
|
18
|
+
} satisfies QueryDef<K8sResourceReference, K8sResource>;
|
|
19
|
+
|
|
20
|
+
function isSameGVK<T extends K8sResource>(
|
|
21
|
+
finding: K8sResourceReference<T>,
|
|
22
|
+
fetched: K8sResource
|
|
23
|
+
): fetched is T {
|
|
24
|
+
return (
|
|
25
|
+
finding.apiVersion === fetched.apiVersion &&
|
|
26
|
+
finding.kind === fetched.kind &&
|
|
27
|
+
finding.name === fetched.metadata.name
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertSameGVK<T extends K8sResource>(
|
|
32
|
+
finding: K8sResourceReference<T>,
|
|
33
|
+
fetched: K8sResource
|
|
34
|
+
): void {
|
|
35
|
+
if (!isSameGVK<T>(finding, fetched)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Fetched Kubernetes resource: ${YAML.stringify(fetched)} is not expected: ${YAML.stringify(finding)}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|