@crewhaus/helm-chart 0.1.4 → 0.1.5
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/index.d.ts +171 -0
- package/dist/index.js +489 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -426
- package/src/index.ts +0 -592
package/src/index.ts
DELETED
|
@@ -1,592 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { TARGET_SHAPES, type TargetShape, isTargetShape } from "@crewhaus/docker-images";
|
|
5
|
-
/**
|
|
6
|
-
* Section 32 — `@crewhaus/helm-chart`
|
|
7
|
-
*
|
|
8
|
-
* Kubernetes Helm chart for crewhaus deployments. Ships:
|
|
9
|
-
* - The chart itself under `helm/crewhaus/` (Chart.yaml, values.yaml,
|
|
10
|
-
* templates/, _helpers.tpl) — installable via `helm install`.
|
|
11
|
-
* - `renderChart()` — a tiny in-process renderer that supports the
|
|
12
|
-
* subset of helm's templating used in our templates (just enough
|
|
13
|
-
* to assert structural correctness in unit tests without requiring
|
|
14
|
-
* `helm` in CI).
|
|
15
|
-
* - `validateValues()` — Zod-free runtime validator for the
|
|
16
|
-
* values.yaml schema.
|
|
17
|
-
*
|
|
18
|
-
* The in-process renderer is intentionally minimal — it handles
|
|
19
|
-
* variable interpolation (`{{ .Values.foo }}`), the `if`/`else`/`end`
|
|
20
|
-
* directive, the `has` and `eq` predicates, the `quote` and `nindent`
|
|
21
|
-
* filters, and the `include` template call. It does **not** replace
|
|
22
|
-
* helm; the production install path is `helm install crewhaus
|
|
23
|
-
* helm/crewhaus -f values.yaml`.
|
|
24
|
-
*/
|
|
25
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
-
|
|
27
|
-
export class HelmChartError extends CrewhausError {
|
|
28
|
-
override readonly name = "HelmChartError";
|
|
29
|
-
constructor(message: string, cause?: unknown) {
|
|
30
|
-
super("config", message, cause);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
35
|
-
const CHART_ROOT = join(PACKAGE_ROOT, "helm", "crewhaus");
|
|
36
|
-
const TEMPLATES_DIR = join(CHART_ROOT, "templates");
|
|
37
|
-
|
|
38
|
-
export function chartRoot(): string {
|
|
39
|
-
return CHART_ROOT;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function templateFiles(): readonly string[] {
|
|
43
|
-
if (!existsSync(TEMPLATES_DIR)) {
|
|
44
|
-
throw new HelmChartError(`templates directory missing at ${TEMPLATES_DIR}`);
|
|
45
|
-
}
|
|
46
|
-
return readdirSync(TEMPLATES_DIR)
|
|
47
|
-
.filter((f) => /\.(ya?ml|tpl)$/.test(f))
|
|
48
|
-
.sort();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function readTemplate(name: string): string {
|
|
52
|
-
if (name.includes("/") || name.includes("..")) {
|
|
53
|
-
throw new HelmChartError(`invalid template name: ${name}`);
|
|
54
|
-
}
|
|
55
|
-
const path = join(TEMPLATES_DIR, name);
|
|
56
|
-
if (!existsSync(path)) {
|
|
57
|
-
throw new HelmChartError(`template not found: ${name}`);
|
|
58
|
-
}
|
|
59
|
-
return readFileSync(path, "utf8");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ─── Values shape ────────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
export type ChartValues = {
|
|
65
|
-
readonly target: TargetShape;
|
|
66
|
-
readonly nameOverride?: string;
|
|
67
|
-
readonly image: {
|
|
68
|
-
readonly repository?: string;
|
|
69
|
-
readonly tag: string;
|
|
70
|
-
readonly pullPolicy: "IfNotPresent" | "Always" | "Never";
|
|
71
|
-
readonly pullSecrets: ReadonlyArray<{ readonly name: string }>;
|
|
72
|
-
};
|
|
73
|
-
readonly replicas: number;
|
|
74
|
-
readonly spec: { readonly configMap: string; readonly fileName: string };
|
|
75
|
-
readonly secrets: {
|
|
76
|
-
readonly anthropic?: { readonly secretName: string; readonly key: string };
|
|
77
|
-
readonly slack?: {
|
|
78
|
-
readonly signing: { readonly secretName: string; readonly key: string };
|
|
79
|
-
readonly bot: { readonly secretName: string; readonly key: string };
|
|
80
|
-
};
|
|
81
|
-
/**
|
|
82
|
-
* Generic provider secret list — one container env var per entry,
|
|
83
|
-
* each sourced from a Kubernetes Secret. The escape hatch for
|
|
84
|
-
* non-Anthropic models (OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY,
|
|
85
|
-
* AWS_BEARER_TOKEN_BEDROCK/AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, …).
|
|
86
|
-
* Presets are documented in values.yaml.
|
|
87
|
-
*/
|
|
88
|
-
readonly provider?: ReadonlyArray<{
|
|
89
|
-
readonly name: string;
|
|
90
|
-
readonly secretName: string;
|
|
91
|
-
readonly key: string;
|
|
92
|
-
}>;
|
|
93
|
-
};
|
|
94
|
-
/**
|
|
95
|
-
* Plain (non-secret) env vars appended to the container — provider
|
|
96
|
-
* region/endpoint selectors like AWS_REGION, OPENAI_BASE_URL,
|
|
97
|
-
* GOOGLE_CLOUD_PROJECT.
|
|
98
|
-
*/
|
|
99
|
-
readonly extraEnv?: ReadonlyArray<{ readonly name: string; readonly value: string }>;
|
|
100
|
-
readonly service: {
|
|
101
|
-
readonly type: "ClusterIP" | "LoadBalancer" | "NodePort";
|
|
102
|
-
readonly port: number;
|
|
103
|
-
readonly targetPort: number;
|
|
104
|
-
};
|
|
105
|
-
readonly ingress: {
|
|
106
|
-
readonly enabled: boolean;
|
|
107
|
-
readonly className: string;
|
|
108
|
-
readonly annotations: Readonly<Record<string, string>>;
|
|
109
|
-
readonly hosts: ReadonlyArray<{
|
|
110
|
-
readonly host: string;
|
|
111
|
-
readonly paths: ReadonlyArray<{ readonly path: string; readonly pathType: string }>;
|
|
112
|
-
}>;
|
|
113
|
-
readonly tls: ReadonlyArray<{ readonly hosts: readonly string[]; readonly secretName: string }>;
|
|
114
|
-
};
|
|
115
|
-
readonly otel: { readonly enabled: boolean; readonly endpoint: string; readonly headers: string };
|
|
116
|
-
readonly serviceMonitor: {
|
|
117
|
-
readonly enabled: boolean;
|
|
118
|
-
readonly interval: string;
|
|
119
|
-
readonly labels: Readonly<Record<string, string>>;
|
|
120
|
-
};
|
|
121
|
-
readonly podSecurityContext: Readonly<Record<string, unknown>>;
|
|
122
|
-
readonly securityContext: Readonly<Record<string, unknown>>;
|
|
123
|
-
readonly resources: Readonly<Record<string, unknown>>;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
export function defaultValues(): ChartValues {
|
|
127
|
-
return {
|
|
128
|
-
target: "channel",
|
|
129
|
-
image: { tag: "latest", pullPolicy: "IfNotPresent", pullSecrets: [] },
|
|
130
|
-
replicas: 1,
|
|
131
|
-
spec: { configMap: "crewhaus-spec", fileName: "crewhaus.yaml" },
|
|
132
|
-
secrets: {
|
|
133
|
-
anthropic: { secretName: "crewhaus-creds", key: "ANTHROPIC_AUTH_TOKEN" },
|
|
134
|
-
slack: {
|
|
135
|
-
signing: { secretName: "crewhaus-creds", key: "SLACK_SIGNING_SECRET" },
|
|
136
|
-
bot: { secretName: "crewhaus-creds", key: "SLACK_BOT_TOKEN" },
|
|
137
|
-
},
|
|
138
|
-
provider: [],
|
|
139
|
-
},
|
|
140
|
-
extraEnv: [],
|
|
141
|
-
service: { type: "ClusterIP", port: 80, targetPort: 3000 },
|
|
142
|
-
ingress: {
|
|
143
|
-
enabled: false,
|
|
144
|
-
className: "",
|
|
145
|
-
annotations: {},
|
|
146
|
-
hosts: [],
|
|
147
|
-
tls: [],
|
|
148
|
-
},
|
|
149
|
-
otel: { enabled: true, endpoint: "", headers: "" },
|
|
150
|
-
serviceMonitor: { enabled: true, interval: "30s", labels: {} },
|
|
151
|
-
podSecurityContext: {
|
|
152
|
-
runAsNonRoot: true,
|
|
153
|
-
runAsUser: 10001,
|
|
154
|
-
fsGroup: 10001,
|
|
155
|
-
},
|
|
156
|
-
securityContext: {
|
|
157
|
-
readOnlyRootFilesystem: true,
|
|
158
|
-
allowPrivilegeEscalation: false,
|
|
159
|
-
capabilities: { drop: ["ALL"] },
|
|
160
|
-
},
|
|
161
|
-
resources: {
|
|
162
|
-
requests: { cpu: "100m", memory: "256Mi" },
|
|
163
|
-
limits: { cpu: "1000m", memory: "1Gi" },
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function validateValues(values: ChartValues): void {
|
|
169
|
-
if (!isTargetShape(values.target)) {
|
|
170
|
-
throw new HelmChartError(
|
|
171
|
-
`unknown target: ${values.target} (allowed: ${TARGET_SHAPES.join(", ")})`,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
if (values.replicas < 0 || !Number.isInteger(values.replicas)) {
|
|
175
|
-
throw new HelmChartError(`replicas must be a non-negative integer; got ${values.replicas}`);
|
|
176
|
-
}
|
|
177
|
-
if (values.service.port <= 0 || values.service.port > 65535) {
|
|
178
|
-
throw new HelmChartError(`service.port out of range: ${values.service.port}`);
|
|
179
|
-
}
|
|
180
|
-
if (!values.image.tag) {
|
|
181
|
-
throw new HelmChartError("image.tag must be non-empty");
|
|
182
|
-
}
|
|
183
|
-
for (const entry of values.secrets.provider ?? []) {
|
|
184
|
-
if (!entry.name || !entry.secretName || !entry.key) {
|
|
185
|
-
throw new HelmChartError(
|
|
186
|
-
`secrets.provider entries need name + secretName + key; got ${JSON.stringify(entry)}`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
for (const entry of values.extraEnv ?? []) {
|
|
191
|
-
if (!entry.name || typeof entry.value !== "string") {
|
|
192
|
-
throw new HelmChartError(
|
|
193
|
-
`extraEnv entries need name + string value; got ${JSON.stringify(entry)}`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ─── Minimal helm-shape renderer ─────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
export type RenderContext = {
|
|
202
|
-
readonly Values: ChartValues;
|
|
203
|
-
readonly Release: { readonly Name: string; readonly Service: string };
|
|
204
|
-
readonly Chart: { readonly Name: string; readonly AppVersion: string };
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
export function renderContext(values: ChartValues, releaseName = "crewhaus"): RenderContext {
|
|
208
|
-
return {
|
|
209
|
-
Values: values,
|
|
210
|
-
Release: { Name: releaseName, Service: "Helm" },
|
|
211
|
-
Chart: { Name: "crewhaus", AppVersion: "1.0.0" },
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const DAEMON_SHAPES = ["channel", "managed", "crew", "voice", "browser"];
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Tokenise a template into actions and literals so we can do depth-aware
|
|
219
|
-
* matching of nested if/range/with blocks (regex won't cut it once the
|
|
220
|
-
* templates nest).
|
|
221
|
-
*/
|
|
222
|
-
type Token = { kind: "text"; value: string } | { kind: "action"; value: string };
|
|
223
|
-
|
|
224
|
-
function tokenise(text: string): Token[] {
|
|
225
|
-
const out: Token[] = [];
|
|
226
|
-
// Strip helm comments first
|
|
227
|
-
const stripped = text.replace(/\{\{-?\s*\/\*[\s\S]*?\*\/\s*-?\}\}/g, "");
|
|
228
|
-
const re = /\{\{-?\s*([\s\S]*?)\s*-?\}\}/g;
|
|
229
|
-
let last = 0;
|
|
230
|
-
let m = re.exec(stripped);
|
|
231
|
-
while (m !== null) {
|
|
232
|
-
if (m.index > last) out.push({ kind: "text", value: stripped.slice(last, m.index) });
|
|
233
|
-
out.push({ kind: "action", value: (m[1] ?? "").trim() });
|
|
234
|
-
last = m.index + m[0].length;
|
|
235
|
-
m = re.exec(stripped);
|
|
236
|
-
}
|
|
237
|
-
if (last < stripped.length) out.push({ kind: "text", value: stripped.slice(last) });
|
|
238
|
-
return out;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
type Node =
|
|
242
|
-
| { kind: "text"; value: string }
|
|
243
|
-
| { kind: "expr"; expr: string }
|
|
244
|
-
| { kind: "if"; cond: string; consequent: Node[]; alternate: Node[] }
|
|
245
|
-
| { kind: "range"; expr: string; body: Node[] }
|
|
246
|
-
| { kind: "with"; expr: string; body: Node[] }
|
|
247
|
-
| { kind: "define"; name: string; body: Node[] }
|
|
248
|
-
| { kind: "include"; name: string; filter?: { name: string; arg?: string } };
|
|
249
|
-
|
|
250
|
-
function parseAst(tokens: Token[]): Node[] {
|
|
251
|
-
const it = { i: 0 };
|
|
252
|
-
return parseUntil(tokens, it, []);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function parseUntil(tokens: Token[], it: { i: number }, stoppers: string[]): Node[] {
|
|
256
|
-
const nodes: Node[] = [];
|
|
257
|
-
while (it.i < tokens.length) {
|
|
258
|
-
const tok = tokens[it.i];
|
|
259
|
-
if (tok === undefined) break;
|
|
260
|
-
if (tok.kind === "text") {
|
|
261
|
-
nodes.push({ kind: "text", value: tok.value });
|
|
262
|
-
it.i++;
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const action = tok.value;
|
|
266
|
-
if (stoppers.includes(actionKeyword(action))) {
|
|
267
|
-
return nodes;
|
|
268
|
-
}
|
|
269
|
-
it.i++;
|
|
270
|
-
const kw = actionKeyword(action);
|
|
271
|
-
if (kw === "if") {
|
|
272
|
-
const cond = action.slice(2).trim();
|
|
273
|
-
const thenBranch = parseUntil(tokens, it, ["else", "end"]);
|
|
274
|
-
let elseBranch: Node[] = [];
|
|
275
|
-
const next = tokens[it.i];
|
|
276
|
-
if (next && next.kind === "action" && actionKeyword(next.value) === "else") {
|
|
277
|
-
it.i++;
|
|
278
|
-
elseBranch = parseUntil(tokens, it, ["end"]);
|
|
279
|
-
}
|
|
280
|
-
if (it.i < tokens.length) it.i++;
|
|
281
|
-
nodes.push({ kind: "if", cond, consequent: thenBranch, alternate: elseBranch });
|
|
282
|
-
} else if (kw === "range") {
|
|
283
|
-
const expr = action.slice(5).trim();
|
|
284
|
-
const body = parseUntil(tokens, it, ["end"]);
|
|
285
|
-
if (it.i < tokens.length) it.i++;
|
|
286
|
-
nodes.push({ kind: "range", expr, body });
|
|
287
|
-
} else if (kw === "with") {
|
|
288
|
-
const expr = action.slice(4).trim();
|
|
289
|
-
const body = parseUntil(tokens, it, ["end"]);
|
|
290
|
-
if (it.i < tokens.length) it.i++;
|
|
291
|
-
nodes.push({ kind: "with", expr, body });
|
|
292
|
-
} else if (kw === "define") {
|
|
293
|
-
const m = /^define\s+"([^"]+)"\s*$/.exec(action);
|
|
294
|
-
const name = m?.[1] ?? "";
|
|
295
|
-
const body = parseUntil(tokens, it, ["end"]);
|
|
296
|
-
if (it.i < tokens.length) it.i++;
|
|
297
|
-
nodes.push({ kind: "define", name, body });
|
|
298
|
-
} else if (kw === "include") {
|
|
299
|
-
const m = /^include\s+"([^"]+)"\s+\.\s*(?:\|\s*(\w+)(?:\s+(\S+))?)?\s*$/.exec(action);
|
|
300
|
-
if (m) {
|
|
301
|
-
const name = m[1] ?? "";
|
|
302
|
-
nodes.push({
|
|
303
|
-
kind: "include",
|
|
304
|
-
name,
|
|
305
|
-
filter: m[2] ? { name: m[2], arg: m[3] } : undefined,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
} else if (kw === "end" || kw === "else") {
|
|
309
|
-
// Stray; drop.
|
|
310
|
-
} else {
|
|
311
|
-
nodes.push({ kind: "expr", expr: action });
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return nodes;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function actionKeyword(action: string): string {
|
|
318
|
-
return action.split(/\s+/)[0] ?? "";
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function renderNodes(nodes: Node[], ctx: RenderContext, defines: Map<string, Node[]>): string {
|
|
322
|
-
let out = "";
|
|
323
|
-
for (const n of nodes) {
|
|
324
|
-
switch (n.kind) {
|
|
325
|
-
case "text":
|
|
326
|
-
out += n.value;
|
|
327
|
-
break;
|
|
328
|
-
case "expr":
|
|
329
|
-
out += renderExpr(n.expr, ctx);
|
|
330
|
-
break;
|
|
331
|
-
case "if": {
|
|
332
|
-
const cond = evalExpression(n.cond, ctx);
|
|
333
|
-
out += renderNodes(cond ? n.consequent : n.alternate, ctx, defines);
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
case "range": {
|
|
337
|
-
const list = evalExpression(n.expr, ctx);
|
|
338
|
-
if (Array.isArray(list)) {
|
|
339
|
-
for (const item of list) {
|
|
340
|
-
const itemCtx = withDot(ctx, item);
|
|
341
|
-
out += renderNodes(n.body, itemCtx, defines);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
break;
|
|
345
|
-
}
|
|
346
|
-
case "with": {
|
|
347
|
-
const value = evalExpression(n.expr, ctx);
|
|
348
|
-
if (value && (typeof value !== "object" || Object.keys(value as object).length > 0)) {
|
|
349
|
-
const itemCtx = withDot(ctx, value);
|
|
350
|
-
out += renderNodes(n.body, itemCtx, defines);
|
|
351
|
-
}
|
|
352
|
-
break;
|
|
353
|
-
}
|
|
354
|
-
case "define":
|
|
355
|
-
defines.set(n.name, n.body);
|
|
356
|
-
break;
|
|
357
|
-
case "include": {
|
|
358
|
-
const body = defines.get(n.name);
|
|
359
|
-
let rendered =
|
|
360
|
-
body === undefined ? `<missing:${n.name}>` : renderNodes(body, ctx, defines).trim();
|
|
361
|
-
const indentArg = n.filter?.arg;
|
|
362
|
-
if (n.filter?.name === "nindent" && indentArg) {
|
|
363
|
-
rendered = `\n${rendered
|
|
364
|
-
.split("\n")
|
|
365
|
-
.map((l) => " ".repeat(Number(indentArg)) + l)
|
|
366
|
-
.join("\n")}`;
|
|
367
|
-
}
|
|
368
|
-
out += rendered;
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return out;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function renderExpr(expr: string, ctx: RenderContext): string {
|
|
377
|
-
const m = /^(.+?)(?:\s*\|\s*(\w+)(?:\s+(\S+))?)?$/.exec(expr.trim());
|
|
378
|
-
if (!m || !m[1]) return "";
|
|
379
|
-
const value = evalExpression(m[1].trim(), ctx);
|
|
380
|
-
const filter = m[2];
|
|
381
|
-
const arg = m[3];
|
|
382
|
-
let s = stringifyValue(value);
|
|
383
|
-
switch (filter) {
|
|
384
|
-
case "quote":
|
|
385
|
-
s = JSON.stringify(s);
|
|
386
|
-
break;
|
|
387
|
-
case "upper":
|
|
388
|
-
s = s.toUpperCase();
|
|
389
|
-
break;
|
|
390
|
-
case "trunc":
|
|
391
|
-
if (arg) s = s.slice(0, Number(arg));
|
|
392
|
-
break;
|
|
393
|
-
case "trimSuffix":
|
|
394
|
-
if (arg) {
|
|
395
|
-
const suffix = JSON.parse(arg);
|
|
396
|
-
s = s.endsWith(suffix) ? s.slice(0, -suffix.length) : s;
|
|
397
|
-
}
|
|
398
|
-
break;
|
|
399
|
-
case "nindent":
|
|
400
|
-
if (arg)
|
|
401
|
-
s = `\n${s
|
|
402
|
-
.split("\n")
|
|
403
|
-
.map((l) => " ".repeat(Number(arg)) + l)
|
|
404
|
-
.join("\n")}`;
|
|
405
|
-
break;
|
|
406
|
-
case "toYaml":
|
|
407
|
-
s = simpleYaml(value, 0);
|
|
408
|
-
break;
|
|
409
|
-
}
|
|
410
|
-
return s;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function withDot(ctx: RenderContext, dot: unknown): RenderContext {
|
|
414
|
-
return { ...ctx, _dot: dot } as unknown as RenderContext;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Render a single template against (values + release) → YAML string.
|
|
419
|
-
* Supports define/include/if/else/range/with and the filters used in our templates.
|
|
420
|
-
*/
|
|
421
|
-
export function renderTemplate(
|
|
422
|
-
text: string,
|
|
423
|
-
ctx: RenderContext,
|
|
424
|
-
defines: Map<string, Node[]> = new Map(),
|
|
425
|
-
): string {
|
|
426
|
-
const tokens = tokenise(text);
|
|
427
|
-
const ast = parseAst(tokens);
|
|
428
|
-
return renderNodes(ast, ctx, defines);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function evalExpression(expr: string, ctx: RenderContext): unknown {
|
|
432
|
-
// Function-style helpers we support: has X (list ...), eq A B, default A B, and pipelines like (list ...).
|
|
433
|
-
const trimmed = expr.trim();
|
|
434
|
-
|
|
435
|
-
// has X (list ...)
|
|
436
|
-
const hasMatch = /^has\s+([^()]+?)\s*\(\s*list\s+(.+)\s*\)\s*$/.exec(trimmed);
|
|
437
|
-
if (hasMatch?.[1] && hasMatch[2]) {
|
|
438
|
-
const target = stringifyValue(evalExpression(hasMatch[1].trim(), ctx));
|
|
439
|
-
const items = parseListLiteral(hasMatch[2]);
|
|
440
|
-
return items.includes(target);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// and A B / and A B C
|
|
444
|
-
if (/^and\s+/.test(trimmed)) {
|
|
445
|
-
const parts = splitTopLevel(trimmed.slice(4));
|
|
446
|
-
return parts.every((p) => Boolean(evalExpression(p, ctx)));
|
|
447
|
-
}
|
|
448
|
-
if (/^or\s+/.test(trimmed)) {
|
|
449
|
-
const parts = splitTopLevel(trimmed.slice(3));
|
|
450
|
-
return parts.some((p) => Boolean(evalExpression(p, ctx)));
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// eq A B
|
|
454
|
-
const eqMatch = /^eq\s+(.+?)\s+(.+)$/.exec(trimmed);
|
|
455
|
-
if (eqMatch?.[1] && eqMatch[2]) {
|
|
456
|
-
return (
|
|
457
|
-
stringifyValue(evalExpression(eqMatch[1].trim(), ctx)) ===
|
|
458
|
-
stringifyValue(evalExpression(eqMatch[2].trim(), ctx))
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// default A B
|
|
463
|
-
const defMatch = /^default\s+(.+?)\s+(.+)$/.exec(trimmed);
|
|
464
|
-
if (defMatch?.[1] && defMatch[2]) {
|
|
465
|
-
const fallback = evalExpression(defMatch[1].trim(), ctx);
|
|
466
|
-
const value = evalExpression(defMatch[2].trim(), ctx);
|
|
467
|
-
return value === undefined || value === null || value === "" ? fallback : value;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// (list ...) literal
|
|
471
|
-
if (/^\(\s*list\s/.test(trimmed)) {
|
|
472
|
-
return parseListLiteral(trimmed.replace(/^\(\s*list\s+/, "").replace(/\s*\)$/, ""));
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// String literal
|
|
476
|
-
if (/^".*"$/.test(trimmed)) return JSON.parse(trimmed);
|
|
477
|
-
|
|
478
|
-
// Number literal
|
|
479
|
-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
480
|
-
|
|
481
|
-
// .Values.foo / .Release.Name / .Chart.Name dot path
|
|
482
|
-
if (trimmed.startsWith(".")) {
|
|
483
|
-
return resolveDotPath(trimmed, ctx);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Bare identifier (could be a chart helper we ignore) → return the literal string
|
|
487
|
-
return trimmed;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function splitTopLevel(text: string): string[] {
|
|
491
|
-
const out: string[] = [];
|
|
492
|
-
let depth = 0;
|
|
493
|
-
let start = 0;
|
|
494
|
-
for (let i = 0; i < text.length; i++) {
|
|
495
|
-
const c = text[i];
|
|
496
|
-
if (c === "(") depth++;
|
|
497
|
-
else if (c === ")") depth--;
|
|
498
|
-
else if (c === " " && depth === 0) {
|
|
499
|
-
out.push(text.slice(start, i));
|
|
500
|
-
start = i + 1;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
out.push(text.slice(start));
|
|
504
|
-
return out.filter((s) => s.length > 0);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function parseListLiteral(text: string): string[] {
|
|
508
|
-
const out: string[] = [];
|
|
509
|
-
const re = /"([^"]*)"/g;
|
|
510
|
-
let m = re.exec(text);
|
|
511
|
-
while (m !== null) {
|
|
512
|
-
out.push(m[1] ?? "");
|
|
513
|
-
m = re.exec(text);
|
|
514
|
-
}
|
|
515
|
-
return out;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function resolveDotPath(path: string, ctx: RenderContext): unknown {
|
|
519
|
-
const dotCtx = (ctx as unknown as { _dot?: unknown })._dot;
|
|
520
|
-
if (path === "." || path === "") return dotCtx ?? ctx;
|
|
521
|
-
// `.foo.bar` reads from `_dot` first if present, else from ctx.
|
|
522
|
-
const parts = path.replace(/^\./, "").split(".");
|
|
523
|
-
let cur: unknown = dotCtx ?? ctx;
|
|
524
|
-
for (const p of parts) {
|
|
525
|
-
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
|
|
526
|
-
cur = (cur as Record<string, unknown>)[p];
|
|
527
|
-
} else if (dotCtx && ctx && typeof ctx === "object" && p in (ctx as Record<string, unknown>)) {
|
|
528
|
-
// Fall back to outer context for absolute paths after we lost track in $-iter.
|
|
529
|
-
cur = (ctx as Record<string, unknown>)[p];
|
|
530
|
-
} else {
|
|
531
|
-
return undefined;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return cur;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function stringifyValue(v: unknown): string {
|
|
538
|
-
if (v === undefined || v === null) return "";
|
|
539
|
-
if (typeof v === "string") return v;
|
|
540
|
-
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
541
|
-
if (Array.isArray(v)) return v.map(stringifyValue).join(",");
|
|
542
|
-
return JSON.stringify(v);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function simpleYaml(v: unknown, indent: number): string {
|
|
546
|
-
const pad = " ".repeat(indent);
|
|
547
|
-
if (v === null || v === undefined) return "null";
|
|
548
|
-
if (typeof v === "string") return JSON.stringify(v);
|
|
549
|
-
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
550
|
-
if (Array.isArray(v)) {
|
|
551
|
-
if (v.length === 0) return "[]";
|
|
552
|
-
return v.map((item) => `${pad}- ${simpleYaml(item, indent + 2).trimStart()}`).join("\n");
|
|
553
|
-
}
|
|
554
|
-
if (typeof v === "object") {
|
|
555
|
-
const entries = Object.entries(v);
|
|
556
|
-
if (entries.length === 0) return "{}";
|
|
557
|
-
return entries
|
|
558
|
-
.map(([k, val]) => {
|
|
559
|
-
const inner = simpleYaml(val, indent + 2);
|
|
560
|
-
if (inner.includes("\n")) return `${pad}${k}:\n${inner}`;
|
|
561
|
-
return `${pad}${k}: ${inner}`;
|
|
562
|
-
})
|
|
563
|
-
.join("\n");
|
|
564
|
-
}
|
|
565
|
-
return String(v);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// ─── High-level API: render all templates for a values set ───────────────────
|
|
569
|
-
|
|
570
|
-
export function renderChart(values: ChartValues, releaseName = "crewhaus"): Record<string, string> {
|
|
571
|
-
validateValues(values);
|
|
572
|
-
const ctx = renderContext(values, releaseName);
|
|
573
|
-
const defines = new Map<string, Node[]>();
|
|
574
|
-
// Pre-load every helpers file so include() resolves.
|
|
575
|
-
for (const file of templateFiles()) {
|
|
576
|
-
if (file.endsWith(".tpl")) {
|
|
577
|
-
// Drains define blocks into the shared map but produces no output.
|
|
578
|
-
renderTemplate(readTemplate(file), ctx, defines);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
const result: Record<string, string> = {};
|
|
582
|
-
for (const file of templateFiles()) {
|
|
583
|
-
if (file.endsWith(".tpl")) continue;
|
|
584
|
-
result[file] = renderTemplate(readTemplate(file), ctx, defines).trim();
|
|
585
|
-
}
|
|
586
|
-
return result;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** Daemon shapes that get a Service + Ingress rendered. */
|
|
590
|
-
export function isDaemonShape(target: TargetShape): boolean {
|
|
591
|
-
return DAEMON_SHAPES.includes(target);
|
|
592
|
-
}
|