@intentius/chant-lexicon-gcp 0.1.6 → 0.1.9
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 +3 -2
- package/src/describe-resources.test.ts +147 -0
- package/src/describe-resources.ts +118 -0
- package/src/import/generator.test.ts +26 -24
- package/src/import/generator.ts +5 -4
- package/src/import/import-fixtures.test.ts +4 -4
- package/src/import/parser.test.ts +2 -2
- package/src/import/parser.ts +2 -3
- package/src/import/roundtrip.test.ts +4 -4
- package/src/lsp/hover.ts +14 -5
- package/src/plugin.test.ts +2 -2
- package/src/plugin.ts +12 -126
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "6636d10b3cf11525d4bd6755ff919fe37c8684e9ad46a3d58c01f05667efe1aa",
|
|
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": "1d080519bb42ffe93a28e56907721646621ff47638a7969eb9ccd87849aabcd3"
|
|
41
41
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"fflate": "^0.8.2",
|
|
47
|
-
"js-yaml": "^4.1.0"
|
|
47
|
+
"js-yaml": "^4.1.0",
|
|
48
|
+
"@types/js-yaml": "^4.0.9"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@intentius/chant": "*",
|
|
@@ -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
|
+
}
|
|
@@ -4,14 +4,19 @@ import { GcpGenerator } from "./generator";
|
|
|
4
4
|
const generator = new GcpGenerator();
|
|
5
5
|
|
|
6
6
|
function makeIR(resources: any[]) {
|
|
7
|
-
return { resources, parameters: []
|
|
7
|
+
return { resources, parameters: [] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function content(ir: ReturnType<typeof makeIR>): string {
|
|
11
|
+
const files = generator.generate(ir);
|
|
12
|
+
return files[0].content;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
describe("GcpGenerator", () => {
|
|
11
16
|
test("generates valid TypeScript from IR", () => {
|
|
12
17
|
const ir = makeIR([
|
|
13
18
|
{
|
|
14
|
-
|
|
19
|
+
logicalId: "my-bucket",
|
|
15
20
|
type: "GCP::Storage::Bucket",
|
|
16
21
|
properties: {
|
|
17
22
|
metadata: { name: "my-bucket" },
|
|
@@ -19,7 +24,7 @@ describe("GcpGenerator", () => {
|
|
|
19
24
|
},
|
|
20
25
|
},
|
|
21
26
|
]);
|
|
22
|
-
const result =
|
|
27
|
+
const result = content(ir);
|
|
23
28
|
expect(result).toContain("import");
|
|
24
29
|
expect(result).toContain("Bucket");
|
|
25
30
|
});
|
|
@@ -27,29 +32,28 @@ describe("GcpGenerator", () => {
|
|
|
27
32
|
test("correct import source (@intentius/chant-lexicon-gcp)", () => {
|
|
28
33
|
const ir = makeIR([
|
|
29
34
|
{
|
|
30
|
-
|
|
35
|
+
logicalId: "my-bucket",
|
|
31
36
|
type: "GCP::Storage::Bucket",
|
|
32
37
|
properties: { location: "US" },
|
|
33
38
|
},
|
|
34
39
|
]);
|
|
35
|
-
|
|
36
|
-
expect(result).toContain('from "@intentius/chant-lexicon-gcp"');
|
|
40
|
+
expect(content(ir)).toContain('from "@intentius/chant-lexicon-gcp"');
|
|
37
41
|
});
|
|
38
42
|
|
|
39
43
|
test("multiple resources produce multiple exports", () => {
|
|
40
44
|
const ir = makeIR([
|
|
41
45
|
{
|
|
42
|
-
|
|
46
|
+
logicalId: "my-bucket",
|
|
43
47
|
type: "GCP::Storage::Bucket",
|
|
44
48
|
properties: { location: "US" },
|
|
45
49
|
},
|
|
46
50
|
{
|
|
47
|
-
|
|
51
|
+
logicalId: "my-vm",
|
|
48
52
|
type: "GCP::Compute::Instance",
|
|
49
53
|
properties: { machineType: "e2-medium" },
|
|
50
54
|
},
|
|
51
55
|
]);
|
|
52
|
-
const result =
|
|
56
|
+
const result = content(ir);
|
|
53
57
|
expect(result).toContain("export const myBucket");
|
|
54
58
|
expect(result).toContain("export const myVm");
|
|
55
59
|
});
|
|
@@ -57,26 +61,25 @@ describe("GcpGenerator", () => {
|
|
|
57
61
|
test("camelCase variable names from kebab-case logical names", () => {
|
|
58
62
|
const ir = makeIR([
|
|
59
63
|
{
|
|
60
|
-
|
|
64
|
+
logicalId: "my-data-bucket",
|
|
61
65
|
type: "GCP::Storage::Bucket",
|
|
62
66
|
properties: { location: "US" },
|
|
63
67
|
},
|
|
64
68
|
]);
|
|
65
|
-
|
|
66
|
-
expect(result).toContain("export const myDataBucket");
|
|
69
|
+
expect(content(ir)).toContain("export const myDataBucket");
|
|
67
70
|
});
|
|
68
71
|
|
|
69
|
-
test("empty IR produces
|
|
72
|
+
test("empty IR still produces a generated file", () => {
|
|
70
73
|
const ir = makeIR([]);
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
expect(typeof
|
|
74
|
+
const files = generator.generate(ir);
|
|
75
|
+
expect(files).toHaveLength(1);
|
|
76
|
+
expect(typeof files[0].content).toBe("string");
|
|
74
77
|
});
|
|
75
78
|
|
|
76
79
|
test("nested object formatting", () => {
|
|
77
80
|
const ir = makeIR([
|
|
78
81
|
{
|
|
79
|
-
|
|
82
|
+
logicalId: "my-bucket",
|
|
80
83
|
type: "GCP::Storage::Bucket",
|
|
81
84
|
properties: {
|
|
82
85
|
location: "US",
|
|
@@ -84,7 +87,7 @@ describe("GcpGenerator", () => {
|
|
|
84
87
|
},
|
|
85
88
|
},
|
|
86
89
|
]);
|
|
87
|
-
const result =
|
|
90
|
+
const result = content(ir);
|
|
88
91
|
expect(result).toContain("versioning:");
|
|
89
92
|
expect(result).toContain("enabled: true");
|
|
90
93
|
});
|
|
@@ -92,29 +95,28 @@ describe("GcpGenerator", () => {
|
|
|
92
95
|
test("uses new Constructor() syntax", () => {
|
|
93
96
|
const ir = makeIR([
|
|
94
97
|
{
|
|
95
|
-
|
|
98
|
+
logicalId: "my-bucket",
|
|
96
99
|
type: "GCP::Storage::Bucket",
|
|
97
100
|
properties: { location: "US" },
|
|
98
101
|
},
|
|
99
102
|
]);
|
|
100
|
-
|
|
101
|
-
expect(result).toContain("new Bucket(");
|
|
103
|
+
expect(content(ir)).toContain("new Bucket(");
|
|
102
104
|
});
|
|
103
105
|
|
|
104
106
|
test("sorts imports alphabetically", () => {
|
|
105
107
|
const ir = makeIR([
|
|
106
108
|
{
|
|
107
|
-
|
|
109
|
+
logicalId: "vm",
|
|
108
110
|
type: "GCP::Compute::Instance",
|
|
109
111
|
properties: { machineType: "e2-medium" },
|
|
110
112
|
},
|
|
111
113
|
{
|
|
112
|
-
|
|
114
|
+
logicalId: "bucket",
|
|
113
115
|
type: "GCP::Storage::Bucket",
|
|
114
116
|
properties: { location: "US" },
|
|
115
117
|
},
|
|
116
118
|
]);
|
|
117
|
-
const result =
|
|
119
|
+
const result = content(ir);
|
|
118
120
|
const importLine = result.split("\n").find((l: string) => l.startsWith("import"));
|
|
119
121
|
expect(importLine).toBeDefined();
|
|
120
122
|
// Bucket should come before Instance alphabetically
|
package/src/import/generator.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Converts import IR from the parser into typed chant TypeScript code.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { TypeScriptGenerator,
|
|
7
|
+
import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
|
|
8
|
+
import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
8
9
|
|
|
9
10
|
export class GcpGenerator implements TypeScriptGenerator {
|
|
10
|
-
generate(ir: TemplateIR):
|
|
11
|
+
generate(ir: TemplateIR): GeneratedFile[] {
|
|
11
12
|
const lines: string[] = [];
|
|
12
13
|
const imports = new Set<string>();
|
|
13
14
|
|
|
@@ -31,13 +32,13 @@ export class GcpGenerator implements TypeScriptGenerator {
|
|
|
31
32
|
for (const resource of ir.resources) {
|
|
32
33
|
const parts = resource.type.split("::");
|
|
33
34
|
const className = parts.length >= 3 ? parts[2] : resource.type;
|
|
34
|
-
const varName = camelCase(resource.
|
|
35
|
+
const varName = camelCase(resource.logicalId);
|
|
35
36
|
|
|
36
37
|
lines.push(`export const ${varName} = new ${className}(${formatProps(resource.properties, 0)});`);
|
|
37
38
|
lines.push("");
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
return lines.join("\n");
|
|
41
|
+
return [{ path: "main.ts", content: lines.join("\n") + "\n" }];
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -16,7 +16,7 @@ spec:
|
|
|
16
16
|
`;
|
|
17
17
|
const ir = parser.parse(yaml);
|
|
18
18
|
expect(ir.resources).toHaveLength(1);
|
|
19
|
-
expect(ir.resources[0].
|
|
19
|
+
expect(ir.resources[0].logicalId).toBe("my-bucket");
|
|
20
20
|
expect(ir.resources[0].type).toContain("Bucket");
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -40,8 +40,8 @@ spec:
|
|
|
40
40
|
`;
|
|
41
41
|
const ir = parser.parse(yaml);
|
|
42
42
|
expect(ir.resources).toHaveLength(2);
|
|
43
|
-
expect(ir.resources[0].
|
|
44
|
-
expect(ir.resources[1].
|
|
43
|
+
expect(ir.resources[0].logicalId).toBe("my-network");
|
|
44
|
+
expect(ir.resources[1].logicalId).toBe("my-subnet");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
test("skips non-Config-Connector resources", () => {
|
|
@@ -91,7 +91,7 @@ spec:
|
|
|
91
91
|
`;
|
|
92
92
|
const ir = parser.parse(yaml);
|
|
93
93
|
const generator = new GcpGenerator();
|
|
94
|
-
const ts = generator.generate(ir);
|
|
94
|
+
const ts = generator.generate(ir)[0].content;
|
|
95
95
|
expect(ts).toContain("Bucket");
|
|
96
96
|
expect(ts).toContain("import");
|
|
97
97
|
});
|
|
@@ -88,7 +88,7 @@ spec:
|
|
|
88
88
|
expect(ir.resources).toEqual([]);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("metadata.name extracted as
|
|
91
|
+
test("metadata.name extracted as logicalId", () => {
|
|
92
92
|
const yaml = `
|
|
93
93
|
apiVersion: compute.cnrm.cloud.google.com/v1beta1
|
|
94
94
|
kind: ComputeNetwork
|
|
@@ -98,7 +98,7 @@ spec:
|
|
|
98
98
|
autoCreateSubnetworks: false
|
|
99
99
|
`;
|
|
100
100
|
const ir = parser.parse(yaml);
|
|
101
|
-
expect((ir.resources[0] as any).
|
|
101
|
+
expect((ir.resources[0] as any).logicalId).toBe("my-network");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
test("properties include metadata and spec fields, exclude apiVersion/kind", () => {
|
package/src/import/parser.ts
CHANGED
|
@@ -51,7 +51,7 @@ export class GcpParser extends BaseValueParser implements TemplateParser {
|
|
|
51
51
|
const metadata = doc.metadata as Record<string, unknown> | undefined;
|
|
52
52
|
const spec = doc.spec as Record<string, unknown> | undefined;
|
|
53
53
|
|
|
54
|
-
const
|
|
54
|
+
const logicalId = (metadata?.name as string) ?? kind;
|
|
55
55
|
|
|
56
56
|
// Build properties from spec
|
|
57
57
|
const properties: Record<string, unknown> = {};
|
|
@@ -65,7 +65,7 @@ export class GcpParser extends BaseValueParser implements TemplateParser {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
resources.push({
|
|
68
|
-
|
|
68
|
+
logicalId,
|
|
69
69
|
type: typeName,
|
|
70
70
|
properties,
|
|
71
71
|
});
|
|
@@ -74,7 +74,6 @@ export class GcpParser extends BaseValueParser implements TemplateParser {
|
|
|
74
74
|
return {
|
|
75
75
|
resources,
|
|
76
76
|
parameters: [],
|
|
77
|
-
outputs: [],
|
|
78
77
|
};
|
|
79
78
|
}
|
|
80
79
|
}
|
|
@@ -18,7 +18,7 @@ describe("roundtrip: parse YAML → generate TypeScript", () => {
|
|
|
18
18
|
test("StorageBucket roundtrip", () => {
|
|
19
19
|
const yaml = readFileSync(join(testdataDir, "storage-bucket.yaml"), "utf-8");
|
|
20
20
|
const ir = parser.parse(yaml);
|
|
21
|
-
const ts = generator.generate(ir);
|
|
21
|
+
const ts = generator.generate(ir)[0].content;
|
|
22
22
|
|
|
23
23
|
expect(ir.resources.length).toBe(1);
|
|
24
24
|
expect(ts).toContain("new Bucket");
|
|
@@ -28,7 +28,7 @@ describe("roundtrip: parse YAML → generate TypeScript", () => {
|
|
|
28
28
|
test("ComputeInstance roundtrip", () => {
|
|
29
29
|
const yaml = readFileSync(join(testdataDir, "compute-instance.yaml"), "utf-8");
|
|
30
30
|
const ir = parser.parse(yaml);
|
|
31
|
-
const ts = generator.generate(ir);
|
|
31
|
+
const ts = generator.generate(ir)[0].content;
|
|
32
32
|
|
|
33
33
|
expect(ir.resources.length).toBe(1);
|
|
34
34
|
expect(ts).toContain("new Instance");
|
|
@@ -41,7 +41,7 @@ describe("roundtrip: parse YAML → generate TypeScript", () => {
|
|
|
41
41
|
const ir = parser.parse(yaml);
|
|
42
42
|
expect(ir.resources.length).toBe(3); // StorageBucket + IAMPolicyMember + ComputeNetwork
|
|
43
43
|
|
|
44
|
-
const ts = generator.generate(ir);
|
|
44
|
+
const ts = generator.generate(ir)[0].content;
|
|
45
45
|
expect(ts).toContain("Bucket");
|
|
46
46
|
expect(ts).toContain("PolicyMember");
|
|
47
47
|
expect(ts).toContain("Network");
|
|
@@ -58,7 +58,7 @@ spec:
|
|
|
58
58
|
storageClass: NEARLINE
|
|
59
59
|
`;
|
|
60
60
|
const ir = parser.parse(yaml);
|
|
61
|
-
const ts = generator.generate(ir);
|
|
61
|
+
const ts = generator.generate(ir)[0].content;
|
|
62
62
|
|
|
63
63
|
expect(ts).toContain("new Bucket");
|
|
64
64
|
expect(ts).toContain("export const");
|
package/src/lsp/hover.ts
CHANGED
|
@@ -3,11 +3,19 @@ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
|
3
3
|
import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
|
|
6
|
+
// GCP's generated lexicon JSON includes extra CRD-derived metadata (apiVersion,
|
|
7
|
+
// gvkKind) that the core LexiconEntry interface doesn't carry. Type the data
|
|
8
|
+
// locally as a GCP-specific extension.
|
|
9
|
+
type GcpLexiconEntry = LexiconEntry & {
|
|
10
|
+
apiVersion?: string;
|
|
11
|
+
gvkKind?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
6
14
|
let cachedIndex: LexiconIndex | null = null;
|
|
7
15
|
|
|
8
16
|
function getIndex(): LexiconIndex {
|
|
9
17
|
if (cachedIndex) return cachedIndex;
|
|
10
|
-
const data = require("../generated/lexicon-gcp.json") as Record<string,
|
|
18
|
+
const data = require("../generated/lexicon-gcp.json") as Record<string, GcpLexiconEntry>;
|
|
11
19
|
cachedIndex = new LexiconIndex(data);
|
|
12
20
|
return cachedIndex;
|
|
13
21
|
}
|
|
@@ -18,17 +26,18 @@ export function gcpHover(ctx: HoverContext): HoverInfo | undefined {
|
|
|
18
26
|
|
|
19
27
|
function resourceHover(className: string, entry: LexiconEntry): HoverInfo | undefined {
|
|
20
28
|
const lines: string[] = [];
|
|
29
|
+
const gcpEntry = entry as GcpLexiconEntry;
|
|
21
30
|
|
|
22
31
|
lines.push(`**${className}**`);
|
|
23
32
|
lines.push("");
|
|
24
33
|
lines.push(`GCP Config Connector resource: \`${entry.resourceType}\``);
|
|
25
34
|
|
|
26
|
-
if (
|
|
27
|
-
lines.push(`API Version: \`${
|
|
35
|
+
if (gcpEntry.apiVersion) {
|
|
36
|
+
lines.push(`API Version: \`${gcpEntry.apiVersion}\``);
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
if (
|
|
31
|
-
lines.push(`Kind: \`${
|
|
39
|
+
if (gcpEntry.gvkKind) {
|
|
40
|
+
lines.push(`Kind: \`${gcpEntry.gvkKind}\``);
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
const customAttrs = Object.entries(entry.attrs ?? {})
|
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
|
@@ -174,20 +174,19 @@ export const bucketReader = new IAMPolicyMember({
|
|
|
174
174
|
},
|
|
175
175
|
|
|
176
176
|
detectTemplate(data: unknown): boolean {
|
|
177
|
-
|
|
177
|
+
// Handle raw string input (unparsed YAML)
|
|
178
|
+
if (typeof data === "string") {
|
|
179
|
+
return data.includes("cnrm.cloud.google.com");
|
|
180
|
+
}
|
|
178
181
|
|
|
179
182
|
// Handle parsed YAML objects
|
|
183
|
+
if (typeof data !== "object" || data === null) return false;
|
|
180
184
|
const obj = data as Record<string, unknown>;
|
|
181
185
|
const apiVersion = obj.apiVersion;
|
|
182
186
|
if (typeof apiVersion === "string" && apiVersion.includes("cnrm.cloud.google.com")) {
|
|
183
187
|
return true;
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
// Handle string input
|
|
187
|
-
if (typeof data === "string") {
|
|
188
|
-
return data.includes("cnrm.cloud.google.com");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
190
|
return false;
|
|
192
191
|
},
|
|
193
192
|
|
|
@@ -207,131 +206,13 @@ export const bucketReader = new IAMPolicyMember({
|
|
|
207
206
|
return gcpHover(ctx);
|
|
208
207
|
},
|
|
209
208
|
|
|
210
|
-
async describeResources(options: {
|
|
211
|
-
environment: string;
|
|
212
|
-
buildOutput: string;
|
|
213
|
-
entityNames: string[];
|
|
214
|
-
}): Promise<Record<string, ResourceMetadata>> {
|
|
215
|
-
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
216
|
-
const rt = getRuntime();
|
|
217
|
-
const resources: Record<string, ResourceMetadata> = {};
|
|
218
|
-
|
|
219
|
-
// Convert TypeScript variable names to kebab-case manifest names
|
|
220
|
-
// (mirrors serializer.ts:165 metadata.name assignment)
|
|
221
|
-
function entityToManifestName(name: string): string {
|
|
222
|
-
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Detect namespace: prefer namespace from manifests, then kubectl context, fallback "default"
|
|
226
|
-
let namespace = "default";
|
|
227
|
-
try {
|
|
228
|
-
// Check manifests for an explicit namespace
|
|
229
|
-
const nsMatch = options.buildOutput.match(/^\s+namespace:\s*(.+)$/m);
|
|
230
|
-
if (nsMatch) {
|
|
231
|
-
namespace = nsMatch[1].trim();
|
|
232
|
-
} else {
|
|
233
|
-
// Try kubectl current context namespace
|
|
234
|
-
const nsResult = await rt.spawn([
|
|
235
|
-
"kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}",
|
|
236
|
-
]);
|
|
237
|
-
if (nsResult.exitCode === 0 && nsResult.stdout.trim()) {
|
|
238
|
-
namespace = nsResult.stdout.trim();
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
} catch (err) {
|
|
242
|
-
console.error(`[gcp] describeResources: namespace detection failed, using "default": ${err instanceof Error ? err.message : String(err)}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Parse build output to extract kind/name pairs
|
|
246
|
-
let manifests: Array<{ kind: string; name: string; apiVersion: string; namespace?: string }> = [];
|
|
247
|
-
try {
|
|
248
|
-
const docs = options.buildOutput.split(/^---$/m).filter((d) => d.trim());
|
|
249
|
-
for (const doc of docs) {
|
|
250
|
-
const kindMatch = doc.match(/^kind:\s*(.+)$/m);
|
|
251
|
-
const nameMatch = doc.match(/^\s+name:\s*(.+)$/m);
|
|
252
|
-
const apiVersionMatch = doc.match(/^apiVersion:\s*(.+)$/m);
|
|
253
|
-
if (kindMatch && nameMatch && apiVersionMatch) {
|
|
254
|
-
const nsMatch = doc.match(/^\s+namespace:\s*(.+)$/m);
|
|
255
|
-
manifests.push({
|
|
256
|
-
kind: kindMatch[1].trim(),
|
|
257
|
-
name: nameMatch[1].trim(),
|
|
258
|
-
apiVersion: apiVersionMatch[1].trim(),
|
|
259
|
-
...(nsMatch && { namespace: nsMatch[1].trim() }),
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
} catch (err) {
|
|
264
|
-
console.error(`[gcp] describeResources: failed to parse build output: ${err instanceof Error ? err.message : String(err)}`);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
let resolved = 0;
|
|
268
|
-
|
|
269
|
-
for (const entityName of options.entityNames) {
|
|
270
|
-
const manifestName = entityToManifestName(entityName);
|
|
271
|
-
const manifest = manifests.find((m) => m.name === manifestName);
|
|
272
|
-
if (!manifest) {
|
|
273
|
-
console.error(`[gcp] describeResources: no manifest found for entity "${entityName}" (expected manifest name "${manifestName}")`);
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const resourceNs = manifest.namespace ?? namespace;
|
|
278
|
-
const resourceType = manifest.kind.toLowerCase();
|
|
279
|
-
const getResult = await rt.spawn([
|
|
280
|
-
"kubectl", "get", resourceType, manifest.name,
|
|
281
|
-
"-n", resourceNs, "-o", "json",
|
|
282
|
-
]);
|
|
283
|
-
|
|
284
|
-
if (getResult.exitCode !== 0) {
|
|
285
|
-
console.error(`[gcp] describeResources: kubectl get ${resourceType} ${manifest.name} -n ${resourceNs} failed (exit ${getResult.exitCode}): ${getResult.stderr.trim()}`);
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
const obj = JSON.parse(getResult.stdout) as {
|
|
291
|
-
metadata: { name: string; uid: string; creationTimestamp: string };
|
|
292
|
-
status?: {
|
|
293
|
-
conditions?: Array<{ type: string; status: string }>;
|
|
294
|
-
externalRef?: string;
|
|
295
|
-
};
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
let status = "Unknown";
|
|
299
|
-
if (obj.status?.conditions) {
|
|
300
|
-
const ready = obj.status.conditions.find((c) => c.type === "Ready");
|
|
301
|
-
status = ready?.status === "True" ? "Ready" : "NotReady";
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const attributes: Record<string, unknown> = {
|
|
305
|
-
uid: obj.metadata.uid,
|
|
306
|
-
};
|
|
307
|
-
if (obj.status?.externalRef) {
|
|
308
|
-
attributes.externalRef = obj.status.externalRef;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
resources[entityName] = {
|
|
312
|
-
type: `${manifest.apiVersion}/${manifest.kind}`,
|
|
313
|
-
physicalId: obj.status?.externalRef ?? obj.metadata.name,
|
|
314
|
-
status,
|
|
315
|
-
lastUpdated: obj.metadata.creationTimestamp,
|
|
316
|
-
attributes,
|
|
317
|
-
};
|
|
318
|
-
resolved++;
|
|
319
|
-
} catch (err) {
|
|
320
|
-
console.error(`[gcp] describeResources: failed to parse kubectl output for "${entityName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
console.error(`[gcp] describeResources: ${resolved}/${options.entityNames.length} resources resolved`);
|
|
325
|
-
return resources;
|
|
326
|
-
},
|
|
327
|
-
|
|
328
209
|
mcpTools() {
|
|
329
|
-
return [createDiffTool(gcpSerializer, "Compare current build output against previous output for GCP Config Connector manifests")];
|
|
210
|
+
return [createDiffTool(gcpSerializer, "Compare current build output against previous output for GCP Config Connector manifests", "gcp")];
|
|
330
211
|
},
|
|
331
212
|
|
|
332
213
|
mcpResources() {
|
|
333
214
|
return [
|
|
334
|
-
createCatalogResource(import.meta.url, "GCP Config Connector Resource Catalog", "JSON list of all supported GCP Config Connector resource types", "lexicon-gcp.json"),
|
|
215
|
+
createCatalogResource(import.meta.url, "GCP Config Connector Resource Catalog", "JSON list of all supported GCP Config Connector resource types", "lexicon-gcp.json", "gcp"),
|
|
335
216
|
{
|
|
336
217
|
uri: "examples/basic-bucket",
|
|
337
218
|
name: "Basic GCS Bucket Example",
|
|
@@ -499,4 +380,9 @@ export const bucket = new StorageBucket({
|
|
|
499
380
|
const { generateDocs } = await import("./codegen/docs");
|
|
500
381
|
await generateDocs(options);
|
|
501
382
|
},
|
|
383
|
+
|
|
384
|
+
async describeResources(options) {
|
|
385
|
+
const { describeResources } = await import("./describe-resources");
|
|
386
|
+
return describeResources(options);
|
|
387
|
+
},
|
|
502
388
|
};
|