@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.
@@ -1,522 +1,168 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { gcpPlugin } from "./plugin";
3
+ import { observeResourcesDeepGcp } from "./deep-observe";
4
+ import { gcpDeepNormalizationHooks, GCP_READ_ONLY_NAMES, GCP_SERVICE_DEFAULTS } from "./deep-observe-hooks";
5
+ import type { DeepNode } from "@intentius/chant/deep-observation";
6
+
1
7
  /**
2
- * GCP deep observation (#1087) the GCP row of the deep-observe contract
3
- * (#1014), and the managed-fields reuse from the k8s row (#1076).
4
- *
5
- * Every case here drives the real reader (`observeResourcesDeepGcp`) with
6
- * `node:child_process`'s `exec` mocked — the same harness
7
- * `describe-resources.test.ts` uses for the thin read. No ambient kubectl
8
- * config is read and no cluster is contacted.
9
- *
10
- * The end-to-end acceptance test drives `observeResourcesDeepGcp`'s real
11
- * output through core's real `diffDeepObservation`, with `gcpPlugin`'s real,
12
- * exported `deepNormalizationHooks` — the same three pieces
13
- * `lexicons/k8s/src/deep-observe.test.ts` exercises for the k8s row.
8
+ * The deep reader is on GCP REST now, not kubectl (#1209), so these stub
9
+ * `fetch`. What they mostly assert is the consequence of that: a REST payload
10
+ * carries no field ownership, so the noise rules are a static table rather than
11
+ * a per-resource managed-fields prune.
14
12
  */
13
+ const fetchMock = vi.fn();
15
14
 
16
- import { describe, test, expect, vi, beforeEach } from "vitest";
17
-
18
- const execMock = vi.fn();
19
- vi.mock("node:child_process", async () => {
20
- const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
21
- return {
22
- ...actual,
23
- exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
24
- Promise.resolve(execMock(cmd)).then(
25
- (out) => cb(null, out),
26
- (err) => cb(err as Error, { stdout: "", stderr: "" }),
27
- );
28
- },
29
- };
30
- });
31
-
32
- const loadChantConfigMock = vi.fn();
33
- vi.mock("@intentius/chant/config", () => ({
34
- loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
35
- }));
15
+ function reply(status: number, body: unknown) {
16
+ return { status, text: async () => JSON.stringify(body) };
17
+ }
36
18
 
37
- const { gcpPlugin } = await import("./plugin");
38
- const { observeResourcesDeepGcp } = await import("./deep-observe");
39
- const { gcpDeepNormalizationHooks } = await import("./deep-observe-hooks");
40
- const { diffDeepObservation, observeDeep } = await import("@intentius/chant/lifecycle/deep-observe");
41
- const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
19
+ const node = (over: Partial<DeepNode>): DeepNode =>
20
+ ({ entityType: "GCP::Storage::Bucket", path: "x", pattern: "x", key: "x", value: undefined, side: "live", counterpart: "unknown", ...over }) as DeepNode;
42
21
 
43
- type Entity = { name: string; entityType: string; props: Record<string, unknown> };
44
- function makeEntities(records: Entity[]): Map<string, { entityType: string; props: Record<string, unknown> }> {
45
- return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
22
+ function entities(records: Array<[string, string, Record<string, unknown>]>) {
23
+ return new Map(records.map(([n, t, p]) => [n, { entityType: t, props: p }]));
46
24
  }
47
25
 
48
- /** Route a mocked `kubectl get <resource> <name> ... -o json` command to a canned JSON body, by matching on `<resource> <name>`. */
49
- function respondTo(bodies: Record<string, unknown>): (cmd: string) => { stdout: string; stderr: string } {
50
- return (cmd: string) => {
51
- for (const [needle, body] of Object.entries(bodies)) {
52
- if (cmd.includes(needle)) return { stdout: JSON.stringify(body), stderr: "" };
53
- }
54
- throw new Error(`unexpected kubectl invocation: ${cmd}`);
55
- };
26
+ async function observe(e: ReturnType<typeof entities>, opts: { owned?: boolean } = {}) {
27
+ return observeResourcesDeepGcp({ environment: "local", entityNames: [...e.keys()], entities: e, ...opts });
56
28
  }
57
29
 
58
- beforeEach(() => {
59
- execMock.mockReset();
60
- loadChantConfigMock.mockReset();
61
- loadChantConfigMock.mockResolvedValue({ config: {} });
62
- });
63
-
64
- describe("gcpPlugin wiring (#1087)", () => {
65
- test("the plugin exposes the deep-observe contract, and the hooks are the shared static instance", () => {
66
- expect(typeof gcpPlugin.observeResourcesDeep).toBe("function");
30
+ describe("gcpPlugin wiring", () => {
31
+ test("exposes the deep-observe contract, sharing the static hook instance", () => {
32
+ expect(gcpPlugin.observeResourcesDeep).toBeTypeOf("function");
67
33
  expect(gcpPlugin.deepNormalizationHooks).toBe(gcpDeepNormalizationHooks);
68
34
  });
69
35
  });
70
36
 
71
- describe("gcpDeepNormalizationHooks — the static rules", () => {
72
- test("prunes status and the server-minted metadata fields, reused verbatim from the k8s row", () => {
73
- const out = normalizeDeepProperties(
74
- {
75
- status: { conditions: [{ type: "Ready", status: "True" }] },
76
- metadata: {
77
- name: "data-bucket",
78
- uid: "u-1",
79
- resourceVersion: "7",
80
- generation: 3,
81
- creationTimestamp: "2026-01-01T00:00:00Z",
82
- managedFields: [{ manager: "cnrm-controller-manager" }],
83
- labels: { app: "web" },
84
- },
85
- },
86
- { entityType: "GCP::Storage::Bucket", side: "live", hooks: gcpDeepNormalizationHooks },
87
- );
88
- expect(out).toEqual({ metadata: { name: "data-bucket", labels: { app: "web" } } });
37
+ describe("gcpDeepNormalizationHooks — the static rules (#1209)", () => {
38
+ test("prunes server-assigned fields wherever they appear, on either side", () => {
39
+ for (const name of ["etag", "selfLink", "id", "timeCreated", "generation"]) {
40
+ expect(GCP_READ_ONLY_NAMES.has(name)).toBe(true);
41
+ expect(gcpDeepNormalizationHooks.prune!(node({ pattern: `spec.${name}`, side: "live" }))).toBe(true);
42
+ expect(gcpDeepNormalizationHooks.prune!(node({ pattern: name, side: "declared" }))).toBe(true);
43
+ }
89
44
  });
90
45
 
91
- test("prunes Config Connector's own observed-state annotation, but not a user-authored cnrm.cloud.google.com annotation", () => {
92
- const out = normalizeDeepProperties(
93
- {
94
- metadata: {
95
- annotations: {
96
- "cnrm.cloud.google.com/observed-secret-versions": '{"db-password":"1"}',
97
- "cnrm.cloud.google.com/deletion-policy": "abandon",
98
- },
99
- },
100
- },
101
- { entityType: "GCP::Storage::Bucket", side: "live", hooks: gcpDeepNormalizationHooks },
102
- );
103
- expect(out).toEqual({ metadata: { annotations: { "cnrm.cloud.google.com/deletion-policy": "abandon" } } });
104
- });
46
+ test("prunes a provider default ONLY where source never declared it", () => {
47
+ const undeclared = node({ pattern: "storageClass", value: "STANDARD", side: "live", counterpart: "absent" });
48
+ expect(gcpDeepNormalizationHooks.prune!(undeclared)).toBe(true);
105
49
 
106
- test("orders containers by name reused for CNRM kinds embedding a k8s-shaped pod spec (e.g. Cloud Run's RunService)", () => {
107
- const out = normalizeDeepProperties(
108
- { spec: { template: { spec: { containers: [{ name: "sidecar" }, { name: "app" }] } } } },
109
- { entityType: "GCP::Run::Service", side: "live", hooks: gcpDeepNormalizationHooks },
110
- );
111
- expect(
112
- (out.spec as { template: { spec: { containers: Array<{ name: string }> } } }).template.spec.containers.map((c) => c.name),
113
- ).toEqual(["app", "sidecar"]);
50
+ // Declared: a change away from it must still surface, so it is not pruned.
51
+ const declared = node({ pattern: "storageClass", value: "STANDARD", side: "live", counterpart: "present" });
52
+ expect(gcpDeepNormalizationHooks.prune!(declared)).toBe(false);
114
53
  });
115
- });
116
54
 
117
- describe("observeResourcesDeepGcp reading through kubectl (#1087)", () => {
118
- test("a hand-edited field surfaces with its path; an unsupported entity type is unsupported-kind", async () => {
119
- execMock.mockImplementation(
120
- respondTo({
121
- "storagebucket.storage.cnrm.cloud.google.com data-bucket": {
122
- apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
123
- kind: "StorageBucket",
124
- metadata: {
125
- name: "data-bucket",
126
- namespace: "config-control",
127
- uid: "uid-bucket",
128
- labels: { app: "web-renamed" },
129
- managedFields: [
130
- {
131
- manager: "kubectl-edit",
132
- operation: "Update",
133
- fieldsV1: { "f:metadata": { "f:labels": { "f:app": {} } } },
134
- },
135
- ],
136
- },
137
- spec: {},
138
- status: {},
139
- },
140
- }),
141
- );
142
-
143
- const result = normalizeDeepObservation(
144
- await observeResourcesDeepGcp({
145
- environment: "prod",
146
- entityNames: ["dataBucket", "unknownKind"],
147
- entities: makeEntities([
148
- {
149
- name: "dataBucket",
150
- entityType: "GCP::Storage::Bucket",
151
- props: { metadata: { name: "data-bucket", namespace: "config-control", labels: { app: "web" } } },
152
- },
153
- { name: "unknownKind", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
154
- ]),
155
- }),
156
- );
157
-
158
- expect(result.resources.dataBucket.properties).toMatchObject({ metadata: { labels: { app: "web-renamed" } } });
159
- expect(result.resources.dataBucket.properties).not.toHaveProperty("status");
160
- expect(result.unobserved.unknownKind.reason).toBe("unsupported-kind");
55
+ test("does not prune a value that merely shares a default's path", () => {
56
+ const different = node({ pattern: "storageClass", value: "NEARLINE", side: "live", counterpart: "absent" });
57
+ expect(gcpDeepNormalizationHooks.prune!(different)).toBe(false);
161
58
  });
162
59
 
163
- test("a read failure is a hole with a reason, never silence", async () => {
164
- execMock.mockImplementation(() => {
165
- throw Object.assign(new Error("kubectl failed"), { stderr: "Error from server (Forbidden): storagebuckets.storage.cnrm.cloud.google.com is forbidden" });
166
- });
167
- const result = normalizeDeepObservation(
168
- await observeResourcesDeepGcp({
169
- environment: "prod",
170
- entityNames: ["broken"],
171
- entities: makeEntities([
172
- { name: "broken", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "broken" } } },
173
- ]),
174
- }),
175
- );
176
- expect(result.resources).toEqual({});
177
- expect(result.unobserved.broken.reason).toBe("no-credentials");
60
+ test("defaults are per kind, not global", () => {
61
+ expect(GCP_SERVICE_DEFAULTS.StorageBucket).toBeDefined();
62
+ const wrongKind = node({ entityType: "GCP::PubSub::Topic", pattern: "storageClass", value: "STANDARD", side: "live", counterpart: "absent" });
63
+ expect(gcpDeepNormalizationHooks.prune!(wrongKind)).toBe(false);
178
64
  });
179
65
 
180
- test("--owned withholds an unmarked object as filtered, not absent", async () => {
181
- execMock.mockImplementation(
182
- respondTo({
183
- "storagebucket.storage.cnrm.cloud.google.com theirs": {
184
- apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
185
- kind: "StorageBucket",
186
- metadata: { name: "theirs", uid: "uid-theirs" },
187
- spec: {},
188
- },
189
- }),
190
- );
191
- const result = normalizeDeepObservation(
192
- await observeResourcesDeepGcp({
193
- environment: "prod",
194
- entityNames: ["theirs"],
195
- owned: true,
196
- entities: makeEntities([{ name: "theirs", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "theirs" } } }]),
197
- }),
198
- );
199
- expect(result.resources).toEqual({});
200
- expect(result.unobserved.theirs.reason).toBe("filtered");
66
+ test("no per-resource ownership hook a REST payload carries none to drive one", () => {
67
+ // The CNRM path layered a managedFields prune under these rules. There is
68
+ // nothing to layer now, which is why the static table has to be enough.
69
+ expect(Object.keys(gcpDeepNormalizationHooks)).toEqual(["prune"]);
201
70
  });
202
71
  });
203
72
 
204
- /**
205
- * The acceptance test for #1087, in the reference shape
206
- * (`lexicons/aws/src/deep-observe.test.ts` / `lexicons/k8s/src/deep-observe.test.ts`):
207
- * declared source, a live tree carrying realistic Config Connector
208
- * managedFields noise, driven through the real reader and core's real
209
- * `diffDeepObservation`, with `gcpPlugin`'s real static hooks.
210
- */
211
- describe("end to end: Config Connector managed-fields-derived drift (#1087)", () => {
212
- const declared = makeEntities([
213
- // "dataBucket": chant's own kubectl-apply owns most fields (attributed to
214
- // "kubectl-client-side-apply", not "chant" — see the module doc's GCP
215
- // twist); CNRM owns an undeclared field and an observed-state annotation
216
- // (both pruned); a hand kubectl-edit changed a declared label (drift);
217
- // a declared annotation moved to a value the baseline accepts.
218
- {
219
- name: "dataBucket",
220
- entityType: "GCP::Storage::Bucket",
221
- props: {
222
- metadata: {
223
- name: "data-bucket",
224
- namespace: "config-control",
225
- labels: { app: "web", "cnrm-test": "true" },
226
- annotations: { "build-id": "42" },
227
- },
228
- spec: { location: "US", storageClass: "STANDARD" },
229
- },
230
- },
231
- // "sqlInstance": chant declares spec.tier; Config Connector's OWN
232
- // controller currently holds it at a different value (a manual gcloud
233
- // change the controller's "merge" reconciliation folded back in). Foreign
234
- // ownership does not silence it, because chant declared it too —
235
- // contested, same as k8s's "worker" case, but contested by CNRM itself
236
- // rather than by a human.
237
- {
238
- name: "sqlInstance",
239
- entityType: "GCP::SQL::Instance",
240
- props: {
241
- metadata: { name: "primary-db", namespace: "config-control" },
242
- spec: { tier: "db-f1-micro" },
243
- },
244
- },
245
- // No Config Connector GVK derivable for this type at all.
246
- { name: "cache", entityType: "AWS::ElastiCache::CacheCluster", props: { metadata: { name: "cache" } } },
247
- // The read itself fails.
248
- { name: "broken", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "broken" } } },
249
- ]);
250
-
251
- const bucketLive = {
252
- apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
253
- kind: "StorageBucket",
254
- metadata: {
255
- name: "data-bucket",
256
- namespace: "config-control",
257
- uid: "uid-bucket",
258
- resourceVersion: "42",
259
- generation: 7,
260
- creationTimestamp: "2026-01-01T00:00:00Z",
261
- // GENUINE DRIFT: chant declares this label; a person ran `kubectl edit`
262
- // and it now belongs to "kubectl-edit", not chant's own apply.
263
- labels: { app: "web-renamed", "cnrm-test": "true" },
264
- // ACCEPTED: chant declares this annotation; the platform's baseline
265
- // accepts "43".
266
- annotations: {
267
- "build-id": "43",
268
- // NOISE: CNRM's own observed-state bookkeeping, undeclared.
269
- "cnrm.cloud.google.com/observed-secret-versions": '{"db-password":"1"}',
270
- // NOISE: classic `kubectl apply`'s own bookkeeping, undeclared —
271
- // pruned by the reused ownership rule with no special case needed,
272
- // because its manager ("kubectl-client-side-apply") is foreign and
273
- // chant's declared tree never carries this key.
274
- "kubectl.kubernetes.io/last-applied-configuration": "{...}",
275
- },
276
- managedFields: [
277
- {
278
- manager: "kubectl-client-side-apply",
279
- operation: "Update",
280
- fieldsV1: {
281
- "f:metadata": {
282
- "f:labels": { "f:cnrm-test": {} },
283
- "f:annotations": {
284
- "f:build-id": {},
285
- "f:kubectl.kubernetes.io/last-applied-configuration": {},
286
- },
287
- },
288
- "f:spec": { "f:location": {}, "f:storageClass": {} },
289
- },
290
- },
291
- {
292
- // The hand edit — transferred ownership of just this one label.
293
- manager: "kubectl-edit",
294
- operation: "Update",
295
- fieldsV1: { "f:metadata": { "f:labels": { "f:app": {} } } },
296
- },
297
- {
298
- // NOISE: Config Connector's own controller sets an undeclared
299
- // field and its own bookkeeping annotation.
300
- manager: "cnrm-controller-manager",
301
- operation: "Update",
302
- fieldsV1: {
303
- "f:spec": { "f:uniformBucketLevelAccess": {} },
304
- "f:metadata": { "f:annotations": { "f:cnrm.cloud.google.com/observed-secret-versions": {} } },
305
- },
306
- },
307
- {
308
- // NOISE: status is a subresource write, excluded by default.
309
- manager: "cnrm-controller-manager",
310
- operation: "Update",
311
- subresource: "status",
312
- fieldsV1: { "f:status": { "f:conditions": {} } },
313
- },
314
- ],
315
- },
316
- spec: { location: "US", storageClass: "STANDARD", uniformBucketLevelAccess: true },
317
- status: { conditions: [{ type: "Ready", status: "True" }] },
318
- };
319
-
320
- const sqlInstanceLive = {
321
- apiVersion: "sql.cnrm.cloud.google.com/v1beta1",
322
- kind: "SQLInstance",
323
- metadata: {
324
- name: "primary-db",
325
- namespace: "config-control",
326
- uid: "uid-sql",
327
- managedFields: [
328
- {
329
- // Config Connector's own reconciliation currently holds `spec.tier`
330
- // — contested because chant declares it too, regardless of who's
331
- // holding it live.
332
- manager: "cnrm-controller-manager",
333
- operation: "Update",
334
- fieldsV1: { "f:spec": { "f:tier": {} } },
335
- },
336
- ],
337
- },
338
- spec: { tier: "db-n1-standard-1" },
339
- };
340
-
341
- const cluster = () =>
342
- respondTo({
343
- "storagebucket.storage.cnrm.cloud.google.com data-bucket": bucketLive,
344
- "sqlinstance.sql.cnrm.cloud.google.com primary-db": sqlInstanceLive,
345
- });
346
-
347
- const baseline = {
348
- dataBucket: {
349
- type: "GCP::Storage::Bucket",
350
- accepted: [{ path: "metadata.annotations.build-id", value: "43" }],
351
- },
352
- };
353
-
354
- test("exactly the genuine + contested drift surfaces; controller/kubectl-apply noise and the accepted annotation do not", async () => {
355
- execMock.mockImplementation((cmd: string) => {
356
- if (cmd.includes("broken")) throw Object.assign(new Error("boom"), { stderr: "Error from server (InternalError): backend unavailable" });
357
- return cluster()(cmd);
358
- });
73
+ describe("observeResourcesDeepGcp — over REST (#1209)", () => {
74
+ beforeEach(() => {
75
+ fetchMock.mockReset();
76
+ vi.stubGlobal("fetch", fetchMock);
77
+ process.env.GOOGLE_CLOUD_PROJECT = "my-project";
78
+ process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
79
+ });
80
+ afterEach(() => {
81
+ vi.unstubAllGlobals();
82
+ delete process.env.GCP_ENDPOINT_URL;
83
+ delete process.env.GOOGLE_CLOUD_PROJECT;
84
+ });
359
85
 
360
- const live = normalizeDeepObservation(
361
- await observeResourcesDeepGcp({ environment: "prod", entityNames: [...declared.keys()], entities: declared }),
86
+ test("reads the property tree over the applier's URL, pruning server noise", async () => {
87
+ fetchMock.mockResolvedValue(
88
+ reply(200, { id: "b/my-bucket", name: "my-bucket", etag: "abc", selfLink: "https://…", storageClass: "NEARLINE", location: "US" }),
362
89
  );
363
- const result = diffDeepObservation(declared, live, gcpDeepNormalizationHooks, baseline);
364
-
365
- expect(result.drifted).toEqual([
366
- {
367
- name: "dataBucket",
368
- type: "GCP::Storage::Bucket",
369
- changes: [{ path: "metadata.labels.app", kind: "changed", declared: "web", live: "web-renamed" }],
370
- },
371
- {
372
- name: "sqlInstance",
373
- type: "GCP::SQL::Instance",
374
- changes: [{ path: "spec.tier", kind: "changed", declared: "db-f1-micro", live: "db-n1-standard-1" }],
375
- },
376
- ]);
377
-
378
- expect(result.accepted).toEqual([
379
- {
380
- name: "dataBucket",
381
- type: "GCP::Storage::Bucket",
382
- changes: [{ path: "metadata.annotations.build-id", kind: "changed", declared: "42", live: "43", baseline: "43" }],
383
- },
384
- ]);
385
-
386
- // CNRM's undeclared field, its observed-state annotation, and classic
387
- // kubectl's own bookkeeping annotation never appear at all — not as
388
- // drift, not as "undeclared" noise.
389
- const bucketDriftPaths = result.drifted.find((d) => d.name === "dataBucket")?.changes.map((c) => c.path) ?? [];
390
- expect(bucketDriftPaths).not.toContain("spec.uniformBucketLevelAccess");
391
- expect(JSON.stringify(result)).not.toContain("observed-secret-versions");
392
- expect(JSON.stringify(result)).not.toContain("last-applied-configuration");
393
-
394
- expect(result.unobserved).toEqual([
395
- { name: "broken", type: "GCP::Storage::Bucket", reason: "read-failed", detail: expect.stringContaining("InternalError") },
396
- {
397
- name: "cache",
398
- type: "AWS::ElastiCache::CacheCluster",
399
- reason: "unsupported-kind",
400
- detail: expect.stringContaining("cannot derive a Config Connector GVK"),
401
- },
402
- ]);
90
+ const out = await observe(entities([["b", "GCP::Storage::Bucket", { metadata: { name: "my-bucket" } }]]));
91
+
92
+ expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:4588/storage/v1/b/my-bucket");
93
+ const props = out.resources.b.properties as Record<string, Record<string, unknown>>;
94
+
95
+ // Reshaped into the CNRM shape the declared source is written in — without
96
+ // this every field drifts twice, once as `spec.x -> <absent>` and once as
97
+ // `x -> <undeclared>`.
98
+ expect(props.spec.storageClass).toBe("NEARLINE");
99
+ expect(props.spec.location).toBe("US");
100
+ expect(props.metadata.name).toBe("my-bucket");
101
+
102
+ // Server-assigned, pruned wherever they landed.
103
+ expect(props.spec.etag).toBeUndefined();
104
+ expect(props.spec.selfLink).toBeUndefined();
105
+ expect(props.spec.id).toBeUndefined();
403
106
  });
404
107
 
405
- test("without the baseline the annotation is drift too; accepting it is what silences it", async () => {
406
- execMock.mockImplementation((cmd: string) => {
407
- if (cmd.includes("broken")) throw Object.assign(new Error("boom"), { stderr: "Error from server (InternalError): backend unavailable" });
408
- return cluster()(cmd);
409
- });
410
- const live = normalizeDeepObservation(
411
- await observeResourcesDeepGcp({ environment: "prod", entityNames: [...declared.keys()], entities: declared }),
412
- );
413
- const result = diffDeepObservation(declared, live, gcpDeepNormalizationHooks);
414
- const bucket = result.drifted.find((d) => d.name === "dataBucket");
415
- expect(bucket?.changes.map((c) => c.path).sort()).toEqual(["metadata.annotations.build-id", "metadata.labels.app"]);
416
- expect(result.accepted).toEqual([]);
108
+ test("chant's own ownership labels are not drift", async () => {
109
+ // The applier stamps them, so reporting them back is chant showing its own
110
+ // signature to itself the correction #1301 made for AWS.
111
+ fetchMock.mockResolvedValue(reply(200, { name: "x", labels: { "managed-by": "chant", team: "data" } }));
112
+ const out = await observe(entities([["b", "GCP::Storage::Bucket", { metadata: { name: "x" } }]]));
113
+ const meta = (out.resources.b.properties as Record<string, Record<string, Record<string, unknown>>>).metadata;
114
+ expect(meta.labels["managed-by"]).toBeUndefined();
115
+ // A user's own label is still a fact worth diffing.
116
+ expect(meta.labels.team).toBe("data");
417
117
  });
418
118
 
419
- test("a whole-lexicon failure (bound-context mismatch) is a hole for every declared entity, never a clean report", async () => {
420
- loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
421
- execMock.mockImplementation((cmd: string) => {
422
- if (cmd.includes("current-context")) return { stdout: "staging-cnrm\n", stderr: "" };
423
- throw new Error(`unexpected cmd (should have refused before any kubectl get): ${cmd}`);
424
- });
119
+ test("a 404 is an absence the thin read already reported, not restated here", async () => {
120
+ fetchMock.mockResolvedValue(reply(404, {}));
121
+ const out = await observe(entities([["b", "GCP::Storage::Bucket", { metadata: { name: "gone" } }]]));
122
+ expect(out.resources.b).toBeUndefined();
123
+ expect(out.unobserved?.b).toBeUndefined();
124
+ });
425
125
 
426
- const live = await observeDeep(gcpPlugin, { environment: "prod", buildOutput: "", entities: declared });
427
- expect(live.resources).toEqual({});
428
- expect(new Set(Object.values(live.unobserved).map((u) => u.reason))).toEqual(new Set(["read-failed"]));
429
- expect(Object.keys(live.unobserved).sort()).toEqual(["broken", "cache", "dataBucket", "sqlInstance"]);
126
+ test("a 403 is a hole with a reason, never silence", async () => {
127
+ fetchMock.mockResolvedValue(reply(403, {}));
128
+ const out = await observe(entities([["b", "GCP::Storage::Bucket", { metadata: { name: "x" } }]]));
129
+ expect(out.unobserved?.b?.reason).toBe("no-credentials");
430
130
  });
431
- });
432
131
 
433
- /**
434
- * The managers-specific case: on GCP's real kubectl-shelled apply path,
435
- * chant's own writes are attributed to kubectl's own default manager
436
- * (`kubectl-client-side-apply`), never to `chant`/`chant:<stack>` — unlike
437
- * the k8s lexicon's typed-client SSA path. This proves the contested-field
438
- * rule (question 3: "is it declared?") is what keeps GCP's drift semantics
439
- * correct despite that, exactly as the module doc claims: the same live
440
- * mutation is drift when declared and silence when undeclared, regardless of
441
- * which non-chant manager holds the field.
442
- */
443
- describe("the GCP manager twist: no chant-branded field manager, and the contested rule covers it anyway (#1087)", () => {
444
- const liveWith = (manager: string, imageTag: string) => ({
445
- apiVersion: "run.cnrm.cloud.google.com/v1beta1",
446
- kind: "RunService",
447
- metadata: {
448
- name: "app",
449
- namespace: "config-control",
450
- uid: "uid-app",
451
- managedFields: [
452
- { manager, operation: "Update", fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": { 'k:{"name":"app"}': { "f:image": {} } } } } } } },
453
- ],
454
- },
455
- spec: { template: { spec: { containers: [{ name: "app", image: imageTag }] } } },
132
+ test("a kind the applier cannot write is unsupported-kind, and is never queried", async () => {
133
+ const out = await observe(entities([["e", "GCP::Compute::Address", { metadata: { name: "a" } }]]));
134
+ expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
135
+ expect(fetchMock).not.toHaveBeenCalled();
456
136
  });
457
137
 
458
- const declaredWith = (declareImage: boolean) =>
459
- makeEntities([
460
- {
461
- name: "app",
462
- entityType: "GCP::Run::Service",
463
- props: {
464
- metadata: { name: "app", namespace: "config-control" },
465
- spec: { template: { spec: { containers: declareImage ? [{ name: "app", image: "app:1.0" }] : [{ name: "app" }] } } },
466
- },
467
- },
468
- ]);
138
+ test("--owned withholds an unmarked object as filtered, not absent", async () => {
139
+ fetchMock.mockResolvedValue(reply(200, { id: "b/x", labels: { team: "other" } }));
140
+ const out = await observe(entities([["b", "GCP::Storage::Bucket", { metadata: { name: "x" } }]]), { owned: true });
141
+ expect(out.unobserved?.b?.reason).toBe("filtered");
142
+ expect(out.resources.b).toBeUndefined();
143
+ });
469
144
 
470
- test("declared: the same mutated value is drift, whether the owning manager is kubectl's default or CNRM's controller", async () => {
471
- for (const manager of ["kubectl-client-side-apply", "cnrm-controller-manager", "some-other-operator"]) {
472
- execMock.mockImplementation(respondTo({ "runservice.run.cnrm.cloud.google.com app": liveWith(manager, "app:2.0") }));
473
- const entities = declaredWith(true);
474
- const live = normalizeDeepObservation(
475
- await observeResourcesDeepGcp({ environment: "prod", entityNames: ["app"], entities }),
476
- );
477
- const result = diffDeepObservation(entities, live, gcpDeepNormalizationHooks);
478
- expect(result.drifted, `manager ${manager}`).toEqual([
479
- {
480
- name: "app",
481
- type: "GCP::Run::Service",
482
- changes: [{ path: "spec.template.spec.containers[#app].image", kind: "changed", declared: "app:1.0", live: "app:2.0" }],
483
- },
484
- ]);
485
- }
145
+ test("--owned keeps an object carrying the marker the applier stamps", async () => {
146
+ fetchMock.mockResolvedValue(reply(200, { id: "b/x", labels: { "managed-by": "chant" } }));
147
+ const out = await observe(entities([["b", "GCP::Storage::Bucket", { metadata: { name: "x" } }]]), { owned: true });
148
+ expect(out.resources.b).toBeDefined();
486
149
  });
487
150
 
488
- test("undeclared: the same mutated value is silence, not drift and not undeclared noise, whoever the manager is", async () => {
489
- for (const manager of ["kubectl-client-side-apply", "cnrm-controller-manager", "some-other-operator"]) {
490
- execMock.mockImplementation(respondTo({ "runservice.run.cnrm.cloud.google.com app": liveWith(manager, "app:2.0") }));
491
- const entities = declaredWith(false);
492
- const live = normalizeDeepObservation(
493
- await observeResourcesDeepGcp({ environment: "prod", entityNames: ["app"], entities }),
494
- );
495
- const result = diffDeepObservation(entities, live, gcpDeepNormalizationHooks);
496
- expect(result.drifted, `manager ${manager}`).toEqual([]);
497
- expect(result.unchanged, `manager ${manager}`).toEqual(["app"]);
498
- expect(JSON.stringify(result)).not.toContain("app:2.0");
499
- }
151
+ test("--owned passes through a kind whose payload has no labels to filter on", async () => {
152
+ fetchMock.mockResolvedValue(reply(200, { name: "projects/my-project/topics/t" }));
153
+ const out = await observe(entities([["t", "GCP::PubSub::Topic", { metadata: { name: "t" } }]]), { owned: true });
154
+ expect(out.resources.t).toBeDefined();
155
+ expect(out.unobserved?.t).toBeUndefined();
500
156
  });
501
157
 
502
- test("chant's own field-manager naming scheme, if it were ever used on this path, would also be recognized", async () => {
503
- // Future-proofing check: an explicit `chant`/`chant:<stack>` manager (were
504
- // gcp's apply path ever to route through server-side apply, matching the
505
- // k8s lexicon) is still classified chant-owned, not merely contested.
506
- execMock.mockImplementation(respondTo({ "runservice.run.cnrm.cloud.google.com app": liveWith("chant:crdb-gke", "app:2.0") }));
507
- const entities = declaredWith(false); // undeclared — the only way to tell "chant-owned" apart from "contested" behaviorally.
508
- const live = normalizeDeepObservation(
509
- await observeResourcesDeepGcp({ environment: "prod", entityNames: ["app"], entities }),
158
+ test("reads concurrently", async () => {
159
+ fetchMock.mockResolvedValue(reply(200, {}));
160
+ await observe(
161
+ entities([
162
+ ["a", "GCP::Storage::Bucket", { metadata: { name: "a" } }],
163
+ ["b", "GCP::Storage::Bucket", { metadata: { name: "b" } }],
164
+ ]),
510
165
  );
511
- const result = diffDeepObservation(entities, live, gcpDeepNormalizationHooks);
512
- // Chant-owned paths are never pruned by the ownership rule, even when
513
- // undeclared — so this reports as an undeclared live property, not silence.
514
- expect(result.drifted).toEqual([
515
- {
516
- name: "app",
517
- type: "GCP::Run::Service",
518
- changes: [{ path: "spec.template.spec.containers[#app].image", kind: "undeclared", live: "app:2.0" }],
519
- },
520
- ]);
166
+ expect(fetchMock).toHaveBeenCalledTimes(2);
521
167
  });
522
168
  });