@intentius/chant-lexicon-helm 0.107.1 → 0.108.1

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A TypeScript type for a chart's values when the chart ships no
3
+ * `values.schema.json`, inferred from its `values.yaml` defaults.
4
+ *
5
+ * Every member is optional, since an override sets any subset. An object's
6
+ * keys are the ones `values.yaml` lists and no others, so a misspelled key
7
+ * fails to typecheck. A default that says nothing about its type becomes
8
+ * `unknown`: a `null` or empty value (`key:` or `key: ~`) is `unknown`, an
9
+ * empty object (`podAnnotations: {}`) a map of `unknown`, an empty list an
10
+ * array of `unknown`. An empty string is still a string. Keys are quoted
11
+ * when they are not identifiers and never renamed.
12
+ */
13
+ /** The type expression for a parsed `values.yaml` document. */
14
+ export declare function inferValuesType(values: unknown): string;
15
+ //# sourceMappingURL=values-type.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"values-type.d.ts","sourceRoot":"","sources":["../../src/codegen/values-type.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAGvD"}
package/dist/config.d.ts CHANGED
@@ -77,16 +77,68 @@ export type HelmCapabilityProfileRef = string | HelmCapabilityProfile;
77
77
  export interface HelmChantConfig {
78
78
  /** Per-cluster capability profiles, keyed by profile (cluster/environment) name. */
79
79
  capabilityProfiles?: Record<string, HelmCapabilityProfileConfig>;
80
+ /**
81
+ * Charts to generate typed render factories for, with `chant generate`,
82
+ * keyed by a name the factory is called after (`traefik` gives
83
+ * `TraefikRender` and `TraefikValues`).
84
+ *
85
+ * Each factory wraps `HelmRender` with the chart's repo, name and version
86
+ * fixed here and its `values` typed from the chart's `values.schema.json`,
87
+ * or inferred from its `values.yaml` when it ships no schema. The code is
88
+ * written to `src/generated/helm/` (see `codegen.outDir`) and committed.
89
+ *
90
+ * ```ts
91
+ * helm: {
92
+ * charts: {
93
+ * traefik: { repo: "https://traefik.github.io/charts", chart: "traefik", version: "34.4.1" },
94
+ * operator: { chart: "oci://ghcr.io/acme/charts/operator", version: "1.2.0" },
95
+ * app: { path: "charts/app" },
96
+ * },
97
+ * }
98
+ * ```
99
+ */
100
+ charts?: Record<string, HelmProjectChart>;
80
101
  }
102
+ /**
103
+ * One `helm.charts` entry: a chart in a classic repository (`repo` + `chart`),
104
+ * an `oci://` chart reference (no `repo`), each pinned by `version`, or a
105
+ * chart directory in the project (`path`, relative to the project root).
106
+ */
107
+ export type HelmProjectChart = {
108
+ repo?: string;
109
+ chart: string;
110
+ version: string;
111
+ digest?: string;
112
+ } | {
113
+ path: string;
114
+ };
81
115
  export declare const helmCapabilityProfileSchema: z.ZodObject<{
82
116
  kubeVersion: z.ZodString;
83
117
  apiVersions: z.ZodOptional<z.ZodArray<z.ZodString>>;
84
118
  }, z.core.$strict>;
119
+ /** What a `helm.charts` key may be: it names the generated factory. */
120
+ export declare const CHART_KEY_PATTERN: RegExp;
121
+ export declare const helmProjectChartSchema: z.ZodUnion<readonly [z.ZodObject<{
122
+ repo: z.ZodOptional<z.ZodString>;
123
+ chart: z.ZodString;
124
+ version: z.ZodString;
125
+ digest: z.ZodOptional<z.ZodString>;
126
+ }, z.core.$strict>, z.ZodObject<{
127
+ path: z.ZodString;
128
+ }, z.core.$strict>]>;
85
129
  export declare const helmConfigSchema: z.ZodObject<{
86
130
  capabilityProfiles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
87
131
  kubeVersion: z.ZodString;
88
132
  apiVersions: z.ZodOptional<z.ZodArray<z.ZodString>>;
89
133
  }, z.core.$strict>>>;
134
+ charts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
135
+ repo: z.ZodOptional<z.ZodString>;
136
+ chart: z.ZodString;
137
+ version: z.ZodString;
138
+ digest: z.ZodOptional<z.ZodString>;
139
+ }, z.core.$strict>, z.ZodObject<{
140
+ path: z.ZodString;
141
+ }, z.core.$strict>]>>>;
90
142
  }, z.core.$strict>;
91
143
  declare module "@intentius/chant/config" {
92
144
  interface ChantConfig {
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAI3D;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,QAAyB,CAAC;AAE3D;;;GAGG;AACH,MAAM,WAAW,2BAA2B;IAC1C,yFAAyF;IACzF,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,gGAAgG;AAChG,MAAM,WAAW,qBAAsB,SAAQ,2BAA2B;IACxE,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,qBAAqB,CAAC;AAEtE,MAAM,WAAW,eAAe;IAC9B,oFAAoF;IACpF,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,2BAA2B,CAAC,CAAC;CAClE;AAED,eAAO,MAAM,2BAA2B;;;kBAKtC,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;kBAE3B,CAAC;AAEH,OAAO,QAAQ,yBAAyB,CAAC;IACvC,UAAU,WAAW;QACnB,IAAI,CAAC,EAAE,eAAe,CAAC;KACxB;CACF;AAED;;;;;GAKG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;AAEnE;;;GAGG;AACH,KAAK,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,SAAS,eAAe,GAClF,eAAe,SAAS,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GACtD,IAAI,GACJ,KAAK,GACP,KAAK,CAAC;AACV,MAAM,MAAM,0BAA0B,GAAG,sBAAsB,CAAC;AAEhE;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,qBAAqB,GAAG,MAAM,EAAE,CAyBlF;AAmBD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,wBAAwB,EAC7B,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3B,qBAAqB,CAyBvB"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAI3D;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,QAAyB,CAAC;AAE3D;;;GAGG;AACH,MAAM,WAAW,2BAA2B;IAC1C,yFAAyF;IACzF,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,gGAAgG;AAChG,MAAM,WAAW,qBAAsB,SAAQ,2BAA2B;IACxE,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,qBAAqB,CAAC;AAEtE,MAAM,WAAW,eAAe;IAC9B,oFAAoF;IACpF,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,2BAA2B,CAAC,CAAC;IACjE;;;;;;;;;;;;;;;;;;;OAmBG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC3C;AAED;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAClE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAErB,eAAO,MAAM,2BAA2B;;;kBAKtC,CAAC;AAEH,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,QAA6B,CAAC;AAE5D,eAAO,MAAM,sBAAsB;;;;;;;oBAQjC,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;kBAK3B,CAAC;AAEH,OAAO,QAAQ,yBAAyB,CAAC;IACvC,UAAU,WAAW;QACnB,IAAI,CAAC,EAAE,eAAe,CAAC;KACxB;CACF;AAED;;;;;GAKG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;AAEnE;;;GAGG;AACH,KAAK,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,SAAS,eAAe,GAClF,eAAe,SAAS,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GACtD,IAAI,GACJ,KAAK,GACP,KAAK,CAAC;AACV,MAAM,MAAM,0BAA0B,GAAG,sBAAsB,CAAC;AAEhE;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,qBAAqB,GAAG,MAAM,EAAE,CAyBlF;AAmBD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,wBAAwB,EAC7B,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3B,qBAAqB,CAyBvB"}
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { helmInstall, helmInstallPinned } from "./op/builders.js";
6
6
  export { Chart, Values, ValuesOverride, HelmTest, HelmNotes, HelmHook, HelmDependency, HelmMaintainer, HelmCRD } from "./resources.js";
7
7
  export { HelmRender, getHelmRenderRecords, clearHelmRenderRecords } from "./render.js";
8
8
  export type { HelmRenderProps, HelmRenderRecord } from "./render.js";
9
+ export type { HelmProjectChart } from "./config.js";
9
10
  export { canonicalizeRender, helmContentDigest, helmInputDigest, renderStability } from "./render-digest.js";
10
11
  export type { HelmInputDigestSource, RenderStabilityGroup, RenderStabilityReport } from "./render-digest.js";
11
12
  export { helmValuesDigest, indexRenderDocuments, renderCacheKey, renderStoreRoot, persistHelmRender, loadRenderManifest, loadRenderContent, listRenderManifests, findRenderByCacheKey, readRenderDocument, } from "./render-store.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "ca5da89b705fb3f621f4ca3b851cc2472ae349b49d7c595a9d4be37df34c0abc",
4
+ "manifest.json": "91d6e6e179420a926b50dd8ef09156a5ac05eef1cc8651ca4f7c52624c41c524",
5
5
  "meta.json": "14243c5730a07c6a6edc35ddd351438547d58df5cf345f2233a355b0c7611ccc",
6
6
  "types/index.d.ts": "5377696ca8698cd2999e4680feb8e8e4b54a7b49fb603a87b2f27356114d1794",
7
7
  "rules/chart-metadata.ts": "8f3377e893d5e2828460b7fe5924fca098334245a9a2fdb90f6b67e490eaf091",
@@ -36,5 +36,5 @@
36
36
  "skills/chant-helm-patterns.md": "9e79e6a46391da46709d8aa57e2825a7cd9eb981cd923f02ad60836c49b2561e",
37
37
  "skills/chant-helm-security.md": "bfc367eabceed2e84f1cf94501b407df78aeed963cec104f24a321d0962063c9"
38
38
  },
39
- "composite": "32a06ff3028404ee3f11131354cab420e4079d94e1bf7319748adc930fd9f95d"
39
+ "composite": "877584defd345e7725184a0ad35e7551bb7b2dc647204f83aa471bcc0e2151e6"
40
40
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helm",
3
- "version": "0.107.1",
3
+ "version": "0.108.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Helm",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAiB7F,eAAO,MAAM,UAAU,EAAE,aAycxB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAkB7F,eAAO,MAAM,UAAU,EAAE,aA6cxB,CAAC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Typed Helm values: the helm lexicon's `projectCodegen` hook (`chant
3
+ * generate`, core's project-codegen.ts).
4
+ *
5
+ * For each chart a project lists under `helm.charts`, generation reads the
6
+ * chart's `values.schema.json` (or, without one, its `values.yaml`) and writes
7
+ * to `<outDir>/helm/index.ts`:
8
+ *
9
+ * - `<Name>Values`, the values type: from the schema, with every member
10
+ * optional (the schema validates the values Helm merges with the chart's
11
+ * defaults, so a key it requires is usually already set), or inferred from
12
+ * `values.yaml` (./codegen/values-type.ts).
13
+ * - `<Name>Render(props)`, a factory that calls `HelmRender` with the repo,
14
+ * chart and version from the config, so the type and the version it was
15
+ * generated from cannot drift apart. It takes `HelmRender`'s other props.
16
+ *
17
+ * A remote chart is downloaded without the helm binary: an `oci://` chart
18
+ * through the registry API, a classic repository through its `index.yaml`.
19
+ * Both go through `ctx.fetch` when the caller passes one.
20
+ */
21
+ import { type ProjectCodegen } from "@intentius/chant/project-codegen";
22
+ import { type HelmProjectChart } from "./config.js";
23
+ /** The chart files generation reads. */
24
+ export interface ChartFiles {
25
+ chartYaml: string;
26
+ valuesYaml?: string;
27
+ valuesSchema?: string;
28
+ }
29
+ type RemoteChart = Extract<HelmProjectChart, {
30
+ chart: string;
31
+ }>;
32
+ /** Download a remote chart's files. Tests replace it. */
33
+ export type ChartPuller = (chart: RemoteChart, fetchImpl: typeof fetch | undefined) => Promise<ChartFiles>;
34
+ /** The hook, with an injectable puller for remote charts. */
35
+ export declare function helmProjectCodegen(options?: {
36
+ pull?: ChartPuller;
37
+ }): ProjectCodegen;
38
+ /** The top-level chart's files out of a chart archive (subcharts are ignored). */
39
+ export declare function chartFilesFromArchive(files: Map<string, Buffer>, label: string): ChartFiles;
40
+ /** Download a remote chart: OCI through the registry API, a classic repository through its index. */
41
+ export declare function pullRemoteChart(chart: RemoteChart, fetchImpl: typeof fetch | undefined): Promise<ChartFiles>;
42
+ /** `ingress-nginx` -> `IngressNginx`. */
43
+ export declare function factoryBase(key: string): string;
44
+ export {};
45
+ //# sourceMappingURL=project-codegen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"project-codegen.d.ts","sourceRoot":"","sources":["../src/project-codegen.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,OAAO,EAAuB,KAAK,cAAc,EAA8B,MAAM,kCAAkC,CAAC;AAIxH,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAGpE,wCAAwC;AACxC,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,KAAK,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEhE,yDAAyD;AACzD,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,KAAK,GAAG,SAAS,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;AAI3G,6DAA6D;AAC7D,wBAAgB,kBAAkB,CAAC,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,WAAW,CAAA;CAAO,GAAG,cAAc,CAwBvF;AA6CD,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,UAAU,CAM3F;AAED,qGAAqG;AACrG,wBAAsB,eAAe,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,KAAK,GAAG,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CA8BlH;AAED,yCAAyC;AACzC,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM/C"}
package/dist/render.d.ts CHANGED
@@ -30,6 +30,7 @@
30
30
  * values: { installCRDs: true },
31
31
  * });
32
32
  */
33
+ import { type RenderedPatch } from "@intentius/chant-lexicon-k8s/patch/rendered-patch";
33
34
  import { type HelmCapabilityProfileRef } from "./config.js";
34
35
  export interface HelmRenderProps {
35
36
  /** Logical name for the render (used in cache key + composite name). */
@@ -84,6 +85,18 @@ export interface HelmRenderProps {
84
85
  * means the manifest records `sourceRef: null`.
85
86
  */
86
87
  sourceRef?: string;
88
+ /**
89
+ * Patches applied to the rendered documents before they become entities
90
+ * (#3566): select by kind and name (namespace and apiVersion optional) and
91
+ * apply an RFC 6902 `jsonPatch` or an RFC 7386 `merge`. The patched form is
92
+ * what the build emits and what `lifecycle diff --live` compares. A
93
+ * selector matching no rendered document fails the build, so a chart
94
+ * upgrade that renames a resource is noticed.
95
+ *
96
+ * Patches act on the parsed render, after caching and digests: the render
97
+ * store and `contentDigest` still describe helm's own output.
98
+ */
99
+ patches?: RenderedPatch[];
87
100
  }
88
101
  export { getHelmRenderRecords, clearHelmRenderRecords, type HelmRenderRecord } from "./render-records.js";
89
102
  export declare const HelmRender: import("@intentius/chant").CompositeDefinition<HelmRenderProps, import("@intentius/chant").CompositeMembers>;
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAeH,OAAO,EAAwD,KAAK,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAY/G,MAAM,WAAW,eAAe;IAC9B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;IAC7C;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,OAAO,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AA8RvG,eAAO,MAAM,UAAU,8GAsFP,CAAC"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAaH,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,mDAAmD,CAAC;AAG7G,OAAO,EAAwD,KAAK,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAY/G,MAAM,WAAW,eAAe;IAC9B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;IAC7C;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,EAAE,aAAa,EAAE,CAAC;CAC3B;AAMD,OAAO,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AA8RvG,eAAO,MAAM,UAAU,8GA0FP,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-helm",
3
- "version": "0.107.1",
3
+ "version": "0.108.1",
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",
@@ -86,7 +86,7 @@
86
86
  "@types/js-yaml": "^4.0.9"
87
87
  },
88
88
  "peerDependencies": {
89
- "@intentius/chant": "^0.107.1",
89
+ "@intentius/chant": "^0.108.1",
90
90
  "typescript": "^5.9.3",
91
91
  "zod": "^4.3.6"
92
92
  }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import yaml from "js-yaml";
3
+ import ts from "typescript";
4
+ import { inferValuesType } from "./values-type";
5
+
6
+ /** Type-check `const v: Values = <value>` against the inferred type. */
7
+ function accepts(valuesYaml: string, value: string): string[] {
8
+ const code = `type Values = ${inferValuesType(yaml.load(valuesYaml))};\nexport const v: Values = ${value};\n`;
9
+ const fileName = "/virtual/v.ts";
10
+ const options: ts.CompilerOptions = { strict: true, noEmit: true, lib: ["lib.es2022.d.ts"] };
11
+ const host = ts.createCompilerHost(options);
12
+ const original = host.getSourceFile;
13
+ host.getSourceFile = (name, lang, ...rest) => (name === fileName ? ts.createSourceFile(name, code, lang) : original.call(host, name, lang, ...rest));
14
+ const program = ts.createProgram([fileName], options, host);
15
+ return ts.getPreEmitDiagnostics(program, program.getSourceFile(fileName)).map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
16
+ }
17
+
18
+ const VALUES = `
19
+ replicaCount: 1
20
+ image:
21
+ repository: traefik
22
+ tag: ""
23
+ pullPolicy: IfNotPresent
24
+ podAnnotations: {}
25
+ tolerations: []
26
+ nodeSelector:
27
+ resources: ~
28
+ ports:
29
+ - name: web
30
+ port: 8000
31
+ - name: websecure
32
+ port: 8443
33
+ tls: true
34
+ "app.kubernetes.io/part-of": edge
35
+ use-forwarded-headers: false
36
+ `;
37
+
38
+ describe("inferValuesType", () => {
39
+ test("every member is optional and typed from its default", () => {
40
+ const type = inferValuesType(yaml.load(VALUES));
41
+ expect(type).toContain(" replicaCount?: number;");
42
+ expect(type).toContain(" tag?: string;");
43
+ expect(type).toContain(` "app.kubernetes.io/part-of"?: string;`);
44
+ expect(type).toContain(` "use-forwarded-headers"?: boolean;`);
45
+ });
46
+
47
+ test("null and empty defaults say nothing about the type", () => {
48
+ const type = inferValuesType(yaml.load(VALUES));
49
+ expect(type).toContain(" nodeSelector?: unknown;");
50
+ expect(type).toContain(" resources?: unknown;");
51
+ expect(type).toContain(" podAnnotations?: Record<string, unknown>;");
52
+ expect(type).toContain(" tolerations?: unknown[];");
53
+ });
54
+
55
+ test("list elements merge their keys", () => {
56
+ const type = inferValuesType(yaml.load(VALUES));
57
+ expect(type).toMatch(/ports\?: Array<\{\n {4}name\?: string;\n {4}port\?: number;\n {4}tls\?: boolean;\n {2}\}>;/);
58
+ });
59
+
60
+ test("a subset of overrides typechecks; a misspelled or mistyped key does not", () => {
61
+ expect(accepts(VALUES, `{ image: { tag: "v3" }, podAnnotations: { a: "b" }, resources: { limits: {} } }`)).toEqual([]);
62
+ expect(accepts(VALUES, `{ image: { tga: "v3" } }`).join("\n")).toMatch(/'tga' does not exist/);
63
+ expect(accepts(VALUES, `{ replicaCount: "2" }`)).not.toEqual([]);
64
+ });
65
+
66
+ test("an empty values file is an open map", () => {
67
+ expect(inferValuesType(yaml.load(""))).toBe("Record<string, unknown>");
68
+ });
69
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A TypeScript type for a chart's values when the chart ships no
3
+ * `values.schema.json`, inferred from its `values.yaml` defaults.
4
+ *
5
+ * Every member is optional, since an override sets any subset. An object's
6
+ * keys are the ones `values.yaml` lists and no others, so a misspelled key
7
+ * fails to typecheck. A default that says nothing about its type becomes
8
+ * `unknown`: a `null` or empty value (`key:` or `key: ~`) is `unknown`, an
9
+ * empty object (`podAnnotations: {}`) a map of `unknown`, an empty list an
10
+ * array of `unknown`. An empty string is still a string. Keys are quoted
11
+ * when they are not identifiers and never renamed.
12
+ */
13
+
14
+ import { tsPropertyKey } from "@intentius/chant/codegen/json-schema-to-ts";
15
+
16
+ /** The type expression for a parsed `values.yaml` document. */
17
+ export function inferValuesType(values: unknown): string {
18
+ if (values === null || values === undefined) return "Record<string, unknown>";
19
+ return render(shape(values), "");
20
+ }
21
+
22
+ type Shape =
23
+ | { kind: "unknown" }
24
+ | { kind: "scalar"; type: "string" | "number" | "boolean" }
25
+ | { kind: "array"; element: Shape }
26
+ | { kind: "object"; members: Map<string, Shape> }
27
+ | { kind: "map" }
28
+ | { kind: "union"; shapes: Shape[] };
29
+
30
+ function shape(value: unknown): Shape {
31
+ if (value === null || value === undefined) return { kind: "unknown" };
32
+ if (typeof value === "string") return { kind: "scalar", type: "string" };
33
+ if (typeof value === "number") return { kind: "scalar", type: "number" };
34
+ if (typeof value === "boolean") return { kind: "scalar", type: "boolean" };
35
+ if (value instanceof Date) return { kind: "scalar", type: "string" };
36
+ if (Array.isArray(value)) {
37
+ if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
38
+ return { kind: "array", element: value.map(shape).reduce(merge) };
39
+ }
40
+ if (typeof value === "object") {
41
+ const entries = Object.entries(value as Record<string, unknown>);
42
+ if (entries.length === 0) return { kind: "map" };
43
+ return { kind: "object", members: new Map(entries.map(([k, v]) => [k, shape(v)])) };
44
+ }
45
+ return { kind: "unknown" };
46
+ }
47
+
48
+ /** Combine the shapes of two list elements: objects merge their keys, anything else unions. */
49
+ function merge(a: Shape, b: Shape): Shape {
50
+ if (a.kind === "unknown" || b.kind === "unknown") return { kind: "unknown" };
51
+ if (a.kind === "object" && b.kind === "object") {
52
+ const members = new Map(a.members);
53
+ for (const [k, s] of b.members) members.set(k, members.has(k) ? merge(members.get(k)!, s) : s);
54
+ return { kind: "object", members };
55
+ }
56
+ if (a.kind === "scalar" && b.kind === "scalar" && a.type === b.type) return a;
57
+ if (a.kind === "map" && b.kind === "map") return a;
58
+ if (a.kind === "array" && b.kind === "array") return { kind: "array", element: merge(a.element, b.element) };
59
+ const shapes = [...(a.kind === "union" ? a.shapes : [a]), ...(b.kind === "union" ? b.shapes : [b])];
60
+ return { kind: "union", shapes };
61
+ }
62
+
63
+ function render(s: Shape, indent: string): string {
64
+ switch (s.kind) {
65
+ case "unknown":
66
+ return "unknown";
67
+ case "scalar":
68
+ return s.type;
69
+ case "map":
70
+ return "Record<string, unknown>";
71
+ case "array": {
72
+ const element = render(s.element, indent);
73
+ return /^[A-Za-z]+$/.test(element) ? `${element}[]` : `Array<${element}>`;
74
+ }
75
+ case "union":
76
+ return [...new Set(s.shapes.map((x) => render(x, indent)))].join(" | ");
77
+ case "object": {
78
+ const inner = indent + " ";
79
+ const lines = ["{"];
80
+ for (const [key, member] of s.members) {
81
+ lines.push(`${inner}${tsPropertyKey(key)}?: ${render(member, inner)};`);
82
+ }
83
+ lines.push(`${indent}}`);
84
+ return lines.join("\n");
85
+ }
86
+ }
87
+ }
package/src/config.ts CHANGED
@@ -86,8 +86,38 @@ export type HelmCapabilityProfileRef = string | HelmCapabilityProfile;
86
86
  export interface HelmChantConfig {
87
87
  /** Per-cluster capability profiles, keyed by profile (cluster/environment) name. */
88
88
  capabilityProfiles?: Record<string, HelmCapabilityProfileConfig>;
89
+ /**
90
+ * Charts to generate typed render factories for, with `chant generate`,
91
+ * keyed by a name the factory is called after (`traefik` gives
92
+ * `TraefikRender` and `TraefikValues`).
93
+ *
94
+ * Each factory wraps `HelmRender` with the chart's repo, name and version
95
+ * fixed here and its `values` typed from the chart's `values.schema.json`,
96
+ * or inferred from its `values.yaml` when it ships no schema. The code is
97
+ * written to `src/generated/helm/` (see `codegen.outDir`) and committed.
98
+ *
99
+ * ```ts
100
+ * helm: {
101
+ * charts: {
102
+ * traefik: { repo: "https://traefik.github.io/charts", chart: "traefik", version: "34.4.1" },
103
+ * operator: { chart: "oci://ghcr.io/acme/charts/operator", version: "1.2.0" },
104
+ * app: { path: "charts/app" },
105
+ * },
106
+ * }
107
+ * ```
108
+ */
109
+ charts?: Record<string, HelmProjectChart>;
89
110
  }
90
111
 
112
+ /**
113
+ * One `helm.charts` entry: a chart in a classic repository (`repo` + `chart`),
114
+ * an `oci://` chart reference (no `repo`), each pinned by `version`, or a
115
+ * chart directory in the project (`path`, relative to the project root).
116
+ */
117
+ export type HelmProjectChart =
118
+ | { repo?: string; chart: string; version: string; digest?: string }
119
+ | { path: string };
120
+
91
121
  export const helmCapabilityProfileSchema = z.strictObject({
92
122
  kubeVersion: z
93
123
  .string()
@@ -95,8 +125,24 @@ export const helmCapabilityProfileSchema = z.strictObject({
95
125
  apiVersions: z.array(z.string().min(1, "apiVersions entries must be non-empty strings")).optional(),
96
126
  });
97
127
 
128
+ /** What a `helm.charts` key may be: it names the generated factory. */
129
+ export const CHART_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
130
+
131
+ export const helmProjectChartSchema = z.union([
132
+ z.strictObject({
133
+ repo: z.string().url().optional(),
134
+ chart: z.string().min(1),
135
+ version: z.string().min(1),
136
+ digest: z.string().optional(),
137
+ }),
138
+ z.strictObject({ path: z.string().min(1) }),
139
+ ]);
140
+
98
141
  export const helmConfigSchema = z.strictObject({
99
142
  capabilityProfiles: z.record(z.string(), helmCapabilityProfileSchema).optional(),
143
+ charts: z
144
+ .record(z.string().regex(CHART_KEY_PATTERN, "must start with a letter and hold only letters, digits, - and _"), helmProjectChartSchema)
145
+ .optional(),
100
146
  });
101
147
 
102
148
  declare module "@intentius/chant/config" {
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export { Chart, Values, ValuesOverride, HelmTest, HelmNotes, HelmHook, HelmDepen
24
24
  // HelmRender — render an upstream chart at chant build time
25
25
  export { HelmRender, getHelmRenderRecords, clearHelmRenderRecords } from "./render";
26
26
  export type { HelmRenderProps, HelmRenderRecord } from "./render";
27
+ export type { HelmProjectChart } from "./config";
27
28
 
28
29
  // #1237 — render canonicalization + the contentDigest/inputDigest split.
29
30
  export { canonicalizeRender, helmContentDigest, helmInputDigest, renderStability } from "./render-digest";
package/src/plugin.ts CHANGED
@@ -20,6 +20,7 @@ import { helmSerializer } from "./serializer";
20
20
  import { helmCompletions } from "./lsp/completions";
21
21
  import { helmHover } from "./lsp/hover";
22
22
  import { helmConfigSchema } from "./config";
23
+ import { helmProjectCodegen } from "./project-codegen";
23
24
  import { helmDeepNormalizationHooks } from "./deep-observe-hooks";
24
25
  import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
25
26
 
@@ -29,6 +30,10 @@ export const helmPlugin: LexiconPlugin = {
29
30
  // Declaring the schema makes core validate the namespace at load, so a
30
31
  // typo'd profile field fails the build instead of silently unpinning.
31
32
  configSchema: helmConfigSchema,
33
+ // Typed values and a render factory per chart in `helm.charts`, written by
34
+ // `chant generate` and checked for drift by every build. See
35
+ // ./project-codegen.ts.
36
+ projectCodegen: () => helmProjectCodegen(),
32
37
  // #1246 — helm resolves real ownership verdicts on the thin read (every row
33
38
  // is release-scoped, so helm-managed = owned via release identity), and the
34
39
  // deep read (#1247) delegates to the k8s reader, which resolves the shared
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Typed Helm values end to end: a project lists charts under `helm.charts`,
3
+ * `chant generate` writes a values type and a render factory per chart, the
4
+ * types catch a misspelled value, a remote chart is read through an injected
5
+ * fetch, and editing a local chart's schema without regenerating is drift.
6
+ * The one step that renders needs the helm binary and is skipped without it.
7
+ */
8
+ import { afterAll, describe, expect, test } from "vitest";
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { gzipSync } from "node:zlib";
16
+ import ts from "typescript";
17
+ import { buildCommand } from "@intentius/chant/cli/commands/build";
18
+ import { loadPlugins } from "@intentius/chant/cli";
19
+ import { loadChantConfig } from "@intentius/chant/config";
20
+ import { checkProjectCodegen, generateProjectCode } from "@intentius/chant/project-codegen";
21
+ import { helmPlugin } from "./plugin";
22
+ import { factoryBase, helmProjectCodegen } from "./project-codegen";
23
+
24
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
25
+ const made: string[] = [];
26
+ afterAll(() => {
27
+ for (const dir of made) rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ function helmOnPath(): boolean {
31
+ try {
32
+ execFileSync("helm", ["version"], { stdio: "ignore" });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ const EDGE_SCHEMA = {
40
+ $schema: "https://json-schema.org/draft/2020-12/schema",
41
+ type: "object",
42
+ additionalProperties: false,
43
+ required: ["image"],
44
+ properties: {
45
+ image: { $ref: "#/$defs/image" },
46
+ logLevel: { type: "string", enum: ["debug", "info", "error"] },
47
+ ports: {
48
+ type: "object",
49
+ additionalProperties: {
50
+ type: "object",
51
+ additionalProperties: false,
52
+ properties: { port: { type: "integer" }, expose: { type: "boolean" } },
53
+ },
54
+ },
55
+ podLabels: { type: "object", additionalProperties: { type: "string" } },
56
+ "app.kubernetes.io/part-of": { type: "string" },
57
+ "use-forwarded-headers": { type: "boolean" },
58
+ replicas: { oneOf: [{ type: "integer" }, { type: "string", pattern: "^auto$" }] },
59
+ },
60
+ $defs: {
61
+ image: {
62
+ type: "object",
63
+ additionalProperties: false,
64
+ required: ["repository"],
65
+ properties: { repository: { type: "string" }, tag: { type: "string" } },
66
+ },
67
+ },
68
+ };
69
+
70
+ const EDGE_VALUES = `image:\n repository: nginx\n tag: "1.27"\nlogLevel: info\nports:\n web:\n port: 8000\n`;
71
+ const EDGE_TEMPLATE = `apiVersion: v1
72
+ kind: ConfigMap
73
+ metadata:
74
+ name: {{ .Release.Name }}-settings
75
+ data:
76
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
77
+ logLevel: {{ .Values.logLevel | quote }}
78
+ `;
79
+
80
+ const PLAIN_VALUES = `replicaCount: 1\nservice:\n type: ClusterIP\n port: 80\nextraEnv: []\nresources:\n`;
81
+
82
+ /** A minimal ustar writer, enough to stand in for a chart archive. */
83
+ function tarGz(entries: Array<{ name: string; body: string }>): Buffer {
84
+ const blocks: Buffer[] = [];
85
+ for (const { name, body } of entries) {
86
+ const data = Buffer.from(body, "utf8");
87
+ const header = Buffer.alloc(512);
88
+ header.write(name, 0, "utf8");
89
+ header.write("0000644\0", 100);
90
+ header.write("0000000\0", 108);
91
+ header.write("0000000\0", 116);
92
+ header.write(data.length.toString(8).padStart(11, "0") + "\0", 124);
93
+ header.write("00000000000\0", 136);
94
+ header.write(" ", 148);
95
+ header.write("0", 156);
96
+ header.write("ustar\0", 257);
97
+ header.write("00", 263);
98
+ let sum = 0;
99
+ for (const b of header) sum += b;
100
+ header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
101
+ blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
102
+ }
103
+ blocks.push(Buffer.alloc(1024));
104
+ return gzipSync(Buffer.concat(blocks));
105
+ }
106
+
107
+ /** A classic chart repository at https://charts.example.test serving one chart, as a fetch. */
108
+ function fakeRepository(): { fetch: typeof fetch; seen: string[] } {
109
+ const tgz = tarGz([
110
+ { name: "remote/Chart.yaml", body: "apiVersion: v2\nname: remote\nversion: 1.2.3\n" },
111
+ { name: "remote/values.yaml", body: "enabled: true\nmode: fast\n" },
112
+ { name: "remote/charts/sub/Chart.yaml", body: "apiVersion: v2\nname: sub\nversion: 0.1.0\n" },
113
+ ]);
114
+ const index = `apiVersion: v1\nentries:\n remote:\n - version: 1.2.3\n urls: [remote-1.2.3.tgz]\n digest: ${createHash("sha256").update(tgz).digest("hex")}\n`;
115
+ const seen: string[] = [];
116
+ const fake = (async (input: string | URL | Request) => {
117
+ const url = String(input);
118
+ seen.push(url);
119
+ if (url === "https://charts.example.test/index.yaml") return new Response(index);
120
+ if (url === "https://charts.example.test/remote-1.2.3.tgz") return new Response(new Uint8Array(tgz));
121
+ return new Response("not found", { status: 404 });
122
+ }) as typeof fetch;
123
+ return { fetch: fake, seen };
124
+ }
125
+
126
+ function project(): string {
127
+ const root = mkdtempSync(join(tmpdir(), "chant-helm-codegen-"));
128
+ made.push(root);
129
+ mkdirSync(join(root, ".git"));
130
+ mkdirSync(join(root, "src"));
131
+ const edge = join(root, "charts", "edge");
132
+ mkdirSync(join(edge, "templates"), { recursive: true });
133
+ writeFileSync(join(edge, "Chart.yaml"), "apiVersion: v2\nname: edge\nversion: 0.3.0\n");
134
+ writeFileSync(join(edge, "values.yaml"), EDGE_VALUES);
135
+ writeFileSync(join(edge, "values.schema.json"), JSON.stringify(EDGE_SCHEMA, null, 2));
136
+ writeFileSync(join(edge, "templates", "configmap.yaml"), EDGE_TEMPLATE);
137
+ const plain = join(root, "charts", "plain");
138
+ mkdirSync(plain, { recursive: true });
139
+ writeFileSync(join(plain, "Chart.yaml"), "apiVersion: v2\nname: plain\nversion: 2.0.0\n");
140
+ writeFileSync(join(plain, "values.yaml"), PLAIN_VALUES);
141
+ writeFileSync(
142
+ join(root, "chant.config.ts"),
143
+ `export default {
144
+ lexicons: ["helm", "k8s"],
145
+ helm: {
146
+ charts: {
147
+ edge: { path: "charts/edge" },
148
+ "plain-app": { path: "charts/plain" },
149
+ remote: { repo: "https://charts.example.test", chart: "remote", version: "1.2.3" },
150
+ },
151
+ },
152
+ };
153
+ `,
154
+ );
155
+ symlinkSync(join(repoRoot, "node_modules"), join(root, "node_modules"), "dir");
156
+ return root;
157
+ }
158
+
159
+ async function config(root: string): Promise<Record<string, unknown>> {
160
+ return (await loadChantConfig(root)).config as unknown as Record<string, unknown>;
161
+ }
162
+
163
+ let previousProgram: ts.Program | undefined;
164
+
165
+ function typecheck(root: string, app: string): string[] {
166
+ writeFileSync(join(root, "src", "app.ts"), app);
167
+ const options: ts.CompilerOptions = {
168
+ strict: true,
169
+ noEmit: true,
170
+ skipLibCheck: true,
171
+ target: ts.ScriptTarget.ES2022,
172
+ module: ts.ModuleKind.ESNext,
173
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
174
+ customConditions: ["development"],
175
+ types: ["node"],
176
+ typeRoots: [join(repoRoot, "node_modules", "@types")],
177
+ };
178
+ const files = [join(root, "src", "app.ts"), join(root, "src", "generated", "helm", "index.ts")];
179
+ // Reusing the previous program skips re-parsing the lexicon sources.
180
+ const program = ts.createProgram(files, options, undefined, previousProgram);
181
+ previousProgram = program;
182
+ return files
183
+ .flatMap((f) => [...program.getSyntacticDiagnostics(program.getSourceFile(f)), ...program.getSemanticDiagnostics(program.getSourceFile(f))])
184
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
185
+ }
186
+
187
+ describe("typed Helm values (helm.charts)", () => {
188
+ test("factory names come from the chart key", () => {
189
+ expect(factoryBase("traefik")).toBe("Traefik");
190
+ expect(factoryBase("ingress-nginx")).toBe("IngressNginx");
191
+ expect(factoryBase("cert_manager")).toBe("CertManager");
192
+ });
193
+
194
+ test("generate writes a values type and a factory per chart, and the types catch bad values", async () => {
195
+ const root = project();
196
+ const repository = fakeRepository();
197
+ const result = await generateProjectCode(helmPlugin, root, await config(root), { fetch: repository.fetch });
198
+ expect(result?.files).toEqual(["index.ts"]);
199
+ expect(result?.summary).toEqual([
200
+ "EdgeRender (edge 0.3.0, values from values.schema.json)",
201
+ "PlainAppRender (plain 2.0.0, values from values.yaml)",
202
+ "RemoteRender (remote 1.2.3, values from values.yaml)",
203
+ ]);
204
+ expect(repository.seen).toEqual(["https://charts.example.test/index.yaml", "https://charts.example.test/remote-1.2.3.tgz"]);
205
+
206
+ const module = readFileSync(join(root, "src", "generated", "helm", "index.ts"), "utf8");
207
+ expect(module).toContain("export type EdgeValuesImage = {");
208
+ expect(module).toContain(" image?: EdgeValuesImage;");
209
+ expect(module).toContain(` "app.kubernetes.io/part-of"?: string;`);
210
+ expect(module).toContain(` logLevel?: "debug" | "info" | "error";`);
211
+ expect(module).toContain(" replicas?: number | string;");
212
+ expect(module).toContain(`chart: chartPath("../../../charts/edge"),`);
213
+ expect(module).toContain(`repo: "https://charts.example.test",`);
214
+ expect(module).toContain(`version: "1.2.3",`);
215
+ expect(module).toContain("export type RemoteValues = {\n enabled?: boolean;\n mode?: string;\n};");
216
+
217
+ const good = `import { EdgeRender, PlainAppRender, RemoteRender } from "./generated/helm";
218
+ export const edge = EdgeRender({
219
+ name: "edge",
220
+ namespace: "edge",
221
+ values: { image: { tag: "1.28" }, ports: { web: { port: 9000 } }, podLabels: { team: "a" }, "use-forwarded-headers": true },
222
+ });
223
+ export const plain = PlainAppRender({ name: "plain", values: { service: { port: 8080 }, resources: { limits: {} } } });
224
+ export const remote = RemoteRender({ name: "remote", values: { mode: "slow" } });
225
+ `;
226
+ expect(typecheck(root, good)).toEqual([]);
227
+
228
+ const bad = (values: string) => `import { EdgeRender, PlainAppRender } from "./generated/helm";
229
+ export const x = ${values.startsWith("plain:") ? `PlainAppRender({ name: "p", values: ${values.slice(6)} })` : `EdgeRender({ name: "e", values: ${values} })`};
230
+ `;
231
+ expect(typecheck(root, bad(`{ imgae: { tag: "x" } }`)).join("\n")).toMatch(/'imgae' does not exist/);
232
+ expect(typecheck(root, bad(`{ ports: { web: { prot: 1 } } }`)).join("\n")).toMatch(/'prot' does not exist/);
233
+ expect(typecheck(root, bad(`{ logLevel: "verbose" }`))).not.toEqual([]);
234
+ expect(typecheck(root, bad(`plain:{ service: { prot: 1 } }`)).join("\n")).toMatch(/'prot' does not exist/);
235
+ // The chart is fixed by the config, not by the caller.
236
+ expect(typecheck(root, bad(`{}, version: "9.9.9"`))).not.toEqual([]);
237
+ }, 120_000);
238
+
239
+ test("editing a local chart's schema without regenerating is drift; a remote chart is checked by its pin", async () => {
240
+ const root = project();
241
+ const repository = fakeRepository();
242
+ await generateProjectCode(helmPlugin, root, await config(root), { fetch: repository.fetch });
243
+ expect(await checkProjectCodegen(helmPlugin, root, await config(root))).toEqual({ status: "current" });
244
+
245
+ // The check is offline: the remote chart is not fetched again.
246
+ expect(repository.seen).toHaveLength(2);
247
+
248
+ const schemaPath = join(root, "charts", "edge", "values.schema.json");
249
+ writeFileSync(schemaPath, readFileSync(schemaPath, "utf8").replace('"error"', '"warn"'));
250
+ const check = await checkProjectCodegen(helmPlugin, root, await config(root));
251
+ expect(check.status).toBe("drift");
252
+ });
253
+
254
+ test("a remote chart needs a version and a repo or an oci reference", () => {
255
+ const codegen = helmProjectCodegen();
256
+ const ctx = (charts: Record<string, unknown>) => ({ projectRoot: "/p", outDir: "/p/out", config: { helm: { charts } } });
257
+ expect(() => codegen.inputs(ctx({ a: { repo: "https://x.test", chart: "a", version: "" } }))).toThrow(/needs a version/);
258
+ expect(() => codegen.inputs(ctx({ a: { chart: "a", version: "1.0.0" } }))).toThrow(/needs a repo, or must be an oci:\/\/ reference/);
259
+ expect(() => codegen.inputs(ctx({ "1a": { chart: "oci://x.test/a", version: "1.0.0" } }))).toThrow(/must start with a letter/);
260
+ });
261
+
262
+ test("an archive whose digest does not match the repository index is refused", async () => {
263
+ const codegen = helmProjectCodegen();
264
+ const tampered = (async (input: string | URL | Request) => {
265
+ const url = String(input);
266
+ if (url.endsWith("index.yaml")) {
267
+ return new Response(`entries:\n r:\n - version: 1.0.0\n urls: [r.tgz]\n digest: ${"0".repeat(64)}\n`);
268
+ }
269
+ return new Response(new Uint8Array(tarGz([{ name: "r/Chart.yaml", body: "name: r\nversion: 1.0.0\n" }])));
270
+ }) as typeof fetch;
271
+ await expect(
272
+ codegen.generate({
273
+ projectRoot: "/p",
274
+ outDir: "/p/out",
275
+ config: { helm: { charts: { r: { repo: "https://r.test", chart: "r", version: "1.0.0" } } } },
276
+ fetch: tampered,
277
+ }),
278
+ ).rejects.toThrow(/the repository index says 0{64}/);
279
+ });
280
+
281
+ test.skipIf(!helmOnPath())("a generated factory renders its local chart in a build", async () => {
282
+ const root = project();
283
+ // Only the local chart, so the build needs no network.
284
+ writeFileSync(
285
+ join(root, "chant.config.ts"),
286
+ `export default { lexicons: ["helm", "k8s"], helm: { charts: { edge: { path: "charts/edge" } } } };\n`,
287
+ );
288
+ await generateProjectCode(helmPlugin, root, await config(root));
289
+ writeFileSync(
290
+ join(root, "src", "app.ts"),
291
+ `import { EdgeRender } from "./generated/helm";\nexport const edge = EdgeRender({ name: "edge", namespace: "edge", noCache: true, values: { image: { repository: "nginx", tag: "1.28" }, logLevel: "debug" } });\n`,
292
+ );
293
+ const plugins = await loadPlugins(["helm", "k8s"]);
294
+ const output = join(root, "out.yaml");
295
+ const result = await buildCommand({
296
+ path: join(root, "src"),
297
+ output,
298
+ format: "yaml",
299
+ serializers: plugins.map((p) => p.serializer).filter((s) => s.name === "k8s"),
300
+ plugins,
301
+ });
302
+ expect(result.errors).toEqual([]);
303
+ const yamlOut = readFileSync(output, "utf8");
304
+ expect(yamlOut).toContain("image: nginx:1.28");
305
+ expect(yamlOut).toContain("logLevel: debug");
306
+ }, 120_000);
307
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Typed Helm values: the helm lexicon's `projectCodegen` hook (`chant
3
+ * generate`, core's project-codegen.ts).
4
+ *
5
+ * For each chart a project lists under `helm.charts`, generation reads the
6
+ * chart's `values.schema.json` (or, without one, its `values.yaml`) and writes
7
+ * to `<outDir>/helm/index.ts`:
8
+ *
9
+ * - `<Name>Values`, the values type: from the schema, with every member
10
+ * optional (the schema validates the values Helm merges with the chart's
11
+ * defaults, so a key it requires is usually already set), or inferred from
12
+ * `values.yaml` (./codegen/values-type.ts).
13
+ * - `<Name>Render(props)`, a factory that calls `HelmRender` with the repo,
14
+ * chart and version from the config, so the type and the version it was
15
+ * generated from cannot drift apart. It takes `HelmRender`'s other props.
16
+ *
17
+ * A remote chart is downloaded without the helm binary: an `oci://` chart
18
+ * through the registry API, a classic repository through its `index.yaml`.
19
+ * Both go through `ctx.fetch` when the caller passes one.
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { join, relative, resolve, sep } from "node:path";
25
+ import yaml from "js-yaml";
26
+ import { GENERATED_TS_HEADER, type ProjectCodegen, type ProjectCodegenContext } from "@intentius/chant/project-codegen";
27
+ import { jsdocLines, schemaToTypeScript } from "@intentius/chant/codegen/json-schema-to-ts";
28
+ import { fetchWithRetry } from "@intentius/chant/codegen/fetch";
29
+ import { pullOciChart, readTarGz } from "@intentius/chant-lexicon-k8s/crd/oci-chart";
30
+ import { CHART_KEY_PATTERN, type HelmProjectChart } from "./config";
31
+ import { inferValuesType } from "./codegen/values-type";
32
+
33
+ /** The chart files generation reads. */
34
+ export interface ChartFiles {
35
+ chartYaml: string;
36
+ valuesYaml?: string;
37
+ valuesSchema?: string;
38
+ }
39
+
40
+ type RemoteChart = Extract<HelmProjectChart, { chart: string }>;
41
+
42
+ /** Download a remote chart's files. Tests replace it. */
43
+ export type ChartPuller = (chart: RemoteChart, fetchImpl: typeof fetch | undefined) => Promise<ChartFiles>;
44
+
45
+ const CHART_FILES = ["Chart.yaml", "values.yaml", "values.schema.json"] as const;
46
+
47
+ /** The hook, with an injectable puller for remote charts. */
48
+ export function helmProjectCodegen(options: { pull?: ChartPuller } = {}): ProjectCodegen {
49
+ const pull = options.pull ?? pullRemoteChart;
50
+ return {
51
+ inputs(ctx) {
52
+ const charts = declaredCharts(ctx);
53
+ if (!charts) return undefined;
54
+ return Object.fromEntries(charts.map(([key, chart]) => [key, describeChart(key, chart, ctx.projectRoot)]));
55
+ },
56
+
57
+ async generate(ctx) {
58
+ const charts = declaredCharts(ctx) ?? [];
59
+ const blocks: string[] = [];
60
+ const summary: string[] = [];
61
+ for (const [key, chart] of charts) {
62
+ describeChart(key, chart, ctx.projectRoot); // validates before any download
63
+ const files = "path" in chart ? readLocalChart(resolve(ctx.projectRoot, chart.path)) : await pull(chart, ctx.fetch);
64
+ const generated = renderChart(key, chart, files, ctx);
65
+ blocks.push(generated.code);
66
+ summary.push(generated.summary);
67
+ }
68
+ const local = charts.some(([, chart]) => "path" in chart);
69
+ return { files: { "index.ts": renderModule(blocks, local) }, summary };
70
+ },
71
+ };
72
+ }
73
+
74
+ function declaredCharts(ctx: ProjectCodegenContext): Array<[string, HelmProjectChart]> | undefined {
75
+ const charts = (ctx.config.helm as { charts?: Record<string, HelmProjectChart> } | undefined)?.charts;
76
+ if (!charts || Object.keys(charts).length === 0) return undefined;
77
+ return Object.entries(charts).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
78
+ }
79
+
80
+ /**
81
+ * A chart as a JSON value for the drift digest: a local chart by the hashes
82
+ * of the files generation reads, a remote one by its pin.
83
+ */
84
+ function describeChart(key: string, chart: HelmProjectChart, projectRoot: string): Record<string, unknown> {
85
+ if (!CHART_KEY_PATTERN.test(key)) {
86
+ throw new Error(`helm.charts.${key}: the key names the generated factory, so it must start with a letter and hold only letters, digits, - and _`);
87
+ }
88
+ if ("path" in chart) {
89
+ const dir = resolve(projectRoot, chart.path);
90
+ if (!existsSync(join(dir, "Chart.yaml"))) throw new Error(`helm.charts.${key}: no Chart.yaml in ${chart.path}`);
91
+ const hashes: Record<string, string> = {};
92
+ for (const file of CHART_FILES) {
93
+ const path = join(dir, file);
94
+ if (existsSync(path)) hashes[file] = createHash("sha256").update(readFileSync(path)).digest("hex");
95
+ }
96
+ return { path: chart.path, files: hashes };
97
+ }
98
+ if (!chart.version) {
99
+ throw new Error(`helm.charts.${key}: ${chart.chart} needs a version. An unpinned chart makes the generated types depend on when they were generated.`);
100
+ }
101
+ if (!chart.repo && !chart.chart.startsWith("oci://")) {
102
+ throw new Error(`helm.charts.${key}: ${chart.chart} needs a repo, or must be an oci:// reference`);
103
+ }
104
+ return {
105
+ ...(chart.repo ? { repo: chart.repo } : {}),
106
+ chart: chart.chart,
107
+ version: chart.version,
108
+ ...(chart.digest ? { digest: chart.digest } : {}),
109
+ };
110
+ }
111
+
112
+ function readLocalChart(dir: string): ChartFiles {
113
+ const read = (f: string) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8") : undefined);
114
+ return { chartYaml: read("Chart.yaml")!, valuesYaml: read("values.yaml"), valuesSchema: read("values.schema.json") };
115
+ }
116
+
117
+ /** The top-level chart's files out of a chart archive (subcharts are ignored). */
118
+ export function chartFilesFromArchive(files: Map<string, Buffer>, label: string): ChartFiles {
119
+ const chartYamlPath = [...files.keys()].find((p) => p.split("/").length === 2 && p.endsWith("/Chart.yaml"));
120
+ if (!chartYamlPath) throw new Error(`${label}: the archive has no top-level Chart.yaml`);
121
+ const dir = chartYamlPath.split("/")[0];
122
+ const read = (f: string) => files.get(`${dir}/${f}`)?.toString("utf8");
123
+ return { chartYaml: read("Chart.yaml")!, valuesYaml: read("values.yaml"), valuesSchema: read("values.schema.json") };
124
+ }
125
+
126
+ /** Download a remote chart: OCI through the registry API, a classic repository through its index. */
127
+ export async function pullRemoteChart(chart: RemoteChart, fetchImpl: typeof fetch | undefined): Promise<ChartFiles> {
128
+ const label = `${chart.repo ? `${chart.repo} ` : ""}${chart.chart} ${chart.version}`;
129
+ if (!chart.repo) {
130
+ const http = fetchImpl ? { probe: fetchImpl, get: fetchImpl } : undefined;
131
+ const tgz = await pullOciChart(chart.chart, chart.version, chart.digest, http);
132
+ return chartFilesFromArchive(readTarGz(tgz), label);
133
+ }
134
+
135
+ const get = async (url: string): Promise<Response> => {
136
+ const response = fetchImpl ? await fetchImpl(url) : await fetchWithRetry(url);
137
+ if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
138
+ return response;
139
+ };
140
+ const base = chart.repo.endsWith("/") ? chart.repo : `${chart.repo}/`;
141
+ // Failsafe: every scalar stays a string, so a digest of all digits or a
142
+ // version like 1.10 is read as written.
143
+ const index = yaml.load(await (await get(new URL("index.yaml", base).href)).text(), { schema: yaml.FAILSAFE_SCHEMA }) as {
144
+ entries?: Record<string, Array<{ version?: string; urls?: string[]; digest?: string }>>;
145
+ };
146
+ const entry = index?.entries?.[chart.chart]?.find((e) => e.version === chart.version);
147
+ if (!entry?.urls?.length) throw new Error(`${label}: not in the repository's index.yaml`);
148
+ const tgz = Buffer.from(await (await get(new URL(entry.urls[0], base).href)).arrayBuffer());
149
+ const digest = createHash("sha256").update(tgz).digest("hex");
150
+ if (entry.digest && entry.digest !== digest) {
151
+ throw new Error(`${label}: the archive's sha256 is ${digest}, but the repository index says ${entry.digest}`);
152
+ }
153
+ if (chart.digest && chart.digest.replace(/^sha256:/, "") !== digest) {
154
+ throw new Error(`${label}: the archive's sha256 is ${digest}, but the config pins ${chart.digest}`);
155
+ }
156
+ return chartFilesFromArchive(readTarGz(tgz), label);
157
+ }
158
+
159
+ /** `ingress-nginx` -> `IngressNginx`. */
160
+ export function factoryBase(key: string): string {
161
+ return key
162
+ .split(/[^A-Za-z0-9]+/)
163
+ .filter(Boolean)
164
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
165
+ .join("");
166
+ }
167
+
168
+ function renderChart(
169
+ key: string,
170
+ chart: HelmProjectChart,
171
+ files: ChartFiles,
172
+ ctx: ProjectCodegenContext,
173
+ ): { code: string; summary: string } {
174
+ const meta = (yaml.load(files.chartYaml) ?? {}) as { name?: string; version?: string };
175
+ const base = factoryBase(key);
176
+ const valuesName = `${base}Values`;
177
+ const fnName = `${base}Render`;
178
+
179
+ const local = "path" in chart;
180
+ const chartName = local ? chart.path : chart.chart;
181
+ // A local chart is located from the generated module, so the render finds
182
+ // it whatever directory the build runs in.
183
+ const chartExpr = local
184
+ ? `chartPath(${JSON.stringify(relative(ctx.outDir, resolve(ctx.projectRoot, chart.path)).split(sep).join("/"))})`
185
+ : JSON.stringify(chart.chart);
186
+ const version = local ? meta.version : chart.version;
187
+ if (!version) throw new Error(`helm.charts.${key}: ${chartName} has no version in Chart.yaml`);
188
+ const repo = local ? "" : (chart.repo ?? "");
189
+ const from = local ? `the chart at ${chartName}` : `${chart.chart} ${version}${chart.repo ? ` from ${chart.repo}` : ""}`;
190
+
191
+ const lines: string[] = [];
192
+ let source: string;
193
+ if (files.valuesSchema) {
194
+ let schema: unknown;
195
+ try {
196
+ schema = JSON.parse(files.valuesSchema);
197
+ } catch (err) {
198
+ throw new Error(`helm.charts.${key}: values.schema.json does not parse: ${err instanceof Error ? err.message : String(err)}`);
199
+ }
200
+ const { type, declarations } = schemaToTypeScript(schema, { allOptional: true, namePrefix: valuesName, rootName: valuesName });
201
+ for (const d of declarations) lines.push(...jsdocLines(d.description, ""), `export type ${d.name} = ${d.type};`, "");
202
+ lines.push(`/** Values for ${from}, from its values.schema.json. */`, `export type ${valuesName} = ${type};`);
203
+ source = "values.schema.json";
204
+ } else {
205
+ let values: unknown;
206
+ try {
207
+ values = files.valuesYaml ? yaml.load(files.valuesYaml) : undefined;
208
+ } catch (err) {
209
+ throw new Error(`helm.charts.${key}: values.yaml does not parse: ${err instanceof Error ? err.message : String(err)}`);
210
+ }
211
+ lines.push(`/** Values for ${from}, inferred from its values.yaml defaults. */`, `export type ${valuesName} = ${inferValuesType(values)};`);
212
+ source = files.valuesYaml ? "values.yaml" : "no values file";
213
+ }
214
+
215
+ lines.push(
216
+ "",
217
+ `/** \`${fnName}\`'s props: \`HelmRender\`'s, with the chart fixed by chant.config and \`values\` typed. */`,
218
+ `export type ${fnName}Props = Omit<HelmRenderProps, "repo" | "chart" | "version" | "values"> & { values?: ${valuesName} };`,
219
+ "",
220
+ `/** Render ${from}. */`,
221
+ `export function ${fnName}(props: ${fnName}Props) {`,
222
+ " return HelmRender({",
223
+ " ...props,",
224
+ ` repo: ${JSON.stringify(repo)},`,
225
+ ` chart: ${chartExpr},`,
226
+ ` version: ${JSON.stringify(version)},`,
227
+ " values: props.values as Record<string, unknown> | undefined,",
228
+ " });",
229
+ "}",
230
+ );
231
+ return { code: lines.join("\n"), summary: `${fnName} (${meta.name ?? chartName} ${version}, values from ${source})` };
232
+ }
233
+
234
+ function renderModule(blocks: string[], local: boolean): string {
235
+ return [
236
+ GENERATED_TS_HEADER,
237
+ "// Typed render factories for the charts helm.charts declares in chant.config.",
238
+ ...(local ? ['import { relative } from "node:path";', 'import { fileURLToPath } from "node:url";'] : []),
239
+ 'import { HelmRender, type HelmRenderProps } from "@intentius/chant-lexicon-helm";',
240
+ ...(local
241
+ ? [
242
+ "",
243
+ "/** A chart directory given relative to this module, as a path from the working directory. */",
244
+ "function chartPath(fromHere: string): string {",
245
+ ' return relative(process.cwd(), fileURLToPath(new URL(fromHere, import.meta.url))) || ".";',
246
+ "}",
247
+ ]
248
+ : []),
249
+ "",
250
+ blocks.join("\n\n"),
251
+ "",
252
+ ].join("\n");
253
+ }
@@ -11,6 +11,8 @@ import { helmContentDigest, helmInputDigest, renderStability } from "./render-di
11
11
  import { loadRenderManifest } from "./render-store";
12
12
  import { clearValuesProbeRecords, getValuesProbeRecords } from "./values-probe";
13
13
  import { whm504 } from "./lint/post-synth/whm504";
14
+ import type { Declarable } from "@intentius/chant/declarable";
15
+ import { k8sSerializer } from "@intentius/chant-lexicon-k8s/serializer";
14
16
 
15
17
  const FIXTURE_DIR = join(tmpdir(), "chant-helm-render-fixture");
16
18
  const CHART_DIR = join(FIXTURE_DIR, "tiny-chart");
@@ -668,3 +670,117 @@ describe.skipIf(!fixtureAvailable)("coalesced-values probe wired into the render
668
670
  expect(diags[0].message).toContain("target no subchart");
669
671
  });
670
672
  });
673
+
674
+ /**
675
+ * Patches on a render (#3566), against a scripted `helm` double that prints
676
+ * a recorded render, so no real helm binary, chart or network is involved.
677
+ */
678
+ describe("HelmRender patches", () => {
679
+ const FAKE_BIN = join(tmpdir(), "chant-helm-render-patch-bin");
680
+ let origPath: string | undefined;
681
+
682
+ beforeAll(() => {
683
+ mkdirSync(FAKE_BIN, { recursive: true });
684
+ writeFileSync(
685
+ join(FAKE_BIN, "helm"),
686
+ `#!/bin/sh
687
+ cat <<'EOF'
688
+ ---
689
+ # Source: widget/templates/deployment.yaml
690
+ apiVersion: apps/v1
691
+ kind: Deployment
692
+ metadata:
693
+ name: rel-widget
694
+ namespace: widgets
695
+ spec:
696
+ replicas: 1
697
+ template:
698
+ spec:
699
+ containers:
700
+ - name: app
701
+ image: example.com/widget:1.0
702
+ ---
703
+ # Source: widget/templates/service.yaml
704
+ apiVersion: v1
705
+ kind: Service
706
+ metadata:
707
+ name: rel-widget
708
+ namespace: widgets
709
+ spec:
710
+ ports:
711
+ - port: 80
712
+ EOF
713
+ `,
714
+ { mode: 0o755 },
715
+ );
716
+ });
717
+
718
+ beforeEach(() => {
719
+ origPath = process.env.PATH;
720
+ process.env.PATH = FAKE_BIN + delimiter + (origPath ?? "");
721
+ clearHelmRenderRecords();
722
+ });
723
+
724
+ afterEach(() => {
725
+ process.env.PATH = origPath;
726
+ });
727
+
728
+ function memberProps(result: ReturnType<typeof HelmRender>, key: string): Record<string, unknown> {
729
+ const member = (result.members as Record<string, { props: Record<string, unknown> }>)[key];
730
+ expect(member).toBeDefined();
731
+ return member.props;
732
+ }
733
+
734
+ test("a merge patch and a JSON patch change the rendered documents before they become members", () => {
735
+ const result = HelmRender({
736
+ name: "rel",
737
+ chart: "/dev/null/widget",
738
+ noCache: true,
739
+ patches: [
740
+ { kind: "Deployment", name: "rel-widget", namespace: "widgets", merge: { spec: { replicas: 3 } } },
741
+ {
742
+ kind: "Service",
743
+ name: "rel-widget",
744
+ jsonPatch: [
745
+ { op: "test", path: "/spec/ports/0/port", value: 80 },
746
+ { op: "add", path: "/spec/ports/0/name", value: "http" },
747
+ { op: "add", path: "/metadata/annotations", value: { "example.com/scrape": "true" } },
748
+ ],
749
+ },
750
+ ],
751
+ } as Parameters<typeof HelmRender>[0]);
752
+
753
+ expect((memberProps(result, "Deployment_rel_widget").spec as { replicas: number }).replicas).toBe(3);
754
+ const service = memberProps(result, "Service_rel_widget");
755
+ expect((service.spec as { ports: unknown[] }).ports).toEqual([{ port: 80, name: "http" }]);
756
+ expect((service.metadata as { annotations: unknown }).annotations).toEqual({ "example.com/scrape": "true" });
757
+ });
758
+
759
+ test("the patched form is what the k8s serializer emits", () => {
760
+ const result = HelmRender({
761
+ name: "rel",
762
+ chart: "/dev/null/widget",
763
+ noCache: true,
764
+ patches: [{ kind: "Deployment", name: "rel-widget", merge: { spec: { replicas: 7 } } }],
765
+ } as Parameters<typeof HelmRender>[0]);
766
+ const entities = new Map(Object.entries(result.members as Record<string, Declarable>));
767
+ const out = k8sSerializer.serialize(entities);
768
+ const yamlText = typeof out === "string" ? out : out.primary;
769
+ expect(yamlText).toContain("replicas: 7");
770
+ expect(yamlText).not.toContain("replicas: 1");
771
+ });
772
+
773
+ test("a selector matching no rendered document fails the render, naming it", () => {
774
+ expect(() =>
775
+ HelmRender({
776
+ name: "rel",
777
+ chart: "/dev/null/widget",
778
+ version: "1.2.0",
779
+ noCache: true,
780
+ patches: [{ kind: "Deployment", name: "widget", merge: { spec: { replicas: 2 } } }],
781
+ } as Parameters<typeof HelmRender>[0]),
782
+ ).toThrow(
783
+ /HelmRender "rel" \(\/dev\/null\/widget@1\.2\.0\): patch 0 \(kind Deployment, name widget\) matched no document\. Deployment documents present: widgets\/rel-widget\./,
784
+ );
785
+ });
786
+ });
package/src/render.ts CHANGED
@@ -42,6 +42,7 @@ import { Composite } from "@intentius/chant";
42
42
  // arbitrary K8s manifests. The k8s serializer reads props.apiVersion and
43
43
  // props.kind verbatim when set, so the actual class doesn't matter.
44
44
  import { Deployment } from "@intentius/chant-lexicon-k8s/generated/index";
45
+ import { applyRenderedPatches, type RenderedPatch } from "@intentius/chant-lexicon-k8s/patch/rendered-patch";
45
46
  import yaml from "js-yaml";
46
47
 
47
48
  import { resolveCapabilityProfile, type HelmCapabilityProfile, type HelmCapabilityProfileRef } from "./config";
@@ -109,6 +110,18 @@ export interface HelmRenderProps {
109
110
  * means the manifest records `sourceRef: null`.
110
111
  */
111
112
  sourceRef?: string;
113
+ /**
114
+ * Patches applied to the rendered documents before they become entities
115
+ * (#3566): select by kind and name (namespace and apiVersion optional) and
116
+ * apply an RFC 6902 `jsonPatch` or an RFC 7386 `merge`. The patched form is
117
+ * what the build emits and what `lifecycle diff --live` compares. A
118
+ * selector matching no rendered document fails the build, so a chart
119
+ * upgrade that renames a resource is noticed.
120
+ *
121
+ * Patches act on the parsed render, after caching and digests: the render
122
+ * store and `contentDigest` still describe helm's own output.
123
+ */
124
+ patches?: RenderedPatch[];
112
125
  }
113
126
 
114
127
  // The invocation-record ledger lives in ./render-records.ts — a module with
@@ -427,7 +440,11 @@ export const HelmRender = Composite<HelmRenderProps>((props) => {
427
440
  const valuesProbe = profile ? runValuesProbeBestEffort(props) : undefined;
428
441
 
429
442
  const yamlText = profile ? loadOrRenderPinned(props, profile, valuesProbe) : loadOrRender(props, undefined);
430
- const docs = parseMultiDoc(yamlText);
443
+ const docs = applyRenderedPatches(
444
+ parseMultiDoc(yamlText),
445
+ props.patches,
446
+ `HelmRender "${props.name}" (${props.chart}${props.version ? `@${props.version}` : ""})`,
447
+ ) as RenderedDoc[];
431
448
 
432
449
  // Digests are recorded only for pinned renders (#1237). Profile presence
433
450
  // is the v1 gate: the classifier (#1234) needs the chart source on disk,