@intentius/chant-lexicon-helm 0.0.16
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/README.md +22 -0
- package/dist/integrity.json +36 -0
- package/dist/manifest.json +37 -0
- package/dist/meta.json +208 -0
- package/dist/rules/chart-metadata.ts +64 -0
- package/dist/rules/helm-helpers.ts +64 -0
- package/dist/rules/no-hardcoded-image.ts +62 -0
- package/dist/rules/values-no-secrets.ts +82 -0
- package/dist/rules/whm101.ts +46 -0
- package/dist/rules/whm102.ts +33 -0
- package/dist/rules/whm103.ts +59 -0
- package/dist/rules/whm104.ts +35 -0
- package/dist/rules/whm105.ts +30 -0
- package/dist/rules/whm201.ts +36 -0
- package/dist/rules/whm202.ts +50 -0
- package/dist/rules/whm203.ts +39 -0
- package/dist/rules/whm204.ts +60 -0
- package/dist/rules/whm301.ts +41 -0
- package/dist/rules/whm302.ts +40 -0
- package/dist/rules/whm401.ts +57 -0
- package/dist/rules/whm402.ts +45 -0
- package/dist/rules/whm403.ts +45 -0
- package/dist/rules/whm404.ts +36 -0
- package/dist/rules/whm405.ts +53 -0
- package/dist/rules/whm406.ts +34 -0
- package/dist/rules/whm407.ts +83 -0
- package/dist/rules/whm501.ts +103 -0
- package/dist/rules/whm502.ts +94 -0
- package/dist/skills/chant-helm-chart-patterns.md +229 -0
- package/dist/skills/chant-helm-chart-security-patterns.md +192 -0
- package/dist/skills/chant-helm-create-chart.md +211 -0
- package/dist/types/index.d.ts +132 -0
- package/package.json +34 -0
- package/src/codegen/docs-cli.ts +4 -0
- package/src/codegen/docs.ts +483 -0
- package/src/codegen/generate-cli.ts +28 -0
- package/src/codegen/generate.ts +249 -0
- package/src/codegen/naming.ts +38 -0
- package/src/codegen/package.ts +64 -0
- package/src/composites/composites.test.ts +1050 -0
- package/src/composites/helm-batch-job.ts +209 -0
- package/src/composites/helm-crd-lifecycle.ts +184 -0
- package/src/composites/helm-cron-job.ts +177 -0
- package/src/composites/helm-daemon-set.ts +169 -0
- package/src/composites/helm-external-secret.ts +93 -0
- package/src/composites/helm-library.ts +51 -0
- package/src/composites/helm-microservice.ts +331 -0
- package/src/composites/helm-monitored-service.ts +252 -0
- package/src/composites/helm-namespace-env.ts +154 -0
- package/src/composites/helm-secure-ingress.ts +114 -0
- package/src/composites/helm-stateful-service.ts +213 -0
- package/src/composites/helm-web-app.ts +264 -0
- package/src/composites/helm-worker.ts +207 -0
- package/src/composites/index.ts +38 -0
- package/src/coverage.test.ts +21 -0
- package/src/coverage.ts +50 -0
- package/src/generated/index.d.ts +132 -0
- package/src/generated/index.ts +13 -0
- package/src/generated/lexicon-helm.json +208 -0
- package/src/helpers.test.ts +51 -0
- package/src/helpers.ts +100 -0
- package/src/import/generator.ts +285 -0
- package/src/import/import.test.ts +224 -0
- package/src/import/parser.ts +160 -0
- package/src/import/template-stripper.ts +123 -0
- package/src/index.ts +108 -0
- package/src/intrinsics.test.ts +380 -0
- package/src/intrinsics.ts +484 -0
- package/src/lint/post-synth/helm-helpers.ts +64 -0
- package/src/lint/post-synth/post-synth.test.ts +504 -0
- package/src/lint/post-synth/whm101.ts +46 -0
- package/src/lint/post-synth/whm102.ts +33 -0
- package/src/lint/post-synth/whm103.ts +59 -0
- package/src/lint/post-synth/whm104.ts +35 -0
- package/src/lint/post-synth/whm105.ts +30 -0
- package/src/lint/post-synth/whm201.ts +36 -0
- package/src/lint/post-synth/whm202.ts +50 -0
- package/src/lint/post-synth/whm203.ts +39 -0
- package/src/lint/post-synth/whm204.ts +60 -0
- package/src/lint/post-synth/whm301.ts +41 -0
- package/src/lint/post-synth/whm302.ts +40 -0
- package/src/lint/post-synth/whm401.ts +57 -0
- package/src/lint/post-synth/whm402.ts +45 -0
- package/src/lint/post-synth/whm403.ts +45 -0
- package/src/lint/post-synth/whm404.ts +36 -0
- package/src/lint/post-synth/whm405.ts +53 -0
- package/src/lint/post-synth/whm406.ts +34 -0
- package/src/lint/post-synth/whm407.ts +83 -0
- package/src/lint/post-synth/whm501.ts +103 -0
- package/src/lint/post-synth/whm502.ts +94 -0
- package/src/lint/rules/chart-metadata.ts +64 -0
- package/src/lint/rules/lint-rules.test.ts +97 -0
- package/src/lint/rules/no-hardcoded-image.ts +62 -0
- package/src/lint/rules/values-no-secrets.ts +82 -0
- package/src/lsp/completions.test.ts +72 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +46 -0
- package/src/lsp/hover.ts +46 -0
- package/src/package-cli.ts +28 -0
- package/src/plugin.test.ts +71 -0
- package/src/plugin.ts +206 -0
- package/src/resources.ts +77 -0
- package/src/serializer.test.ts +930 -0
- package/src/serializer.ts +835 -0
- package/src/skills/chart-patterns.md +229 -0
- package/src/skills/chart-security-patterns.md +192 -0
- package/src/skills/create-chart.md +211 -0
- package/src/validate-cli.ts +21 -0
- package/src/validate.test.ts +37 -0
- package/src/validate.ts +36 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM501: Unused values keys.
|
|
3
|
+
*
|
|
4
|
+
* Parses values.yaml key paths and scans all template files for
|
|
5
|
+
* `.Values.` references. Keys defined but never referenced produce
|
|
6
|
+
* an info diagnostic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getChartFiles } from "./helm-helpers";
|
|
11
|
+
|
|
12
|
+
/** Keys that are always implicitly used (e.g. in _helpers.tpl). */
|
|
13
|
+
const IMPLICIT_KEYS = new Set(["nameOverride", "fullnameOverride"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract all top-level and nested key paths from values.yaml content.
|
|
17
|
+
* Uses indentation to track nesting (2-space indent per level).
|
|
18
|
+
*/
|
|
19
|
+
function extractValuePaths(content: string): string[] {
|
|
20
|
+
const paths: string[] = [];
|
|
21
|
+
const stack: string[] = [];
|
|
22
|
+
let prevIndent = -1;
|
|
23
|
+
|
|
24
|
+
for (const line of content.split("\n")) {
|
|
25
|
+
const trimmed = line.trimStart();
|
|
26
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
|
|
27
|
+
|
|
28
|
+
const indent = line.length - trimmed.length;
|
|
29
|
+
const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/);
|
|
30
|
+
if (!match) continue;
|
|
31
|
+
|
|
32
|
+
const key = match[1];
|
|
33
|
+
|
|
34
|
+
// Adjust stack based on indentation
|
|
35
|
+
while (stack.length > 0 && indent <= prevIndent) {
|
|
36
|
+
stack.pop();
|
|
37
|
+
prevIndent -= 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
stack.push(key);
|
|
41
|
+
paths.push(stack.join("."));
|
|
42
|
+
prevIndent = indent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return paths;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const whm501: PostSynthCheck = {
|
|
49
|
+
id: "WHM501",
|
|
50
|
+
description: "Detect values keys that are defined but never referenced in templates",
|
|
51
|
+
|
|
52
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
53
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
54
|
+
|
|
55
|
+
for (const [, output] of ctx.outputs) {
|
|
56
|
+
const files = getChartFiles(output);
|
|
57
|
+
const valuesContent = files["values.yaml"];
|
|
58
|
+
if (!valuesContent || valuesContent.trim() === "{}" || valuesContent.trim() === "") continue;
|
|
59
|
+
|
|
60
|
+
const definedPaths = extractValuePaths(valuesContent);
|
|
61
|
+
if (definedPaths.length === 0) continue;
|
|
62
|
+
|
|
63
|
+
// Collect all .Values references from templates
|
|
64
|
+
const referencedPaths = new Set<string>();
|
|
65
|
+
const valuesRegex = /\.Values\.([a-zA-Z0-9_.]+)/g;
|
|
66
|
+
|
|
67
|
+
for (const [filename, content] of Object.entries(files)) {
|
|
68
|
+
if (!filename.startsWith("templates/")) continue;
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = valuesRegex.exec(content)) !== null) {
|
|
71
|
+
referencedPaths.add(match[1]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const path of definedPaths) {
|
|
76
|
+
if (IMPLICIT_KEYS.has(path)) continue;
|
|
77
|
+
|
|
78
|
+
// Check if this path or any child is referenced
|
|
79
|
+
const isReferenced = referencedPaths.has(path) ||
|
|
80
|
+
[...referencedPaths].some((ref) => ref.startsWith(path + "."));
|
|
81
|
+
|
|
82
|
+
// Check if any parent of this path is referenced (parent consumed entirely)
|
|
83
|
+
const parts = path.split(".");
|
|
84
|
+
const parentReferenced = parts.some((_, i) => {
|
|
85
|
+
if (i === parts.length - 1) return false;
|
|
86
|
+
return referencedPaths.has(parts.slice(0, i + 1).join("."));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!isReferenced && !parentReferenced) {
|
|
90
|
+
diagnostics.push({
|
|
91
|
+
checkId: "WHM501",
|
|
92
|
+
severity: "info",
|
|
93
|
+
message: `values.yaml defines "${path}" but it is never referenced in templates`,
|
|
94
|
+
entity: "values.yaml",
|
|
95
|
+
lexicon: "helm",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return diagnostics;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM502: K8s API version/kind validation.
|
|
3
|
+
*
|
|
4
|
+
* Checks for deprecated or invalid Kubernetes API versions in templates
|
|
5
|
+
* and suggests replacements.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getChartFiles } from "./helm-helpers";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Deprecated API versions with their replacements.
|
|
13
|
+
*/
|
|
14
|
+
const DEPRECATED_APIS: Record<string, { replacement: string; kinds: string[] }> = {
|
|
15
|
+
"extensions/v1beta1": {
|
|
16
|
+
replacement: "networking.k8s.io/v1",
|
|
17
|
+
kinds: ["Ingress"],
|
|
18
|
+
},
|
|
19
|
+
"networking.k8s.io/v1beta1": {
|
|
20
|
+
replacement: "networking.k8s.io/v1",
|
|
21
|
+
kinds: ["Ingress", "IngressClass"],
|
|
22
|
+
},
|
|
23
|
+
"apps/v1beta1": {
|
|
24
|
+
replacement: "apps/v1",
|
|
25
|
+
kinds: ["Deployment", "StatefulSet"],
|
|
26
|
+
},
|
|
27
|
+
"apps/v1beta2": {
|
|
28
|
+
replacement: "apps/v1",
|
|
29
|
+
kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"],
|
|
30
|
+
},
|
|
31
|
+
"rbac.authorization.k8s.io/v1beta1": {
|
|
32
|
+
replacement: "rbac.authorization.k8s.io/v1",
|
|
33
|
+
kinds: ["ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding"],
|
|
34
|
+
},
|
|
35
|
+
"admissionregistration.k8s.io/v1beta1": {
|
|
36
|
+
replacement: "admissionregistration.k8s.io/v1",
|
|
37
|
+
kinds: ["MutatingWebhookConfiguration", "ValidatingWebhookConfiguration"],
|
|
38
|
+
},
|
|
39
|
+
"batch/v1beta1": {
|
|
40
|
+
replacement: "batch/v1",
|
|
41
|
+
kinds: ["CronJob"],
|
|
42
|
+
},
|
|
43
|
+
"policy/v1beta1": {
|
|
44
|
+
replacement: "policy/v1",
|
|
45
|
+
kinds: ["PodDisruptionBudget", "PodSecurityPolicy"],
|
|
46
|
+
},
|
|
47
|
+
"autoscaling/v2beta1": {
|
|
48
|
+
replacement: "autoscaling/v2",
|
|
49
|
+
kinds: ["HorizontalPodAutoscaler"],
|
|
50
|
+
},
|
|
51
|
+
"autoscaling/v2beta2": {
|
|
52
|
+
replacement: "autoscaling/v2",
|
|
53
|
+
kinds: ["HorizontalPodAutoscaler"],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const whm502: PostSynthCheck = {
|
|
58
|
+
id: "WHM502",
|
|
59
|
+
description: "Detect deprecated or invalid Kubernetes API versions",
|
|
60
|
+
|
|
61
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
62
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
63
|
+
|
|
64
|
+
for (const [, output] of ctx.outputs) {
|
|
65
|
+
const files = getChartFiles(output);
|
|
66
|
+
|
|
67
|
+
for (const [filename, content] of Object.entries(files)) {
|
|
68
|
+
if (!filename.startsWith("templates/") || filename.endsWith("_helpers.tpl") || filename.endsWith("NOTES.txt")) continue;
|
|
69
|
+
|
|
70
|
+
// Extract apiVersion from template
|
|
71
|
+
const apiVersionMatch = content.match(/apiVersion:\s*(.+)/);
|
|
72
|
+
if (!apiVersionMatch) continue;
|
|
73
|
+
|
|
74
|
+
const apiVersion = apiVersionMatch[1].trim();
|
|
75
|
+
|
|
76
|
+
// Skip template expressions
|
|
77
|
+
if (apiVersion.includes("{{")) continue;
|
|
78
|
+
|
|
79
|
+
const deprecation = DEPRECATED_APIS[apiVersion];
|
|
80
|
+
if (deprecation) {
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
checkId: "WHM502",
|
|
83
|
+
severity: "warning",
|
|
84
|
+
message: `${filename}: apiVersion "${apiVersion}" is deprecated — use "${deprecation.replacement}" instead`,
|
|
85
|
+
entity: filename,
|
|
86
|
+
lexicon: "helm",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return diagnostics;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM001: Chart Metadata Required
|
|
3
|
+
*
|
|
4
|
+
* Detects Chart constructors missing required fields: name, version, apiVersion.
|
|
5
|
+
*
|
|
6
|
+
* Bad: new Chart({})
|
|
7
|
+
* Good: new Chart({ apiVersion: "v2", name: "my-app", version: "0.1.0" })
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
11
|
+
import * as ts from "typescript";
|
|
12
|
+
|
|
13
|
+
const REQUIRED_FIELDS = ["name", "version", "apiVersion"];
|
|
14
|
+
|
|
15
|
+
export const chartMetadataRule: LintRule = {
|
|
16
|
+
id: "WHM001",
|
|
17
|
+
severity: "error",
|
|
18
|
+
category: "correctness",
|
|
19
|
+
description:
|
|
20
|
+
"Chart must have name, version, and apiVersion fields",
|
|
21
|
+
|
|
22
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
23
|
+
const { sourceFile } = context;
|
|
24
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
25
|
+
|
|
26
|
+
function visit(node: ts.Node): void {
|
|
27
|
+
// Look for `new Chart({ ... })`
|
|
28
|
+
if (
|
|
29
|
+
ts.isNewExpression(node) &&
|
|
30
|
+
ts.isIdentifier(node.expression) &&
|
|
31
|
+
node.expression.text === "Chart" &&
|
|
32
|
+
node.arguments &&
|
|
33
|
+
node.arguments.length > 0
|
|
34
|
+
) {
|
|
35
|
+
const arg = node.arguments[0];
|
|
36
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
37
|
+
const presentKeys = new Set<string>();
|
|
38
|
+
for (const prop of arg.properties) {
|
|
39
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
40
|
+
presentKeys.add(prop.name.text);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const missing = REQUIRED_FIELDS.filter((f) => !presentKeys.has(f));
|
|
45
|
+
if (missing.length > 0) {
|
|
46
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
47
|
+
diagnostics.push({
|
|
48
|
+
file: sourceFile.fileName,
|
|
49
|
+
line: line + 1,
|
|
50
|
+
column: character + 1,
|
|
51
|
+
ruleId: "WHM001",
|
|
52
|
+
severity: "error",
|
|
53
|
+
message: `Chart is missing required fields: ${missing.join(", ")}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
ts.forEachChild(node, visit);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
visit(sourceFile);
|
|
62
|
+
return diagnostics;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
4
|
+
import { chartMetadataRule } from "./chart-metadata";
|
|
5
|
+
import { valuesNoSecretsRule } from "./values-no-secrets";
|
|
6
|
+
import { noHardcodedImageRule } from "./no-hardcoded-image";
|
|
7
|
+
|
|
8
|
+
function makeContext(code: string): LintContext {
|
|
9
|
+
const sourceFile = ts.createSourceFile("test.ts", code, ts.ScriptTarget.Latest, true);
|
|
10
|
+
return { sourceFile, entities: [], filePath: "test.ts" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("WHM001: chartMetadataRule", () => {
|
|
14
|
+
test("passes when all required fields present", () => {
|
|
15
|
+
const ctx = makeContext(`new Chart({ apiVersion: "v2", name: "my-app", version: "0.1.0" });`);
|
|
16
|
+
expect(chartMetadataRule.check(ctx)).toHaveLength(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("fails when name is missing", () => {
|
|
20
|
+
const ctx = makeContext(`new Chart({ apiVersion: "v2", version: "0.1.0" });`);
|
|
21
|
+
const diags = chartMetadataRule.check(ctx);
|
|
22
|
+
expect(diags).toHaveLength(1);
|
|
23
|
+
expect(diags[0].message).toContain("name");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("fails when all fields are missing", () => {
|
|
27
|
+
const ctx = makeContext(`new Chart({});`);
|
|
28
|
+
const diags = chartMetadataRule.check(ctx);
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].message).toContain("name");
|
|
31
|
+
expect(diags[0].message).toContain("version");
|
|
32
|
+
expect(diags[0].message).toContain("apiVersion");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("ignores non-Chart constructors", () => {
|
|
36
|
+
const ctx = makeContext(`new Deployment({ name: "test" });`);
|
|
37
|
+
expect(chartMetadataRule.check(ctx)).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("WHM002: valuesNoSecretsRule", () => {
|
|
42
|
+
test("passes with empty values", () => {
|
|
43
|
+
const ctx = makeContext(`new Values({});`);
|
|
44
|
+
expect(valuesNoSecretsRule.check(ctx)).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("warns on hardcoded password", () => {
|
|
48
|
+
const ctx = makeContext(`new Values({ password: "hunter2" });`);
|
|
49
|
+
const diags = valuesNoSecretsRule.check(ctx);
|
|
50
|
+
expect(diags).toHaveLength(1);
|
|
51
|
+
expect(diags[0].ruleId).toBe("WHM002");
|
|
52
|
+
expect(diags[0].message).toContain("password");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("warns on hardcoded secret in nested object", () => {
|
|
56
|
+
const ctx = makeContext(`new Values({ db: { secret: "s3cret" } });`);
|
|
57
|
+
const diags = valuesNoSecretsRule.check(ctx);
|
|
58
|
+
expect(diags).toHaveLength(1);
|
|
59
|
+
expect(diags[0].message).toContain("secret");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("passes when secret value is empty", () => {
|
|
63
|
+
const ctx = makeContext(`new Values({ password: "" });`);
|
|
64
|
+
expect(valuesNoSecretsRule.check(ctx)).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("passes for non-sensitive keys", () => {
|
|
68
|
+
const ctx = makeContext(`new Values({ replicaCount: 3, name: "test" });`);
|
|
69
|
+
expect(valuesNoSecretsRule.check(ctx)).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("WHM003: noHardcodedImageRule", () => {
|
|
74
|
+
test("warns on hardcoded image with tag", () => {
|
|
75
|
+
const ctx = makeContext(`new Deployment({ spec: { containers: [{ image: "nginx:1.19" }] } });`);
|
|
76
|
+
const diags = noHardcodedImageRule.check(ctx);
|
|
77
|
+
expect(diags).toHaveLength(1);
|
|
78
|
+
expect(diags[0].ruleId).toBe("WHM003");
|
|
79
|
+
expect(diags[0].message).toContain("nginx:1.19");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("warns on registry/image:tag format", () => {
|
|
83
|
+
const ctx = makeContext(`({ image: "registry.io/app:latest" })`);
|
|
84
|
+
const diags = noHardcodedImageRule.check(ctx);
|
|
85
|
+
expect(diags).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("passes when image is not a string literal (intrinsic)", () => {
|
|
89
|
+
const ctx = makeContext(`({ image: printf("%s:%s", values.image.repo, values.image.tag) })`);
|
|
90
|
+
expect(noHardcodedImageRule.check(ctx)).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("passes for image key without colon-tag pattern", () => {
|
|
94
|
+
const ctx = makeContext(`({ image: "just-a-name" })`);
|
|
95
|
+
expect(noHardcodedImageRule.check(ctx)).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM003: Container Images Should Use Values References
|
|
3
|
+
*
|
|
4
|
+
* Detects container image strings that are hardcoded in K8s resource
|
|
5
|
+
* constructors instead of using values references. In Helm charts, images
|
|
6
|
+
* should be parameterized via values.yaml so they can be overridden at
|
|
7
|
+
* install time.
|
|
8
|
+
*
|
|
9
|
+
* Bad: image: "nginx:1.19"
|
|
10
|
+
* Good: image: printf("%s:%s", values.image.repository, values.image.tag)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
14
|
+
import * as ts from "typescript";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pattern matching common container image references.
|
|
18
|
+
* Matches strings like "nginx:1.19", "registry.io/app:latest", etc.
|
|
19
|
+
*/
|
|
20
|
+
const IMAGE_PATTERN = /^[a-z0-9][a-z0-9._/-]*:[a-z0-9][a-z0-9._-]*$/i;
|
|
21
|
+
|
|
22
|
+
export const noHardcodedImageRule: LintRule = {
|
|
23
|
+
id: "WHM003",
|
|
24
|
+
severity: "warning",
|
|
25
|
+
category: "correctness",
|
|
26
|
+
description:
|
|
27
|
+
"Container images should use values references, not hardcoded tags",
|
|
28
|
+
|
|
29
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
30
|
+
const { sourceFile } = context;
|
|
31
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
32
|
+
|
|
33
|
+
function visit(node: ts.Node): void {
|
|
34
|
+
// Look for property assignments: `image: "nginx:1.19"`
|
|
35
|
+
if (
|
|
36
|
+
ts.isPropertyAssignment(node) &&
|
|
37
|
+
ts.isIdentifier(node.name) &&
|
|
38
|
+
node.name.text === "image" &&
|
|
39
|
+
ts.isStringLiteral(node.initializer)
|
|
40
|
+
) {
|
|
41
|
+
const value = node.initializer.text;
|
|
42
|
+
if (IMAGE_PATTERN.test(value)) {
|
|
43
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
|
|
44
|
+
node.initializer.getStart(),
|
|
45
|
+
);
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
file: sourceFile.fileName,
|
|
48
|
+
line: line + 1,
|
|
49
|
+
column: character + 1,
|
|
50
|
+
ruleId: "WHM003",
|
|
51
|
+
severity: "warning",
|
|
52
|
+
message: `Hardcoded image "${value}" — use values references (e.g. printf("%s:%s", values.image.repository, values.image.tag)) for Helm chart parameterization`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
ts.forEachChild(node, visit);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
visit(sourceFile);
|
|
60
|
+
return diagnostics;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHM002: Values Should Not Contain Bare Secrets
|
|
3
|
+
*
|
|
4
|
+
* Detects Values constructor props that contain keys suggesting sensitive
|
|
5
|
+
* data (password, token, key, secret, apiKey) without an `existingSecret`
|
|
6
|
+
* pattern — hardcoded secrets in values.yaml are a security anti-pattern.
|
|
7
|
+
*
|
|
8
|
+
* Bad: new Values({ dbPassword: "hunter2" })
|
|
9
|
+
* Good: new Values({ existingSecret: "", dbPasswordKey: "password" })
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
13
|
+
import * as ts from "typescript";
|
|
14
|
+
|
|
15
|
+
const SENSITIVE_KEY_PATTERN = /^(password|secret|token|apiKey|api_key|private_key|privateKey)$/i;
|
|
16
|
+
const SAFE_PATTERNS = /existing|ref|key_name|keyName|secretName/i;
|
|
17
|
+
|
|
18
|
+
export const valuesNoSecretsRule: LintRule = {
|
|
19
|
+
id: "WHM002",
|
|
20
|
+
severity: "warning",
|
|
21
|
+
category: "security",
|
|
22
|
+
description:
|
|
23
|
+
"Values should not contain bare secrets — use existingSecret pattern instead",
|
|
24
|
+
|
|
25
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
26
|
+
const { sourceFile } = context;
|
|
27
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
28
|
+
|
|
29
|
+
function visit(node: ts.Node): void {
|
|
30
|
+
// Look for `new Values({ ... })`
|
|
31
|
+
if (
|
|
32
|
+
ts.isNewExpression(node) &&
|
|
33
|
+
ts.isIdentifier(node.expression) &&
|
|
34
|
+
node.expression.text === "Values" &&
|
|
35
|
+
node.arguments &&
|
|
36
|
+
node.arguments.length > 0
|
|
37
|
+
) {
|
|
38
|
+
const arg = node.arguments[0];
|
|
39
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
40
|
+
checkObjectLiteral(arg, sourceFile, diagnostics);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(node, visit);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
visit(sourceFile);
|
|
47
|
+
return diagnostics;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function checkObjectLiteral(
|
|
52
|
+
obj: ts.ObjectLiteralExpression,
|
|
53
|
+
sourceFile: ts.SourceFile,
|
|
54
|
+
diagnostics: LintDiagnostic[],
|
|
55
|
+
): void {
|
|
56
|
+
for (const prop of obj.properties) {
|
|
57
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
58
|
+
const key = prop.name.text;
|
|
59
|
+
|
|
60
|
+
// Check if the key looks sensitive
|
|
61
|
+
if (SENSITIVE_KEY_PATTERN.test(key) && !SAFE_PATTERNS.test(key)) {
|
|
62
|
+
// Check if the value is a string literal (hardcoded secret)
|
|
63
|
+
if (ts.isStringLiteral(prop.initializer) && prop.initializer.text !== "") {
|
|
64
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(prop.getStart());
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
file: sourceFile.fileName,
|
|
67
|
+
line: line + 1,
|
|
68
|
+
column: character + 1,
|
|
69
|
+
ruleId: "WHM002",
|
|
70
|
+
severity: "warning",
|
|
71
|
+
message: `Values key "${key}" contains a hardcoded secret — use existingSecret pattern or leave empty as default`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Recurse into nested objects
|
|
77
|
+
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
78
|
+
checkObjectLiteral(prop.initializer, sourceFile, diagnostics);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
7
|
+
const hasGenerated = existsSync(join(pkgDir, "src", "generated", "lexicon-helm.json"));
|
|
8
|
+
|
|
9
|
+
describe.skipIf(!hasGenerated)("helmCompletions", () => {
|
|
10
|
+
test("returns resource completions for `new ` prefix", async () => {
|
|
11
|
+
const { helmCompletions } = await import("./completions");
|
|
12
|
+
const ctx = {
|
|
13
|
+
uri: "file:///test.ts",
|
|
14
|
+
content: "const c = new Chart",
|
|
15
|
+
position: { line: 0, character: 19 },
|
|
16
|
+
wordAtCursor: "Chart",
|
|
17
|
+
linePrefix: "const c = new Chart",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const items = helmCompletions(ctx);
|
|
21
|
+
expect(items.length).toBeGreaterThan(0);
|
|
22
|
+
|
|
23
|
+
const chartItem = items.find((i) => i.label === "Chart");
|
|
24
|
+
expect(chartItem).toBeDefined();
|
|
25
|
+
expect(chartItem?.kind).toBe("resource");
|
|
26
|
+
expect(chartItem?.detail).toContain("Helm::Chart");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("filters resource completions by prefix", async () => {
|
|
30
|
+
const { helmCompletions } = await import("./completions");
|
|
31
|
+
const ctx = {
|
|
32
|
+
uri: "file:///test.ts",
|
|
33
|
+
content: "const h = new Helm",
|
|
34
|
+
position: { line: 0, character: 18 },
|
|
35
|
+
wordAtCursor: "Helm",
|
|
36
|
+
linePrefix: "const h = new Helm",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const items = helmCompletions(ctx);
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
expect(item.label.toLowerCase().startsWith("helm")).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("limits results to 50", async () => {
|
|
46
|
+
const { helmCompletions } = await import("./completions");
|
|
47
|
+
const ctx = {
|
|
48
|
+
uri: "file:///test.ts",
|
|
49
|
+
content: "const x = new ",
|
|
50
|
+
position: { line: 0, character: 14 },
|
|
51
|
+
wordAtCursor: "",
|
|
52
|
+
linePrefix: "const x = new ",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const items = helmCompletions(ctx);
|
|
56
|
+
expect(items.length).toBeLessThanOrEqual(50);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns empty for non-matching context", async () => {
|
|
60
|
+
const { helmCompletions } = await import("./completions");
|
|
61
|
+
const ctx = {
|
|
62
|
+
uri: "file:///test.ts",
|
|
63
|
+
content: "const x = 42",
|
|
64
|
+
position: { line: 0, character: 13 },
|
|
65
|
+
wordAtCursor: "42",
|
|
66
|
+
linePrefix: "const x = 42",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const items = helmCompletions(ctx);
|
|
70
|
+
expect(items.length).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
7
|
+
|
|
8
|
+
function getIndex(): LexiconIndex {
|
|
9
|
+
if (cachedIndex) return cachedIndex;
|
|
10
|
+
const data = require("../generated/lexicon-helm.json") as Record<string, LexiconEntry>;
|
|
11
|
+
cachedIndex = new LexiconIndex(data);
|
|
12
|
+
return cachedIndex;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provide Helm resource completions based on context.
|
|
17
|
+
*/
|
|
18
|
+
export function helmCompletions(ctx: CompletionContext): CompletionItem[] {
|
|
19
|
+
return lexiconCompletions(ctx, getIndex(), "Helm chart resource");
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
7
|
+
const hasGenerated = existsSync(join(pkgDir, "src", "generated", "lexicon-helm.json"));
|
|
8
|
+
|
|
9
|
+
describe.skipIf(!hasGenerated)("helmHover", () => {
|
|
10
|
+
test("returns hover info for Chart", async () => {
|
|
11
|
+
const { helmHover } = await import("./hover");
|
|
12
|
+
const info = helmHover({ uri: "file:///test.ts", content: "", position: { line: 0, character: 0 }, word: "Chart", lineText: "" });
|
|
13
|
+
|
|
14
|
+
expect(info).toBeDefined();
|
|
15
|
+
expect(info!.contents).toContain("Chart");
|
|
16
|
+
expect(info!.contents).toContain("Helm::Chart");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("shows resource kind for resource types", async () => {
|
|
20
|
+
const { helmHover } = await import("./hover");
|
|
21
|
+
const info = helmHover({ uri: "file:///test.ts", content: "", position: { line: 0, character: 0 }, word: "Values", lineText: "" });
|
|
22
|
+
|
|
23
|
+
expect(info).toBeDefined();
|
|
24
|
+
expect(info!.contents).toContain("Resource");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns undefined for unknown word", async () => {
|
|
28
|
+
const { helmHover } = await import("./hover");
|
|
29
|
+
const info = helmHover({ uri: "file:///test.ts", content: "", position: { line: 0, character: 0 }, word: "NotARealResource12345", lineText: "" });
|
|
30
|
+
expect(info).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns undefined for empty word", async () => {
|
|
34
|
+
const { helmHover } = await import("./hover");
|
|
35
|
+
const info = helmHover({ uri: "file:///test.ts", content: "", position: { line: 0, character: 0 }, word: "", lineText: "" });
|
|
36
|
+
expect(info).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns info for HelmHook property type", async () => {
|
|
40
|
+
const { helmHover } = await import("./hover");
|
|
41
|
+
const info = helmHover({ uri: "file:///test.ts", content: "", position: { line: 0, character: 0 }, word: "HelmHook", lineText: "" });
|
|
42
|
+
|
|
43
|
+
expect(info).toBeDefined();
|
|
44
|
+
expect(info!.contents).toContain("Helm::Hook");
|
|
45
|
+
});
|
|
46
|
+
});
|