@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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { stripServerFields, buildExportFromObjects } from "./live-export";
|
|
3
|
+
import { K8sGenerator } from "./generator";
|
|
4
|
+
|
|
5
|
+
// A live object as `kubectl get -o json` would return it — full of
|
|
6
|
+
// server-written fields the user never authored.
|
|
7
|
+
const liveDeployment = () => ({
|
|
8
|
+
apiVersion: "apps/v1",
|
|
9
|
+
kind: "Deployment",
|
|
10
|
+
metadata: {
|
|
11
|
+
name: "web",
|
|
12
|
+
namespace: "default",
|
|
13
|
+
uid: "abc-123",
|
|
14
|
+
resourceVersion: "9981",
|
|
15
|
+
generation: 4,
|
|
16
|
+
creationTimestamp: "2026-01-01T00:00:00Z",
|
|
17
|
+
managedFields: [{ manager: "kubectl", operation: "Apply" }],
|
|
18
|
+
annotations: {
|
|
19
|
+
"kubectl.kubernetes.io/last-applied-configuration": "{...}",
|
|
20
|
+
"team": "platform",
|
|
21
|
+
},
|
|
22
|
+
labels: { app: "web" },
|
|
23
|
+
},
|
|
24
|
+
spec: { replicas: 3, selector: { matchLabels: { app: "web" } } },
|
|
25
|
+
status: { readyReplicas: 3, replicas: 3 },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const liveService = () => ({
|
|
29
|
+
apiVersion: "v1",
|
|
30
|
+
kind: "Service",
|
|
31
|
+
metadata: { name: "web", namespace: "default", uid: "svc-1", resourceVersion: "12" },
|
|
32
|
+
spec: { ports: [{ port: 80 }], clusterIP: "10.0.0.1" },
|
|
33
|
+
status: { loadBalancer: {} },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("stripServerFields (#116)", () => {
|
|
37
|
+
test("removes status, managedFields, and server metadata", () => {
|
|
38
|
+
const cleaned = stripServerFields(liveDeployment());
|
|
39
|
+
expect(cleaned.status).toBeUndefined();
|
|
40
|
+
const md = cleaned.metadata as Record<string, unknown>;
|
|
41
|
+
expect(md.managedFields).toBeUndefined();
|
|
42
|
+
expect(md.uid).toBeUndefined();
|
|
43
|
+
expect(md.resourceVersion).toBeUndefined();
|
|
44
|
+
expect(md.generation).toBeUndefined();
|
|
45
|
+
expect(md.creationTimestamp).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("drops server annotations but keeps authored ones", () => {
|
|
49
|
+
const cleaned = stripServerFields(liveDeployment());
|
|
50
|
+
const annotations = (cleaned.metadata as Record<string, unknown>).annotations as Record<string, string>;
|
|
51
|
+
expect(annotations["kubectl.kubernetes.io/last-applied-configuration"]).toBeUndefined();
|
|
52
|
+
expect(annotations.team).toBe("platform");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("keeps authored spec and labels", () => {
|
|
56
|
+
const cleaned = stripServerFields(liveDeployment());
|
|
57
|
+
expect(cleaned.spec).toEqual({ replicas: 3, selector: { matchLabels: { app: "web" } } });
|
|
58
|
+
expect((cleaned.metadata as Record<string, unknown>).labels).toEqual({ app: "web" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("does not mutate the input", () => {
|
|
62
|
+
const input = liveDeployment();
|
|
63
|
+
stripServerFields(input);
|
|
64
|
+
expect(input.status).toBeDefined();
|
|
65
|
+
expect(input.metadata.managedFields).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("buildExportFromObjects (#116)", () => {
|
|
70
|
+
test("maps live objects to export IR, stripped by default", () => {
|
|
71
|
+
const ir = buildExportFromObjects([liveDeployment(), liveService()]);
|
|
72
|
+
expect(ir.resources).toHaveLength(2);
|
|
73
|
+
const dep = ir.resources.find((r) => r.type === "K8s::Apps::Deployment")!;
|
|
74
|
+
expect(dep.properties.status).toBeUndefined();
|
|
75
|
+
expect((dep.properties.metadata as Record<string, unknown>).uid).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("verbatim keeps server fields", () => {
|
|
79
|
+
const ir = buildExportFromObjects([liveDeployment()], { verbatim: true });
|
|
80
|
+
const dep = ir.resources[0];
|
|
81
|
+
expect(dep.properties.status).toBeDefined();
|
|
82
|
+
expect((dep.properties.metadata as Record<string, unknown>).uid).toBe("abc-123");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("selector by type narrows the export", () => {
|
|
86
|
+
const ir = buildExportFromObjects([liveDeployment(), liveService()], {
|
|
87
|
+
selector: { type: "K8s::Core::Service" },
|
|
88
|
+
});
|
|
89
|
+
expect(ir.resources.map((r) => r.type)).toEqual(["K8s::Core::Service"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("selector by name matches the live resource name", () => {
|
|
93
|
+
const ir = buildExportFromObjects([liveDeployment(), liveService()], {
|
|
94
|
+
selector: { name: "web" },
|
|
95
|
+
});
|
|
96
|
+
// Both are named "web" — name alone keeps both kinds.
|
|
97
|
+
expect(ir.resources).toHaveLength(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("owned filter keeps only objects carrying the chant marker label (#120)", () => {
|
|
101
|
+
const mine = liveDeployment();
|
|
102
|
+
(mine.metadata as any).labels = { "app.kubernetes.io/managed-by": "chant", "chant.intentius.io/stack": "billing" };
|
|
103
|
+
const theirs = liveService(); // no chant label
|
|
104
|
+
const ir = buildExportFromObjects([mine, theirs], { owned: true });
|
|
105
|
+
expect(ir.resources.map((r) => r.type)).toEqual(["K8s::Apps::Deployment"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("export IR feeds K8sGenerator (templateGenerator) unchanged", () => {
|
|
109
|
+
const ir = buildExportFromObjects([liveDeployment()]);
|
|
110
|
+
const files = new K8sGenerator().generate(ir);
|
|
111
|
+
expect(files.length).toBeGreaterThan(0);
|
|
112
|
+
expect(files.map((f) => f.content).join("\n")).toContain("Deployment");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
2
|
+
import { hasOwnershipMarker } from "@intentius/chant/ownership";
|
|
3
|
+
import { K8sParser } from "./parser";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-managed metadata fields that the API server fills in. They are not
|
|
7
|
+
* part of the declared shape and would only add noise to regenerated source,
|
|
8
|
+
* so they are stripped unless `verbatim` is set.
|
|
9
|
+
*/
|
|
10
|
+
const SERVER_METADATA_FIELDS = [
|
|
11
|
+
"managedFields",
|
|
12
|
+
"uid",
|
|
13
|
+
"resourceVersion",
|
|
14
|
+
"generation",
|
|
15
|
+
"creationTimestamp",
|
|
16
|
+
"selfLink",
|
|
17
|
+
"ownerReferences",
|
|
18
|
+
"finalizers",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Annotations the cluster writes back that are not authored config. */
|
|
22
|
+
const SERVER_ANNOTATIONS = [
|
|
23
|
+
"kubectl.kubernetes.io/last-applied-configuration",
|
|
24
|
+
"deployment.kubernetes.io/revision",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
28
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Strip `status`, `managedFields`, and other server-written fields to reach the
|
|
33
|
+
* declared shape. Returns a new object; the input is not mutated.
|
|
34
|
+
*/
|
|
35
|
+
export function stripServerFields(obj: Record<string, unknown>): Record<string, unknown> {
|
|
36
|
+
const clone = JSON.parse(JSON.stringify(obj)) as Record<string, unknown>;
|
|
37
|
+
delete clone.status;
|
|
38
|
+
|
|
39
|
+
if (isRecord(clone.metadata)) {
|
|
40
|
+
const md = clone.metadata;
|
|
41
|
+
for (const f of SERVER_METADATA_FIELDS) delete md[f];
|
|
42
|
+
if (isRecord(md.annotations)) {
|
|
43
|
+
for (const a of SERVER_ANNOTATIONS) delete md.annotations[a];
|
|
44
|
+
if (Object.keys(md.annotations).length === 0) delete md.annotations;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return clone;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build full-fidelity export IR from a list of live Kubernetes objects (the
|
|
52
|
+
* `items` of a `kubectl get -o json` List). Reuses the import K8sParser by
|
|
53
|
+
* feeding it the cleaned objects as JSON documents — JSON is valid YAML, so no
|
|
54
|
+
* separate serializer is needed. Pure: all I/O stays in the caller.
|
|
55
|
+
*/
|
|
56
|
+
export function buildExportFromObjects(
|
|
57
|
+
objects: unknown[],
|
|
58
|
+
opts: { verbatim?: boolean; selector?: ResourceSelector; owned?: boolean } = {},
|
|
59
|
+
): ExportedTemplate {
|
|
60
|
+
const owning = opts.owned
|
|
61
|
+
? objects.filter((o) => {
|
|
62
|
+
if (!isRecord(o)) return false;
|
|
63
|
+
const labels = isRecord(o.metadata) ? (o.metadata.labels as Record<string, unknown>) : undefined;
|
|
64
|
+
return hasOwnershipMarker(labels, "label");
|
|
65
|
+
})
|
|
66
|
+
: objects;
|
|
67
|
+
|
|
68
|
+
const cleaned = owning
|
|
69
|
+
.filter(isRecord)
|
|
70
|
+
.map((o) => (opts.verbatim ? o : stripServerFields(o)));
|
|
71
|
+
|
|
72
|
+
const content = cleaned.map((o) => JSON.stringify(o, null, 2)).join("\n---\n");
|
|
73
|
+
const ir = new K8sParser().parse(content);
|
|
74
|
+
|
|
75
|
+
const selector = opts.selector;
|
|
76
|
+
if (!selector || (selector.type === undefined && selector.name === undefined)) {
|
|
77
|
+
return ir;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
...ir,
|
|
81
|
+
resources: ir.resources.filter((r) => {
|
|
82
|
+
const liveName = (r.metadata?.originalName as string | undefined) ?? r.logicalId;
|
|
83
|
+
return (
|
|
84
|
+
(selector.type === undefined || r.type === selector.type) &&
|
|
85
|
+
(selector.name === undefined || liveName === selector.name)
|
|
86
|
+
);
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
MetricsServer, WorkloadIdentityServiceAccount, GcePdStorageClass, FilestoreStorageClass, GkeGateway, ConfigConnectorContext,
|
|
27
27
|
GceIngress, CockroachDbCluster, CockroachDbRegionStack,
|
|
28
28
|
RayCluster, RayJob, RayService,
|
|
29
|
+
ArgoAppFor, ArgoAppSetForRegions, registerArgoCluster,
|
|
29
30
|
AgicIngress, AzureDiskStorageClass, AzureFileStorageClass, AzureMonitorCollector,
|
|
30
31
|
AksWorkloadIdentityServiceAccount,
|
|
31
32
|
GkeFluentBitAgent, GkeOtelCollector, GkeExternalDnsAgent, AksExternalDnsAgent,
|
|
@@ -52,6 +53,10 @@ export type {
|
|
|
52
53
|
RayClusterProps, RayClusterResult, RayClusterSpec, ResourceSpec, HeadGroupSpec, WorkerGroupSpec,
|
|
53
54
|
RayJobProps, RayJobResult,
|
|
54
55
|
RayServiceProps, RayServiceResult,
|
|
56
|
+
ArgoDestination, ArgoSyncPolicy,
|
|
57
|
+
ArgoAppForOptions, ArgoAppForResult,
|
|
58
|
+
ArgoRegionTarget, ArgoAppSetForRegionsOptions, ArgoAppSetForRegionsResult,
|
|
59
|
+
RegisterArgoClusterOptions, RegisterArgoClusterResult,
|
|
55
60
|
AgicIngressProps, AgicIngressResult,
|
|
56
61
|
AzureDiskStorageClassProps, AzureDiskStorageClassResult,
|
|
57
62
|
AzureFileStorageClassProps, AzureFileStorageClassResult,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-lexicon lifecycle integration (#163) — Kubernetes row.
|
|
3
|
+
*
|
|
4
|
+
* Drives the REAL k8sPlugin through core's live-import driver and the changeset
|
|
5
|
+
* path, with the `kubectl` edge mocked.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
8
|
+
import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const execMock = vi.fn();
|
|
13
|
+
vi.mock("node:child_process", async () => {
|
|
14
|
+
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
exec: (
|
|
18
|
+
cmd: string,
|
|
19
|
+
cb: (err: Error | null, out: { stdout: string; stderr: string }) => void,
|
|
20
|
+
) => {
|
|
21
|
+
const r = execMock(cmd);
|
|
22
|
+
queueMicrotask(() =>
|
|
23
|
+
r instanceof Error
|
|
24
|
+
? cb(r, { stdout: "", stderr: "" })
|
|
25
|
+
: cb(null, r as { stdout: string; stderr: string }),
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { k8sPlugin } = await import("./plugin");
|
|
32
|
+
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
33
|
+
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
34
|
+
|
|
35
|
+
const liveDeployment = {
|
|
36
|
+
apiVersion: "apps/v1",
|
|
37
|
+
kind: "Deployment",
|
|
38
|
+
metadata: { name: "web", namespace: "default", uid: "d-1" },
|
|
39
|
+
spec: { replicas: 3, selector: { matchLabels: { app: "web" } } },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const emptyList = { stdout: JSON.stringify({ items: [] }), stderr: "" };
|
|
43
|
+
|
|
44
|
+
describe("k8s lifecycle integration (#163)", () => {
|
|
45
|
+
beforeEach(() => execMock.mockReset());
|
|
46
|
+
|
|
47
|
+
test("live-import driver: real exportResources → IR → generated source", async () => {
|
|
48
|
+
execMock.mockImplementation((cmd?: string) =>
|
|
49
|
+
cmd?.includes("get deployment.apps")
|
|
50
|
+
? { stdout: JSON.stringify({ items: [liveDeployment] }), stderr: "" }
|
|
51
|
+
: emptyList,
|
|
52
|
+
);
|
|
53
|
+
const output = mkdtempSync(join(tmpdir(), "chant-k8s-li-"));
|
|
54
|
+
try {
|
|
55
|
+
const result = await liveImportFromPlugins([k8sPlugin], {
|
|
56
|
+
environment: "prod",
|
|
57
|
+
output,
|
|
58
|
+
force: true,
|
|
59
|
+
});
|
|
60
|
+
expect(result.success).toBe(true);
|
|
61
|
+
expect(result.generatedFiles.length).toBeGreaterThan(0);
|
|
62
|
+
const all = readdirSync(output)
|
|
63
|
+
.map((f) => readFileSync(join(output, f), "utf-8"))
|
|
64
|
+
.join("\n")
|
|
65
|
+
.toLowerCase();
|
|
66
|
+
expect(all).toContain("deployment");
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(output, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
|
|
73
|
+
execMock.mockImplementation((cmd?: string) =>
|
|
74
|
+
cmd?.includes("deployment.apps web")
|
|
75
|
+
? {
|
|
76
|
+
stdout: JSON.stringify({
|
|
77
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-1" },
|
|
78
|
+
status: { readyReplicas: 3, replicas: 3 },
|
|
79
|
+
}),
|
|
80
|
+
stderr: "",
|
|
81
|
+
}
|
|
82
|
+
: new Error("not found"),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const observedNow = await k8sPlugin.describeResources!({
|
|
86
|
+
environment: "prod",
|
|
87
|
+
buildOutput: "",
|
|
88
|
+
entityNames: ["web"],
|
|
89
|
+
entities: new Map([
|
|
90
|
+
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
|
|
91
|
+
]),
|
|
92
|
+
});
|
|
93
|
+
expect(observedNow.web?.type).toBe("K8s::Apps::Deployment");
|
|
94
|
+
|
|
95
|
+
const cs = buildChangeSet("prod", {
|
|
96
|
+
declared: new Set(["webSvc"]),
|
|
97
|
+
observedNow,
|
|
98
|
+
observedThen: undefined,
|
|
99
|
+
});
|
|
100
|
+
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
|
|
101
|
+
expect(byName.webSvc).toBe("create");
|
|
102
|
+
expect(byName.web).toBe("adopt");
|
|
103
|
+
|
|
104
|
+
const cs2 = buildChangeSet("prod", {
|
|
105
|
+
declared: new Set(["web"]),
|
|
106
|
+
observedNow,
|
|
107
|
+
observedThen: undefined,
|
|
108
|
+
});
|
|
109
|
+
expect(cs2.entries.find((e) => e.name === "web")!.action).toBe("noop");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the Argo post-synth checks (ARGO002, ARGO003, ARGO005).
|
|
3
|
+
*
|
|
4
|
+
* Excluded from check auto-discovery by the "helper" filename filter.
|
|
5
|
+
*/
|
|
6
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
7
|
+
import { getPrimaryOutput, parseK8sManifests, type K8sManifest } from "./k8s-helpers";
|
|
8
|
+
|
|
9
|
+
/** The always-present, in-cluster Argo destination. */
|
|
10
|
+
export const IN_CLUSTER_SERVER = "https://kubernetes.default.svc";
|
|
11
|
+
export const IN_CLUSTER_NAME = "in-cluster";
|
|
12
|
+
|
|
13
|
+
/** Label that marks a Secret as an Argo CD external cluster registration. */
|
|
14
|
+
export const CLUSTER_SECRET_TYPE_LABEL = "argocd.argoproj.io/secret-type";
|
|
15
|
+
|
|
16
|
+
/** All manifests across every lexicon output. */
|
|
17
|
+
export function allManifests(ctx: PostSynthContext): K8sManifest[] {
|
|
18
|
+
const manifests: K8sManifest[] = [];
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
manifests.push(...parseK8sManifests(getPrimaryOutput(output)));
|
|
21
|
+
}
|
|
22
|
+
return manifests;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Manifests of a given Argo kind. */
|
|
26
|
+
export function manifestsOfKind(manifests: K8sManifest[], kind: string): K8sManifest[] {
|
|
27
|
+
return manifests.filter((m) => m.kind === kind);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a string field from a Secret's stringData or data block.
|
|
32
|
+
* (Argo cluster Secrets conventionally use stringData.)
|
|
33
|
+
*/
|
|
34
|
+
export function secretField(manifest: K8sManifest, key: string): string | undefined {
|
|
35
|
+
const stringData = manifest.stringData as Record<string, unknown> | undefined;
|
|
36
|
+
const data = manifest.data as Record<string, unknown> | undefined;
|
|
37
|
+
const fromStringData = stringData?.[key];
|
|
38
|
+
if (typeof fromStringData === "string") return fromStringData;
|
|
39
|
+
const fromData = data?.[key];
|
|
40
|
+
if (typeof fromData === "string") return fromData;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True if the Secret is labelled as an Argo cluster registration. */
|
|
45
|
+
export function isClusterSecret(manifest: K8sManifest): boolean {
|
|
46
|
+
if (manifest.kind !== "Secret") return false;
|
|
47
|
+
const labels = manifest.metadata?.labels;
|
|
48
|
+
return labels?.[CLUSTER_SECRET_TYPE_LABEL] === "cluster";
|
|
49
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGO002: Application.spec.project must reference a declared AppProject
|
|
3
|
+
*
|
|
4
|
+
* An Argo `Application` names an `AppProject` in `spec.project`; the project is
|
|
5
|
+
* the RBAC and source/destination guardrail. If the named project isn't
|
|
6
|
+
* declared in the build, Argo will reject the Application at sync time. The
|
|
7
|
+
* built-in `default` project always exists on an Argo install, so a reference
|
|
8
|
+
* to `default` is never flagged.
|
|
9
|
+
*/
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { allManifests, manifestsOfKind } from "./argo-helpers";
|
|
12
|
+
|
|
13
|
+
export const argo002: PostSynthCheck = {
|
|
14
|
+
id: "ARGO002",
|
|
15
|
+
description: "Application.spec.project must reference a declared AppProject (or the built-in default)",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
const manifests = allManifests(ctx);
|
|
20
|
+
|
|
21
|
+
const declaredProjects = new Set(
|
|
22
|
+
manifestsOfKind(manifests, "AppProject")
|
|
23
|
+
.map((p) => p.metadata?.name)
|
|
24
|
+
.filter((n): n is string => typeof n === "string"),
|
|
25
|
+
);
|
|
26
|
+
// Argo ships a built-in default project.
|
|
27
|
+
declaredProjects.add("default");
|
|
28
|
+
|
|
29
|
+
for (const app of manifestsOfKind(manifests, "Application")) {
|
|
30
|
+
const project = app.spec?.project;
|
|
31
|
+
const name = app.metadata?.name ?? "Application";
|
|
32
|
+
// No project set → defaults to `default` at sync time; not this check's job.
|
|
33
|
+
if (typeof project !== "string" || project === "") continue;
|
|
34
|
+
if (declaredProjects.has(project)) continue;
|
|
35
|
+
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
checkId: "ARGO002",
|
|
38
|
+
severity: "error",
|
|
39
|
+
message: `Application "${name}" references AppProject "${project}", which is not declared. Add an AppProject named "${project}" or reference an existing project.`,
|
|
40
|
+
entity: name,
|
|
41
|
+
lexicon: "k8s",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return diagnostics;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGO003: Application.spec.destination must reference a registered cluster
|
|
3
|
+
*
|
|
4
|
+
* An Argo `Application` targets a cluster via `spec.destination.server` (an API
|
|
5
|
+
* server URL) or `spec.destination.name` (a registered cluster name). External
|
|
6
|
+
* clusters are registered with a Secret labelled
|
|
7
|
+
* `argocd.argoproj.io/secret-type: cluster`. The in-cluster target
|
|
8
|
+
* (`https://kubernetes.default.svc` / name `in-cluster`) is always available.
|
|
9
|
+
* A destination that names neither a registered cluster nor the in-cluster
|
|
10
|
+
* target won't resolve at sync time.
|
|
11
|
+
*/
|
|
12
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
13
|
+
import {
|
|
14
|
+
allManifests,
|
|
15
|
+
manifestsOfKind,
|
|
16
|
+
isClusterSecret,
|
|
17
|
+
secretField,
|
|
18
|
+
IN_CLUSTER_SERVER,
|
|
19
|
+
IN_CLUSTER_NAME,
|
|
20
|
+
} from "./argo-helpers";
|
|
21
|
+
|
|
22
|
+
export const argo003: PostSynthCheck = {
|
|
23
|
+
id: "ARGO003",
|
|
24
|
+
description: "Application.spec.destination must reference a registered cluster or the in-cluster target",
|
|
25
|
+
|
|
26
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
27
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
28
|
+
const manifests = allManifests(ctx);
|
|
29
|
+
|
|
30
|
+
const registeredServers = new Set<string>([IN_CLUSTER_SERVER]);
|
|
31
|
+
const registeredNames = new Set<string>([IN_CLUSTER_NAME]);
|
|
32
|
+
for (const secret of manifests.filter(isClusterSecret)) {
|
|
33
|
+
const server = secretField(secret, "server");
|
|
34
|
+
if (server) registeredServers.add(server);
|
|
35
|
+
const name = secretField(secret, "name") ?? secret.metadata?.name;
|
|
36
|
+
if (typeof name === "string") registeredNames.add(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const app of manifestsOfKind(manifests, "Application")) {
|
|
40
|
+
const name = app.metadata?.name ?? "Application";
|
|
41
|
+
const destination = app.spec?.destination as
|
|
42
|
+
| { server?: unknown; name?: unknown }
|
|
43
|
+
| undefined;
|
|
44
|
+
|
|
45
|
+
if (!destination || (destination.server === undefined && destination.name === undefined)) {
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
checkId: "ARGO003",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: `Application "${name}" has no spec.destination.server or spec.destination.name — Argo cannot resolve a target cluster.`,
|
|
50
|
+
entity: name,
|
|
51
|
+
lexicon: "k8s",
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof destination.server === "string" && !registeredServers.has(destination.server)) {
|
|
57
|
+
diagnostics.push({
|
|
58
|
+
checkId: "ARGO003",
|
|
59
|
+
severity: "error",
|
|
60
|
+
message: `Application "${name}" targets cluster server "${destination.server}", which is not registered. Register it with a cluster Secret (label argocd.argoproj.io/secret-type=cluster) or use the in-cluster target.`,
|
|
61
|
+
entity: name,
|
|
62
|
+
lexicon: "k8s",
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof destination.name === "string" && !registeredNames.has(destination.name)) {
|
|
68
|
+
diagnostics.push({
|
|
69
|
+
checkId: "ARGO003",
|
|
70
|
+
severity: "error",
|
|
71
|
+
message: `Application "${name}" targets cluster name "${destination.name}", which is not registered. Register it with a cluster Secret or use the in-cluster target.`,
|
|
72
|
+
entity: name,
|
|
73
|
+
lexicon: "k8s",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return diagnostics;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGO005: Application source.path should point at an existing directory (warn)
|
|
3
|
+
*
|
|
4
|
+
* When an Argo `Application` syncs from a git source it names a `source.path`
|
|
5
|
+
* inside the repo. In the common monorepo layout — Argo watches the same repo
|
|
6
|
+
* Chant builds — that path is relative to the build root, so a typo'd or moved
|
|
7
|
+
* path surfaces as a sync error only after deploy. This check warns when the
|
|
8
|
+
* path doesn't resolve to a directory under the build root.
|
|
9
|
+
*
|
|
10
|
+
* It is a warning, not an error: Applications that sync from a *different*
|
|
11
|
+
* remote repo legitimately reference paths that don't exist locally. Helm chart
|
|
12
|
+
* sources (`source.chart`) and pathless sources are skipped.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, statSync } from "fs";
|
|
15
|
+
import { isAbsolute, resolve } from "path";
|
|
16
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
17
|
+
import { allManifests, manifestsOfKind } from "./argo-helpers";
|
|
18
|
+
|
|
19
|
+
function dirExists(path: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const full = isAbsolute(path) ? path : resolve(process.cwd(), path);
|
|
22
|
+
return existsSync(full) && statSync(full).isDirectory();
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const argo005: PostSynthCheck = {
|
|
29
|
+
id: "ARGO005",
|
|
30
|
+
description: "Application source.path should resolve to an existing directory under the build root",
|
|
31
|
+
|
|
32
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
33
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
34
|
+
|
|
35
|
+
for (const app of manifestsOfKind(allManifests(ctx), "Application")) {
|
|
36
|
+
const name = app.metadata?.name ?? "Application";
|
|
37
|
+
const source = app.spec?.source as
|
|
38
|
+
| { path?: unknown; chart?: unknown }
|
|
39
|
+
| undefined;
|
|
40
|
+
if (!source) continue;
|
|
41
|
+
// Helm chart sources have no filesystem path.
|
|
42
|
+
if (typeof source.chart === "string" && source.chart !== "") continue;
|
|
43
|
+
|
|
44
|
+
const path = source.path;
|
|
45
|
+
if (typeof path !== "string" || path === "") continue;
|
|
46
|
+
if (dirExists(path)) continue;
|
|
47
|
+
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
checkId: "ARGO005",
|
|
50
|
+
severity: "warning",
|
|
51
|
+
message: `Application "${name}" source.path "${path}" does not resolve to a directory under the build root. Confirm the path (or ignore if it lives in a different repo).`,
|
|
52
|
+
entity: name,
|
|
53
|
+
lexicon: "k8s",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return diagnostics;
|
|
58
|
+
},
|
|
59
|
+
};
|