@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
package/src/config.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Helm capability profiles — the `helm` namespace in `chant.config.ts` (#1235,
3
+ * epic #1228 Phase 1).
4
+ *
5
+ * `helm template` looks like a pure function of (chart, values) and is not:
6
+ * `.Capabilities.KubeVersion` defaults to a version baked into the helm
7
+ * binary (v1.31.0 on helm 3.16.2, v1.35.0 on 4.1.1), and
8
+ * `.Capabilities.APIVersions` is silently empty offline. Two engineers on
9
+ * different helm versions therefore render different bytes from identical
10
+ * inputs unless the capability profile is declared. Declaring it closes both
11
+ * inputs: `--kube-version` and `--api-versions` are passed on every render
12
+ * that references a profile, so the render is a function of
13
+ * (chart, values, profile) and its digest means something.
14
+ *
15
+ * Profiles are scoped **per cluster** (see the epic's Decisions): each
16
+ * cluster declares its own `kubeVersion` / `apiVersions`, and a render is
17
+ * pinned against exactly one profile. This mirrors how the k8s lexicon binds
18
+ * environments to clusters (`k8s.profiles.<env>.context`, chant #1100) — a
19
+ * capability profile is the same per-cluster fact seen from the render side.
20
+ *
21
+ * ```ts
22
+ * export default {
23
+ * lexicons: ["helm", "k8s"],
24
+ * helm: {
25
+ * capabilityProfiles: {
26
+ * prod: { kubeVersion: "1.33.6", apiVersions: ["monitoring.coreos.com/v1"] },
27
+ * staging: { kubeVersion: "1.31.4", apiVersions: [] },
28
+ * },
29
+ * },
30
+ * };
31
+ * ```
32
+ *
33
+ * A `HelmRender` references a profile by name (`capabilityProfile: "prod"`)
34
+ * or carries one inline. No profile keeps today's unpinned behavior; a
35
+ * reference that resolves to nothing is a build error naming the profile —
36
+ * never a silent fallback to the binary's default.
37
+ *
38
+ * Follows the k8s lexicon's config seam (#1344): the zod schema is the
39
+ * runtime half core validates the namespace with, the interface is the
40
+ * compile-time half projects write `satisfies` against, and the agreement
41
+ * type at the bottom keeps the two from drifting.
42
+ */
43
+
44
+ import { readFileSync } from "node:fs";
45
+ import { z } from "zod";
46
+ import type { ChantConfig } from "@intentius/chant/config";
47
+ import { findProjectConfig } from "@intentius/chant/project-root";
48
+ import { evaluateProjectConfigSync } from "@intentius/chant/config-sandbox";
49
+
50
+ /**
51
+ * What `--kube-version` accepts: `1.33`, `1.33.6`, `v1.33.6`. Deliberately
52
+ * looser than full semver (no prerelease/build tags — helm's capability
53
+ * parsing does not want them) and tighter than "any string" (a typo like
54
+ * `"latest"` must fail at declaration, not render as garbage).
55
+ */
56
+ export const KUBE_VERSION_PATTERN = /^v?\d+\.\d+(\.\d+)?$/;
57
+
58
+ /**
59
+ * One cluster's declared capabilities, as written in
60
+ * `helm.capabilityProfiles.<name>`. The profile's name is the record key.
61
+ */
62
+ export interface HelmCapabilityProfileConfig {
63
+ /** Kubernetes version the cluster runs, e.g. `"1.33.6"` — passed as `--kube-version`. */
64
+ kubeVersion: string;
65
+ /**
66
+ * API versions available on the cluster beyond the core set, e.g.
67
+ * `"monitoring.coreos.com/v1"` — each passed as `--api-versions`. Omit (or
68
+ * declare empty) for a cluster with no extra API groups charts probe for.
69
+ */
70
+ apiVersions?: string[];
71
+ }
72
+
73
+ /** A resolved capability profile: the declared facts plus the name they were declared under. */
74
+ export interface HelmCapabilityProfile extends HelmCapabilityProfileConfig {
75
+ /** The profile's declared name (the `helm.capabilityProfiles` key, or the inline `name`). */
76
+ name: string;
77
+ }
78
+
79
+ /**
80
+ * How a `HelmRender` names its profile: a string resolved against
81
+ * `helm.capabilityProfiles`, or an inline profile object for the rare render
82
+ * whose cluster facts live nowhere else.
83
+ */
84
+ export type HelmCapabilityProfileRef = string | HelmCapabilityProfile;
85
+
86
+ export interface HelmChantConfig {
87
+ /** Per-cluster capability profiles, keyed by profile (cluster/environment) name. */
88
+ capabilityProfiles?: Record<string, HelmCapabilityProfileConfig>;
89
+ }
90
+
91
+ export const helmCapabilityProfileSchema = z.strictObject({
92
+ kubeVersion: z
93
+ .string()
94
+ .regex(KUBE_VERSION_PATTERN, 'must be a Kubernetes version like "1.33.6" or "v1.33"'),
95
+ apiVersions: z.array(z.string().min(1, "apiVersions entries must be non-empty strings")).optional(),
96
+ });
97
+
98
+ export const helmConfigSchema = z.strictObject({
99
+ capabilityProfiles: z.record(z.string(), helmCapabilityProfileSchema).optional(),
100
+ });
101
+
102
+ declare module "@intentius/chant/config" {
103
+ interface ChantConfig {
104
+ helm?: HelmChantConfig;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Compile-time proof the augmentation above reaches `ChantConfig` — the same
110
+ * guard the k8s and forgejo lexicons carry (#1344). Without it, a project
111
+ * writing the documented snippet with `satisfies ChantConfig` fails to
112
+ * compile and nothing in this repo notices.
113
+ */
114
+ export type HelmConfigNamespace = NonNullable<ChantConfig["helm"]>;
115
+
116
+ /**
117
+ * The schema and the documented interface describe the same namespace. If a
118
+ * field is added to one and not the other, this stops compiling.
119
+ */
120
+ type SchemaMatchesInterface = z.infer<typeof helmConfigSchema> extends HelmChantConfig
121
+ ? HelmChantConfig extends z.infer<typeof helmConfigSchema>
122
+ ? true
123
+ : never
124
+ : never;
125
+ export type _SchemaAgreesWithInterface = SchemaMatchesInterface;
126
+
127
+ /**
128
+ * Validate a resolved profile's fields. Returns one message per problem,
129
+ * each naming the profile — empty when the profile is sound.
130
+ *
131
+ * Config-declared profiles are already schema-checked at config load
132
+ * (`helmConfigSchema` via the plugin's `configSchema`); this covers inline
133
+ * profiles handed straight to `HelmRender`, and re-checks resolved ones so a
134
+ * config loaded outside the CLI (a bare API caller) fails identically.
135
+ */
136
+ export function validateCapabilityProfile(profile: HelmCapabilityProfile): string[] {
137
+ const errors: string[] = [];
138
+ const label = typeof profile.name === "string" && profile.name.length > 0 ? `"${profile.name}"` : "(unnamed)";
139
+ if (typeof profile.name !== "string" || profile.name.length === 0) {
140
+ errors.push("helm capability profile: name must be a non-empty string");
141
+ }
142
+ if (typeof profile.kubeVersion !== "string" || !KUBE_VERSION_PATTERN.test(profile.kubeVersion)) {
143
+ errors.push(
144
+ `helm capability profile ${label}: kubeVersion must be a Kubernetes version like "1.33.6" or "v1.33", got ${JSON.stringify(profile.kubeVersion)}`,
145
+ );
146
+ }
147
+ if (profile.apiVersions !== undefined) {
148
+ if (!Array.isArray(profile.apiVersions)) {
149
+ errors.push(`helm capability profile ${label}: apiVersions must be an array of non-empty strings`);
150
+ } else {
151
+ for (const entry of profile.apiVersions) {
152
+ if (typeof entry !== "string" || entry.length === 0) {
153
+ errors.push(
154
+ `helm capability profile ${label}: apiVersions entries must be non-empty strings, got ${JSON.stringify(entry)}`,
155
+ );
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return errors;
161
+ }
162
+
163
+ /**
164
+ * Synchronously load the nearest project config's raw object, walking upward
165
+ * from `startDir` exactly like `loadChantConfigUpward` (`findProjectConfig`
166
+ * is the shared walk). Synchronous because `HelmRender` resolves at synth
167
+ * time inside a composite factory — the same constraint that gave
168
+ * `chant lint` `evaluateProjectConfigSync`, which this reuses so the
169
+ * sandbox boundary (chant #1113) stays in one place.
170
+ */
171
+ function loadProjectConfigSync(startDir: string): { config: ChantConfig; configPath?: string } {
172
+ const { dir, configPath } = findProjectConfig(startDir);
173
+ if (!configPath) return { config: {} };
174
+ if (configPath.endsWith(".json")) {
175
+ return { config: JSON.parse(readFileSync(configPath, "utf8")) as ChantConfig, configPath };
176
+ }
177
+ return { config: evaluateProjectConfigSync(configPath, dir) as ChantConfig, configPath };
178
+ }
179
+
180
+ /**
181
+ * Resolve a `HelmRender`'s profile reference to a concrete profile.
182
+ *
183
+ * - An inline profile object is validated and returned as-is.
184
+ * - A string is looked up in `helm.capabilityProfiles` of the nearest
185
+ * `chant.config.ts`/`.json` above `opts.startDir` (default: cwd).
186
+ *
187
+ * A reference that resolves to nothing throws, naming the missing profile,
188
+ * the profiles that ARE declared, and where to declare it — a declared but
189
+ * unresolvable profile must be a build error, never a silent fall-through to
190
+ * the helm binary's default capabilities (epic #1228, finding 1).
191
+ */
192
+ export function resolveCapabilityProfile(
193
+ ref: HelmCapabilityProfileRef,
194
+ opts?: { startDir?: string },
195
+ ): HelmCapabilityProfile {
196
+ if (typeof ref !== "string") {
197
+ const errors = validateCapabilityProfile(ref);
198
+ if (errors.length > 0) throw new Error(errors.join("\n"));
199
+ return ref;
200
+ }
201
+
202
+ const startDir = opts?.startDir ?? process.cwd();
203
+ const { config, configPath } = loadProjectConfigSync(startDir);
204
+ const profiles = config.helm?.capabilityProfiles;
205
+ const declared = profiles?.[ref];
206
+ if (!declared) {
207
+ const names = Object.keys(profiles ?? {});
208
+ const where = configPath ?? `chant.config.ts (no config found above ${startDir})`;
209
+ throw new Error(
210
+ `helm capability profile "${ref}" is not declared` +
211
+ (names.length > 0 ? ` — declared profiles: ${names.join(", ")}` : "") +
212
+ `. Declare it as helm.capabilityProfiles.${ref} = { kubeVersion, apiVersions } in ${where}.`,
213
+ );
214
+ }
215
+
216
+ const profile: HelmCapabilityProfile = { name: ref, ...declared };
217
+ const errors = validateCapabilityProfile(profile);
218
+ if (errors.length > 0) throw new Error(errors.join("\n"));
219
+ return profile;
220
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The helm lexicon's deep-observation noise rules (#1247) — the k8s
3
+ * lexicon's, by reference.
4
+ *
5
+ * Everything a Helm release deploys is a Kubernetes object, so the rules for
6
+ * which fields are server-populated, which defaults are noise, which arrays
7
+ * are sets, and which Secret paths must be masked are exactly the k8s
8
+ * lexicon's `k8sDeepNormalizationHooks`. This is deliberately the same
9
+ * *object*, not a copy: core applies `deepNormalizationHooks` to the
10
+ * declared tree as well as the live one (`lexicon.ts`), and two lists would
11
+ * drift apart the first time one gained a rule.
12
+ *
13
+ * Static-import safe for the same reason the k8s module is: the hooks file
14
+ * carries no `@intentius/chant-k8s-client` dependency, so `plugin.ts` can
15
+ * expose it as plain data without pulling the live-read machinery into the
16
+ * build path.
17
+ */
18
+ import { k8sDeepNormalizationHooks } from "@intentius/chant-lexicon-k8s/deep-observe-hooks";
19
+
20
+ export const helmDeepNormalizationHooks = k8sDeepNormalizationHooks;
@@ -0,0 +1,165 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { MASKED, normalizeDeepObservation } from "@intentius/chant/deep-observation";
3
+ import { fakeCluster, objectKey } from "@intentius/chant-lexicon-k8s/api/fake-cluster";
4
+ import { k8sDeepNormalizationHooks } from "@intentius/chant-lexicon-k8s/deep-observe-hooks";
5
+ import { observeResourcesDeepHelm } from "./deep-observe";
6
+ import { helmDeepNormalizationHooks } from "./deep-observe-hooks";
7
+ import type { HelmRunner } from "./release-observe";
8
+
9
+ const LIST = JSON.stringify([
10
+ { name: "web-app", namespace: "prod", revision: "2", status: "deployed", chart: "web-app-0.1.0" },
11
+ ]);
12
+
13
+ const MANIFEST = `---
14
+ apiVersion: apps/v1
15
+ kind: Deployment
16
+ metadata:
17
+ name: web-app
18
+ spec:
19
+ replicas: 2
20
+ ---
21
+ apiVersion: v1
22
+ kind: Secret
23
+ metadata:
24
+ name: web-app-creds
25
+ stringData:
26
+ password: declared-secret
27
+ `;
28
+
29
+ const scripted = (outputs: { list?: string; manifest?: string; hooks?: string }): HelmRunner =>
30
+ async (command) => {
31
+ if (command.startsWith("helm list")) return { stdout: outputs.list ?? "[]" };
32
+ if (command.startsWith("helm get manifest")) return { stdout: outputs.manifest ?? "" };
33
+ if (command.startsWith("helm get hooks")) return { stdout: outputs.hooks ?? "" };
34
+ throw new Error(`unexpected command: ${command}`);
35
+ };
36
+
37
+ const entities = new Map([["chart", { entityType: "Helm::Chart", props: { name: "web-app" } }]]);
38
+ const baseOptions = {
39
+ environment: "prod",
40
+ buildOutput: "",
41
+ entityNames: ["chart"],
42
+ entities,
43
+ };
44
+
45
+ const liveDeployment = {
46
+ apiVersion: "apps/v1",
47
+ kind: "Deployment",
48
+ metadata: {
49
+ name: "web-app",
50
+ namespace: "prod",
51
+ uid: "uid-dep",
52
+ resourceVersion: "9",
53
+ managedFields: [
54
+ {
55
+ manager: "hpa-controller",
56
+ operation: "Update",
57
+ fieldsType: "FieldsV1",
58
+ fieldsV1: { "f:spec": { "f:replicas": {} } },
59
+ },
60
+ ],
61
+ },
62
+ spec: { replicas: 3 },
63
+ status: { readyReplicas: 3, replicas: 3 },
64
+ };
65
+
66
+ const liveSecret = {
67
+ apiVersion: "v1",
68
+ kind: "Secret",
69
+ metadata: { name: "web-app-creds", namespace: "prod", uid: "uid-sec" },
70
+ data: { password: "aHVudGVyMg==" },
71
+ };
72
+
73
+ describe("helm observeResourcesDeep (#1247)", () => {
74
+ test("the hooks ARE the k8s lexicon's — delegation, not a copy", () => {
75
+ expect(helmDeepNormalizationHooks).toBe(k8sDeepNormalizationHooks);
76
+ });
77
+
78
+ test("deep rows come from the live cluster through the k8s machinery, keyed like the thin rows", async () => {
79
+ const cluster = fakeCluster({
80
+ objects: {
81
+ [objectKey("apps/v1", "Deployment", "web-app", "prod")]: liveDeployment,
82
+ [objectKey("v1", "Secret", "web-app-creds", "prod")]: liveSecret,
83
+ },
84
+ });
85
+ const result = normalizeDeepObservation(
86
+ await observeResourcesDeepHelm(baseOptions, {
87
+ run: scripted({ list: LIST, manifest: MANIFEST }),
88
+ connect: cluster.connector,
89
+ }),
90
+ );
91
+
92
+ const dep = result.resources["Deployment/prod/web-app"];
93
+ expect(dep).toBeDefined();
94
+ expect(dep.type).toBe("K8s::Apps::Deployment");
95
+ // The live value, not the declared one — this is what property drift diffs.
96
+ expect((dep.properties.spec as { replicas?: number }).replicas).toBe(3);
97
+ expect(result.unobserved).toEqual({});
98
+ });
99
+
100
+ test("normalization and masking come along for free via the k8s hooks: status pruned, Secret values masked", async () => {
101
+ const cluster = fakeCluster({
102
+ objects: {
103
+ [objectKey("apps/v1", "Deployment", "web-app", "prod")]: liveDeployment,
104
+ [objectKey("v1", "Secret", "web-app-creds", "prod")]: liveSecret,
105
+ },
106
+ });
107
+ const result = normalizeDeepObservation(
108
+ await observeResourcesDeepHelm(baseOptions, {
109
+ run: scripted({ list: LIST, manifest: MANIFEST }),
110
+ connect: cluster.connector,
111
+ }),
112
+ );
113
+
114
+ const dep = result.resources["Deployment/prod/web-app"];
115
+ // Server-populated envelope fields are pruned by the shared rules.
116
+ expect(dep.properties.status).toBeUndefined();
117
+ expect((dep.properties.metadata as Record<string, unknown>).uid).toBeUndefined();
118
+
119
+ // Secret material never reaches a consumer — masked, not returned.
120
+ const secret = result.resources["Secret/prod/web-app-creds"];
121
+ expect((secret.properties.data as Record<string, unknown>).password).toBe(MASKED);
122
+ expect(JSON.stringify(secret.properties)).not.toContain("aHVudGVyMg==");
123
+ });
124
+
125
+ test("managed-fields ownership rides through: the drift line can name the manager (#1189)", async () => {
126
+ const cluster = fakeCluster({
127
+ objects: { [objectKey("apps/v1", "Deployment", "web-app", "prod")]: liveDeployment },
128
+ });
129
+ const result = normalizeDeepObservation(
130
+ await observeResourcesDeepHelm(baseOptions, {
131
+ run: scripted({ list: LIST, manifest: MANIFEST }),
132
+ connect: cluster.connector,
133
+ }),
134
+ );
135
+ const dep = result.resources["Deployment/prod/web-app"];
136
+ expect(dep.fieldOwners?.["spec.replicas"]).toBe("hpa-controller");
137
+ });
138
+
139
+ test("a rendered resource absent live is an absence, not a hole — the thin read owns existence", async () => {
140
+ const cluster = fakeCluster({
141
+ objects: { [objectKey("apps/v1", "Deployment", "web-app", "prod")]: liveDeployment },
142
+ });
143
+ const result = normalizeDeepObservation(
144
+ await observeResourcesDeepHelm(baseOptions, {
145
+ run: scripted({ list: LIST, manifest: MANIFEST }),
146
+ connect: cluster.connector,
147
+ }),
148
+ );
149
+ expect(result.resources["Secret/prod/web-app-creds"]).toBeUndefined();
150
+ expect(result.unobserved["Secret/prod/web-app-creds"]).toBeUndefined();
151
+ });
152
+
153
+ test("a helm-side failure is a hole with a total reason, never a thin-but-clean tree (#1089)", async () => {
154
+ const result = normalizeDeepObservation(
155
+ await observeResourcesDeepHelm(baseOptions, {
156
+ run: async () => {
157
+ throw Object.assign(new Error("helm: command not found"), { code: 127 });
158
+ },
159
+ connect: fakeCluster().connector,
160
+ }),
161
+ );
162
+ expect(result.resources).toEqual({});
163
+ expect(result.unobserved.chart.reason).toBe("no-credentials");
164
+ });
165
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Helm deep observation (#1247, epic #1228 phase 5) — property-level live
3
+ * trees for the resources a release manages, read through the k8s lexicon's
4
+ * machinery rather than a second implementation.
5
+ *
6
+ * The objects a release deploys are Kubernetes objects, so the deep read
7
+ * composes rather than reimplements:
8
+ *
9
+ * 1. The helm half (shared with `describeResources`, `./release-observe.ts`)
10
+ * resolves each declared `Helm::Chart` to its release and parses what
11
+ * the release holds — `helm get manifest` plus `helm get hooks`, both
12
+ * channels. Each rendered document becomes a synthetic declared entity:
13
+ * the release's stored manifest IS the declared side of a helm deep
14
+ * diff — what the cluster is supposed to hold is what the release
15
+ * applied.
16
+ * 2. The k8s half (`observeResourcesDeepK8s`) reads the live property tree
17
+ * for each of those entities over the typed API client, resolves
18
+ * per-field ownership from `metadata.managedFields` (#1189), and
19
+ * normalizes with `k8sDeepNormalizationHooks` — so Secret-value masking
20
+ * and the managed-fields rules apply to helm rows exactly as they do to
21
+ * k8s rows, from the same hook object (see `./deep-observe-hooks.ts`).
22
+ *
23
+ * Verdicts compose per the deep contract (#1014/#1089): a helm-side failure
24
+ * (missing binary, unreachable cluster, unreadable release) lands the chart
25
+ * entity in `unobserved` with a total reason; a k8s-side failure lands the
26
+ * specific resource row there. Chart-authoring entities (`Helm::Chart`,
27
+ * `Helm::Values`, …) have no property tree of their own — their deep form is
28
+ * the per-resource rows this read returns — so on a successful read they are
29
+ * deliberately in neither map, which core's deep diff treats as
30
+ * nothing-to-compare rather than drift.
31
+ */
32
+ import type { DeepObservationResult, UnobservedEntity } from "@intentius/chant/lexicon";
33
+ import { deepObservation } from "@intentius/chant/deep-observation";
34
+ import { unobservedAll } from "@intentius/chant/observation";
35
+ import { observeResourcesDeepK8s } from "@intentius/chant-lexicon-k8s/deep-observe";
36
+ import type { K8sConnector } from "@intentius/chant-lexicon-k8s/api/connect";
37
+ import {
38
+ defaultHelmRunner,
39
+ observeReleases,
40
+ type HelmRunner,
41
+ } from "./release-observe";
42
+
43
+ export interface HelmDeepObserveOptions {
44
+ environment: string;
45
+ buildOutput: string;
46
+ entityNames: string[];
47
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
48
+ stack?: string;
49
+ region?: string;
50
+ owned?: boolean;
51
+ }
52
+
53
+ /** Injectable seams, so tests run without a helm binary or a kubeconfig. */
54
+ export interface HelmDeepObserveDeps {
55
+ run?: HelmRunner;
56
+ /** Forwarded to the k8s reader — a test passes a fake cluster's connector. */
57
+ connect?: K8sConnector;
58
+ }
59
+
60
+ /** The document minus the envelope the row's `type` already carries. */
61
+ function declaredTreeOf(doc: Record<string, unknown>, namespace: string | undefined): Record<string, unknown> {
62
+ const { apiVersion: _apiVersion, kind: _kind, ...rest } = doc;
63
+ const metadata = (rest.metadata ?? {}) as Record<string, unknown>;
64
+ return {
65
+ ...rest,
66
+ // The k8s reader addresses the object by `props.metadata.{name,namespace}`;
67
+ // helm applies the release namespace to namespace-silent documents, so the
68
+ // synthetic entity must carry the resolved one.
69
+ metadata: { ...metadata, ...(namespace && metadata.namespace === undefined ? { namespace } : {}) },
70
+ };
71
+ }
72
+
73
+ export async function observeResourcesDeepHelm(
74
+ options: HelmDeepObserveOptions,
75
+ deps: HelmDeepObserveDeps = {},
76
+ ): Promise<DeepObservationResult> {
77
+ const run = deps.run ?? defaultHelmRunner;
78
+
79
+ let observed;
80
+ try {
81
+ observed = await observeReleases(
82
+ { environment: options.environment, entities: options.entities, stack: options.stack },
83
+ run,
84
+ );
85
+ } catch (err) {
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ return deepObservation({}, unobservedAll(options.entityNames, "read-failed", message, options.entities));
88
+ }
89
+
90
+ const unobserved: Record<string, UnobservedEntity> = { ...observed.unobserved };
91
+
92
+ // One synthetic declared entity per rendered document, keyed like the thin
93
+ // read's rows so the two reads spell the same identity for the same object.
94
+ const synthetic = new Map<string, { entityType: string; props: Record<string, unknown> }>();
95
+ for (const release of observed.releases) {
96
+ for (const row of release.resources) {
97
+ synthetic.set(row.key, {
98
+ entityType: row.entityType,
99
+ props: declaredTreeOf(row.doc, row.namespace),
100
+ });
101
+ }
102
+ }
103
+
104
+ if (synthetic.size === 0) return deepObservation({}, unobserved);
105
+
106
+ const k8sOptions = {
107
+ environment: options.environment,
108
+ buildOutput: options.buildOutput,
109
+ entityNames: [...synthetic.keys()],
110
+ entities: synthetic,
111
+ ...(options.owned !== undefined ? { owned: options.owned } : {}),
112
+ };
113
+ const k8sResult = deps.connect
114
+ ? await observeResourcesDeepK8s(k8sOptions, deps.connect)
115
+ : await observeResourcesDeepK8s(k8sOptions);
116
+
117
+ return deepObservation(k8sResult.resources ?? {}, {
118
+ ...(k8sResult.unobserved ?? {}),
119
+ ...unobserved,
120
+ });
121
+ }