@intentius/chant-lexicon-k8s 0.1.6 → 0.1.8

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "7c2c231f5582d9228dc65679781e924d847bb5e8bb09a8861b6ec41c73bb17af",
4
+ "manifest.json": "26168ff9d5f4d906e451b0e2c9e7a868b9ae3ab366579d9abf76a45ace9a3b95",
5
5
  "meta.json": "970c70eb6eea1686f7cb8ce6ceccc46ce2e57d696d344f1dd52460e266f9a56c",
6
6
  "types/index.d.ts": "07473e029254345488bc9952585e88bcf18da79d1026cc26744027683f472554",
7
7
  "rules/hardcoded-namespace.ts": "ba3f43f2adbffdd87db20a2c45839354ceecda1b9f04f29ae31c4c077dddc7ec",
@@ -42,5 +42,5 @@
42
42
  "skills/chant-k8s-gke.md": "8938840bf9ef5ed58d6333fdd773b3dd54ecaf25a9df35e58f7f5c3355d4928f",
43
43
  "skills/chant-k8s-aks.md": "e18f0e2b055f72cd7a37deaf258d7027c2d4d3e286e8fd4975b27a1f981a3ad9"
44
44
  },
45
- "composite": "0c9b24ec573fdf75f6cc905b73496a3dc14c77121003ccfb4ea30a74cae054ef"
45
+ "composite": "de04872487ff8b14bc40143791702adeea08ff52942eca65e90e6611623ed767"
46
46
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k8s",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "K8s",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -0,0 +1,162 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ const execMock = vi.fn();
4
+ vi.mock("node:child_process", async () => {
5
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
6
+ return { ...actual, exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
7
+ Promise.resolve(execMock(cmd)).then(
8
+ (out) => cb(null, out),
9
+ (err) => cb(err as Error, { stdout: "", stderr: "" }),
10
+ );
11
+ } };
12
+ });
13
+
14
+ const { describeResources } = await import("./describe-resources");
15
+
16
+ function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
17
+ return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
18
+ }
19
+
20
+ describe("k8s describeResources", () => {
21
+ beforeEach(() => {
22
+ execMock.mockReset();
23
+ });
24
+
25
+ test("queries kubectl for each declared K8s entity and maps response to ResourceMetadata", async () => {
26
+ execMock.mockImplementation((cmd: string) => {
27
+ if (cmd.includes("deployment.apps web")) {
28
+ return {
29
+ stdout: JSON.stringify({
30
+ metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z", labels: { app: "web" } },
31
+ status: { readyReplicas: 3, replicas: 3 },
32
+ }),
33
+ stderr: "",
34
+ };
35
+ }
36
+ if (cmd.includes("service web-svc")) {
37
+ return {
38
+ stdout: JSON.stringify({
39
+ metadata: { name: "web-svc", namespace: "prod", uid: "uid-2", creationTimestamp: "2026-05-01T00:00:00Z" },
40
+ }),
41
+ stderr: "",
42
+ };
43
+ }
44
+ throw new Error(`unexpected cmd: ${cmd}`);
45
+ });
46
+
47
+ const entities = makeEntities([
48
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
49
+ { name: "webSvc", entityType: "K8s::Core::Service", props: { metadata: { name: "web-svc", namespace: "prod" } } },
50
+ ]);
51
+
52
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web", "webSvc"], entities });
53
+
54
+ expect(result["web"]).toMatchObject({
55
+ type: "K8s::Apps::Deployment",
56
+ physicalId: "uid-1",
57
+ status: "READY",
58
+ attributes: expect.objectContaining({ namespace: "prod", labels: { app: "web" } }),
59
+ });
60
+ expect(result["webSvc"]).toMatchObject({
61
+ type: "K8s::Core::Service",
62
+ physicalId: "uid-2",
63
+ status: "PRESENT",
64
+ });
65
+ });
66
+
67
+ test("kubectl-not-found leaves entity out of the result (so state diff reports as missing)", async () => {
68
+ execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): deployments.apps "missing" not found'); });
69
+
70
+ const entities = makeEntities([
71
+ { name: "missing", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "missing", namespace: "prod" } } },
72
+ ]);
73
+
74
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["missing"], entities });
75
+
76
+ expect(result).toEqual({});
77
+ });
78
+
79
+ test("entity types without kubectl mapping are warn-skipped", async () => {
80
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
81
+ const entities = makeEntities([
82
+ { name: "exotic", entityType: "K8s::Custom::CRD::SomeOperator", props: { metadata: { name: "x" } } },
83
+ ]);
84
+
85
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["exotic"], entities });
86
+
87
+ expect(result).toEqual({});
88
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("kubectl mapping"));
89
+ expect(warnSpy.mock.calls[0][0]).toContain("K8s::Custom::CRD::SomeOperator");
90
+ warnSpy.mockRestore();
91
+ });
92
+
93
+ test("Deployment with replicas != readyReplicas reports PROGRESSING", async () => {
94
+ execMock.mockResolvedValue({
95
+ stdout: JSON.stringify({
96
+ metadata: { name: "web", namespace: "prod", uid: "uid", creationTimestamp: "t" },
97
+ status: { readyReplicas: 1, replicas: 3 },
98
+ }),
99
+ stderr: "",
100
+ });
101
+
102
+ const entities = makeEntities([
103
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
104
+ ]);
105
+
106
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
107
+
108
+ expect(result["web"].status).toBe("PROGRESSING(1/3)");
109
+ });
110
+
111
+ test("Pod uses status.phase as the status", async () => {
112
+ execMock.mockResolvedValue({
113
+ stdout: JSON.stringify({
114
+ metadata: { name: "p", namespace: "prod", uid: "uid", creationTimestamp: "t" },
115
+ status: { phase: "Running" },
116
+ }),
117
+ stderr: "",
118
+ });
119
+
120
+ const entities = makeEntities([
121
+ { name: "p", entityType: "K8s::Core::Pod", props: { metadata: { name: "p", namespace: "prod" } } },
122
+ ]);
123
+
124
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["p"], entities });
125
+
126
+ expect(result["p"].status).toBe("Running");
127
+ });
128
+
129
+ test("namespace-less resource omits the -n flag", async () => {
130
+ let receivedCmd = "";
131
+ execMock.mockImplementation((cmd: string) => {
132
+ receivedCmd = cmd;
133
+ return {
134
+ stdout: JSON.stringify({
135
+ metadata: { name: "mynamespace", uid: "uid", creationTimestamp: "t" },
136
+ status: { phase: "Active" },
137
+ }),
138
+ stderr: "",
139
+ };
140
+ });
141
+
142
+ const entities = makeEntities([
143
+ { name: "ns", entityType: "K8s::Core::Namespace", props: { metadata: { name: "mynamespace" } } },
144
+ ]);
145
+
146
+ await describeResources({ environment: "prod", buildOutput: "", entityNames: ["ns"], entities });
147
+
148
+ expect(receivedCmd).not.toContain("-n");
149
+ expect(receivedCmd).toContain("namespace mynamespace");
150
+ });
151
+
152
+ test("entity without metadata.name is silently skipped", async () => {
153
+ const entities = makeEntities([
154
+ { name: "broken", entityType: "K8s::Apps::Deployment", props: {} },
155
+ ]);
156
+
157
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
158
+
159
+ expect(result).toEqual({});
160
+ expect(execMock).not.toHaveBeenCalled();
161
+ });
162
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Live introspection of a Kubernetes cluster — implements the
3
+ * LexiconPlugin.describeResources() contract for the k8s lexicon.
4
+ *
5
+ * For each declared K8s entity, runs `kubectl get <kind> <name> [-n <ns>] -o json`
6
+ * and maps the response to a ResourceMetadata entry keyed by the chant entity
7
+ * name (using the props.metadata.name + props.metadata.namespace from #39's
8
+ * entity-prop pass-through).
9
+ *
10
+ * Resource-not-found is silent — `state diff --live` then reports it as
11
+ * missing (declared, not in cloud). Unknown entity types are warn-skipped;
12
+ * extending the KUBECTL_RESOURCE map covers more.
13
+ */
14
+
15
+ import { exec } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
18
+
19
+ const execAsync = promisify(exec);
20
+
21
+ /**
22
+ * Map chant entity types to `kubectl get` resource names. Add entries here
23
+ * as new types are needed.
24
+ */
25
+ const KUBECTL_RESOURCE: Record<string, string> = {
26
+ "K8s::Apps::Deployment": "deployment.apps",
27
+ "K8s::Apps::StatefulSet": "statefulset.apps",
28
+ "K8s::Apps::DaemonSet": "daemonset.apps",
29
+ "K8s::Apps::ReplicaSet": "replicaset.apps",
30
+ "K8s::Core::Service": "service",
31
+ "K8s::Core::ConfigMap": "configmap",
32
+ "K8s::Core::Secret": "secret",
33
+ "K8s::Core::Namespace": "namespace",
34
+ "K8s::Core::Pod": "pod",
35
+ "K8s::Core::PersistentVolumeClaim": "persistentvolumeclaim",
36
+ "K8s::Core::ServiceAccount": "serviceaccount",
37
+ "K8s::Batch::Job": "job.batch",
38
+ "K8s::Batch::CronJob": "cronjob.batch",
39
+ "K8s::Networking::Ingress": "ingress.networking.k8s.io",
40
+ "K8s::Networking::NetworkPolicy": "networkpolicy.networking.k8s.io",
41
+ "K8s::Rbac::Role": "role.rbac.authorization.k8s.io",
42
+ "K8s::Rbac::RoleBinding": "rolebinding.rbac.authorization.k8s.io",
43
+ "K8s::Rbac::ClusterRole": "clusterrole.rbac.authorization.k8s.io",
44
+ "K8s::Rbac::ClusterRoleBinding": "clusterrolebinding.rbac.authorization.k8s.io",
45
+ };
46
+
47
+ interface KubectlResponse {
48
+ metadata?: {
49
+ name?: string;
50
+ namespace?: string;
51
+ uid?: string;
52
+ creationTimestamp?: string;
53
+ labels?: Record<string, string>;
54
+ resourceVersion?: string;
55
+ };
56
+ status?: {
57
+ phase?: string;
58
+ [k: string]: unknown;
59
+ };
60
+ }
61
+
62
+ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
63
+ const out: Record<string, unknown> = {};
64
+ for (const [k, v] of Object.entries(obj)) {
65
+ if (v !== undefined) out[k] = v;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function statusFromKubectl(obj: KubectlResponse): string {
71
+ // Different K8s resource types report status differently. Fall back to
72
+ // "PRESENT" if we can't extract a meaningful field.
73
+ const phase = obj.status?.phase;
74
+ if (typeof phase === "string") return phase;
75
+ // Deployment/StatefulSet — readyReplicas == replicas → READY
76
+ const status = obj.status as Record<string, unknown> | undefined;
77
+ if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") {
78
+ return status.readyReplicas === status.replicas ? "READY" : `PROGRESSING(${status.readyReplicas}/${status.replicas})`;
79
+ }
80
+ return "PRESENT";
81
+ }
82
+
83
+ export async function describeResources(options: {
84
+ environment: string;
85
+ buildOutput: string;
86
+ entityNames: string[];
87
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
88
+ }): Promise<Record<string, ResourceMetadata>> {
89
+ const result: Record<string, ResourceMetadata> = {};
90
+ const skippedTypes = new Set<string>();
91
+
92
+ for (const [entityName, { entityType, props }] of options.entities) {
93
+ const kubectlResource = KUBECTL_RESOURCE[entityType];
94
+ if (!kubectlResource) {
95
+ skippedTypes.add(entityType);
96
+ continue;
97
+ }
98
+
99
+ const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
100
+ const name = metadata?.name;
101
+ if (!name) continue;
102
+
103
+ const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
104
+ const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, "-o", "json"].join(" ");
105
+
106
+ try {
107
+ const { stdout } = await execAsync(cmd);
108
+ const obj: KubectlResponse = JSON.parse(stdout);
109
+ result[entityName] = {
110
+ type: entityType,
111
+ physicalId: obj.metadata?.uid,
112
+ status: statusFromKubectl(obj),
113
+ lastUpdated: obj.metadata?.creationTimestamp,
114
+ attributes: pruneUndefined({
115
+ namespace: obj.metadata?.namespace,
116
+ labels: obj.metadata?.labels,
117
+ resourceVersion: obj.metadata?.resourceVersion,
118
+ }),
119
+ };
120
+ } catch {
121
+ // Resource not found / kubectl error — leave it out so state diff
122
+ // can report it as missing. Don't fail the whole snapshot.
123
+ }
124
+ }
125
+
126
+ if (skippedTypes.size > 0) {
127
+ // eslint-disable-next-line no-console
128
+ console.warn(
129
+ `[k8s] skipped ${skippedTypes.size} entity type(s) without kubectl mapping: ${[...skippedTypes].join(", ")}`,
130
+ );
131
+ }
132
+
133
+ return result;
134
+ }
@@ -177,13 +177,13 @@ describe("k8sPlugin", () => {
177
177
  test("mcpTools() returns diff tool", () => {
178
178
  const tools = k8sPlugin.mcpTools!();
179
179
  expect(Array.isArray(tools)).toBe(true);
180
- expect(tools.some((t) => t.name === "diff")).toBe(true);
180
+ expect(tools.some((t) => t.name === "k8s:diff")).toBe(true);
181
181
  });
182
182
 
183
183
  test("mcpResources() returns resource-catalog and examples", () => {
184
184
  const resources = k8sPlugin.mcpResources!();
185
185
  expect(Array.isArray(resources)).toBe(true);
186
- expect(resources.some((r) => r.uri === "resource-catalog")).toBe(true);
186
+ expect(resources.some((r) => r.uri === "k8s:resource-catalog")).toBe(true);
187
187
  expect(resources.some((r) => r.uri === "examples/basic-deployment")).toBe(true);
188
188
  });
189
189
 
package/src/plugin.ts CHANGED
@@ -364,12 +364,12 @@ export const service = new Service({
364
364
  },
365
365
 
366
366
  mcpTools() {
367
- return [createDiffTool(k8sSerializer, "Compare current build output against previous output for Kubernetes manifests")];
367
+ return [createDiffTool(k8sSerializer, "Compare current build output against previous output for Kubernetes manifests", "k8s")];
368
368
  },
369
369
 
370
370
  mcpResources() {
371
371
  return [
372
- createCatalogResource(import.meta.url, "Kubernetes Resource Catalog", "JSON list of all supported Kubernetes resource types", "lexicon-k8s.json"),
372
+ createCatalogResource(import.meta.url, "Kubernetes Resource Catalog", "JSON list of all supported Kubernetes resource types", "lexicon-k8s.json", "k8s"),
373
373
  {
374
374
  uri: "examples/basic-deployment",
375
375
  name: "Basic Deployment Example",
@@ -657,4 +657,9 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
657
657
  ],
658
658
  },
659
659
  ]),
660
+
661
+ async describeResources(options) {
662
+ const { describeResources } = await import("./describe-resources");
663
+ return describeResources(options);
664
+ },
660
665
  };