@intentius/chant-lexicon-gcp 0.27.0 → 0.28.0

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.
@@ -10,6 +10,13 @@
10
10
  * GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
11
11
  *
12
12
  * Resource-not-found is silent — `state diff --live` reports it as missing.
13
+ *
14
+ * Since this reads Config Connector CRDs through the same kubectl path as
15
+ * the K8s lexicon, it resolves the same environment→cluster binding (chant
16
+ * #1100) via `resolveClusterTarget` — `k8s.profiles.<env>.context` in
17
+ * `chant.config.ts` (see `lexicons/k8s/src/config.ts`), not a separate
18
+ * `gcp.profiles` key, because it is fundamentally the same kubectl context a
19
+ * project's K8s entities would use against the same cluster.
13
20
  */
14
21
  import type { ResourceMetadata } from "@intentius/chant/lexicon";
15
22
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAoBjE;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CASpF;AA4BD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAwC5C"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAsBjE;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CASpF;AA4BD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAgD5C"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "0fcdcb7ff056aa43420690a59a0927d5cfb50ab6de1b3399a399dbb529ea8782",
4
+ "manifest.json": "e3947f44bb23120c8a83b0ab90621adf262b9c578b763f025673669f11112b2a",
5
5
  "meta.json": "475f10909854d059a2e2d4fb541c058803422e38b9c64bd4d6858c0623eedb8c",
6
6
  "types/index.d.ts": "5433b54c04b57d6fda8f3d81ef92ed0aca87f7e8e5dd3ea92d8de6e62a3ccfab",
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": "3ae099bad7752191fc7ffaec95374370d69de052349b8888efeafc758aeca632"
40
+ "composite": "7387aef29444d3c55659eaab15b350dda09923008bc2b95b2131b4bc956b7772"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GCP",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
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",
@@ -76,7 +76,7 @@
76
76
  "typescript": "^5.9.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@intentius/chant": "^0.27.0",
79
+ "@intentius/chant": "^0.28.0",
80
80
  "typescript": "^5.9.3"
81
81
  }
82
82
  }
@@ -11,6 +11,11 @@ vi.mock("node:child_process", async () => {
11
11
  } };
12
12
  });
13
13
 
14
+ const loadChantConfigMock = vi.fn();
15
+ vi.mock("@intentius/chant/config", () => ({
16
+ loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
17
+ }));
18
+
14
19
  const { describeResources } = await import("./describe-resources");
15
20
 
16
21
  function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
@@ -20,6 +25,10 @@ function makeEntities(records: Array<{ name: string; entityType: string; props:
20
25
  describe("gcp describeResources (Config Connector)", () => {
21
26
  beforeEach(() => {
22
27
  execMock.mockReset();
28
+ loadChantConfigMock.mockReset();
29
+ // No binding declared by default — matches every test below except the
30
+ // dedicated cluster-binding tests, which override this per case.
31
+ loadChantConfigMock.mockResolvedValue({ config: {} });
23
32
  });
24
33
 
25
34
  test("queries kubectl with the derived CC GVK and maps the response", async () => {
@@ -144,4 +153,71 @@ describe("gcp describeResources (Config Connector)", () => {
144
153
  expect(result).toEqual({});
145
154
  expect(execMock).not.toHaveBeenCalled();
146
155
  });
156
+
157
+ // chant #1100 — GCP-via-CNRM resolves the same environment→cluster binding
158
+ // as the K8s lexicon (bound-and-matching, bound-and-mismatched loud
159
+ // refusal, unbound unchanged), since it observes through the same kubectl
160
+ // path against the same cluster.
161
+ describe("cluster binding (chant #1100)", () => {
162
+ function bucketEntities() {
163
+ return makeEntities([
164
+ { name: "dataBucket", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket" } } },
165
+ ]);
166
+ }
167
+
168
+ const bucketStdout = JSON.stringify({
169
+ metadata: { name: "data-bucket", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z" },
170
+ status: { conditions: [{ type: "Ready", status: "True" }] },
171
+ });
172
+
173
+ test("bound and ambient context matches: observes explicitly via --context", async () => {
174
+ loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
175
+ let receivedCmd = "";
176
+ execMock.mockImplementation((cmd: string) => {
177
+ if (cmd.includes("current-context")) return { stdout: "prod-cnrm\n", stderr: "" };
178
+ receivedCmd = cmd;
179
+ return { stdout: bucketStdout, stderr: "" };
180
+ });
181
+
182
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
183
+
184
+ expect(receivedCmd).toContain("--context prod-cnrm");
185
+ expect(result["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
186
+ });
187
+
188
+ test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
189
+ loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
190
+ execMock.mockImplementation((cmd: string) => {
191
+ if (cmd.includes("current-context")) return { stdout: "staging-cnrm\n", stderr: "" };
192
+ throw new Error(`unexpected cmd (should have refused before any kubectl get): ${cmd}`);
193
+ });
194
+
195
+ await expect(
196
+ describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() }),
197
+ ).rejects.toThrow(/environment "prod".*"prod-cnrm".*"staging-cnrm"/s);
198
+
199
+ expect(execMock).toHaveBeenCalledTimes(1);
200
+ });
201
+
202
+ test("unbound: ambient context is used unchanged, but the fallback is visible (not silent)", async () => {
203
+ loadChantConfigMock.mockResolvedValue({ config: {} });
204
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
205
+ let receivedCmd = "";
206
+ execMock.mockImplementation((cmd: string) => {
207
+ receivedCmd = cmd;
208
+ return { stdout: bucketStdout, stderr: "" };
209
+ });
210
+
211
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
212
+
213
+ expect(receivedCmd).not.toContain("--context");
214
+ expect(receivedCmd).not.toContain("current-context");
215
+ expect(result["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
216
+
217
+ const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
218
+ expect(bindingWarning?.[0]).toContain('environment "prod"');
219
+ expect(bindingWarning?.[0]).toContain("k8s.profiles.prod.context");
220
+ warnSpy.mockRestore();
221
+ });
222
+ });
147
223
  });
@@ -10,12 +10,21 @@
10
10
  * GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
11
11
  *
12
12
  * Resource-not-found is silent — `state diff --live` reports it as missing.
13
+ *
14
+ * Since this reads Config Connector CRDs through the same kubectl path as
15
+ * the K8s lexicon, it resolves the same environment→cluster binding (chant
16
+ * #1100) via `resolveClusterTarget` — `k8s.profiles.<env>.context` in
17
+ * `chant.config.ts` (see `lexicons/k8s/src/config.ts`), not a separate
18
+ * `gcp.profiles` key, because it is fundamentally the same kubectl context a
19
+ * project's K8s entities would use against the same cluster.
13
20
  */
14
21
 
15
22
  import { exec } from "node:child_process";
16
23
  import { promisify } from "node:util";
17
24
  import type { ResourceMetadata } from "@intentius/chant/lexicon";
18
25
  import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
26
+ import { loadChantConfig } from "@intentius/chant/config";
27
+ import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
19
28
 
20
29
  const execAsync = promisify(exec);
21
30
 
@@ -85,6 +94,14 @@ export async function describeResources(options: {
85
94
  }): Promise<Record<string, ResourceMetadata>> {
86
95
  const result: Record<string, ResourceMetadata> = {};
87
96
 
97
+ // Resolve the cluster identity for this environment before touching any
98
+ // resource — a declared-but-mismatched binding throws here, aborting the
99
+ // whole describe rather than letting the per-entity try/catch below
100
+ // absorb it as an ordinary "not found".
101
+ const { config } = await loadChantConfig(process.cwd());
102
+ const target = await resolveClusterTarget(config as Record<string, unknown>, options.environment, "gcp");
103
+ const ctxArg = target.context ? ["--context", target.context] : [];
104
+
88
105
  for (const [entityName, { entityType, props }] of options.entities) {
89
106
  const gvk = deriveGVK(entityType);
90
107
  if (!gvk) continue;
@@ -95,7 +112,7 @@ export async function describeResources(options: {
95
112
 
96
113
  const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
97
114
  const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
98
- const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, "-o", "json"].join(" ");
115
+ const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
99
116
 
100
117
  try {
101
118
  const { stdout } = await execAsync(cmd);