@intentius/chant-lexicon-gcp 0.28.0 → 0.30.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.
@@ -9,7 +9,10 @@
9
9
  * GCP::Storage::Bucket → kubectl get storagebucket.storage.cnrm.cloud.google.com
10
10
  * GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
11
11
  *
12
- * Resource-not-found is silent — `state diff --live` reports it as missing.
12
+ * Resource-not-found is an absence — `state diff --live` reports it as missing.
13
+ * Everything else the kubectl call can fail with (auth, an unreachable API
14
+ * server, an entity type with no derivable GVK) is NOT-OBSERVED (#1089), so a
15
+ * read that never happened cannot become a proposed `create`.
13
16
  *
14
17
  * Since this reads Config Connector CRDs through the same kubectl path as
15
18
  * the K8s lexicon, it resolves the same environment→cluster binding (chant
@@ -18,7 +21,7 @@
18
21
  * `gcp.profiles` key, because it is fundamentally the same kubectl context a
19
22
  * project's K8s entities would use against the same cluster.
20
23
  */
21
- import type { ResourceMetadata } from "@intentius/chant/lexicon";
24
+ import type { ObservationResult } from "@intentius/chant/lexicon";
22
25
  /**
23
26
  * Mirror of `lexicons/gcp/src/serializer.ts:deriveGVKFromType` — keeping the
24
27
  * derivation logic local so describeResources can compute the kubectl resource
@@ -37,5 +40,5 @@ export declare function describeResources(options: {
37
40
  props: Record<string, unknown>;
38
41
  }>;
39
42
  owned?: boolean;
40
- }): Promise<Record<string, ResourceMetadata>>;
43
+ }): Promise<ObservationResult>;
41
44
  //# sourceMappingURL=describe-resources.d.ts.map
@@ -1 +1 @@
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
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,MAAM,0BAA0B,CAAC;AAuBtG;;;;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,iBAAiB,CAAC,CA8E7B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "e3947f44bb23120c8a83b0ab90621adf262b9c578b763f025673669f11112b2a",
4
+ "manifest.json": "934b2e465f4a0be499267e8b23f84e1df1353c29eb599046ccb50bc20b3c2bd8",
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": "7387aef29444d3c55659eaab15b350dda09923008bc2b95b2131b4bc956b7772"
40
+ "composite": "9b0f2abc33da4def1bad8cc078ec2d56039630e59fe397152d17d1a317f80b30"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp",
3
- "version": "0.28.0",
3
+ "version": "0.30.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.28.0",
3
+ "version": "0.30.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.28.0",
79
+ "@intentius/chant": "^0.30.0",
80
80
  "typescript": "^5.9.3"
81
81
  }
82
82
  }
@@ -55,7 +55,7 @@ describe("gcp describeResources (Config Connector)", () => {
55
55
  expect(receivedCmd).toContain("data-bucket");
56
56
  expect(receivedCmd).toContain("-n config-control");
57
57
 
58
- expect(result["dataBucket"]).toMatchObject({
58
+ expect(result.resources["dataBucket"]).toMatchObject({
59
59
  type: "GCP::Storage::Bucket",
60
60
  physicalId: "uid-1",
61
61
  status: "READY",
@@ -99,7 +99,7 @@ describe("gcp describeResources (Config Connector)", () => {
99
99
 
100
100
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
101
101
 
102
- expect(result["x"].status).toBe("DependencyNotFound");
102
+ expect(result.resources["x"].status).toBe("DependencyNotFound");
103
103
  });
104
104
 
105
105
  test("missing Ready condition falls back to PRESENT", async () => {
@@ -117,10 +117,10 @@ describe("gcp describeResources (Config Connector)", () => {
117
117
 
118
118
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
119
119
 
120
- expect(result["x"].status).toBe("PRESENT");
120
+ expect(result.resources["x"].status).toBe("PRESENT");
121
121
  });
122
122
 
123
- test("kubectl-not-found leaves entity out of result", async () => {
123
+ test("kubectl-not-found leaves entity out of result (a confirmed absence)", async () => {
124
124
  execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): storagebucket "x" not found'); });
125
125
 
126
126
  const entities = makeEntities([
@@ -129,28 +129,48 @@ describe("gcp describeResources (Config Connector)", () => {
129
129
 
130
130
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
131
131
 
132
- expect(result).toEqual({});
132
+ expect(result.resources).toEqual({});
133
+ expect(result.unobserved ?? {}).toEqual({});
133
134
  });
134
135
 
135
- test("non-GCP entity types are skipped", async () => {
136
+ test("an unreachable cluster is unobserved, not absent (#1089)", async () => {
137
+ execMock.mockImplementation(() => {
138
+ throw Object.assign(new Error("kubectl failed"), {
139
+ stderr: "Unable to connect to the server: dial tcp: i/o timeout",
140
+ });
141
+ });
142
+
143
+ const entities = makeEntities([
144
+ { name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
145
+ ]);
146
+
147
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
148
+
149
+ expect(result.resources).toEqual({});
150
+ expect(result.unobserved?.x?.reason).toBe("no-binding");
151
+ });
152
+
153
+ test("non-GCP entity types are unobserved — no GVK to query (#1089)", async () => {
136
154
  const entities = makeEntities([
137
155
  { name: "x", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
138
156
  ]);
139
157
 
140
158
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
141
159
 
142
- expect(result).toEqual({});
160
+ expect(result.resources).toEqual({});
161
+ expect(result.unobserved?.x?.reason).toBe("unsupported-kind");
143
162
  expect(execMock).not.toHaveBeenCalled();
144
163
  });
145
164
 
146
- test("entity without metadata.name is silently skipped", async () => {
165
+ test("entity without metadata.name is unobserved — nothing was queried", async () => {
147
166
  const entities = makeEntities([
148
167
  { name: "broken", entityType: "GCP::Storage::Bucket", props: {} },
149
168
  ]);
150
169
 
151
170
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
152
171
 
153
- expect(result).toEqual({});
172
+ expect(result.resources).toEqual({});
173
+ expect(result.unobserved?.broken?.reason).toBe("read-failed");
154
174
  expect(execMock).not.toHaveBeenCalled();
155
175
  });
156
176
 
@@ -182,7 +202,7 @@ describe("gcp describeResources (Config Connector)", () => {
182
202
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
183
203
 
184
204
  expect(receivedCmd).toContain("--context prod-cnrm");
185
- expect(result["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
205
+ expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
186
206
  });
187
207
 
188
208
  test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
@@ -212,7 +232,7 @@ describe("gcp describeResources (Config Connector)", () => {
212
232
 
213
233
  expect(receivedCmd).not.toContain("--context");
214
234
  expect(receivedCmd).not.toContain("current-context");
215
- expect(result["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
235
+ expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
216
236
 
217
237
  const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
218
238
  expect(bindingWarning?.[0]).toContain('environment "prod"');
@@ -9,7 +9,10 @@
9
9
  * GCP::Storage::Bucket → kubectl get storagebucket.storage.cnrm.cloud.google.com
10
10
  * GCP::Compute::Subnetwork → kubectl get computesubnetwork.compute.cnrm.cloud.google.com
11
11
  *
12
- * Resource-not-found is silent — `state diff --live` reports it as missing.
12
+ * Resource-not-found is an absence — `state diff --live` reports it as missing.
13
+ * Everything else the kubectl call can fail with (auth, an unreachable API
14
+ * server, an entity type with no derivable GVK) is NOT-OBSERVED (#1089), so a
15
+ * read that never happened cannot become a proposed `create`.
13
16
  *
14
17
  * Since this reads Config Connector CRDs through the same kubectl path as
15
18
  * the K8s lexicon, it resolves the same environment→cluster binding (chant
@@ -21,10 +24,11 @@
21
24
 
22
25
  import { exec } from "node:child_process";
23
26
  import { promisify } from "node:util";
24
- import type { ResourceMetadata } from "@intentius/chant/lexicon";
27
+ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
28
+ import { observation } from "@intentius/chant/observation";
25
29
  import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
26
30
  import { loadChantConfig } from "@intentius/chant/config";
27
- import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
31
+ import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
28
32
 
29
33
  const execAsync = promisify(exec);
30
34
 
@@ -91,8 +95,9 @@ export async function describeResources(options: {
91
95
  entityNames: string[];
92
96
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
93
97
  owned?: boolean;
94
- }): Promise<Record<string, ResourceMetadata>> {
98
+ }): Promise<ObservationResult> {
95
99
  const result: Record<string, ResourceMetadata> = {};
100
+ const unobserved: Record<string, UnobservedEntity> = {};
96
101
 
97
102
  // Resolve the cluster identity for this environment before touching any
98
103
  // resource — a declared-but-mismatched binding throws here, aborting the
@@ -104,11 +109,27 @@ export async function describeResources(options: {
104
109
 
105
110
  for (const [entityName, { entityType, props }] of options.entities) {
106
111
  const gvk = deriveGVK(entityType);
107
- if (!gvk) continue;
112
+ if (!gvk) {
113
+ // Not a `GCP::Service::Kind` this lexicon can turn into a Config
114
+ // Connector GVK — nothing was queried, so nothing is known (#1089).
115
+ unobserved[entityName] = {
116
+ type: entityType,
117
+ reason: "unsupported-kind",
118
+ detail: `cannot derive a Config Connector GVK from ${entityType}`,
119
+ };
120
+ continue;
121
+ }
108
122
 
109
123
  const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
110
124
  const name = metadata?.name;
111
- if (!name) continue;
125
+ if (!name) {
126
+ unobserved[entityName] = {
127
+ type: entityType,
128
+ reason: "read-failed",
129
+ detail: "declared entity has no metadata.name to query by",
130
+ };
131
+ continue;
132
+ }
112
133
 
113
134
  const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
114
135
  const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
@@ -117,8 +138,14 @@ export async function describeResources(options: {
117
138
  try {
118
139
  const { stdout } = await execAsync(cmd);
119
140
  const obj: KubectlResponse = JSON.parse(stdout);
120
- // owned filter: skip resources not carrying chant's marker label.
141
+ // owned filter: withhold resources not carrying chant's marker label.
142
+ // Withheld is not absent (#1089) — the CR exists, it just isn't chant's.
121
143
  if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
144
+ unobserved[entityName] = {
145
+ type: entityType,
146
+ reason: "filtered",
147
+ detail: "live resource carries no chant ownership marker and --owned was requested",
148
+ };
122
149
  continue;
123
150
  }
124
151
  result[entityName] = {
@@ -133,10 +160,17 @@ export async function describeResources(options: {
133
160
  annotations: obj.metadata?.annotations,
134
161
  }),
135
162
  };
136
- } catch {
137
- // CRD or instance not found skip silently
163
+ } catch (err) {
164
+ // A NotFound is a real absence (the CR isn't there, or Config Connector
165
+ // doesn't serve that CRD, so no instance can be). Anything else — auth,
166
+ // an unreachable API server, a context that doesn't resolve — proves
167
+ // nothing and is reported as a hole rather than an absence (#1089).
168
+ const outcome = classifyKubectlFailure(err);
169
+ if (outcome.kind === "unobserved") {
170
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
171
+ }
138
172
  }
139
173
  }
140
174
 
141
- return result;
175
+ return observation(result, unobserved);
142
176
  }
@@ -31,6 +31,8 @@ vi.mock("node:child_process", async () => {
31
31
  const { gcpPlugin } = await import("./plugin");
32
32
  const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
33
33
  const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
34
+ const { normalizeObservation } = await import("@intentius/chant/observation");
35
+ const { describeObservationConformance } = await import("@intentius/chant-test-utils");
34
36
 
35
37
  const liveBucket = {
36
38
  apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
@@ -84,20 +86,22 @@ describe("gcp lifecycle integration (#163)", () => {
84
86
  : new Error("not found"),
85
87
  );
86
88
 
87
- const observedNow = await gcpPlugin.describeResources!({
88
- environment: "prod",
89
- buildOutput: "",
90
- entityNames: ["dataBucket"],
91
- entities: new Map([
92
- [
93
- "dataBucket",
94
- {
95
- entityType: "GCP::Storage::Bucket",
96
- props: { metadata: { name: "data-bucket", namespace: "config-control" } },
97
- },
98
- ],
99
- ]),
100
- });
89
+ const { resources: observedNow } = normalizeObservation(
90
+ await gcpPlugin.describeResources!({
91
+ environment: "prod",
92
+ buildOutput: "",
93
+ entityNames: ["dataBucket"],
94
+ entities: new Map([
95
+ [
96
+ "dataBucket",
97
+ {
98
+ entityType: "GCP::Storage::Bucket",
99
+ props: { metadata: { name: "data-bucket", namespace: "config-control" } },
100
+ },
101
+ ],
102
+ ]),
103
+ }),
104
+ );
101
105
  expect(observedNow.dataBucket?.type).toBe("GCP::Storage::Bucket");
102
106
 
103
107
  const cs = buildChangeSet("prod", {
@@ -117,3 +121,46 @@ describe("gcp lifecycle integration (#163)", () => {
117
121
  expect(cs2.entries.find((e) => e.name === "dataBucket")!.action).toBe("noop");
118
122
  });
119
123
  });
124
+
125
+ // The shared conformance suite (#1089).
126
+ describeObservationConformance({
127
+ lexicon: "gcp",
128
+ scenarios: [
129
+ {
130
+ name: "an entity type with no derivable Config Connector GVK",
131
+ declared: ["notGcp", "gone"],
132
+ expectUnobserved: ["notGcp"],
133
+ expectAbsent: ["gone"],
134
+ run: () => {
135
+ execMock.mockImplementation(() => new Error('Error from server (NotFound): storagebucket "gone" not found'));
136
+ return gcpPlugin.describeResources!({
137
+ environment: "prod",
138
+ buildOutput: "",
139
+ entityNames: ["notGcp", "gone"],
140
+ entities: new Map([
141
+ ["notGcp", { entityType: "AWS::S3::Bucket", props: { metadata: { name: "not-gcp" } } }],
142
+ ["gone", { entityType: "GCP::Storage::Bucket", props: { metadata: { name: "gone" } } }],
143
+ ]),
144
+ });
145
+ },
146
+ },
147
+ {
148
+ name: "an unreachable Config Connector cluster",
149
+ declared: ["dataBucket"],
150
+ expectUnobserved: ["dataBucket"],
151
+ run: () => {
152
+ execMock.mockImplementation(() =>
153
+ Object.assign(new Error("kubectl failed"), { stderr: "Unable to connect to the server: dial tcp: i/o timeout" }),
154
+ );
155
+ return gcpPlugin.describeResources!({
156
+ environment: "prod",
157
+ buildOutput: "",
158
+ entityNames: ["dataBucket"],
159
+ entities: new Map([
160
+ ["dataBucket", { entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket" } } }],
161
+ ]),
162
+ });
163
+ },
164
+ },
165
+ ],
166
+ });