@intentius/chant-lexicon-gcp 0.0.18 → 0.0.22
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 +8 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +18141 -0
- package/dist/rules/schema-registry.ts +91 -0
- package/dist/rules/wgc401.ts +59 -0
- package/dist/rules/wgc402.ts +54 -0
- package/dist/rules/wgc403.ts +84 -0
- package/dist/skills/chant-gcp.md +4 -1
- package/package.json +20 -2
- package/src/codegen/docs.test.ts +16 -0
- package/src/codegen/generate.test.ts +18 -0
- package/src/codegen/generate.ts +11 -0
- package/src/codegen/package.test.ts +16 -0
- package/src/generated/lexicon-gcp.json +18141 -0
- package/src/import/import-fixtures.test.ts +98 -0
- package/src/lint/post-synth/gcp-helpers.test.ts +166 -0
- package/src/lint/post-synth/post-synth.test.ts +130 -0
- package/src/lint/post-synth/schema-registry.ts +91 -0
- package/src/lint/post-synth/wgc101.test.ts +39 -0
- package/src/lint/post-synth/wgc102.test.ts +38 -0
- package/src/lint/post-synth/wgc103.test.ts +38 -0
- package/src/lint/post-synth/wgc104.test.ts +37 -0
- package/src/lint/post-synth/wgc105.test.ts +46 -0
- package/src/lint/post-synth/wgc106.test.ts +38 -0
- package/src/lint/post-synth/wgc107.test.ts +38 -0
- package/src/lint/post-synth/wgc108.test.ts +42 -0
- package/src/lint/post-synth/wgc109.test.ts +46 -0
- package/src/lint/post-synth/wgc110.test.ts +37 -0
- package/src/lint/post-synth/wgc111.test.ts +46 -0
- package/src/lint/post-synth/wgc112.test.ts +48 -0
- package/src/lint/post-synth/wgc113.test.ts +36 -0
- package/src/lint/post-synth/wgc201.test.ts +38 -0
- package/src/lint/post-synth/wgc202.test.ts +38 -0
- package/src/lint/post-synth/wgc203.test.ts +45 -0
- package/src/lint/post-synth/wgc204.test.ts +42 -0
- package/src/lint/post-synth/wgc301.test.ts +39 -0
- package/src/lint/post-synth/wgc302.test.ts +36 -0
- package/src/lint/post-synth/wgc303.test.ts +37 -0
- package/src/lint/post-synth/wgc401.test.ts +46 -0
- package/src/lint/post-synth/wgc401.ts +59 -0
- package/src/lint/post-synth/wgc402.test.ts +40 -0
- package/src/lint/post-synth/wgc402.ts +54 -0
- package/src/lint/post-synth/wgc403.test.ts +59 -0
- package/src/lint/post-synth/wgc403.ts +84 -0
- package/src/plugin.test.ts +4 -1
- package/src/plugin.ts +172 -3
- package/src/skills/chant-gcp.md +4 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGC401: Unknown spec field
|
|
3
|
+
*
|
|
4
|
+
* Flags fields in a Config Connector resource spec that are not defined
|
|
5
|
+
* in the CRD schema. Includes "did you mean?" suggestion via Levenshtein.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { parseGcpManifests, isConfigConnectorResource, getResourceName, getSpec } from "./gcp-helpers";
|
|
10
|
+
import { getSchemaRegistry, suggestField } from "./schema-registry";
|
|
11
|
+
|
|
12
|
+
export const wgc401: PostSynthCheck = {
|
|
13
|
+
id: "WGC401",
|
|
14
|
+
description: "Config Connector resource spec contains unknown field",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
const registry = getSchemaRegistry();
|
|
19
|
+
|
|
20
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
21
|
+
if (typeof output !== "string") continue;
|
|
22
|
+
|
|
23
|
+
const manifests = parseGcpManifests(output);
|
|
24
|
+
|
|
25
|
+
for (const manifest of manifests) {
|
|
26
|
+
if (!isConfigConnectorResource(manifest)) continue;
|
|
27
|
+
|
|
28
|
+
const kind = manifest.kind;
|
|
29
|
+
if (!kind) continue;
|
|
30
|
+
|
|
31
|
+
const schema = registry.get(kind);
|
|
32
|
+
if (!schema) continue; // No schema available for this kind
|
|
33
|
+
|
|
34
|
+
const spec = getSpec(manifest);
|
|
35
|
+
if (!spec) continue;
|
|
36
|
+
|
|
37
|
+
const knownFields = Object.keys(schema.fields);
|
|
38
|
+
const resourceName = getResourceName(manifest);
|
|
39
|
+
|
|
40
|
+
for (const field of Object.keys(spec)) {
|
|
41
|
+
if (field in schema.fields) continue;
|
|
42
|
+
|
|
43
|
+
const suggestion = suggestField(field, knownFields);
|
|
44
|
+
const hint = suggestion ? ` — did you mean "${suggestion}"?` : "";
|
|
45
|
+
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
checkId: "WGC401",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: `Resource "${resourceName}" (${kind}): unknown spec field "${field}"${hint}`,
|
|
50
|
+
entity: resourceName,
|
|
51
|
+
lexicon: "gcp",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return diagnostics;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc402 } from "./wgc402";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC402: missing required field", () => {
|
|
11
|
+
test("flags missing required field", () => {
|
|
12
|
+
const yaml = `apiVersion: compute.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: ComputeAddress
|
|
14
|
+
metadata:
|
|
15
|
+
name: my-addr
|
|
16
|
+
spec:
|
|
17
|
+
description: test
|
|
18
|
+
`;
|
|
19
|
+
const diags = wgc402.check(makeCtx(yaml));
|
|
20
|
+
const requiredDiags = diags.filter(d => d.checkId === "WGC402");
|
|
21
|
+
// ComputeAddress requires "location" -- if schema is loaded, this flags it
|
|
22
|
+
if (requiredDiags.length > 0) {
|
|
23
|
+
expect(requiredDiags[0].severity).toBe("error");
|
|
24
|
+
expect(requiredDiags[0].message).toContain("required");
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("no diagnostic when all required fields present", () => {
|
|
29
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
30
|
+
kind: StorageBucket
|
|
31
|
+
metadata:
|
|
32
|
+
name: my-bucket
|
|
33
|
+
spec:
|
|
34
|
+
location: US
|
|
35
|
+
`;
|
|
36
|
+
const diags = wgc402.check(makeCtx(yaml));
|
|
37
|
+
const requiredDiags = diags.filter(d => d.checkId === "WGC402");
|
|
38
|
+
expect(requiredDiags).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGC402: Missing required spec field
|
|
3
|
+
*
|
|
4
|
+
* Flags Config Connector resources that are missing required fields
|
|
5
|
+
* according to their CRD schema.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { parseGcpManifests, isConfigConnectorResource, getResourceName, getSpec } from "./gcp-helpers";
|
|
10
|
+
import { getSchemaRegistry } from "./schema-registry";
|
|
11
|
+
|
|
12
|
+
export const wgc402: PostSynthCheck = {
|
|
13
|
+
id: "WGC402",
|
|
14
|
+
description: "Config Connector resource is missing a required spec field",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
const registry = getSchemaRegistry();
|
|
19
|
+
|
|
20
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
21
|
+
if (typeof output !== "string") continue;
|
|
22
|
+
|
|
23
|
+
const manifests = parseGcpManifests(output);
|
|
24
|
+
|
|
25
|
+
for (const manifest of manifests) {
|
|
26
|
+
if (!isConfigConnectorResource(manifest)) continue;
|
|
27
|
+
|
|
28
|
+
const kind = manifest.kind;
|
|
29
|
+
if (!kind) continue;
|
|
30
|
+
|
|
31
|
+
const schema = registry.get(kind);
|
|
32
|
+
if (!schema) continue;
|
|
33
|
+
|
|
34
|
+
const spec = getSpec(manifest);
|
|
35
|
+
const specKeys = new Set(spec ? Object.keys(spec) : []);
|
|
36
|
+
const resourceName = getResourceName(manifest);
|
|
37
|
+
|
|
38
|
+
for (const requiredField of schema.required) {
|
|
39
|
+
if (!specKeys.has(requiredField)) {
|
|
40
|
+
diagnostics.push({
|
|
41
|
+
checkId: "WGC402",
|
|
42
|
+
severity: "error",
|
|
43
|
+
message: `Resource "${resourceName}" (${kind}): missing required field "${requiredField}"`,
|
|
44
|
+
entity: resourceName,
|
|
45
|
+
lexicon: "gcp",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc403 } from "./wgc403";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC403: type/structure mismatch", () => {
|
|
11
|
+
test("flags string where number expected", () => {
|
|
12
|
+
const yaml = `apiVersion: cloudfunctions.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: CloudFunctionsFunction
|
|
14
|
+
metadata:
|
|
15
|
+
name: my-fn
|
|
16
|
+
spec:
|
|
17
|
+
runtime: nodejs18
|
|
18
|
+
availableMemoryMb: "512"
|
|
19
|
+
region: us-central1
|
|
20
|
+
`;
|
|
21
|
+
const diags = wgc403.check(makeCtx(yaml));
|
|
22
|
+
const typeDiags = diags.filter(d => d.checkId === "WGC403");
|
|
23
|
+
if (typeDiags.length > 0) {
|
|
24
|
+
expect(typeDiags[0].severity).toBe("error");
|
|
25
|
+
expect(typeDiags[0].message).toContain("number");
|
|
26
|
+
expect(typeDiags[0].message).toContain("string");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("flags bare string instead of resourceRef object", () => {
|
|
31
|
+
const yaml = `apiVersion: pubsub.cnrm.cloud.google.com/v1beta1
|
|
32
|
+
kind: PubSubSubscription
|
|
33
|
+
metadata:
|
|
34
|
+
name: my-sub
|
|
35
|
+
spec:
|
|
36
|
+
topicRef: my-topic
|
|
37
|
+
`;
|
|
38
|
+
const diags = wgc403.check(makeCtx(yaml));
|
|
39
|
+
const typeDiags = diags.filter(d => d.checkId === "WGC403");
|
|
40
|
+
if (typeDiags.length > 0) {
|
|
41
|
+
expect(typeDiags[0].severity).toBe("error");
|
|
42
|
+
expect(typeDiags[0].message).toContain("resourceRef");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("no diagnostic with correct types", () => {
|
|
47
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
48
|
+
kind: StorageBucket
|
|
49
|
+
metadata:
|
|
50
|
+
name: my-bucket
|
|
51
|
+
spec:
|
|
52
|
+
location: US
|
|
53
|
+
uniformBucketLevelAccess: true
|
|
54
|
+
`;
|
|
55
|
+
const diags = wgc403.check(makeCtx(yaml));
|
|
56
|
+
const typeDiags = diags.filter(d => d.checkId === "WGC403");
|
|
57
|
+
expect(typeDiags).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGC403: Type/structure mismatch in spec field
|
|
3
|
+
*
|
|
4
|
+
* Flags spec fields where the value type doesn't match the CRD schema:
|
|
5
|
+
* - String where number expected (e.g. availableMemoryMb: "512")
|
|
6
|
+
* - Scalar string where resourceRef object expected (e.g. topicRef: "name")
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { parseGcpManifests, isConfigConnectorResource, getResourceName, getSpec } from "./gcp-helpers";
|
|
11
|
+
import { getSchemaRegistry } from "./schema-registry";
|
|
12
|
+
|
|
13
|
+
export const wgc403: PostSynthCheck = {
|
|
14
|
+
id: "WGC403",
|
|
15
|
+
description: "Config Connector resource spec field has wrong type or structure",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
const registry = getSchemaRegistry();
|
|
20
|
+
|
|
21
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
22
|
+
if (typeof output !== "string") continue;
|
|
23
|
+
|
|
24
|
+
const manifests = parseGcpManifests(output);
|
|
25
|
+
|
|
26
|
+
for (const manifest of manifests) {
|
|
27
|
+
if (!isConfigConnectorResource(manifest)) continue;
|
|
28
|
+
|
|
29
|
+
const kind = manifest.kind;
|
|
30
|
+
if (!kind) continue;
|
|
31
|
+
|
|
32
|
+
const schema = registry.get(kind);
|
|
33
|
+
if (!schema) continue;
|
|
34
|
+
|
|
35
|
+
const spec = getSpec(manifest);
|
|
36
|
+
if (!spec) continue;
|
|
37
|
+
|
|
38
|
+
const resourceName = getResourceName(manifest);
|
|
39
|
+
|
|
40
|
+
for (const [field, value] of Object.entries(spec)) {
|
|
41
|
+
const fieldSchema = schema.fields[field];
|
|
42
|
+
if (!fieldSchema) continue; // Unknown fields handled by WGC401
|
|
43
|
+
|
|
44
|
+
// Check: resourceRef field expects an object, got a scalar
|
|
45
|
+
if (fieldSchema.ref && typeof value === "string") {
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
checkId: "WGC403",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: `Resource "${resourceName}" (${kind}): field "${field}" expects a resourceRef object (e.g. { name, kind }), got string "${value}"`,
|
|
50
|
+
entity: resourceName,
|
|
51
|
+
lexicon: "gcp",
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check: number field got a string that looks numeric
|
|
57
|
+
if (fieldSchema.type === "number" && typeof value === "string") {
|
|
58
|
+
diagnostics.push({
|
|
59
|
+
checkId: "WGC403",
|
|
60
|
+
severity: "error",
|
|
61
|
+
message: `Resource "${resourceName}" (${kind}): field "${field}" expects a number, got string "${value}"`,
|
|
62
|
+
entity: resourceName,
|
|
63
|
+
lexicon: "gcp",
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check: boolean field got a string
|
|
69
|
+
if (fieldSchema.type === "boolean" && typeof value === "string") {
|
|
70
|
+
diagnostics.push({
|
|
71
|
+
checkId: "WGC403",
|
|
72
|
+
severity: "error",
|
|
73
|
+
message: `Resource "${resourceName}" (${kind}): field "${field}" expects a boolean, got string "${value}"`,
|
|
74
|
+
entity: resourceName,
|
|
75
|
+
lexicon: "gcp",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return diagnostics;
|
|
83
|
+
},
|
|
84
|
+
};
|
package/src/plugin.test.ts
CHANGED
|
@@ -30,7 +30,7 @@ describe("gcpPlugin", () => {
|
|
|
30
30
|
|
|
31
31
|
test("returns post-synth checks", () => {
|
|
32
32
|
const checks = gcpPlugin.postSynthChecks!();
|
|
33
|
-
expect(checks).toHaveLength(
|
|
33
|
+
expect(checks).toHaveLength(23);
|
|
34
34
|
const ids = checks.map((c) => c.id);
|
|
35
35
|
expect(ids).toContain("WGC101");
|
|
36
36
|
expect(ids).toContain("WGC102");
|
|
@@ -52,6 +52,9 @@ describe("gcpPlugin", () => {
|
|
|
52
52
|
expect(ids).toContain("WGC301");
|
|
53
53
|
expect(ids).toContain("WGC302");
|
|
54
54
|
expect(ids).toContain("WGC303");
|
|
55
|
+
expect(ids).toContain("WGC401");
|
|
56
|
+
expect(ids).toContain("WGC402");
|
|
57
|
+
expect(ids).toContain("WGC403");
|
|
55
58
|
});
|
|
56
59
|
|
|
57
60
|
// ── Intrinsics / pseudo-parameters ─────────────────────────────────
|
package/src/plugin.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createRequire } from "module";
|
|
9
|
-
import type { LexiconPlugin, SkillDefinition, InitTemplateSet } from "@intentius/chant/lexicon";
|
|
9
|
+
import type { LexiconPlugin, SkillDefinition, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
12
12
|
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
@@ -48,11 +48,15 @@ export const gcpPlugin: LexiconPlugin = {
|
|
|
48
48
|
const { wgc111 } = require("./lint/post-synth/wgc111");
|
|
49
49
|
const { wgc112 } = require("./lint/post-synth/wgc112");
|
|
50
50
|
const { wgc113 } = require("./lint/post-synth/wgc113");
|
|
51
|
+
const { wgc401 } = require("./lint/post-synth/wgc401");
|
|
52
|
+
const { wgc402 } = require("./lint/post-synth/wgc402");
|
|
53
|
+
const { wgc403 } = require("./lint/post-synth/wgc403");
|
|
51
54
|
return [
|
|
52
55
|
wgc101, wgc102, wgc103, wgc104, wgc105, wgc106, wgc107, wgc108, wgc109, wgc110,
|
|
53
56
|
wgc111, wgc112, wgc113,
|
|
54
57
|
wgc201, wgc202, wgc203, wgc204,
|
|
55
58
|
wgc301, wgc302, wgc303,
|
|
59
|
+
wgc401, wgc402, wgc403,
|
|
56
60
|
];
|
|
57
61
|
},
|
|
58
62
|
|
|
@@ -87,10 +91,9 @@ export const annotations = defaultAnnotations({
|
|
|
87
91
|
export const cluster = new GKECluster({
|
|
88
92
|
location: GCP.Region,
|
|
89
93
|
initialNodeCount: 1,
|
|
90
|
-
removeDefaultNodePool: true,
|
|
91
94
|
releaseChannel: { channel: "REGULAR" },
|
|
92
95
|
workloadIdentityConfig: {
|
|
93
|
-
workloadPool:
|
|
96
|
+
workloadPool: \`\${GCP.ProjectId}.svc.id.goog\`,
|
|
94
97
|
},
|
|
95
98
|
});
|
|
96
99
|
|
|
@@ -111,6 +114,54 @@ export const nodePool = new NodePool({
|
|
|
111
114
|
};
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
if (template === "cloud-function") {
|
|
118
|
+
return {
|
|
119
|
+
src: {
|
|
120
|
+
"infra.ts": `/**
|
|
121
|
+
* Cloud Function with Pub/Sub trigger
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
import { CloudFunction, StorageBucket, GCPServiceAccount, IAMPolicyMember, GCP } from "@intentius/chant-lexicon-gcp";
|
|
125
|
+
import { defaultAnnotations } from "@intentius/chant-lexicon-gcp";
|
|
126
|
+
|
|
127
|
+
export const annotations = defaultAnnotations({
|
|
128
|
+
"cnrm.cloud.google.com/project-id": GCP.ProjectId,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const sourceBucket = new StorageBucket({
|
|
132
|
+
location: GCP.Region,
|
|
133
|
+
storageClass: "STANDARD",
|
|
134
|
+
uniformBucketLevelAccess: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const functionSa = new GCPServiceAccount({
|
|
138
|
+
displayName: "Cloud Function Service Account",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export const fn = new CloudFunction({
|
|
142
|
+
location: GCP.Region,
|
|
143
|
+
runtime: "nodejs22",
|
|
144
|
+
entryPoint: "handler",
|
|
145
|
+
sourceArchiveBucket: sourceBucket,
|
|
146
|
+
sourceArchiveObject: "function-source.zip",
|
|
147
|
+
triggerHttp: true,
|
|
148
|
+
serviceAccountEmail: functionSa,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const invoker = new IAMPolicyMember({
|
|
152
|
+
member: "allUsers",
|
|
153
|
+
role: "roles/cloudfunctions.invoker",
|
|
154
|
+
resourceRef: {
|
|
155
|
+
apiVersion: "cloudfunctions.cnrm.cloud.google.com/v1beta1",
|
|
156
|
+
kind: "CloudFunction",
|
|
157
|
+
name: fn,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
`,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
114
165
|
// Default: StorageBucket + IAMPolicyMember
|
|
115
166
|
return {
|
|
116
167
|
src: {
|
|
@@ -184,6 +235,124 @@ export const bucketReader = new IAMPolicyMember({
|
|
|
184
235
|
return gcpHover(ctx);
|
|
185
236
|
},
|
|
186
237
|
|
|
238
|
+
async describeResources(options: {
|
|
239
|
+
environment: string;
|
|
240
|
+
buildOutput: string;
|
|
241
|
+
entityNames: string[];
|
|
242
|
+
}): Promise<Record<string, ResourceMetadata>> {
|
|
243
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
244
|
+
const rt = getRuntime();
|
|
245
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
246
|
+
|
|
247
|
+
// Convert TypeScript variable names to kebab-case manifest names
|
|
248
|
+
// (mirrors serializer.ts:165 metadata.name assignment)
|
|
249
|
+
function entityToManifestName(name: string): string {
|
|
250
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Detect namespace: prefer namespace from manifests, then kubectl context, fallback "default"
|
|
254
|
+
let namespace = "default";
|
|
255
|
+
try {
|
|
256
|
+
// Check manifests for an explicit namespace
|
|
257
|
+
const nsMatch = options.buildOutput.match(/^\s+namespace:\s*(.+)$/m);
|
|
258
|
+
if (nsMatch) {
|
|
259
|
+
namespace = nsMatch[1].trim();
|
|
260
|
+
} else {
|
|
261
|
+
// Try kubectl current context namespace
|
|
262
|
+
const nsResult = await rt.spawn([
|
|
263
|
+
"kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}",
|
|
264
|
+
]);
|
|
265
|
+
if (nsResult.exitCode === 0 && nsResult.stdout.trim()) {
|
|
266
|
+
namespace = nsResult.stdout.trim();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error(`[gcp] describeResources: namespace detection failed, using "default": ${err instanceof Error ? err.message : String(err)}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Parse build output to extract kind/name pairs
|
|
274
|
+
let manifests: Array<{ kind: string; name: string; apiVersion: string; namespace?: string }> = [];
|
|
275
|
+
try {
|
|
276
|
+
const docs = options.buildOutput.split(/^---$/m).filter((d) => d.trim());
|
|
277
|
+
for (const doc of docs) {
|
|
278
|
+
const kindMatch = doc.match(/^kind:\s*(.+)$/m);
|
|
279
|
+
const nameMatch = doc.match(/^\s+name:\s*(.+)$/m);
|
|
280
|
+
const apiVersionMatch = doc.match(/^apiVersion:\s*(.+)$/m);
|
|
281
|
+
if (kindMatch && nameMatch && apiVersionMatch) {
|
|
282
|
+
const nsMatch = doc.match(/^\s+namespace:\s*(.+)$/m);
|
|
283
|
+
manifests.push({
|
|
284
|
+
kind: kindMatch[1].trim(),
|
|
285
|
+
name: nameMatch[1].trim(),
|
|
286
|
+
apiVersion: apiVersionMatch[1].trim(),
|
|
287
|
+
...(nsMatch && { namespace: nsMatch[1].trim() }),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error(`[gcp] describeResources: failed to parse build output: ${err instanceof Error ? err.message : String(err)}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let resolved = 0;
|
|
296
|
+
|
|
297
|
+
for (const entityName of options.entityNames) {
|
|
298
|
+
const manifestName = entityToManifestName(entityName);
|
|
299
|
+
const manifest = manifests.find((m) => m.name === manifestName);
|
|
300
|
+
if (!manifest) {
|
|
301
|
+
console.error(`[gcp] describeResources: no manifest found for entity "${entityName}" (expected manifest name "${manifestName}")`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const resourceNs = manifest.namespace ?? namespace;
|
|
306
|
+
const resourceType = manifest.kind.toLowerCase();
|
|
307
|
+
const getResult = await rt.spawn([
|
|
308
|
+
"kubectl", "get", resourceType, manifest.name,
|
|
309
|
+
"-n", resourceNs, "-o", "json",
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
if (getResult.exitCode !== 0) {
|
|
313
|
+
console.error(`[gcp] describeResources: kubectl get ${resourceType} ${manifest.name} -n ${resourceNs} failed (exit ${getResult.exitCode}): ${getResult.stderr.trim()}`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const obj = JSON.parse(getResult.stdout) as {
|
|
319
|
+
metadata: { name: string; uid: string; creationTimestamp: string };
|
|
320
|
+
status?: {
|
|
321
|
+
conditions?: Array<{ type: string; status: string }>;
|
|
322
|
+
externalRef?: string;
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
let status = "Unknown";
|
|
327
|
+
if (obj.status?.conditions) {
|
|
328
|
+
const ready = obj.status.conditions.find((c) => c.type === "Ready");
|
|
329
|
+
status = ready?.status === "True" ? "Ready" : "NotReady";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const attributes: Record<string, unknown> = {
|
|
333
|
+
uid: obj.metadata.uid,
|
|
334
|
+
};
|
|
335
|
+
if (obj.status?.externalRef) {
|
|
336
|
+
attributes.externalRef = obj.status.externalRef;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
resources[entityName] = {
|
|
340
|
+
type: `${manifest.apiVersion}/${manifest.kind}`,
|
|
341
|
+
physicalId: obj.status?.externalRef ?? obj.metadata.name,
|
|
342
|
+
status,
|
|
343
|
+
lastUpdated: obj.metadata.creationTimestamp,
|
|
344
|
+
attributes,
|
|
345
|
+
};
|
|
346
|
+
resolved++;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.error(`[gcp] describeResources: failed to parse kubectl output for "${entityName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.error(`[gcp] describeResources: ${resolved}/${options.entityNames.length} resources resolved`);
|
|
353
|
+
return resources;
|
|
354
|
+
},
|
|
355
|
+
|
|
187
356
|
mcpTools(): McpToolContribution[] {
|
|
188
357
|
return [
|
|
189
358
|
{
|
package/src/skills/chant-gcp.md
CHANGED
|
@@ -9,7 +9,7 @@ user-invocable: true
|
|
|
9
9
|
|
|
10
10
|
## How chant and Config Connector relate
|
|
11
11
|
|
|
12
|
-
chant is a **synthesis
|
|
12
|
+
chant is a **synthesis compiler** — it compiles TypeScript source files into Config Connector YAML manifests. `chant build` does not call GCP APIs; synthesis is pure and deterministic. Config Connector resources are Kubernetes objects, so the optional `chant state snapshot` command queries the Kubernetes API to capture deployment metadata (resource status, conditions, observed state) for observability. Your job as an agent is to bridge synthesis and deployment:
|
|
13
13
|
|
|
14
14
|
- Use **chant** for: build, lint, diff (local YAML comparison)
|
|
15
15
|
- Use **kubectl** for: apply, rollback, monitoring, troubleshooting
|
|
@@ -49,6 +49,9 @@ chant lint src/
|
|
|
49
49
|
# Build
|
|
50
50
|
chant build src/ --output manifests.yaml
|
|
51
51
|
|
|
52
|
+
# See what changed since last deploy (compares current build against last snapshot's digest)
|
|
53
|
+
chant state diff staging gcp
|
|
54
|
+
|
|
52
55
|
# Dry run
|
|
53
56
|
kubectl apply -f manifests.yaml --dry-run=server
|
|
54
57
|
|