@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.ts
CHANGED
|
@@ -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,
|
|
127
|
-
import {
|
|
128
|
-
import {
|
|
129
|
-
import {
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
222
|
+
return;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
const metadata = props.metadata as { name?: string;
|
|
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
|
-
|
|
233
|
+
return;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
let client: GcpReadClientOptions;
|
|
236
237
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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:
|
|
257
|
-
properties: normalizeDeepProperties(
|
|
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
|
-
|
|
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
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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("
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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("
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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("
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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("
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
expect(
|
|
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("
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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("
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
143
|
+
test("an explicit state wins", () => {
|
|
144
|
+
expect(statusFromRest({ state: "ACTIVE" })).toBe("ACTIVE");
|
|
145
|
+
});
|
|
218
146
|
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
});
|