@intentius/chant-lexicon-helm 0.0.22 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +7 -5
- package/dist/manifest.json +1 -1
- package/dist/rules/values-no-helm-tpl.ts +92 -0
- package/dist/rules/whm005-no-empty-wrapper.ts +54 -0
- package/dist/skills/{chant-helm-chart-patterns.md → chant-helm-patterns.md} +52 -0
- package/dist/skills/chant-helm.md +496 -0
- package/package.json +6 -3
- package/src/codegen/docs.ts +3 -2
- package/src/composites/composites.test.ts +116 -110
- package/src/composites/helm-batch-job.ts +33 -19
- package/src/composites/helm-crd-lifecycle.ts +37 -24
- package/src/composites/helm-cron-job.ts +25 -13
- package/src/composites/helm-daemon-set.ts +26 -14
- package/src/composites/helm-external-secret.ts +21 -12
- package/src/composites/helm-library.ts +21 -7
- package/src/composites/helm-microservice.ts +46 -29
- package/src/composites/helm-monitored-service.ts +75 -51
- package/src/composites/helm-namespace-env.ts +80 -52
- package/src/composites/helm-secure-ingress.ts +66 -50
- package/src/composites/helm-stateful-service.ts +29 -16
- package/src/composites/helm-web-app.ts +37 -22
- package/src/composites/helm-worker.ts +34 -20
- package/src/index.ts +4 -1
- package/src/intrinsics.ts +53 -0
- package/src/lint/post-synth/post-synth.test.ts +43 -0
- package/src/lint/post-synth/whm005-no-empty-wrapper.ts +54 -0
- package/src/lint/rules/lint-rules.test.ts +35 -0
- package/src/lint/rules/values-no-helm-tpl.ts +92 -0
- package/src/plugin.test.ts +7 -5
- package/src/plugin.ts +8 -42
- package/src/resources.ts +49 -0
- package/src/serializer.test.ts +113 -2
- package/src/serializer.ts +149 -13
- package/src/skills/{chart-patterns.md → chant-helm-patterns.md} +52 -0
- package/src/skills/chant-helm.md +496 -0
- package/dist/skills/chant-helm-create-chart.md +0 -211
- package/src/skills/create-chart.md +0 -211
- /package/dist/skills/{chant-helm-chart-security-patterns.md → chant-helm-security.md} +0 -0
- /package/src/skills/{chart-security-patterns.md → chant-helm-security.md} +0 -0
|
@@ -18,6 +18,7 @@ import { whm406 } from "./whm406";
|
|
|
18
18
|
import { whm407 } from "./whm407";
|
|
19
19
|
import { whm501 } from "./whm501";
|
|
20
20
|
import { whm502 } from "./whm502";
|
|
21
|
+
import { whm005 } from "./whm005-no-empty-wrapper";
|
|
21
22
|
|
|
22
23
|
function makeCtx(files: Record<string, string>): PostSynthContext {
|
|
23
24
|
const result: SerializerResult = { primary: files["Chart.yaml"] ?? "", files };
|
|
@@ -36,6 +37,48 @@ function makeCtx(files: Record<string, string>): PostSynthContext {
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
describe("WHM005: noEmptyWrapperChart", () => {
|
|
41
|
+
test("warns when chart has HelmDependency but no templates", () => {
|
|
42
|
+
const ctx = makeCtx({
|
|
43
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
44
|
+
"Chart.yaml.deps": "",
|
|
45
|
+
"templates/_helpers.tpl": "{{/* helpers */}}",
|
|
46
|
+
});
|
|
47
|
+
// Inject dependencies block
|
|
48
|
+
const files = {
|
|
49
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\ndependencies:\n - name: gitlab\n version: 8.7.2\n repository: https://charts.gitlab.io\n",
|
|
50
|
+
"templates/_helpers.tpl": "{{/* helpers */}}",
|
|
51
|
+
};
|
|
52
|
+
const ctx2 = makeCtx(files);
|
|
53
|
+
const diags = whm005.check(ctx2);
|
|
54
|
+
expect(diags).toHaveLength(1);
|
|
55
|
+
expect(diags[0].checkId).toBe("WHM005");
|
|
56
|
+
expect(diags[0].severity).toBe("warning");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("passes when chart has dependencies and templates", () => {
|
|
60
|
+
const ctx = makeCtx({
|
|
61
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\ndependencies:\n - name: gitlab\n version: 8.7.2\n repository: https://charts.gitlab.io\n",
|
|
62
|
+
"templates/_helpers.tpl": "{{/* helpers */}}",
|
|
63
|
+
"templates/deployment.yaml": "apiVersion: apps/v1\nkind: Deployment\n",
|
|
64
|
+
});
|
|
65
|
+
expect(whm005.check(ctx)).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("passes when chart has no dependencies", () => {
|
|
69
|
+
const ctx = makeCtx({
|
|
70
|
+
"Chart.yaml": "apiVersion: v2\nname: test\nversion: 0.1.0\n",
|
|
71
|
+
"templates/deployment.yaml": "apiVersion: apps/v1\nkind: Deployment\n",
|
|
72
|
+
});
|
|
73
|
+
expect(whm005.check(ctx)).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("passes when no Chart.yaml present", () => {
|
|
77
|
+
const ctx = makeCtx({});
|
|
78
|
+
expect(whm005.check(ctx)).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
39
82
|
describe("WHM101: Chart.yaml validation", () => {
|
|
40
83
|
test("passes with valid Chart.yaml", () => {
|
|
41
84
|
const ctx = makeCtx({
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM005: Chart Has Sub-chart Dependencies But Generates No Templates
|
|
3
|
+
*
|
|
4
|
+
* A chart with HelmDependency entries but no templates/*.yaml files generates
|
|
5
|
+
* an empty templates/ directory. Deploying it requires `helm dependency build`
|
|
6
|
+
* as a non-obvious prerequisite.
|
|
7
|
+
*
|
|
8
|
+
* If you only need value overrides for an upstream chart, deploy it directly:
|
|
9
|
+
* helm upgrade upstream-chart -f values-override.yaml
|
|
10
|
+
* and use ValuesOverride to generate the override file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
14
|
+
import { getChartFiles } from "./helm-helpers";
|
|
15
|
+
|
|
16
|
+
export const whm005: PostSynthCheck = {
|
|
17
|
+
id: "WHM005",
|
|
18
|
+
description:
|
|
19
|
+
"Chart with sub-chart dependencies but no templates should deploy upstream chart directly",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const files = getChartFiles(output);
|
|
26
|
+
const chartYaml = files["Chart.yaml"];
|
|
27
|
+
if (!chartYaml) continue;
|
|
28
|
+
|
|
29
|
+
// Check for non-empty dependencies block
|
|
30
|
+
const hasDependencies = /^dependencies:/m.test(chartYaml);
|
|
31
|
+
if (!hasDependencies) continue;
|
|
32
|
+
|
|
33
|
+
// Check for template files (excluding _helpers.tpl and NOTES.txt)
|
|
34
|
+
const hasTemplates = Object.keys(files).some((path) => {
|
|
35
|
+
if (!path.startsWith("templates/")) return false;
|
|
36
|
+
const filename = path.slice("templates/".length);
|
|
37
|
+
if (filename === "_helpers.tpl" || filename === "NOTES.txt") return false;
|
|
38
|
+
return filename.endsWith(".yaml") || filename.endsWith(".yml");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!hasTemplates) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WHM005",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
message:
|
|
46
|
+
"Chart has sub-chart dependencies but generates no templates. Deploying this chart requires 'helm dependency build' first. If you only need value overrides for an upstream chart, deploy it directly with 'helm upgrade upstream-chart -f values-override.yaml' and use ValuesOverride to generate the override file.",
|
|
47
|
+
lexicon: "helm",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -4,6 +4,7 @@ import type { LintContext } from "@intentius/chant/lint/rule";
|
|
|
4
4
|
import { chartMetadataRule } from "./chart-metadata";
|
|
5
5
|
import { valuesNoSecretsRule } from "./values-no-secrets";
|
|
6
6
|
import { noHardcodedImageRule } from "./no-hardcoded-image";
|
|
7
|
+
import { valuesNoHelmTplRule } from "./values-no-helm-tpl";
|
|
7
8
|
|
|
8
9
|
function makeContext(code: string): LintContext {
|
|
9
10
|
const sourceFile = ts.createSourceFile("test.ts", code, ts.ScriptTarget.Latest, true);
|
|
@@ -70,6 +71,40 @@ describe("WHM002: valuesNoSecretsRule", () => {
|
|
|
70
71
|
});
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
describe("WHM004: valuesNoHelmTplRule", () => {
|
|
75
|
+
test("warns when Values prop uses v.xxx", () => {
|
|
76
|
+
const ctx = makeContext(`new Values({ host: v.myHost });`);
|
|
77
|
+
const diags = valuesNoHelmTplRule.check(ctx);
|
|
78
|
+
expect(diags).toHaveLength(1);
|
|
79
|
+
expect(diags[0].ruleId).toBe("WHM004");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("warns on nested v.xxx", () => {
|
|
83
|
+
const ctx = makeContext(`new Values({ global: { hosts: { domain: v.cellDomain } } });`);
|
|
84
|
+
expect(valuesNoHelmTplRule.check(ctx).length).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("passes when Values props are static", () => {
|
|
88
|
+
const ctx = makeContext(`new Values({ host: "localhost" });`);
|
|
89
|
+
expect(valuesNoHelmTplRule.check(ctx)).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("passes for runtimeSlot() calls", () => {
|
|
93
|
+
const ctx = makeContext(`new Values({ host: runtimeSlot("Cloud SQL IP") });`);
|
|
94
|
+
expect(valuesNoHelmTplRule.check(ctx)).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("does not fire on non-Values constructors", () => {
|
|
98
|
+
const ctx = makeContext(`new Deployment({ image: v.image });`);
|
|
99
|
+
expect(valuesNoHelmTplRule.check(ctx)).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("warns on v.xxx pipe chain", () => {
|
|
103
|
+
const ctx = makeContext(`new Values({ host: v.myHost.pipe("quote") });`);
|
|
104
|
+
expect(valuesNoHelmTplRule.check(ctx)).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
73
108
|
describe("WHM003: noHardcodedImageRule", () => {
|
|
74
109
|
test("warns on hardcoded image with tag", () => {
|
|
75
110
|
const ctx = makeContext(`new Deployment({ spec: { containers: [{ image: "nginx:1.19" }] } });`);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM004: HelmTpl Expression Has No Effect in Values Constructor
|
|
3
|
+
*
|
|
4
|
+
* Detects Values constructor props that use `v.xxx` (the `values` proxy)
|
|
5
|
+
* or any HelmTpl-like expression. values.yaml is static YAML — it is NOT
|
|
6
|
+
* processed as a Go template by Helm. These expressions silently become ''.
|
|
7
|
+
*
|
|
8
|
+
* Bad: new Values({ host: v.pgHost })
|
|
9
|
+
* Good: new Values({ host: runtimeSlot("Cloud SQL private IP") })
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
13
|
+
import * as ts from "typescript";
|
|
14
|
+
|
|
15
|
+
export const valuesNoHelmTplRule: LintRule = {
|
|
16
|
+
id: "WHM004",
|
|
17
|
+
severity: "warning",
|
|
18
|
+
category: "correctness",
|
|
19
|
+
description:
|
|
20
|
+
"HelmTpl expression has no effect in values.yaml — use runtimeSlot() for deploy-time values",
|
|
21
|
+
|
|
22
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
23
|
+
const { sourceFile } = context;
|
|
24
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
25
|
+
|
|
26
|
+
function visit(node: ts.Node): void {
|
|
27
|
+
if (
|
|
28
|
+
ts.isNewExpression(node) &&
|
|
29
|
+
ts.isIdentifier(node.expression) &&
|
|
30
|
+
node.expression.text === "Values" &&
|
|
31
|
+
node.arguments &&
|
|
32
|
+
node.arguments.length > 0
|
|
33
|
+
) {
|
|
34
|
+
const arg = node.arguments[0];
|
|
35
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
36
|
+
checkObjectLiteral(arg, [], sourceFile, diagnostics);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
ts.forEachChild(node, visit);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
visit(sourceFile);
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the root identifier name of a property access / call chain.
|
|
49
|
+
* v.foo → "v"; values.x.pipe("fn") → "values"; runtimeSlot() → "runtimeSlot"
|
|
50
|
+
*/
|
|
51
|
+
function getRootIdentifier(node: ts.Node): string | null {
|
|
52
|
+
if (ts.isIdentifier(node)) return node.text;
|
|
53
|
+
if (ts.isPropertyAccessExpression(node)) return getRootIdentifier(node.expression);
|
|
54
|
+
if (ts.isCallExpression(node)) return getRootIdentifier(node.expression);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isHelmTplExpr(node: ts.Node): boolean {
|
|
59
|
+
const root = getRootIdentifier(node);
|
|
60
|
+
return root === "v" || root === "values";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkObjectLiteral(
|
|
64
|
+
obj: ts.ObjectLiteralExpression,
|
|
65
|
+
path: string[],
|
|
66
|
+
sourceFile: ts.SourceFile,
|
|
67
|
+
diagnostics: LintDiagnostic[],
|
|
68
|
+
): void {
|
|
69
|
+
for (const prop of obj.properties) {
|
|
70
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
71
|
+
|
|
72
|
+
const keyName = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
|
|
73
|
+
? prop.name.text
|
|
74
|
+
: undefined;
|
|
75
|
+
const propPath = keyName ? [...path, keyName] : path;
|
|
76
|
+
|
|
77
|
+
if (isHelmTplExpr(prop.initializer)) {
|
|
78
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(prop.getStart());
|
|
79
|
+
const pathStr = propPath.join(".");
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
file: sourceFile.fileName,
|
|
82
|
+
line: line + 1,
|
|
83
|
+
column: character + 1,
|
|
84
|
+
ruleId: "WHM004",
|
|
85
|
+
severity: "warning",
|
|
86
|
+
message: `HelmTpl expression has no effect in values.yaml (values.yaml is not a Go template). Use runtimeSlot() for deploy-time values or a static default.${pathStr ? ` (path: ${pathStr})` : ""}`,
|
|
87
|
+
});
|
|
88
|
+
} else if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
89
|
+
checkObjectLiteral(prop.initializer, propPath, sourceFile, diagnostics);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -28,17 +28,19 @@ describe("helmPlugin", () => {
|
|
|
28
28
|
|
|
29
29
|
test("provides lint rules", () => {
|
|
30
30
|
const rules = helmPlugin.lintRules!();
|
|
31
|
-
expect(rules.length).toBe(
|
|
31
|
+
expect(rules.length).toBe(4);
|
|
32
32
|
const ids = rules.map((r) => r.id);
|
|
33
33
|
expect(ids).toContain("WHM001");
|
|
34
34
|
expect(ids).toContain("WHM002");
|
|
35
35
|
expect(ids).toContain("WHM003");
|
|
36
|
+
expect(ids).toContain("WHM004");
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
test("provides post-synth checks", () => {
|
|
39
40
|
const checks = helmPlugin.postSynthChecks!();
|
|
40
|
-
expect(checks.length).toBe(
|
|
41
|
+
expect(checks.length).toBe(21);
|
|
41
42
|
const ids = checks.map((c) => c.id);
|
|
43
|
+
expect(ids).toContain("WHM005");
|
|
42
44
|
expect(ids).toContain("WHM101");
|
|
43
45
|
expect(ids).toContain("WHM105");
|
|
44
46
|
expect(ids).toContain("WHM301");
|
|
@@ -64,8 +66,8 @@ describe("helmPlugin", () => {
|
|
|
64
66
|
expect(Array.isArray(skills)).toBe(true);
|
|
65
67
|
expect(skills.length).toBeGreaterThanOrEqual(3);
|
|
66
68
|
const names = skills.map((s) => s.name);
|
|
67
|
-
expect(names).toContain("chant-helm
|
|
68
|
-
expect(names).toContain("chant-helm-
|
|
69
|
-
expect(names).toContain("chant-helm-
|
|
69
|
+
expect(names).toContain("chant-helm");
|
|
70
|
+
expect(names).toContain("chant-helm-patterns");
|
|
71
|
+
expect(names).toContain("chant-helm-security");
|
|
70
72
|
});
|
|
71
73
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* Helm-specific intrinsics, lint rules, and post-synth checks.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { LexiconPlugin, IntrinsicDef, InitTemplateSet
|
|
8
|
+
import type { LexiconPlugin, IntrinsicDef, InitTemplateSet } from "@intentius/chant/lexicon";
|
|
9
9
|
import { discoverLintRules, discoverPostSynthChecks } from "@intentius/chant/lint/discover";
|
|
10
|
-
import {
|
|
10
|
+
import { createSkillsLoader, createDiffTool } from "@intentius/chant/lexicon-plugin-helpers";
|
|
11
11
|
import { join, dirname } from "path";
|
|
12
12
|
import { fileURLToPath } from "url";
|
|
13
13
|
import { helmSerializer } from "./serializer";
|
|
@@ -65,29 +65,7 @@ export const helmPlugin: LexiconPlugin = {
|
|
|
65
65
|
},
|
|
66
66
|
|
|
67
67
|
mcpTools() {
|
|
68
|
-
return [
|
|
69
|
-
{
|
|
70
|
-
name: "diff",
|
|
71
|
-
description: "Compare current Helm chart build output against previous output",
|
|
72
|
-
inputSchema: {
|
|
73
|
-
type: "object" as const,
|
|
74
|
-
properties: {
|
|
75
|
-
path: {
|
|
76
|
-
type: "string",
|
|
77
|
-
description: "Path to the infrastructure project directory",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
82
|
-
const { diffCommand } = await import("@intentius/chant/cli/commands/diff");
|
|
83
|
-
const result = await diffCommand({
|
|
84
|
-
path: (params.path as string) ?? ".",
|
|
85
|
-
serializers: [helmSerializer],
|
|
86
|
-
});
|
|
87
|
-
return result;
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
];
|
|
68
|
+
return [createDiffTool(helmSerializer, "Compare current Helm chart build output against previous output")];
|
|
91
69
|
},
|
|
92
70
|
|
|
93
71
|
mcpResources() {
|
|
@@ -344,23 +322,11 @@ export const service = new Service({
|
|
|
344
322
|
};
|
|
345
323
|
},
|
|
346
324
|
|
|
347
|
-
skills
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (!file.endsWith(".md")) continue;
|
|
353
|
-
const content = readFileSync(join(skillsDir, file), "utf-8");
|
|
354
|
-
const name = file.replace(/\.md$/, "");
|
|
355
|
-
// Derive a readable description from the filename
|
|
356
|
-
const desc = name.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
357
|
-
skills.push({ name: `chant-helm-${name}`, description: desc, content });
|
|
358
|
-
}
|
|
359
|
-
} catch {
|
|
360
|
-
// No skills directory
|
|
361
|
-
}
|
|
362
|
-
return skills;
|
|
363
|
-
},
|
|
325
|
+
skills: createSkillsLoader(import.meta.url, [
|
|
326
|
+
{ file: "chant-helm.md", name: "chant-helm", description: "Build, validate, and package Helm charts from a chant project" },
|
|
327
|
+
{ file: "chant-helm-patterns.md", name: "chant-helm-patterns", description: "Common Helm chart patterns and best practices using chant" },
|
|
328
|
+
{ file: "chant-helm-security.md", name: "chant-helm-security", description: "Security best practices for Helm charts built with chant" },
|
|
329
|
+
]),
|
|
364
330
|
|
|
365
331
|
async docs(options?: { verbose?: boolean }): Promise<void> {
|
|
366
332
|
const { generateDocs } = await import("./codegen/docs");
|
package/src/resources.ts
CHANGED
|
@@ -67,6 +67,26 @@ export const HelmDependency = createProperty("Helm::Dependency", LEXICON);
|
|
|
67
67
|
*/
|
|
68
68
|
export const HelmMaintainer = createProperty("Helm::Maintainer", LEXICON);
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Helm::ValuesOverride — Generate a named YAML override file.
|
|
72
|
+
*
|
|
73
|
+
* Emits a standalone YAML file inside the chart directory, intended to be
|
|
74
|
+
* passed as `helm upgrade -f chart-dir/values-base.yaml`. Use this for
|
|
75
|
+
* static configuration shared across all deployments (disabled bundled
|
|
76
|
+
* services, shared secret references, etc.).
|
|
77
|
+
*
|
|
78
|
+
* Props: { filename: string, values: Record<string, unknown> }
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* new ValuesOverride({
|
|
82
|
+
* filename: "values-base",
|
|
83
|
+
* values: { postgresql: { install: false }, redis: { install: false } },
|
|
84
|
+
* })
|
|
85
|
+
* // → generates chart-dir/values-base.yaml
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export const ValuesOverride = createResource("Helm::ValuesOverride", LEXICON, {});
|
|
89
|
+
|
|
70
90
|
// ── CRD ──────────────────────────────────────────────────
|
|
71
91
|
|
|
72
92
|
/**
|
|
@@ -75,3 +95,32 @@ export const HelmMaintainer = createProperty("Helm::Maintainer", LEXICON);
|
|
|
75
95
|
* Props: { content: string, filename?: string }
|
|
76
96
|
*/
|
|
77
97
|
export const HelmCRD = createResource("Helm::CRD", LEXICON, {});
|
|
98
|
+
|
|
99
|
+
// ── K8s resource types used by composites ────────────────
|
|
100
|
+
// These are thin wrappers so composite members are Declarable.
|
|
101
|
+
|
|
102
|
+
const K8S = "k8s";
|
|
103
|
+
|
|
104
|
+
export const Deployment = createResource("K8s::Apps::Deployment", K8S, {});
|
|
105
|
+
export const StatefulSet = createResource("K8s::Apps::StatefulSet", K8S, {});
|
|
106
|
+
export const DaemonSet = createResource("K8s::Apps::DaemonSet", K8S, {});
|
|
107
|
+
export const Service = createResource("K8s::Core::Service", K8S, {});
|
|
108
|
+
export const ServiceAccount = createResource("K8s::Core::ServiceAccount", K8S, {});
|
|
109
|
+
export const ConfigMap = createResource("K8s::Core::ConfigMap", K8S, {});
|
|
110
|
+
export const Namespace = createResource("K8s::Core::Namespace", K8S, {});
|
|
111
|
+
export const Job = createResource("K8s::Batch::Job", K8S, {});
|
|
112
|
+
export const CronJob = createResource("K8s::Batch::CronJob", K8S, {});
|
|
113
|
+
export const Ingress = createResource("K8s::Networking::Ingress", K8S, {});
|
|
114
|
+
export const NetworkPolicy = createResource("K8s::Networking::NetworkPolicy", K8S, {});
|
|
115
|
+
export const HPA = createResource("K8s::Autoscaling::HorizontalPodAutoscaler", K8S, {});
|
|
116
|
+
export const PDB = createResource("K8s::Policy::PodDisruptionBudget", K8S, {});
|
|
117
|
+
export const ResourceQuota = createResource("K8s::Core::ResourceQuota", K8S, {});
|
|
118
|
+
export const LimitRange = createResource("K8s::Core::LimitRange", K8S, {});
|
|
119
|
+
export const ClusterRole = createResource("K8s::Rbac::ClusterRole", K8S, {});
|
|
120
|
+
export const ClusterRoleBinding = createResource("K8s::Rbac::ClusterRoleBinding", K8S, {});
|
|
121
|
+
export const Role = createResource("K8s::Rbac::Role", K8S, {});
|
|
122
|
+
export const RoleBinding = createResource("K8s::Rbac::RoleBinding", K8S, {});
|
|
123
|
+
export const ExternalSecret = createResource("K8s::ExternalSecrets::ExternalSecret", K8S, {});
|
|
124
|
+
export const ServiceMonitor = createResource("K8s::Monitoring::ServiceMonitor", K8S, {});
|
|
125
|
+
export const PrometheusRule = createResource("K8s::Monitoring::PrometheusRule", K8S, {});
|
|
126
|
+
export const Certificate = createResource("K8s::CertManager::Certificate", K8S, {});
|
package/src/serializer.test.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { createResource, createProperty } from "@intentius/chant/runtime";
|
|
|
3
3
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
4
4
|
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
5
5
|
import { helmSerializer } from "./serializer";
|
|
6
|
-
import { Chart, Values, HelmNotes, HelmTest, HelmHook, HelmDependency, HelmMaintainer, HelmCRD } from "./resources";
|
|
7
|
-
import { values, include, printf, toYaml, quote, helmDefault, required, If, ElseIf, Range, With, Release, ChartRef, Capabilities } from "./intrinsics";
|
|
6
|
+
import { Chart, Values, ValuesOverride, HelmNotes, HelmTest, HelmHook, HelmDependency, HelmMaintainer, HelmCRD } from "./resources";
|
|
7
|
+
import { values, include, printf, toYaml, quote, helmDefault, required, If, ElseIf, Range, With, Release, ChartRef, Capabilities, runtimeSlot } from "./intrinsics";
|
|
8
8
|
|
|
9
9
|
function makeEntities(...pairs: [string, Record<string, unknown>][]): Map<string, Declarable> {
|
|
10
10
|
const entities = new Map<string, Declarable>();
|
|
@@ -792,6 +792,117 @@ describe("Capabilities in template", () => {
|
|
|
792
792
|
});
|
|
793
793
|
});
|
|
794
794
|
|
|
795
|
+
describe("runtimeSlot in Values", () => {
|
|
796
|
+
test("emits '' for runtimeSlot in values.yaml", () => {
|
|
797
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
798
|
+
const vals = new Values({
|
|
799
|
+
global: {
|
|
800
|
+
psql: { host: runtimeSlot("Cloud SQL private IP") },
|
|
801
|
+
},
|
|
802
|
+
replicaCount: 1,
|
|
803
|
+
});
|
|
804
|
+
const entities = makeEntities(["chart", chart], ["values", vals]);
|
|
805
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
806
|
+
const valuesYaml = result.files!["values.yaml"];
|
|
807
|
+
|
|
808
|
+
expect(valuesYaml).toContain("host: ''");
|
|
809
|
+
expect(valuesYaml).toContain("replicaCount: 1");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("emits values-runtime-slots.yaml when RuntimeSlot present", () => {
|
|
813
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
814
|
+
const vals = new Values({
|
|
815
|
+
global: {
|
|
816
|
+
psql: { host: runtimeSlot("Cloud SQL private IP") },
|
|
817
|
+
redis: { host: runtimeSlot("Memorystore host") },
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
const entities = makeEntities(["chart", chart], ["values", vals]);
|
|
821
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
822
|
+
const slotsYaml = result.files!["values-runtime-slots.yaml"];
|
|
823
|
+
|
|
824
|
+
expect(slotsYaml).toBeDefined();
|
|
825
|
+
expect(slotsYaml).toContain("# Generated by chant");
|
|
826
|
+
expect(slotsYaml).toContain("# Cloud SQL private IP");
|
|
827
|
+
expect(slotsYaml).toContain("host: ''");
|
|
828
|
+
expect(slotsYaml).toContain("# Memorystore host");
|
|
829
|
+
// Only RuntimeSlot fields should appear
|
|
830
|
+
expect(slotsYaml).not.toContain("replicaCount");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("does not emit values-runtime-slots.yaml when no RuntimeSlot", () => {
|
|
834
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
835
|
+
const vals = new Values({ replicaCount: 1, image: { repository: "nginx" } });
|
|
836
|
+
const entities = makeEntities(["chart", chart], ["values", vals]);
|
|
837
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
838
|
+
|
|
839
|
+
expect(result.files!["values-runtime-slots.yaml"]).toBeUndefined();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("runtimeSlot with empty description emits no comment", () => {
|
|
843
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
844
|
+
const vals = new Values({ host: runtimeSlot() });
|
|
845
|
+
const entities = makeEntities(["chart", chart], ["values", vals]);
|
|
846
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
847
|
+
const slotsYaml = result.files!["values-runtime-slots.yaml"];
|
|
848
|
+
|
|
849
|
+
expect(slotsYaml).toBeDefined();
|
|
850
|
+
expect(slotsYaml).toContain("host: ''");
|
|
851
|
+
// No comment line for empty description
|
|
852
|
+
const lines = slotsYaml!.split("\n");
|
|
853
|
+
const hostLine = lines.findIndex((l) => l.includes("host:"));
|
|
854
|
+
const prevLine = lines[hostLine - 1] ?? "";
|
|
855
|
+
expect(prevLine.trim().startsWith("#") && !prevLine.includes("Generated")).toBe(false);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
describe("ValuesOverride serialization", () => {
|
|
860
|
+
test("emits ValuesOverride as a named yaml file", () => {
|
|
861
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
862
|
+
const override = new ValuesOverride({
|
|
863
|
+
filename: "values-base",
|
|
864
|
+
values: {
|
|
865
|
+
postgresql: { install: false },
|
|
866
|
+
redis: { install: false },
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
const entities = makeEntities(["chart", chart], ["baseOverride", override]);
|
|
870
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
871
|
+
|
|
872
|
+
expect(result.files!["values-base.yaml"]).toBeDefined();
|
|
873
|
+
expect(result.files!["values-base.yaml"]).toContain("postgresql:");
|
|
874
|
+
expect(result.files!["values-base.yaml"]).toContain("install: false");
|
|
875
|
+
expect(result.files!["values-base.yaml"]).toContain("redis:");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("ValuesOverride content does not affect values.yaml", () => {
|
|
879
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
880
|
+
const vals = new Values({ replicaCount: 1 });
|
|
881
|
+
const override = new ValuesOverride({
|
|
882
|
+
filename: "values-base",
|
|
883
|
+
values: { postgresql: { install: false } },
|
|
884
|
+
});
|
|
885
|
+
const entities = makeEntities(["chart", chart], ["values", vals], ["override", override]);
|
|
886
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
887
|
+
|
|
888
|
+
expect(result.files!["values.yaml"]).toContain("replicaCount: 1");
|
|
889
|
+
expect(result.files!["values.yaml"]).not.toContain("postgresql");
|
|
890
|
+
expect(result.files!["values-base.yaml"]).toContain("postgresql:");
|
|
891
|
+
expect(result.files!["values-base.yaml"]).not.toContain("replicaCount");
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("multiple ValuesOverride entities emit separate files", () => {
|
|
895
|
+
const chart = new Chart({ name: "test", version: "0.1.0" });
|
|
896
|
+
const ov1 = new ValuesOverride({ filename: "values-dev", values: { env: "dev" } });
|
|
897
|
+
const ov2 = new ValuesOverride({ filename: "values-prod", values: { env: "prod" } });
|
|
898
|
+
const entities = makeEntities(["chart", chart], ["dev", ov1], ["prod", ov2]);
|
|
899
|
+
const result = helmSerializer.serialize(entities) as SerializerResult;
|
|
900
|
+
|
|
901
|
+
expect(result.files!["values-dev.yaml"]).toContain("env: dev");
|
|
902
|
+
expect(result.files!["values-prod.yaml"]).toContain("env: prod");
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
795
906
|
describe("helpers", () => {
|
|
796
907
|
test("generateHelpers includes all standard templates", () => {
|
|
797
908
|
const { generateHelpers } = require("./helpers");
|