@intentius/chant-lexicon-helm 0.124.31 → 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.
Files changed (111) hide show
  1. package/dist/archive-render.js +76 -0
  2. package/dist/codegen/docs-cli.js +3 -0
  3. package/dist/codegen/docs.js +113 -0
  4. package/dist/codegen/generate-cli.js +19 -0
  5. package/dist/codegen/generate.js +210 -0
  6. package/dist/codegen/naming.js +29 -0
  7. package/dist/codegen/package.js +44 -0
  8. package/dist/codegen/values-type.js +87 -0
  9. package/dist/commands.js +413 -0
  10. package/dist/components/builders.js +9 -0
  11. package/dist/components/capability-plugin.js +17 -0
  12. package/dist/components/helm-upgrade.js +109 -0
  13. package/dist/components/index.js +11 -0
  14. package/dist/composites/catalog.js +1119 -0
  15. package/dist/composites/helm-batch-job.js +148 -0
  16. package/dist/composites/helm-crd-lifecycle.js +145 -0
  17. package/dist/composites/helm-cron-job.js +130 -0
  18. package/dist/composites/helm-daemon-set.js +125 -0
  19. package/dist/composites/helm-external-secret.js +63 -0
  20. package/dist/composites/helm-library.js +24 -0
  21. package/dist/composites/helm-microservice.js +260 -0
  22. package/dist/composites/helm-monitored-service.js +188 -0
  23. package/dist/composites/helm-namespace-env.js +116 -0
  24. package/dist/composites/helm-secure-ingress.js +86 -0
  25. package/dist/composites/helm-stateful-service.js +160 -0
  26. package/dist/composites/helm-web-app.js +201 -0
  27. package/dist/composites/helm-worker.js +164 -0
  28. package/dist/composites/index.js +13 -0
  29. package/dist/config.js +161 -0
  30. package/dist/coverage.js +37 -0
  31. package/dist/deep-observe-hooks.js +19 -0
  32. package/dist/deep-observe.js +55 -0
  33. package/dist/describe-resources.js +119 -0
  34. package/dist/describe-stack-status.js +66 -0
  35. package/dist/detect.js +11 -0
  36. package/dist/generated/index.js +11 -0
  37. package/dist/helpers.js +84 -0
  38. package/dist/import/generator.js +292 -0
  39. package/dist/import/parser.js +141 -0
  40. package/dist/import/template-stripper.js +86 -0
  41. package/dist/index.js +55 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/intrinsics.js +482 -0
  44. package/dist/lint/audit-catalog.js +33 -0
  45. package/dist/lint/audit-lineage.js +53 -0
  46. package/dist/lint/post-synth/helm-helpers.js +60 -0
  47. package/dist/lint/post-synth/index.js +48 -0
  48. package/dist/lint/post-synth/whm005-no-empty-wrapper.js +47 -0
  49. package/dist/lint/post-synth/whm101.js +38 -0
  50. package/dist/lint/post-synth/whm102.js +26 -0
  51. package/dist/lint/post-synth/whm103.js +51 -0
  52. package/dist/lint/post-synth/whm104.js +29 -0
  53. package/dist/lint/post-synth/whm105.js +23 -0
  54. package/dist/lint/post-synth/whm201.js +29 -0
  55. package/dist/lint/post-synth/whm202.js +42 -0
  56. package/dist/lint/post-synth/whm203.js +32 -0
  57. package/dist/lint/post-synth/whm204.js +51 -0
  58. package/dist/lint/post-synth/whm301.js +31 -0
  59. package/dist/lint/post-synth/whm302.js +33 -0
  60. package/dist/lint/post-synth/whm401.js +52 -0
  61. package/dist/lint/post-synth/whm402.js +38 -0
  62. package/dist/lint/post-synth/whm403.js +38 -0
  63. package/dist/lint/post-synth/whm404.js +30 -0
  64. package/dist/lint/post-synth/whm405.js +47 -0
  65. package/dist/lint/post-synth/whm406.js +27 -0
  66. package/dist/lint/post-synth/whm407.js +72 -0
  67. package/dist/lint/post-synth/whm501.js +89 -0
  68. package/dist/lint/post-synth/whm502.js +85 -0
  69. package/dist/lint/post-synth/whm503.js +82 -0
  70. package/dist/lint/post-synth/whm504.js +47 -0
  71. package/dist/lint/rules/chart-metadata.js +53 -0
  72. package/dist/lint/rules/no-hardcoded-image.js +50 -0
  73. package/dist/lint/rules/values-no-helm-tpl.js +78 -0
  74. package/dist/lint/rules/values-no-secrets.js +65 -0
  75. package/dist/list-artifacts.js +134 -0
  76. package/dist/lsp/completions.d.ts.map +1 -1
  77. package/dist/lsp/completions.js +18 -0
  78. package/dist/lsp/hover.d.ts.map +1 -1
  79. package/dist/lsp/hover.js +40 -0
  80. package/dist/manifest.json +1 -1
  81. package/dist/op/activities/cluster-probe.js +116 -0
  82. package/dist/op/activities/helm.js +449 -0
  83. package/dist/op/activities/index.js +10 -0
  84. package/dist/op/builders.js +45 -0
  85. package/dist/package-cli.js +21 -0
  86. package/dist/pinnability/actions.js +207 -0
  87. package/dist/pinnability/classify.js +416 -0
  88. package/dist/pinnability/conditions.js +0 -0
  89. package/dist/pinnability/index.js +16 -0
  90. package/dist/pinnability/localize.js +518 -0
  91. package/dist/pinnability/render-stream.js +53 -0
  92. package/dist/pinnability/values.js +151 -0
  93. package/dist/plugin.js +458 -0
  94. package/dist/project-codegen.js +215 -0
  95. package/dist/release-observe.js +287 -0
  96. package/dist/render-diff.js +279 -0
  97. package/dist/render-digest.js +0 -0
  98. package/dist/render-records.js +29 -0
  99. package/dist/render-store.js +401 -0
  100. package/dist/render-wrapper.js +164 -0
  101. package/dist/render.js +394 -0
  102. package/dist/resources.js +109 -0
  103. package/dist/serializer.js +903 -0
  104. package/dist/validate-cli.js +17 -0
  105. package/dist/validate.js +30 -0
  106. package/dist/values-probe.js +504 -0
  107. package/dist/wrapper-chart.js +95 -0
  108. package/package.json +11 -11
  109. package/src/codegen/package.ts +2 -2
  110. package/src/lsp/completions.ts +2 -1
  111. package/src/lsp/hover.ts +2 -1
package/dist/config.js ADDED
@@ -0,0 +1,161 @@
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
+ import { readFileSync } from "node:fs";
44
+ import { z } from "zod";
45
+ import { findProjectConfig } from "@intentius/chant/project-root";
46
+ import { evaluateProjectConfigSync } from "@intentius/chant/config-sandbox";
47
+ /**
48
+ * What `--kube-version` accepts: `1.33`, `1.33.6`, `v1.33.6`. Deliberately
49
+ * looser than full semver (no prerelease/build tags — helm's capability
50
+ * parsing does not want them) and tighter than "any string" (a typo like
51
+ * `"latest"` must fail at declaration, not render as garbage).
52
+ */
53
+ export const KUBE_VERSION_PATTERN = /^v?\d+\.\d+(\.\d+)?$/;
54
+ export const helmCapabilityProfileSchema = z.strictObject({
55
+ kubeVersion: z
56
+ .string()
57
+ .regex(KUBE_VERSION_PATTERN, 'must be a Kubernetes version like "1.33.6" or "v1.33"'),
58
+ apiVersions: z.array(z.string().min(1, "apiVersions entries must be non-empty strings")).optional(),
59
+ });
60
+ /** What a `helm.charts` key may be: it names the generated factory. */
61
+ export const CHART_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
62
+ export const helmProjectChartSchema = z.union([
63
+ z.strictObject({
64
+ repo: z.string().url().optional(),
65
+ chart: z.string().min(1),
66
+ version: z.string().min(1),
67
+ digest: z.string().optional(),
68
+ }),
69
+ z.strictObject({ path: z.string().min(1) }),
70
+ ]);
71
+ export const helmConfigSchema = z.strictObject({
72
+ capabilityProfiles: z.record(z.string(), helmCapabilityProfileSchema).optional(),
73
+ charts: z
74
+ .record(z.string().regex(CHART_KEY_PATTERN, "must start with a letter and hold only letters, digits, - and _"), helmProjectChartSchema)
75
+ .optional(),
76
+ });
77
+ /**
78
+ * Validate a resolved profile's fields. Returns one message per problem,
79
+ * each naming the profile — empty when the profile is sound.
80
+ *
81
+ * Config-declared profiles are already schema-checked at config load
82
+ * (`helmConfigSchema` via the plugin's `configSchema`); this covers inline
83
+ * profiles handed straight to `HelmRender`, and re-checks resolved ones so a
84
+ * config loaded outside the CLI (a bare API caller) fails identically.
85
+ */
86
+ export function validateCapabilityProfile(profile) {
87
+ const errors = [];
88
+ const label = typeof profile.name === "string" && profile.name.length > 0 ? `"${profile.name}"` : "(unnamed)";
89
+ if (typeof profile.name !== "string" || profile.name.length === 0) {
90
+ errors.push("helm capability profile: name must be a non-empty string");
91
+ }
92
+ if (typeof profile.kubeVersion !== "string" || !KUBE_VERSION_PATTERN.test(profile.kubeVersion)) {
93
+ errors.push(`helm capability profile ${label}: kubeVersion must be a Kubernetes version like "1.33.6" or "v1.33", got ${JSON.stringify(profile.kubeVersion)}`);
94
+ }
95
+ if (profile.apiVersions !== undefined) {
96
+ if (!Array.isArray(profile.apiVersions)) {
97
+ errors.push(`helm capability profile ${label}: apiVersions must be an array of non-empty strings`);
98
+ }
99
+ else {
100
+ for (const entry of profile.apiVersions) {
101
+ if (typeof entry !== "string" || entry.length === 0) {
102
+ errors.push(`helm capability profile ${label}: apiVersions entries must be non-empty strings, got ${JSON.stringify(entry)}`);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return errors;
108
+ }
109
+ /**
110
+ * Synchronously load the nearest project config's raw object, walking upward
111
+ * from `startDir` exactly like `loadChantConfigUpward` (`findProjectConfig`
112
+ * is the shared walk). Synchronous because `HelmRender` resolves at synth
113
+ * time inside a composite factory — the same constraint that gave
114
+ * `chant lint` `evaluateProjectConfigSync`, which this reuses so the
115
+ * sandbox boundary (chant #1113) stays in one place.
116
+ */
117
+ function loadProjectConfigSync(startDir) {
118
+ const { dir, configPath } = findProjectConfig(startDir);
119
+ if (!configPath)
120
+ return { config: {} };
121
+ if (configPath.endsWith(".json")) {
122
+ return { config: JSON.parse(readFileSync(configPath, "utf8")), configPath };
123
+ }
124
+ return { config: evaluateProjectConfigSync(configPath, dir), configPath };
125
+ }
126
+ /**
127
+ * Resolve a `HelmRender`'s profile reference to a concrete profile.
128
+ *
129
+ * - An inline profile object is validated and returned as-is.
130
+ * - A string is looked up in `helm.capabilityProfiles` of the nearest
131
+ * `chant.config.ts`/`.json` above `opts.startDir` (default: cwd).
132
+ *
133
+ * A reference that resolves to nothing throws, naming the missing profile,
134
+ * the profiles that ARE declared, and where to declare it — a declared but
135
+ * unresolvable profile must be a build error, never a silent fall-through to
136
+ * the helm binary's default capabilities (epic #1228, finding 1).
137
+ */
138
+ export function resolveCapabilityProfile(ref, opts) {
139
+ if (typeof ref !== "string") {
140
+ const errors = validateCapabilityProfile(ref);
141
+ if (errors.length > 0)
142
+ throw new Error(errors.join("\n"));
143
+ return ref;
144
+ }
145
+ const startDir = opts?.startDir ?? process.cwd();
146
+ const { config, configPath } = loadProjectConfigSync(startDir);
147
+ const profiles = config.helm?.capabilityProfiles;
148
+ const declared = profiles?.[ref];
149
+ if (!declared) {
150
+ const names = Object.keys(profiles ?? {});
151
+ const where = configPath ?? `chant.config.ts (no config found above ${startDir})`;
152
+ throw new Error(`helm capability profile "${ref}" is not declared` +
153
+ (names.length > 0 ? ` — declared profiles: ${names.join(", ")}` : "") +
154
+ `. Declare it as helm.capabilityProfiles.${ref} = { kubeVersion, apiVersions } in ${where}.`);
155
+ }
156
+ const profile = { name: ref, ...declared };
157
+ const errors = validateCapabilityProfile(profile);
158
+ if (errors.length > 0)
159
+ throw new Error(errors.join("\n"));
160
+ return profile;
161
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Coverage analysis for the Helm lexicon.
3
+ *
4
+ * Since Helm types are static and few, coverage is inherently high.
5
+ * This module provides a consistent interface with other lexicons.
6
+ */
7
+ import { readFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
11
+ /**
12
+ * Analyze coverage of the Helm lexicon.
13
+ */
14
+ export async function analyzeHelmCoverage(opts) {
15
+ const generatedDir = join(pkgDir, "src", "generated");
16
+ let lexiconData;
17
+ try {
18
+ const raw = readFileSync(join(generatedDir, "lexicon-helm.json"), "utf-8");
19
+ lexiconData = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ console.error("Helm lexicon coverage: generated artifacts not found. Run 'just generate' first.");
23
+ process.exit(1);
24
+ return;
25
+ }
26
+ const typeCount = Object.keys(lexiconData).length;
27
+ const resources = Object.values(lexiconData).filter((e) => e.kind === "resource").length;
28
+ const properties = Object.values(lexiconData).filter((e) => e.kind === "property").length;
29
+ // Helm types are fully statically defined, so coverage is 100%
30
+ const pct = 100;
31
+ console.error(`Helm lexicon coverage: ${pct}%`);
32
+ console.error(` Types: ${typeCount} (${resources} resources, ${properties} properties)`);
33
+ if (opts?.minOverall && pct < opts.minOverall) {
34
+ console.error(`Coverage ${pct}% is below minimum ${opts.minOverall}%`);
35
+ process.exit(1);
36
+ }
37
+ }
@@ -0,0 +1,19 @@
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
+ export const helmDeepNormalizationHooks = k8sDeepNormalizationHooks;
@@ -0,0 +1,55 @@
1
+ import { deepObservation } from "@intentius/chant/deep-observation";
2
+ import { unobservedAll } from "@intentius/chant/observation";
3
+ import { observeResourcesDeepK8s } from "@intentius/chant-lexicon-k8s/deep-observe";
4
+ import { defaultHelmRunner, observeReleases, } from "./release-observe.js";
5
+ /** The document minus the envelope the row's `type` already carries. */
6
+ function declaredTreeOf(doc, namespace) {
7
+ const { apiVersion: _apiVersion, kind: _kind, ...rest } = doc;
8
+ const metadata = (rest.metadata ?? {});
9
+ return {
10
+ ...rest,
11
+ // The k8s reader addresses the object by `props.metadata.{name,namespace}`;
12
+ // helm applies the release namespace to namespace-silent documents, so the
13
+ // synthetic entity must carry the resolved one.
14
+ metadata: { ...metadata, ...(namespace && metadata.namespace === undefined ? { namespace } : {}) },
15
+ };
16
+ }
17
+ export async function observeResourcesDeepHelm(options, deps = {}) {
18
+ const run = deps.run ?? defaultHelmRunner;
19
+ let observed;
20
+ try {
21
+ observed = await observeReleases({ environment: options.environment, entities: options.entities, stack: options.stack }, run);
22
+ }
23
+ catch (err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ return deepObservation({}, unobservedAll(options.entityNames, "read-failed", message, options.entities));
26
+ }
27
+ const unobserved = { ...observed.unobserved };
28
+ // One synthetic declared entity per rendered document, keyed like the thin
29
+ // read's rows so the two reads spell the same identity for the same object.
30
+ const synthetic = new Map();
31
+ for (const release of observed.releases) {
32
+ for (const row of release.resources) {
33
+ synthetic.set(row.key, {
34
+ entityType: row.entityType,
35
+ props: declaredTreeOf(row.doc, row.namespace),
36
+ });
37
+ }
38
+ }
39
+ if (synthetic.size === 0)
40
+ return deepObservation({}, unobserved);
41
+ const k8sOptions = {
42
+ environment: options.environment,
43
+ buildOutput: options.buildOutput,
44
+ entityNames: [...synthetic.keys()],
45
+ entities: synthetic,
46
+ ...(options.owned !== undefined ? { owned: options.owned } : {}),
47
+ };
48
+ const k8sResult = deps.connect
49
+ ? await observeResourcesDeepK8s(k8sOptions, deps.connect)
50
+ : await observeResourcesDeepK8s(k8sOptions);
51
+ return deepObservation(k8sResult.resources ?? {}, {
52
+ ...(k8sResult.unobserved ?? {}),
53
+ ...unobserved,
54
+ });
55
+ }
@@ -0,0 +1,119 @@
1
+ import { observation } from "@intentius/chant/observation";
2
+ import { LABEL_OWNERSHIP_KEYS, readOwnership } from "@intentius/chant/ownership";
3
+ import { HELM_CHART_ENTITY_TYPE, HELM_RELEASE_TYPE, defaultHelmRunner, observeReleases, } from "./release-observe.js";
4
+ function pruneUndefined(obj) {
5
+ const out = {};
6
+ for (const [k, v] of Object.entries(obj)) {
7
+ if (v !== undefined)
8
+ out[k] = v;
9
+ }
10
+ return out;
11
+ }
12
+ function releaseRow(release) {
13
+ return {
14
+ type: HELM_RELEASE_TYPE,
15
+ physicalId: `${release.namespace}/${release.release}`,
16
+ status: release.status ?? "unknown",
17
+ ...(release.updated ? { lastUpdated: release.updated } : {}),
18
+ ownership: "owned",
19
+ attributes: pruneUndefined({
20
+ chart: release.chart,
21
+ revision: release.revision,
22
+ appVersion: release.appVersion,
23
+ namespace: release.namespace,
24
+ resources: release.resources.length,
25
+ }),
26
+ };
27
+ }
28
+ export async function describeResources(options, run = defaultHelmRunner) {
29
+ const satellites = [];
30
+ for (const [entityName, entity] of options.entities) {
31
+ if (entity.entityType !== HELM_CHART_ENTITY_TYPE) {
32
+ satellites.push({ entityName, entityType: entity.entityType });
33
+ }
34
+ }
35
+ const observed = await observeReleases({ environment: options.environment, entities: options.entities, stack: options.stack }, run);
36
+ const chartCount = options.entities.size - satellites.length;
37
+ const resources = {};
38
+ const unobserved = { ...observed.unobserved };
39
+ const notes = [];
40
+ if (chartCount === 0 && satellites.length > 0) {
41
+ for (const { entityName, entityType } of satellites) {
42
+ unobserved[entityName] = {
43
+ type: entityType,
44
+ reason: "unsupported-kind",
45
+ detail: "chart-authoring entity with no declared Helm::Chart to attribute a release to",
46
+ };
47
+ }
48
+ return observation(resources, unobserved, observed.queried, notes);
49
+ }
50
+ for (const release of observed.releases) {
51
+ resources[release.entityName] = releaseRow(release);
52
+ for (const row of release.resources) {
53
+ const labels = row.doc.metadata?.labels;
54
+ const marker = readOwnership(labels, LABEL_OWNERSHIP_KEYS);
55
+ resources[row.key] = {
56
+ type: row.entityType,
57
+ physicalId: row.namespace ? `${row.namespace}/${row.name}` : row.name,
58
+ status: release.status ?? "unknown",
59
+ // Owned via release identity (#1246): the read is scoped to a release
60
+ // this project declares, so every document it returns is helm-managed
61
+ // by construction — the release IS the marker channel here.
62
+ ownership: "owned",
63
+ ...(marker ? { marker } : {}),
64
+ // The release's resources are what the declared chart deploys —
65
+ // expected runtime, never orphan/delete candidates (#1077).
66
+ ownerChain: { root: "declared", entity: release.entityName },
67
+ attributes: pruneUndefined({
68
+ release: release.release,
69
+ releaseNamespace: release.namespace,
70
+ namespace: row.namespace,
71
+ channel: row.channel,
72
+ hook: row.hook?.hook,
73
+ hookWeight: row.hook?.weight,
74
+ hookDeletePolicy: row.hook?.deletePolicy,
75
+ }),
76
+ };
77
+ }
78
+ }
79
+ // Chart-authoring satellites ride the single chart's verdict; see the
80
+ // module doc. Their deployed form is the release itself, so the release
81
+ // read is the honest answer for them — present, absent, or unobserved
82
+ // exactly as the chart is.
83
+ if (satellites.length > 0) {
84
+ if (chartCount === 1) {
85
+ const [chartEntity] = [...options.entities].find(([, e]) => e.entityType === HELM_CHART_ENTITY_TYPE);
86
+ const release = observed.releases.find((r) => r.entityName === chartEntity);
87
+ const hole = unobserved[chartEntity];
88
+ for (const { entityName, entityType } of satellites) {
89
+ if (release) {
90
+ resources[entityName] = {
91
+ type: entityType,
92
+ physicalId: `${release.namespace}/${release.release}`,
93
+ status: release.status ?? "unknown",
94
+ ownership: "owned",
95
+ attributes: { release: release.release, namespace: release.namespace, via: "release-identity" },
96
+ };
97
+ }
98
+ else if (hole) {
99
+ unobserved[entityName] = { type: entityType, reason: hole.reason, ...(hole.detail ? { detail: hole.detail } : {}) };
100
+ }
101
+ // Release confirmed absent → the satellite is absent too: in neither
102
+ // map, and deploying the chart is what creates it.
103
+ }
104
+ }
105
+ else {
106
+ for (const { entityName, entityType } of satellites) {
107
+ unobserved[entityName] = {
108
+ type: entityType,
109
+ reason: "unsupported-kind",
110
+ detail: "chart-authoring entity in a multi-chart project — cannot attribute it to a single release",
111
+ };
112
+ }
113
+ }
114
+ }
115
+ if (options.owned) {
116
+ notes.push("helm scopes reads to declared releases, so every row is helm-managed (owned) by construction — --owned withholds nothing");
117
+ }
118
+ return observation(resources, unobserved, observed.queried, notes);
119
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Deploy-unit status for Helm (#1495 piece 4).
3
+ *
4
+ * A Helm deploy unit *is* a release: `helm list` already reports its
5
+ * presence, revision, and native status, and that status lands on
6
+ * `StackStatusObservation` almost field-for-field — `deployed` is the one
7
+ * terminal success state, so `healthy` is exactly `status === "deployed"`.
8
+ * This reads back what `helm-upgrade` (the paired capability) writes, the
9
+ * same one-name-both-jobs pairing `cfn-deploy`/`kubectl-apply` have.
10
+ *
11
+ * Tri-state, matching the k8s observer's contract:
12
+ * - release found → `{ present: true, status, healthy }`
13
+ * - release not in list → `{ present: false }` (pre-first-install)
14
+ * - helm missing, no cluster, exec failure, unparseable output → `null` —
15
+ * "I cannot tell", never a confident absence.
16
+ *
17
+ * Cluster selection follows #1488: the environment's declared k8s binding
18
+ * (`k8s.profiles.<env>.context`) is passed as `--kube-context` when present;
19
+ * ambient otherwise. A Helm release lives on the same cluster the k8s half
20
+ * observes, so it rides the same binding.
21
+ */
22
+ import { exec } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+ import { loadChantConfigUpward } from "@intentius/chant/config";
25
+ import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
26
+ const execAsync = promisify(exec);
27
+ export async function describeStackStatus(options, run = execAsync) {
28
+ // The unit is a release name, optionally namespace-qualified as
29
+ // "<namespace>/<name>" when the same release name exists in two namespaces.
30
+ const slash = options.stack.indexOf("/");
31
+ const wantNs = slash > 0 ? options.stack.slice(0, slash) : undefined;
32
+ const wantName = slash > 0 ? options.stack.slice(slash + 1) : options.stack;
33
+ let context;
34
+ try {
35
+ const { config } = await loadChantConfigUpward(process.cwd());
36
+ context = (await resolveClusterTarget(config, options.environment, "helm")).context;
37
+ }
38
+ catch {
39
+ context = undefined;
40
+ }
41
+ let stdout;
42
+ try {
43
+ ({ stdout } = await run(`helm list -A -o json${context ? ` --kube-context '${context.replace(/'/g, "'\\''")}'` : ""}`));
44
+ }
45
+ catch {
46
+ // Binary missing, no kubeconfig, cluster unreachable — indeterminate.
47
+ return null;
48
+ }
49
+ let entries;
50
+ try {
51
+ entries = JSON.parse(stdout);
52
+ if (!Array.isArray(entries))
53
+ return null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ const found = entries.find((e) => e.name === wantName && (wantNs === undefined || e.namespace === wantNs));
59
+ if (!found)
60
+ return { stack: options.stack, present: false };
61
+ return {
62
+ stack: options.stack,
63
+ present: true,
64
+ ...(found.status ? { status: found.status, healthy: found.status === "deployed" } : {}),
65
+ };
66
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Edge-safe template detection for helm (Chart.yaml) — see #426. */
2
+ export function detectTemplate(data) {
3
+ if (typeof data !== "object" || data === null)
4
+ return false;
5
+ const obj = data;
6
+ // Helm charts have apiVersion: v2 in Chart.yaml
7
+ if (obj.apiVersion === "v2" && typeof obj.name === "string" && typeof obj.version === "string") {
8
+ return true;
9
+ }
10
+ return false;
11
+ }
@@ -0,0 +1,11 @@
1
+ // Auto-generated Helm lexicon runtime index
2
+ // Do not edit manually
3
+ import { createResource, createProperty } from "@intentius/chant/runtime";
4
+ export const Chart = createResource("Helm::Chart", "helm", {});
5
+ export const Values = createResource("Helm::Values", "helm", {});
6
+ export const HelmTest = createResource("Helm::Test", "helm", {});
7
+ export const HelmNotes = createResource("Helm::Notes", "helm", {});
8
+ export const HelmHook = createProperty("Helm::Hook", "helm");
9
+ export const HelmDependency = createProperty("Helm::Dependency", "helm");
10
+ export const HelmMaintainer = createProperty("Helm::Maintainer", "helm");
11
+ export const HelmCRD = createResource("Helm::CRD", "helm", {});
@@ -0,0 +1,84 @@
1
+ /**
2
+ * _helpers.tpl generation for Helm charts.
3
+ *
4
+ * Generates the standard named templates that Helm charts typically include:
5
+ * chart name, fullname, labels, selectorLabels, chart metadata, serviceAccountName.
6
+ */
7
+ /**
8
+ * Generate the _helpers.tpl content for a Helm chart.
9
+ */
10
+ export function generateHelpers(config) {
11
+ const { chartName, includeServiceAccount = true } = config;
12
+ const lines = [];
13
+ // Chart name
14
+ lines.push(`{{/*
15
+ Expand the name of the chart.
16
+ */}}
17
+ {{- define "${chartName}.name" -}}
18
+ {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
19
+ {{- end }}`);
20
+ // Fullname
21
+ lines.push(`
22
+ {{/*
23
+ Create a default fully qualified app name.
24
+ We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
25
+ If release name contains chart name it will be used as a full name.
26
+ */}}
27
+ {{- define "${chartName}.fullname" -}}
28
+ {{- if .Values.fullnameOverride }}
29
+ {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
30
+ {{- else }}
31
+ {{- $name := default .Chart.Name .Values.nameOverride }}
32
+ {{- if contains $name .Release.Name }}
33
+ {{- .Release.Name | trunc 63 | trimSuffix "-" }}
34
+ {{- else }}
35
+ {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
36
+ {{- end }}
37
+ {{- end }}
38
+ {{- end }}`);
39
+ // Chart label
40
+ lines.push(`
41
+ {{/*
42
+ Create chart name and version as used by the chart label.
43
+ */}}
44
+ {{- define "${chartName}.chart" -}}
45
+ {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
46
+ {{- end }}`);
47
+ // Common labels
48
+ lines.push(`
49
+ {{/*
50
+ Common labels
51
+ */}}
52
+ {{- define "${chartName}.labels" -}}
53
+ helm.sh/chart: {{ include "${chartName}.chart" . }}
54
+ {{ include "${chartName}.selectorLabels" . }}
55
+ {{- if .Chart.AppVersion }}
56
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
57
+ {{- end }}
58
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
59
+ {{- end }}`);
60
+ // Selector labels
61
+ lines.push(`
62
+ {{/*
63
+ Selector labels
64
+ */}}
65
+ {{- define "${chartName}.selectorLabels" -}}
66
+ app.kubernetes.io/name: {{ include "${chartName}.name" . }}
67
+ app.kubernetes.io/instance: {{ .Release.Name }}
68
+ {{- end }}`);
69
+ // Service account name
70
+ if (includeServiceAccount) {
71
+ lines.push(`
72
+ {{/*
73
+ Create the name of the service account to use
74
+ */}}
75
+ {{- define "${chartName}.serviceAccountName" -}}
76
+ {{- if .Values.serviceAccount.create }}
77
+ {{- default (include "${chartName}.fullname" .) .Values.serviceAccount.name }}
78
+ {{- else }}
79
+ {{- default "default" .Values.serviceAccount.name }}
80
+ {{- end }}
81
+ {{- end }}`);
82
+ }
83
+ return lines.join("\n") + "\n";
84
+ }