@intentius/chant-lexicon-helm 0.106.4 → 0.108.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/dist/codegen/values-type.d.ts +15 -0
- package/dist/codegen/values-type.d.ts.map +1 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +45 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/values-type.test.ts +69 -0
- package/src/codegen/values-type.ts +87 -0
- package/src/config.ts +46 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +5 -0
- package/src/project-codegen.e2e.test.ts +307 -0
- package/src/project-codegen.ts +253 -0
- package/src/render.test.ts +116 -0
- package/src/render.ts +18 -1
|
@@ -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 {
|
package/dist/config.d.ts.map
CHANGED
|
@@ -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;
|
|
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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAKtC,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAC1F,OAAO,EAAE,qBAAqB,EAAE,2BAA2B,EAAE,KAAK,gBAAgB,EAAE,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAQ/I,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAG/D,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGpI,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AACpF,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "0ffddd1e31bf974f249ef71ee511436edff394b8a9c830ae71c874bdba8a3e14",
|
|
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": "
|
|
39
|
+
"composite": "73f83be0747f857940551b69454e57a00388a775e07fe64292df42e1edcf86ac"
|
|
40
40
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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;
|
|
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>;
|
package/dist/render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;
|
|
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.
|
|
3
|
+
"version": "0.108.0",
|
|
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.
|
|
89
|
+
"@intentius/chant": "^0.108.0",
|
|
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
|
+
}
|
package/src/render.test.ts
CHANGED
|
@@ -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 =
|
|
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,
|