@intentius/chant-lexicon-helm 0.0.16
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 +22 -0
- package/dist/integrity.json +36 -0
- package/dist/manifest.json +37 -0
- package/dist/meta.json +208 -0
- package/dist/rules/chart-metadata.ts +64 -0
- package/dist/rules/helm-helpers.ts +64 -0
- package/dist/rules/no-hardcoded-image.ts +62 -0
- package/dist/rules/values-no-secrets.ts +82 -0
- package/dist/rules/whm101.ts +46 -0
- package/dist/rules/whm102.ts +33 -0
- package/dist/rules/whm103.ts +59 -0
- package/dist/rules/whm104.ts +35 -0
- package/dist/rules/whm105.ts +30 -0
- package/dist/rules/whm201.ts +36 -0
- package/dist/rules/whm202.ts +50 -0
- package/dist/rules/whm203.ts +39 -0
- package/dist/rules/whm204.ts +60 -0
- package/dist/rules/whm301.ts +41 -0
- package/dist/rules/whm302.ts +40 -0
- package/dist/rules/whm401.ts +57 -0
- package/dist/rules/whm402.ts +45 -0
- package/dist/rules/whm403.ts +45 -0
- package/dist/rules/whm404.ts +36 -0
- package/dist/rules/whm405.ts +53 -0
- package/dist/rules/whm406.ts +34 -0
- package/dist/rules/whm407.ts +83 -0
- package/dist/rules/whm501.ts +103 -0
- package/dist/rules/whm502.ts +94 -0
- package/dist/skills/chant-helm-chart-patterns.md +229 -0
- package/dist/skills/chant-helm-chart-security-patterns.md +192 -0
- package/dist/skills/chant-helm-create-chart.md +211 -0
- package/dist/types/index.d.ts +132 -0
- package/package.json +34 -0
- package/src/codegen/docs-cli.ts +4 -0
- package/src/codegen/docs.ts +483 -0
- package/src/codegen/generate-cli.ts +28 -0
- package/src/codegen/generate.ts +249 -0
- package/src/codegen/naming.ts +38 -0
- package/src/codegen/package.ts +64 -0
- package/src/composites/composites.test.ts +1050 -0
- package/src/composites/helm-batch-job.ts +209 -0
- package/src/composites/helm-crd-lifecycle.ts +184 -0
- package/src/composites/helm-cron-job.ts +177 -0
- package/src/composites/helm-daemon-set.ts +169 -0
- package/src/composites/helm-external-secret.ts +93 -0
- package/src/composites/helm-library.ts +51 -0
- package/src/composites/helm-microservice.ts +331 -0
- package/src/composites/helm-monitored-service.ts +252 -0
- package/src/composites/helm-namespace-env.ts +154 -0
- package/src/composites/helm-secure-ingress.ts +114 -0
- package/src/composites/helm-stateful-service.ts +213 -0
- package/src/composites/helm-web-app.ts +264 -0
- package/src/composites/helm-worker.ts +207 -0
- package/src/composites/index.ts +38 -0
- package/src/coverage.test.ts +21 -0
- package/src/coverage.ts +50 -0
- package/src/generated/index.d.ts +132 -0
- package/src/generated/index.ts +13 -0
- package/src/generated/lexicon-helm.json +208 -0
- package/src/helpers.test.ts +51 -0
- package/src/helpers.ts +100 -0
- package/src/import/generator.ts +285 -0
- package/src/import/import.test.ts +224 -0
- package/src/import/parser.ts +160 -0
- package/src/import/template-stripper.ts +123 -0
- package/src/index.ts +108 -0
- package/src/intrinsics.test.ts +380 -0
- package/src/intrinsics.ts +484 -0
- package/src/lint/post-synth/helm-helpers.ts +64 -0
- package/src/lint/post-synth/post-synth.test.ts +504 -0
- package/src/lint/post-synth/whm101.ts +46 -0
- package/src/lint/post-synth/whm102.ts +33 -0
- package/src/lint/post-synth/whm103.ts +59 -0
- package/src/lint/post-synth/whm104.ts +35 -0
- package/src/lint/post-synth/whm105.ts +30 -0
- package/src/lint/post-synth/whm201.ts +36 -0
- package/src/lint/post-synth/whm202.ts +50 -0
- package/src/lint/post-synth/whm203.ts +39 -0
- package/src/lint/post-synth/whm204.ts +60 -0
- package/src/lint/post-synth/whm301.ts +41 -0
- package/src/lint/post-synth/whm302.ts +40 -0
- package/src/lint/post-synth/whm401.ts +57 -0
- package/src/lint/post-synth/whm402.ts +45 -0
- package/src/lint/post-synth/whm403.ts +45 -0
- package/src/lint/post-synth/whm404.ts +36 -0
- package/src/lint/post-synth/whm405.ts +53 -0
- package/src/lint/post-synth/whm406.ts +34 -0
- package/src/lint/post-synth/whm407.ts +83 -0
- package/src/lint/post-synth/whm501.ts +103 -0
- package/src/lint/post-synth/whm502.ts +94 -0
- package/src/lint/rules/chart-metadata.ts +64 -0
- package/src/lint/rules/lint-rules.test.ts +97 -0
- package/src/lint/rules/no-hardcoded-image.ts +62 -0
- package/src/lint/rules/values-no-secrets.ts +82 -0
- package/src/lsp/completions.test.ts +72 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +46 -0
- package/src/lsp/hover.ts +46 -0
- package/src/package-cli.ts +28 -0
- package/src/plugin.test.ts +71 -0
- package/src/plugin.ts +206 -0
- package/src/resources.ts +77 -0
- package/src/serializer.test.ts +930 -0
- package/src/serializer.ts +835 -0
- package/src/skills/chart-patterns.md +229 -0
- package/src/skills/chart-security-patterns.md +192 -0
- package/src/skills/create-chart.md +211 -0
- package/src/validate-cli.ts +21 -0
- package/src/validate.test.ts +37 -0
- package/src/validate.ts +36 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript code generator for Helm chart import.
|
|
3
|
+
*
|
|
4
|
+
* Converts a TemplateIR (from parsed Helm charts) into TypeScript source code
|
|
5
|
+
* using @intentius/chant-lexicon-helm and @intentius/chant-lexicon-k8s constructors,
|
|
6
|
+
* mapping Go template expressions to Helm intrinsics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
|
|
10
|
+
import type { TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
|
|
11
|
+
import type { ExpressionKind } from "./template-stripper";
|
|
12
|
+
|
|
13
|
+
/** K8s type to class name mapping. */
|
|
14
|
+
const K8S_TYPE_TO_CLASS: Record<string, string> = {
|
|
15
|
+
"K8s::Core::Pod": "Pod",
|
|
16
|
+
"K8s::Core::Service": "Service",
|
|
17
|
+
"K8s::Core::ConfigMap": "ConfigMap",
|
|
18
|
+
"K8s::Core::Secret": "Secret",
|
|
19
|
+
"K8s::Core::ServiceAccount": "ServiceAccount",
|
|
20
|
+
"K8s::Apps::Deployment": "Deployment",
|
|
21
|
+
"K8s::Apps::StatefulSet": "StatefulSet",
|
|
22
|
+
"K8s::Apps::DaemonSet": "DaemonSet",
|
|
23
|
+
"K8s::Batch::Job": "Job",
|
|
24
|
+
"K8s::Batch::CronJob": "CronJob",
|
|
25
|
+
"K8s::Networking::Ingress": "Ingress",
|
|
26
|
+
"K8s::Networking::NetworkPolicy": "NetworkPolicy",
|
|
27
|
+
"K8s::Rbac::Role": "Role",
|
|
28
|
+
"K8s::Rbac::ClusterRole": "ClusterRole",
|
|
29
|
+
"K8s::Rbac::RoleBinding": "RoleBinding",
|
|
30
|
+
"K8s::Rbac::ClusterRoleBinding": "ClusterRoleBinding",
|
|
31
|
+
"K8s::Autoscaling::HorizontalPodAutoscaler": "HorizontalPodAutoscaler",
|
|
32
|
+
"K8s::Policy::PodDisruptionBudget": "PodDisruptionBudget",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Helm type to class name mapping. */
|
|
36
|
+
const HELM_TYPE_TO_CLASS: Record<string, string> = {
|
|
37
|
+
"Helm::Chart": "Chart",
|
|
38
|
+
"Helm::Values": "Values",
|
|
39
|
+
"Helm::Notes": "HelmNotes",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export class HelmGenerator implements TypeScriptGenerator {
|
|
43
|
+
generate(ir: TemplateIR): GeneratedFile[] {
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
const helmImports = new Set<string>();
|
|
46
|
+
const k8sImports = new Set<string>();
|
|
47
|
+
const intrinsicImports = new Set<string>();
|
|
48
|
+
|
|
49
|
+
// Classify resources and collect needed imports
|
|
50
|
+
for (const resource of ir.resources) {
|
|
51
|
+
const helmClass = HELM_TYPE_TO_CLASS[resource.type];
|
|
52
|
+
if (helmClass) {
|
|
53
|
+
helmImports.add(helmClass);
|
|
54
|
+
} else {
|
|
55
|
+
const k8sClass = this.resolveK8sClass(resource.type);
|
|
56
|
+
if (k8sClass) k8sImports.add(k8sClass);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Scan for template expressions to determine intrinsic imports
|
|
60
|
+
this.collectIntrinsicImports(resource, intrinsicImports);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Emit imports
|
|
64
|
+
if (helmImports.size > 0) {
|
|
65
|
+
lines.push(`import { ${[...helmImports].sort().join(", ")} } from "@intentius/chant-lexicon-helm";`);
|
|
66
|
+
}
|
|
67
|
+
if (intrinsicImports.size > 0) {
|
|
68
|
+
lines.push(`import { ${[...intrinsicImports].sort().join(", ")} } from "@intentius/chant-lexicon-helm";`);
|
|
69
|
+
}
|
|
70
|
+
if (k8sImports.size > 0) {
|
|
71
|
+
lines.push(`import { ${[...k8sImports].sort().join(", ")} } from "@intentius/chant-lexicon-k8s";`);
|
|
72
|
+
}
|
|
73
|
+
lines.push("");
|
|
74
|
+
|
|
75
|
+
// Emit resources
|
|
76
|
+
for (const resource of ir.resources) {
|
|
77
|
+
const cls = HELM_TYPE_TO_CLASS[resource.type] ?? this.resolveK8sClass(resource.type);
|
|
78
|
+
if (!cls) {
|
|
79
|
+
lines.push(`// TODO: unsupported type ${resource.type} (${resource.logicalId})`);
|
|
80
|
+
lines.push("");
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Replace placeholders with intrinsic calls in properties
|
|
85
|
+
const props = this.substituteExpressions(resource.properties, resource.metadata?.templateExpressions as Record<string, { expression: string; kind: string }> | undefined);
|
|
86
|
+
const propsStr = this.emitProps(props, 1);
|
|
87
|
+
|
|
88
|
+
lines.push(`export const ${resource.logicalId} = new ${cls}(${propsStr});`);
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [{ path: "chart.ts", content: lines.join("\n") }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private resolveK8sClass(type: string): string | undefined {
|
|
96
|
+
if (K8S_TYPE_TO_CLASS[type]) return K8S_TYPE_TO_CLASS[type];
|
|
97
|
+
const parts = type.split("::");
|
|
98
|
+
if (parts.length === 3 && parts[0] === "K8s") return parts[2];
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private collectIntrinsicImports(resource: ResourceIR, imports: Set<string>): void {
|
|
103
|
+
const exprs = resource.metadata?.templateExpressions as Record<string, { expression: string; kind: string }> | undefined;
|
|
104
|
+
if (!exprs) return;
|
|
105
|
+
|
|
106
|
+
for (const entry of Object.values(exprs)) {
|
|
107
|
+
const kind = entry.kind as ExpressionKind;
|
|
108
|
+
switch (kind) {
|
|
109
|
+
case "values": imports.add("values"); break;
|
|
110
|
+
case "release": imports.add("Release"); break;
|
|
111
|
+
case "chart": imports.add("ChartRef"); break;
|
|
112
|
+
case "include": imports.add("include"); break;
|
|
113
|
+
case "toYaml": imports.add("toYaml"); imports.add("values"); break;
|
|
114
|
+
case "required": imports.add("required"); imports.add("values"); break;
|
|
115
|
+
case "default": imports.add("helmDefault"); imports.add("values"); break;
|
|
116
|
+
case "printf": imports.add("printf"); break;
|
|
117
|
+
case "quote": imports.add("quote"); imports.add("values"); break;
|
|
118
|
+
case "lookup": imports.add("lookup"); break;
|
|
119
|
+
case "tpl": imports.add("tpl"); break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Walk through properties, replacing placeholder strings with intrinsic call representations.
|
|
126
|
+
*/
|
|
127
|
+
private substituteExpressions(
|
|
128
|
+
props: Record<string, unknown>,
|
|
129
|
+
exprs: Record<string, { expression: string; kind: string }> | undefined,
|
|
130
|
+
): Record<string, unknown> {
|
|
131
|
+
if (!exprs) return props;
|
|
132
|
+
return this.walkAndReplace(props, exprs) as Record<string, unknown>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private walkAndReplace(
|
|
136
|
+
value: unknown,
|
|
137
|
+
exprs: Record<string, { expression: string; kind: string }>,
|
|
138
|
+
): unknown {
|
|
139
|
+
if (typeof value === "string") {
|
|
140
|
+
// Check if the entire string is a placeholder
|
|
141
|
+
const expr = exprs[value];
|
|
142
|
+
if (expr) {
|
|
143
|
+
return { __intrinsicCall: this.expressionToIntrinsic(expr.expression, expr.kind as ExpressionKind) };
|
|
144
|
+
}
|
|
145
|
+
// Check if the string contains placeholders mixed with text
|
|
146
|
+
let result = value;
|
|
147
|
+
for (const [placeholder, entry] of Object.entries(exprs)) {
|
|
148
|
+
if (result.includes(placeholder)) {
|
|
149
|
+
result = result.replace(placeholder, `\${${this.expressionToIntrinsic(entry.expression, entry.kind as ExpressionKind)}}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (result !== value) {
|
|
153
|
+
return { __intrinsicCall: result };
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
return value.map((item) => this.walkAndReplace(item, exprs));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (value && typeof value === "object") {
|
|
163
|
+
const result: Record<string, unknown> = {};
|
|
164
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
165
|
+
result[k] = this.walkAndReplace(v, exprs);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Convert a Go template expression to a TypeScript intrinsic call string.
|
|
175
|
+
*/
|
|
176
|
+
private expressionToIntrinsic(expression: string, kind: ExpressionKind): string {
|
|
177
|
+
switch (kind) {
|
|
178
|
+
case "values": {
|
|
179
|
+
const path = expression.replace(/^\.Values\./, "");
|
|
180
|
+
return `values.${path}`;
|
|
181
|
+
}
|
|
182
|
+
case "release": {
|
|
183
|
+
const field = expression.replace(/^\.Release\./, "");
|
|
184
|
+
return `Release.${field}`;
|
|
185
|
+
}
|
|
186
|
+
case "chart": {
|
|
187
|
+
const field = expression.replace(/^\.Chart\./, "");
|
|
188
|
+
return `ChartRef.${field}`;
|
|
189
|
+
}
|
|
190
|
+
case "include": {
|
|
191
|
+
const match = expression.match(/^include\s+"([^"]+)"\s+(.+)$/);
|
|
192
|
+
if (match) return `include("${match[1]}")`;
|
|
193
|
+
return `include(${JSON.stringify(expression)})`;
|
|
194
|
+
}
|
|
195
|
+
case "toYaml": {
|
|
196
|
+
const match = expression.match(/toYaml\s+(.+?)(?:\s*\|\s*nindent\s+(\d+))?$/);
|
|
197
|
+
if (match) {
|
|
198
|
+
const ref = this.refToIntrinsic(match[1]);
|
|
199
|
+
return match[2] ? `toYaml(${ref}, ${match[2]})` : `toYaml(${ref})`;
|
|
200
|
+
}
|
|
201
|
+
return `toYaml(${JSON.stringify(expression)})`;
|
|
202
|
+
}
|
|
203
|
+
case "required": {
|
|
204
|
+
const match = expression.match(/^required\s+"([^"]+)"\s+(.+)$/);
|
|
205
|
+
if (match) return `required("${match[1]}", ${this.refToIntrinsic(match[2])})`;
|
|
206
|
+
return `required(${JSON.stringify(expression)})`;
|
|
207
|
+
}
|
|
208
|
+
case "default": {
|
|
209
|
+
const match = expression.match(/^default\s+"([^"]+)"\s+(.+)$/);
|
|
210
|
+
if (match) return `helmDefault("${match[1]}", ${this.refToIntrinsic(match[2])})`;
|
|
211
|
+
return `helmDefault(${JSON.stringify(expression)})`;
|
|
212
|
+
}
|
|
213
|
+
case "printf": {
|
|
214
|
+
const match = expression.match(/^printf\s+"([^"]+)"\s+(.+)$/);
|
|
215
|
+
if (match) {
|
|
216
|
+
const args = match[2].split(/\s+/).map((a) => this.refToIntrinsic(a));
|
|
217
|
+
return `printf("${match[1]}", ${args.join(", ")})`;
|
|
218
|
+
}
|
|
219
|
+
return `printf(${JSON.stringify(expression)})`;
|
|
220
|
+
}
|
|
221
|
+
case "quote":
|
|
222
|
+
return `quote(${this.refToIntrinsic(expression.replace(/\s*\|\s*quote\s*$/, ""))})`;
|
|
223
|
+
case "lookup":
|
|
224
|
+
return `lookup(${JSON.stringify(expression)})`;
|
|
225
|
+
case "tpl":
|
|
226
|
+
return `tpl(${JSON.stringify(expression)})`;
|
|
227
|
+
default:
|
|
228
|
+
return `/* ${expression} */`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Convert a Go template reference to a TypeScript expression.
|
|
234
|
+
*/
|
|
235
|
+
private refToIntrinsic(ref: string): string {
|
|
236
|
+
const trimmed = ref.trim();
|
|
237
|
+
if (trimmed.startsWith(".Values.")) return `values.${trimmed.slice(8)}`;
|
|
238
|
+
if (trimmed.startsWith(".Release.")) return `Release.${trimmed.slice(9)}`;
|
|
239
|
+
if (trimmed.startsWith(".Chart.")) return `ChartRef.${trimmed.slice(7)}`;
|
|
240
|
+
if (trimmed === ".") return "context";
|
|
241
|
+
return JSON.stringify(trimmed);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private emitProps(props: Record<string, unknown>, depth: number): string {
|
|
245
|
+
const indent = " ".repeat(depth);
|
|
246
|
+
const innerIndent = " ".repeat(depth + 1);
|
|
247
|
+
const entries: string[] = [];
|
|
248
|
+
|
|
249
|
+
for (const [key, value] of Object.entries(props)) {
|
|
250
|
+
if (value === undefined || value === null) continue;
|
|
251
|
+
entries.push(`${innerIndent}${key}: ${this.emitValue(value, depth + 1)},`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (entries.length === 0) return "{}";
|
|
255
|
+
return `{\n${entries.join("\n")}\n${indent}}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private emitValue(value: unknown, depth: number): string {
|
|
259
|
+
if (value === null || value === undefined) return "undefined";
|
|
260
|
+
|
|
261
|
+
// Intrinsic call placeholder
|
|
262
|
+
if (typeof value === "object" && value !== null && "__intrinsicCall" in value) {
|
|
263
|
+
return (value as { __intrinsicCall: string }).__intrinsicCall;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
267
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
268
|
+
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
if (value.length === 0) return "[]";
|
|
271
|
+
const items = value.map((item) => this.emitValue(item, depth + 1));
|
|
272
|
+
const oneLine = `[${items.join(", ")}]`;
|
|
273
|
+
if (oneLine.length < 80) return oneLine;
|
|
274
|
+
const indent = " ".repeat(depth);
|
|
275
|
+
const innerIndent = " ".repeat(depth + 1);
|
|
276
|
+
return `[\n${items.map((i) => `${innerIndent}${i},`).join("\n")}\n${indent}]`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (typeof value === "object") {
|
|
280
|
+
return this.emitProps(value as Record<string, unknown>, depth);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return String(value);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { stripTemplateExpressions, classifyExpression } from "./template-stripper";
|
|
3
|
+
import { HelmParser } from "./parser";
|
|
4
|
+
import { HelmGenerator } from "./generator";
|
|
5
|
+
|
|
6
|
+
describe("template-stripper", () => {
|
|
7
|
+
test("strips inline expressions and replaces with placeholders", () => {
|
|
8
|
+
const template = `name: {{ .Values.name }}\nport: {{ .Values.service.port }}`;
|
|
9
|
+
const result = stripTemplateExpressions(template);
|
|
10
|
+
expect(result.yaml).toContain("__HELM_PLACEHOLDER0__");
|
|
11
|
+
expect(result.yaml).toContain("__HELM_PLACEHOLDER1__");
|
|
12
|
+
expect(result.expressions.size).toBe(2);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("removes standalone block directives", () => {
|
|
16
|
+
const template = `{{- if .Values.ingress.enabled }}\napiVersion: v1\n{{- end }}`;
|
|
17
|
+
const result = stripTemplateExpressions(template);
|
|
18
|
+
expect(result.yaml).not.toContain("if .Values");
|
|
19
|
+
expect(result.yaml).not.toContain("end");
|
|
20
|
+
expect(result.yaml).toContain("apiVersion: v1");
|
|
21
|
+
expect(result.blockDirectives).toHaveLength(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("handles whitespace-trimming markers", () => {
|
|
25
|
+
const template = `name: {{- .Values.name -}}`;
|
|
26
|
+
const result = stripTemplateExpressions(template);
|
|
27
|
+
expect(result.expressions.size).toBe(1);
|
|
28
|
+
const expr = [...result.expressions.values()][0];
|
|
29
|
+
expect(expr.expression).toBe(".Values.name");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("preserves non-template lines", () => {
|
|
33
|
+
const template = `apiVersion: v1\nkind: Service\nmetadata:\n name: {{ .Values.name }}`;
|
|
34
|
+
const result = stripTemplateExpressions(template);
|
|
35
|
+
expect(result.yaml).toContain("apiVersion: v1");
|
|
36
|
+
expect(result.yaml).toContain("kind: Service");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("classifyExpression", () => {
|
|
41
|
+
test("classifies .Values references", () => {
|
|
42
|
+
expect(classifyExpression(".Values.name")).toBe("values");
|
|
43
|
+
expect(classifyExpression(".Values.image.tag")).toBe("values");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("classifies .Release references", () => {
|
|
47
|
+
expect(classifyExpression(".Release.Name")).toBe("release");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("classifies .Chart references", () => {
|
|
51
|
+
expect(classifyExpression(".Chart.Name")).toBe("chart");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("classifies include calls", () => {
|
|
55
|
+
expect(classifyExpression('include "my-app.fullname" .')).toBe("include");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("classifies toYaml calls", () => {
|
|
59
|
+
expect(classifyExpression("toYaml .Values.resources | nindent 12")).toBe("toYaml");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("classifies printf calls", () => {
|
|
63
|
+
expect(classifyExpression('printf "%s:%s" .Values.image.repo .Values.image.tag')).toBe("printf");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("classifies pipe expressions", () => {
|
|
67
|
+
expect(classifyExpression(".Values.name | upper")).toBe("pipe");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("classifies quote expressions", () => {
|
|
71
|
+
expect(classifyExpression(".Values.name | quote")).toBe("quote");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("HelmParser", () => {
|
|
76
|
+
test("parses Chart.yaml into Helm::Chart resource", () => {
|
|
77
|
+
const files = {
|
|
78
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
79
|
+
};
|
|
80
|
+
const parser = new HelmParser();
|
|
81
|
+
const ir = parser.parseFiles(files);
|
|
82
|
+
const chartRes = ir.resources.find((r) => r.type === "Helm::Chart");
|
|
83
|
+
expect(chartRes).toBeDefined();
|
|
84
|
+
expect(chartRes!.properties.name).toBe("test");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("parses values.yaml into parameters", () => {
|
|
88
|
+
const files = {
|
|
89
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
90
|
+
"values.yaml": "replicaCount: 2\nimage:\n repository: nginx\n tag: latest\n",
|
|
91
|
+
};
|
|
92
|
+
const parser = new HelmParser();
|
|
93
|
+
const ir = parser.parseFiles(files);
|
|
94
|
+
expect(ir.parameters.length).toBeGreaterThan(0);
|
|
95
|
+
const replicaParam = ir.parameters.find((p) => p.name === "replicaCount");
|
|
96
|
+
expect(replicaParam).toBeDefined();
|
|
97
|
+
expect(replicaParam!.defaultValue).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("parses template files as K8s resources", () => {
|
|
101
|
+
const files = {
|
|
102
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
103
|
+
"templates/deploy.yaml": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: test\n",
|
|
104
|
+
};
|
|
105
|
+
const parser = new HelmParser();
|
|
106
|
+
const ir = parser.parseFiles(files);
|
|
107
|
+
const deploy = ir.resources.find((r) => r.type === "K8s::Apps::Deployment");
|
|
108
|
+
expect(deploy).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("parses NOTES.txt as Helm::Notes", () => {
|
|
112
|
+
const files = {
|
|
113
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
114
|
+
"templates/NOTES.txt": "Thank you for installing {{ .Chart.Name }}",
|
|
115
|
+
};
|
|
116
|
+
const parser = new HelmParser();
|
|
117
|
+
const ir = parser.parseFiles(files);
|
|
118
|
+
const notes = ir.resources.find((r) => r.type === "Helm::Notes");
|
|
119
|
+
expect(notes).toBeDefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("strips Go template expressions from templates", () => {
|
|
123
|
+
const files = {
|
|
124
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
125
|
+
"templates/svc.yaml": "apiVersion: v1\nkind: Service\nmetadata:\n name: {{ include \"test.fullname\" . }}\n",
|
|
126
|
+
};
|
|
127
|
+
const parser = new HelmParser();
|
|
128
|
+
const ir = parser.parseFiles(files);
|
|
129
|
+
const svc = ir.resources.find((r) => r.type === "K8s::Core::Service");
|
|
130
|
+
expect(svc).toBeDefined();
|
|
131
|
+
expect(svc!.metadata?.templateExpressions).toBeDefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("sets chart name in metadata", () => {
|
|
135
|
+
const files = {
|
|
136
|
+
"Chart.yaml": "apiVersion: v2\nname: my-app\nversion: 0.1.0\n",
|
|
137
|
+
};
|
|
138
|
+
const parser = new HelmParser();
|
|
139
|
+
const ir = parser.parseFiles(files);
|
|
140
|
+
expect(ir.metadata?.chartName).toBe("my-app");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("HelmGenerator", () => {
|
|
145
|
+
test("generates TypeScript with Helm imports", () => {
|
|
146
|
+
const ir = {
|
|
147
|
+
resources: [
|
|
148
|
+
{ logicalId: "chart", type: "Helm::Chart", properties: { apiVersion: "v2", name: "test", version: "0.1.0" } },
|
|
149
|
+
{ logicalId: "valuesSchema", type: "Helm::Values", properties: { replicaCount: 1 } },
|
|
150
|
+
],
|
|
151
|
+
parameters: [],
|
|
152
|
+
};
|
|
153
|
+
const gen = new HelmGenerator();
|
|
154
|
+
const files = gen.generate(ir);
|
|
155
|
+
expect(files).toHaveLength(1);
|
|
156
|
+
expect(files[0].path).toBe("chart.ts");
|
|
157
|
+
expect(files[0].content).toContain("@intentius/chant-lexicon-helm");
|
|
158
|
+
expect(files[0].content).toContain("export const chart = new Chart(");
|
|
159
|
+
expect(files[0].content).toContain("export const valuesSchema = new Values(");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("generates K8s resource imports", () => {
|
|
163
|
+
const ir = {
|
|
164
|
+
resources: [
|
|
165
|
+
{
|
|
166
|
+
logicalId: "deployment",
|
|
167
|
+
type: "K8s::Apps::Deployment",
|
|
168
|
+
properties: { metadata: { name: "test" } },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
parameters: [],
|
|
172
|
+
};
|
|
173
|
+
const gen = new HelmGenerator();
|
|
174
|
+
const files = gen.generate(ir);
|
|
175
|
+
expect(files[0].content).toContain("@intentius/chant-lexicon-k8s");
|
|
176
|
+
expect(files[0].content).toContain("export const deployment = new Deployment(");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("substitutes template expressions with intrinsic calls", () => {
|
|
180
|
+
const ir = {
|
|
181
|
+
resources: [
|
|
182
|
+
{
|
|
183
|
+
logicalId: "service",
|
|
184
|
+
type: "K8s::Core::Service",
|
|
185
|
+
properties: {
|
|
186
|
+
metadata: { name: "__HELM_PLACEHOLDER0__" },
|
|
187
|
+
},
|
|
188
|
+
metadata: {
|
|
189
|
+
templateExpressions: {
|
|
190
|
+
"__HELM_PLACEHOLDER0__": { expression: 'include "test.fullname" .', kind: "include" },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
parameters: [],
|
|
196
|
+
};
|
|
197
|
+
const gen = new HelmGenerator();
|
|
198
|
+
const files = gen.generate(ir);
|
|
199
|
+
expect(files[0].content).toContain('include("test.fullname")');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("imports intrinsics based on template expressions", () => {
|
|
203
|
+
const ir = {
|
|
204
|
+
resources: [
|
|
205
|
+
{
|
|
206
|
+
logicalId: "deploy",
|
|
207
|
+
type: "K8s::Apps::Deployment",
|
|
208
|
+
properties: { metadata: { name: "__HELM_PLACEHOLDER0__" }, spec: { replicas: "__HELM_PLACEHOLDER1__" } },
|
|
209
|
+
metadata: {
|
|
210
|
+
templateExpressions: {
|
|
211
|
+
"__HELM_PLACEHOLDER0__": { expression: 'include "test.fullname" .', kind: "include" },
|
|
212
|
+
"__HELM_PLACEHOLDER1__": { expression: ".Values.replicaCount", kind: "values" },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
parameters: [],
|
|
218
|
+
};
|
|
219
|
+
const gen = new HelmGenerator();
|
|
220
|
+
const files = gen.generate(ir);
|
|
221
|
+
expect(files[0].content).toContain("include");
|
|
222
|
+
expect(files[0].content).toContain("values");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helm chart parser for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Parses an existing Helm chart directory (Chart.yaml, values.yaml, templates/)
|
|
5
|
+
* into the core TemplateIR format, preserving Go template expressions as metadata
|
|
6
|
+
* for reconstruction as Helm intrinsics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TemplateParser, TemplateIR, ResourceIR, ParameterIR } from "@intentius/chant/import/parser";
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
import { stripTemplateExpressions, classifyExpression } from "./template-stripper";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Helm chart parser.
|
|
15
|
+
*
|
|
16
|
+
* Input: a map of file paths to contents (as would be read from a chart directory).
|
|
17
|
+
* The parse() method accepts the contents as a JSON-encoded map.
|
|
18
|
+
*/
|
|
19
|
+
export class HelmParser implements TemplateParser {
|
|
20
|
+
parse(content: string): TemplateIR {
|
|
21
|
+
// Content is a JSON map of { "path": "content" }
|
|
22
|
+
const files: Record<string, string> = JSON.parse(content);
|
|
23
|
+
return this.parseFiles(files);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
parseFiles(files: Record<string, string>): TemplateIR {
|
|
27
|
+
const resources: ResourceIR[] = [];
|
|
28
|
+
const parameters: ParameterIR[] = [];
|
|
29
|
+
|
|
30
|
+
// Parse Chart.yaml
|
|
31
|
+
const chartYaml = files["Chart.yaml"];
|
|
32
|
+
if (chartYaml) {
|
|
33
|
+
const chartData = parseYAML(chartYaml) as Record<string, unknown>;
|
|
34
|
+
resources.push({
|
|
35
|
+
logicalId: "chart",
|
|
36
|
+
type: "Helm::Chart",
|
|
37
|
+
properties: chartData,
|
|
38
|
+
metadata: { file: "Chart.yaml" },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse values.yaml → extract parameters
|
|
43
|
+
const valuesYaml = files["values.yaml"];
|
|
44
|
+
if (valuesYaml) {
|
|
45
|
+
const valuesData = parseYAML(valuesYaml) as Record<string, unknown>;
|
|
46
|
+
if (valuesData && typeof valuesData === "object") {
|
|
47
|
+
resources.push({
|
|
48
|
+
logicalId: "valuesSchema",
|
|
49
|
+
type: "Helm::Values",
|
|
50
|
+
properties: valuesData,
|
|
51
|
+
metadata: { file: "values.yaml" },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Flatten values into parameters
|
|
55
|
+
this.extractParameters(valuesData, "", parameters);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse templates
|
|
60
|
+
for (const [path, content] of Object.entries(files)) {
|
|
61
|
+
if (!path.startsWith("templates/")) continue;
|
|
62
|
+
if (path.endsWith("_helpers.tpl")) continue;
|
|
63
|
+
if (path.endsWith("NOTES.txt")) {
|
|
64
|
+
resources.push({
|
|
65
|
+
logicalId: "notes",
|
|
66
|
+
type: "Helm::Notes",
|
|
67
|
+
properties: { content },
|
|
68
|
+
metadata: { file: path },
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.parseTemplate(path, content, resources);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
resources,
|
|
78
|
+
parameters,
|
|
79
|
+
metadata: {
|
|
80
|
+
chartName: (resources.find((r) => r.type === "Helm::Chart")?.properties?.name as string) ?? "unknown",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private parseTemplate(path: string, content: string, resources: ResourceIR[]): void {
|
|
86
|
+
const stripped = stripTemplateExpressions(content);
|
|
87
|
+
const parsed = parseYAML(stripped.yaml);
|
|
88
|
+
|
|
89
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
90
|
+
|
|
91
|
+
const doc = parsed as Record<string, unknown>;
|
|
92
|
+
const apiVersion = doc.apiVersion as string | undefined;
|
|
93
|
+
const kind = doc.kind as string | undefined;
|
|
94
|
+
|
|
95
|
+
if (!apiVersion || !kind) return;
|
|
96
|
+
|
|
97
|
+
// Derive a logical ID from the file path
|
|
98
|
+
const fileName = path.replace("templates/", "").replace(/\.yaml$/, "").replace(/[/\\-]/g, "_");
|
|
99
|
+
const logicalId = kind.charAt(0).toLowerCase() + kind.slice(1) + capitalize(fileName);
|
|
100
|
+
|
|
101
|
+
// Restore template expressions as metadata
|
|
102
|
+
const templateExpressions: Record<string, { expression: string; kind: string }> = {};
|
|
103
|
+
for (const [placeholder, expr] of stripped.expressions) {
|
|
104
|
+
templateExpressions[placeholder] = {
|
|
105
|
+
expression: expr.expression,
|
|
106
|
+
kind: classifyExpression(expr.expression),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Extract properties (skip apiVersion/kind)
|
|
111
|
+
const properties: Record<string, unknown> = {};
|
|
112
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
113
|
+
if (key === "apiVersion" || key === "kind") continue;
|
|
114
|
+
properties[key] = value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
resources.push({
|
|
118
|
+
logicalId,
|
|
119
|
+
type: `K8s::${resolveGroup(apiVersion)}::${kind}`,
|
|
120
|
+
properties,
|
|
121
|
+
metadata: {
|
|
122
|
+
file: path,
|
|
123
|
+
apiVersion,
|
|
124
|
+
kind,
|
|
125
|
+
templateExpressions,
|
|
126
|
+
blockDirectives: stripped.blockDirectives,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private extractParameters(
|
|
132
|
+
obj: Record<string, unknown>,
|
|
133
|
+
prefix: string,
|
|
134
|
+
params: ParameterIR[],
|
|
135
|
+
): void {
|
|
136
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
137
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
138
|
+
|
|
139
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
140
|
+
this.extractParameters(value as Record<string, unknown>, fullPath, params);
|
|
141
|
+
} else {
|
|
142
|
+
params.push({
|
|
143
|
+
name: fullPath,
|
|
144
|
+
type: Array.isArray(value) ? "array" : typeof value,
|
|
145
|
+
defaultValue: value,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveGroup(apiVersion: string): string {
|
|
153
|
+
if (!apiVersion.includes("/")) return "Core";
|
|
154
|
+
const group = apiVersion.split("/")[0].split(".")[0];
|
|
155
|
+
return group.charAt(0).toUpperCase() + group.slice(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function capitalize(s: string): string {
|
|
159
|
+
return s.split(/[_-]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
160
|
+
}
|