@intentius/chant-lexicon-k8s 0.1.14 → 0.1.15
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 +0 -1
- package/dist/integrity.json +13 -5
- package/dist/manifest.json +1 -1
- package/dist/meta.json +216 -0
- package/dist/rules/argo-appset-single-project.ts +66 -0
- package/dist/rules/argo-ast.ts +121 -0
- package/dist/rules/argo-automated-prune.ts +75 -0
- package/dist/rules/argo-helpers.ts +49 -0
- package/dist/rules/argo002.ts +47 -0
- package/dist/rules/argo003.ts +80 -0
- package/dist/rules/argo005.ts +59 -0
- package/dist/skills/chant-k8s-argo.md +176 -0
- package/dist/types/index.d.ts +34 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +13 -0
- package/src/codegen/versions.ts +8 -5
- package/src/composites/argo-app.ts +380 -0
- package/src/composites/composites.test.ts +136 -0
- package/src/composites/index.ts +12 -0
- package/src/crd/crd-sources.ts +18 -0
- package/src/crd/loader.ts +4 -5
- package/src/crd/parser.test.ts +61 -0
- package/src/crd/parser.ts +37 -2
- package/src/describe-resources.ts +8 -1
- package/src/export-resources-io.test.ts +72 -0
- package/src/export-resources.ts +60 -0
- package/src/generated/index.d.ts +34 -0
- package/src/generated/index.ts +25 -0
- package/src/generated/lexicon-k8s.json +216 -0
- package/src/import/live-export.test.ts +114 -0
- package/src/import/live-export.ts +89 -0
- package/src/index.ts +5 -0
- package/src/lifecycle-integration.test.ts +111 -0
- package/src/lint/post-synth/argo-helpers.ts +49 -0
- package/src/lint/post-synth/argo002.ts +47 -0
- package/src/lint/post-synth/argo003.ts +80 -0
- package/src/lint/post-synth/argo005.ts +59 -0
- package/src/lint/post-synth/post-synth.test.ts +123 -0
- package/src/lint/rules/argo-appset-single-project.ts +66 -0
- package/src/lint/rules/argo-ast.ts +121 -0
- package/src/lint/rules/argo-automated-prune.ts +75 -0
- package/src/lint/rules/rules.test.ts +109 -0
- package/src/plugin.test.ts +6 -1
- package/src/plugin.ts +44 -1
- package/src/serializer-ownership.test.ts +44 -0
- package/src/serializer.test.ts +25 -0
- package/src/serializer.ts +9 -4
- package/src/skills/chant-k8s-argo.md +176 -0
|
@@ -28,6 +28,9 @@ import { wk8306 } from "./wk8306";
|
|
|
28
28
|
import { wk8401 } from "./wk8401";
|
|
29
29
|
import { wk8402 } from "./wk8402";
|
|
30
30
|
import { wk8403 } from "./wk8403";
|
|
31
|
+
import { argo002 } from "./argo002";
|
|
32
|
+
import { argo003 } from "./argo003";
|
|
33
|
+
import { argo005 } from "./argo005";
|
|
31
34
|
|
|
32
35
|
function makeCtx(yaml: string): PostSynthContext {
|
|
33
36
|
return {
|
|
@@ -43,6 +46,11 @@ function makeCtx(yaml: string): PostSynthContext {
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** Join several manifests (objects) into a multi-document YAML string. */
|
|
50
|
+
function manifestsCtx(...objs: unknown[]): PostSynthContext {
|
|
51
|
+
return makeCtx(objs.map((o) => JSON.stringify(o)).join("\n---\n"));
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
// ── WK8005: Secrets in env ──────────────────────────────────────────
|
|
47
55
|
// Note: Tests with nested container properties (env, resources, securityContext,
|
|
48
56
|
// ports, probes) use JSON format because the core parseYAML line-based parser
|
|
@@ -1476,3 +1484,118 @@ describe("WK8403: spec.rayVersion does not match image tag", () => {
|
|
|
1476
1484
|
expect(diags.filter((d) => d.checkId === "WK8403").length).toBe(0);
|
|
1477
1485
|
});
|
|
1478
1486
|
});
|
|
1487
|
+
|
|
1488
|
+
// ── ARGO002: Application.spec.project references a declared AppProject ─────────
|
|
1489
|
+
|
|
1490
|
+
function argoApp(name: string, spec: Record<string, unknown>) {
|
|
1491
|
+
return { apiVersion: "argoproj.io/v1alpha1", kind: "Application", metadata: { name }, spec };
|
|
1492
|
+
}
|
|
1493
|
+
function appProject(name: string) {
|
|
1494
|
+
return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", metadata: { name }, spec: {} };
|
|
1495
|
+
}
|
|
1496
|
+
const inClusterDest = { server: "https://kubernetes.default.svc", namespace: "demo" };
|
|
1497
|
+
|
|
1498
|
+
describe("ARGO002: Application project references a declared AppProject", () => {
|
|
1499
|
+
test("metadata", () => {
|
|
1500
|
+
expect(argo002.id).toBe("ARGO002");
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
test("flags an Application referencing an undeclared project", () => {
|
|
1504
|
+
const ctx = manifestsCtx(argoApp("api", { project: "team-a", destination: inClusterDest }));
|
|
1505
|
+
const diags = argo002.check(ctx);
|
|
1506
|
+
expect(diags.length).toBe(1);
|
|
1507
|
+
expect(diags[0].checkId).toBe("ARGO002");
|
|
1508
|
+
expect(diags[0].message).toContain("team-a");
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
test("passes when the AppProject is declared in the same build", () => {
|
|
1512
|
+
const ctx = manifestsCtx(
|
|
1513
|
+
appProject("team-a"),
|
|
1514
|
+
argoApp("api", { project: "team-a", destination: inClusterDest }),
|
|
1515
|
+
);
|
|
1516
|
+
expect(argo002.check(ctx).length).toBe(0);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
test("does NOT flag the built-in default project", () => {
|
|
1520
|
+
const ctx = manifestsCtx(argoApp("api", { project: "default", destination: inClusterDest }));
|
|
1521
|
+
expect(argo002.check(ctx).length).toBe(0);
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
// ── ARGO003: Application destination references a registered cluster ──────────
|
|
1526
|
+
|
|
1527
|
+
function clusterSecret(name: string, server: string) {
|
|
1528
|
+
return {
|
|
1529
|
+
apiVersion: "v1",
|
|
1530
|
+
kind: "Secret",
|
|
1531
|
+
metadata: { name, labels: { "argocd.argoproj.io/secret-type": "cluster" } },
|
|
1532
|
+
stringData: { name, server },
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
describe("ARGO003: Application destination references a registered cluster", () => {
|
|
1537
|
+
test("metadata", () => {
|
|
1538
|
+
expect(argo003.id).toBe("ARGO003");
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
test("does NOT flag the in-cluster destination", () => {
|
|
1542
|
+
const ctx = manifestsCtx(argoApp("api", { project: "default", destination: inClusterDest }));
|
|
1543
|
+
expect(argo003.check(ctx).length).toBe(0);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
test("flags an unregistered cluster server", () => {
|
|
1547
|
+
const ctx = manifestsCtx(
|
|
1548
|
+
argoApp("api", { project: "default", destination: { server: "https://prod.example.com", namespace: "demo" } }),
|
|
1549
|
+
);
|
|
1550
|
+
const diags = argo003.check(ctx);
|
|
1551
|
+
expect(diags.length).toBe(1);
|
|
1552
|
+
expect(diags[0].checkId).toBe("ARGO003");
|
|
1553
|
+
expect(diags[0].message).toContain("prod.example.com");
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test("passes when the cluster is registered via a cluster Secret", () => {
|
|
1557
|
+
const ctx = manifestsCtx(
|
|
1558
|
+
clusterSecret("prod", "https://prod.example.com"),
|
|
1559
|
+
argoApp("api", { project: "default", destination: { server: "https://prod.example.com", namespace: "demo" } }),
|
|
1560
|
+
);
|
|
1561
|
+
expect(argo003.check(ctx).length).toBe(0);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test("flags a destination with neither server nor name", () => {
|
|
1565
|
+
const ctx = manifestsCtx(argoApp("api", { project: "default", destination: { namespace: "demo" } }));
|
|
1566
|
+
expect(argo003.check(ctx).length).toBe(1);
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
// ── ARGO005: Application source.path resolves to an existing directory ────────
|
|
1571
|
+
|
|
1572
|
+
describe("ARGO005: Application source path exists", () => {
|
|
1573
|
+
test("metadata", () => {
|
|
1574
|
+
expect(argo005.id).toBe("ARGO005");
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
test("does NOT flag a path that exists under the build root", () => {
|
|
1578
|
+
// `lexicons` is a directory at the repo root (the vitest cwd).
|
|
1579
|
+
const ctx = manifestsCtx(
|
|
1580
|
+
argoApp("api", { project: "default", destination: inClusterDest, source: { repoURL: "x", path: "lexicons" } }),
|
|
1581
|
+
);
|
|
1582
|
+
expect(argo005.check(ctx).length).toBe(0);
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
test("warns on a path that does not resolve to a directory", () => {
|
|
1586
|
+
const ctx = manifestsCtx(
|
|
1587
|
+
argoApp("api", { project: "default", destination: inClusterDest, source: { repoURL: "x", path: "no-such-argo-dir-xyz" } }),
|
|
1588
|
+
);
|
|
1589
|
+
const diags = argo005.check(ctx);
|
|
1590
|
+
expect(diags.length).toBe(1);
|
|
1591
|
+
expect(diags[0].checkId).toBe("ARGO005");
|
|
1592
|
+
expect(diags[0].severity).toBe("warning");
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
test("skips Helm chart sources", () => {
|
|
1596
|
+
const ctx = manifestsCtx(
|
|
1597
|
+
argoApp("api", { project: "default", destination: inClusterDest, source: { repoURL: "x", chart: "redis" } }),
|
|
1598
|
+
);
|
|
1599
|
+
expect(argo005.check(ctx).length).toBe(0);
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
2
|
+
import { findResourceLiterals, getNestedObject, getNestedString, lineCol } from "./argo-ast";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ARGO004: ApplicationSet template must scope to a single AppProject
|
|
6
|
+
*
|
|
7
|
+
* An `ApplicationSet` generates many Applications from one template. The
|
|
8
|
+
* template's `spec.project` should name a single, static `AppProject` so every
|
|
9
|
+
* generated Application lands in the same security boundary. If `project` is
|
|
10
|
+
* missing, the generated Applications fall back to whatever default and dodge
|
|
11
|
+
* project-level RBAC; if it is templated with a generator placeholder
|
|
12
|
+
* (`{{...}}`) the set sprays Applications across many projects, which defeats
|
|
13
|
+
* the point of an AppProject as a guardrail.
|
|
14
|
+
*
|
|
15
|
+
* Bad: new ApplicationSet({ spec: { template: { spec: { repoURL: "..." } } } }) // no project
|
|
16
|
+
* Bad: new ApplicationSet({ spec: { template: { spec: { project: "{{path.basename}}" } } } }) // templated
|
|
17
|
+
* Good: new ApplicationSet({ spec: { template: { spec: { project: "team-a" } } } })
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const argoAppSetSingleProjectRule: LintRule = {
|
|
21
|
+
id: "ARGO004",
|
|
22
|
+
severity: "warning",
|
|
23
|
+
category: "correctness",
|
|
24
|
+
description:
|
|
25
|
+
"ApplicationSet template must scope to a single static AppProject (spec.template.spec.project)",
|
|
26
|
+
|
|
27
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
28
|
+
const { sourceFile } = context;
|
|
29
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
30
|
+
|
|
31
|
+
for (const { literal } of findResourceLiterals(sourceFile, new Set(["ApplicationSet"]))) {
|
|
32
|
+
const templateSpec = getNestedObject(literal, ["spec", "template", "spec"]);
|
|
33
|
+
// No template/spec to inspect — leave it to other tooling.
|
|
34
|
+
if (!templateSpec) continue;
|
|
35
|
+
|
|
36
|
+
const project = getNestedString(literal, ["spec", "template", "spec", "project"]);
|
|
37
|
+
const { line, column } = lineCol(sourceFile, templateSpec);
|
|
38
|
+
|
|
39
|
+
if (project === undefined) {
|
|
40
|
+
diagnostics.push({
|
|
41
|
+
file: sourceFile.fileName,
|
|
42
|
+
line,
|
|
43
|
+
column,
|
|
44
|
+
ruleId: "ARGO004",
|
|
45
|
+
severity: "warning",
|
|
46
|
+
message:
|
|
47
|
+
"ApplicationSet template has no spec.project — scope it to a single AppProject so every generated Application inherits the same RBAC boundary.",
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (project.includes("{{")) {
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
file: sourceFile.fileName,
|
|
55
|
+
line,
|
|
56
|
+
column,
|
|
57
|
+
ruleId: "ARGO004",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
message: `ApplicationSet template scopes spec.project to a generator placeholder ("${project}"), spraying Applications across projects. Pin it to a single static AppProject.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return diagnostics;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AST helpers for the Argo declarative lint rules (ARGO001, ARGO004).
|
|
3
|
+
*
|
|
4
|
+
* The Argo CRDs are constructed like any other k8s resource —
|
|
5
|
+
* `new Application({ metadata, spec })` / `new ApplicationSet({ ... })`. These
|
|
6
|
+
* helpers walk the object-literal first argument so a rule can read a nested
|
|
7
|
+
* property by path without re-implementing the traversal each time.
|
|
8
|
+
*/
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Find the object-literal first argument of every `new <Kind>(...)` expression
|
|
13
|
+
* in the file, where Kind is one of the supplied resource kinds.
|
|
14
|
+
*/
|
|
15
|
+
export function findResourceLiterals(
|
|
16
|
+
sourceFile: ts.SourceFile,
|
|
17
|
+
kinds: Set<string>,
|
|
18
|
+
): Array<{ kind: string; literal: ts.ObjectLiteralExpression; node: ts.NewExpression }> {
|
|
19
|
+
const found: Array<{ kind: string; literal: ts.ObjectLiteralExpression; node: ts.NewExpression }> = [];
|
|
20
|
+
|
|
21
|
+
function visit(node: ts.Node): void {
|
|
22
|
+
if (
|
|
23
|
+
ts.isNewExpression(node) &&
|
|
24
|
+
ts.isIdentifier(node.expression) &&
|
|
25
|
+
kinds.has(node.expression.text) &&
|
|
26
|
+
node.arguments &&
|
|
27
|
+
node.arguments.length > 0 &&
|
|
28
|
+
ts.isObjectLiteralExpression(node.arguments[0])
|
|
29
|
+
) {
|
|
30
|
+
found.push({
|
|
31
|
+
kind: node.expression.text,
|
|
32
|
+
literal: node.arguments[0],
|
|
33
|
+
node,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
ts.forEachChild(node, visit);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
visit(sourceFile);
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read the initializer node for `key` on an object literal, if present. */
|
|
44
|
+
export function getProp(
|
|
45
|
+
obj: ts.ObjectLiteralExpression,
|
|
46
|
+
key: string,
|
|
47
|
+
): ts.Expression | undefined {
|
|
48
|
+
for (const prop of obj.properties) {
|
|
49
|
+
if (
|
|
50
|
+
ts.isPropertyAssignment(prop) &&
|
|
51
|
+
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
|
|
52
|
+
prop.name.text === key
|
|
53
|
+
) {
|
|
54
|
+
return prop.initializer;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read a nested object literal by walking `path` (each segment an object). */
|
|
61
|
+
export function getNestedObject(
|
|
62
|
+
obj: ts.ObjectLiteralExpression,
|
|
63
|
+
path: string[],
|
|
64
|
+
): ts.ObjectLiteralExpression | undefined {
|
|
65
|
+
let current: ts.ObjectLiteralExpression | undefined = obj;
|
|
66
|
+
for (const segment of path) {
|
|
67
|
+
if (!current) return undefined;
|
|
68
|
+
const next = getProp(current, segment);
|
|
69
|
+
current = next && ts.isObjectLiteralExpression(next) ? next : undefined;
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read a string-literal property value by path (last segment is the key). */
|
|
75
|
+
export function getNestedString(
|
|
76
|
+
obj: ts.ObjectLiteralExpression,
|
|
77
|
+
path: string[],
|
|
78
|
+
): string | undefined {
|
|
79
|
+
const key = path[path.length - 1];
|
|
80
|
+
const parent = getNestedObject(obj, path.slice(0, -1));
|
|
81
|
+
if (!parent) return undefined;
|
|
82
|
+
const value = getProp(parent, key);
|
|
83
|
+
return value && ts.isStringLiteral(value) ? value.text : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read a boolean-literal property value by path (last segment is the key). */
|
|
87
|
+
export function getNestedBoolean(
|
|
88
|
+
obj: ts.ObjectLiteralExpression,
|
|
89
|
+
path: string[],
|
|
90
|
+
): boolean | undefined {
|
|
91
|
+
const key = path[path.length - 1];
|
|
92
|
+
const parent = getNestedObject(obj, path.slice(0, -1));
|
|
93
|
+
if (!parent) return undefined;
|
|
94
|
+
const value = getProp(parent, key);
|
|
95
|
+
if (!value) return undefined;
|
|
96
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
97
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True if the literal carries the given annotation key under
|
|
103
|
+
* `metadata.annotations`, regardless of value.
|
|
104
|
+
*/
|
|
105
|
+
export function hasAnnotation(
|
|
106
|
+
obj: ts.ObjectLiteralExpression,
|
|
107
|
+
annotationKey: string,
|
|
108
|
+
): boolean {
|
|
109
|
+
const annotations = getNestedObject(obj, ["metadata", "annotations"]);
|
|
110
|
+
if (!annotations) return false;
|
|
111
|
+
return getProp(annotations, annotationKey) !== undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Line/column (1-based) for a node, for diagnostics. */
|
|
115
|
+
export function lineCol(
|
|
116
|
+
sourceFile: ts.SourceFile,
|
|
117
|
+
node: ts.Node,
|
|
118
|
+
): { line: number; column: number } {
|
|
119
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
120
|
+
return { line: line + 1, column: character + 1 };
|
|
121
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
2
|
+
import {
|
|
3
|
+
findResourceLiterals,
|
|
4
|
+
getNestedBoolean,
|
|
5
|
+
getNestedString,
|
|
6
|
+
getProp,
|
|
7
|
+
hasAnnotation,
|
|
8
|
+
lineCol,
|
|
9
|
+
} from "./argo-ast";
|
|
10
|
+
import * as ts from "typescript";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ARGO001: Production Application must not enable automated prune
|
|
14
|
+
*
|
|
15
|
+
* An Argo CD `Application` whose `syncPolicy.automated.prune` is `true` lets the
|
|
16
|
+
* controller delete live resources that disappear from git. On a production
|
|
17
|
+
* Application that is a foot-gun — a bad merge can sweep away running
|
|
18
|
+
* infrastructure. Require `prune: false` for prod Applications unless the author
|
|
19
|
+
* opts in explicitly with the `argocd.chant.dev/allow-prune` annotation.
|
|
20
|
+
*
|
|
21
|
+
* "Production" is inferred from the Application name, its `metadata.namespace`,
|
|
22
|
+
* or its `spec.destination.namespace` containing `prod`.
|
|
23
|
+
*
|
|
24
|
+
* Bad: new Application({ metadata: { name: "api-prod" }, spec: { syncPolicy: { automated: { prune: true } } } })
|
|
25
|
+
* Good: new Application({ metadata: { name: "api-prod" }, spec: { syncPolicy: { automated: { prune: false } } } })
|
|
26
|
+
* Good: new Application({ metadata: { name: "api-prod", annotations: { "argocd.chant.dev/allow-prune": "true" } }, spec: { syncPolicy: { automated: { prune: true } } } })
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const ALLOW_PRUNE_ANNOTATION = "argocd.chant.dev/allow-prune";
|
|
30
|
+
|
|
31
|
+
function looksProd(obj: ts.ObjectLiteralExpression): boolean {
|
|
32
|
+
const candidates = [
|
|
33
|
+
getNestedString(obj, ["metadata", "name"]),
|
|
34
|
+
getNestedString(obj, ["metadata", "namespace"]),
|
|
35
|
+
getNestedString(obj, ["spec", "destination", "namespace"]),
|
|
36
|
+
];
|
|
37
|
+
return candidates.some((v) => v !== undefined && /prod/i.test(v));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const argoAutomatedPruneRule: LintRule = {
|
|
41
|
+
id: "ARGO001",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
category: "correctness",
|
|
44
|
+
description:
|
|
45
|
+
"Production Argo Application must not enable syncPolicy.automated.prune without the allow-prune override annotation",
|
|
46
|
+
|
|
47
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
48
|
+
const { sourceFile } = context;
|
|
49
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
50
|
+
|
|
51
|
+
for (const { literal } of findResourceLiterals(sourceFile, new Set(["Application"]))) {
|
|
52
|
+
const prune = getNestedBoolean(literal, ["spec", "syncPolicy", "automated", "prune"]);
|
|
53
|
+
if (prune !== true) continue;
|
|
54
|
+
if (!looksProd(literal)) continue;
|
|
55
|
+
if (hasAnnotation(literal, ALLOW_PRUNE_ANNOTATION)) continue;
|
|
56
|
+
|
|
57
|
+
// Anchor the diagnostic at the `prune` assignment when we can find it.
|
|
58
|
+
const automated = getProp(literal, "spec");
|
|
59
|
+
const anchor: ts.Node = automated ?? literal;
|
|
60
|
+
const { line, column } = lineCol(sourceFile, anchor);
|
|
61
|
+
|
|
62
|
+
const name = getNestedString(literal, ["metadata", "name"]) ?? "(unnamed)";
|
|
63
|
+
diagnostics.push({
|
|
64
|
+
file: sourceFile.fileName,
|
|
65
|
+
line,
|
|
66
|
+
column,
|
|
67
|
+
ruleId: "ARGO001",
|
|
68
|
+
severity: "warning",
|
|
69
|
+
message: `Production Application "${name}" enables automated prune. Set syncPolicy.automated.prune=false, or opt in explicitly with the "${ALLOW_PRUNE_ANNOTATION}" annotation.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return diagnostics;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -2,6 +2,8 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import { hardcodedNamespaceRule } from "./hardcoded-namespace";
|
|
3
3
|
import { latestImageTagRule } from "./latest-image-tag";
|
|
4
4
|
import { missingResourceLimitsRule } from "./missing-resource-limits";
|
|
5
|
+
import { argoAutomatedPruneRule } from "./argo-automated-prune";
|
|
6
|
+
import { argoAppSetSingleProjectRule } from "./argo-appset-single-project";
|
|
5
7
|
import * as ts from "typescript";
|
|
6
8
|
|
|
7
9
|
function createContext(code: string) {
|
|
@@ -259,3 +261,110 @@ describe("WK8003: Missing Resource Limits", () => {
|
|
|
259
261
|
expect(diags.length).toBe(2);
|
|
260
262
|
});
|
|
261
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("ARGO001: Production Application automated prune", () => {
|
|
266
|
+
test("rule metadata", () => {
|
|
267
|
+
expect(argoAutomatedPruneRule.id).toBe("ARGO001");
|
|
268
|
+
expect(argoAutomatedPruneRule.severity).toBe("warning");
|
|
269
|
+
expect(argoAutomatedPruneRule.category).toBe("correctness");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("flags prod Application with automated prune and no override", () => {
|
|
273
|
+
const ctx = createContext(`
|
|
274
|
+
new Application({
|
|
275
|
+
metadata: { name: "api-prod" },
|
|
276
|
+
spec: { syncPolicy: { automated: { prune: true, selfHeal: true } } },
|
|
277
|
+
});
|
|
278
|
+
`);
|
|
279
|
+
const diags = argoAutomatedPruneRule.check(ctx);
|
|
280
|
+
expect(diags.length).toBe(1);
|
|
281
|
+
expect(diags[0].ruleId).toBe("ARGO001");
|
|
282
|
+
expect(diags[0].message).toContain("api-prod");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("does NOT flag when prune is false", () => {
|
|
286
|
+
const ctx = createContext(`
|
|
287
|
+
new Application({
|
|
288
|
+
metadata: { name: "api-prod" },
|
|
289
|
+
spec: { syncPolicy: { automated: { prune: false } } },
|
|
290
|
+
});
|
|
291
|
+
`);
|
|
292
|
+
expect(argoAutomatedPruneRule.check(ctx).length).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("does NOT flag prod Application carrying the allow-prune override annotation", () => {
|
|
296
|
+
const ctx = createContext(`
|
|
297
|
+
new Application({
|
|
298
|
+
metadata: { name: "api-prod", annotations: { "argocd.chant.dev/allow-prune": "true" } },
|
|
299
|
+
spec: { syncPolicy: { automated: { prune: true } } },
|
|
300
|
+
});
|
|
301
|
+
`);
|
|
302
|
+
expect(argoAutomatedPruneRule.check(ctx).length).toBe(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("does NOT flag a non-prod Application with automated prune", () => {
|
|
306
|
+
const ctx = createContext(`
|
|
307
|
+
new Application({
|
|
308
|
+
metadata: { name: "api-staging" },
|
|
309
|
+
spec: { syncPolicy: { automated: { prune: true } } },
|
|
310
|
+
});
|
|
311
|
+
`);
|
|
312
|
+
expect(argoAutomatedPruneRule.check(ctx).length).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("infers prod from destination namespace", () => {
|
|
316
|
+
const ctx = createContext(`
|
|
317
|
+
new Application({
|
|
318
|
+
metadata: { name: "api" },
|
|
319
|
+
spec: {
|
|
320
|
+
destination: { namespace: "production" },
|
|
321
|
+
syncPolicy: { automated: { prune: true } },
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
`);
|
|
325
|
+
expect(argoAutomatedPruneRule.check(ctx).length).toBe(1);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("ARGO004: ApplicationSet single AppProject", () => {
|
|
330
|
+
test("rule metadata", () => {
|
|
331
|
+
expect(argoAppSetSingleProjectRule.id).toBe("ARGO004");
|
|
332
|
+
expect(argoAppSetSingleProjectRule.severity).toBe("warning");
|
|
333
|
+
expect(argoAppSetSingleProjectRule.category).toBe("correctness");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("does NOT flag a static single project", () => {
|
|
337
|
+
const ctx = createContext(`
|
|
338
|
+
new ApplicationSet({
|
|
339
|
+
metadata: { name: "team-a-apps" },
|
|
340
|
+
spec: { template: { spec: { project: "team-a", repoURL: "https://example.com/repo" } } },
|
|
341
|
+
});
|
|
342
|
+
`);
|
|
343
|
+
expect(argoAppSetSingleProjectRule.check(ctx).length).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("flags a missing template project", () => {
|
|
347
|
+
const ctx = createContext(`
|
|
348
|
+
new ApplicationSet({
|
|
349
|
+
metadata: { name: "team-a-apps" },
|
|
350
|
+
spec: { template: { spec: { repoURL: "https://example.com/repo" } } },
|
|
351
|
+
});
|
|
352
|
+
`);
|
|
353
|
+
const diags = argoAppSetSingleProjectRule.check(ctx);
|
|
354
|
+
expect(diags.length).toBe(1);
|
|
355
|
+
expect(diags[0].ruleId).toBe("ARGO004");
|
|
356
|
+
expect(diags[0].message).toContain("no spec.project");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("flags a templated (generator placeholder) project", () => {
|
|
360
|
+
const ctx = createContext(`
|
|
361
|
+
new ApplicationSet({
|
|
362
|
+
metadata: { name: "per-cluster" },
|
|
363
|
+
spec: { template: { spec: { project: "{{path.basename}}" } } },
|
|
364
|
+
});
|
|
365
|
+
`);
|
|
366
|
+
const diags = argoAppSetSingleProjectRule.check(ctx);
|
|
367
|
+
expect(diags.length).toBe(1);
|
|
368
|
+
expect(diags[0].message).toContain("placeholder");
|
|
369
|
+
});
|
|
370
|
+
});
|
package/src/plugin.test.ts
CHANGED
|
@@ -33,7 +33,12 @@ describe("k8sPlugin", () => {
|
|
|
33
33
|
test("postSynthChecks() returns array of post-synth checks", () => {
|
|
34
34
|
const checks = k8sPlugin.postSynthChecks!();
|
|
35
35
|
expect(Array.isArray(checks)).toBe(true);
|
|
36
|
-
|
|
36
|
+
// Auto-discovered from src/lint/post-synth — assert a floor and representative
|
|
37
|
+
// IDs rather than an exact count, so adding a check doesn't break this test.
|
|
38
|
+
expect(checks.length).toBeGreaterThanOrEqual(26);
|
|
39
|
+
const ids = checks.map((c) => c.id);
|
|
40
|
+
expect(ids).toContain("WK8101");
|
|
41
|
+
expect(ids).toContain("ARGO002");
|
|
37
42
|
});
|
|
38
43
|
|
|
39
44
|
test("intrinsics() returns empty array", () => {
|
package/src/plugin.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { k8sSerializer } from "./serializer";
|
|
|
15
15
|
import { hardcodedNamespaceRule } from "./lint/rules/hardcoded-namespace";
|
|
16
16
|
import { latestImageTagRule } from "./lint/rules/latest-image-tag";
|
|
17
17
|
import { missingResourceLimitsRule } from "./lint/rules/missing-resource-limits";
|
|
18
|
+
import { argoAutomatedPruneRule } from "./lint/rules/argo-automated-prune";
|
|
19
|
+
import { argoAppSetSingleProjectRule } from "./lint/rules/argo-appset-single-project";
|
|
18
20
|
import { k8sCompletions } from "./lsp/completions";
|
|
19
21
|
import { k8sHover } from "./lsp/hover";
|
|
20
22
|
import { K8sParser } from "./import/parser";
|
|
@@ -25,7 +27,13 @@ export const k8sPlugin: LexiconPlugin = {
|
|
|
25
27
|
serializer: k8sSerializer,
|
|
26
28
|
|
|
27
29
|
lintRules(): LintRule[] {
|
|
28
|
-
return [
|
|
30
|
+
return [
|
|
31
|
+
hardcodedNamespaceRule,
|
|
32
|
+
latestImageTagRule,
|
|
33
|
+
missingResourceLimitsRule,
|
|
34
|
+
argoAutomatedPruneRule,
|
|
35
|
+
argoAppSetSingleProjectRule,
|
|
36
|
+
];
|
|
29
37
|
},
|
|
30
38
|
|
|
31
39
|
postSynthChecks() {
|
|
@@ -566,10 +574,45 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
|
|
|
566
574
|
},
|
|
567
575
|
],
|
|
568
576
|
},
|
|
577
|
+
{
|
|
578
|
+
file: "chant-k8s-argo.md",
|
|
579
|
+
name: "chant-k8s-argo",
|
|
580
|
+
description: "Argo CD composites — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and the Argo-vs-Temporal split",
|
|
581
|
+
triggers: [
|
|
582
|
+
{ type: "context", value: "argo" },
|
|
583
|
+
{ type: "context", value: "argo cd" },
|
|
584
|
+
{ type: "context", value: "argocd" },
|
|
585
|
+
{ type: "context", value: "gitops" },
|
|
586
|
+
{ type: "context", value: "application" },
|
|
587
|
+
{ type: "context", value: "applicationset" },
|
|
588
|
+
{ type: "context", value: "appproject" },
|
|
589
|
+
{ type: "context", value: "reconcile" },
|
|
590
|
+
],
|
|
591
|
+
parameters: [],
|
|
592
|
+
examples: [
|
|
593
|
+
{
|
|
594
|
+
title: "Application from a build target",
|
|
595
|
+
description: "Reconcile a Chant build target with Argo CD",
|
|
596
|
+
input: "Deploy my api target through Argo CD",
|
|
597
|
+
output: "import { ArgoAppFor } from \"@intentius/chant-lexicon-k8s\";\n\nexport const api = ArgoAppFor(\"api\", {\n repo: \"https://github.com/acme/infra\",\n path: \"dist/api\",\n destination: { server: \"https://kubernetes.default.svc\", namespace: \"api\" },\n});",
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
title: "Per-region ApplicationSet",
|
|
601
|
+
description: "Fan one app out across regional clusters",
|
|
602
|
+
input: "Deploy crdb to east, central, and west clusters via Argo",
|
|
603
|
+
output: "import { ArgoAppSetForRegions } from \"@intentius/chant-lexicon-k8s\";\n\nexport const crdb = ArgoAppSetForRegions(\n [\"east\", \"central\", \"west\"],\n (region) => ({ server: servers[region], namespace: `crdb-${region}`, path: `dist/${region}` }),\n { name: \"crdb\", repo: \"https://github.com/acme/infra\", project: \"crdb\" },\n);",
|
|
604
|
+
},
|
|
605
|
+
],
|
|
606
|
+
},
|
|
569
607
|
]),
|
|
570
608
|
|
|
571
609
|
async describeResources(options) {
|
|
572
610
|
const { describeResources } = await import("./describe-resources");
|
|
573
611
|
return describeResources(options);
|
|
574
612
|
},
|
|
613
|
+
|
|
614
|
+
async exportResources(options) {
|
|
615
|
+
const { exportResources } = await import("./export-resources");
|
|
616
|
+
return exportResources(options);
|
|
617
|
+
},
|
|
575
618
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { k8sSerializer } from "./serializer";
|
|
3
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
4
|
+
|
|
5
|
+
function mockResource(entityType: string, props: Record<string, unknown>): any {
|
|
6
|
+
return { [DECLARABLE_MARKER]: true, lexicon: "k8s", entityType, kind: "resource", props };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("k8sSerializer ownership stamping (#119)", () => {
|
|
10
|
+
test("stamps the ownership marker as labels when context.ownership is set", () => {
|
|
11
|
+
const entities = new Map<string, any>([
|
|
12
|
+
["web", mockResource("K8s::Apps::Deployment", { metadata: { name: "web" }, spec: { replicas: 1 } })],
|
|
13
|
+
]);
|
|
14
|
+
const yaml = k8sSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
|
|
15
|
+
expect(yaml).toContain("app.kubernetes.io/managed-by: chant");
|
|
16
|
+
expect(yaml).toContain("chant.intentius.io/stack: billing");
|
|
17
|
+
expect(yaml).toContain("chant.intentius.io/env: prod");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("explicit resource labels still win over the stamped marker", () => {
|
|
21
|
+
const entities = new Map<string, any>([
|
|
22
|
+
[
|
|
23
|
+
"web",
|
|
24
|
+
mockResource("K8s::Apps::Deployment", {
|
|
25
|
+
metadata: { name: "web", labels: { "app.kubernetes.io/managed-by": "argocd" } },
|
|
26
|
+
spec: { replicas: 1 },
|
|
27
|
+
}),
|
|
28
|
+
],
|
|
29
|
+
]);
|
|
30
|
+
const yaml = k8sSerializer.serialize(entities, [], { ownership: { stack: "billing" } });
|
|
31
|
+
expect(yaml).toContain("app.kubernetes.io/managed-by: argocd");
|
|
32
|
+
// stack identity still stamped (no explicit override)
|
|
33
|
+
expect(yaml).toContain("chant.intentius.io/stack: billing");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("no ownership context → no chant labels", () => {
|
|
37
|
+
const entities = new Map<string, any>([
|
|
38
|
+
["web", mockResource("K8s::Apps::Deployment", { metadata: { name: "web" }, spec: { replicas: 1 } })],
|
|
39
|
+
]);
|
|
40
|
+
const yaml = k8sSerializer.serialize(entities, []);
|
|
41
|
+
expect(yaml).not.toContain("app.kubernetes.io/managed-by: chant");
|
|
42
|
+
expect(yaml).not.toContain("chant.intentius.io/stack");
|
|
43
|
+
});
|
|
44
|
+
});
|