@intentius/chant-lexicon-k8s 0.0.12
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/integrity.json +32 -0
- package/dist/manifest.json +8 -0
- package/dist/meta.json +1413 -0
- package/dist/rules/hardcoded-namespace.ts +56 -0
- package/dist/rules/k8s-helpers.ts +149 -0
- package/dist/rules/wk8005.ts +59 -0
- package/dist/rules/wk8006.ts +68 -0
- package/dist/rules/wk8041.ts +73 -0
- package/dist/rules/wk8042.ts +48 -0
- package/dist/rules/wk8101.ts +65 -0
- package/dist/rules/wk8102.ts +42 -0
- package/dist/rules/wk8103.ts +45 -0
- package/dist/rules/wk8104.ts +69 -0
- package/dist/rules/wk8105.ts +45 -0
- package/dist/rules/wk8201.ts +55 -0
- package/dist/rules/wk8202.ts +46 -0
- package/dist/rules/wk8203.ts +46 -0
- package/dist/rules/wk8204.ts +54 -0
- package/dist/rules/wk8205.ts +56 -0
- package/dist/rules/wk8207.ts +45 -0
- package/dist/rules/wk8208.ts +45 -0
- package/dist/rules/wk8209.ts +45 -0
- package/dist/rules/wk8301.ts +51 -0
- package/dist/rules/wk8302.ts +46 -0
- package/dist/rules/wk8303.ts +96 -0
- package/dist/skills/chant-k8s.md +433 -0
- package/dist/types/index.d.ts +2934 -0
- package/package.json +30 -0
- package/src/actions/actions.test.ts +83 -0
- package/src/actions/apps.ts +23 -0
- package/src/actions/batch.ts +9 -0
- package/src/actions/core.ts +62 -0
- package/src/actions/index.ts +50 -0
- package/src/actions/networking.ts +15 -0
- package/src/actions/rbac.ts +13 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +1147 -0
- package/src/codegen/generate-cli.ts +41 -0
- package/src/codegen/generate-lexicon.ts +69 -0
- package/src/codegen/generate-typescript.ts +97 -0
- package/src/codegen/generate.ts +144 -0
- package/src/codegen/naming.test.ts +63 -0
- package/src/codegen/naming.ts +187 -0
- package/src/codegen/package.ts +56 -0
- package/src/codegen/patches.ts +108 -0
- package/src/codegen/snapshot.test.ts +95 -0
- package/src/codegen/typecheck.test.ts +24 -0
- package/src/codegen/typecheck.ts +4 -0
- package/src/codegen/versions.ts +43 -0
- package/src/composites/autoscaled-service.ts +236 -0
- package/src/composites/composites.test.ts +1109 -0
- package/src/composites/cron-workload.ts +167 -0
- package/src/composites/index.ts +14 -0
- package/src/composites/namespace-env.ts +163 -0
- package/src/composites/node-agent.ts +224 -0
- package/src/composites/stateful-app.ts +134 -0
- package/src/composites/web-app.ts +180 -0
- package/src/composites/worker-pool.ts +230 -0
- package/src/coverage.test.ts +27 -0
- package/src/coverage.ts +35 -0
- package/src/crd/loader.ts +112 -0
- package/src/crd/parser.test.ts +217 -0
- package/src/crd/parser.ts +279 -0
- package/src/crd/types.ts +54 -0
- package/src/default-labels.test.ts +111 -0
- package/src/default-labels.ts +122 -0
- package/src/generated/index.d.ts +2934 -0
- package/src/generated/index.ts +203 -0
- package/src/generated/lexicon-k8s.json +1413 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +121 -0
- package/src/import/generator.ts +285 -0
- package/src/import/parser.test.ts +156 -0
- package/src/import/parser.ts +204 -0
- package/src/import/roundtrip.test.ts +86 -0
- package/src/index.ts +38 -0
- package/src/lint/post-synth/k8s-helpers.test.ts +219 -0
- package/src/lint/post-synth/k8s-helpers.ts +149 -0
- package/src/lint/post-synth/post-synth.test.ts +969 -0
- package/src/lint/post-synth/wk8005.ts +59 -0
- package/src/lint/post-synth/wk8006.ts +68 -0
- package/src/lint/post-synth/wk8041.ts +73 -0
- package/src/lint/post-synth/wk8042.ts +48 -0
- package/src/lint/post-synth/wk8101.ts +65 -0
- package/src/lint/post-synth/wk8102.ts +42 -0
- package/src/lint/post-synth/wk8103.ts +45 -0
- package/src/lint/post-synth/wk8104.ts +69 -0
- package/src/lint/post-synth/wk8105.ts +45 -0
- package/src/lint/post-synth/wk8201.ts +55 -0
- package/src/lint/post-synth/wk8202.ts +46 -0
- package/src/lint/post-synth/wk8203.ts +46 -0
- package/src/lint/post-synth/wk8204.ts +54 -0
- package/src/lint/post-synth/wk8205.ts +56 -0
- package/src/lint/post-synth/wk8207.ts +45 -0
- package/src/lint/post-synth/wk8208.ts +45 -0
- package/src/lint/post-synth/wk8209.ts +45 -0
- package/src/lint/post-synth/wk8301.ts +51 -0
- package/src/lint/post-synth/wk8302.ts +46 -0
- package/src/lint/post-synth/wk8303.ts +96 -0
- package/src/lint/rules/hardcoded-namespace.ts +56 -0
- package/src/lint/rules/rules.test.ts +69 -0
- package/src/lsp/completions.test.ts +64 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +69 -0
- package/src/lsp/hover.ts +68 -0
- package/src/package-cli.ts +28 -0
- package/src/plugin.test.ts +209 -0
- package/src/plugin.ts +915 -0
- package/src/serializer.test.ts +275 -0
- package/src/serializer.ts +278 -0
- package/src/spec/fetch.test.ts +24 -0
- package/src/spec/fetch.ts +68 -0
- package/src/spec/parse.test.ts +102 -0
- package/src/spec/parse.ts +477 -0
- package/src/testdata/manifests/configmap.yaml +7 -0
- package/src/testdata/manifests/deployment.yaml +22 -0
- package/src/testdata/manifests/full-app.yaml +61 -0
- package/src/testdata/manifests/secret.yaml +7 -0
- package/src/testdata/manifests/service.yaml +15 -0
- package/src/validate-cli.ts +21 -0
- package/src/validate.test.ts +29 -0
- package/src/validate.ts +46 -0
- package/src/variables.ts +36 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { k8sSerializer } from "./serializer";
|
|
3
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
4
|
+
import {
|
|
5
|
+
defaultLabels,
|
|
6
|
+
defaultAnnotations,
|
|
7
|
+
DEFAULT_LABELS_MARKER,
|
|
8
|
+
DEFAULT_ANNOTATIONS_MARKER,
|
|
9
|
+
} from "./default-labels";
|
|
10
|
+
|
|
11
|
+
// ── Mock helpers ────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function mockResource(
|
|
14
|
+
entityType: string,
|
|
15
|
+
props: Record<string, unknown>,
|
|
16
|
+
): any {
|
|
17
|
+
return {
|
|
18
|
+
[DECLARABLE_MARKER]: true,
|
|
19
|
+
lexicon: "k8s",
|
|
20
|
+
entityType,
|
|
21
|
+
kind: "resource",
|
|
22
|
+
props,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mockProperty(
|
|
27
|
+
entityType: string,
|
|
28
|
+
props: Record<string, unknown>,
|
|
29
|
+
): any {
|
|
30
|
+
return {
|
|
31
|
+
[DECLARABLE_MARKER]: true,
|
|
32
|
+
lexicon: "k8s",
|
|
33
|
+
entityType,
|
|
34
|
+
kind: "property",
|
|
35
|
+
props,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Tests ───────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("k8sSerializer", () => {
|
|
42
|
+
test("name is k8s", () => {
|
|
43
|
+
expect(k8sSerializer.name).toBe("k8s");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rulePrefix is WK8", () => {
|
|
47
|
+
expect(k8sSerializer.rulePrefix).toBe("WK8");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("empty entities produce empty string", () => {
|
|
51
|
+
const result = k8sSerializer.serialize(new Map());
|
|
52
|
+
expect(result).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("single Deployment produces valid YAML with apiVersion/kind/metadata/spec", () => {
|
|
56
|
+
const entities = new Map<string, any>();
|
|
57
|
+
entities.set(
|
|
58
|
+
"myApp",
|
|
59
|
+
mockResource("K8s::Apps::Deployment", {
|
|
60
|
+
metadata: { name: "my-app", labels: { app: "my-app" } },
|
|
61
|
+
spec: { replicas: 2 },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const result = k8sSerializer.serialize(entities);
|
|
66
|
+
expect(result).toContain("apiVersion:");
|
|
67
|
+
expect(result).toContain("kind: Deployment");
|
|
68
|
+
expect(result).toContain("name: my-app");
|
|
69
|
+
expect(result).toContain("replicas: 2");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("metadata.name auto-generated from logical name (camelCase→kebab-case)", () => {
|
|
73
|
+
const entities = new Map<string, any>();
|
|
74
|
+
entities.set(
|
|
75
|
+
"myWebApp",
|
|
76
|
+
mockResource("K8s::Apps::Deployment", {
|
|
77
|
+
spec: { replicas: 1 },
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = k8sSerializer.serialize(entities);
|
|
82
|
+
expect(result).toContain("name: my-web-app");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("explicit metadata.name preserved", () => {
|
|
86
|
+
const entities = new Map<string, any>();
|
|
87
|
+
entities.set(
|
|
88
|
+
"myApp",
|
|
89
|
+
mockResource("K8s::Apps::Deployment", {
|
|
90
|
+
metadata: { name: "custom-name" },
|
|
91
|
+
spec: { replicas: 1 },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const result = k8sSerializer.serialize(entities);
|
|
96
|
+
expect(result).toContain("name: custom-name");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("ConfigMap uses top-level data (specless type)", () => {
|
|
100
|
+
const entities = new Map<string, any>();
|
|
101
|
+
entities.set(
|
|
102
|
+
"config",
|
|
103
|
+
mockResource("K8s::Core::ConfigMap", {
|
|
104
|
+
metadata: { name: "app-config" },
|
|
105
|
+
data: { DB_HOST: "localhost" },
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = k8sSerializer.serialize(entities);
|
|
110
|
+
expect(result).toContain("kind: ConfigMap");
|
|
111
|
+
expect(result).toContain("DB_HOST: localhost");
|
|
112
|
+
expect(result).not.toContain("spec:");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("Secret uses top-level stringData (specless type)", () => {
|
|
116
|
+
const entities = new Map<string, any>();
|
|
117
|
+
entities.set(
|
|
118
|
+
"secret",
|
|
119
|
+
mockResource("K8s::Core::Secret", {
|
|
120
|
+
metadata: { name: "app-secret" },
|
|
121
|
+
stringData: { API_KEY: "secret123" },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const result = k8sSerializer.serialize(entities);
|
|
126
|
+
expect(result).toContain("kind: Secret");
|
|
127
|
+
expect(result).toContain("API_KEY: secret123");
|
|
128
|
+
expect(result).not.toContain("spec:");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("Namespace is specless type", () => {
|
|
132
|
+
const entities = new Map<string, any>();
|
|
133
|
+
entities.set(
|
|
134
|
+
"myNs",
|
|
135
|
+
mockResource("K8s::Core::Namespace", {
|
|
136
|
+
metadata: { name: "my-namespace" },
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const result = k8sSerializer.serialize(entities);
|
|
141
|
+
expect(result).toContain("kind: Namespace");
|
|
142
|
+
expect(result).toContain("name: my-namespace");
|
|
143
|
+
expect(result).not.toContain("spec:");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("multi-resource entities joined by ---", () => {
|
|
147
|
+
const entities = new Map<string, any>();
|
|
148
|
+
entities.set(
|
|
149
|
+
"deploy",
|
|
150
|
+
mockResource("K8s::Apps::Deployment", {
|
|
151
|
+
metadata: { name: "app" },
|
|
152
|
+
spec: { replicas: 1 },
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
entities.set(
|
|
156
|
+
"svc",
|
|
157
|
+
mockResource("K8s::Core::Service", {
|
|
158
|
+
metadata: { name: "app" },
|
|
159
|
+
spec: { ports: [{ port: 80 }] },
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = k8sSerializer.serialize(entities);
|
|
164
|
+
expect(result).toContain("---");
|
|
165
|
+
expect(result).toContain("kind: Deployment");
|
|
166
|
+
expect(result).toContain("kind: Service");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("default labels merged into metadata.labels", () => {
|
|
170
|
+
const entities = new Map<string, any>();
|
|
171
|
+
entities.set("labels", defaultLabels({ env: "prod" }));
|
|
172
|
+
entities.set(
|
|
173
|
+
"deploy",
|
|
174
|
+
mockResource("K8s::Apps::Deployment", {
|
|
175
|
+
metadata: { name: "app" },
|
|
176
|
+
spec: { replicas: 1 },
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const result = k8sSerializer.serialize(entities);
|
|
181
|
+
expect(result).toContain("env: prod");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("default annotations merged into metadata.annotations", () => {
|
|
185
|
+
const entities = new Map<string, any>();
|
|
186
|
+
entities.set("annot", defaultAnnotations({ "note": "hello" }));
|
|
187
|
+
entities.set(
|
|
188
|
+
"deploy",
|
|
189
|
+
mockResource("K8s::Apps::Deployment", {
|
|
190
|
+
metadata: { name: "app" },
|
|
191
|
+
spec: { replicas: 1 },
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const result = k8sSerializer.serialize(entities);
|
|
196
|
+
expect(result).toContain("note: hello");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("explicit labels override default labels", () => {
|
|
200
|
+
const entities = new Map<string, any>();
|
|
201
|
+
entities.set("labels", defaultLabels({ env: "dev" }));
|
|
202
|
+
entities.set(
|
|
203
|
+
"deploy",
|
|
204
|
+
mockResource("K8s::Apps::Deployment", {
|
|
205
|
+
metadata: { name: "app", labels: { env: "prod" } },
|
|
206
|
+
spec: { replicas: 1 },
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const result = k8sSerializer.serialize(entities);
|
|
211
|
+
expect(result).toContain("env: prod");
|
|
212
|
+
// Should not contain "dev" since explicit overrides
|
|
213
|
+
const envLines = result.split("\n").filter((l: string) => l.includes("env:"));
|
|
214
|
+
expect(envLines.every((l: string) => !l.includes("dev"))).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("property entities skipped in output", () => {
|
|
218
|
+
const entities = new Map<string, any>();
|
|
219
|
+
entities.set("container", mockProperty("K8s::Core::Container", { name: "app" }));
|
|
220
|
+
entities.set(
|
|
221
|
+
"deploy",
|
|
222
|
+
mockResource("K8s::Apps::Deployment", {
|
|
223
|
+
metadata: { name: "app" },
|
|
224
|
+
spec: { replicas: 1 },
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const result = k8sSerializer.serialize(entities);
|
|
229
|
+
expect(result).toContain("kind: Deployment");
|
|
230
|
+
// Only one document — property entities should not appear as separate docs
|
|
231
|
+
expect(result.split("---").length).toBeLessThanOrEqual(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("DefaultLabels entities skipped in output", () => {
|
|
235
|
+
const entities = new Map<string, any>();
|
|
236
|
+
entities.set("labels", defaultLabels({ env: "prod" }));
|
|
237
|
+
|
|
238
|
+
const result = k8sSerializer.serialize(entities);
|
|
239
|
+
// defaultLabels alone should not produce any output
|
|
240
|
+
expect(result).toBe("");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("DefaultAnnotations entities skipped in output", () => {
|
|
244
|
+
const entities = new Map<string, any>();
|
|
245
|
+
entities.set("annotations", defaultAnnotations({ note: "hi" }));
|
|
246
|
+
|
|
247
|
+
const result = k8sSerializer.serialize(entities);
|
|
248
|
+
expect(result).toBe("");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("key ordering: apiVersion, kind, metadata, spec, then rest", () => {
|
|
252
|
+
const entities = new Map<string, any>();
|
|
253
|
+
entities.set(
|
|
254
|
+
"deploy",
|
|
255
|
+
mockResource("K8s::Apps::Deployment", {
|
|
256
|
+
metadata: { name: "app" },
|
|
257
|
+
spec: { replicas: 1 },
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const result = k8sSerializer.serialize(entities);
|
|
262
|
+
const lines = result.split("\n");
|
|
263
|
+
const keyLines = lines.filter((l: string) => /^\w+:/.test(l));
|
|
264
|
+
const keys = keyLines.map((l: string) => l.split(":")[0]);
|
|
265
|
+
|
|
266
|
+
const apiIdx = keys.indexOf("apiVersion");
|
|
267
|
+
const kindIdx = keys.indexOf("kind");
|
|
268
|
+
const metaIdx = keys.indexOf("metadata");
|
|
269
|
+
const specIdx = keys.indexOf("spec");
|
|
270
|
+
|
|
271
|
+
expect(apiIdx).toBeLessThan(kindIdx);
|
|
272
|
+
expect(kindIdx).toBeLessThan(metaIdx);
|
|
273
|
+
expect(metaIdx).toBeLessThan(specIdx);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes YAML serializer.
|
|
3
|
+
*
|
|
4
|
+
* Converts Chant declarables to multi-document K8s YAML output with
|
|
5
|
+
* apiVersion, kind, metadata, and spec structure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createRequire } from "module";
|
|
9
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
12
|
+
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
13
|
+
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
14
|
+
import { emitYAML } from "@intentius/chant/yaml";
|
|
15
|
+
import { isDefaultLabels, isDefaultAnnotations, type DefaultLabels, type DefaultAnnotations } from "./default-labels";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* K8s resource kinds whose properties live directly on the manifest
|
|
21
|
+
* (not nested under `spec`). These types use `data`, `stringData`,
|
|
22
|
+
* or have no spec field at all.
|
|
23
|
+
*/
|
|
24
|
+
const SPECLESS_TYPES = new Set([
|
|
25
|
+
"ConfigMap",
|
|
26
|
+
"Secret",
|
|
27
|
+
"Namespace",
|
|
28
|
+
"ServiceAccount",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GVK mapping entry — loaded from generated lexicon-k8s.json.
|
|
33
|
+
*/
|
|
34
|
+
interface GVKEntry {
|
|
35
|
+
resourceType: string;
|
|
36
|
+
kind: "resource" | "property";
|
|
37
|
+
apiVersion?: string;
|
|
38
|
+
gvkKind?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cachedGVKMap: Record<string, GVKEntry> | null = null;
|
|
42
|
+
|
|
43
|
+
function getGVKMap(): Record<string, GVKEntry> {
|
|
44
|
+
if (cachedGVKMap) return cachedGVKMap;
|
|
45
|
+
try {
|
|
46
|
+
cachedGVKMap = require("./generated/lexicon-k8s.json") as Record<string, GVKEntry>;
|
|
47
|
+
} catch {
|
|
48
|
+
cachedGVKMap = {};
|
|
49
|
+
}
|
|
50
|
+
return cachedGVKMap!;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve entityType to apiVersion and kind.
|
|
55
|
+
*/
|
|
56
|
+
function resolveGVK(entityType: string): { apiVersion: string; kind: string } | null {
|
|
57
|
+
const gvkMap = getGVKMap();
|
|
58
|
+
|
|
59
|
+
// Search for matching entry by resourceType
|
|
60
|
+
for (const entry of Object.values(gvkMap)) {
|
|
61
|
+
if (entry.resourceType === entityType && entry.apiVersion && entry.gvkKind) {
|
|
62
|
+
return { apiVersion: entry.apiVersion, kind: entry.gvkKind };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fallback: derive from entity type string (K8s::Group::Kind → group/v1, Kind)
|
|
67
|
+
return deriveGVKFromType(entityType);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Well-known K8s API group → apiVersion mappings for fallback when
|
|
72
|
+
* the generated lexicon JSON is not available.
|
|
73
|
+
*/
|
|
74
|
+
const API_GROUP_VERSIONS: Record<string, string> = {
|
|
75
|
+
Core: "v1",
|
|
76
|
+
Apps: "apps/v1",
|
|
77
|
+
Batch: "batch/v1",
|
|
78
|
+
Networking: "networking.k8s.io/v1",
|
|
79
|
+
Policy: "policy/v1",
|
|
80
|
+
Rbac: "rbac.authorization.k8s.io/v1",
|
|
81
|
+
Storage: "storage.k8s.io/v1",
|
|
82
|
+
Autoscaling: "autoscaling/v2",
|
|
83
|
+
Admissionregistration: "admissionregistration.k8s.io/v1",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function deriveGVKFromType(entityType: string): { apiVersion: string; kind: string } | null {
|
|
87
|
+
// Format: K8s::Group::Kind
|
|
88
|
+
const parts = entityType.split("::");
|
|
89
|
+
if (parts.length !== 3 || parts[0] !== "K8s") return null;
|
|
90
|
+
|
|
91
|
+
const group = parts[1];
|
|
92
|
+
const kind = parts[2];
|
|
93
|
+
const apiVersion = API_GROUP_VERSIONS[group];
|
|
94
|
+
|
|
95
|
+
if (!apiVersion) return null;
|
|
96
|
+
return { apiVersion, kind };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* K8s visitor for the generic serializer walker.
|
|
101
|
+
*/
|
|
102
|
+
function k8sVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
103
|
+
return {
|
|
104
|
+
attrRef: (name, attr) => {
|
|
105
|
+
// For K8s, attribute references typically resolve to metadata.name
|
|
106
|
+
if (attr === "name") return name;
|
|
107
|
+
return name;
|
|
108
|
+
},
|
|
109
|
+
resourceRef: (name) => name,
|
|
110
|
+
propertyDeclarable: (entity, walk) => {
|
|
111
|
+
if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const props = entity.props as Record<string, unknown>;
|
|
115
|
+
const result: Record<string, unknown> = {};
|
|
116
|
+
for (const [key, value] of Object.entries(props)) {
|
|
117
|
+
if (value !== undefined) {
|
|
118
|
+
result[key] = walk(value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Convert a value to YAML-compatible form using the walker.
|
|
128
|
+
*/
|
|
129
|
+
function toYAMLValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
|
|
130
|
+
return walkValue(value, entityNames, k8sVisitor(entityNames));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Kubernetes YAML serializer implementation.
|
|
135
|
+
*/
|
|
136
|
+
export const k8sSerializer: Serializer = {
|
|
137
|
+
name: "k8s",
|
|
138
|
+
rulePrefix: "WK8",
|
|
139
|
+
|
|
140
|
+
serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[]): string {
|
|
141
|
+
// Build reverse map: entity → name
|
|
142
|
+
const entityNames = new Map<Declarable, string>();
|
|
143
|
+
for (const [name, entity] of entities) {
|
|
144
|
+
entityNames.set(entity, name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Collect default labels and annotations
|
|
148
|
+
let defaultLabelEntries: Record<string, unknown> = {};
|
|
149
|
+
let defaultAnnotationEntries: Record<string, unknown> = {};
|
|
150
|
+
|
|
151
|
+
for (const [, entity] of entities) {
|
|
152
|
+
if (isDefaultLabels(entity)) {
|
|
153
|
+
defaultLabelEntries = { ...defaultLabelEntries, ...(entity as DefaultLabels).labels };
|
|
154
|
+
}
|
|
155
|
+
if (isDefaultAnnotations(entity)) {
|
|
156
|
+
defaultAnnotationEntries = { ...defaultAnnotationEntries, ...(entity as DefaultAnnotations).annotations };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const documents: string[] = [];
|
|
161
|
+
|
|
162
|
+
for (const [name, entity] of entities) {
|
|
163
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
164
|
+
if (isDefaultLabels(entity) || isDefaultAnnotations(entity)) continue;
|
|
165
|
+
|
|
166
|
+
const entityType = (entity as unknown as Record<string, unknown>).entityType as string;
|
|
167
|
+
const gvk = resolveGVK(entityType);
|
|
168
|
+
if (!gvk) continue;
|
|
169
|
+
|
|
170
|
+
const props = toYAMLValue(
|
|
171
|
+
(entity as unknown as Record<string, unknown>).props,
|
|
172
|
+
entityNames,
|
|
173
|
+
) as Record<string, unknown> | undefined;
|
|
174
|
+
|
|
175
|
+
if (!props) continue;
|
|
176
|
+
|
|
177
|
+
// Build the K8s manifest structure
|
|
178
|
+
const manifest: Record<string, unknown> = {
|
|
179
|
+
apiVersion: gvk.apiVersion,
|
|
180
|
+
kind: gvk.kind,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Build metadata
|
|
184
|
+
const metadata: Record<string, unknown> = props.metadata as Record<string, unknown> ?? {};
|
|
185
|
+
if (!metadata.name) {
|
|
186
|
+
// Use the logical name as the resource name (kebab-case)
|
|
187
|
+
metadata.name = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Merge default labels
|
|
191
|
+
if (Object.keys(defaultLabelEntries).length > 0) {
|
|
192
|
+
const existingLabels = (metadata.labels ?? {}) as Record<string, unknown>;
|
|
193
|
+
metadata.labels = { ...defaultLabelEntries, ...existingLabels };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Merge default annotations
|
|
197
|
+
if (Object.keys(defaultAnnotationEntries).length > 0) {
|
|
198
|
+
const existingAnnotations = (metadata.annotations ?? {}) as Record<string, unknown>;
|
|
199
|
+
metadata.annotations = { ...defaultAnnotationEntries, ...existingAnnotations };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
manifest.metadata = metadata;
|
|
203
|
+
|
|
204
|
+
// The remaining properties go under spec (or directly on the manifest for certain types)
|
|
205
|
+
if (SPECLESS_TYPES.has(gvk.kind)) {
|
|
206
|
+
// These types have their data directly on the manifest (data, stringData, etc.)
|
|
207
|
+
for (const [key, value] of Object.entries(props)) {
|
|
208
|
+
if (key !== "metadata") {
|
|
209
|
+
manifest[key] = value;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else if (props.spec !== undefined) {
|
|
213
|
+
// If spec is already set, use it directly
|
|
214
|
+
manifest.spec = props.spec;
|
|
215
|
+
for (const [key, value] of Object.entries(props)) {
|
|
216
|
+
if (key !== "metadata" && key !== "spec") {
|
|
217
|
+
manifest[key] = value;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
// Place remaining props under spec
|
|
222
|
+
const spec: Record<string, unknown> = {};
|
|
223
|
+
for (const [key, value] of Object.entries(props)) {
|
|
224
|
+
if (key !== "metadata") {
|
|
225
|
+
spec[key] = value;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (Object.keys(spec).length > 0) {
|
|
229
|
+
manifest.spec = spec;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Emit as YAML
|
|
234
|
+
const yamlDoc = emitK8sManifest(manifest);
|
|
235
|
+
documents.push(yamlDoc);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return documents.join("\n---\n");
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Emit a K8s manifest object as YAML.
|
|
244
|
+
* Preserves key ordering: apiVersion, kind, metadata, spec, then rest.
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* Emit a key-value pair as YAML. Scalars get ` value` suffix; objects get
|
|
248
|
+
* block-style indented below the key.
|
|
249
|
+
*/
|
|
250
|
+
function emitKeyValue(key: string, value: unknown): string {
|
|
251
|
+
const yamlStr = emitYAML(value, 1);
|
|
252
|
+
// If the YAML starts with a newline, it's a block value (object/array)
|
|
253
|
+
if (yamlStr.startsWith("\n")) {
|
|
254
|
+
return `${key}:${yamlStr}`;
|
|
255
|
+
}
|
|
256
|
+
return `${key}: ${yamlStr}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function emitK8sManifest(manifest: Record<string, unknown>): string {
|
|
260
|
+
const orderedKeys = ["apiVersion", "kind", "metadata", "spec"];
|
|
261
|
+
const lines: string[] = [];
|
|
262
|
+
|
|
263
|
+
// Emit ordered keys first
|
|
264
|
+
for (const key of orderedKeys) {
|
|
265
|
+
if (manifest[key] !== undefined) {
|
|
266
|
+
lines.push(emitKeyValue(key, manifest[key]));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Emit remaining keys
|
|
271
|
+
for (const [key, value] of Object.entries(manifest)) {
|
|
272
|
+
if (!orderedKeys.includes(key) && value !== undefined) {
|
|
273
|
+
lines.push(emitKeyValue(key, value));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return lines.join("\n") + "\n";
|
|
278
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { fetchK8sSchema, fetchSchemas, K8S_SCHEMA_VERSION } from "./fetch";
|
|
3
|
+
|
|
4
|
+
describe("fetch", () => {
|
|
5
|
+
test("fetchK8sSchema function exists", () => {
|
|
6
|
+
expect(typeof fetchK8sSchema).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("fetchSchemas function exists", () => {
|
|
10
|
+
expect(typeof fetchSchemas).toBe("function");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("K8S_SCHEMA_VERSION is defined", () => {
|
|
14
|
+
expect(typeof K8S_SCHEMA_VERSION).toBe("string");
|
|
15
|
+
expect(K8S_SCHEMA_VERSION).toMatch(/^v\d+\.\d+\.\d+$/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test.skip("integration: fetchK8sSchema returns Buffer (requires network)", async () => {
|
|
19
|
+
const data = await fetchK8sSchema();
|
|
20
|
+
expect(data).toBeInstanceOf(Buffer);
|
|
21
|
+
const parsed = JSON.parse(data.toString("utf-8"));
|
|
22
|
+
expect(parsed.definitions).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes OpenAPI schema fetching — downloads the Swagger 2.0 spec
|
|
3
|
+
* and caches it locally.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { fetchWithCache, clearCacheFile } from "@intentius/chant/codegen/fetch";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pinned Kubernetes version for schema download.
|
|
12
|
+
*/
|
|
13
|
+
export const K8S_SCHEMA_VERSION = "v1.32.0";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the schema URL for a given version.
|
|
17
|
+
*/
|
|
18
|
+
function schemaUrl(version: string): string {
|
|
19
|
+
return `https://raw.githubusercontent.com/kubernetes/kubernetes/${version}/api/openapi-spec/swagger.json`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the cache file path for the K8s schema.
|
|
24
|
+
*/
|
|
25
|
+
export function getCachePath(version?: string): string {
|
|
26
|
+
const ref = version ?? K8S_SCHEMA_VERSION;
|
|
27
|
+
return join(homedir(), ".chant", `k8s-swagger-${ref}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch the Kubernetes OpenAPI Swagger spec, returning the raw JSON buffer.
|
|
32
|
+
* Uses local file caching with 24-hour TTL.
|
|
33
|
+
*
|
|
34
|
+
* @param force Bypass cache and download fresh.
|
|
35
|
+
* @param version Kubernetes version tag. Defaults to {@link K8S_SCHEMA_VERSION}.
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchK8sSchema(force?: boolean, version?: string): Promise<Buffer> {
|
|
38
|
+
const ref = version ?? K8S_SCHEMA_VERSION;
|
|
39
|
+
return fetchWithCache(
|
|
40
|
+
{
|
|
41
|
+
url: schemaUrl(ref),
|
|
42
|
+
cacheFile: getCachePath(ref),
|
|
43
|
+
},
|
|
44
|
+
force,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fetch the K8s schema and return it as a Map<typeName, Buffer>
|
|
50
|
+
* compatible with the generatePipeline fetchSchemas callback.
|
|
51
|
+
*
|
|
52
|
+
* The K8s schema is a single document, so we return a single entry
|
|
53
|
+
* keyed by "Kubernetes::OpenAPI" — the parse step will split it
|
|
54
|
+
* into multiple resources.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchSchemas(force?: boolean, version?: string): Promise<Map<string, Buffer>> {
|
|
57
|
+
const data = await fetchK8sSchema(force, version);
|
|
58
|
+
const schemas = new Map<string, Buffer>();
|
|
59
|
+
schemas.set("Kubernetes::OpenAPI", data);
|
|
60
|
+
return schemas;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clear the cached schema file.
|
|
65
|
+
*/
|
|
66
|
+
export function clearCache(version?: string): void {
|
|
67
|
+
clearCacheFile(getCachePath(version));
|
|
68
|
+
}
|