@intentius/chant-lexicon-gcp 0.14.0 → 0.15.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/integrity.json +2 -2
- package/dist/lint/audit-catalog.d.ts +7 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +278 -0
- package/dist/op/activities/gcp-apply.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +9 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +7 -2
- package/src/describe-resources.ts +3 -3
- package/src/import/live-export.ts +2 -2
- package/src/lint/audit-catalog.ts +33 -0
- package/src/op/activities/gcp-apply.test.ts +475 -0
- package/src/op/activities/gcp-apply.ts +701 -0
- package/src/op/activities/index.ts +46 -0
- package/src/plugin.ts +9 -0
- package/src/serializer.ts +2 -2
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
bucketInsertBody,
|
|
4
|
+
pubSubTopicBody,
|
|
5
|
+
cloudRunServiceBody,
|
|
6
|
+
resolveGcpProject,
|
|
7
|
+
applyResource,
|
|
8
|
+
deleteResource,
|
|
9
|
+
waitForOperation,
|
|
10
|
+
longRunningOperation,
|
|
11
|
+
parseManifest,
|
|
12
|
+
referencedNames,
|
|
13
|
+
orderByReferences,
|
|
14
|
+
pruneOrphans,
|
|
15
|
+
chantOwnershipLabels,
|
|
16
|
+
isChantOwned,
|
|
17
|
+
pubSubSubscriptionBody,
|
|
18
|
+
storageBucketMapper,
|
|
19
|
+
pubSubTopicMapper,
|
|
20
|
+
pubSubSubscriptionMapper,
|
|
21
|
+
cloudRunServiceMapper,
|
|
22
|
+
secretManagerSecretMapper,
|
|
23
|
+
gcpServiceAccountMapper,
|
|
24
|
+
MAPPERS,
|
|
25
|
+
type CnrmStorageBucket,
|
|
26
|
+
type GcpResource,
|
|
27
|
+
type GcpHttp,
|
|
28
|
+
} from "./gcp-apply";
|
|
29
|
+
|
|
30
|
+
const RUN_SERVICE: GcpResource = {
|
|
31
|
+
apiVersion: "run.cnrm.cloud.google.com/v1beta1",
|
|
32
|
+
kind: "RunService",
|
|
33
|
+
metadata: { name: "hello-svc" },
|
|
34
|
+
spec: { location: "us-central1", template: { containers: [{ image: "gcr.io/x/hello" }] } },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const BUCKET: CnrmStorageBucket = {
|
|
38
|
+
apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
|
|
39
|
+
kind: "StorageBucket",
|
|
40
|
+
metadata: {
|
|
41
|
+
name: "my-data-bucket",
|
|
42
|
+
annotations: { "cnrm.cloud.google.com/project-id": "annotated-project" },
|
|
43
|
+
},
|
|
44
|
+
spec: {
|
|
45
|
+
location: "US",
|
|
46
|
+
storageClass: "STANDARD",
|
|
47
|
+
uniformBucketLevelAccess: true,
|
|
48
|
+
versioning: { enabled: true },
|
|
49
|
+
lifecycleRule: [{ action: { type: "Delete" }, condition: { age: 365 } }],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const TOPIC: GcpResource = {
|
|
54
|
+
apiVersion: "pubsub.cnrm.cloud.google.com/v1beta1",
|
|
55
|
+
kind: "PubSubTopic",
|
|
56
|
+
metadata: { name: "events", labels: { team: "data" } },
|
|
57
|
+
spec: { messageRetentionDuration: "86400s" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe("bucketInsertBody (#711)", () => {
|
|
61
|
+
test("maps every field, renaming to the GCS insert shape", () => {
|
|
62
|
+
expect(bucketInsertBody(BUCKET)).toEqual({
|
|
63
|
+
name: "my-data-bucket",
|
|
64
|
+
location: "US",
|
|
65
|
+
storageClass: "STANDARD",
|
|
66
|
+
iamConfiguration: { uniformBucketLevelAccess: { enabled: true } },
|
|
67
|
+
versioning: { enabled: true },
|
|
68
|
+
lifecycle: { rule: [{ action: { type: "Delete" }, condition: { age: 365 } }] },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("minimal — name plus location only", () => {
|
|
73
|
+
expect(bucketInsertBody({ metadata: { name: "b" }, spec: { location: "EU" } })).toEqual({
|
|
74
|
+
name: "b",
|
|
75
|
+
location: "EU",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("throws without metadata.name", () => {
|
|
80
|
+
expect(() => bucketInsertBody({ spec: { location: "US" } })).toThrow(/metadata.name/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("pubSubTopicBody (#706)", () => {
|
|
85
|
+
test("maps labels and messageRetentionDuration; name travels in the URL", () => {
|
|
86
|
+
expect(pubSubTopicBody(TOPIC)).toEqual({
|
|
87
|
+
labels: { team: "data" },
|
|
88
|
+
messageRetentionDuration: "86400s",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("empty for a bare topic", () => {
|
|
93
|
+
expect(pubSubTopicBody({ metadata: { name: "t" } })).toEqual({});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("mappers build correct plans (#706)", () => {
|
|
98
|
+
test("StorageBucket → POST /storage/v1/b?project=", () => {
|
|
99
|
+
const plan = storageBucketMapper.plan(BUCKET, { base: "http://localhost:4588", project: "p" });
|
|
100
|
+
expect(plan.getUrl).toBe("http://localhost:4588/storage/v1/b/my-data-bucket");
|
|
101
|
+
expect(plan.create.method).toBe("POST");
|
|
102
|
+
expect(plan.create.url).toBe("http://localhost:4588/storage/v1/b?project=p");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("PubSubTopic → PUT /v1/projects/{p}/topics/{t}, same URL for GET", () => {
|
|
106
|
+
const plan = pubSubTopicMapper.plan(TOPIC, { base: "http://localhost:4588", project: "p" });
|
|
107
|
+
const url = "http://localhost:4588/v1/projects/p/topics/events";
|
|
108
|
+
expect(plan.getUrl).toBe(url);
|
|
109
|
+
expect(plan.create.method).toBe("PUT");
|
|
110
|
+
expect(plan.create.url).toBe(url);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("registry keys match each mapper's kind", () => {
|
|
114
|
+
expect(Object.keys(MAPPERS).sort()).toEqual([
|
|
115
|
+
"IAMServiceAccount",
|
|
116
|
+
"PubSubSubscription",
|
|
117
|
+
"PubSubTopic",
|
|
118
|
+
"RunService",
|
|
119
|
+
"SecretManagerSecret",
|
|
120
|
+
"StorageBucket",
|
|
121
|
+
]);
|
|
122
|
+
for (const [key, mapper] of Object.entries(MAPPERS)) expect(mapper.kind).toBe(key);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("Cloud Run mapper + LRO (#706)", () => {
|
|
127
|
+
test("cloudRunServiceBody passes the template through", () => {
|
|
128
|
+
expect(cloudRunServiceBody(RUN_SERVICE)).toEqual({
|
|
129
|
+
template: { containers: [{ image: "gcr.io/x/hello" }] },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("RunService plan → POST v2 services?serviceId, async operation present", () => {
|
|
134
|
+
const plan = cloudRunServiceMapper.plan(RUN_SERVICE, { base: "http://x", project: "p" });
|
|
135
|
+
expect(plan.getUrl).toBe("http://x/v2/projects/p/locations/us-central1/services/hello-svc");
|
|
136
|
+
expect(plan.create.method).toBe("POST");
|
|
137
|
+
expect(plan.create.url).toBe("http://x/v2/projects/p/locations/us-central1/services?serviceId=hello-svc");
|
|
138
|
+
expect(cloudRunServiceMapper.operation).toBeDefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("longRunningOperation: poll url from an operation name, undefined for a sync resource", () => {
|
|
142
|
+
const op = longRunningOperation("v2");
|
|
143
|
+
expect(op.pollUrl({ name: "projects/p/locations/l/operations/abc" }, { base: "http://x", project: "p" }))
|
|
144
|
+
.toBe("http://x/v2/projects/p/locations/l/operations/abc");
|
|
145
|
+
// A synchronous create returns the resource (no /operations/ segment) → no poll.
|
|
146
|
+
expect(op.pollUrl({ name: "projects/p/locations/l/services/hello" }, { base: "http://x", project: "p" }))
|
|
147
|
+
.toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("longRunningOperation: isDone + error extraction", () => {
|
|
151
|
+
const op = longRunningOperation("v2");
|
|
152
|
+
expect(op.isDone({ done: true })).toBe(true);
|
|
153
|
+
expect(op.isDone({ done: null })).toBe(false);
|
|
154
|
+
expect(op.error({ done: true, error: { message: "boom" } })).toBe("boom");
|
|
155
|
+
expect(op.error({ done: true })).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("waitForOperation polls until done", async () => {
|
|
159
|
+
let polls = 0;
|
|
160
|
+
const http: GcpHttp = async () => {
|
|
161
|
+
polls++;
|
|
162
|
+
return { status: 200, text: JSON.stringify({ done: polls >= 2 }) };
|
|
163
|
+
};
|
|
164
|
+
await waitForOperation(longRunningOperation("v2"), "http://x/v2/op", http, undefined, { intervalMs: 1 });
|
|
165
|
+
expect(polls).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("waitForOperation throws on operation error", async () => {
|
|
169
|
+
const http: GcpHttp = async () => ({ status: 200, text: JSON.stringify({ done: true, error: { message: "denied" } }) });
|
|
170
|
+
await expect(
|
|
171
|
+
waitForOperation(longRunningOperation("v2"), "http://x/v2/op", http, undefined, { intervalMs: 1 }),
|
|
172
|
+
).rejects.toThrow(/operation failed: denied/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("deleteResource: async delete polls the operation to done", async () => {
|
|
176
|
+
const calls: string[] = [];
|
|
177
|
+
const http: GcpHttp = async (method, url) => {
|
|
178
|
+
calls.push(`${method} ${url.includes("/operations/") ? "op" : "svc"}`);
|
|
179
|
+
if (method === "GET" && url.includes("/operations/")) return { status: 200, text: JSON.stringify({ done: true }) };
|
|
180
|
+
// DELETE → returns an operation
|
|
181
|
+
return { status: 200, text: JSON.stringify({ name: "projects/p/locations/us-central1/operations/del" }) };
|
|
182
|
+
};
|
|
183
|
+
const res = await deleteResource(cloudRunServiceMapper, RUN_SERVICE, { base: "http://x", project: "p" }, http);
|
|
184
|
+
expect(res).toEqual({ kind: "RunService", name: "hello-svc", deleted: true });
|
|
185
|
+
expect(calls).toEqual(["DELETE svc", "GET op"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("applyResource: async create → polls the operation to done", async () => {
|
|
189
|
+
const calls: string[] = [];
|
|
190
|
+
const http: GcpHttp = async (method, url) => {
|
|
191
|
+
calls.push(`${method} ${url.includes("/operations/") ? "op" : url.includes("?serviceId") ? "create" : "get"}`);
|
|
192
|
+
if (method === "GET" && url.includes("/operations/")) return { status: 200, text: JSON.stringify({ done: true }) };
|
|
193
|
+
if (method === "GET") return { status: 404, text: "" }; // service not there yet
|
|
194
|
+
// create → return an operation
|
|
195
|
+
return { status: 200, text: JSON.stringify({ name: "projects/p/locations/us-central1/operations/xyz" }) };
|
|
196
|
+
};
|
|
197
|
+
const res = await applyResource(cloudRunServiceMapper, RUN_SERVICE, { base: "http://x", project: "p" }, http);
|
|
198
|
+
expect(res).toEqual({ kind: "RunService", name: "hello-svc", created: true, updated: false });
|
|
199
|
+
expect(calls).toEqual(["GET get", "POST create", "GET op"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("applyResource: async update (existing) → PATCH then polls the operation", async () => {
|
|
203
|
+
const calls: string[] = [];
|
|
204
|
+
const http: GcpHttp = async (method, url) => {
|
|
205
|
+
calls.push(`${method} ${url.includes("/operations/") ? "op" : "svc"}`);
|
|
206
|
+
if (method === "GET" && url.includes("/operations/")) return { status: 200, text: JSON.stringify({ done: true }) };
|
|
207
|
+
if (method === "GET") return { status: 200, text: "{}" }; // service exists → reconcile
|
|
208
|
+
// PATCH → return an operation
|
|
209
|
+
return { status: 200, text: JSON.stringify({ name: "projects/p/locations/us-central1/operations/upd" }) };
|
|
210
|
+
};
|
|
211
|
+
const res = await applyResource(cloudRunServiceMapper, RUN_SERVICE, { base: "http://x", project: "p" }, http);
|
|
212
|
+
expect(res).toEqual({ kind: "RunService", name: "hello-svc", created: false, updated: true });
|
|
213
|
+
expect(calls).toEqual(["GET svc", "PATCH svc", "GET op"]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("resolveGcpProject (#711)", () => {
|
|
218
|
+
test("env wins over the annotation", () => {
|
|
219
|
+
expect(resolveGcpProject(BUCKET, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv))
|
|
220
|
+
.toBe("env-project");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("falls back to the CNRM annotation", () => {
|
|
224
|
+
expect(resolveGcpProject(BUCKET, {} as NodeJS.ProcessEnv)).toBe("annotated-project");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("throws when neither is present", () => {
|
|
228
|
+
expect(() => resolveGcpProject({ metadata: { name: "b" } }, {} as NodeJS.ProcessEnv))
|
|
229
|
+
.toThrow(/no GCP project/);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("applyResource create/update (#706)", () => {
|
|
234
|
+
function recorder(getStatus: number): { http: GcpHttp; calls: Array<{ method: string; url: string }> } {
|
|
235
|
+
const calls: Array<{ method: string; url: string }> = [];
|
|
236
|
+
const http: GcpHttp = async (method, url) => {
|
|
237
|
+
calls.push({ method, url });
|
|
238
|
+
return method === "GET" ? { status: getStatus, text: "" } : { status: 200, text: "{}" };
|
|
239
|
+
};
|
|
240
|
+
return { http, calls };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test("absent (GET 404) → create (POST)", async () => {
|
|
244
|
+
const { http, calls } = recorder(404);
|
|
245
|
+
const res = await applyResource(storageBucketMapper, BUCKET, { base: "http://x", project: "p" }, http);
|
|
246
|
+
expect(res).toEqual({ kind: "StorageBucket", name: "my-data-bucket", created: true, updated: false });
|
|
247
|
+
expect(calls.map((c) => c.method)).toEqual(["GET", "POST"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("existing (GET 200) with update support → reconcile (PATCH)", async () => {
|
|
251
|
+
const { http, calls } = recorder(200);
|
|
252
|
+
const res = await applyResource(storageBucketMapper, BUCKET, { base: "http://x", project: "p" }, http);
|
|
253
|
+
expect(res).toEqual({ kind: "StorageBucket", name: "my-data-bucket", created: false, updated: true });
|
|
254
|
+
expect(calls.map((c) => c.method)).toEqual(["GET", "PATCH"]);
|
|
255
|
+
// name is immutable — the PATCH body omits it.
|
|
256
|
+
expect(JSON.parse(String((await recorderPatchBody(BUCKET)))).name).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("existing (GET 200) without update support (subscription) → left unchanged", async () => {
|
|
260
|
+
const sub: GcpResource = {
|
|
261
|
+
kind: "PubSubSubscription",
|
|
262
|
+
metadata: { name: "s" },
|
|
263
|
+
spec: { topicRef: { name: "events" } },
|
|
264
|
+
};
|
|
265
|
+
const { http, calls } = recorder(200);
|
|
266
|
+
const res = await applyResource(pubSubSubscriptionMapper, sub, { base: "http://x", project: "p" }, http);
|
|
267
|
+
expect(res).toEqual({ kind: "PubSubSubscription", name: "s", created: false, updated: false });
|
|
268
|
+
expect(calls.map((c) => c.method)).toEqual(["GET"]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("PubSubTopic update uses the {topic, updateMask} envelope", async () => {
|
|
272
|
+
let patchBody: unknown;
|
|
273
|
+
const http: GcpHttp = async (method, _url, body) => {
|
|
274
|
+
if (method === "PATCH") patchBody = body;
|
|
275
|
+
return { status: 200, text: "{}" };
|
|
276
|
+
};
|
|
277
|
+
const res = await applyResource(pubSubTopicMapper, TOPIC, { base: "http://x", project: "p" }, http);
|
|
278
|
+
expect(res).toEqual({ kind: "PubSubTopic", name: "events", created: false, updated: true });
|
|
279
|
+
const b = patchBody as { topic: { name: string; labels: Record<string, string> }; updateMask: string };
|
|
280
|
+
expect(b.topic.name).toBe("projects/p/topics/events");
|
|
281
|
+
expect(b.topic.labels["managed-by"]).toBe("chant");
|
|
282
|
+
expect(b.updateMask.split(",")).toContain("labels");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("create failure surfaces kind + status", async () => {
|
|
286
|
+
const http: GcpHttp = async (method) =>
|
|
287
|
+
method === "GET" ? { status: 404, text: "" } : { status: 403, text: "denied" };
|
|
288
|
+
await expect(
|
|
289
|
+
applyResource(storageBucketMapper, BUCKET, { base: "http://x", project: "p" }, http),
|
|
290
|
+
).rejects.toThrow(/StorageBucket my-data-bucket create failed \(403\)/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("update failure surfaces kind + status", async () => {
|
|
294
|
+
const http: GcpHttp = async (method) =>
|
|
295
|
+
method === "GET" ? { status: 200, text: "" } : { status: 409, text: "conflict" };
|
|
296
|
+
await expect(
|
|
297
|
+
applyResource(storageBucketMapper, BUCKET, { base: "http://x", project: "p" }, http),
|
|
298
|
+
).rejects.toThrow(/StorageBucket my-data-bucket update failed \(409\)/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
async function recorderPatchBody(res: CnrmStorageBucket): Promise<string> {
|
|
302
|
+
let patchBody = "{}";
|
|
303
|
+
const http: GcpHttp = async (method, _url, body) => {
|
|
304
|
+
if (method === "PATCH") patchBody = JSON.stringify(body);
|
|
305
|
+
return { status: 200, text: "" };
|
|
306
|
+
};
|
|
307
|
+
await applyResource(storageBucketMapper, res, { base: "http://x", project: "p" }, http);
|
|
308
|
+
return patchBody;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("deleteResource (#706)", () => {
|
|
313
|
+
test("sync delete: DELETE the resource URL → deleted", async () => {
|
|
314
|
+
const calls: Array<{ method: string; url: string }> = [];
|
|
315
|
+
const http: GcpHttp = async (method, url) => {
|
|
316
|
+
calls.push({ method, url });
|
|
317
|
+
return { status: 200, text: "" };
|
|
318
|
+
};
|
|
319
|
+
const res = await deleteResource(storageBucketMapper, BUCKET, { base: "http://x", project: "p" }, http);
|
|
320
|
+
expect(res).toEqual({ kind: "StorageBucket", name: "my-data-bucket", deleted: true });
|
|
321
|
+
expect(calls).toEqual([{ method: "DELETE", url: "http://x/storage/v1/b/my-data-bucket" }]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("already-absent (DELETE 404) → deleted:false, idempotent", async () => {
|
|
325
|
+
const http: GcpHttp = async () => ({ status: 404, text: "" });
|
|
326
|
+
const res = await deleteResource(pubSubTopicMapper, TOPIC, { base: "http://x", project: "p" }, http);
|
|
327
|
+
expect(res).toEqual({ kind: "PubSubTopic", name: "events", deleted: false });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("delete failure surfaces kind + status", async () => {
|
|
331
|
+
const http: GcpHttp = async () => ({ status: 409, text: "in use" });
|
|
332
|
+
await expect(
|
|
333
|
+
deleteResource(storageBucketMapper, BUCKET, { base: "http://x", project: "p" }, http),
|
|
334
|
+
).rejects.toThrow(/StorageBucket my-data-bucket delete failed \(409\)/);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("Secret Manager + IAM service account mappers (#706)", () => {
|
|
339
|
+
test("secret: POST ?secretId, default automatic replication, stamped, PATCH updateMask=labels", () => {
|
|
340
|
+
const secret: GcpResource = { kind: "SecretManagerSecret", metadata: { name: "api-key" }, spec: {} };
|
|
341
|
+
const plan = secretManagerSecretMapper.plan(secret, { base: "http://x", project: "p" });
|
|
342
|
+
expect(plan.getUrl).toBe("http://x/v1/projects/p/secrets/api-key");
|
|
343
|
+
expect(plan.create.url).toBe("http://x/v1/projects/p/secrets?secretId=api-key");
|
|
344
|
+
expect((plan.create.body as { replication: unknown; labels: Record<string, string> }).replication).toEqual({ automatic: {} });
|
|
345
|
+
expect((plan.create.body as { labels: Record<string, string> }).labels["managed-by"]).toBe("chant");
|
|
346
|
+
expect(plan.update?.url).toBe("http://x/v1/projects/p/secrets/api-key?updateMask=labels");
|
|
347
|
+
expect(secretManagerSecretMapper.list).toBeDefined();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("service account: POST serviceAccounts, GET by derived email, no prune/stamp", () => {
|
|
351
|
+
const sa: GcpResource = { kind: "IAMServiceAccount", metadata: { name: "deployer" }, spec: { displayName: "Deployer" } };
|
|
352
|
+
const plan = gcpServiceAccountMapper.plan(sa, { base: "http://x", project: "p" });
|
|
353
|
+
expect(plan.getUrl).toBe("http://x/v1/projects/p/serviceAccounts/deployer@p.iam.gserviceaccount.com");
|
|
354
|
+
expect(plan.create.url).toBe("http://x/v1/projects/p/serviceAccounts");
|
|
355
|
+
expect(plan.create.body).toEqual({ accountId: "deployer", serviceAccount: { displayName: "Deployer" } });
|
|
356
|
+
expect(gcpServiceAccountMapper.list).toBeUndefined(); // no labels → not pruned
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("references + ordering (#706)", () => {
|
|
361
|
+
const topic: GcpResource = { kind: "PubSubTopic", metadata: { name: "orders" } };
|
|
362
|
+
const sub: GcpResource = {
|
|
363
|
+
kind: "PubSubSubscription",
|
|
364
|
+
metadata: { name: "orders-sub" },
|
|
365
|
+
spec: { topicRef: { name: "orders" }, ackDeadlineSeconds: 20 },
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
test("referencedNames pulls local *Ref names, ignores external", () => {
|
|
369
|
+
expect(referencedNames(sub)).toEqual(["orders"]);
|
|
370
|
+
expect(referencedNames({ spec: { topicRef: { external: "projects/p/topics/x" } } })).toEqual([]);
|
|
371
|
+
expect(referencedNames(topic)).toEqual([]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("referencedNames handles *Refs arrays and nesting", () => {
|
|
375
|
+
const r: GcpResource = {
|
|
376
|
+
spec: { config: { subnetworkRefs: [{ name: "a" }, { name: "b" }], networkRef: { name: "a" } } },
|
|
377
|
+
};
|
|
378
|
+
expect(referencedNames(r).sort()).toEqual(["a", "b"]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("orderByReferences puts a referenced resource before its referrer", () => {
|
|
382
|
+
// Manifest lists the subscription first (wrong order); ordering fixes it.
|
|
383
|
+
const ordered = orderByReferences([sub, topic]);
|
|
384
|
+
expect(ordered.map((r) => r.metadata?.name)).toEqual(["orders", "orders-sub"]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("orderByReferences is stable for independent resources", () => {
|
|
388
|
+
const a: GcpResource = { kind: "PubSubTopic", metadata: { name: "a" } };
|
|
389
|
+
const b: GcpResource = { kind: "PubSubTopic", metadata: { name: "b" } };
|
|
390
|
+
expect(orderByReferences([a, b]).map((r) => r.metadata?.name)).toEqual(["a", "b"]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("orderByReferences throws on a cycle", () => {
|
|
394
|
+
const x: GcpResource = { kind: "K", metadata: { name: "x" }, spec: { yRef: { name: "y" } } };
|
|
395
|
+
const y: GcpResource = { kind: "K", metadata: { name: "y" }, spec: { xRef: { name: "x" } } };
|
|
396
|
+
expect(() => orderByReferences([x, y])).toThrow(/reference cycle/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("pubSubSubscriptionBody resolves topicRef.name to a full topic path", () => {
|
|
400
|
+
expect(pubSubSubscriptionBody(sub, "floci-local")).toEqual({
|
|
401
|
+
topic: "projects/floci-local/topics/orders",
|
|
402
|
+
ackDeadlineSeconds: 20,
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("subscription mapper plan → PUT the subscription URL", () => {
|
|
407
|
+
const plan = pubSubSubscriptionMapper.plan(sub, { base: "http://x", project: "p" });
|
|
408
|
+
expect(plan.getUrl).toBe("http://x/v1/projects/p/subscriptions/orders-sub");
|
|
409
|
+
expect(plan.create.method).toBe("PUT");
|
|
410
|
+
expect((plan.create.body as { topic: string }).topic).toBe("projects/p/topics/orders");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("ownership + prune (#706)", () => {
|
|
415
|
+
test("chantOwnershipLabels / isChantOwned", () => {
|
|
416
|
+
expect(chantOwnershipLabels()).toEqual({ "managed-by": "chant" });
|
|
417
|
+
expect(isChantOwned({ "managed-by": "chant" })).toBe(true);
|
|
418
|
+
expect(isChantOwned({ "managed-by": "other" })).toBe(false);
|
|
419
|
+
expect(isChantOwned(null)).toBe(false);
|
|
420
|
+
expect(isChantOwned(undefined)).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("create bodies are stamped with the ownership label", () => {
|
|
424
|
+
const plan = storageBucketMapper.plan(BUCKET, { base: "http://x", project: "p" });
|
|
425
|
+
expect((plan.create.body as { labels: Record<string, string> }).labels["managed-by"]).toBe("chant");
|
|
426
|
+
const topicPlan = pubSubTopicMapper.plan(TOPIC, { base: "http://x", project: "p" });
|
|
427
|
+
expect((topicPlan.create.body as { labels: Record<string, string> }).labels["managed-by"]).toBe("chant");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("list specs read live items (bucket items, topic name from path)", () => {
|
|
431
|
+
expect(storageBucketMapper.list?.url({ base: "http://x", project: "p" })).toBe("http://x/storage/v1/b?project=p");
|
|
432
|
+
expect(storageBucketMapper.list?.items({ items: [{ name: "b", labels: null }] })).toEqual([{ name: "b", labels: null }]);
|
|
433
|
+
expect(pubSubTopicMapper.list?.items({ topics: [{ name: "projects/p/topics/orders", labels: { "managed-by": "chant" } }] }))
|
|
434
|
+
.toEqual([{ name: "orders", labels: { "managed-by": "chant" } }]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("pruneOrphans deletes chant-owned resources absent from the manifest, leaves foreign", async () => {
|
|
438
|
+
const desired: GcpResource[] = [{ kind: "StorageBucket", metadata: { name: "keep" } }];
|
|
439
|
+
const calls: Array<{ method: string; url: string }> = [];
|
|
440
|
+
const http: GcpHttp = async (method, url) => {
|
|
441
|
+
calls.push({ method, url });
|
|
442
|
+
if (method === "GET") {
|
|
443
|
+
return {
|
|
444
|
+
status: 200,
|
|
445
|
+
text: JSON.stringify({
|
|
446
|
+
items: [
|
|
447
|
+
{ name: "keep", labels: { "managed-by": "chant" } },
|
|
448
|
+
{ name: "orphan", labels: { "managed-by": "chant" } },
|
|
449
|
+
{ name: "foreign", labels: null },
|
|
450
|
+
],
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return { status: 200, text: "" }; // DELETE
|
|
455
|
+
};
|
|
456
|
+
const resolve = () => ({ base: "http://x", project: "p" });
|
|
457
|
+
const pruned = await pruneOrphans(desired, resolve, http);
|
|
458
|
+
expect(pruned).toEqual([{ kind: "StorageBucket", name: "orphan", deleted: true }]);
|
|
459
|
+
expect(calls.filter((c) => c.method === "DELETE")).toEqual([
|
|
460
|
+
{ method: "DELETE", url: "http://x/storage/v1/b/orphan" },
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("parseManifest (#711)", () => {
|
|
466
|
+
test("JSON array", () => {
|
|
467
|
+
expect(parseManifest('[{"kind":"StorageBucket"}]', "x.json")).toHaveLength(1);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("YAML multi-doc splits on ---", () => {
|
|
471
|
+
const yaml = "kind: StorageBucket\nmetadata:\n name: a\n---\nkind: PubSubTopic\nmetadata:\n name: b\n";
|
|
472
|
+
const docs = parseManifest(yaml, "x.yaml");
|
|
473
|
+
expect(docs.map((d) => d.kind)).toEqual(["StorageBucket", "PubSubTopic"]);
|
|
474
|
+
});
|
|
475
|
+
});
|