@intentius/chant-lexicon-k8s 0.1.14 → 0.1.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 +0 -1
- package/dist/integrity.json +14 -6
- 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/rules/wk8301.ts +11 -3
- 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 +14 -1
- 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 +146 -2
- package/src/lint/post-synth/wk8301.ts +11 -3
- 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
|
|
@@ -846,7 +854,7 @@ spec:
|
|
|
846
854
|
// ── WK8301: Probes required ────────────────────────────────────────
|
|
847
855
|
|
|
848
856
|
describe("WK8301: Probes required", () => {
|
|
849
|
-
test("flags container without probes", () => {
|
|
857
|
+
test("flags port-serving container without probes", () => {
|
|
850
858
|
const ctx = makeCtx(JSON.stringify({
|
|
851
859
|
apiVersion: "apps/v1",
|
|
852
860
|
kind: "Deployment",
|
|
@@ -854,7 +862,9 @@ describe("WK8301: Probes required", () => {
|
|
|
854
862
|
spec: {
|
|
855
863
|
template: {
|
|
856
864
|
spec: {
|
|
857
|
-
containers: [
|
|
865
|
+
containers: [
|
|
866
|
+
{ name: "app", image: "app:1.0", ports: [{ containerPort: 8080 }] },
|
|
867
|
+
],
|
|
858
868
|
},
|
|
859
869
|
},
|
|
860
870
|
},
|
|
@@ -864,6 +874,24 @@ describe("WK8301: Probes required", () => {
|
|
|
864
874
|
expect(diags[0].checkId).toBe("WK8301");
|
|
865
875
|
});
|
|
866
876
|
|
|
877
|
+
test("skips port-less worker Deployment (no inbound traffic to probe)", () => {
|
|
878
|
+
const ctx = makeCtx(JSON.stringify({
|
|
879
|
+
apiVersion: "apps/v1",
|
|
880
|
+
kind: "Deployment",
|
|
881
|
+
metadata: { name: "worker" },
|
|
882
|
+
spec: {
|
|
883
|
+
template: {
|
|
884
|
+
spec: {
|
|
885
|
+
// A queue/Temporal worker — long-running, no port, no probes by design.
|
|
886
|
+
containers: [{ name: "worker", image: "worker:1.0" }],
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
}));
|
|
891
|
+
const diags = wk8301.check(ctx);
|
|
892
|
+
expect(diags.length).toBe(0);
|
|
893
|
+
});
|
|
894
|
+
|
|
867
895
|
test("passes with both probes", () => {
|
|
868
896
|
const ctx = makeCtx(JSON.stringify({
|
|
869
897
|
apiVersion: "apps/v1",
|
|
@@ -876,6 +904,7 @@ describe("WK8301: Probes required", () => {
|
|
|
876
904
|
{
|
|
877
905
|
name: "app",
|
|
878
906
|
image: "app:1.0",
|
|
907
|
+
ports: [{ containerPort: 8080 }],
|
|
879
908
|
livenessProbe: { httpGet: { path: "/healthz", port: 8080 } },
|
|
880
909
|
readinessProbe: { httpGet: { path: "/readyz", port: 8080 } },
|
|
881
910
|
},
|
|
@@ -1476,3 +1505,118 @@ describe("WK8403: spec.rayVersion does not match image tag", () => {
|
|
|
1476
1505
|
expect(diags.filter((d) => d.checkId === "WK8403").length).toBe(0);
|
|
1477
1506
|
});
|
|
1478
1507
|
});
|
|
1508
|
+
|
|
1509
|
+
// ── ARGO002: Application.spec.project references a declared AppProject ─────────
|
|
1510
|
+
|
|
1511
|
+
function argoApp(name: string, spec: Record<string, unknown>) {
|
|
1512
|
+
return { apiVersion: "argoproj.io/v1alpha1", kind: "Application", metadata: { name }, spec };
|
|
1513
|
+
}
|
|
1514
|
+
function appProject(name: string) {
|
|
1515
|
+
return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", metadata: { name }, spec: {} };
|
|
1516
|
+
}
|
|
1517
|
+
const inClusterDest = { server: "https://kubernetes.default.svc", namespace: "demo" };
|
|
1518
|
+
|
|
1519
|
+
describe("ARGO002: Application project references a declared AppProject", () => {
|
|
1520
|
+
test("metadata", () => {
|
|
1521
|
+
expect(argo002.id).toBe("ARGO002");
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
test("flags an Application referencing an undeclared project", () => {
|
|
1525
|
+
const ctx = manifestsCtx(argoApp("api", { project: "team-a", destination: inClusterDest }));
|
|
1526
|
+
const diags = argo002.check(ctx);
|
|
1527
|
+
expect(diags.length).toBe(1);
|
|
1528
|
+
expect(diags[0].checkId).toBe("ARGO002");
|
|
1529
|
+
expect(diags[0].message).toContain("team-a");
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
test("passes when the AppProject is declared in the same build", () => {
|
|
1533
|
+
const ctx = manifestsCtx(
|
|
1534
|
+
appProject("team-a"),
|
|
1535
|
+
argoApp("api", { project: "team-a", destination: inClusterDest }),
|
|
1536
|
+
);
|
|
1537
|
+
expect(argo002.check(ctx).length).toBe(0);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
test("does NOT flag the built-in default project", () => {
|
|
1541
|
+
const ctx = manifestsCtx(argoApp("api", { project: "default", destination: inClusterDest }));
|
|
1542
|
+
expect(argo002.check(ctx).length).toBe(0);
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// ── ARGO003: Application destination references a registered cluster ──────────
|
|
1547
|
+
|
|
1548
|
+
function clusterSecret(name: string, server: string) {
|
|
1549
|
+
return {
|
|
1550
|
+
apiVersion: "v1",
|
|
1551
|
+
kind: "Secret",
|
|
1552
|
+
metadata: { name, labels: { "argocd.argoproj.io/secret-type": "cluster" } },
|
|
1553
|
+
stringData: { name, server },
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
describe("ARGO003: Application destination references a registered cluster", () => {
|
|
1558
|
+
test("metadata", () => {
|
|
1559
|
+
expect(argo003.id).toBe("ARGO003");
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
test("does NOT flag the in-cluster destination", () => {
|
|
1563
|
+
const ctx = manifestsCtx(argoApp("api", { project: "default", destination: inClusterDest }));
|
|
1564
|
+
expect(argo003.check(ctx).length).toBe(0);
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
test("flags an unregistered cluster server", () => {
|
|
1568
|
+
const ctx = manifestsCtx(
|
|
1569
|
+
argoApp("api", { project: "default", destination: { server: "https://prod.example.com", namespace: "demo" } }),
|
|
1570
|
+
);
|
|
1571
|
+
const diags = argo003.check(ctx);
|
|
1572
|
+
expect(diags.length).toBe(1);
|
|
1573
|
+
expect(diags[0].checkId).toBe("ARGO003");
|
|
1574
|
+
expect(diags[0].message).toContain("prod.example.com");
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
test("passes when the cluster is registered via a cluster Secret", () => {
|
|
1578
|
+
const ctx = manifestsCtx(
|
|
1579
|
+
clusterSecret("prod", "https://prod.example.com"),
|
|
1580
|
+
argoApp("api", { project: "default", destination: { server: "https://prod.example.com", namespace: "demo" } }),
|
|
1581
|
+
);
|
|
1582
|
+
expect(argo003.check(ctx).length).toBe(0);
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
test("flags a destination with neither server nor name", () => {
|
|
1586
|
+
const ctx = manifestsCtx(argoApp("api", { project: "default", destination: { namespace: "demo" } }));
|
|
1587
|
+
expect(argo003.check(ctx).length).toBe(1);
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
// ── ARGO005: Application source.path resolves to an existing directory ────────
|
|
1592
|
+
|
|
1593
|
+
describe("ARGO005: Application source path exists", () => {
|
|
1594
|
+
test("metadata", () => {
|
|
1595
|
+
expect(argo005.id).toBe("ARGO005");
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
test("does NOT flag a path that exists under the build root", () => {
|
|
1599
|
+
// `lexicons` is a directory at the repo root (the vitest cwd).
|
|
1600
|
+
const ctx = manifestsCtx(
|
|
1601
|
+
argoApp("api", { project: "default", destination: inClusterDest, source: { repoURL: "x", path: "lexicons" } }),
|
|
1602
|
+
);
|
|
1603
|
+
expect(argo005.check(ctx).length).toBe(0);
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
test("warns on a path that does not resolve to a directory", () => {
|
|
1607
|
+
const ctx = manifestsCtx(
|
|
1608
|
+
argoApp("api", { project: "default", destination: inClusterDest, source: { repoURL: "x", path: "no-such-argo-dir-xyz" } }),
|
|
1609
|
+
);
|
|
1610
|
+
const diags = argo005.check(ctx);
|
|
1611
|
+
expect(diags.length).toBe(1);
|
|
1612
|
+
expect(diags[0].checkId).toBe("ARGO005");
|
|
1613
|
+
expect(diags[0].severity).toBe("warning");
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
test("skips Helm chart sources", () => {
|
|
1617
|
+
const ctx = manifestsCtx(
|
|
1618
|
+
argoApp("api", { project: "default", destination: inClusterDest, source: { repoURL: "x", chart: "redis" } }),
|
|
1619
|
+
);
|
|
1620
|
+
expect(argo005.check(ctx).length).toBe(0);
|
|
1621
|
+
});
|
|
1622
|
+
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WK8301: Probes Required
|
|
3
3
|
*
|
|
4
|
-
* Containers should have both livenessProbe and
|
|
5
|
-
* Without probes, Kubernetes cannot detect
|
|
6
|
-
* when a container is ready to receive traffic.
|
|
4
|
+
* Containers that expose a port should have both livenessProbe and
|
|
5
|
+
* readinessProbe configured. Without probes, Kubernetes cannot detect
|
|
6
|
+
* unhealthy containers or know when a container is ready to receive traffic.
|
|
7
|
+
*
|
|
8
|
+
* Containers that declare no port (queue/Temporal workers, batch consumers)
|
|
9
|
+
* are exempt: they take no inbound traffic, so there is no endpoint to
|
|
10
|
+
* HTTP-probe and no readiness signal for a Service to gate on. Flagging them
|
|
11
|
+
* would force a placeholder exec probe on a correct, port-less pattern.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
@@ -29,6 +34,9 @@ export const wk8301: PostSynthCheck = {
|
|
|
29
34
|
const resourceName = manifest.metadata?.name ?? manifest.kind;
|
|
30
35
|
|
|
31
36
|
for (const container of containers) {
|
|
37
|
+
// No declared port → no inbound traffic → nothing to HTTP-probe.
|
|
38
|
+
if (!container.ports || container.ports.length === 0) continue;
|
|
39
|
+
|
|
32
40
|
const missing: string[] = [];
|
|
33
41
|
if (!container.livenessProbe) missing.push("livenessProbe");
|
|
34
42
|
if (!container.readinessProbe) missing.push("readinessProbe");
|
|
@@ -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", () => {
|