@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.
- package/dist/api/read-client.d.ts +71 -0
- package/dist/api/read-client.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +41 -30
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +24 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +65 -39
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/floci-gcp.d.ts +12 -1
- package/dist/op/activities/floci-gcp.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/api/read-client.ts +129 -0
- package/src/deep-observe-hooks.ts +112 -60
- package/src/deep-observe.test.ts +124 -478
- package/src/deep-observe.ts +94 -74
- package/src/describe-resources.test.ts +119 -206
- package/src/describe-resources.ts +153 -111
- package/src/lifecycle-integration.test.ts +32 -18
- package/src/op/activities/floci-gcp.test.ts +1 -1
- package/src/op/activities/floci-gcp.ts +22 -5
- package/src/plugin.ts +4 -0
package/src/deep-observe.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
92
|
-
const
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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("
|
|
164
|
-
|
|
165
|
-
|
|
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("
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
364
|
-
|
|
365
|
-
expect(
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
expect(
|
|
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("
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
});
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
expect(
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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("
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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("
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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("
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
});
|