@intentius/chant-lexicon-k8s 0.30.0 → 0.31.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/dist/api/classify.d.ts +55 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- package/dist/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +41 -24
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +27 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +38 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +131 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -0
- package/src/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -0
- package/src/describe-resources.test.ts +396 -191
- package/src/describe-resources.ts +134 -118
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +63 -35
- package/src/generated/operations.json +2156 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +2 -1
- package/src/op/activities/kubectl.test.ts +148 -0
- package/src/op/activities/kubectl.ts +86 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/spec/parse.ts +93 -1
|
@@ -1,34 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-lexicon lifecycle integration (#163) — Kubernetes row.
|
|
3
3
|
*
|
|
4
|
-
* Drives the REAL k8sPlugin through core's live-import driver and the
|
|
5
|
-
* path, with the
|
|
4
|
+
* Drives the REAL k8sPlugin through core's live-import driver and the
|
|
5
|
+
* changeset path, with the cluster edge faked at
|
|
6
|
+
* `@kubernetes/client-node`'s request layer (chant #1074, previously at
|
|
7
|
+
* `kubectl`). The plugin's own `describeResources` / `exportResources` are what
|
|
8
|
+
* run; only the socket is replaced, and only a literal kubeconfig is ever read.
|
|
6
9
|
*/
|
|
7
10
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
8
11
|
import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
|
|
9
12
|
import { tmpdir } from "node:os";
|
|
10
13
|
import { join } from "node:path";
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The plugin reaches the cluster through `./api/connect`'s default connector,
|
|
17
|
+
* which is the one thing a test must not let run for real: it would read the
|
|
18
|
+
* developer's kubeconfig. Replacing the module swaps in a connector backed by
|
|
19
|
+
* `fakeCluster`, which still builds a real client over a literal kubeconfig.
|
|
20
|
+
*/
|
|
21
|
+
const clusterState: { objects: Record<string, unknown>; fail?: (path: string) => { status: number; body: unknown } | undefined } = {
|
|
22
|
+
objects: {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
vi.mock("./api/connect", async () => {
|
|
26
|
+
const { fakeCluster } = await import("./api/fake-cluster");
|
|
15
27
|
return {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const r = execMock(cmd);
|
|
22
|
-
queueMicrotask(() =>
|
|
23
|
-
r instanceof Error
|
|
24
|
-
? cb(r, { stdout: "", stderr: "" })
|
|
25
|
-
: cb(null, r as { stdout: string; stderr: string }),
|
|
26
|
-
);
|
|
27
|
-
},
|
|
28
|
+
defaultK8sConnector: (options: unknown) =>
|
|
29
|
+
fakeCluster({
|
|
30
|
+
objects: clusterState.objects as never,
|
|
31
|
+
respond: (req) => clusterState.fail?.(req.path),
|
|
32
|
+
}).connector(options as never),
|
|
28
33
|
};
|
|
29
34
|
});
|
|
30
35
|
|
|
31
36
|
const { k8sPlugin } = await import("./plugin");
|
|
37
|
+
const { objectKey } = await import("./api/fake-cluster");
|
|
38
|
+
const { statusBody } = await import("@intentius/chant-k8s-client/testing");
|
|
32
39
|
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
33
40
|
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
34
41
|
const { normalizeObservation } = await import("@intentius/chant/observation");
|
|
@@ -42,24 +49,24 @@ const liveDeployment = {
|
|
|
42
49
|
spec: { replicas: 3, selector: { matchLabels: { app: "web" } } },
|
|
43
50
|
};
|
|
44
51
|
|
|
45
|
-
const
|
|
52
|
+
const observedWeb = {
|
|
53
|
+
apiVersion: "apps/v1",
|
|
54
|
+
kind: "Deployment",
|
|
55
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-1" },
|
|
56
|
+
status: { readyReplicas: 3, replicas: 3 },
|
|
57
|
+
};
|
|
46
58
|
|
|
47
59
|
describe("k8s lifecycle integration (#163)", () => {
|
|
48
|
-
beforeEach(() =>
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
clusterState.objects = {};
|
|
62
|
+
clusterState.fail = undefined;
|
|
63
|
+
});
|
|
49
64
|
|
|
50
65
|
test("live-import driver: real exportResources → IR → generated source", async () => {
|
|
51
|
-
|
|
52
|
-
cmd?.includes("get deployment.apps")
|
|
53
|
-
? { stdout: JSON.stringify({ items: [liveDeployment] }), stderr: "" }
|
|
54
|
-
: emptyList,
|
|
55
|
-
);
|
|
66
|
+
clusterState.objects = { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment };
|
|
56
67
|
const output = mkdtempSync(join(tmpdir(), "chant-k8s-li-"));
|
|
57
68
|
try {
|
|
58
|
-
const result = await liveImportFromPlugins([k8sPlugin], {
|
|
59
|
-
environment: "prod",
|
|
60
|
-
output,
|
|
61
|
-
force: true,
|
|
62
|
-
});
|
|
69
|
+
const result = await liveImportFromPlugins([k8sPlugin], { environment: "prod", output, force: true });
|
|
63
70
|
expect(result.success).toBe(true);
|
|
64
71
|
expect(result.generatedFiles.length).toBeGreaterThan(0);
|
|
65
72
|
const all = readdirSync(output)
|
|
@@ -73,17 +80,7 @@ describe("k8s lifecycle integration (#163)", () => {
|
|
|
73
80
|
});
|
|
74
81
|
|
|
75
82
|
test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
|
|
76
|
-
|
|
77
|
-
cmd?.includes("deployment.apps web")
|
|
78
|
-
? {
|
|
79
|
-
stdout: JSON.stringify({
|
|
80
|
-
metadata: { name: "web", namespace: "prod", uid: "uid-1" },
|
|
81
|
-
status: { readyReplicas: 3, replicas: 3 },
|
|
82
|
-
}),
|
|
83
|
-
stderr: "",
|
|
84
|
-
}
|
|
85
|
-
: new Error("not found"),
|
|
86
|
-
);
|
|
83
|
+
clusterState.objects = { [objectKey("apps/v1", "Deployment", "web", "prod")]: observedWeb };
|
|
87
84
|
|
|
88
85
|
const { resources: observedNow } = normalizeObservation(
|
|
89
86
|
await k8sPlugin.describeResources!({
|
|
@@ -97,45 +94,32 @@ describe("k8s lifecycle integration (#163)", () => {
|
|
|
97
94
|
);
|
|
98
95
|
expect(observedNow.web?.type).toBe("K8s::Apps::Deployment");
|
|
99
96
|
|
|
100
|
-
const cs = buildChangeSet("prod", {
|
|
101
|
-
declared: new Set(["webSvc"]),
|
|
102
|
-
observedNow,
|
|
103
|
-
observedThen: undefined,
|
|
104
|
-
});
|
|
97
|
+
const cs = buildChangeSet("prod", { declared: new Set(["webSvc"]), observedNow, observedThen: undefined });
|
|
105
98
|
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
|
|
106
99
|
expect(byName.webSvc).toBe("create");
|
|
107
100
|
expect(byName.web).toBe("adopt");
|
|
108
101
|
|
|
109
|
-
const cs2 = buildChangeSet("prod", {
|
|
110
|
-
declared: new Set(["web"]),
|
|
111
|
-
observedNow,
|
|
112
|
-
observedThen: undefined,
|
|
113
|
-
});
|
|
102
|
+
const cs2 = buildChangeSet("prod", { declared: new Set(["web"]), observedNow, observedThen: undefined });
|
|
114
103
|
expect(cs2.entries.find((e) => e.name === "web")!.action).toBe("noop");
|
|
115
104
|
});
|
|
116
105
|
|
|
117
106
|
/**
|
|
118
|
-
* The #1089 chain, end to end on the real plugin
|
|
119
|
-
*
|
|
120
|
-
*
|
|
107
|
+
* The #1089 chain, end to end on the real plugin. The entity that used to
|
|
108
|
+
* demonstrate it — a CRD with no `KUBECTL_RESOURCE` entry — is no longer a
|
|
109
|
+
* hole, because #1074 removed the map; an RBAC-denied read is what produces
|
|
110
|
+
* a hole now, and it has to survive describe → plan → status the same way.
|
|
121
111
|
*/
|
|
122
|
-
test("tri-state chain: an unreadable
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
status: { readyReplicas: 3, replicas: 3 },
|
|
129
|
-
}),
|
|
130
|
-
stderr: "",
|
|
131
|
-
}
|
|
132
|
-
: new Error('Error from server (NotFound): deployments.apps "gone" not found'),
|
|
133
|
-
);
|
|
112
|
+
test("tri-state chain: an unreadable entity stays unobserved through describe → plan → status (#1089)", async () => {
|
|
113
|
+
clusterState.objects = { [objectKey("apps/v1", "Deployment", "web", "prod")]: observedWeb };
|
|
114
|
+
clusterState.fail = (path) =>
|
|
115
|
+
path.endsWith("/deployments/widget")
|
|
116
|
+
? { status: 403, body: statusBody(403, "Forbidden", 'deployments.apps "widget" is forbidden') }
|
|
117
|
+
: undefined;
|
|
134
118
|
|
|
135
119
|
const entities = new Map([
|
|
136
120
|
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
|
|
137
121
|
["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone", namespace: "prod" } } }],
|
|
138
|
-
["widget", { entityType: "K8s::
|
|
122
|
+
["widget", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "widget", namespace: "prod" } } }],
|
|
139
123
|
]);
|
|
140
124
|
|
|
141
125
|
// 1. describe — three declared entities, three different verdicts.
|
|
@@ -148,10 +132,11 @@ describe("k8s lifecycle integration (#163)", () => {
|
|
|
148
132
|
}),
|
|
149
133
|
);
|
|
150
134
|
expect(Object.keys(observed.resources)).toEqual(["web"]);
|
|
151
|
-
expect(observed.unobserved.widget.reason).toBe("
|
|
135
|
+
expect(observed.unobserved.widget.reason).toBe("no-credentials");
|
|
152
136
|
expect(observed.unobserved.gone).toBeUndefined(); // NotFound is an absence
|
|
153
137
|
|
|
154
|
-
// 2. plan — the
|
|
138
|
+
// 2. plan — the unreadable one is `unobserved`; only the confirmed-absent
|
|
139
|
+
// one is a create.
|
|
155
140
|
const cs = buildChangeSet("prod", {
|
|
156
141
|
declared: new Set(entities.keys()),
|
|
157
142
|
observedNow: observed.resources,
|
|
@@ -163,15 +148,64 @@ describe("k8s lifecycle integration (#163)", () => {
|
|
|
163
148
|
|
|
164
149
|
// 3. status — a recorded component whose entity was never read reports
|
|
165
150
|
// `unknown`, not `stale`, and carries no `live` boolean at all.
|
|
166
|
-
const rows = reconcileStatus(
|
|
167
|
-
|
|
168
|
-
|
|
151
|
+
const rows = reconcileStatus(
|
|
152
|
+
"prod",
|
|
153
|
+
[
|
|
154
|
+
{
|
|
155
|
+
component: "widget",
|
|
156
|
+
env: "prod",
|
|
157
|
+
digest: "sha256:abc",
|
|
158
|
+
gitSha: "g",
|
|
159
|
+
runId: "r",
|
|
160
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
161
|
+
actor: "ci",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
{ liveEvidence: liveEvidenceFromChangeSet(cs) },
|
|
165
|
+
);
|
|
169
166
|
const widgetRow = rows.find((r) => r.component === "widget")!;
|
|
170
167
|
expect(widgetRow.reconciliation).toBe("unknown");
|
|
171
168
|
expect(widgetRow.live).toBeUndefined();
|
|
172
|
-
expect(widgetRow.unobserved?.reason).toBe("
|
|
169
|
+
expect(widgetRow.unobserved?.reason).toBe("no-credentials");
|
|
173
170
|
expect(widgetRow.detail).toContain("could not be observed");
|
|
174
171
|
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* chant #1074's coverage claim, on the real plugin: a CRD that the old path
|
|
175
|
+
* warn-skipped as `unsupported-kind` now reads like anything else.
|
|
176
|
+
*/
|
|
177
|
+
test("a CRD is observed, not skipped (chant #1074)", async () => {
|
|
178
|
+
clusterState.objects = {
|
|
179
|
+
[objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: {
|
|
180
|
+
apiVersion: "ray.io/v1",
|
|
181
|
+
kind: "RayCluster",
|
|
182
|
+
metadata: { name: "ml", namespace: "ray", uid: "uid-ray" },
|
|
183
|
+
status: { phase: "ready" },
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const observed = normalizeObservation(
|
|
188
|
+
await k8sPlugin.describeResources!({
|
|
189
|
+
environment: "prod",
|
|
190
|
+
buildOutput: "",
|
|
191
|
+
entityNames: ["ml"],
|
|
192
|
+
entities: new Map([
|
|
193
|
+
["ml", { entityType: "K8s::Ray::RayCluster", props: { metadata: { name: "ml", namespace: "ray" } } }],
|
|
194
|
+
]),
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect(observed.unobserved).toEqual({});
|
|
199
|
+
expect(observed.resources.ml?.physicalId).toBe("uid-ray");
|
|
200
|
+
|
|
201
|
+
const cs = buildChangeSet("prod", {
|
|
202
|
+
declared: new Set(["ml"]),
|
|
203
|
+
observedNow: observed.resources,
|
|
204
|
+
observedThen: undefined,
|
|
205
|
+
unobserved: observed.unobserved,
|
|
206
|
+
});
|
|
207
|
+
expect(cs.entries.find((e) => e.name === "ml")!.action).toBe("noop");
|
|
208
|
+
});
|
|
175
209
|
});
|
|
176
210
|
|
|
177
211
|
// The shared conformance suite (#1089) — every observing lexicon runs it.
|
|
@@ -179,18 +213,22 @@ describeObservationConformance({
|
|
|
179
213
|
lexicon: "k8s",
|
|
180
214
|
scenarios: [
|
|
181
215
|
{
|
|
182
|
-
name: "
|
|
216
|
+
name: "an RBAC-denied read alongside a confirmed absence",
|
|
183
217
|
declared: ["widget", "gone"],
|
|
184
218
|
expectUnobserved: ["widget"],
|
|
185
219
|
expectAbsent: ["gone"],
|
|
186
220
|
run: () => {
|
|
187
|
-
|
|
221
|
+
clusterState.objects = {};
|
|
222
|
+
clusterState.fail = (path) =>
|
|
223
|
+
path.endsWith("/deployments/widget")
|
|
224
|
+
? { status: 403, body: statusBody(403, "Forbidden", "forbidden") }
|
|
225
|
+
: undefined;
|
|
188
226
|
return k8sPlugin.describeResources!({
|
|
189
227
|
environment: "prod",
|
|
190
228
|
buildOutput: "",
|
|
191
229
|
entityNames: ["widget", "gone"],
|
|
192
230
|
entities: new Map([
|
|
193
|
-
["widget", { entityType: "K8s::
|
|
231
|
+
["widget", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "widget" } } }],
|
|
194
232
|
["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone" } } }],
|
|
195
233
|
]),
|
|
196
234
|
});
|
|
@@ -201,18 +239,15 @@ describeObservationConformance({
|
|
|
201
239
|
declared: ["web"],
|
|
202
240
|
expectUnobserved: ["web"],
|
|
203
241
|
run: () => {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
);
|
|
242
|
+
clusterState.objects = {};
|
|
243
|
+
clusterState.fail = () => {
|
|
244
|
+
throw new Error("connect ECONNREFUSED 127.0.0.1:6443");
|
|
245
|
+
};
|
|
209
246
|
return k8sPlugin.describeResources!({
|
|
210
247
|
environment: "prod",
|
|
211
248
|
buildOutput: "",
|
|
212
249
|
entityNames: ["web"],
|
|
213
|
-
entities: new Map([
|
|
214
|
-
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
|
|
215
|
-
]),
|
|
250
|
+
entities: new Map([["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }]]),
|
|
216
251
|
});
|
|
217
252
|
},
|
|
218
253
|
},
|
|
@@ -221,20 +256,25 @@ describeObservationConformance({
|
|
|
221
256
|
declared: ["web"],
|
|
222
257
|
expectPresent: ["web"],
|
|
223
258
|
run: () => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
259
|
+
clusterState.fail = undefined;
|
|
260
|
+
clusterState.objects = {
|
|
261
|
+
[objectKey("apps/v1", "Deployment", "web", "default")]: {
|
|
262
|
+
apiVersion: "apps/v1",
|
|
263
|
+
kind: "Deployment",
|
|
264
|
+
metadata: {
|
|
265
|
+
name: "web",
|
|
266
|
+
namespace: "default",
|
|
267
|
+
uid: "uid-1",
|
|
268
|
+
labels: { "app.kubernetes.io/managed-by": "chant" },
|
|
269
|
+
},
|
|
227
270
|
status: { readyReplicas: 1, replicas: 1 },
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
}));
|
|
271
|
+
},
|
|
272
|
+
};
|
|
231
273
|
return k8sPlugin.describeResources!({
|
|
232
274
|
environment: "prod",
|
|
233
275
|
buildOutput: "",
|
|
234
276
|
entityNames: ["web"],
|
|
235
|
-
entities: new Map([
|
|
236
|
-
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
|
|
237
|
-
]),
|
|
277
|
+
entities: new Map([["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }]]),
|
|
238
278
|
});
|
|
239
279
|
},
|
|
240
280
|
},
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* dependency-light — it shells out to a CLI and does not import the k8s declarable
|
|
12
12
|
* surface — so a Temporal worker loads it cheaply.
|
|
13
13
|
*/
|
|
14
|
-
export { kubectlApply } from "./kubectl";
|
|
14
|
+
export { kubectlApply, readManifestDocuments } from "./kubectl";
|
|
15
15
|
export type { KubectlApplyArgs } from "./kubectl";
|
|
16
16
|
|
|
17
17
|
export { k3dUp, k3dDown, k3dUpCommand, k3dDownCommand, k3dExistsCommand } from "./k3d";
|
|
@@ -23,6 +23,7 @@ export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./ar
|
|
|
23
23
|
export {
|
|
24
24
|
waitForReady,
|
|
25
25
|
defaultResourceFetcher,
|
|
26
|
+
apiResourceFetcher,
|
|
26
27
|
ReadinessFailedError,
|
|
27
28
|
readinessFor,
|
|
28
29
|
isReady,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kubectlApply` over the typed API client (chant #1074).
|
|
3
|
+
*
|
|
4
|
+
* The activity contract is what Temporal workers register, so the shape of the
|
|
5
|
+
* arguments and the `Promise<void>` return are asserted alongside the new
|
|
6
|
+
* behavior. Nothing here spawns a process or reads an ambient kubeconfig,
|
|
7
|
+
* which is the acceptance criterion: a worker image needs no `kubectl` binary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { kubectlApply, readManifestDocuments } from "./kubectl";
|
|
15
|
+
import { fakeCluster } from "../../api/fake-cluster";
|
|
16
|
+
import { statusBody } from "@intentius/chant-k8s-client/testing";
|
|
17
|
+
|
|
18
|
+
let dir: string;
|
|
19
|
+
|
|
20
|
+
const deploymentYaml = `apiVersion: apps/v1
|
|
21
|
+
kind: Deployment
|
|
22
|
+
metadata:
|
|
23
|
+
name: web
|
|
24
|
+
namespace: prod
|
|
25
|
+
spec:
|
|
26
|
+
replicas: 3
|
|
27
|
+
---
|
|
28
|
+
apiVersion: v1
|
|
29
|
+
kind: Service
|
|
30
|
+
metadata:
|
|
31
|
+
name: web-svc
|
|
32
|
+
namespace: prod
|
|
33
|
+
spec:
|
|
34
|
+
ports:
|
|
35
|
+
- port: 80
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
dir = mkdtempSync(join(tmpdir(), "chant-k8s-apply-"));
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("readManifestDocuments", () => {
|
|
46
|
+
test("splits a multi-document file, dropping empty documents", () => {
|
|
47
|
+
const file = join(dir, "k8s.yaml");
|
|
48
|
+
writeFileSync(file, `${deploymentYaml}---\n---\n`);
|
|
49
|
+
const docs = readManifestDocuments(file);
|
|
50
|
+
expect(docs.map((d) => d.kind)).toEqual(["Deployment", "Service"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a directory is read in sorted file order, as `kubectl apply -f <dir>` does", () => {
|
|
54
|
+
writeFileSync(join(dir, "20-service.yaml"), "apiVersion: v1\nkind: Service\nmetadata:\n name: b\n");
|
|
55
|
+
writeFileSync(join(dir, "10-deployment.yaml"), "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: a\n");
|
|
56
|
+
writeFileSync(join(dir, "notes.txt"), "ignored");
|
|
57
|
+
const docs = readManifestDocuments(dir);
|
|
58
|
+
expect(docs.map((d) => d.kind)).toEqual(["Deployment", "Service"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("JSON manifests are read too", () => {
|
|
62
|
+
const file = join(dir, "k8s.json");
|
|
63
|
+
writeFileSync(file, JSON.stringify({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: "c" } }));
|
|
64
|
+
expect(readManifestDocuments(file).map((d) => d.kind)).toEqual(["ConfigMap"]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("kubectlApply", () => {
|
|
69
|
+
test("server-side applies every document, in file order, as the chant field manager", async () => {
|
|
70
|
+
const file = join(dir, "k8s.yaml");
|
|
71
|
+
writeFileSync(file, deploymentYaml);
|
|
72
|
+
const cluster = fakeCluster({
|
|
73
|
+
respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await kubectlApply({ manifest: file }, undefined, cluster.connector);
|
|
77
|
+
|
|
78
|
+
const patches = cluster.layer.requests.filter((r) => r.method === "PATCH");
|
|
79
|
+
expect(patches.map((p) => p.path)).toEqual([
|
|
80
|
+
"/apis/apps/v1/namespaces/prod/deployments/web",
|
|
81
|
+
"/api/v1/namespaces/prod/services/web-svc",
|
|
82
|
+
]);
|
|
83
|
+
for (const patch of patches) {
|
|
84
|
+
expect(patch.headers["Content-Type"]).toBe("application/apply-patch+yaml");
|
|
85
|
+
expect(patch.query).toMatchObject({ fieldManager: "chant", force: "false" });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an explicit context is honored and skips the environment lookup entirely", async () => {
|
|
90
|
+
const file = join(dir, "k8s.yaml");
|
|
91
|
+
writeFileSync(file, deploymentYaml);
|
|
92
|
+
const cluster = fakeCluster({
|
|
93
|
+
respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await kubectlApply({ manifest: file, context: "test-context" }, undefined, cluster.connector);
|
|
97
|
+
|
|
98
|
+
expect(cluster.connects[0]).toMatchObject({ context: "test-context" });
|
|
99
|
+
expect(cluster.connects[0].environment).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("an environment is passed through so the cluster binding applies to the write path too", async () => {
|
|
103
|
+
const file = join(dir, "k8s.yaml");
|
|
104
|
+
writeFileSync(file, deploymentYaml);
|
|
105
|
+
const cluster = fakeCluster({
|
|
106
|
+
respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await kubectlApply({ manifest: file, environment: "prod" }, undefined, cluster.connector);
|
|
110
|
+
expect(cluster.connects[0]).toMatchObject({ environment: "prod" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("force is forwarded, for the caller that means to take ownership (chant #1075)", async () => {
|
|
114
|
+
const file = join(dir, "k8s.yaml");
|
|
115
|
+
writeFileSync(file, deploymentYaml);
|
|
116
|
+
const cluster = fakeCluster({
|
|
117
|
+
respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await kubectlApply({ manifest: file, force: true, fieldManager: "chant-op" }, undefined, cluster.connector);
|
|
121
|
+
const patch = cluster.layer.requests.find((r) => r.method === "PATCH")!;
|
|
122
|
+
expect(patch.query).toMatchObject({ force: "true", fieldManager: "chant-op" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a field-ownership conflict surfaces as a typed error rather than parsed stderr", async () => {
|
|
126
|
+
const file = join(dir, "k8s.yaml");
|
|
127
|
+
writeFileSync(file, deploymentYaml);
|
|
128
|
+
const cluster = fakeCluster({
|
|
129
|
+
respond: (req) =>
|
|
130
|
+
req.method === "PATCH"
|
|
131
|
+
? { status: 409, body: statusBody(409, "Conflict", 'Apply failed with 1 conflict: conflict with "kubectl"') }
|
|
132
|
+
: undefined,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const err = await kubectlApply({ manifest: file }, undefined, cluster.connector).catch((e: unknown) => e);
|
|
136
|
+
expect((err as { name?: string }).name).toBe("K8sApiError");
|
|
137
|
+
expect((err as { statusCode?: number }).statusCode).toBe(409);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("resolves to undefined, keeping the activity's Promise<void> contract", async () => {
|
|
141
|
+
const file = join(dir, "k8s.yaml");
|
|
142
|
+
writeFileSync(file, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n namespace: prod\n");
|
|
143
|
+
const cluster = fakeCluster({
|
|
144
|
+
respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
145
|
+
});
|
|
146
|
+
await expect(kubectlApply({ manifest: file }, undefined, cluster.connector)).resolves.toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1,10 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `kubectlApply` — apply a rendered manifest to a cluster.
|
|
3
|
+
*
|
|
4
|
+
* chant #1074 moved this off `kubectl apply -f`. The activity contract is
|
|
5
|
+
* unchanged (a manifest path, an optional context, `Promise<void>`, the
|
|
6
|
+
* `longInfra` profile's 15s heartbeat) because Temporal workers register it by
|
|
7
|
+
* that signature; what changed is underneath. The name is kept for the same
|
|
8
|
+
* reason.
|
|
9
|
+
*
|
|
10
|
+
* Consequences worth knowing:
|
|
11
|
+
*
|
|
12
|
+
* - **A worker image needs no `kubectl` binary.** That was the point.
|
|
13
|
+
* - **It is a server-side apply**, with `chant` as the field manager, rather
|
|
14
|
+
* than the client-side three-way merge `kubectl apply` performs by default.
|
|
15
|
+
* Server-side apply is the direction Kubernetes itself has taken, it removes
|
|
16
|
+
* the `last-applied-configuration` annotation from the story, and it is what
|
|
17
|
+
* chant #1075 builds the field-ownership and conflict surface on. A conflict
|
|
18
|
+
* with another field manager arrives here as a typed 409 rather than a line
|
|
19
|
+
* of stderr; #1075 is where it gets a proper presentation.
|
|
20
|
+
* - **Documents apply in file order**, as `kubectl apply -f` does, and a
|
|
21
|
+
* directory's files are read in sorted order.
|
|
22
|
+
*/
|
|
4
23
|
|
|
5
|
-
|
|
24
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { loadAll } from "js-yaml";
|
|
27
|
+
import { safeHeartbeat } from "@intentius/chant/op";
|
|
28
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
29
|
+
import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
6
30
|
|
|
7
31
|
export interface KubectlApplyArgs {
|
|
32
|
+
/** Path to a manifest file, or a directory of them. */
|
|
8
33
|
manifest: string;
|
|
9
34
|
/**
|
|
10
35
|
* kubectl context name. Uses the ambient context if omitted. To target the
|
|
@@ -13,25 +38,73 @@ export interface KubectlApplyArgs {
|
|
|
13
38
|
* `./index.ts` and pass `.context` through.
|
|
14
39
|
*/
|
|
15
40
|
context?: string;
|
|
41
|
+
/**
|
|
42
|
+
* chant environment, used to resolve `k8s.profiles.<env>.context` when no
|
|
43
|
+
* explicit `context` is given. Optional and additive: omitting both keeps
|
|
44
|
+
* the previous behavior of using whatever the kubeconfig selects.
|
|
45
|
+
*/
|
|
46
|
+
environment?: string;
|
|
47
|
+
/** Field manager recorded on the applied objects. Default `chant`. */
|
|
48
|
+
fieldManager?: string;
|
|
49
|
+
/** Take ownership of fields another manager owns instead of failing (chant #1075). */
|
|
50
|
+
force?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read a manifest path — one file, or every YAML/JSON file in a directory. */
|
|
54
|
+
export function readManifestDocuments(path: string): Record<string, unknown>[] {
|
|
55
|
+
const files = statSync(path).isDirectory()
|
|
56
|
+
? readdirSync(path)
|
|
57
|
+
.filter((f) => /\.(ya?ml|json)$/i.test(f))
|
|
58
|
+
.sort()
|
|
59
|
+
.map((f) => join(path, f))
|
|
60
|
+
: [path];
|
|
61
|
+
|
|
62
|
+
const documents: Record<string, unknown>[] = [];
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
for (const doc of loadAll(readFileSync(file, "utf-8"))) {
|
|
65
|
+
// Multi-document YAML files routinely carry empty documents between
|
|
66
|
+
// separators; they are not objects to apply.
|
|
67
|
+
if (doc && typeof doc === "object") documents.push(doc as Record<string, unknown>);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return documents;
|
|
16
71
|
}
|
|
17
72
|
|
|
18
73
|
/**
|
|
19
|
-
*
|
|
74
|
+
* Apply every document in `args.manifest`.
|
|
20
75
|
* Uses longInfra profile — 20m timeout, heartbeat every 15s.
|
|
21
76
|
*/
|
|
22
|
-
export async function kubectlApply(
|
|
23
|
-
|
|
77
|
+
export async function kubectlApply(
|
|
78
|
+
args: KubectlApplyArgs,
|
|
79
|
+
signal?: AbortSignal,
|
|
80
|
+
connect: K8sConnector = defaultK8sConnector,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const documents = readManifestDocuments(args.manifest);
|
|
24
83
|
const heartbeatInterval = setInterval(() => {
|
|
25
84
|
safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
|
|
26
85
|
}, 15_000);
|
|
27
86
|
|
|
28
87
|
try {
|
|
29
|
-
const {
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
|
|
88
|
+
const { client } = await connect({
|
|
89
|
+
...(args.environment !== undefined ? { environment: args.environment } : {}),
|
|
90
|
+
...(args.context !== undefined ? { context: args.context } : {}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
for (const document of documents) {
|
|
94
|
+
const applied = await client.apply(document as K8sObject, {
|
|
95
|
+
fieldManager: args.fieldManager ?? "chant",
|
|
96
|
+
force: args.force ?? false,
|
|
97
|
+
signal,
|
|
98
|
+
});
|
|
99
|
+
safeHeartbeat({
|
|
100
|
+
step: "kubectl apply",
|
|
101
|
+
manifest: args.manifest,
|
|
102
|
+
applied: `${applied.kind ?? document.kind}/${applied.metadata?.name ?? "?"}`,
|
|
103
|
+
});
|
|
104
|
+
console.log(
|
|
105
|
+
`${String(applied.apiVersion ?? document.apiVersion)} ${String(applied.kind ?? document.kind)}/${String(applied.metadata?.name ?? "")} applied`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
35
108
|
} finally {
|
|
36
109
|
clearInterval(heartbeatInterval);
|
|
37
110
|
}
|