@intentius/chant-lexicon-helm 0.45.0 → 0.49.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.
Files changed (118) hide show
  1. package/dist/archive-render.d.ts +94 -0
  2. package/dist/archive-render.d.ts.map +1 -0
  3. package/dist/commands.d.ts +54 -0
  4. package/dist/commands.d.ts.map +1 -0
  5. package/dist/config.d.ts +135 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +2 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +54 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +17 -0
  12. package/dist/describe-resources.d.ts.map +1 -0
  13. package/dist/index.d.ts +18 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/integrity.json +5 -3
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/post-synth/index.d.ts.map +1 -1
  18. package/dist/lint/post-synth/whm503.d.ts +34 -0
  19. package/dist/lint/post-synth/whm503.d.ts.map +1 -0
  20. package/dist/lint/post-synth/whm504.d.ts +20 -0
  21. package/dist/lint/post-synth/whm504.d.ts.map +1 -0
  22. package/dist/manifest.json +1 -1
  23. package/dist/okf/index.md +2 -0
  24. package/dist/okf/rules/WHM503.md +15 -0
  25. package/dist/okf/rules/WHM504.md +11 -0
  26. package/dist/okf/types/Values.md +1 -0
  27. package/dist/op/activities/cluster-probe.d.ts +44 -0
  28. package/dist/op/activities/cluster-probe.d.ts.map +1 -0
  29. package/dist/op/activities/helm.d.ts +179 -5
  30. package/dist/op/activities/helm.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +4 -2
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/pinnability/actions.d.ts +76 -0
  34. package/dist/pinnability/actions.d.ts.map +1 -0
  35. package/dist/pinnability/classify.d.ts +129 -0
  36. package/dist/pinnability/classify.d.ts.map +1 -0
  37. package/dist/pinnability/conditions.d.ts +109 -0
  38. package/dist/pinnability/conditions.d.ts.map +1 -0
  39. package/dist/pinnability/index.d.ts +17 -0
  40. package/dist/pinnability/index.d.ts.map +1 -0
  41. package/dist/pinnability/localize.d.ts +124 -0
  42. package/dist/pinnability/localize.d.ts.map +1 -0
  43. package/dist/pinnability/render-stream.d.ts +27 -0
  44. package/dist/pinnability/render-stream.d.ts.map +1 -0
  45. package/dist/pinnability/values.d.ts +69 -0
  46. package/dist/pinnability/values.d.ts.map +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/release-observe.d.ts +109 -0
  49. package/dist/release-observe.d.ts.map +1 -0
  50. package/dist/render-diff.d.ts +192 -0
  51. package/dist/render-diff.d.ts.map +1 -0
  52. package/dist/render-digest.d.ts +135 -0
  53. package/dist/render-digest.d.ts.map +1 -0
  54. package/dist/render-store.d.ts +310 -0
  55. package/dist/render-store.d.ts.map +1 -0
  56. package/dist/render-wrapper.d.ts +140 -0
  57. package/dist/render-wrapper.d.ts.map +1 -0
  58. package/dist/render.d.ts +81 -0
  59. package/dist/render.d.ts.map +1 -1
  60. package/dist/rules/whm503.ts +101 -0
  61. package/dist/rules/whm504.ts +52 -0
  62. package/dist/skills/chant-helm.md +7 -0
  63. package/dist/values-probe.d.ts +177 -0
  64. package/dist/values-probe.d.ts.map +1 -0
  65. package/dist/wrapper-chart.d.ts +61 -0
  66. package/dist/wrapper-chart.d.ts.map +1 -0
  67. package/package.json +10 -4
  68. package/src/archive-render.test.ts +201 -0
  69. package/src/archive-render.ts +119 -0
  70. package/src/commands.test.ts +487 -0
  71. package/src/commands.ts +445 -0
  72. package/src/config.test.ts +130 -0
  73. package/src/config.ts +220 -0
  74. package/src/deep-observe-hooks.ts +20 -0
  75. package/src/deep-observe.test.ts +165 -0
  76. package/src/deep-observe.ts +121 -0
  77. package/src/describe-resources.test.ts +255 -0
  78. package/src/describe-resources.ts +198 -0
  79. package/src/index.ts +98 -2
  80. package/src/lifecycle-integration.test.ts +289 -0
  81. package/src/lint/audit-catalog.ts +2 -0
  82. package/src/lint/post-synth/index.ts +4 -0
  83. package/src/lint/post-synth/whm503.test.ts +134 -0
  84. package/src/lint/post-synth/whm503.ts +101 -0
  85. package/src/lint/post-synth/whm504.test.ts +105 -0
  86. package/src/lint/post-synth/whm504.ts +52 -0
  87. package/src/op/activities/cluster-probe.test.ts +66 -0
  88. package/src/op/activities/cluster-probe.ts +148 -0
  89. package/src/op/activities/helm.test.ts +824 -0
  90. package/src/op/activities/helm.ts +628 -5
  91. package/src/op/activities/index.ts +17 -2
  92. package/src/pinnability/actions.ts +263 -0
  93. package/src/pinnability/classify.ts +579 -0
  94. package/src/pinnability/conditions.ts +0 -0
  95. package/src/pinnability/index.ts +77 -0
  96. package/src/pinnability/localize.test.ts +234 -0
  97. package/src/pinnability/localize.ts +644 -0
  98. package/src/pinnability/pinnability.test.ts +560 -0
  99. package/src/pinnability/render-stream.ts +59 -0
  100. package/src/pinnability/values.ts +205 -0
  101. package/src/plugin.test.ts +3 -1
  102. package/src/plugin.ts +47 -0
  103. package/src/release-observe.ts +392 -0
  104. package/src/render-diff.test.ts +366 -0
  105. package/src/render-diff.ts +407 -0
  106. package/src/render-digest.test.ts +186 -0
  107. package/src/render-digest.ts +207 -0
  108. package/src/render-store.test.ts +412 -0
  109. package/src/render-store.ts +621 -0
  110. package/src/render-wrapper.test.ts +259 -0
  111. package/src/render-wrapper.ts +274 -0
  112. package/src/render.test.ts +383 -4
  113. package/src/render.ts +319 -8
  114. package/src/skills/chant-helm.md +7 -0
  115. package/src/values-probe.test.ts +400 -0
  116. package/src/values-probe.ts +627 -0
  117. package/src/wrapper-chart.test.ts +110 -0
  118. package/src/wrapper-chart.ts +124 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Chart model and value coalescing for the pinnability classifier (#1234,
3
+ * epic #1228).
4
+ *
5
+ * Pinnability is a property of `(chart, values)`, not of the chart alone —
6
+ * so the classifier needs each subchart instance's EFFECTIVE values to ask
7
+ * whether a gate is open. This module walks `Chart.yaml` dependencies
8
+ * (aliases and `condition:` included — finding 12: dependency conditions are
9
+ * values-driven and therefore closed inputs) and approximates helm's
10
+ * coalescing: child defaults, overridden by the parent's `<alias-or-name>`
11
+ * subtree, with `global` propagated down.
12
+ *
13
+ * The approximation is deliberate and bounded. `import-values`, `tags`, and
14
+ * `tpl`-computed values are not modeled; a gate they would decide resolves
15
+ * as UNKNOWN and the classifier refuses rather than pins (see
16
+ * conditions.ts). The build-time values probe (#1251) replaces this with
17
+ * helm's own coalescing.
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import yaml from "js-yaml";
23
+
24
+ export interface ChartDependency {
25
+ name: string;
26
+ alias?: string;
27
+ /** Comma-separated values paths, evaluated against the ROOT values. */
28
+ condition?: string;
29
+ }
30
+
31
+ export interface ChartMeta {
32
+ name: string;
33
+ dependencies: ChartDependency[];
34
+ }
35
+
36
+ export function readChartMeta(chartDir: string): ChartMeta {
37
+ const raw = readYamlFile(join(chartDir, "Chart.yaml")) as
38
+ | { name?: unknown; dependencies?: unknown }
39
+ | undefined;
40
+ const deps: ChartDependency[] = [];
41
+ if (Array.isArray(raw?.dependencies)) {
42
+ for (const d of raw.dependencies) {
43
+ if (d === null || typeof d !== "object") continue;
44
+ const dep = d as Record<string, unknown>;
45
+ if (typeof dep.name !== "string") continue;
46
+ deps.push({
47
+ name: dep.name,
48
+ alias: typeof dep.alias === "string" ? dep.alias : undefined,
49
+ condition: typeof dep.condition === "string" ? dep.condition : undefined,
50
+ });
51
+ }
52
+ }
53
+ return {
54
+ name: typeof raw?.name === "string" ? raw.name : "",
55
+ dependencies: deps,
56
+ };
57
+ }
58
+
59
+ export function readYamlFile(path: string): unknown {
60
+ if (!existsSync(path)) return undefined;
61
+ return yaml.load(readFileSync(path, "utf8"));
62
+ }
63
+
64
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
65
+ return v !== null && typeof v === "object" && !Array.isArray(v);
66
+ }
67
+
68
+ /**
69
+ * Helm-style deep merge: `override` wins, maps merge recursively, a `null`
70
+ * override deletes the key, arrays replace wholesale.
71
+ */
72
+ export function mergeValues(base: unknown, override: unknown): unknown {
73
+ if (override === undefined) return base;
74
+ if (!isPlainObject(base) || !isPlainObject(override)) return override;
75
+ const out: Record<string, unknown> = { ...base };
76
+ for (const [k, v] of Object.entries(override)) {
77
+ if (v === null) {
78
+ delete out[k];
79
+ continue;
80
+ }
81
+ out[k] = k in out ? mergeValues(out[k], v) : v;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Resolve a dot path against a values tree; undefined when absent. */
87
+ export function valueAtPath(values: unknown, path: string[]): unknown {
88
+ let v = values;
89
+ for (const seg of path) {
90
+ if (!isPlainObject(v)) return undefined;
91
+ v = v[seg];
92
+ }
93
+ return v;
94
+ }
95
+
96
+ /**
97
+ * One chart in the tree, with the values IT sees. An aliased dependency
98
+ * yields two instances over the same directory — each with its own value
99
+ * scope, which is why findings are evaluated per instance but reported per
100
+ * file.
101
+ */
102
+ export interface ChartInstance {
103
+ /** Directory of the (sub)chart. */
104
+ dir: string;
105
+ /** Directory relative to the root chart ("" for the root). */
106
+ relDir: string;
107
+ /** Values scope from the root, e.g. ["grafana"] or ["kidtwo"]; [] for root. */
108
+ scope: readonly string[];
109
+ /** Effective values as this instance sees them. */
110
+ values: unknown;
111
+ /**
112
+ * When a `condition:` on the dependency chain disables this instance under
113
+ * the supplied values: the first condition path that resolved false, in
114
+ * root coordinates. Constructs inside a disabled instance are gated by it.
115
+ */
116
+ disabledBy?: string;
117
+ }
118
+
119
+ /**
120
+ * Build the instance tree for a chart directory and the values the render
121
+ * was given. `suppliedValues` merges over the root chart's `values.yaml`
122
+ * exactly like `helm template --values`.
123
+ */
124
+ export function buildChartInstances(
125
+ chartDir: string,
126
+ suppliedValues?: unknown,
127
+ ): { instances: ChartInstance[]; warnings: string[] } {
128
+ const warnings: string[] = [];
129
+ const rootDefaults = readYamlFile(join(chartDir, "values.yaml")) ?? {};
130
+ const rootValues = mergeValues(rootDefaults, suppliedValues);
131
+ const instances: ChartInstance[] = [];
132
+
133
+ const visit = (
134
+ dir: string,
135
+ relDir: string,
136
+ scope: readonly string[],
137
+ values: unknown,
138
+ disabledBy: string | undefined,
139
+ ): void => {
140
+ instances.push({ dir, relDir, scope, values, disabledBy });
141
+ const meta = readChartMeta(dir);
142
+ const chartsDir = join(dir, "charts");
143
+ const childDirs = existsSync(chartsDir)
144
+ ? readdirSync(chartsDir).filter((e) => {
145
+ const p = join(chartsDir, e);
146
+ return statSync(p).isDirectory() && existsSync(join(p, "Chart.yaml"));
147
+ })
148
+ : [];
149
+ const declared = new Set<string>();
150
+
151
+ const visitChild = (dep: ChartDependency): void => {
152
+ const key = dep.alias ?? dep.name;
153
+ const childDir = join(chartsDir, dep.name);
154
+ if (!existsSync(join(childDir, "Chart.yaml"))) {
155
+ warnings.push(
156
+ `dependency ${dep.name} of ${relDir === "" ? "the chart" : relDir} is not vendored under charts/ — not scanned`,
157
+ );
158
+ return;
159
+ }
160
+ declared.add(dep.name);
161
+ // Condition paths are resolved against the TOP parent's values
162
+ // (helm's documented semantics); first present path wins, absent
163
+ // conditions leave the dependency enabled.
164
+ let childDisabledBy = disabledBy;
165
+ if (childDisabledBy === undefined && dep.condition !== undefined) {
166
+ for (const rawPath of dep.condition.split(",")) {
167
+ const path = rawPath.trim();
168
+ if (path === "") continue;
169
+ const v = valueAtPath(rootValues, path.split("."));
170
+ if (v === undefined) continue;
171
+ if (v === false) childDisabledBy = path;
172
+ break;
173
+ }
174
+ }
175
+ const childDefaults = readYamlFile(join(childDir, "values.yaml")) ?? {};
176
+ const parentSlice = isPlainObject(values) ? values[key] : undefined;
177
+ let childValues = mergeValues(childDefaults, parentSlice);
178
+ // Globals propagate into every subchart tree.
179
+ const parentGlobal = isPlainObject(values) ? values.global : undefined;
180
+ if (parentGlobal !== undefined && isPlainObject(childValues)) {
181
+ childValues = {
182
+ ...childValues,
183
+ global: mergeValues(childValues.global, parentGlobal),
184
+ };
185
+ }
186
+ visit(
187
+ childDir,
188
+ relDir === "" ? `charts/${dep.name}` : `${relDir}/charts/${dep.name}`,
189
+ [...scope, key],
190
+ childValues,
191
+ childDisabledBy,
192
+ );
193
+ };
194
+
195
+ for (const dep of meta.dependencies) visitChild(dep);
196
+ // charts/ directories not declared in Chart.yaml are implicit,
197
+ // always-enabled dependencies.
198
+ for (const name of childDirs) {
199
+ if (!declared.has(name)) visitChild({ name });
200
+ }
201
+ };
202
+
203
+ visit(chartDir, "", [], rootValues, undefined);
204
+ return { instances, warnings };
205
+ }
@@ -38,12 +38,14 @@ describe("helmPlugin", () => {
38
38
 
39
39
  test("provides post-synth checks", () => {
40
40
  const checks = helmPlugin.postSynthChecks!();
41
- expect(checks.length).toBe(21);
41
+ expect(checks.length).toBe(23);
42
42
  const ids = checks.map((c) => c.id);
43
43
  expect(ids).toContain("WHM005");
44
44
  expect(ids).toContain("WHM101");
45
45
  expect(ids).toContain("WHM105");
46
46
  expect(ids).toContain("WHM301");
47
+ expect(ids).toContain("WHM503");
48
+ expect(ids).toContain("WHM504");
47
49
  });
48
50
 
49
51
  test("detectTemplate identifies Chart.yaml data", () => {
package/src/plugin.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { LexiconPlugin, IntrinsicDef, InitTemplateSet } from "@intentius/chant/lexicon";
9
+ import type { CommandGroup } from "@intentius/chant/cli/command-group";
10
+ import { helmCommandGroup } from "./commands";
9
11
  import { detectTemplate } from "./detect";
10
12
  import { discoverLintRules } from "@intentius/chant/lint/discover";
11
13
  import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
@@ -16,12 +18,35 @@ import { fileURLToPath } from "url";
16
18
  import { helmSerializer } from "./serializer";
17
19
  import { helmCompletions } from "./lsp/completions";
18
20
  import { helmHover } from "./lsp/hover";
21
+ import { helmConfigSchema } from "./config";
22
+ import { helmDeepNormalizationHooks } from "./deep-observe-hooks";
23
+ import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
19
24
 
20
25
  export const helmPlugin: LexiconPlugin = {
21
26
  name: "helm",
27
+ // The `helm` namespace in chant.config.ts — capability profiles (#1235).
28
+ // Declaring the schema makes core validate the namespace at load, so a
29
+ // typo'd profile field fails the build instead of silently unpinning.
30
+ configSchema: helmConfigSchema,
31
+ // #1246 — helm resolves real ownership verdicts on the thin read (every row
32
+ // is release-scoped, so helm-managed = owned via release identity), and the
33
+ // deep read (#1247) delegates to the k8s reader, which resolves the shared
34
+ // label channel (`app.kubernetes.io/managed-by` + `chant.intentius.io/*`)
35
+ // on the live objects.
36
+ ownershipChannel: { keys: LABEL_OWNERSHIP_KEYS, reads: ["describeResources", "observeResourcesDeep"] },
22
37
  auditCatalog: () => helmAuditCatalog,
23
38
  serializer: helmSerializer,
24
39
 
40
+ // #1248 (epic #1228 Phase 6, via the #1078 seam) — `chant helm` mounts the
41
+ // pinned-render surface: the pinnability gate (#1234), the double-render
42
+ // localizer (#1236), and the recorded-render listing with digests (#1237).
43
+ // See ./commands.ts for the verb list; this plugin only mounts it. The
44
+ // diff verbs (#1249 render-to-render, #1250 render-to-live, both mounted on
45
+ // "diff") land there.
46
+ commands(): CommandGroup {
47
+ return helmCommandGroup();
48
+ },
49
+
25
50
  lintRules() {
26
51
  const rulesDir = join(dirname(fileURLToPath(import.meta.url)), "lint", "rules");
27
52
  return discoverLintRules(rulesDir, import.meta.url);
@@ -383,6 +408,28 @@ export const service = new Service({
383
408
  return listArtifacts(options);
384
409
  },
385
410
 
411
+ // #1246 — per-resource observation of what each declared chart's release
412
+ // holds (`helm get manifest` + `helm get hooks`, both channels), with total
413
+ // tri-state verdicts. This is what gives `lifecycle diff --live` helm rows.
414
+ async describeResources(options) {
415
+ const { describeResources } = await import("./describe-resources");
416
+ return describeResources(options);
417
+ },
418
+
419
+ // #1247 — deep property-level trees for the release's resources, read live
420
+ // through the k8s lexicon's typed client and normalized by its hooks.
421
+ // Dynamic import for the same reason k8s's is: the reader reaches
422
+ // `@intentius/chant-k8s-client`, which must stay off the build path.
423
+ async observeResourcesDeep(options) {
424
+ const { observeResourcesDeepHelm } = await import("./deep-observe");
425
+ return observeResourcesDeepHelm(options);
426
+ },
427
+
428
+ // The k8s lexicon's hooks by reference, not a copy (#1247) — the objects a
429
+ // release deploys are Kubernetes objects, and core normalizes the declared
430
+ // tree with the same rules the reader used.
431
+ deepNormalizationHooks: helmDeepNormalizationHooks,
432
+
386
433
  // #1495 piece 4 — the deploy-unit observer for helm-upgrade units: a
387
434
  // release's presence and native status, read back by the same name the
388
435
  // capability deploys under. Dynamic import mirrors listArtifacts.
@@ -0,0 +1,392 @@
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 type { UnobservedEntity, UnobservedReason } from "@intentius/chant/lexicon";
30
+ import { loadChantConfigUpward } from "@intentius/chant/config";
31
+ import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
32
+ import { gvkToTypeName } from "@intentius/chant-lexicon-k8s/spec/parse";
33
+
34
+ // `helm get manifest` for a large chart is several megabytes, past node's
35
+ // default 1MiB exec buffer — the same truncation `helm-upgrade.ts` hit live.
36
+ const execP = promisify(exec);
37
+ const defaultRunner = (command: string): Promise<{ stdout: string }> =>
38
+ execP(command, { maxBuffer: 64 * 1024 * 1024 });
39
+
40
+ /** Injectable command runner, so tests drive every branch without helm or a cluster. */
41
+ export type HelmRunner = (command: string) => Promise<{ stdout: string }>;
42
+
43
+ export { defaultRunner as defaultHelmRunner };
44
+
45
+ export const HELM_CHART_ENTITY_TYPE = "Helm::Chart";
46
+ export const HELM_RELEASE_TYPE = "Helm::Release";
47
+
48
+ /** Shell-quote one argv element, same convention as `helm-upgrade.ts`. */
49
+ export function q(arg: string): string {
50
+ return `'${arg.replace(/'/g, "'\\''")}'`;
51
+ }
52
+
53
+ /**
54
+ * The environment's kube context (#1488): the declared binding when present,
55
+ * ambient otherwise. Unresolvable config is ambient, chant's own fallback —
56
+ * identical to `list-artifacts.ts`.
57
+ */
58
+ export async function resolveHelmContext(environment: string): Promise<string | undefined> {
59
+ try {
60
+ const { config } = await loadChantConfigUpward(process.cwd());
61
+ return (await resolveClusterTarget(config as Record<string, unknown>, environment, "helm")).context;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Total verdict for a failed helm invocation (#1089/#1246). A missing binary
69
+ * and a missing/unusable kubeconfig are both `no-credentials` — the read had
70
+ * no usable way to reach the target. Anything else the CLI refused is
71
+ * `read-failed`.
72
+ */
73
+ export function classifyHelmFailure(err: unknown): { reason: UnobservedReason; detail: string } {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ const code = (err as { code?: string | number } | null)?.code;
76
+ if (
77
+ code === "ENOENT" ||
78
+ code === 127 ||
79
+ /command not found|not recognized|ENOENT/i.test(message)
80
+ ) {
81
+ return { reason: "no-credentials", detail: "helm binary not found on PATH" };
82
+ }
83
+ if (/kubeconfig|kubernetes cluster unreachable|unauthorized|forbidden|no configuration has been provided|connection refused/i.test(message)) {
84
+ return { reason: "no-credentials", detail: firstLine(message) };
85
+ }
86
+ return { reason: "read-failed", detail: firstLine(message) };
87
+ }
88
+
89
+ function firstLine(message: string): string {
90
+ const line = message.split("\n").find((l) => l.trim().length > 0);
91
+ return (line ?? message).trim();
92
+ }
93
+
94
+ interface HelmListEntry {
95
+ name?: string;
96
+ namespace?: string;
97
+ revision?: string;
98
+ updated?: string;
99
+ status?: string;
100
+ chart?: string;
101
+ app_version?: string;
102
+ }
103
+
104
+ /**
105
+ * Kinds that are cluster-scoped, so a document without `metadata.namespace`
106
+ * must not inherit the release namespace. Well-known built-ins only; a
107
+ * cluster-scoped CRD instance this set does not name defaults to the release
108
+ * namespace, which affects the row's address, never its verdict.
109
+ */
110
+ export const CLUSTER_SCOPED_KINDS = new Set([
111
+ "Namespace",
112
+ "CustomResourceDefinition",
113
+ "ClusterRole",
114
+ "ClusterRoleBinding",
115
+ "StorageClass",
116
+ "PriorityClass",
117
+ "IngressClass",
118
+ "PersistentVolume",
119
+ "ValidatingWebhookConfiguration",
120
+ "MutatingWebhookConfiguration",
121
+ "APIService",
122
+ "RuntimeClass",
123
+ "CSIDriver",
124
+ "CSINode",
125
+ ]);
126
+
127
+ /** One rendered document a release holds, from either channel. */
128
+ export interface ParsedReleaseResource {
129
+ /**
130
+ * Row key, following the k8s runtime-sweep convention
131
+ * (`<Kind>/<namespace>/<name>`, `cluster:<Kind>/<name>`) so a helm row and
132
+ * a k8s row for the same object spell the same identity.
133
+ */
134
+ key: string;
135
+ apiVersion: string;
136
+ kind: string;
137
+ name: string;
138
+ namespace?: string;
139
+ /** Which read reported it — hooks are invisible to the manifest channel. */
140
+ channel: "manifest" | "hooks";
141
+ /** chant's k8s entity type for the document's GVK (`K8s::Apps::Deployment`). */
142
+ entityType: string;
143
+ /** The document itself, minus nothing — the release's stored declaration. */
144
+ doc: Record<string, unknown>;
145
+ /** `helm.sh/hook*` annotations, for hook-channel rows. */
146
+ hook?: { hook: string; weight?: string; deletePolicy?: string };
147
+ }
148
+
149
+ /** One declared chart resolved to a live release, with what the release holds. */
150
+ export interface ObservedRelease {
151
+ entityName: string;
152
+ release: string;
153
+ namespace: string;
154
+ status?: string;
155
+ revision?: string;
156
+ chart?: string;
157
+ appVersion?: string;
158
+ updated?: string;
159
+ resources: ParsedReleaseResource[];
160
+ }
161
+
162
+ export interface ReleaseObservation {
163
+ /** Charts whose release was found and fully read. */
164
+ releases: ObservedRelease[];
165
+ /** Chart entities whose release `helm list` did not report — a real absence. */
166
+ absent: string[];
167
+ /** Chart entities that could not be read, with a total reason (#1089). */
168
+ unobserved: Record<string, UnobservedEntity>;
169
+ /** The helm command issued per chart entity (#1620). */
170
+ queried: Record<string, string>;
171
+ }
172
+
173
+ export interface ReleaseObserveOptions {
174
+ environment: string;
175
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
176
+ /** Deploy unit, `<namespace>/<release>` or `<release>` (see `describeStackStatus`). */
177
+ stack?: string;
178
+ }
179
+
180
+ /** The declared chart entities, with the release name each one deploys as. */
181
+ export function chartEntities(
182
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
183
+ ): Array<{ entityName: string; release: string }> {
184
+ const charts: Array<{ entityName: string; release: string }> = [];
185
+ for (const [entityName, entity] of entities) {
186
+ if (entity.entityType !== HELM_CHART_ENTITY_TYPE) continue;
187
+ const name = entity.props?.name;
188
+ charts.push({ entityName, release: typeof name === "string" && name ? name : entityName });
189
+ }
190
+ return charts;
191
+ }
192
+
193
+ function parseStack(stack: string): { namespace?: string; release: string } {
194
+ const slash = stack.indexOf("/");
195
+ return slash > 0
196
+ ? { namespace: stack.slice(0, slash), release: stack.slice(slash + 1) }
197
+ : { release: stack };
198
+ }
199
+
200
+ /**
201
+ * Find the list entry for one declared chart. Release identity resolves in
202
+ * order: the deploy unit (`stack`), a release named after the chart, then a
203
+ * unique release whose `chart` field is `<chartName>-<version>` — the release
204
+ * installed from this chart under another name. Ambiguity resolves to
205
+ * nothing: two candidate releases are not an identity.
206
+ */
207
+ function findRelease(
208
+ entries: HelmListEntry[],
209
+ release: string,
210
+ wantNamespace: string | undefined,
211
+ chartName: string,
212
+ ): { entry: HelmListEntry } | { ambiguous: string } | undefined {
213
+ const named = entries.filter(
214
+ (e) => e.name === release && (wantNamespace === undefined || e.namespace === wantNamespace),
215
+ );
216
+ if (named.length === 1) return { entry: named[0] };
217
+ if (named.length > 1) {
218
+ // The same release name in several namespaces is not an identity — and
219
+ // not an absence either. Say which namespaces so the caller can scope
220
+ // the read with a deploy unit (`<namespace>/<release>`).
221
+ return { ambiguous: named.map((e) => e.namespace).join(", ") };
222
+ }
223
+
224
+ const byChart = entries.filter(
225
+ (e) => typeof e.chart === "string" && new RegExp(`^${escapeRegExp(chartName)}-\\d`).test(e.chart),
226
+ );
227
+ return byChart.length === 1 ? { entry: byChart[0] } : undefined;
228
+ }
229
+
230
+ function escapeRegExp(s: string): string {
231
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
232
+ }
233
+
234
+ /** Parse one channel's multi-document YAML stream into per-resource rows. */
235
+ export function parseReleaseDocuments(
236
+ text: string,
237
+ channel: "manifest" | "hooks",
238
+ releaseNamespace: string,
239
+ ): ParsedReleaseResource[] {
240
+ const rows: ParsedReleaseResource[] = [];
241
+ let docs: unknown[];
242
+ try {
243
+ docs = loadAll(text);
244
+ } catch {
245
+ return rows;
246
+ }
247
+ for (const raw of docs) {
248
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
249
+ const doc = raw as Record<string, unknown>;
250
+ const apiVersion = typeof doc.apiVersion === "string" ? doc.apiVersion : undefined;
251
+ const kind = typeof doc.kind === "string" ? doc.kind : undefined;
252
+ const metadata = doc.metadata as { name?: unknown; namespace?: unknown; annotations?: Record<string, unknown> } | undefined;
253
+ const name = typeof metadata?.name === "string" ? metadata.name : undefined;
254
+ if (!apiVersion || !kind || !name) continue;
255
+
256
+ const clusterScoped = CLUSTER_SCOPED_KINDS.has(kind) && typeof metadata?.namespace !== "string";
257
+ const namespace = clusterScoped
258
+ ? undefined
259
+ : typeof metadata?.namespace === "string"
260
+ ? metadata.namespace
261
+ : releaseNamespace; // helm applies the release namespace to namespace-silent documents
262
+
263
+ const slash = apiVersion.indexOf("/");
264
+ const entityType = gvkToTypeName({
265
+ group: slash > 0 ? apiVersion.slice(0, slash) : "",
266
+ version: slash > 0 ? apiVersion.slice(slash + 1) : apiVersion,
267
+ kind,
268
+ });
269
+
270
+ const annotations = metadata?.annotations ?? {};
271
+ const hookAnnotation = annotations["helm.sh/hook"];
272
+ const hook =
273
+ channel === "hooks" && typeof hookAnnotation === "string"
274
+ ? {
275
+ hook: hookAnnotation,
276
+ ...(typeof annotations["helm.sh/hook-weight"] === "string"
277
+ ? { weight: annotations["helm.sh/hook-weight"] as string }
278
+ : {}),
279
+ ...(typeof annotations["helm.sh/hook-delete-policy"] === "string"
280
+ ? { deletePolicy: annotations["helm.sh/hook-delete-policy"] as string }
281
+ : {}),
282
+ }
283
+ : undefined;
284
+
285
+ rows.push({
286
+ key: namespace ? `${kind}/${namespace}/${name}` : `cluster:${kind}/${name}`,
287
+ apiVersion,
288
+ kind,
289
+ name,
290
+ ...(namespace ? { namespace } : {}),
291
+ channel,
292
+ entityType,
293
+ doc,
294
+ ...(hook ? { hook } : {}),
295
+ });
296
+ }
297
+ return rows;
298
+ }
299
+
300
+ /**
301
+ * Resolve every declared chart to its release and read both channels.
302
+ * Never throws for a per-release failure; a whole-read failure (the `helm
303
+ * list` itself) lands every chart entity in `unobserved` with one reason.
304
+ */
305
+ export async function observeReleases(
306
+ options: ReleaseObserveOptions,
307
+ run: HelmRunner = defaultRunner,
308
+ ): Promise<ReleaseObservation> {
309
+ const charts = chartEntities(options.entities);
310
+ const result: ReleaseObservation = { releases: [], absent: [], unobserved: {}, queried: {} };
311
+ if (charts.length === 0) return result;
312
+
313
+ const stack = options.stack ? parseStack(options.stack) : undefined;
314
+ // The deploy unit names the release when the project has one chart — the
315
+ // component flow deploys each chart under the step's `release`, which need
316
+ // not equal the chart name.
317
+ if (stack && charts.length === 1) charts[0].release = stack.release;
318
+
319
+ const context = await resolveHelmContext(options.environment);
320
+ const ctxFlag = context ? ` --kube-context ${q(context)}` : "";
321
+
322
+ const listCommand = `helm list -A -o json${ctxFlag}`;
323
+ let entries: HelmListEntry[];
324
+ try {
325
+ const { stdout } = await run(listCommand);
326
+ const parsed: unknown = JSON.parse(stdout);
327
+ if (!Array.isArray(parsed)) throw new Error("helm list returned non-array JSON");
328
+ entries = parsed as HelmListEntry[];
329
+ } catch (err) {
330
+ const { reason, detail } = classifyHelmFailure(err);
331
+ for (const { entityName } of charts) {
332
+ result.unobserved[entityName] = { type: HELM_CHART_ENTITY_TYPE, reason, detail };
333
+ result.queried[entityName] = listCommand;
334
+ }
335
+ return result;
336
+ }
337
+
338
+ for (const { entityName, release } of charts) {
339
+ const found = findRelease(entries, release, stack?.namespace, release);
340
+ if (found && "ambiguous" in found) {
341
+ result.unobserved[entityName] = {
342
+ type: HELM_CHART_ENTITY_TYPE,
343
+ reason: "read-failed",
344
+ detail: `release "${release}" exists in several namespaces (${found.ambiguous}) — scope the read with a <namespace>/<release> deploy unit`,
345
+ };
346
+ result.queried[entityName] = listCommand;
347
+ continue;
348
+ }
349
+ const entry = found?.entry;
350
+ if (!entry || !entry.name || !entry.namespace) {
351
+ result.absent.push(entityName);
352
+ result.queried[entityName] = listCommand;
353
+ continue;
354
+ }
355
+
356
+ const base = `-n ${q(entry.namespace)}${ctxFlag}`;
357
+ const manifestCommand = `helm get manifest ${q(entry.name)} ${base}`;
358
+ const hooksCommand = `helm get hooks ${q(entry.name)} ${base}`;
359
+ result.queried[entityName] = manifestCommand;
360
+
361
+ let manifestText: string;
362
+ let hooksText: string;
363
+ try {
364
+ ({ stdout: manifestText } = await run(manifestCommand));
365
+ ({ stdout: hooksText } = await run(hooksCommand));
366
+ } catch (err) {
367
+ // The release exists but what it holds could not be read — an
368
+ // unreachable release is NOT-OBSERVED with a reason, never absent and
369
+ // never a clean row that claims nothing drifted (#1246).
370
+ const { reason, detail } = classifyHelmFailure(err);
371
+ result.unobserved[entityName] = { type: HELM_CHART_ENTITY_TYPE, reason, detail };
372
+ continue;
373
+ }
374
+
375
+ result.releases.push({
376
+ entityName,
377
+ release: entry.name,
378
+ namespace: entry.namespace,
379
+ ...(entry.status ? { status: entry.status } : {}),
380
+ ...(entry.revision ? { revision: entry.revision } : {}),
381
+ ...(entry.chart ? { chart: entry.chart } : {}),
382
+ ...(entry.app_version ? { appVersion: entry.app_version } : {}),
383
+ ...(entry.updated ? { updated: entry.updated } : {}),
384
+ resources: [
385
+ ...parseReleaseDocuments(manifestText, "manifest", entry.namespace),
386
+ ...parseReleaseDocuments(hooksText, "hooks", entry.namespace),
387
+ ],
388
+ });
389
+ }
390
+
391
+ return result;
392
+ }