@intentius/chant-lexicon-gcp 0.0.16 → 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/docs.ts +3 -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,98 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { GcpParser } from "./parser";
|
|
3
|
+
import { GcpGenerator } from "./generator";
|
|
4
|
+
|
|
5
|
+
const parser = new GcpParser();
|
|
6
|
+
|
|
7
|
+
describe("GCP import with inline YAML fixtures", () => {
|
|
8
|
+
test("parses a single StorageBucket manifest", () => {
|
|
9
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
10
|
+
kind: StorageBucket
|
|
11
|
+
metadata:
|
|
12
|
+
name: my-bucket
|
|
13
|
+
spec:
|
|
14
|
+
location: US
|
|
15
|
+
uniformBucketLevelAccess: true
|
|
16
|
+
`;
|
|
17
|
+
const ir = parser.parse(yaml);
|
|
18
|
+
expect(ir.resources).toHaveLength(1);
|
|
19
|
+
expect(ir.resources[0].logicalName).toBe("my-bucket");
|
|
20
|
+
expect(ir.resources[0].type).toContain("Bucket");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parses multi-document YAML into multiple resources", () => {
|
|
24
|
+
const yaml = `apiVersion: compute.cnrm.cloud.google.com/v1beta1
|
|
25
|
+
kind: ComputeNetwork
|
|
26
|
+
metadata:
|
|
27
|
+
name: my-network
|
|
28
|
+
spec:
|
|
29
|
+
autoCreateSubnetworks: false
|
|
30
|
+
---
|
|
31
|
+
apiVersion: compute.cnrm.cloud.google.com/v1beta1
|
|
32
|
+
kind: ComputeSubnetwork
|
|
33
|
+
metadata:
|
|
34
|
+
name: my-subnet
|
|
35
|
+
spec:
|
|
36
|
+
region: us-central1
|
|
37
|
+
ipCidrRange: "10.0.0.0/24"
|
|
38
|
+
networkRef:
|
|
39
|
+
name: my-network
|
|
40
|
+
`;
|
|
41
|
+
const ir = parser.parse(yaml);
|
|
42
|
+
expect(ir.resources).toHaveLength(2);
|
|
43
|
+
expect(ir.resources[0].logicalName).toBe("my-network");
|
|
44
|
+
expect(ir.resources[1].logicalName).toBe("my-subnet");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("skips non-Config-Connector resources", () => {
|
|
48
|
+
const yaml = `apiVersion: apps/v1
|
|
49
|
+
kind: Deployment
|
|
50
|
+
metadata:
|
|
51
|
+
name: my-deploy
|
|
52
|
+
spec:
|
|
53
|
+
replicas: 3
|
|
54
|
+
---
|
|
55
|
+
apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
56
|
+
kind: StorageBucket
|
|
57
|
+
metadata:
|
|
58
|
+
name: my-bucket
|
|
59
|
+
spec:
|
|
60
|
+
location: US
|
|
61
|
+
`;
|
|
62
|
+
const ir = parser.parse(yaml);
|
|
63
|
+
expect(ir.resources).toHaveLength(1);
|
|
64
|
+
expect(ir.resources[0].type).toContain("Bucket");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("preserves spec properties in parsed IR", () => {
|
|
68
|
+
const yaml = `apiVersion: sql.cnrm.cloud.google.com/v1beta1
|
|
69
|
+
kind: SQLInstance
|
|
70
|
+
metadata:
|
|
71
|
+
name: my-db
|
|
72
|
+
spec:
|
|
73
|
+
databaseVersion: POSTGRES_15
|
|
74
|
+
settings:
|
|
75
|
+
tier: db-f1-micro
|
|
76
|
+
backupConfiguration:
|
|
77
|
+
enabled: true
|
|
78
|
+
`;
|
|
79
|
+
const ir = parser.parse(yaml);
|
|
80
|
+
expect(ir.resources).toHaveLength(1);
|
|
81
|
+
expect(ir.resources[0].properties.databaseVersion).toBe("POSTGRES_15");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("round-trips through parser and generator", () => {
|
|
85
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
86
|
+
kind: StorageBucket
|
|
87
|
+
metadata:
|
|
88
|
+
name: my-bucket
|
|
89
|
+
spec:
|
|
90
|
+
location: US
|
|
91
|
+
`;
|
|
92
|
+
const ir = parser.parse(yaml);
|
|
93
|
+
const generator = new GcpGenerator();
|
|
94
|
+
const ts = generator.generate(ir);
|
|
95
|
+
expect(ts).toContain("Bucket");
|
|
96
|
+
expect(ts).toContain("import");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseGcpManifests,
|
|
4
|
+
isConfigConnectorResource,
|
|
5
|
+
getSpec,
|
|
6
|
+
getAnnotations,
|
|
7
|
+
getResourceName,
|
|
8
|
+
findResourceRefs,
|
|
9
|
+
} from "./gcp-helpers";
|
|
10
|
+
|
|
11
|
+
describe("parseGcpManifests", () => {
|
|
12
|
+
test("parses single document", () => {
|
|
13
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
14
|
+
kind: StorageBucket
|
|
15
|
+
metadata:
|
|
16
|
+
name: my-bucket
|
|
17
|
+
spec:
|
|
18
|
+
location: US
|
|
19
|
+
`;
|
|
20
|
+
const manifests = parseGcpManifests(yaml);
|
|
21
|
+
expect(manifests).toHaveLength(1);
|
|
22
|
+
expect(manifests[0].kind).toBe("StorageBucket");
|
|
23
|
+
expect(manifests[0].metadata?.name).toBe("my-bucket");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("parses multi-document YAML", () => {
|
|
27
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
28
|
+
kind: StorageBucket
|
|
29
|
+
metadata:
|
|
30
|
+
name: bucket-a
|
|
31
|
+
spec:
|
|
32
|
+
location: US
|
|
33
|
+
---
|
|
34
|
+
apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
35
|
+
kind: StorageBucket
|
|
36
|
+
metadata:
|
|
37
|
+
name: bucket-b
|
|
38
|
+
spec:
|
|
39
|
+
location: EU
|
|
40
|
+
`;
|
|
41
|
+
const manifests = parseGcpManifests(yaml);
|
|
42
|
+
expect(manifests).toHaveLength(2);
|
|
43
|
+
expect(manifests[0].metadata?.name).toBe("bucket-a");
|
|
44
|
+
expect(manifests[1].metadata?.name).toBe("bucket-b");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("skips empty documents", () => {
|
|
48
|
+
const yaml = `---
|
|
49
|
+
---
|
|
50
|
+
apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
51
|
+
kind: StorageBucket
|
|
52
|
+
metadata:
|
|
53
|
+
name: my-bucket
|
|
54
|
+
spec:
|
|
55
|
+
location: US
|
|
56
|
+
---
|
|
57
|
+
`;
|
|
58
|
+
const manifests = parseGcpManifests(yaml);
|
|
59
|
+
expect(manifests).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns empty array for empty input", () => {
|
|
63
|
+
const manifests = parseGcpManifests("");
|
|
64
|
+
expect(manifests).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("isConfigConnectorResource", () => {
|
|
69
|
+
test("returns true for cnrm resource", () => {
|
|
70
|
+
expect(
|
|
71
|
+
isConfigConnectorResource({
|
|
72
|
+
apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
|
|
73
|
+
kind: "StorageBucket",
|
|
74
|
+
}),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns false for non-cnrm resource", () => {
|
|
79
|
+
expect(
|
|
80
|
+
isConfigConnectorResource({
|
|
81
|
+
apiVersion: "apps/v1",
|
|
82
|
+
kind: "Deployment",
|
|
83
|
+
}),
|
|
84
|
+
).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns false when apiVersion is missing", () => {
|
|
88
|
+
expect(isConfigConnectorResource({ kind: "StorageBucket" })).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("getSpec", () => {
|
|
93
|
+
test("returns spec when present", () => {
|
|
94
|
+
const spec = getSpec({ spec: { location: "US" } });
|
|
95
|
+
expect(spec).toEqual({ location: "US" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns undefined when spec is missing", () => {
|
|
99
|
+
expect(getSpec({})).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("getAnnotations", () => {
|
|
104
|
+
test("returns annotations when present", () => {
|
|
105
|
+
const annotations = getAnnotations({
|
|
106
|
+
metadata: {
|
|
107
|
+
name: "x",
|
|
108
|
+
annotations: { "cnrm.cloud.google.com/project-id": "my-project" },
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(annotations).toEqual({
|
|
112
|
+
"cnrm.cloud.google.com/project-id": "my-project",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("returns undefined when annotations missing", () => {
|
|
117
|
+
expect(getAnnotations({ metadata: { name: "x" } })).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns undefined when metadata missing", () => {
|
|
121
|
+
expect(getAnnotations({})).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("getResourceName", () => {
|
|
126
|
+
test("returns name from metadata", () => {
|
|
127
|
+
expect(getResourceName({ metadata: { name: "my-bucket" } })).toBe(
|
|
128
|
+
"my-bucket",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns 'unknown' when name missing", () => {
|
|
133
|
+
expect(getResourceName({})).toBe("unknown");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("findResourceRefs", () => {
|
|
138
|
+
test("finds refs in spec", () => {
|
|
139
|
+
const refs = findResourceRefs({
|
|
140
|
+
clusterRef: { name: "my-cluster" },
|
|
141
|
+
networkRef: { name: "my-network" },
|
|
142
|
+
});
|
|
143
|
+
expect(refs.has("my-cluster")).toBe(true);
|
|
144
|
+
expect(refs.has("my-network")).toBe(true);
|
|
145
|
+
expect(refs.size).toBe(2);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("skips external refs", () => {
|
|
149
|
+
const refs = findResourceRefs({
|
|
150
|
+
projectRef: { name: "my-project", external: true },
|
|
151
|
+
});
|
|
152
|
+
expect(refs.size).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("finds refs nested in arrays", () => {
|
|
156
|
+
const refs = findResourceRefs({
|
|
157
|
+
items: [{ subnetRef: { name: "subnet-1" } }],
|
|
158
|
+
});
|
|
159
|
+
expect(refs.has("subnet-1")).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("returns empty set for null/undefined", () => {
|
|
163
|
+
expect(findResourceRefs(null).size).toBe(0);
|
|
164
|
+
expect(findResourceRefs(undefined).size).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -19,6 +19,9 @@ import { wgc303 } from "./wgc303";
|
|
|
19
19
|
import { wgc111 } from "./wgc111";
|
|
20
20
|
import { wgc112 } from "./wgc112";
|
|
21
21
|
import { wgc113 } from "./wgc113";
|
|
22
|
+
import { wgc401 } from "./wgc401";
|
|
23
|
+
import { wgc402 } from "./wgc402";
|
|
24
|
+
import { wgc403 } from "./wgc403";
|
|
22
25
|
|
|
23
26
|
function makeCtx(yaml: string) {
|
|
24
27
|
return {
|
|
@@ -709,3 +712,130 @@ spec:
|
|
|
709
712
|
expect(diags).toHaveLength(0);
|
|
710
713
|
});
|
|
711
714
|
});
|
|
715
|
+
|
|
716
|
+
// ── WGC401: Unknown spec field ─────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
describe("WGC401: unknown spec field", () => {
|
|
719
|
+
test("flags unknown field with did-you-mean suggestion", () => {
|
|
720
|
+
const yaml = `apiVersion: compute.cnrm.cloud.google.com/v1beta1
|
|
721
|
+
kind: ComputeFirewall
|
|
722
|
+
metadata:
|
|
723
|
+
name: my-fw
|
|
724
|
+
spec:
|
|
725
|
+
allowed:
|
|
726
|
+
- protocol: tcp
|
|
727
|
+
networkRef:
|
|
728
|
+
name: my-network
|
|
729
|
+
`;
|
|
730
|
+
const diags = wgc401.check(makeCtx(yaml));
|
|
731
|
+
// "allowed" is not a valid field — "allow" is. Should flag it.
|
|
732
|
+
const unknownDiags = diags.filter(d => d.checkId === "WGC401");
|
|
733
|
+
// If schema is loaded, this will flag "allowed"
|
|
734
|
+
// If schema is not loaded (pre-generate), skip gracefully
|
|
735
|
+
if (unknownDiags.length > 0) {
|
|
736
|
+
expect(unknownDiags[0].severity).toBe("error");
|
|
737
|
+
expect(unknownDiags[0].message).toContain("allowed");
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("no diagnostic for valid fields", () => {
|
|
742
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
743
|
+
kind: StorageBucket
|
|
744
|
+
metadata:
|
|
745
|
+
name: my-bucket
|
|
746
|
+
spec:
|
|
747
|
+
location: US
|
|
748
|
+
`;
|
|
749
|
+
const diags = wgc401.check(makeCtx(yaml));
|
|
750
|
+
// "location" is a valid StorageBucket field
|
|
751
|
+
const unknownDiags = diags.filter(d => d.checkId === "WGC401");
|
|
752
|
+
expect(unknownDiags).toHaveLength(0);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// ── WGC402: Missing required field ─────────────────────────────────
|
|
757
|
+
|
|
758
|
+
describe("WGC402: missing required field", () => {
|
|
759
|
+
test("flags missing required field", () => {
|
|
760
|
+
const yaml = `apiVersion: compute.cnrm.cloud.google.com/v1beta1
|
|
761
|
+
kind: ComputeAddress
|
|
762
|
+
metadata:
|
|
763
|
+
name: my-addr
|
|
764
|
+
spec:
|
|
765
|
+
description: test
|
|
766
|
+
`;
|
|
767
|
+
const diags = wgc402.check(makeCtx(yaml));
|
|
768
|
+
const requiredDiags = diags.filter(d => d.checkId === "WGC402");
|
|
769
|
+
// ComputeAddress requires "location" — if schema is loaded, this flags it
|
|
770
|
+
if (requiredDiags.length > 0) {
|
|
771
|
+
expect(requiredDiags[0].severity).toBe("error");
|
|
772
|
+
expect(requiredDiags[0].message).toContain("required");
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("no diagnostic when all required fields present", () => {
|
|
777
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
778
|
+
kind: StorageBucket
|
|
779
|
+
metadata:
|
|
780
|
+
name: my-bucket
|
|
781
|
+
spec:
|
|
782
|
+
location: US
|
|
783
|
+
`;
|
|
784
|
+
const diags = wgc402.check(makeCtx(yaml));
|
|
785
|
+
const requiredDiags = diags.filter(d => d.checkId === "WGC402");
|
|
786
|
+
expect(requiredDiags).toHaveLength(0);
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ── WGC403: Type/structure mismatch ────────────────────────────────
|
|
791
|
+
|
|
792
|
+
describe("WGC403: type/structure mismatch", () => {
|
|
793
|
+
test("flags string where number expected", () => {
|
|
794
|
+
const yaml = `apiVersion: cloudfunctions.cnrm.cloud.google.com/v1beta1
|
|
795
|
+
kind: CloudFunctionsFunction
|
|
796
|
+
metadata:
|
|
797
|
+
name: my-fn
|
|
798
|
+
spec:
|
|
799
|
+
runtime: nodejs18
|
|
800
|
+
availableMemoryMb: "512"
|
|
801
|
+
region: us-central1
|
|
802
|
+
`;
|
|
803
|
+
const diags = wgc403.check(makeCtx(yaml));
|
|
804
|
+
const typeDiags = diags.filter(d => d.checkId === "WGC403");
|
|
805
|
+
if (typeDiags.length > 0) {
|
|
806
|
+
expect(typeDiags[0].severity).toBe("error");
|
|
807
|
+
expect(typeDiags[0].message).toContain("number");
|
|
808
|
+
expect(typeDiags[0].message).toContain("string");
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("flags bare string instead of resourceRef object", () => {
|
|
813
|
+
const yaml = `apiVersion: pubsub.cnrm.cloud.google.com/v1beta1
|
|
814
|
+
kind: PubSubSubscription
|
|
815
|
+
metadata:
|
|
816
|
+
name: my-sub
|
|
817
|
+
spec:
|
|
818
|
+
topicRef: my-topic
|
|
819
|
+
`;
|
|
820
|
+
const diags = wgc403.check(makeCtx(yaml));
|
|
821
|
+
const typeDiags = diags.filter(d => d.checkId === "WGC403");
|
|
822
|
+
if (typeDiags.length > 0) {
|
|
823
|
+
expect(typeDiags[0].severity).toBe("error");
|
|
824
|
+
expect(typeDiags[0].message).toContain("resourceRef");
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("no diagnostic with correct types", () => {
|
|
829
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
830
|
+
kind: StorageBucket
|
|
831
|
+
metadata:
|
|
832
|
+
name: my-bucket
|
|
833
|
+
spec:
|
|
834
|
+
location: US
|
|
835
|
+
uniformBucketLevelAccess: true
|
|
836
|
+
`;
|
|
837
|
+
const diags = wgc403.check(makeCtx(yaml));
|
|
838
|
+
const typeDiags = diags.filter(d => d.checkId === "WGC403");
|
|
839
|
+
expect(typeDiags).toHaveLength(0);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema registry for GCP Config Connector resources.
|
|
3
|
+
*
|
|
4
|
+
* Loads lexicon-gcp.json and builds a lookup from CRD kind to field schema,
|
|
5
|
+
* used by post-synth rules to validate YAML against known CRD schemas.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createRequire } from "module";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
export interface FieldSchema {
|
|
12
|
+
type: string;
|
|
13
|
+
required: boolean;
|
|
14
|
+
enum?: string[];
|
|
15
|
+
ref?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResourceSchema {
|
|
19
|
+
fields: Record<string, FieldSchema>;
|
|
20
|
+
required: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LexiconEntry {
|
|
24
|
+
kind: "resource" | "property";
|
|
25
|
+
gvkKind?: string;
|
|
26
|
+
schema?: ResourceSchema;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let cachedRegistry: Map<string, ResourceSchema> | null = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the schema registry: Map<gvkKind, ResourceSchema>.
|
|
33
|
+
*/
|
|
34
|
+
export function getSchemaRegistry(): Map<string, ResourceSchema> {
|
|
35
|
+
if (cachedRegistry) return cachedRegistry;
|
|
36
|
+
|
|
37
|
+
cachedRegistry = new Map();
|
|
38
|
+
try {
|
|
39
|
+
const lexicon = require("../../generated/lexicon-gcp.json") as Record<string, LexiconEntry>;
|
|
40
|
+
for (const entry of Object.values(lexicon)) {
|
|
41
|
+
if (entry.kind === "resource" && entry.gvkKind && entry.schema) {
|
|
42
|
+
cachedRegistry.set(entry.gvkKind, entry.schema);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Lexicon JSON not yet generated — return empty registry
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cachedRegistry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Levenshtein distance between two strings.
|
|
54
|
+
*/
|
|
55
|
+
export function levenshtein(a: string, b: string): number {
|
|
56
|
+
const m = a.length;
|
|
57
|
+
const n = b.length;
|
|
58
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
61
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
62
|
+
|
|
63
|
+
for (let i = 1; i <= m; i++) {
|
|
64
|
+
for (let j = 1; j <= n; j++) {
|
|
65
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
66
|
+
? dp[i - 1][j - 1]
|
|
67
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return dp[m][n];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find the closest field name by Levenshtein distance.
|
|
76
|
+
* Returns the suggestion if distance ≤ 3, otherwise undefined.
|
|
77
|
+
*/
|
|
78
|
+
export function suggestField(unknown: string, knownFields: string[]): string | undefined {
|
|
79
|
+
let best: string | undefined;
|
|
80
|
+
let bestDist = 4; // threshold
|
|
81
|
+
|
|
82
|
+
for (const field of knownFields) {
|
|
83
|
+
const dist = levenshtein(unknown.toLowerCase(), field.toLowerCase());
|
|
84
|
+
if (dist < bestDist) {
|
|
85
|
+
bestDist = dist;
|
|
86
|
+
best = field;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return best;
|
|
91
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc101 } from "./wgc101";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC101: missing encryption", () => {
|
|
11
|
+
test("flags StorageBucket without encryption", () => {
|
|
12
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: StorageBucket
|
|
14
|
+
metadata:
|
|
15
|
+
name: my-bucket
|
|
16
|
+
spec:
|
|
17
|
+
location: US
|
|
18
|
+
`;
|
|
19
|
+
const diags = wgc101.check(makeCtx(yaml));
|
|
20
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
21
|
+
expect(diags[0].checkId).toBe("WGC101");
|
|
22
|
+
expect(diags[0].severity).toBe("warning");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("no diagnostic when encryption present", () => {
|
|
26
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
27
|
+
kind: StorageBucket
|
|
28
|
+
metadata:
|
|
29
|
+
name: my-bucket
|
|
30
|
+
spec:
|
|
31
|
+
location: US
|
|
32
|
+
encryption:
|
|
33
|
+
defaultKmsKeyName: projects/p/locations/l/keyRings/kr/cryptoKeys/k
|
|
34
|
+
`;
|
|
35
|
+
const diags = wgc101.check(makeCtx(yaml));
|
|
36
|
+
const bucketDiags = diags.filter(d => d.entity === "my-bucket");
|
|
37
|
+
expect(bucketDiags).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc102 } from "./wgc102";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC102: public IAM in output", () => {
|
|
11
|
+
test("flags allUsers in output", () => {
|
|
12
|
+
const yaml = `apiVersion: iam.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: IAMPolicyMember
|
|
14
|
+
metadata:
|
|
15
|
+
name: public-access
|
|
16
|
+
spec:
|
|
17
|
+
member: allUsers
|
|
18
|
+
role: roles/run.invoker
|
|
19
|
+
`;
|
|
20
|
+
const diags = wgc102.check(makeCtx(yaml));
|
|
21
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
22
|
+
expect(diags[0].checkId).toBe("WGC102");
|
|
23
|
+
expect(diags[0].severity).toBe("warning");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("no diagnostic when no public members", () => {
|
|
27
|
+
const yaml = `apiVersion: iam.cnrm.cloud.google.com/v1beta1
|
|
28
|
+
kind: IAMPolicyMember
|
|
29
|
+
metadata:
|
|
30
|
+
name: private-access
|
|
31
|
+
spec:
|
|
32
|
+
member: serviceAccount:sa@project.iam.gserviceaccount.com
|
|
33
|
+
role: roles/viewer
|
|
34
|
+
`;
|
|
35
|
+
const diags = wgc102.check(makeCtx(yaml));
|
|
36
|
+
expect(diags).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc103 } from "./wgc103";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC103: missing project annotation", () => {
|
|
11
|
+
test("flags resource without project annotation", () => {
|
|
12
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: StorageBucket
|
|
14
|
+
metadata:
|
|
15
|
+
name: my-bucket
|
|
16
|
+
spec:
|
|
17
|
+
location: US
|
|
18
|
+
`;
|
|
19
|
+
const diags = wgc103.check(makeCtx(yaml));
|
|
20
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
21
|
+
expect(diags[0].checkId).toBe("WGC103");
|
|
22
|
+
expect(diags[0].severity).toBe("info");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("no diagnostic when project annotation present", () => {
|
|
26
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
27
|
+
kind: StorageBucket
|
|
28
|
+
metadata:
|
|
29
|
+
name: my-bucket
|
|
30
|
+
annotations:
|
|
31
|
+
cnrm.cloud.google.com/project-id: my-project
|
|
32
|
+
spec:
|
|
33
|
+
location: US
|
|
34
|
+
`;
|
|
35
|
+
const diags = wgc103.check(makeCtx(yaml));
|
|
36
|
+
expect(diags).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc104 } from "./wgc104";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC104: missing uniform bucket access", () => {
|
|
11
|
+
test("flags StorageBucket without uniformBucketLevelAccess", () => {
|
|
12
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: StorageBucket
|
|
14
|
+
metadata:
|
|
15
|
+
name: my-bucket
|
|
16
|
+
spec:
|
|
17
|
+
location: US
|
|
18
|
+
`;
|
|
19
|
+
const diags = wgc104.check(makeCtx(yaml));
|
|
20
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
21
|
+
expect(diags[0].checkId).toBe("WGC104");
|
|
22
|
+
expect(diags[0].severity).toBe("warning");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("no diagnostic when uniformBucketLevelAccess is true", () => {
|
|
26
|
+
const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
|
|
27
|
+
kind: StorageBucket
|
|
28
|
+
metadata:
|
|
29
|
+
name: my-bucket
|
|
30
|
+
spec:
|
|
31
|
+
location: US
|
|
32
|
+
uniformBucketLevelAccess: true
|
|
33
|
+
`;
|
|
34
|
+
const diags = wgc104.check(makeCtx(yaml));
|
|
35
|
+
expect(diags).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgc105 } from "./wgc105";
|
|
3
|
+
|
|
4
|
+
function makeCtx(yaml: string) {
|
|
5
|
+
return {
|
|
6
|
+
outputs: new Map([["gcp", yaml]]),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("WGC105: public Cloud SQL", () => {
|
|
11
|
+
test("flags SQLInstance with 0.0.0.0/0 in authorizedNetworks", () => {
|
|
12
|
+
const yaml = `apiVersion: sql.cnrm.cloud.google.com/v1beta1
|
|
13
|
+
kind: SQLInstance
|
|
14
|
+
metadata:
|
|
15
|
+
name: my-db
|
|
16
|
+
spec:
|
|
17
|
+
databaseVersion: POSTGRES_15
|
|
18
|
+
settings:
|
|
19
|
+
ipConfiguration:
|
|
20
|
+
authorizedNetworks:
|
|
21
|
+
- value: "0.0.0.0/0"
|
|
22
|
+
name: public
|
|
23
|
+
`;
|
|
24
|
+
const diags = wgc105.check(makeCtx(yaml));
|
|
25
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
26
|
+
expect(diags[0].checkId).toBe("WGC105");
|
|
27
|
+
expect(diags[0].severity).toBe("warning");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("no diagnostic with private networks only", () => {
|
|
31
|
+
const yaml = `apiVersion: sql.cnrm.cloud.google.com/v1beta1
|
|
32
|
+
kind: SQLInstance
|
|
33
|
+
metadata:
|
|
34
|
+
name: my-db
|
|
35
|
+
spec:
|
|
36
|
+
databaseVersion: POSTGRES_15
|
|
37
|
+
settings:
|
|
38
|
+
ipConfiguration:
|
|
39
|
+
authorizedNetworks:
|
|
40
|
+
- value: "10.0.0.0/8"
|
|
41
|
+
name: internal
|
|
42
|
+
`;
|
|
43
|
+
const diags = wgc105.check(makeCtx(yaml));
|
|
44
|
+
expect(diags).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
});
|