@intentius/chant-lexicon-gcp 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.
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/describe-resources.test.ts +147 -0
- package/src/describe-resources.ts +118 -0
- package/src/plugin.test.ts +2 -2
- package/src/plugin.ts +7 -2
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "31ad54417045b2707c880b9b6176dd0aa8a5feeb1dfbc645f9d1a20707c525f5",
|
|
5
5
|
"meta.json": "2bc3713e9e01e90832d18dedaf4bf544dd4d8fe32f1363f7e95dc711d3d76e9d",
|
|
6
6
|
"types/index.d.ts": "0554f7e883c6216ba735cbe79a8534ccad762bea28cc4d4c4884f8760a2f1dd3",
|
|
7
7
|
"rules/hardcoded-project.ts": "228631d3159e1ffcce2359c66073d4ae59bb0285f378e46e446f416aec50481c",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"skills/chant-gcp-patterns.md": "a7ef31c1eb2f7244d3f73952c300472ef94c1eb09bd7a1003281b89299b6b704",
|
|
38
38
|
"skills/chant-gcp-gke.md": "be277019da9a722c851e47cd2dfb9c9536668948c3535fb20db7697e934c4e2b"
|
|
39
39
|
},
|
|
40
|
-
"composite": "
|
|
40
|
+
"composite": "a1ac4dc45f5c0421e27da3284a16555e53820e14a09161ce3318d10cb75b5024"
|
|
41
41
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
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("gcp describeResources (Config Connector)", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
execMock.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("queries kubectl with the derived CC GVK and maps the response", async () => {
|
|
26
|
+
let receivedCmd = "";
|
|
27
|
+
execMock.mockImplementation((cmd: string) => {
|
|
28
|
+
receivedCmd = cmd;
|
|
29
|
+
return {
|
|
30
|
+
stdout: JSON.stringify({
|
|
31
|
+
metadata: { name: "data-bucket", namespace: "config-control", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z" },
|
|
32
|
+
status: { conditions: [{ type: "Ready", status: "True" }] },
|
|
33
|
+
}),
|
|
34
|
+
stderr: "",
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const entities = makeEntities([
|
|
39
|
+
{ name: "dataBucket", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket", namespace: "config-control" } } },
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities });
|
|
43
|
+
|
|
44
|
+
// Resource name follows: <lowerKind>.<service>.cnrm.cloud.google.com
|
|
45
|
+
expect(receivedCmd).toContain("storagebucket.storage.cnrm.cloud.google.com");
|
|
46
|
+
expect(receivedCmd).toContain("data-bucket");
|
|
47
|
+
expect(receivedCmd).toContain("-n config-control");
|
|
48
|
+
|
|
49
|
+
expect(result["dataBucket"]).toMatchObject({
|
|
50
|
+
type: "GCP::Storage::Bucket",
|
|
51
|
+
physicalId: "uid-1",
|
|
52
|
+
status: "READY",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("Compute resource derives correct GVK with service prefix", async () => {
|
|
57
|
+
let receivedCmd = "";
|
|
58
|
+
execMock.mockImplementation((cmd: string) => {
|
|
59
|
+
receivedCmd = cmd;
|
|
60
|
+
return {
|
|
61
|
+
stdout: JSON.stringify({
|
|
62
|
+
metadata: { name: "subnet-1", uid: "uid", creationTimestamp: "t" },
|
|
63
|
+
status: { conditions: [{ type: "Ready", status: "True" }] },
|
|
64
|
+
}),
|
|
65
|
+
stderr: "",
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const entities = makeEntities([
|
|
70
|
+
{ name: "sub", entityType: "GCP::Compute::Subnetwork", props: { metadata: { name: "subnet-1" } } },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
await describeResources({ environment: "prod", buildOutput: "", entityNames: ["sub"], entities });
|
|
74
|
+
|
|
75
|
+
expect(receivedCmd).toContain("computesubnetwork.compute.cnrm.cloud.google.com");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("Ready=False maps to the condition's reason", async () => {
|
|
79
|
+
execMock.mockResolvedValue({
|
|
80
|
+
stdout: JSON.stringify({
|
|
81
|
+
metadata: { name: "x", uid: "uid", creationTimestamp: "t" },
|
|
82
|
+
status: { conditions: [{ type: "Ready", status: "False", reason: "DependencyNotFound", message: "..." }] },
|
|
83
|
+
}),
|
|
84
|
+
stderr: "",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const entities = makeEntities([
|
|
88
|
+
{ name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
92
|
+
|
|
93
|
+
expect(result["x"].status).toBe("DependencyNotFound");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("missing Ready condition falls back to PRESENT", async () => {
|
|
97
|
+
execMock.mockResolvedValue({
|
|
98
|
+
stdout: JSON.stringify({
|
|
99
|
+
metadata: { name: "x", uid: "uid", creationTimestamp: "t" },
|
|
100
|
+
status: {},
|
|
101
|
+
}),
|
|
102
|
+
stderr: "",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const entities = makeEntities([
|
|
106
|
+
{ name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
110
|
+
|
|
111
|
+
expect(result["x"].status).toBe("PRESENT");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("kubectl-not-found leaves entity out of result", async () => {
|
|
115
|
+
execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): storagebucket "x" not found'); });
|
|
116
|
+
|
|
117
|
+
const entities = makeEntities([
|
|
118
|
+
{ name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
122
|
+
|
|
123
|
+
expect(result).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("non-GCP entity types are skipped", async () => {
|
|
127
|
+
const entities = makeEntities([
|
|
128
|
+
{ name: "x", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual({});
|
|
134
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("entity without metadata.name is silently skipped", async () => {
|
|
138
|
+
const entities = makeEntities([
|
|
139
|
+
{ name: "broken", entityType: "GCP::Storage::Bucket", props: {} },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
|
|
143
|
+
|
|
144
|
+
expect(result).toEqual({});
|
|
145
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live introspection of a GCP project via Config Connector CRDs.
|
|
3
|
+
*
|
|
4
|
+
* GCP entities in chant are emitted as Config Connector custom resources
|
|
5
|
+
* (apiVersion <service>.cnrm.cloud.google.com/v1beta1, kind <Service><Kind>).
|
|
6
|
+
* To observe them at runtime we shell out to kubectl against a Config
|
|
7
|
+
* Connector-enabled cluster — the same pattern as the K8s lexicon.
|
|
8
|
+
*
|
|
9
|
+
* GCP::Storage::Bucket → kubectl get storagebucket.storage.cnrm.cloud.google.com
|
|
10
|
+
* GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
|
|
11
|
+
*
|
|
12
|
+
* Resource-not-found is silent — `state diff --live` reports it as missing.
|
|
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
|
+
interface KubectlResponse {
|
|
22
|
+
metadata?: {
|
|
23
|
+
name?: string;
|
|
24
|
+
namespace?: string;
|
|
25
|
+
uid?: string;
|
|
26
|
+
creationTimestamp?: string;
|
|
27
|
+
labels?: Record<string, string>;
|
|
28
|
+
annotations?: Record<string, string>;
|
|
29
|
+
};
|
|
30
|
+
status?: {
|
|
31
|
+
conditions?: Array<{ type?: string; status?: string; reason?: string; message?: string }>;
|
|
32
|
+
[k: string]: unknown;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mirror of `lexicons/gcp/src/serializer.ts:deriveGVKFromType` — keeping the
|
|
38
|
+
* derivation logic local so describeResources can compute the kubectl resource
|
|
39
|
+
* name without importing serializer internals.
|
|
40
|
+
*/
|
|
41
|
+
function deriveGVK(entityType: string): { group: string; kind: string } | null {
|
|
42
|
+
const parts = entityType.split("::");
|
|
43
|
+
if (parts.length !== 3 || parts[0] !== "GCP") return null;
|
|
44
|
+
const service = parts[1].toLowerCase();
|
|
45
|
+
const shortKind = parts[2];
|
|
46
|
+
return {
|
|
47
|
+
group: `${service}.cnrm.cloud.google.com`,
|
|
48
|
+
kind: `${parts[1]}${shortKind}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
53
|
+
const out: Record<string, unknown> = {};
|
|
54
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
55
|
+
if (v !== undefined) out[k] = v;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Config Connector encodes deployment state as a `Ready` condition on the
|
|
62
|
+
* resource's status. Fall back to listing all condition types if `Ready`
|
|
63
|
+
* isn't present.
|
|
64
|
+
*/
|
|
65
|
+
function statusFromCC(obj: KubectlResponse): string {
|
|
66
|
+
const conditions = obj.status?.conditions ?? [];
|
|
67
|
+
const ready = conditions.find((c) => c.type === "Ready");
|
|
68
|
+
if (ready) {
|
|
69
|
+
if (ready.status === "True") return "READY";
|
|
70
|
+
return ready.reason ?? "NOT_READY";
|
|
71
|
+
}
|
|
72
|
+
if (conditions.length > 0) {
|
|
73
|
+
return conditions.map((c) => `${c.type}=${c.status}`).join(",");
|
|
74
|
+
}
|
|
75
|
+
return "PRESENT";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function describeResources(options: {
|
|
79
|
+
environment: string;
|
|
80
|
+
buildOutput: string;
|
|
81
|
+
entityNames: string[];
|
|
82
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
83
|
+
}): Promise<Record<string, ResourceMetadata>> {
|
|
84
|
+
const result: Record<string, ResourceMetadata> = {};
|
|
85
|
+
|
|
86
|
+
for (const [entityName, { entityType, props }] of options.entities) {
|
|
87
|
+
const gvk = deriveGVK(entityType);
|
|
88
|
+
if (!gvk) continue;
|
|
89
|
+
|
|
90
|
+
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
|
|
91
|
+
const name = metadata?.name;
|
|
92
|
+
if (!name) continue;
|
|
93
|
+
|
|
94
|
+
const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
|
|
95
|
+
const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
|
|
96
|
+
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, "-o", "json"].join(" ");
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const { stdout } = await execAsync(cmd);
|
|
100
|
+
const obj: KubectlResponse = JSON.parse(stdout);
|
|
101
|
+
result[entityName] = {
|
|
102
|
+
type: entityType,
|
|
103
|
+
physicalId: obj.metadata?.uid,
|
|
104
|
+
status: statusFromCC(obj),
|
|
105
|
+
lastUpdated: obj.metadata?.creationTimestamp,
|
|
106
|
+
attributes: pruneUndefined({
|
|
107
|
+
namespace: obj.metadata?.namespace,
|
|
108
|
+
labels: obj.metadata?.labels,
|
|
109
|
+
annotations: obj.metadata?.annotations,
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
// CRD or instance not found — skip silently
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -214,7 +214,7 @@ describe("gcpPlugin", () => {
|
|
|
214
214
|
|
|
215
215
|
test("diff tool has correct structure", () => {
|
|
216
216
|
const tools = gcpPlugin.mcpTools!();
|
|
217
|
-
const diffTool = tools.find((t) => t.name === "diff");
|
|
217
|
+
const diffTool = tools.find((t) => t.name === "gcp:diff");
|
|
218
218
|
expect(diffTool).toBeDefined();
|
|
219
219
|
expect(diffTool!.description.length).toBeGreaterThan(0);
|
|
220
220
|
expect(diffTool!.inputSchema.type).toBe("object");
|
|
@@ -230,7 +230,7 @@ describe("gcpPlugin", () => {
|
|
|
230
230
|
|
|
231
231
|
test("resource-catalog has correct structure", () => {
|
|
232
232
|
const resources = gcpPlugin.mcpResources!();
|
|
233
|
-
const catalog = resources.find((r) => r.uri === "resource-catalog");
|
|
233
|
+
const catalog = resources.find((r) => r.uri === "gcp:resource-catalog");
|
|
234
234
|
expect(catalog).toBeDefined();
|
|
235
235
|
expect(catalog!.mimeType).toBe("application/json");
|
|
236
236
|
expect(typeof catalog!.handler).toBe("function");
|
package/src/plugin.ts
CHANGED
|
@@ -326,12 +326,12 @@ export const bucketReader = new IAMPolicyMember({
|
|
|
326
326
|
},
|
|
327
327
|
|
|
328
328
|
mcpTools() {
|
|
329
|
-
return [createDiffTool(gcpSerializer, "Compare current build output against previous output for GCP Config Connector manifests")];
|
|
329
|
+
return [createDiffTool(gcpSerializer, "Compare current build output against previous output for GCP Config Connector manifests", "gcp")];
|
|
330
330
|
},
|
|
331
331
|
|
|
332
332
|
mcpResources() {
|
|
333
333
|
return [
|
|
334
|
-
createCatalogResource(import.meta.url, "GCP Config Connector Resource Catalog", "JSON list of all supported GCP Config Connector resource types", "lexicon-gcp.json"),
|
|
334
|
+
createCatalogResource(import.meta.url, "GCP Config Connector Resource Catalog", "JSON list of all supported GCP Config Connector resource types", "lexicon-gcp.json", "gcp"),
|
|
335
335
|
{
|
|
336
336
|
uri: "examples/basic-bucket",
|
|
337
337
|
name: "Basic GCS Bucket Example",
|
|
@@ -499,4 +499,9 @@ export const bucket = new StorageBucket({
|
|
|
499
499
|
const { generateDocs } = await import("./codegen/docs");
|
|
500
500
|
await generateDocs(options);
|
|
501
501
|
},
|
|
502
|
+
|
|
503
|
+
async describeResources(options) {
|
|
504
|
+
const { describeResources } = await import("./describe-resources");
|
|
505
|
+
return describeResources(options);
|
|
506
|
+
},
|
|
502
507
|
};
|