@intentius/chant-lexicon-gcp 0.37.2 → 0.38.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.
@@ -123,12 +123,19 @@ import type {
123
123
  UnobservedEntity,
124
124
  } from "@intentius/chant/lexicon";
125
125
  import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
126
- import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
127
- import { classifyKubectlFailure } from "@intentius/chant/kubectl-context";
128
- import { buildOwnershipSets, pruneByOwnership, type OwnershipSets, type ManagedFieldsEntryLike } from "@intentius/chant/managed-fields";
129
- import { deriveGVK, execConfigConnectorGet, resolveGcpKubectlContext } from "./describe-resources";
126
+ import { hasOwnershipMarker, type ChannelKeys } from "@intentius/chant/ownership";
127
+ import { deriveGVK } from "./describe-resources";
128
+ import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
129
+ import { resolveGcpProject } from "./op/activities/gcp-apply";
130
130
  import { gcpDeepNormalizationHooks } from "./deep-observe-hooks";
131
131
 
132
+ /** The labels the applier stamps — see describe-resources.ts. */
133
+ const GCP_OWNERSHIP_LABEL_KEYS: ChannelKeys = {
134
+ managedBy: "managed-by",
135
+ stack: "chant-stack",
136
+ env: "chant-env",
137
+ };
138
+
132
139
  // Re-exported so a dynamic importer of this module (plugin.ts's
133
140
  // `observeResourcesDeep`, a test) can get the reader and its hooks from one
134
141
  // place. `plugin.ts`'s own `deepNormalizationHooks` field imports the hooks
@@ -145,48 +152,44 @@ export interface GcpDeepObserveOptions {
145
152
  }
146
153
 
147
154
  /**
148
- * Matches chant's field-manager naming scheme (chant #1075: bare `chant`, or
149
- * `chant:<stack>`) — the same convention `@intentius/chant-k8s-client`'s
150
- * `isChantFieldManager` checks. Restated here rather than imported: gcp must
151
- * never depend on that package (chant #1074/#1177's structural boundary the
152
- * k8s lexicon reads live cluster state through a typed client, gcp shells
153
- * kubectl, and the two stay independently deployable). This is a two-branch
154
- * string comparison with nothing to drift out of sync; the piece that
155
- * genuinely could the managed-fields ownership walk — is shared through
156
- * `@intentius/chant/managed-fields`, not reimplemented here. See the module
157
- * doc for why this rarely matches anything on GCP's real deploy path today,
158
- * and why that's fine.
159
- */
160
- function isGcpChantFieldManager(manager: string | undefined): boolean {
161
- if (!manager) return false;
162
- return manager === "chant" || manager.startsWith("chant:");
163
- }
164
-
165
- /**
166
- * The managed-fields prune, composed with the static rules, for one
167
- * resource's normalization call the same layering k8s's `perResourceHooks`
168
- * uses, sharing the actual rule (`pruneByOwnership`) rather than restating it.
155
+ * Reshape a GCP REST body into the CNRM shape the declared source is written in.
156
+ *
157
+ * This is the half of the port that is not transport (#1209). chant's GCP
158
+ * source declares Config Connector custom resources `{ metadata: { name },
159
+ * spec: { location, storageClass } }` while the REST APIs return their own
160
+ * flat shape, `{ name, location, storageClass }`. Diffing one against the other
161
+ * makes every field drift twice: once as `spec.location: US -> <absent>` and
162
+ * again as `location: <undeclared> -> US`.
163
+ *
164
+ * Verified against floci-gcp before this existed: a bucket that matched its
165
+ * declaration exactly reported **7 property drifts**, all of them shape.
166
+ *
167
+ * chant has met this before. #1207 records it for AWS: Cloud Control returns
168
+ * the CloudFormation resource model and lines up for free, while the EC2 API
169
+ * returns the EC2 shape and needs mapping onto the declared shape before the
170
+ * diff can compare. GCP is the EC2 case.
171
+ *
172
+ * The mapping is CNRM's own convention rather than a per-kind table: identity
173
+ * and labels live under `metadata`, everything else is `spec`. That holds for
174
+ * every kind the applier can write, and a per-kind table would be a second
175
+ * place to forget a field.
169
176
  */
170
- function perResourceHooks(sets: OwnershipSets): DeepNormalizationHooks {
177
+ export function restToCnrmShape(body: Record<string, unknown>): Record<string, unknown> {
178
+ const metadata: Record<string, unknown> = {};
179
+ const spec: Record<string, unknown> = {};
180
+ for (const [key, value] of Object.entries(body)) {
181
+ if (key === "name" || key === "labels" || key === "annotations") metadata[key] = value;
182
+ else spec[key] = value;
183
+ }
171
184
  return {
172
- prune(node) {
173
- if (gcpDeepNormalizationHooks.prune?.(node)) return true;
174
- return pruneByOwnership(node, sets);
175
- },
176
- orderKey: gcpDeepNormalizationHooks.orderKey,
185
+ ...(Object.keys(metadata).length ? { metadata } : {}),
186
+ ...(Object.keys(spec).length ? { spec } : {}),
177
187
  };
178
188
  }
179
189
 
180
- /** `metadata.managedFields` off a raw `kubectl get -o json` object a plain decode, no client-side type coercion. */
181
- function managedFieldsOfRaw(obj: Record<string, unknown>): ManagedFieldsEntryLike[] {
182
- const metadata = obj.metadata;
183
- if (!metadata || typeof metadata !== "object") return [];
184
- const entries = (metadata as Record<string, unknown>).managedFields;
185
- if (!Array.isArray(entries)) return [];
186
- return entries.filter((e): e is ManagedFieldsEntryLike => !!e && typeof e === "object");
187
- }
188
-
189
- /** The live object minus the envelope fields that live outside `properties` on {@link DeepResourceObservation} (mirrors k8s's `propertiesTreeOf`). */
190
+ /** The live payload minus the fields that live outside `properties` on
191
+ * {@link DeepResourceObservation}. A REST body has no `apiVersion`, but `kind`
192
+ * shows up on some (GCS returns `storage#bucket`), so both are dropped. */
190
193
  function propertiesTreeOf(obj: Record<string, unknown>): Record<string, unknown> {
191
194
  const { apiVersion: _apiVersion, kind: _kind, ...rest } = obj;
192
195
  return rest;
@@ -204,25 +207,22 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
204
207
  const resources: Record<string, DeepResourceObservation> = {};
205
208
  const unobserved: Record<string, UnobservedEntity> = {};
206
209
 
207
- // Resolve the cluster identity once, before touching any resource — a
208
- // declared-but-mismatched binding throws here (chant #1100), aborting the
209
- // whole read rather than letting the per-entity try/catch below absorb it
210
- // as an ordinary "not found". Core turns the throw into NOT-OBSERVED for
211
- // every declared entity.
212
- const ctxArg = await resolveGcpKubectlContext(options.environment);
210
+ const endpoint = process.env.GCP_ENDPOINT_URL;
213
211
 
214
- for (const [entityName, { entityType, props }] of options.entities) {
212
+ const reads = [...options.entities].map(async ([entityName, { entityType, props }]) => {
215
213
  const gvk = deriveGVK(entityType);
216
- if (!gvk) {
214
+ if (!gvk || !mapperForKind(gvk.kind)) {
217
215
  unobserved[entityName] = {
218
216
  type: entityType,
219
217
  reason: "unsupported-kind",
220
- detail: `cannot derive a Config Connector GVK from ${entityType}`,
218
+ detail: gvk
219
+ ? `no REST mapper for ${gvk.kind} — chant cannot apply this kind either`
220
+ : `cannot derive a GCP kind from ${entityType}`,
221
221
  };
222
- continue;
222
+ return;
223
223
  }
224
224
 
225
- const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
225
+ const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
226
226
  const name = metadata?.name;
227
227
  if (!name) {
228
228
  unobserved[entityName] = {
@@ -230,47 +230,67 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
230
230
  reason: "read-failed",
231
231
  detail: "declared entity has no metadata.name to query by",
232
232
  };
233
- continue;
233
+ return;
234
234
  }
235
235
 
236
+ let client: GcpReadClientOptions;
236
237
  try {
237
- const obj = await execConfigConnectorGet(gvk, name, metadata.namespace, ctxArg);
238
- const objMetadata = obj.metadata as { labels?: Record<string, string>; uid?: string } | undefined;
238
+ client = {
239
+ project: resolveGcpProject({ kind: gvk.kind, metadata }),
240
+ ...(endpoint ? { endpoint } : {}),
241
+ };
242
+ } catch (err) {
243
+ unobserved[entityName] = {
244
+ type: entityType,
245
+ reason: "no-binding",
246
+ detail: err instanceof Error ? err.message : String(err),
247
+ };
248
+ return;
249
+ }
239
250
 
240
- // owned filter: withhold resources not carrying chant's marker label.
241
- // Withheld is not absent (#1089) the CR exists, it just isn't chant's.
242
- if (options.owned && !hasOwnershipMarker(objMetadata?.labels, LABEL_OWNERSHIP_KEYS)) {
251
+ try {
252
+ const obj = await getResource(client, gvk.kind, name, props);
253
+ const labels = obj.labels as Record<string, string> | null | undefined;
254
+
255
+ // owned filter: withhold what does not carry chant's marker. Withheld is
256
+ // not absent (#1089). Where the payload has no labels at all there is
257
+ // nothing to filter on, so the resource passes through — the same
258
+ // detect-only degradation the thin path takes.
259
+ if (options.owned && labels != null && !hasOwnershipMarker(labels, GCP_OWNERSHIP_LABEL_KEYS)) {
243
260
  unobserved[entityName] = {
244
261
  type: entityType,
245
262
  reason: "filtered",
246
263
  detail: "live resource carries no chant ownership marker and --owned was requested",
247
264
  };
248
- continue;
265
+ return;
249
266
  }
250
267
 
251
- const liveRoot = propertiesTreeOf(obj);
252
- const sets = buildOwnershipSets(managedFieldsOfRaw(obj), liveRoot, props, isGcpChantFieldManager);
253
-
254
268
  resources[entityName] = {
255
269
  type: entityType,
256
- physicalId: objMetadata?.uid,
257
- properties: normalizeDeepProperties(liveRoot, {
270
+ physicalId: (obj.id as string | undefined) ?? (obj.selfLink as string | undefined),
271
+ properties: normalizeDeepProperties(restToCnrmShape(propertiesTreeOf(obj)), {
258
272
  entityType,
259
273
  side: "live",
260
- hooks: perResourceHooks(sets),
274
+ // The static table is the whole prune now — there is no per-resource
275
+ // ownership pass, because a REST payload carries no field ownership
276
+ // to drive one (see ./deep-observe-hooks.ts).
277
+ hooks: gcpDeepNormalizationHooks,
261
278
  }),
262
279
  };
263
280
  } catch (err) {
264
- // A NotFound is a real absence, same as the thin read — records
265
- // nothing here, since restating it would turn one finding into two.
266
- // Anything else (auth, connectivity, a mismatched context) proves
267
- // nothing and is a hole rather than an absence (#1089).
268
- const outcome = classifyKubectlFailure(err);
269
- if (outcome.kind === "unobserved") {
270
- unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
271
- }
281
+ // A 404 is a real absence, same as the thin read — recorded there, not
282
+ // restated here. Anything else proves nothing and is a hole (#1089).
283
+ if (isNotFound(err)) return;
284
+ const status = err instanceof GcpReadError ? err.status : undefined;
285
+ unobserved[entityName] = {
286
+ type: entityType,
287
+ reason: status === 401 || status === 403 ? "no-credentials" : "read-failed",
288
+ detail: err instanceof Error ? err.message : String(err),
289
+ };
272
290
  }
273
- }
291
+ });
292
+
293
+ await Promise.all(reads);
274
294
 
275
295
  return deepObservation(resources, unobserved);
276
296
  }
@@ -1,243 +1,156 @@
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 loadChantConfigMock = vi.fn();
15
- vi.mock("@intentius/chant/config", () => ({
16
- loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
17
- }));
18
-
19
- const { describeResources } = await import("./describe-resources");
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { describeResources, statusFromRest } from "./describe-resources";
3
+
4
+ /**
5
+ * The transport is `fetch` now, not a kubectl spawn (#1209), so these stub the
6
+ * global rather than `node:child_process`. Stubbing fetch also keeps the URL
7
+ * under test: a reader that composed its own paths instead of reusing the
8
+ * applier's mapper would show up here as a changed URL.
9
+ */
10
+ const fetchMock = vi.fn();
11
+
12
+ function reply(status: number, body: unknown) {
13
+ return { status, text: async () => (typeof body === "string" ? body : JSON.stringify(body)) };
14
+ }
20
15
 
21
16
  function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
22
17
  return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
23
18
  }
24
19
 
25
- describe("gcp describeResources (Config Connector)", () => {
26
- beforeEach(() => {
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: {} });
32
- });
33
-
34
- test("queries kubectl with the derived CC GVK and maps the response", async () => {
35
- let receivedCmd = "";
36
- execMock.mockImplementation((cmd: string) => {
37
- receivedCmd = cmd;
38
- return {
39
- stdout: JSON.stringify({
40
- metadata: { name: "data-bucket", namespace: "config-control", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z" },
41
- status: { conditions: [{ type: "Ready", status: "True" }] },
42
- }),
43
- stderr: "",
44
- };
45
- });
46
-
47
- const entities = makeEntities([
48
- { name: "dataBucket", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket", namespace: "config-control" } } },
49
- ]);
50
-
51
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities });
52
-
53
- // Resource name follows: <lowerKind>.<service>.cnrm.cloud.google.com
54
- expect(receivedCmd).toContain("storagebucket.storage.cnrm.cloud.google.com");
55
- expect(receivedCmd).toContain("data-bucket");
56
- expect(receivedCmd).toContain("-n config-control");
20
+ const bucket = (name: string) => ({
21
+ name: "bucket-entity",
22
+ entityType: "GCP::Storage::Bucket",
23
+ props: { metadata: { name }, spec: { location: "US" } },
24
+ });
57
25
 
58
- expect(result.resources["dataBucket"]).toMatchObject({
59
- type: "GCP::Storage::Bucket",
60
- physicalId: "uid-1",
61
- status: "READY",
62
- });
26
+ async function read(entities: ReturnType<typeof makeEntities>, opts: { owned?: boolean } = {}) {
27
+ return describeResources({
28
+ environment: "local",
29
+ buildOutput: "",
30
+ entityNames: [...entities.keys()],
31
+ entities,
32
+ ...opts,
63
33
  });
34
+ }
64
35
 
65
- test("Compute resource derives correct GVK with service prefix", async () => {
66
- let receivedCmd = "";
67
- execMock.mockImplementation((cmd: string) => {
68
- receivedCmd = cmd;
69
- return {
70
- stdout: JSON.stringify({
71
- metadata: { name: "subnet-1", uid: "uid", creationTimestamp: "t" },
72
- status: { conditions: [{ type: "Ready", status: "True" }] },
73
- }),
74
- stderr: "",
75
- };
76
- });
77
-
78
- const entities = makeEntities([
79
- { name: "sub", entityType: "GCP::Compute::Subnetwork", props: { metadata: { name: "subnet-1" } } },
80
- ]);
81
-
82
- await describeResources({ environment: "prod", buildOutput: "", entityNames: ["sub"], entities });
83
-
84
- expect(receivedCmd).toContain("computesubnetwork.compute.cnrm.cloud.google.com");
36
+ describe("gcp describeResources direct REST (#1209)", () => {
37
+ beforeEach(() => {
38
+ fetchMock.mockReset();
39
+ vi.stubGlobal("fetch", fetchMock);
40
+ process.env.GOOGLE_CLOUD_PROJECT = "my-project";
41
+ process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
85
42
  });
86
43
 
87
- test("Ready=False maps to the condition's reason", async () => {
88
- execMock.mockResolvedValue({
89
- stdout: JSON.stringify({
90
- metadata: { name: "x", uid: "uid", creationTimestamp: "t" },
91
- status: { conditions: [{ type: "Ready", status: "False", reason: "DependencyNotFound", message: "..." }] },
92
- }),
93
- stderr: "",
94
- });
95
-
96
- const entities = makeEntities([
97
- { name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
98
- ]);
99
-
100
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
101
-
102
- expect(result.resources["x"].status).toBe("DependencyNotFound");
44
+ afterEach(() => {
45
+ vi.unstubAllGlobals();
46
+ delete process.env.GCP_ENDPOINT_URL;
47
+ delete process.env.GOOGLE_CLOUD_PROJECT;
103
48
  });
104
49
 
105
- test("missing Ready condition falls back to PRESENT", async () => {
106
- execMock.mockResolvedValue({
107
- stdout: JSON.stringify({
108
- metadata: { name: "x", uid: "uid", creationTimestamp: "t" },
109
- status: {},
110
- }),
111
- stderr: "",
112
- });
113
-
114
- const entities = makeEntities([
115
- { name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
116
- ]);
117
-
118
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
50
+ test("reads through the applier's mapper URL, against the endpoint override", async () => {
51
+ fetchMock.mockResolvedValue(reply(200, { id: "b/my-bucket", labels: { "managed-by": "chant" } }));
52
+ const out = await read(makeEntities([bucket("my-bucket")]));
119
53
 
120
- expect(result.resources["x"].status).toBe("PRESENT");
54
+ // floci-gcp, and the storage path the applier writes to — not a URL this
55
+ // reader composed for itself.
56
+ expect(fetchMock).toHaveBeenCalledTimes(1);
57
+ expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:4588/storage/v1/b/my-bucket");
58
+ expect(out.resources["bucket-entity"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "b/my-bucket" });
121
59
  });
122
60
 
123
- test("kubectl-not-found leaves entity out of result (a confirmed absence)", async () => {
124
- execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): storagebucket "x" not found'); });
125
-
126
- const entities = makeEntities([
127
- { name: "x", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "x" } } },
128
- ]);
129
-
130
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
131
-
132
- expect(result.resources).toEqual({});
133
- expect(result.unobserved ?? {}).toEqual({});
61
+ test("a 404 is a real absence — reported as neither present nor a hole", async () => {
62
+ fetchMock.mockResolvedValue(reply(404, { error: "not found" }));
63
+ const out = await read(makeEntities([bucket("gone")]));
64
+ expect(out.resources["bucket-entity"]).toBeUndefined();
65
+ expect(out.unobserved?.["bucket-entity"]).toBeUndefined();
134
66
  });
135
67
 
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");
68
+ test("a 403 is a hole, not an absence (#1089)", async () => {
69
+ fetchMock.mockResolvedValue(reply(403, { error: "denied" }));
70
+ const out = await read(makeEntities([bucket("secret")]));
71
+ expect(out.resources["bucket-entity"]).toBeUndefined();
72
+ expect(out.unobserved?.["bucket-entity"]?.reason).toBe("no-credentials");
151
73
  });
152
74
 
153
- test("non-GCP entity types are unobserved — no GVK to query (#1089)", async () => {
154
- const entities = makeEntities([
155
- { name: "x", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
156
- ]);
75
+ test("an unreachable endpoint is a hole", async () => {
76
+ fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
77
+ const out = await read(makeEntities([bucket("x")]));
78
+ expect(out.unobserved?.["bucket-entity"]?.reason).toBe("read-failed");
79
+ });
157
80
 
158
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["x"], entities });
81
+ test("a kind the applier has no mapper for is unsupported-kind, not absent", async () => {
82
+ const out = await read(
83
+ makeEntities([{ name: "e", entityType: "GCP::Compute::Address", props: { metadata: { name: "addr" } } }]),
84
+ );
85
+ expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
86
+ expect(fetchMock).not.toHaveBeenCalled();
87
+ });
159
88
 
160
- expect(result.resources).toEqual({});
161
- expect(result.unobserved?.x?.reason).toBe("unsupported-kind");
162
- expect(execMock).not.toHaveBeenCalled();
89
+ test("an entity with no metadata.name has nothing to query by", async () => {
90
+ const out = await read(makeEntities([{ name: "e", entityType: "GCP::Storage::Bucket", props: {} }]));
91
+ expect(out.unobserved?.e?.reason).toBe("read-failed");
92
+ expect(fetchMock).not.toHaveBeenCalled();
163
93
  });
164
94
 
165
- test("entity without metadata.name is unobserved nothing was queried", async () => {
95
+ test("reads concurrently, where the kubectl path was one spawn after another", async () => {
96
+ fetchMock.mockResolvedValue(reply(200, {}));
166
97
  const entities = makeEntities([
167
- { name: "broken", entityType: "GCP::Storage::Bucket", props: {} },
98
+ { name: "a", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "a" } } },
99
+ { name: "b", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "b" } } },
100
+ { name: "c", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "c" } } },
168
101
  ]);
169
-
170
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
171
-
172
- expect(result.resources).toEqual({});
173
- expect(result.unobserved?.broken?.reason).toBe("read-failed");
174
- expect(execMock).not.toHaveBeenCalled();
102
+ await read(entities);
103
+ expect(fetchMock).toHaveBeenCalledTimes(3);
175
104
  });
176
105
 
177
- // chant #1100 — GCP-via-CNRM resolves the same environment→cluster binding
178
- // as the K8s lexicon (bound-and-matching, bound-and-mismatched loud
179
- // refusal, unbound unchanged), since it observes through the same kubectl
180
- // path against the same cluster.
181
- describe("cluster binding (chant #1100)", () => {
182
- function bucketEntities() {
183
- return makeEntities([
184
- { name: "dataBucket", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "data-bucket" } } },
185
- ]);
186
- }
187
-
188
- const bucketStdout = JSON.stringify({
189
- metadata: { name: "data-bucket", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z" },
190
- status: { conditions: [{ type: "Ready", status: "True" }] },
106
+ describe("--owned", () => {
107
+ test("withholds a resource whose labels carry no chant marker", async () => {
108
+ fetchMock.mockResolvedValue(reply(200, { id: "b/theirs", labels: { team: "other" } }));
109
+ const out = await read(makeEntities([bucket("theirs")]), { owned: true });
110
+ expect(out.unobserved?.["bucket-entity"]?.reason).toBe("filtered");
191
111
  });
192
112
 
193
- test("bound and ambient context matches: observes explicitly via --context", async () => {
194
- loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
195
- let receivedCmd = "";
196
- execMock.mockImplementation((cmd: string) => {
197
- if (cmd.includes("current-context")) return { stdout: "prod-cnrm\n", stderr: "" };
198
- receivedCmd = cmd;
199
- return { stdout: bucketStdout, stderr: "" };
200
- });
201
-
202
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
113
+ test("keeps one carrying the marker the APPLIER stamps, not the CNRM label", async () => {
114
+ // gcp-apply stamps `managed-by: chant` GCP label keys cannot hold the
115
+ // k8s `app.kubernetes.io/managed-by` form the kubectl path looked for.
116
+ fetchMock.mockResolvedValue(reply(200, { id: "b/ours", labels: { "managed-by": "chant" } }));
117
+ const out = await read(makeEntities([bucket("ours")]), { owned: true });
118
+ expect(out.resources["bucket-entity"]?.ownership).toBe("owned");
119
+ });
203
120
 
204
- expect(receivedCmd).toContain("--context prod-cnrm");
205
- expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
121
+ test("degrades to detect-only for a kind whose payload has no labels at all", async () => {
122
+ // A Pub/Sub topic carries none. Withholding everything it cannot prove
123
+ // would report a live estate as empty; the AWS thin path takes the same
124
+ // posture when describe-stack-resources returns no tags.
125
+ fetchMock.mockResolvedValue(reply(200, { name: "projects/my-project/topics/t" }));
126
+ const out = await read(
127
+ makeEntities([{ name: "t", entityType: "GCP::PubSub::Topic", props: { metadata: { name: "t" } } }]),
128
+ { owned: true },
129
+ );
130
+ expect(out.unobserved?.t).toBeUndefined();
131
+ expect(out.resources.t?.ownership).toBe("unknown");
206
132
  });
133
+ });
134
+ });
207
135
 
208
- test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
209
- loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
210
- execMock.mockImplementation((cmd: string) => {
211
- if (cmd.includes("current-context")) return { stdout: "staging-cnrm\n", stderr: "" };
212
- throw new Error(`unexpected cmd (should have refused before any kubectl get): ${cmd}`);
213
- });
136
+ describe("statusFromRest", () => {
137
+ test("PRESENT when the payload carries no state the common case", () => {
138
+ // A bucket that answers a GET simply exists. Same sentinel the Azure
139
+ // reader emits for a resource with no provisioningState.
140
+ expect(statusFromRest({})).toBe("PRESENT");
141
+ });
214
142
 
215
- await expect(
216
- describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() }),
217
- ).rejects.toThrow(/environment "prod".*"prod-cnrm".*"staging-cnrm"/s);
143
+ test("an explicit state wins", () => {
144
+ expect(statusFromRest({ state: "ACTIVE" })).toBe("ACTIVE");
145
+ });
218
146
 
219
- expect(execMock).toHaveBeenCalledTimes(1);
220
- });
147
+ test("a Ready condition reads like the CNRM path did", () => {
148
+ expect(statusFromRest({ status: { conditions: [{ type: "Ready", status: "True" }] } })).toBe("READY");
149
+ expect(statusFromRest({ status: { conditions: [{ type: "Ready", status: "False", reason: "Failed" }] } })).toBe("Failed");
150
+ expect(statusFromRest({ status: { conditions: [{ type: "Ready", status: "False" }] } })).toBe("NOT_READY");
151
+ });
221
152
 
222
- test("unbound: ambient context is used unchanged, but the fallback is visible (not silent)", async () => {
223
- loadChantConfigMock.mockResolvedValue({ config: {} });
224
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
225
- let receivedCmd = "";
226
- execMock.mockImplementation((cmd: string) => {
227
- receivedCmd = cmd;
228
- return { stdout: bucketStdout, stderr: "" };
229
- });
230
-
231
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dataBucket"], entities: bucketEntities() });
232
-
233
- expect(receivedCmd).not.toContain("--context");
234
- expect(receivedCmd).not.toContain("current-context");
235
- expect(result.resources["dataBucket"]).toMatchObject({ type: "GCP::Storage::Bucket", physicalId: "uid-1", status: "READY" });
236
-
237
- const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
238
- expect(bindingWarning?.[0]).toContain('environment "prod"');
239
- expect(bindingWarning?.[0]).toContain("k8s.profiles.prod.context");
240
- warnSpy.mockRestore();
241
- });
153
+ test("falls back to listing conditions when there is no Ready", () => {
154
+ expect(statusFromRest({ status: { conditions: [{ type: "Synced", status: "True" }] } })).toBe("Synced=True");
242
155
  });
243
156
  });