@intentius/chant-lexicon-helm 0.124.30 → 0.124.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/archive-render.js +76 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +113 -0
- package/dist/codegen/generate-cli.js +19 -0
- package/dist/codegen/generate.js +210 -0
- package/dist/codegen/naming.js +29 -0
- package/dist/codegen/package.js +44 -0
- package/dist/codegen/values-type.js +87 -0
- package/dist/commands.js +413 -0
- package/dist/components/builders.js +9 -0
- package/dist/components/capability-plugin.js +17 -0
- package/dist/components/helm-upgrade.js +109 -0
- package/dist/components/index.js +11 -0
- package/dist/composites/catalog.js +1119 -0
- package/dist/composites/helm-batch-job.js +148 -0
- package/dist/composites/helm-crd-lifecycle.js +145 -0
- package/dist/composites/helm-cron-job.js +130 -0
- package/dist/composites/helm-daemon-set.js +125 -0
- package/dist/composites/helm-external-secret.js +63 -0
- package/dist/composites/helm-library.js +24 -0
- package/dist/composites/helm-microservice.js +260 -0
- package/dist/composites/helm-monitored-service.js +188 -0
- package/dist/composites/helm-namespace-env.js +116 -0
- package/dist/composites/helm-secure-ingress.js +86 -0
- package/dist/composites/helm-stateful-service.js +160 -0
- package/dist/composites/helm-web-app.js +201 -0
- package/dist/composites/helm-worker.js +164 -0
- package/dist/composites/index.js +13 -0
- package/dist/config.js +161 -0
- package/dist/coverage.js +37 -0
- package/dist/deep-observe-hooks.js +19 -0
- package/dist/deep-observe.js +55 -0
- package/dist/describe-resources.js +119 -0
- package/dist/describe-stack-status.js +66 -0
- package/dist/detect.js +11 -0
- package/dist/generated/index.js +11 -0
- package/dist/helpers.js +84 -0
- package/dist/import/generator.js +292 -0
- package/dist/import/parser.js +141 -0
- package/dist/import/template-stripper.js +86 -0
- package/dist/index.js +55 -0
- package/dist/integrity.json +2 -2
- package/dist/intrinsics.js +482 -0
- package/dist/lint/audit-catalog.js +33 -0
- package/dist/lint/audit-lineage.js +53 -0
- package/dist/lint/post-synth/helm-helpers.js +60 -0
- package/dist/lint/post-synth/index.js +48 -0
- package/dist/lint/post-synth/whm005-no-empty-wrapper.js +47 -0
- package/dist/lint/post-synth/whm101.js +38 -0
- package/dist/lint/post-synth/whm102.js +26 -0
- package/dist/lint/post-synth/whm103.js +51 -0
- package/dist/lint/post-synth/whm104.js +29 -0
- package/dist/lint/post-synth/whm105.js +23 -0
- package/dist/lint/post-synth/whm201.js +29 -0
- package/dist/lint/post-synth/whm202.js +42 -0
- package/dist/lint/post-synth/whm203.js +32 -0
- package/dist/lint/post-synth/whm204.js +51 -0
- package/dist/lint/post-synth/whm301.js +31 -0
- package/dist/lint/post-synth/whm302.js +33 -0
- package/dist/lint/post-synth/whm401.js +52 -0
- package/dist/lint/post-synth/whm402.js +38 -0
- package/dist/lint/post-synth/whm403.js +38 -0
- package/dist/lint/post-synth/whm404.js +30 -0
- package/dist/lint/post-synth/whm405.js +47 -0
- package/dist/lint/post-synth/whm406.js +27 -0
- package/dist/lint/post-synth/whm407.js +72 -0
- package/dist/lint/post-synth/whm501.js +89 -0
- package/dist/lint/post-synth/whm502.js +85 -0
- package/dist/lint/post-synth/whm503.js +82 -0
- package/dist/lint/post-synth/whm504.js +47 -0
- package/dist/lint/rules/chart-metadata.js +53 -0
- package/dist/lint/rules/no-hardcoded-image.js +50 -0
- package/dist/lint/rules/values-no-helm-tpl.js +78 -0
- package/dist/lint/rules/values-no-secrets.js +65 -0
- package/dist/list-artifacts.js +134 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +40 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/cluster-probe.js +116 -0
- package/dist/op/activities/helm.js +449 -0
- package/dist/op/activities/index.js +10 -0
- package/dist/op/builders.js +45 -0
- package/dist/package-cli.js +21 -0
- package/dist/pinnability/actions.js +207 -0
- package/dist/pinnability/classify.js +416 -0
- package/dist/pinnability/conditions.js +0 -0
- package/dist/pinnability/index.js +16 -0
- package/dist/pinnability/localize.js +518 -0
- package/dist/pinnability/render-stream.js +53 -0
- package/dist/pinnability/values.js +151 -0
- package/dist/plugin.js +458 -0
- package/dist/project-codegen.js +215 -0
- package/dist/release-observe.js +287 -0
- package/dist/render-diff.js +279 -0
- package/dist/render-digest.js +0 -0
- package/dist/render-records.js +29 -0
- package/dist/render-store.js +401 -0
- package/dist/render-wrapper.js +164 -0
- package/dist/render.js +394 -0
- package/dist/resources.js +109 -0
- package/dist/serializer.js +903 -0
- package/dist/validate-cli.js +17 -0
- package/dist/validate.js +30 -0
- package/dist/values-probe.js +504 -0
- package/dist/wrapper-chart.js +95 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
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
|
+
}
|
package/dist/coverage.js
ADDED
|
@@ -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", {});
|
package/dist/helpers.js
ADDED
|
@@ -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
|
+
}
|