@intentius/chant-lexicon-helm 0.124.30 → 0.124.32
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/archive-render.js +76 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +113 -0
- package/dist/codegen/generate-cli.js +19 -0
- package/dist/codegen/generate.js +210 -0
- package/dist/codegen/naming.js +29 -0
- package/dist/codegen/package.js +44 -0
- package/dist/codegen/values-type.js +87 -0
- package/dist/commands.js +413 -0
- package/dist/components/builders.js +9 -0
- package/dist/components/capability-plugin.js +17 -0
- package/dist/components/helm-upgrade.js +109 -0
- package/dist/components/index.js +11 -0
- package/dist/composites/catalog.js +1119 -0
- package/dist/composites/helm-batch-job.js +148 -0
- package/dist/composites/helm-crd-lifecycle.js +145 -0
- package/dist/composites/helm-cron-job.js +130 -0
- package/dist/composites/helm-daemon-set.js +125 -0
- package/dist/composites/helm-external-secret.js +63 -0
- package/dist/composites/helm-library.js +24 -0
- package/dist/composites/helm-microservice.js +260 -0
- package/dist/composites/helm-monitored-service.js +188 -0
- package/dist/composites/helm-namespace-env.js +116 -0
- package/dist/composites/helm-secure-ingress.js +86 -0
- package/dist/composites/helm-stateful-service.js +160 -0
- package/dist/composites/helm-web-app.js +201 -0
- package/dist/composites/helm-worker.js +164 -0
- package/dist/composites/index.js +13 -0
- package/dist/config.js +161 -0
- package/dist/coverage.js +37 -0
- package/dist/deep-observe-hooks.js +19 -0
- package/dist/deep-observe.js +55 -0
- package/dist/describe-resources.js +119 -0
- package/dist/describe-stack-status.js +66 -0
- package/dist/detect.js +11 -0
- package/dist/generated/index.js +11 -0
- package/dist/helpers.js +84 -0
- package/dist/import/generator.js +292 -0
- package/dist/import/parser.js +141 -0
- package/dist/import/template-stripper.js +86 -0
- package/dist/index.js +55 -0
- package/dist/integrity.json +2 -2
- package/dist/intrinsics.js +482 -0
- package/dist/lint/audit-catalog.js +33 -0
- package/dist/lint/audit-lineage.js +53 -0
- package/dist/lint/post-synth/helm-helpers.js +60 -0
- package/dist/lint/post-synth/index.js +48 -0
- package/dist/lint/post-synth/whm005-no-empty-wrapper.js +47 -0
- package/dist/lint/post-synth/whm101.js +38 -0
- package/dist/lint/post-synth/whm102.js +26 -0
- package/dist/lint/post-synth/whm103.js +51 -0
- package/dist/lint/post-synth/whm104.js +29 -0
- package/dist/lint/post-synth/whm105.js +23 -0
- package/dist/lint/post-synth/whm201.js +29 -0
- package/dist/lint/post-synth/whm202.js +42 -0
- package/dist/lint/post-synth/whm203.js +32 -0
- package/dist/lint/post-synth/whm204.js +51 -0
- package/dist/lint/post-synth/whm301.js +31 -0
- package/dist/lint/post-synth/whm302.js +33 -0
- package/dist/lint/post-synth/whm401.js +52 -0
- package/dist/lint/post-synth/whm402.js +38 -0
- package/dist/lint/post-synth/whm403.js +38 -0
- package/dist/lint/post-synth/whm404.js +30 -0
- package/dist/lint/post-synth/whm405.js +47 -0
- package/dist/lint/post-synth/whm406.js +27 -0
- package/dist/lint/post-synth/whm407.js +72 -0
- package/dist/lint/post-synth/whm501.js +89 -0
- package/dist/lint/post-synth/whm502.js +85 -0
- package/dist/lint/post-synth/whm503.js +82 -0
- package/dist/lint/post-synth/whm504.js +47 -0
- package/dist/lint/rules/chart-metadata.js +53 -0
- package/dist/lint/rules/no-hardcoded-image.js +50 -0
- package/dist/lint/rules/values-no-helm-tpl.js +78 -0
- package/dist/lint/rules/values-no-secrets.js +65 -0
- package/dist/list-artifacts.js +134 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +40 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/cluster-probe.js +116 -0
- package/dist/op/activities/helm.js +449 -0
- package/dist/op/activities/index.js +10 -0
- package/dist/op/builders.js +45 -0
- package/dist/package-cli.js +21 -0
- package/dist/pinnability/actions.js +207 -0
- package/dist/pinnability/classify.js +416 -0
- package/dist/pinnability/conditions.js +0 -0
- package/dist/pinnability/index.js +16 -0
- package/dist/pinnability/localize.js +518 -0
- package/dist/pinnability/render-stream.js +53 -0
- package/dist/pinnability/values.js +151 -0
- package/dist/plugin.js +458 -0
- package/dist/project-codegen.js +215 -0
- package/dist/release-observe.js +287 -0
- package/dist/render-diff.js +279 -0
- package/dist/render-digest.js +0 -0
- package/dist/render-records.js +29 -0
- package/dist/render-store.js +401 -0
- package/dist/render-wrapper.js +164 -0
- package/dist/render.js +394 -0
- package/dist/resources.js +109 -0
- package/dist/serializer.js +903 -0
- package/dist/validate-cli.js +17 -0
- package/dist/validate.js +30 -0
- package/dist/values-probe.js +504 -0
- package/dist/wrapper-chart.js +95 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
|
@@ -0,0 +1,215 @@
|
|
|
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 { createHash } from "node:crypto";
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
24
|
+
import yaml from "js-yaml";
|
|
25
|
+
import { GENERATED_TS_HEADER } from "@intentius/chant/project-codegen";
|
|
26
|
+
import { jsdocLines, schemaToTypeScript } from "@intentius/chant/codegen/json-schema-to-ts";
|
|
27
|
+
import { fetchWithRetry } from "@intentius/chant/codegen/fetch";
|
|
28
|
+
import { pullOciChart, readTarGz } from "@intentius/chant-lexicon-k8s/crd/oci-chart";
|
|
29
|
+
import { CHART_KEY_PATTERN } from "./config.js";
|
|
30
|
+
import { inferValuesType } from "./codegen/values-type.js";
|
|
31
|
+
const CHART_FILES = ["Chart.yaml", "values.yaml", "values.schema.json"];
|
|
32
|
+
/** The hook, with an injectable puller for remote charts. */
|
|
33
|
+
export function helmProjectCodegen(options = {}) {
|
|
34
|
+
const pull = options.pull ?? pullRemoteChart;
|
|
35
|
+
return {
|
|
36
|
+
inputs(ctx) {
|
|
37
|
+
const charts = declaredCharts(ctx);
|
|
38
|
+
if (!charts)
|
|
39
|
+
return undefined;
|
|
40
|
+
return Object.fromEntries(charts.map(([key, chart]) => [key, describeChart(key, chart, ctx.projectRoot)]));
|
|
41
|
+
},
|
|
42
|
+
async generate(ctx) {
|
|
43
|
+
const charts = declaredCharts(ctx) ?? [];
|
|
44
|
+
const blocks = [];
|
|
45
|
+
const summary = [];
|
|
46
|
+
for (const [key, chart] of charts) {
|
|
47
|
+
describeChart(key, chart, ctx.projectRoot); // validates before any download
|
|
48
|
+
const files = "path" in chart ? readLocalChart(resolve(ctx.projectRoot, chart.path)) : await pull(chart, ctx.fetch);
|
|
49
|
+
const generated = renderChart(key, chart, files, ctx);
|
|
50
|
+
blocks.push(generated.code);
|
|
51
|
+
summary.push(generated.summary);
|
|
52
|
+
}
|
|
53
|
+
const local = charts.some(([, chart]) => "path" in chart);
|
|
54
|
+
return { files: { "index.ts": renderModule(blocks, local) }, summary };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function declaredCharts(ctx) {
|
|
59
|
+
const charts = ctx.config.helm?.charts;
|
|
60
|
+
if (!charts || Object.keys(charts).length === 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
return Object.entries(charts).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A chart as a JSON value for the drift digest: a local chart by the hashes
|
|
66
|
+
* of the files generation reads, a remote one by its pin.
|
|
67
|
+
*/
|
|
68
|
+
function describeChart(key, chart, projectRoot) {
|
|
69
|
+
if (!CHART_KEY_PATTERN.test(key)) {
|
|
70
|
+
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 _`);
|
|
71
|
+
}
|
|
72
|
+
if ("path" in chart) {
|
|
73
|
+
const dir = resolve(projectRoot, chart.path);
|
|
74
|
+
if (!existsSync(join(dir, "Chart.yaml")))
|
|
75
|
+
throw new Error(`helm.charts.${key}: no Chart.yaml in ${chart.path}`);
|
|
76
|
+
const hashes = {};
|
|
77
|
+
for (const file of CHART_FILES) {
|
|
78
|
+
const path = join(dir, file);
|
|
79
|
+
if (existsSync(path))
|
|
80
|
+
hashes[file] = createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
return { path: chart.path, files: hashes };
|
|
83
|
+
}
|
|
84
|
+
if (!chart.version) {
|
|
85
|
+
throw new Error(`helm.charts.${key}: ${chart.chart} needs a version. An unpinned chart makes the generated types depend on when they were generated.`);
|
|
86
|
+
}
|
|
87
|
+
if (!chart.repo && !chart.chart.startsWith("oci://")) {
|
|
88
|
+
throw new Error(`helm.charts.${key}: ${chart.chart} needs a repo, or must be an oci:// reference`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...(chart.repo ? { repo: chart.repo } : {}),
|
|
92
|
+
chart: chart.chart,
|
|
93
|
+
version: chart.version,
|
|
94
|
+
...(chart.digest ? { digest: chart.digest } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function readLocalChart(dir) {
|
|
98
|
+
const read = (f) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8") : undefined);
|
|
99
|
+
return { chartYaml: read("Chart.yaml"), valuesYaml: read("values.yaml"), valuesSchema: read("values.schema.json") };
|
|
100
|
+
}
|
|
101
|
+
/** The top-level chart's files out of a chart archive (subcharts are ignored). */
|
|
102
|
+
export function chartFilesFromArchive(files, label) {
|
|
103
|
+
const chartYamlPath = [...files.keys()].find((p) => p.split("/").length === 2 && p.endsWith("/Chart.yaml"));
|
|
104
|
+
if (!chartYamlPath)
|
|
105
|
+
throw new Error(`${label}: the archive has no top-level Chart.yaml`);
|
|
106
|
+
const dir = chartYamlPath.split("/")[0];
|
|
107
|
+
const read = (f) => files.get(`${dir}/${f}`)?.toString("utf8");
|
|
108
|
+
return { chartYaml: read("Chart.yaml"), valuesYaml: read("values.yaml"), valuesSchema: read("values.schema.json") };
|
|
109
|
+
}
|
|
110
|
+
/** Download a remote chart: OCI through the registry API, a classic repository through its index. */
|
|
111
|
+
export async function pullRemoteChart(chart, fetchImpl) {
|
|
112
|
+
const label = `${chart.repo ? `${chart.repo} ` : ""}${chart.chart} ${chart.version}`;
|
|
113
|
+
if (!chart.repo) {
|
|
114
|
+
const http = fetchImpl ? { probe: fetchImpl, get: fetchImpl } : undefined;
|
|
115
|
+
const tgz = await pullOciChart(chart.chart, chart.version, chart.digest, http);
|
|
116
|
+
return chartFilesFromArchive(readTarGz(tgz), label);
|
|
117
|
+
}
|
|
118
|
+
const get = async (url) => {
|
|
119
|
+
const response = fetchImpl ? await fetchImpl(url) : await fetchWithRetry(url);
|
|
120
|
+
if (!response.ok)
|
|
121
|
+
throw new Error(`${url}: HTTP ${response.status}`);
|
|
122
|
+
return response;
|
|
123
|
+
};
|
|
124
|
+
const base = chart.repo.endsWith("/") ? chart.repo : `${chart.repo}/`;
|
|
125
|
+
// Failsafe: every scalar stays a string, so a digest of all digits or a
|
|
126
|
+
// version like 1.10 is read as written.
|
|
127
|
+
const index = yaml.load(await (await get(new URL("index.yaml", base).href)).text(), { schema: yaml.FAILSAFE_SCHEMA });
|
|
128
|
+
const entry = index?.entries?.[chart.chart]?.find((e) => e.version === chart.version);
|
|
129
|
+
if (!entry?.urls?.length)
|
|
130
|
+
throw new Error(`${label}: not in the repository's index.yaml`);
|
|
131
|
+
const tgz = Buffer.from(await (await get(new URL(entry.urls[0], base).href)).arrayBuffer());
|
|
132
|
+
const digest = createHash("sha256").update(tgz).digest("hex");
|
|
133
|
+
if (entry.digest && entry.digest !== digest) {
|
|
134
|
+
throw new Error(`${label}: the archive's sha256 is ${digest}, but the repository index says ${entry.digest}`);
|
|
135
|
+
}
|
|
136
|
+
if (chart.digest && chart.digest.replace(/^sha256:/, "") !== digest) {
|
|
137
|
+
throw new Error(`${label}: the archive's sha256 is ${digest}, but the config pins ${chart.digest}`);
|
|
138
|
+
}
|
|
139
|
+
return chartFilesFromArchive(readTarGz(tgz), label);
|
|
140
|
+
}
|
|
141
|
+
/** `ingress-nginx` -> `IngressNginx`. */
|
|
142
|
+
export function factoryBase(key) {
|
|
143
|
+
return key
|
|
144
|
+
.split(/[^A-Za-z0-9]+/)
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
147
|
+
.join("");
|
|
148
|
+
}
|
|
149
|
+
function renderChart(key, chart, files, ctx) {
|
|
150
|
+
const meta = (yaml.load(files.chartYaml) ?? {});
|
|
151
|
+
const base = factoryBase(key);
|
|
152
|
+
const valuesName = `${base}Values`;
|
|
153
|
+
const fnName = `${base}Render`;
|
|
154
|
+
const local = "path" in chart;
|
|
155
|
+
const chartName = local ? chart.path : chart.chart;
|
|
156
|
+
// A local chart is located from the generated module, so the render finds
|
|
157
|
+
// it whatever directory the build runs in.
|
|
158
|
+
const chartExpr = local
|
|
159
|
+
? `chartPath(${JSON.stringify(relative(ctx.outDir, resolve(ctx.projectRoot, chart.path)).split(sep).join("/"))})`
|
|
160
|
+
: JSON.stringify(chart.chart);
|
|
161
|
+
const version = local ? meta.version : chart.version;
|
|
162
|
+
if (!version)
|
|
163
|
+
throw new Error(`helm.charts.${key}: ${chartName} has no version in Chart.yaml`);
|
|
164
|
+
const repo = local ? "" : (chart.repo ?? "");
|
|
165
|
+
const from = local ? `the chart at ${chartName}` : `${chart.chart} ${version}${chart.repo ? ` from ${chart.repo}` : ""}`;
|
|
166
|
+
const lines = [];
|
|
167
|
+
let source;
|
|
168
|
+
if (files.valuesSchema) {
|
|
169
|
+
let schema;
|
|
170
|
+
try {
|
|
171
|
+
schema = JSON.parse(files.valuesSchema);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
throw new Error(`helm.charts.${key}: values.schema.json does not parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
175
|
+
}
|
|
176
|
+
const { type, declarations } = schemaToTypeScript(schema, { allOptional: true, namePrefix: valuesName, rootName: valuesName });
|
|
177
|
+
for (const d of declarations)
|
|
178
|
+
lines.push(...jsdocLines(d.description, ""), `export type ${d.name} = ${d.type};`, "");
|
|
179
|
+
lines.push(`/** Values for ${from}, from its values.schema.json. */`, `export type ${valuesName} = ${type};`);
|
|
180
|
+
source = "values.schema.json";
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
let values;
|
|
184
|
+
try {
|
|
185
|
+
values = files.valuesYaml ? yaml.load(files.valuesYaml) : undefined;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
throw new Error(`helm.charts.${key}: values.yaml does not parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push(`/** Values for ${from}, inferred from its values.yaml defaults. */`, `export type ${valuesName} = ${inferValuesType(values)};`);
|
|
191
|
+
source = files.valuesYaml ? "values.yaml" : "no values file";
|
|
192
|
+
}
|
|
193
|
+
lines.push("", `/** \`${fnName}\`'s props: \`HelmRender\`'s, with the chart fixed by chant.config and \`values\` typed. */`, `export type ${fnName}Props = Omit<HelmRenderProps, "repo" | "chart" | "version" | "values"> & { values?: ${valuesName} };`, "", `/** Render ${from}. */`, `export function ${fnName}(props: ${fnName}Props) {`, " return HelmRender({", " ...props,", ` repo: ${JSON.stringify(repo)},`, ` chart: ${chartExpr},`, ` version: ${JSON.stringify(version)},`, " values: props.values as Record<string, unknown> | undefined,", " });", "}");
|
|
194
|
+
return { code: lines.join("\n"), summary: `${fnName} (${meta.name ?? chartName} ${version}, values from ${source})` };
|
|
195
|
+
}
|
|
196
|
+
function renderModule(blocks, local) {
|
|
197
|
+
return [
|
|
198
|
+
GENERATED_TS_HEADER,
|
|
199
|
+
"// Typed render factories for the charts helm.charts declares in chant.config.",
|
|
200
|
+
...(local ? ['import { relative } from "node:path";', 'import { fileURLToPath } from "node:url";'] : []),
|
|
201
|
+
'import { HelmRender, type HelmRenderProps } from "@intentius/chant-lexicon-helm";',
|
|
202
|
+
...(local
|
|
203
|
+
? [
|
|
204
|
+
"",
|
|
205
|
+
"/** A chart directory given relative to this module, as a path from the working directory. */",
|
|
206
|
+
"function chartPath(fromHere: string): string {",
|
|
207
|
+
' return relative(process.cwd(), fileURLToPath(new URL(fromHere, import.meta.url))) || ".";',
|
|
208
|
+
"}",
|
|
209
|
+
]
|
|
210
|
+
: []),
|
|
211
|
+
"",
|
|
212
|
+
blocks.join("\n\n"),
|
|
213
|
+
"",
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-scoped observation plumbing, shared by the helm lexicon's
|
|
3
|
+
* `describeResources` (#1246) and `observeResourcesDeep` (#1247).
|
|
4
|
+
*
|
|
5
|
+
* The helm lexicon's runtime unit is the release: the chant project declares
|
|
6
|
+
* a `Helm::Chart`, `helm upgrade --install` turns it into a release, and the
|
|
7
|
+
* release stores the rendered manifests it applied. Observation therefore
|
|
8
|
+
* resolves each declared chart to its release (via `helm list`, the same
|
|
9
|
+
* read `listArtifacts` has always used) and reads what the release holds.
|
|
10
|
+
*
|
|
11
|
+
* Two channels, both mandatory (#1246): `helm get manifest` reports the
|
|
12
|
+
* non-hook documents and `helm get hooks` reports the hook resources —
|
|
13
|
+
* hooks are excluded from the manifest channel, so reading one channel
|
|
14
|
+
* would report every hook resource as drift.
|
|
15
|
+
*
|
|
16
|
+
* Failure discipline is the observation contract (#1089): a missing helm
|
|
17
|
+
* binary or an unreachable cluster is NOT-OBSERVED with a total reason,
|
|
18
|
+
* never a clean empty result that classifies as N creates. Only a release
|
|
19
|
+
* that `helm list` was asked about and did not report is an absence.
|
|
20
|
+
*
|
|
21
|
+
* Cluster selection follows #1488, same as `list-artifacts.ts` and
|
|
22
|
+
* `describe-stack-status.ts`: the environment's declared k8s binding
|
|
23
|
+
* (`k8s.profiles.<env>.context`) rides as `--kube-context` when present;
|
|
24
|
+
* ambient otherwise.
|
|
25
|
+
*/
|
|
26
|
+
import { exec } from "node:child_process";
|
|
27
|
+
import { promisify } from "node:util";
|
|
28
|
+
import { loadAll } from "js-yaml";
|
|
29
|
+
import { loadChantConfigUpward } from "@intentius/chant/config";
|
|
30
|
+
import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
|
|
31
|
+
import { gvkToTypeName } from "@intentius/chant-lexicon-k8s/spec/parse";
|
|
32
|
+
// `helm get manifest` for a large chart is several megabytes, past node's
|
|
33
|
+
// default 1MiB exec buffer — the same truncation `helm-upgrade.ts` hit live.
|
|
34
|
+
const execP = promisify(exec);
|
|
35
|
+
const defaultRunner = (command) => execP(command, { maxBuffer: 64 * 1024 * 1024 });
|
|
36
|
+
export { defaultRunner as defaultHelmRunner };
|
|
37
|
+
export const HELM_CHART_ENTITY_TYPE = "Helm::Chart";
|
|
38
|
+
export const HELM_RELEASE_TYPE = "Helm::Release";
|
|
39
|
+
/** Shell-quote one argv element, same convention as `helm-upgrade.ts`. */
|
|
40
|
+
export function q(arg) {
|
|
41
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The environment's kube context (#1488): the declared binding when present,
|
|
45
|
+
* ambient otherwise. Unresolvable config is ambient, chant's own fallback —
|
|
46
|
+
* identical to `list-artifacts.ts`.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveHelmContext(environment) {
|
|
49
|
+
try {
|
|
50
|
+
const { config } = await loadChantConfigUpward(process.cwd());
|
|
51
|
+
return (await resolveClusterTarget(config, environment, "helm")).context;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Total verdict for a failed helm invocation (#1089/#1246). A missing binary
|
|
59
|
+
* and a missing/unusable kubeconfig are both `no-credentials` — the read had
|
|
60
|
+
* no usable way to reach the target. Anything else the CLI refused is
|
|
61
|
+
* `read-failed`.
|
|
62
|
+
*/
|
|
63
|
+
export function classifyHelmFailure(err) {
|
|
64
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
65
|
+
const code = err?.code;
|
|
66
|
+
if (code === "ENOENT" ||
|
|
67
|
+
code === 127 ||
|
|
68
|
+
/command not found|not recognized|ENOENT/i.test(message)) {
|
|
69
|
+
return { reason: "no-credentials", detail: "helm binary not found on PATH" };
|
|
70
|
+
}
|
|
71
|
+
if (/kubeconfig|kubernetes cluster unreachable|unauthorized|forbidden|no configuration has been provided|connection refused/i.test(message)) {
|
|
72
|
+
return { reason: "no-credentials", detail: firstLine(message) };
|
|
73
|
+
}
|
|
74
|
+
return { reason: "read-failed", detail: firstLine(message) };
|
|
75
|
+
}
|
|
76
|
+
function firstLine(message) {
|
|
77
|
+
const line = message.split("\n").find((l) => l.trim().length > 0);
|
|
78
|
+
return (line ?? message).trim();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Kinds that are cluster-scoped, so a document without `metadata.namespace`
|
|
82
|
+
* must not inherit the release namespace. Well-known built-ins only; a
|
|
83
|
+
* cluster-scoped CRD instance this set does not name defaults to the release
|
|
84
|
+
* namespace, which affects the row's address, never its verdict.
|
|
85
|
+
*/
|
|
86
|
+
export const CLUSTER_SCOPED_KINDS = new Set([
|
|
87
|
+
"Namespace",
|
|
88
|
+
"CustomResourceDefinition",
|
|
89
|
+
"ClusterRole",
|
|
90
|
+
"ClusterRoleBinding",
|
|
91
|
+
"StorageClass",
|
|
92
|
+
"PriorityClass",
|
|
93
|
+
"IngressClass",
|
|
94
|
+
"PersistentVolume",
|
|
95
|
+
"ValidatingWebhookConfiguration",
|
|
96
|
+
"MutatingWebhookConfiguration",
|
|
97
|
+
"APIService",
|
|
98
|
+
"RuntimeClass",
|
|
99
|
+
"CSIDriver",
|
|
100
|
+
"CSINode",
|
|
101
|
+
]);
|
|
102
|
+
/** The declared chart entities, with the release name each one deploys as. */
|
|
103
|
+
export function chartEntities(entities) {
|
|
104
|
+
const charts = [];
|
|
105
|
+
for (const [entityName, entity] of entities) {
|
|
106
|
+
if (entity.entityType !== HELM_CHART_ENTITY_TYPE)
|
|
107
|
+
continue;
|
|
108
|
+
const name = entity.props?.name;
|
|
109
|
+
charts.push({ entityName, release: typeof name === "string" && name ? name : entityName });
|
|
110
|
+
}
|
|
111
|
+
return charts;
|
|
112
|
+
}
|
|
113
|
+
function parseStack(stack) {
|
|
114
|
+
const slash = stack.indexOf("/");
|
|
115
|
+
return slash > 0
|
|
116
|
+
? { namespace: stack.slice(0, slash), release: stack.slice(slash + 1) }
|
|
117
|
+
: { release: stack };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Find the list entry for one declared chart. Release identity resolves in
|
|
121
|
+
* order: the deploy unit (`stack`), a release named after the chart, then a
|
|
122
|
+
* unique release whose `chart` field is `<chartName>-<version>` — the release
|
|
123
|
+
* installed from this chart under another name. Ambiguity resolves to
|
|
124
|
+
* nothing: two candidate releases are not an identity.
|
|
125
|
+
*/
|
|
126
|
+
function findRelease(entries, release, wantNamespace, chartName) {
|
|
127
|
+
const named = entries.filter((e) => e.name === release && (wantNamespace === undefined || e.namespace === wantNamespace));
|
|
128
|
+
if (named.length === 1)
|
|
129
|
+
return { entry: named[0] };
|
|
130
|
+
if (named.length > 1) {
|
|
131
|
+
// The same release name in several namespaces is not an identity — and
|
|
132
|
+
// not an absence either. Say which namespaces so the caller can scope
|
|
133
|
+
// the read with a deploy unit (`<namespace>/<release>`).
|
|
134
|
+
return { ambiguous: named.map((e) => e.namespace).join(", ") };
|
|
135
|
+
}
|
|
136
|
+
const byChart = entries.filter((e) => typeof e.chart === "string" && new RegExp(`^${escapeRegExp(chartName)}-\\d`).test(e.chart));
|
|
137
|
+
return byChart.length === 1 ? { entry: byChart[0] } : undefined;
|
|
138
|
+
}
|
|
139
|
+
function escapeRegExp(s) {
|
|
140
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
141
|
+
}
|
|
142
|
+
/** Parse one channel's multi-document YAML stream into per-resource rows. */
|
|
143
|
+
export function parseReleaseDocuments(text, channel, releaseNamespace) {
|
|
144
|
+
const rows = [];
|
|
145
|
+
let docs;
|
|
146
|
+
try {
|
|
147
|
+
docs = loadAll(text);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return rows;
|
|
151
|
+
}
|
|
152
|
+
for (const raw of docs) {
|
|
153
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
154
|
+
continue;
|
|
155
|
+
const doc = raw;
|
|
156
|
+
const apiVersion = typeof doc.apiVersion === "string" ? doc.apiVersion : undefined;
|
|
157
|
+
const kind = typeof doc.kind === "string" ? doc.kind : undefined;
|
|
158
|
+
const metadata = doc.metadata;
|
|
159
|
+
const name = typeof metadata?.name === "string" ? metadata.name : undefined;
|
|
160
|
+
if (!apiVersion || !kind || !name)
|
|
161
|
+
continue;
|
|
162
|
+
const clusterScoped = CLUSTER_SCOPED_KINDS.has(kind) && typeof metadata?.namespace !== "string";
|
|
163
|
+
const namespace = clusterScoped
|
|
164
|
+
? undefined
|
|
165
|
+
: typeof metadata?.namespace === "string"
|
|
166
|
+
? metadata.namespace
|
|
167
|
+
: releaseNamespace; // helm applies the release namespace to namespace-silent documents
|
|
168
|
+
const slash = apiVersion.indexOf("/");
|
|
169
|
+
const entityType = gvkToTypeName({
|
|
170
|
+
group: slash > 0 ? apiVersion.slice(0, slash) : "",
|
|
171
|
+
version: slash > 0 ? apiVersion.slice(slash + 1) : apiVersion,
|
|
172
|
+
kind,
|
|
173
|
+
});
|
|
174
|
+
const annotations = metadata?.annotations ?? {};
|
|
175
|
+
const hookAnnotation = annotations["helm.sh/hook"];
|
|
176
|
+
const hook = channel === "hooks" && typeof hookAnnotation === "string"
|
|
177
|
+
? {
|
|
178
|
+
hook: hookAnnotation,
|
|
179
|
+
...(typeof annotations["helm.sh/hook-weight"] === "string"
|
|
180
|
+
? { weight: annotations["helm.sh/hook-weight"] }
|
|
181
|
+
: {}),
|
|
182
|
+
...(typeof annotations["helm.sh/hook-delete-policy"] === "string"
|
|
183
|
+
? { deletePolicy: annotations["helm.sh/hook-delete-policy"] }
|
|
184
|
+
: {}),
|
|
185
|
+
}
|
|
186
|
+
: undefined;
|
|
187
|
+
rows.push({
|
|
188
|
+
key: namespace ? `${kind}/${namespace}/${name}` : `cluster:${kind}/${name}`,
|
|
189
|
+
apiVersion,
|
|
190
|
+
kind,
|
|
191
|
+
name,
|
|
192
|
+
...(namespace ? { namespace } : {}),
|
|
193
|
+
channel,
|
|
194
|
+
entityType,
|
|
195
|
+
doc,
|
|
196
|
+
...(hook ? { hook } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return rows;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolve every declared chart to its release and read both channels.
|
|
203
|
+
* Never throws for a per-release failure; a whole-read failure (the `helm
|
|
204
|
+
* list` itself) lands every chart entity in `unobserved` with one reason.
|
|
205
|
+
*/
|
|
206
|
+
export async function observeReleases(options, run = defaultRunner) {
|
|
207
|
+
const charts = chartEntities(options.entities);
|
|
208
|
+
const result = { releases: [], absent: [], unobserved: {}, queried: {} };
|
|
209
|
+
if (charts.length === 0)
|
|
210
|
+
return result;
|
|
211
|
+
const stack = options.stack ? parseStack(options.stack) : undefined;
|
|
212
|
+
// The deploy unit names the release when the project has one chart — the
|
|
213
|
+
// component flow deploys each chart under the step's `release`, which need
|
|
214
|
+
// not equal the chart name.
|
|
215
|
+
if (stack && charts.length === 1)
|
|
216
|
+
charts[0].release = stack.release;
|
|
217
|
+
const context = await resolveHelmContext(options.environment);
|
|
218
|
+
const ctxFlag = context ? ` --kube-context ${q(context)}` : "";
|
|
219
|
+
const listCommand = `helm list -A -o json${ctxFlag}`;
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
const { stdout } = await run(listCommand);
|
|
223
|
+
const parsed = JSON.parse(stdout);
|
|
224
|
+
if (!Array.isArray(parsed))
|
|
225
|
+
throw new Error("helm list returned non-array JSON");
|
|
226
|
+
entries = parsed;
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
const { reason, detail } = classifyHelmFailure(err);
|
|
230
|
+
for (const { entityName } of charts) {
|
|
231
|
+
result.unobserved[entityName] = { type: HELM_CHART_ENTITY_TYPE, reason, detail };
|
|
232
|
+
result.queried[entityName] = listCommand;
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
for (const { entityName, release } of charts) {
|
|
237
|
+
const found = findRelease(entries, release, stack?.namespace, release);
|
|
238
|
+
if (found && "ambiguous" in found) {
|
|
239
|
+
result.unobserved[entityName] = {
|
|
240
|
+
type: HELM_CHART_ENTITY_TYPE,
|
|
241
|
+
reason: "read-failed",
|
|
242
|
+
detail: `release "${release}" exists in several namespaces (${found.ambiguous}) — scope the read with a <namespace>/<release> deploy unit`,
|
|
243
|
+
};
|
|
244
|
+
result.queried[entityName] = listCommand;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const entry = found?.entry;
|
|
248
|
+
if (!entry || !entry.name || !entry.namespace) {
|
|
249
|
+
result.absent.push(entityName);
|
|
250
|
+
result.queried[entityName] = listCommand;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const base = `-n ${q(entry.namespace)}${ctxFlag}`;
|
|
254
|
+
const manifestCommand = `helm get manifest ${q(entry.name)} ${base}`;
|
|
255
|
+
const hooksCommand = `helm get hooks ${q(entry.name)} ${base}`;
|
|
256
|
+
result.queried[entityName] = manifestCommand;
|
|
257
|
+
let manifestText;
|
|
258
|
+
let hooksText;
|
|
259
|
+
try {
|
|
260
|
+
({ stdout: manifestText } = await run(manifestCommand));
|
|
261
|
+
({ stdout: hooksText } = await run(hooksCommand));
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
// The release exists but what it holds could not be read — an
|
|
265
|
+
// unreachable release is NOT-OBSERVED with a reason, never absent and
|
|
266
|
+
// never a clean row that claims nothing drifted (#1246).
|
|
267
|
+
const { reason, detail } = classifyHelmFailure(err);
|
|
268
|
+
result.unobserved[entityName] = { type: HELM_CHART_ENTITY_TYPE, reason, detail };
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
result.releases.push({
|
|
272
|
+
entityName,
|
|
273
|
+
release: entry.name,
|
|
274
|
+
namespace: entry.namespace,
|
|
275
|
+
...(entry.status ? { status: entry.status } : {}),
|
|
276
|
+
...(entry.revision ? { revision: entry.revision } : {}),
|
|
277
|
+
...(entry.chart ? { chart: entry.chart } : {}),
|
|
278
|
+
...(entry.app_version ? { appVersion: entry.app_version } : {}),
|
|
279
|
+
...(entry.updated ? { updated: entry.updated } : {}),
|
|
280
|
+
resources: [
|
|
281
|
+
...parseReleaseDocuments(manifestText, "manifest", entry.namespace),
|
|
282
|
+
...parseReleaseDocuments(hooksText, "hooks", entry.namespace),
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|