@intentius/chant-lexicon-helm 0.0.24 → 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 +6 -4
- 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-patterns.md +52 -0
- package/dist/skills/chant-helm.md +71 -22
- package/package.json +6 -3
- package/src/codegen/docs.ts +3 -2
- 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 +4 -2
- package/src/resources.ts +20 -0
- package/src/serializer.test.ts +113 -2
- package/src/serializer.ts +149 -13
- package/src/skills/chant-helm-patterns.md +52 -0
- package/src/skills/chant-helm.md +71 -22
|
@@ -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");
|
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
|
/**
|
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");
|
package/src/serializer.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
|
19
19
|
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
20
20
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
21
21
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
22
|
-
import { HELM_TPL_KEY, HELM_IF_KEY, HELM_RANGE_KEY, HELM_WITH_KEY, type HelmConditional } from "./intrinsics";
|
|
22
|
+
import { HELM_TPL_KEY, HELM_IF_KEY, HELM_RANGE_KEY, HELM_WITH_KEY, RUNTIME_SLOT_KEY, type HelmConditional } from "./intrinsics";
|
|
23
23
|
import { generateHelpers } from "./helpers";
|
|
24
24
|
|
|
25
25
|
// ── GVK resolution for K8s resources ──────────────────────
|
|
@@ -96,14 +96,33 @@ function emitElseChain(elseBody: unknown, indent: number): string {
|
|
|
96
96
|
/**
|
|
97
97
|
* Emit a YAML value, detecting __helm_tpl markers and emitting them
|
|
98
98
|
* as raw Go template expressions.
|
|
99
|
+
*
|
|
100
|
+
* @param valuesContext - When true, HelmTpl intrinsics are emitted as empty
|
|
101
|
+
* string placeholders instead of raw Go template expressions. values.yaml
|
|
102
|
+
* is not processed as a Go template by Helm, so {{ .Values.x }} is invalid
|
|
103
|
+
* there. Actual values are provided via -f override files at deploy time.
|
|
99
104
|
*/
|
|
100
|
-
function emitHelmYAML(value: unknown, indent: number): string {
|
|
105
|
+
function emitHelmYAML(value: unknown, indent: number, valuesContext: boolean = false): string {
|
|
101
106
|
const prefix = " ".repeat(indent);
|
|
102
107
|
|
|
103
108
|
if (value === null || value === undefined) return "null";
|
|
104
109
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
105
110
|
if (typeof value === "number") return String(value);
|
|
106
111
|
|
|
112
|
+
// Detect HelmTpl / Intrinsic objects via INTRINSIC_MARKER before string check
|
|
113
|
+
if (typeof value === "object" && value !== null && INTRINSIC_MARKER in value) {
|
|
114
|
+
const tplObj = value as { toJSON(): unknown };
|
|
115
|
+
if (valuesContext) {
|
|
116
|
+
// In values.yaml context: emit empty placeholder — actual value comes from override files
|
|
117
|
+
return "''";
|
|
118
|
+
}
|
|
119
|
+
const json = tplObj.toJSON() as Record<string, unknown>;
|
|
120
|
+
if (typeof json === "object" && json !== null && HELM_TPL_KEY in json) {
|
|
121
|
+
return json[HELM_TPL_KEY] as string;
|
|
122
|
+
}
|
|
123
|
+
return "''";
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
if (typeof value === "string") {
|
|
108
127
|
// Check if this is a raw template expression that was already inlined
|
|
109
128
|
if (value.startsWith("{{") && value.endsWith("}}")) {
|
|
@@ -131,7 +150,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
131
150
|
const entries = Object.entries(item as Record<string, unknown>);
|
|
132
151
|
if (entries.length > 0) {
|
|
133
152
|
const [firstKey, firstVal] = entries[0];
|
|
134
|
-
const firstEmitted = emitHelmYAML(firstVal, indent + 2);
|
|
153
|
+
const firstEmitted = emitHelmYAML(firstVal, indent + 2, valuesContext);
|
|
135
154
|
if (firstEmitted.startsWith("\n")) {
|
|
136
155
|
lines.push(`${prefix}- ${firstKey}:${firstEmitted}`);
|
|
137
156
|
} else {
|
|
@@ -139,7 +158,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
139
158
|
}
|
|
140
159
|
for (let i = 1; i < entries.length; i++) {
|
|
141
160
|
const [key, val] = entries[i];
|
|
142
|
-
const emitted = emitHelmYAML(val, indent + 2);
|
|
161
|
+
const emitted = emitHelmYAML(val, indent + 2, valuesContext);
|
|
143
162
|
if (emitted.startsWith("\n")) {
|
|
144
163
|
lines.push(`${prefix} ${key}:${emitted}`);
|
|
145
164
|
} else {
|
|
@@ -148,7 +167,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
169
|
} else {
|
|
151
|
-
lines.push(`${prefix}- ${emitHelmYAML(item, indent + 1).trimStart()}`);
|
|
170
|
+
lines.push(`${prefix}- ${emitHelmYAML(item, indent + 1, valuesContext).trimStart()}`);
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
return "\n" + lines.join("\n");
|
|
@@ -157,8 +176,9 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
157
176
|
if (typeof value === "object") {
|
|
158
177
|
const obj = value as Record<string, unknown>;
|
|
159
178
|
|
|
160
|
-
// Detect __helm_tpl marker → emit raw template expression
|
|
179
|
+
// Detect __helm_tpl marker → emit raw template expression (not valid in valuesContext)
|
|
161
180
|
if (HELM_TPL_KEY in obj && typeof obj[HELM_TPL_KEY] === "string") {
|
|
181
|
+
if (valuesContext) return "''";
|
|
162
182
|
return obj[HELM_TPL_KEY] as string;
|
|
163
183
|
}
|
|
164
184
|
|
|
@@ -167,7 +187,7 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
167
187
|
const condition = obj[HELM_IF_KEY] as string;
|
|
168
188
|
const body = obj.body;
|
|
169
189
|
const elseBody = obj.else;
|
|
170
|
-
let result = `{{- if ${condition} }}\n${emitHelmYAML(body, indent)}`;
|
|
190
|
+
let result = `{{- if ${condition} }}\n${emitHelmYAML(body, indent, valuesContext)}`;
|
|
171
191
|
if (elseBody !== undefined) {
|
|
172
192
|
result += emitElseChain(elseBody, indent);
|
|
173
193
|
}
|
|
@@ -179,21 +199,21 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
179
199
|
if (HELM_RANGE_KEY in obj) {
|
|
180
200
|
const list = obj[HELM_RANGE_KEY] as string;
|
|
181
201
|
const body = obj.body;
|
|
182
|
-
return `{{- range ${list} }}\n${emitHelmYAML(body, indent)}\n{{- end }}`;
|
|
202
|
+
return `{{- range ${list} }}\n${emitHelmYAML(body, indent, valuesContext)}\n{{- end }}`;
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
// Detect __helm_with marker → emit with scope
|
|
186
206
|
if (HELM_WITH_KEY in obj) {
|
|
187
207
|
const scope = obj[HELM_WITH_KEY] as string;
|
|
188
208
|
const body = obj.body;
|
|
189
|
-
return `{{- with ${scope} }}\n${emitHelmYAML(body, indent)}\n{{- end }}`;
|
|
209
|
+
return `{{- with ${scope} }}\n${emitHelmYAML(body, indent, valuesContext)}\n{{- end }}`;
|
|
190
210
|
}
|
|
191
211
|
|
|
192
212
|
const entries = Object.entries(obj);
|
|
193
213
|
if (entries.length === 0) return "{}";
|
|
194
214
|
const lines: string[] = [];
|
|
195
215
|
for (const [key, val] of entries) {
|
|
196
|
-
const emitted = emitHelmYAML(val, indent + 1);
|
|
216
|
+
const emitted = emitHelmYAML(val, indent + 1, valuesContext);
|
|
197
217
|
if (emitted.startsWith("\n")) {
|
|
198
218
|
lines.push(`${prefix}${key}:${emitted}`);
|
|
199
219
|
} else {
|
|
@@ -209,8 +229,8 @@ function emitHelmYAML(value: unknown, indent: number): string {
|
|
|
209
229
|
/**
|
|
210
230
|
* Emit a top-level key-value pair in Helm YAML.
|
|
211
231
|
*/
|
|
212
|
-
function emitKeyValue(key: string, value: unknown): string {
|
|
213
|
-
const yamlStr = emitHelmYAML(value, 1);
|
|
232
|
+
function emitKeyValue(key: string, value: unknown, valuesContext: boolean = false): string {
|
|
233
|
+
const yamlStr = emitHelmYAML(value, 1, valuesContext);
|
|
214
234
|
if (yamlStr.startsWith("\n")) {
|
|
215
235
|
return `${key}:${yamlStr}`;
|
|
216
236
|
}
|
|
@@ -264,16 +284,105 @@ function emitChartYaml(props: Record<string, unknown>): string {
|
|
|
264
284
|
|
|
265
285
|
/**
|
|
266
286
|
* Generate values.yaml content from Helm::Values entity props.
|
|
287
|
+
* HelmTpl intrinsics are emitted as empty placeholders since values.yaml
|
|
288
|
+
* is not processed as a Go template by Helm.
|
|
267
289
|
*/
|
|
268
290
|
function emitValuesYaml(props: Record<string, unknown>): string {
|
|
269
291
|
if (Object.keys(props).length === 0) return "{}\n";
|
|
270
292
|
const lines: string[] = [];
|
|
271
293
|
for (const [key, val] of Object.entries(props)) {
|
|
272
|
-
lines.push(emitKeyValue(key, val));
|
|
294
|
+
lines.push(emitKeyValue(key, val, true));
|
|
273
295
|
}
|
|
274
296
|
return lines.join("\n") + "\n";
|
|
275
297
|
}
|
|
276
298
|
|
|
299
|
+
// ── RuntimeSlot helpers ───────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Represents the tree structure built from runtime slot paths.
|
|
303
|
+
* Leaves are description strings; branches are nested SlotTree objects.
|
|
304
|
+
*/
|
|
305
|
+
type SlotTree = { [key: string]: SlotTree | string };
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Collect all RuntimeSlot instances from a props tree.
|
|
309
|
+
* Returns the path (as array of keys) and description for each slot.
|
|
310
|
+
*/
|
|
311
|
+
function collectRuntimeSlots(
|
|
312
|
+
props: unknown,
|
|
313
|
+
path: string[],
|
|
314
|
+
): { path: string[]; description: string }[] {
|
|
315
|
+
const result: { path: string[]; description: string }[] = [];
|
|
316
|
+
if (props === null || props === undefined) return result;
|
|
317
|
+
|
|
318
|
+
if (typeof props === "object" && INTRINSIC_MARKER in (props as object)) {
|
|
319
|
+
const json = (props as { toJSON(): unknown }).toJSON() as Record<string, unknown>;
|
|
320
|
+
if (typeof json === "object" && json !== null && RUNTIME_SLOT_KEY in json) {
|
|
321
|
+
result.push({ path, description: json[RUNTIME_SLOT_KEY] as string });
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (typeof props === "object" && !Array.isArray(props)) {
|
|
327
|
+
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
|
|
328
|
+
result.push(...collectRuntimeSlots(value, [...path, key]));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Build a nested tree from flat path+description pairs.
|
|
337
|
+
*/
|
|
338
|
+
function buildSlotTree(slots: { path: string[]; description: string }[]): SlotTree {
|
|
339
|
+
const tree: SlotTree = {};
|
|
340
|
+
for (const { path, description } of slots) {
|
|
341
|
+
let node = tree;
|
|
342
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
343
|
+
const key = path[i];
|
|
344
|
+
if (typeof node[key] !== "object") {
|
|
345
|
+
node[key] = {};
|
|
346
|
+
}
|
|
347
|
+
node = node[key] as SlotTree;
|
|
348
|
+
}
|
|
349
|
+
node[path[path.length - 1]] = description;
|
|
350
|
+
}
|
|
351
|
+
return tree;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Emit a slot tree as YAML lines with description comments before leaves.
|
|
356
|
+
*/
|
|
357
|
+
function emitSlotTreeYaml(tree: SlotTree, indent: number): string {
|
|
358
|
+
const prefix = " ".repeat(indent);
|
|
359
|
+
const lines: string[] = [];
|
|
360
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
361
|
+
if (typeof value === "string") {
|
|
362
|
+
if (value) {
|
|
363
|
+
lines.push(`${prefix}# ${value}`);
|
|
364
|
+
}
|
|
365
|
+
lines.push(`${prefix}${key}: ''`);
|
|
366
|
+
} else {
|
|
367
|
+
lines.push(`${prefix}${key}:`);
|
|
368
|
+
const nested = emitSlotTreeYaml(value, indent + 1);
|
|
369
|
+
if (nested) lines.push(nested);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return lines.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Generate values-runtime-slots.yaml from collected RuntimeSlot instances.
|
|
377
|
+
* Returns empty string if no slots found.
|
|
378
|
+
*/
|
|
379
|
+
function emitRuntimeSlotsYaml(slots: { path: string[]; description: string }[]): string {
|
|
380
|
+
if (slots.length === 0) return "";
|
|
381
|
+
const tree = buildSlotTree(slots);
|
|
382
|
+
return "# Generated by chant — fill these in before running helm upgrade\n" +
|
|
383
|
+
emitSlotTreeYaml(tree, 0) + "\n";
|
|
384
|
+
}
|
|
385
|
+
|
|
277
386
|
/**
|
|
278
387
|
* Description inference — static map from common Helm value key names.
|
|
279
388
|
*/
|
|
@@ -397,6 +506,11 @@ function generateValuesSchema(props: Record<string, unknown>): string {
|
|
|
397
506
|
return schema;
|
|
398
507
|
}
|
|
399
508
|
|
|
509
|
+
if (typeof value === "object" && value !== null && INTRINSIC_MARKER in value) {
|
|
510
|
+
// HelmTpl / Intrinsic — actual value provided via -f override; accept any type
|
|
511
|
+
return {};
|
|
512
|
+
}
|
|
513
|
+
|
|
400
514
|
if (typeof value === "object") {
|
|
401
515
|
const obj = value as Record<string, unknown>;
|
|
402
516
|
const properties: Record<string, unknown> = {};
|
|
@@ -645,6 +759,7 @@ export const helmSerializer: Serializer = {
|
|
|
645
759
|
let notesContent: string | undefined;
|
|
646
760
|
const dependencies: Record<string, unknown>[] = [];
|
|
647
761
|
const maintainers: Record<string, unknown>[] = [];
|
|
762
|
+
const valuesOverrides: { filename: string; values: Record<string, unknown> }[] = [];
|
|
648
763
|
|
|
649
764
|
// First pass: extract Helm-specific resources and collect metadata
|
|
650
765
|
for (const [_name, entity] of entities) {
|
|
@@ -674,6 +789,14 @@ export const helmSerializer: Serializer = {
|
|
|
674
789
|
const filename = (props.filename as string) ?? `${toFileName(_name)}.yaml`;
|
|
675
790
|
files[`crds/${filename}`] = props.content as string;
|
|
676
791
|
}
|
|
792
|
+
} else if (entityType === "Helm::ValuesOverride") {
|
|
793
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown>;
|
|
794
|
+
if (props?.filename && props?.values) {
|
|
795
|
+
valuesOverrides.push({
|
|
796
|
+
filename: props.filename as string,
|
|
797
|
+
values: props.values as Record<string, unknown>,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
677
800
|
}
|
|
678
801
|
}
|
|
679
802
|
|
|
@@ -696,6 +819,19 @@ export const helmSerializer: Serializer = {
|
|
|
696
819
|
// Emit values.yaml
|
|
697
820
|
files["values.yaml"] = emitValuesYaml(valuesProps);
|
|
698
821
|
|
|
822
|
+
// Emit values-runtime-slots.yaml if any RuntimeSlot instances found
|
|
823
|
+
if (hasValues) {
|
|
824
|
+
const slots = collectRuntimeSlots(valuesProps, []);
|
|
825
|
+
if (slots.length > 0) {
|
|
826
|
+
files["values-runtime-slots.yaml"] = emitRuntimeSlotsYaml(slots);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Emit ValuesOverride files
|
|
831
|
+
for (const override of valuesOverrides) {
|
|
832
|
+
files[`${override.filename}.yaml`] = emitValuesYaml(override.values);
|
|
833
|
+
}
|
|
834
|
+
|
|
699
835
|
// Emit values.schema.json if we have values
|
|
700
836
|
if (hasValues && Object.keys(valuesProps).length > 0) {
|
|
701
837
|
files["values.schema.json"] = generateValuesSchema(valuesProps);
|
|
@@ -215,6 +215,58 @@ ChartRef.Name // {{ .Chart.Name }}
|
|
|
215
215
|
ChartRef.Version // {{ .Chart.Version }}
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
## Deploying Upstream Charts with Value Overrides
|
|
219
|
+
|
|
220
|
+
When you need to deploy an upstream chart (like `gitlab/gitlab`) with custom values, avoid wrapper charts with no templates. Instead:
|
|
221
|
+
|
|
222
|
+
1. Use `runtimeSlot()` in `new Values({...})` for deploy-time values (DB IPs, bucket names, replicas)
|
|
223
|
+
2. Use `ValuesOverride` for static values shared across all environments (disabled bundled services, shared secret refs)
|
|
224
|
+
3. Deploy the upstream chart directly with `-f` flags
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { Values, ValuesOverride, runtimeSlot } from "@intentius/chant-lexicon-helm";
|
|
228
|
+
|
|
229
|
+
// Runtime slots → values-runtime-slots.yaml (deploy-time checklist)
|
|
230
|
+
export const vals = new Values({
|
|
231
|
+
global: {
|
|
232
|
+
psql: { host: runtimeSlot("Cloud SQL private IP") },
|
|
233
|
+
redis: { host: runtimeSlot("Memorystore host") },
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Static overrides → values-base.yaml (shared across all deployments)
|
|
238
|
+
export const baseOverride = new ValuesOverride({
|
|
239
|
+
filename: "values-base",
|
|
240
|
+
values: {
|
|
241
|
+
postgresql: { install: false },
|
|
242
|
+
redis: { install: false },
|
|
243
|
+
certmanager: { install: false },
|
|
244
|
+
"nginx-ingress": { enabled: false },
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Outputs:
|
|
250
|
+
- `chart-dir/values.yaml` — defaults; runtime slots appear as `''`
|
|
251
|
+
- `chart-dir/values-base.yaml` — generated static overrides
|
|
252
|
+
- `chart-dir/values-runtime-slots.yaml` — deploy-time slots with descriptions as comments
|
|
253
|
+
|
|
254
|
+
Deploy:
|
|
255
|
+
```bash
|
|
256
|
+
# chant build generates chart-dir/ including values-base.yaml
|
|
257
|
+
chant build
|
|
258
|
+
|
|
259
|
+
# Fill in runtime-slot values (one per environment)
|
|
260
|
+
# values-prod.yaml contains: global.psql.host, global.redis.host, etc.
|
|
261
|
+
|
|
262
|
+
helm upgrade --install my-release upstream/chart \
|
|
263
|
+
-f chart-dir/values-base.yaml \
|
|
264
|
+
-f values-prod.yaml \
|
|
265
|
+
--wait
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**WHM005** warns when a chart has `HelmDependency` entries but generates no templates — this is the "empty wrapper" anti-pattern that requires `helm dependency build` as a non-obvious prerequisite.
|
|
269
|
+
|
|
218
270
|
## Template Functions
|
|
219
271
|
|
|
220
272
|
```typescript
|