@intentius/chant-lexicon-helm 0.1.8 → 0.1.10
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 +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/codegen/docs.ts +1 -1
- package/src/composites/helm-monitored-service.ts +2 -2
- package/src/composites/helm-namespace-env.ts +3 -3
- package/src/composites/helm-secure-ingress.ts +2 -2
- package/src/index.ts +4 -0
- package/src/intrinsics.ts +3 -1
- package/src/render.test.ts +173 -0
- package/src/render.ts +198 -0
- package/src/serializer.ts +1 -1
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "ef5f7e294c0c8b6ca190fe59db119c9a09a62a7707c2b1aa70de6897f7c0fc0a",
|
|
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": "
|
|
37
|
+
"composite": "794ebe6ec1f86321a5a64cecbd8e8c0fcaa64dd4cb85d7c93562eaff8cd64cf7"
|
|
38
38
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
package/src/codegen/docs.ts
CHANGED
|
@@ -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.
|
|
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,198 @@
|
|
|
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
|
+
// Use the k8s lexicon's Deployment as a generic Declarable wrapper for
|
|
42
|
+
// arbitrary K8s manifests. The k8s serializer reads props.apiVersion and
|
|
43
|
+
// props.kind verbatim when set, so the actual class doesn't matter.
|
|
44
|
+
import { Deployment } from "@intentius/chant-lexicon-k8s/generated/index";
|
|
45
|
+
import yaml from "js-yaml";
|
|
46
|
+
|
|
47
|
+
export interface HelmRenderProps {
|
|
48
|
+
/** Logical name for the render (used in cache key + composite name). */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Chart repo URL, e.g. https://charts.external-secrets.io */
|
|
51
|
+
repo: string;
|
|
52
|
+
/** Chart name, e.g. "external-secrets" */
|
|
53
|
+
chart: string;
|
|
54
|
+
/** Pinned chart version, e.g. "0.10.4" */
|
|
55
|
+
version: string;
|
|
56
|
+
/** Target namespace passed to `helm template --namespace`. */
|
|
57
|
+
namespace?: string;
|
|
58
|
+
/** Also emit a Namespace manifest. Default: false. */
|
|
59
|
+
createNamespace?: boolean;
|
|
60
|
+
/** Helm values overrides (written to a values.yaml then passed via -f). */
|
|
61
|
+
values?: Record<string, unknown>;
|
|
62
|
+
/**
|
|
63
|
+
* Skip the on-disk cache. Default: false. Tests pass `true` to force a
|
|
64
|
+
* fresh render.
|
|
65
|
+
*/
|
|
66
|
+
noCache?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface RenderedDoc {
|
|
70
|
+
apiVersion?: string;
|
|
71
|
+
kind?: string;
|
|
72
|
+
metadata?: { name?: string; namespace?: string; [k: string]: unknown };
|
|
73
|
+
[k: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CACHE_ROOT = join(homedir(), ".chant", "helm-renders");
|
|
77
|
+
|
|
78
|
+
function cacheKey(props: HelmRenderProps): string {
|
|
79
|
+
const stable = JSON.stringify({
|
|
80
|
+
repo: props.repo,
|
|
81
|
+
chart: props.chart,
|
|
82
|
+
version: props.version,
|
|
83
|
+
namespace: props.namespace ?? null,
|
|
84
|
+
values: props.values ?? null,
|
|
85
|
+
});
|
|
86
|
+
return createHash("sha256").update(stable).digest("hex").slice(0, 16);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderViaHelm(props: HelmRenderProps): string {
|
|
90
|
+
// Write values overrides to a tempfile if any.
|
|
91
|
+
let valuesArgs: string[] = [];
|
|
92
|
+
if (props.values && Object.keys(props.values).length > 0) {
|
|
93
|
+
const valuesPath = join(tmpdir(), `chant-helm-values-${cacheKey(props)}.yaml`);
|
|
94
|
+
writeFileSync(valuesPath, yaml.dump(props.values));
|
|
95
|
+
valuesArgs = ["--values", valuesPath];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// When `repo` is set, helm fetches the chart by name+version from the repo.
|
|
99
|
+
// When `repo` is absent, treat `chart` as a local path.
|
|
100
|
+
const fetchArgs: string[] = [];
|
|
101
|
+
if (props.repo) {
|
|
102
|
+
fetchArgs.push("--repo", props.repo);
|
|
103
|
+
if (props.version) fetchArgs.push("--version", props.version);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const args = [
|
|
107
|
+
"template",
|
|
108
|
+
props.name,
|
|
109
|
+
props.chart,
|
|
110
|
+
...fetchArgs,
|
|
111
|
+
...(props.namespace ? ["--namespace", props.namespace] : []),
|
|
112
|
+
...valuesArgs,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const out = execFileSync("helm", args, {
|
|
117
|
+
encoding: "utf8",
|
|
118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
119
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
120
|
+
});
|
|
121
|
+
return out;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const stderr =
|
|
124
|
+
err && typeof err === "object" && "stderr" in err
|
|
125
|
+
? String((err as { stderr: unknown }).stderr)
|
|
126
|
+
: String(err);
|
|
127
|
+
throw new Error(
|
|
128
|
+
`HelmRender failed for ${props.repo}/${props.chart}@${props.version}:\n${stderr}\n` +
|
|
129
|
+
`Hint: ensure the 'helm' CLI is on PATH (helm version) and the chart is reachable.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function loadOrRender(props: HelmRenderProps): string {
|
|
135
|
+
if (props.noCache) {
|
|
136
|
+
return renderViaHelm(props);
|
|
137
|
+
}
|
|
138
|
+
const cacheDir = join(CACHE_ROOT, cacheKey(props));
|
|
139
|
+
const cachePath = join(cacheDir, "manifests.yaml");
|
|
140
|
+
if (existsSync(cachePath)) {
|
|
141
|
+
return readFileSync(cachePath, "utf8");
|
|
142
|
+
}
|
|
143
|
+
const out = renderViaHelm(props);
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
146
|
+
writeFileSync(cachePath, out);
|
|
147
|
+
} catch {
|
|
148
|
+
// Cache write failure is non-fatal — the render is still in memory.
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseMultiDoc(text: string): RenderedDoc[] {
|
|
154
|
+
const docs = yaml.loadAll(text);
|
|
155
|
+
return docs
|
|
156
|
+
.filter((d): d is RenderedDoc => d !== null && typeof d === "object")
|
|
157
|
+
.filter((d) => d.kind && d.apiVersion);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Sanitize an arbitrary string into a valid TS/JS identifier suffix.
|
|
162
|
+
* Used to derive Composite Members keys from manifest kind+name pairs.
|
|
163
|
+
*/
|
|
164
|
+
function safeKey(input: string): string {
|
|
165
|
+
return input.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const HelmRender = Composite<HelmRenderProps>((props) => {
|
|
169
|
+
const yamlText = loadOrRender(props);
|
|
170
|
+
const docs = parseMultiDoc(yamlText);
|
|
171
|
+
|
|
172
|
+
const out: Record<string, InstanceType<typeof Deployment>> = {};
|
|
173
|
+
|
|
174
|
+
if (props.createNamespace && props.namespace) {
|
|
175
|
+
out["__namespace"] = new Deployment({
|
|
176
|
+
apiVersion: "v1",
|
|
177
|
+
kind: "Namespace",
|
|
178
|
+
metadata: { name: props.namespace },
|
|
179
|
+
} as Record<string, unknown>);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const usedKeys = new Set<string>();
|
|
183
|
+
for (let i = 0; i < docs.length; i++) {
|
|
184
|
+
const doc = docs[i];
|
|
185
|
+
const kind = doc.kind ?? "Unknown";
|
|
186
|
+
const name = doc.metadata?.name ?? `doc${i}`;
|
|
187
|
+
let key = safeKey(`${kind}_${name}`);
|
|
188
|
+
// Disambiguate on collision (e.g. same kind+name across docs).
|
|
189
|
+
let collisionN = 2;
|
|
190
|
+
while (usedKeys.has(key)) {
|
|
191
|
+
key = `${safeKey(`${kind}_${name}`)}_${collisionN++}`;
|
|
192
|
+
}
|
|
193
|
+
usedKeys.add(key);
|
|
194
|
+
out[key] = new Deployment(doc as Record<string, unknown>);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return out;
|
|
198
|
+
}, "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 "''";
|