@intentius/chant-lexicon-helm 0.1.8 → 0.1.9

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "d4a9a8168563da87916d1ff56334f061b9e7f573dd37a4b34714863a8570df1e",
4
+ "manifest.json": "9a813a15786c5b5691cfc1ad544f2a2ba1148c0ef7a13a66ca72aeaf9b94a87b",
5
5
  "meta.json": "14243c5730a07c6a6edc35ddd351438547d58df5cf345f2233a355b0c7611ccc",
6
6
  "types/index.d.ts": "5377696ca8698cd2999e4680feb8e8e4b54a7b49fb603a87b2f27356114d1794",
7
7
  "rules/chart-metadata.ts": "8f3377e893d5e2828460b7fe5924fca098334245a9a2fdb90f6b67e490eaf091",
@@ -34,5 +34,5 @@
34
34
  "skills/chant-helm-patterns.md": "9e79e6a46391da46709d8aa57e2825a7cd9eb981cd923f02ad60836c49b2561e",
35
35
  "skills/chant-helm-security.md": "bfc367eabceed2e84f1cf94501b407df78aeed963cec104f24a321d0962063c9"
36
36
  },
37
- "composite": "ca5dd2d974996391ad2e74466d0b3e8d2c879212cbf72b56ebe1499d6bb7d897"
37
+ "composite": "50e28442c88c8b3fddc2f5c33e9a5ddca3fd44e129c2147d500d91b9f45de1b5"
38
38
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helm",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Helm",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-helm",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Helm chart lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -479,6 +479,6 @@ When you scaffold a new project with \`chant init --lexicon helm\`, the skill is
479
479
  writeDocsSite(config, result);
480
480
 
481
481
  if (opts?.verbose) {
482
- console.error(`Generated ${result.pages.length} documentation pages`);
482
+ console.error(`Generated ${result.pages.size} documentation pages`);
483
483
  }
484
484
  }
@@ -228,7 +228,7 @@ export const HelmMonitoredService = Composite<HelmMonitoredServiceProps>((props)
228
228
  interval: values.monitoring.scrapeInterval,
229
229
  }],
230
230
  },
231
- }) as Record<string, unknown>,
231
+ }) as unknown as Record<string, unknown>,
232
232
  defs?.serviceMonitor,
233
233
  ));
234
234
 
@@ -267,7 +267,7 @@ export const HelmMonitoredService = Composite<HelmMonitoredServiceProps>((props)
267
267
  rules: toYaml(values.alerting.rules),
268
268
  }],
269
269
  },
270
- }) as Record<string, unknown>,
270
+ }) as unknown as Record<string, unknown>,
271
271
  defs?.prometheusRule,
272
272
  ));
273
273
  }
@@ -134,7 +134,7 @@ export const HelmNamespaceEnv = Composite<HelmNamespaceEnvProps>((props) => {
134
134
  spec: {
135
135
  hard: toYaml(values.resourceQuota.hard),
136
136
  },
137
- }) as Record<string, unknown>,
137
+ }) as unknown as Record<string, unknown>,
138
138
  defs?.resourceQuota,
139
139
  ));
140
140
  }
@@ -155,7 +155,7 @@ export const HelmNamespaceEnv = Composite<HelmNamespaceEnvProps>((props) => {
155
155
  defaultRequest: toYaml(values.limitRange.defaultRequest),
156
156
  }],
157
157
  },
158
- }) as Record<string, unknown>,
158
+ }) as unknown as Record<string, unknown>,
159
159
  defs?.limitRange,
160
160
  ));
161
161
  }
@@ -173,7 +173,7 @@ export const HelmNamespaceEnv = Composite<HelmNamespaceEnvProps>((props) => {
173
173
  podSelector: {},
174
174
  policyTypes: ["Ingress", "Egress"],
175
175
  },
176
- }) as Record<string, unknown>,
176
+ }) as unknown as Record<string, unknown>,
177
177
  defs?.networkPolicy,
178
178
  ));
179
179
  }
@@ -96,7 +96,7 @@ export const HelmSecureIngress = Composite<HelmSecureIngressProps>((props) => {
96
96
  },
97
97
  }),
98
98
  },
99
- }) as Record<string, unknown>,
99
+ }) as unknown as Record<string, unknown>,
100
100
  defs?.ingress,
101
101
  ));
102
102
 
@@ -117,7 +117,7 @@ export const HelmSecureIngress = Composite<HelmSecureIngressProps>((props) => {
117
117
  },
118
118
  dnsNames: values.ingress.hosts,
119
119
  },
120
- }) as Record<string, unknown>,
120
+ }) as unknown as Record<string, unknown>,
121
121
  defs?.certificate,
122
122
  ));
123
123
 
package/src/index.ts CHANGED
@@ -7,6 +7,10 @@ export { helmPlugin } from "./plugin";
7
7
  // Resources
8
8
  export { Chart, Values, ValuesOverride, HelmTest, HelmNotes, HelmHook, HelmDependency, HelmMaintainer, HelmCRD } from "./resources";
9
9
 
10
+ // HelmRender — render an upstream chart at chant build time
11
+ export { HelmRender } from "./render";
12
+ export type { HelmRenderProps } from "./render";
13
+
10
14
  // Intrinsics
11
15
  export {
12
16
  HelmTpl,
package/src/intrinsics.ts CHANGED
@@ -308,7 +308,9 @@ export function quote(val: HelmTpl): HelmTpl {
308
308
  * `printf("%s:%s", values.image.repository, values.image.tag)`
309
309
  * → `{{ printf "%s:%s" .Values.image.repository .Values.image.tag }}`
310
310
  */
311
- export function printf(fmt: string, ...args: HelmTpl[]): HelmTpl {
311
+ export function printf(fmt: string, ...args: (HelmTpl | string)[]): HelmTpl {
312
+ // extractExpr already accepts both HelmTpl and string; widening here is
313
+ // purely additive (existing callers passing HelmTpl[] keep working).
312
314
  const argExprs = args.map(extractExpr).join(" ");
313
315
  return new HelmTpl(`{{ printf "${fmt}" ${argExprs} }}`);
314
316
  }
@@ -0,0 +1,173 @@
1
+ import { describe, test, expect, beforeAll } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import { HelmRender } from "./render";
8
+
9
+ const FIXTURE_DIR = join(tmpdir(), "chant-helm-render-fixture");
10
+ const CHART_DIR = join(FIXTURE_DIR, "tiny-chart");
11
+ const REPO_DIR = join(FIXTURE_DIR, "repo");
12
+
13
+ /**
14
+ * Builds a tiny self-contained chart that emits one Deployment + one
15
+ * Service, packages it as a local chart repo, and serves it via file://.
16
+ * Avoids network access in tests.
17
+ */
18
+ function maybeSetupFixture(): boolean {
19
+ // If helm isn't on PATH, the test will be skipped at the call site.
20
+ try {
21
+ execFileSync("helm", ["version"], { stdio: "ignore" });
22
+ } catch {
23
+ return false;
24
+ }
25
+
26
+ if (existsSync(FIXTURE_DIR)) {
27
+ rmSync(FIXTURE_DIR, { recursive: true, force: true });
28
+ }
29
+ mkdirSync(join(CHART_DIR, "templates"), { recursive: true });
30
+ mkdirSync(REPO_DIR, { recursive: true });
31
+
32
+ writeFileSync(
33
+ join(CHART_DIR, "Chart.yaml"),
34
+ `apiVersion: v2
35
+ name: tiny-chart
36
+ description: Minimal chart for chant-lexicon-helm HelmRender tests
37
+ type: application
38
+ version: 0.1.0
39
+ appVersion: "1.0"
40
+ `,
41
+ );
42
+
43
+ writeFileSync(
44
+ join(CHART_DIR, "values.yaml"),
45
+ `replicaCount: 1
46
+ image:
47
+ repository: nginx
48
+ tag: "latest"
49
+ service:
50
+ port: 80
51
+ `,
52
+ );
53
+
54
+ writeFileSync(
55
+ join(CHART_DIR, "templates", "deployment.yaml"),
56
+ `apiVersion: apps/v1
57
+ kind: Deployment
58
+ metadata:
59
+ name: {{ .Release.Name }}-tiny
60
+ spec:
61
+ replicas: {{ .Values.replicaCount }}
62
+ selector:
63
+ matchLabels:
64
+ app: {{ .Release.Name }}
65
+ template:
66
+ metadata:
67
+ labels:
68
+ app: {{ .Release.Name }}
69
+ spec:
70
+ containers:
71
+ - name: app
72
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
73
+ `,
74
+ );
75
+
76
+ writeFileSync(
77
+ join(CHART_DIR, "templates", "service.yaml"),
78
+ `apiVersion: v1
79
+ kind: Service
80
+ metadata:
81
+ name: {{ .Release.Name }}-tiny
82
+ spec:
83
+ selector:
84
+ app: {{ .Release.Name }}
85
+ ports:
86
+ - port: {{ .Values.service.port }}
87
+ `,
88
+ );
89
+
90
+ // Package the chart into a .tgz and create a repo index.yaml.
91
+ execFileSync("helm", ["package", CHART_DIR, "-d", REPO_DIR], { stdio: "ignore" });
92
+ execFileSync("helm", ["repo", "index", REPO_DIR], { stdio: "ignore" });
93
+ return true;
94
+ }
95
+
96
+ const fixtureAvailable = maybeSetupFixture();
97
+
98
+ describe.skipIf(!fixtureAvailable)("HelmRender", () => {
99
+ beforeAll(() => {
100
+ // Ensure fixture is fresh for the suite.
101
+ expect(fixtureAvailable).toBe(true);
102
+ });
103
+
104
+ test("renders a local chart into K8s declarables (Deployment + Service)", () => {
105
+ const result = HelmRender({
106
+ name: "rel",
107
+ chart: CHART_DIR,
108
+ noCache: true,
109
+ });
110
+
111
+ // Composite returns its members under .members; iterate keys.
112
+ const members = result.members as Record<string, unknown>;
113
+ const keys = Object.keys(members);
114
+ expect(keys.length).toBeGreaterThanOrEqual(2);
115
+ const deployment = keys.find((k) => k.startsWith("Deployment_"));
116
+ const service = keys.find((k) => k.startsWith("Service_"));
117
+ expect(deployment).toBeDefined();
118
+ expect(service).toBeDefined();
119
+ });
120
+
121
+ test("createNamespace adds a Namespace declarable", () => {
122
+ const result = HelmRender({
123
+ name: "rel",
124
+ chart: CHART_DIR,
125
+ namespace: "myns",
126
+ createNamespace: true,
127
+ noCache: true,
128
+ });
129
+ const keys = Object.keys(result.members as Record<string, unknown>);
130
+ expect(keys).toContain("__namespace");
131
+ });
132
+
133
+ test("values overrides are applied (replicaCount: 3)", () => {
134
+ const result = HelmRender({
135
+ name: "rel",
136
+ chart: CHART_DIR,
137
+ values: { replicaCount: 3 },
138
+ noCache: true,
139
+ });
140
+ const members = result.members as Record<string, unknown>;
141
+ const deploymentKey = Object.keys(members).find((k) => k.startsWith("Deployment_"));
142
+ expect(deploymentKey).toBeDefined();
143
+ const dep = members[deploymentKey!] as {
144
+ props: { spec: { replicas: number } };
145
+ };
146
+ expect(dep.props.spec.replicas).toBe(3);
147
+ });
148
+
149
+ test("cache reuse: second render with same args skips helm CLI", () => {
150
+ // First, render with cache enabled.
151
+ const first = HelmRender({
152
+ name: "rel",
153
+ chart: CHART_DIR,
154
+ });
155
+ expect(Object.keys(first.members as Record<string, unknown>).length).toBeGreaterThanOrEqual(2);
156
+
157
+ // Now sabotage `helm` by pointing PATH at an empty dir — if cache is used,
158
+ // the second call should still succeed.
159
+ const emptyDir = join(tmpdir(), "chant-helm-render-empty-path");
160
+ if (!existsSync(emptyDir)) mkdirSync(emptyDir);
161
+ const origPath = process.env.PATH;
162
+ process.env.PATH = emptyDir;
163
+ try {
164
+ const second = HelmRender({
165
+ name: "rel",
166
+ chart: CHART_DIR,
167
+ });
168
+ expect(Object.keys(second.members as Record<string, unknown>).length).toBeGreaterThanOrEqual(2);
169
+ } finally {
170
+ process.env.PATH = origPath;
171
+ }
172
+ });
173
+ });
package/src/render.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * HelmRender — render an upstream Helm chart at chant build time.
3
+ *
4
+ * Most chant projects that want to install third-party operators (ESO,
5
+ * cert-manager, ingress-nginx, etc.) ran `helm template` or `helm install`
6
+ * as a separate deploy phase. That meant the chant build output was
7
+ * incomplete — `kubectl apply -f dist/...yaml` didn't carry those operators.
8
+ *
9
+ * `HelmRender({ repo, chart, version, values })` resolves at synth time:
10
+ * 1. Shells out to `helm template` (requires the `helm` binary in PATH).
11
+ * 2. Parses the resulting multi-document YAML.
12
+ * 3. Emits each rendered K8s manifest as a Declarable in the build output.
13
+ * 4. Caches the rendered output under `~/.chant/helm-renders/<hash>/`
14
+ * keyed by (repo, chart, version, values) so subsequent builds skip
15
+ * network access.
16
+ *
17
+ * The lexicon must include both `helm` and `k8s` (since rendered manifests
18
+ * are k8s resources).
19
+ *
20
+ * @example
21
+ * import { HelmRender } from "@intentius/chant-lexicon-helm";
22
+ *
23
+ * export const eso = HelmRender({
24
+ * name: "external-secrets",
25
+ * repo: "https://charts.external-secrets.io",
26
+ * chart: "external-secrets",
27
+ * version: "0.10.4",
28
+ * namespace: "external-secrets",
29
+ * createNamespace: true,
30
+ * values: { installCRDs: true },
31
+ * });
32
+ */
33
+
34
+ import { execFileSync } from "node:child_process";
35
+ import { createHash } from "node:crypto";
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
37
+ import { homedir, tmpdir } from "node:os";
38
+ import { join } from "node:path";
39
+
40
+ import { Composite } from "@intentius/chant";
41
+ import { Deployment } from "@intentius/chant-lexicon-k8s/generated";
42
+ import yaml from "js-yaml";
43
+
44
+ export interface HelmRenderProps {
45
+ /** Logical name for the render (used in cache key + composite name). */
46
+ name: string;
47
+ /** Chart repo URL, e.g. https://charts.external-secrets.io */
48
+ repo: string;
49
+ /** Chart name, e.g. "external-secrets" */
50
+ chart: string;
51
+ /** Pinned chart version, e.g. "0.10.4" */
52
+ version: string;
53
+ /** Target namespace passed to `helm template --namespace`. */
54
+ namespace?: string;
55
+ /** Also emit a Namespace manifest. Default: false. */
56
+ createNamespace?: boolean;
57
+ /** Helm values overrides (written to a values.yaml then passed via -f). */
58
+ values?: Record<string, unknown>;
59
+ /**
60
+ * Skip the on-disk cache. Default: false. Tests pass `true` to force a
61
+ * fresh render.
62
+ */
63
+ noCache?: boolean;
64
+ }
65
+
66
+ interface RenderedDoc {
67
+ apiVersion?: string;
68
+ kind?: string;
69
+ metadata?: { name?: string; namespace?: string; [k: string]: unknown };
70
+ [k: string]: unknown;
71
+ }
72
+
73
+ const CACHE_ROOT = join(homedir(), ".chant", "helm-renders");
74
+
75
+ function cacheKey(props: HelmRenderProps): string {
76
+ const stable = JSON.stringify({
77
+ repo: props.repo,
78
+ chart: props.chart,
79
+ version: props.version,
80
+ namespace: props.namespace ?? null,
81
+ values: props.values ?? null,
82
+ });
83
+ return createHash("sha256").update(stable).digest("hex").slice(0, 16);
84
+ }
85
+
86
+ function renderViaHelm(props: HelmRenderProps): string {
87
+ // Write values overrides to a tempfile if any.
88
+ let valuesArgs: string[] = [];
89
+ if (props.values && Object.keys(props.values).length > 0) {
90
+ const valuesPath = join(tmpdir(), `chant-helm-values-${cacheKey(props)}.yaml`);
91
+ writeFileSync(valuesPath, yaml.dump(props.values));
92
+ valuesArgs = ["--values", valuesPath];
93
+ }
94
+
95
+ // When `repo` is set, helm fetches the chart by name+version from the repo.
96
+ // When `repo` is absent, treat `chart` as a local path.
97
+ const fetchArgs: string[] = [];
98
+ if (props.repo) {
99
+ fetchArgs.push("--repo", props.repo);
100
+ if (props.version) fetchArgs.push("--version", props.version);
101
+ }
102
+
103
+ const args = [
104
+ "template",
105
+ props.name,
106
+ props.chart,
107
+ ...fetchArgs,
108
+ ...(props.namespace ? ["--namespace", props.namespace] : []),
109
+ ...valuesArgs,
110
+ ];
111
+
112
+ try {
113
+ const out = execFileSync("helm", args, {
114
+ encoding: "utf8",
115
+ stdio: ["ignore", "pipe", "pipe"],
116
+ maxBuffer: 16 * 1024 * 1024,
117
+ });
118
+ return out;
119
+ } catch (err) {
120
+ const stderr =
121
+ err && typeof err === "object" && "stderr" in err
122
+ ? String((err as { stderr: unknown }).stderr)
123
+ : String(err);
124
+ throw new Error(
125
+ `HelmRender failed for ${props.repo}/${props.chart}@${props.version}:\n${stderr}\n` +
126
+ `Hint: ensure the 'helm' CLI is on PATH (helm version) and the chart is reachable.`,
127
+ );
128
+ }
129
+ }
130
+
131
+ function loadOrRender(props: HelmRenderProps): string {
132
+ if (props.noCache) {
133
+ return renderViaHelm(props);
134
+ }
135
+ const cacheDir = join(CACHE_ROOT, cacheKey(props));
136
+ const cachePath = join(cacheDir, "manifests.yaml");
137
+ if (existsSync(cachePath)) {
138
+ return readFileSync(cachePath, "utf8");
139
+ }
140
+ const out = renderViaHelm(props);
141
+ try {
142
+ mkdirSync(cacheDir, { recursive: true });
143
+ writeFileSync(cachePath, out);
144
+ } catch {
145
+ // Cache write failure is non-fatal — the render is still in memory.
146
+ }
147
+ return out;
148
+ }
149
+
150
+ function parseMultiDoc(text: string): RenderedDoc[] {
151
+ const docs = yaml.loadAll(text);
152
+ return docs
153
+ .filter((d): d is RenderedDoc => d !== null && typeof d === "object")
154
+ .filter((d) => d.kind && d.apiVersion);
155
+ }
156
+
157
+ /**
158
+ * Sanitize an arbitrary string into a valid TS/JS identifier suffix.
159
+ * Used to derive Composite Members keys from manifest kind+name pairs.
160
+ */
161
+ function safeKey(input: string): string {
162
+ return input.replace(/[^a-zA-Z0-9_]/g, "_");
163
+ }
164
+
165
+ export const HelmRender = Composite<HelmRenderProps>((props) => {
166
+ const yamlText = loadOrRender(props);
167
+ const docs = parseMultiDoc(yamlText);
168
+
169
+ const out: Record<string, InstanceType<typeof Deployment>> = {};
170
+
171
+ if (props.createNamespace && props.namespace) {
172
+ out["__namespace"] = new Deployment({
173
+ apiVersion: "v1",
174
+ kind: "Namespace",
175
+ metadata: { name: props.namespace },
176
+ } as Record<string, unknown>);
177
+ }
178
+
179
+ const usedKeys = new Set<string>();
180
+ for (let i = 0; i < docs.length; i++) {
181
+ const doc = docs[i];
182
+ const kind = doc.kind ?? "Unknown";
183
+ const name = doc.metadata?.name ?? `doc${i}`;
184
+ let key = safeKey(`${kind}_${name}`);
185
+ // Disambiguate on collision (e.g. same kind+name across docs).
186
+ let collisionN = 2;
187
+ while (usedKeys.has(key)) {
188
+ key = `${safeKey(`${kind}_${name}`)}_${collisionN++}`;
189
+ }
190
+ usedKeys.add(key);
191
+ out[key] = new Deployment(doc as Record<string, unknown>);
192
+ }
193
+
194
+ return out;
195
+ }, "HelmRender");
package/src/serializer.ts CHANGED
@@ -111,7 +111,7 @@ function emitHelmYAML(value: unknown, indent: number, valuesContext: boolean = f
111
111
 
112
112
  // Detect HelmTpl / Intrinsic objects via INTRINSIC_MARKER before string check
113
113
  if (typeof value === "object" && value !== null && INTRINSIC_MARKER in value) {
114
- const tplObj = value as { toJSON(): unknown };
114
+ const tplObj = value as unknown as { toJSON(): unknown };
115
115
  if (valuesContext) {
116
116
  // In values.yaml context: emit empty placeholder — actual value comes from override files
117
117
  return "''";