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