@intentius/chant-lexicon-gcp 0.124.34 → 0.124.36
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 +8 -3
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +37 -15
- package/dist/composites/cloud-run-service.d.ts +2 -0
- package/dist/composites/cloud-run-service.d.ts.map +1 -1
- package/dist/composites/cloud-run-service.js +4 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +176 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +62 -18
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +128 -16
- package/dist/op/activities/gcp-compute.d.ts +66 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -0
- package/dist/op/activities/gcp-compute.js +349 -0
- package/dist/op/activities/gcp-iam.d.ts +82 -0
- package/dist/op/activities/gcp-iam.d.ts.map +1 -0
- package/dist/op/activities/gcp-iam.js +215 -0
- package/dist/serializer.d.ts +6 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +21 -9
- package/package.json +2 -2
- package/src/api/read-client.ts +36 -16
- package/src/composites/cloud-run-service.ts +6 -0
- package/src/composites/composites.test.ts +7 -0
- package/src/deep-observe.test.ts +42 -1
- package/src/deep-observe.ts +171 -0
- package/src/describe-resources.test.ts +1 -1
- package/src/op/activities/gcp-apply.test.ts +75 -0
- package/src/op/activities/gcp-apply.ts +167 -22
- package/src/op/activities/gcp-compute.test.ts +306 -0
- package/src/op/activities/gcp-compute.ts +354 -0
- package/src/op/activities/gcp-iam.test.ts +152 -0
- package/src/op/activities/gcp-iam.ts +284 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +22 -9
package/src/deep-observe.ts
CHANGED
|
@@ -182,9 +182,180 @@ const CNRM_REFINERS: Record<string, (tree: CnrmTree, ctx: RefineContext) => void
|
|
|
182
182
|
// name. The declared side writes it as `location`.
|
|
183
183
|
const m = ctx.restName ? /\/locations\/([^/]+)\//.exec(ctx.restName) : null;
|
|
184
184
|
if (m) tree.location = m[1];
|
|
185
|
+
// #4240: v2 returns the account as an email; the declared side writes a
|
|
186
|
+
// `serviceAccountRef`, `name` for an account in the project.
|
|
187
|
+
const template = tree.template as Record<string, unknown> | undefined;
|
|
188
|
+
if (template && typeof template.serviceAccount === "string") {
|
|
189
|
+
const email = template.serviceAccount;
|
|
190
|
+
const suffix = `@${ctx.project}.iam.gserviceaccount.com`;
|
|
191
|
+
template.serviceAccountRef = email.endsWith(suffix) ? { name: email.slice(0, -suffix.length) } : { external: email };
|
|
192
|
+
delete template.serviceAccount;
|
|
193
|
+
}
|
|
194
|
+
// #4249: Direct VPC egress paths back to CNRM's refs, `name` for the
|
|
195
|
+
// project's own network and subnetwork, `external` otherwise.
|
|
196
|
+
const vpcAccess = template?.vpcAccess as { networkInterfaces?: unknown } | undefined;
|
|
197
|
+
if (vpcAccess && Array.isArray(vpcAccess.networkInterfaces)) {
|
|
198
|
+
const own = (value: string, pattern: RegExp): { name: string } | { external: string } => {
|
|
199
|
+
if (!value.includes("/")) return { name: value };
|
|
200
|
+
const hit = pattern.exec(value);
|
|
201
|
+
return hit && hit[1] === ctx.project ? { name: hit[2] } : { external: value };
|
|
202
|
+
};
|
|
203
|
+
vpcAccess.networkInterfaces = (vpcAccess.networkInterfaces as Array<Record<string, unknown>>).map((i) => {
|
|
204
|
+
const { network, subnetwork, ...rest } = i;
|
|
205
|
+
return {
|
|
206
|
+
...rest,
|
|
207
|
+
...(typeof network === "string" ? { networkRef: own(network, /^projects\/([^/]+)\/global\/networks\/([^/]+)$/) } : {}),
|
|
208
|
+
...(typeof subnetwork === "string" ? { subnetworkRef: own(subnetwork, /^projects\/([^/]+)\/regions\/[^/]+\/subnetworks\/([^/]+)$/) } : {}),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
// #4233: the inverse of gcp-compute.ts's body builders. Compute returns refs
|
|
214
|
+
// as full URLs and a region as a URL; CNRM writes `*Ref: { name }` and a
|
|
215
|
+
// bare `location` (`"global"` when the resource has no region).
|
|
216
|
+
IAMPolicyMember(tree) {
|
|
217
|
+
delete tree.etag;
|
|
218
|
+
delete tree.resource;
|
|
219
|
+
},
|
|
220
|
+
ComputeAddress(tree) {
|
|
221
|
+
computeLocation(tree);
|
|
222
|
+
},
|
|
223
|
+
ComputeRegionNetworkEndpointGroup(tree) {
|
|
224
|
+
if (typeof tree.region === "string") tree.region = lastSegment(tree.region);
|
|
225
|
+
const cloudRun = tree.cloudRun as Record<string, unknown> | undefined;
|
|
226
|
+
if (cloudRun && typeof cloudRun.service === "string") {
|
|
227
|
+
cloudRun.serviceRef = { name: cloudRun.service };
|
|
228
|
+
delete cloudRun.service;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
ComputeBackendService(tree) {
|
|
232
|
+
computeLocation(tree);
|
|
233
|
+
if (tree.enableCDN !== undefined) {
|
|
234
|
+
tree.enableCdn = tree.enableCDN;
|
|
235
|
+
delete tree.enableCDN;
|
|
236
|
+
}
|
|
237
|
+
const draining = tree.connectionDraining as { drainingTimeoutSec?: unknown } | undefined;
|
|
238
|
+
if (draining) {
|
|
239
|
+
if (draining.drainingTimeoutSec !== undefined) tree.connectionDrainingTimeoutSec = draining.drainingTimeoutSec;
|
|
240
|
+
delete tree.connectionDraining;
|
|
241
|
+
}
|
|
242
|
+
if (Array.isArray(tree.backends)) {
|
|
243
|
+
tree.backend = (tree.backends as Array<Record<string, unknown>>).map((b) => {
|
|
244
|
+
const { group, ...rest } = b;
|
|
245
|
+
return { ...rest, ...(typeof group === "string" ? { group: refOf(group) } : {}) };
|
|
246
|
+
});
|
|
247
|
+
delete tree.backends;
|
|
248
|
+
}
|
|
249
|
+
if (Array.isArray(tree.healthChecks)) {
|
|
250
|
+
tree.healthChecks = (tree.healthChecks as unknown[]).map((h) => (typeof h === "string" ? { healthCheckRef: { name: lastSegment(h) } } : h));
|
|
251
|
+
}
|
|
252
|
+
if (typeof tree.securityPolicy === "string") {
|
|
253
|
+
tree.securityPolicyRef = { name: lastSegment(tree.securityPolicy) };
|
|
254
|
+
delete tree.securityPolicy;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
ComputeURLMap(tree) {
|
|
258
|
+
computeLocation(tree);
|
|
259
|
+
if (typeof tree.defaultService === "string") tree.defaultService = refOf(tree.defaultService);
|
|
260
|
+
if (Array.isArray(tree.hostRules)) {
|
|
261
|
+
tree.hostRule = tree.hostRules;
|
|
262
|
+
delete tree.hostRules;
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(tree.pathMatchers)) {
|
|
265
|
+
tree.pathMatcher = (tree.pathMatchers as Array<Record<string, unknown>>).map((m) => {
|
|
266
|
+
const { defaultService, pathRules, ...rest } = m;
|
|
267
|
+
return {
|
|
268
|
+
...rest,
|
|
269
|
+
...(typeof defaultService === "string" ? { defaultService: refOf(defaultService) } : {}),
|
|
270
|
+
...(Array.isArray(pathRules)
|
|
271
|
+
? {
|
|
272
|
+
pathRule: (pathRules as Array<Record<string, unknown>>).map(({ service, ...r }) => ({
|
|
273
|
+
...r,
|
|
274
|
+
...(typeof service === "string" ? { service: refOf(service) } : {}),
|
|
275
|
+
})),
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
delete tree.pathMatchers;
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
ComputeTargetHTTPProxy(tree) {
|
|
284
|
+
computeLocation(tree);
|
|
285
|
+
if (typeof tree.urlMap === "string") {
|
|
286
|
+
tree.urlMapRef = { name: lastSegment(tree.urlMap) };
|
|
287
|
+
delete tree.urlMap;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
// #4248: a network is global and has no location; compute nests the routing
|
|
291
|
+
// mode and lists the subnetworks it holds, which CNRM declares separately.
|
|
292
|
+
ComputeNetwork(tree) {
|
|
293
|
+
const routing = tree.routingConfig as { routingMode?: unknown } | undefined;
|
|
294
|
+
if (routing) {
|
|
295
|
+
if (routing.routingMode !== undefined) tree.routingMode = routing.routingMode;
|
|
296
|
+
delete tree.routingConfig;
|
|
297
|
+
}
|
|
298
|
+
delete tree.subnetworks;
|
|
299
|
+
delete tree.gatewayIPv4;
|
|
300
|
+
},
|
|
301
|
+
ComputeSubnetwork(tree) {
|
|
302
|
+
if (typeof tree.region === "string") tree.region = lastSegment(tree.region);
|
|
303
|
+
if (typeof tree.network === "string") {
|
|
304
|
+
tree.networkRef = { name: lastSegment(tree.network) };
|
|
305
|
+
delete tree.network;
|
|
306
|
+
}
|
|
307
|
+
if (Array.isArray(tree.secondaryIpRanges)) {
|
|
308
|
+
tree.secondaryIpRange = tree.secondaryIpRanges;
|
|
309
|
+
delete tree.secondaryIpRanges;
|
|
310
|
+
}
|
|
311
|
+
const log = tree.logConfig as { enable?: unknown } | undefined;
|
|
312
|
+
if (log) {
|
|
313
|
+
if (log.enable === false) delete tree.logConfig;
|
|
314
|
+
else delete log.enable;
|
|
315
|
+
}
|
|
316
|
+
delete tree.gatewayAddress;
|
|
317
|
+
delete tree.fingerprint;
|
|
318
|
+
},
|
|
319
|
+
ComputeForwardingRule(tree) {
|
|
320
|
+
computeLocation(tree);
|
|
321
|
+
if (tree.IPProtocol !== undefined) {
|
|
322
|
+
tree.ipProtocol = tree.IPProtocol;
|
|
323
|
+
delete tree.IPProtocol;
|
|
324
|
+
}
|
|
325
|
+
if (typeof tree.target === "string") tree.target = refOf(tree.target);
|
|
326
|
+
if (typeof tree.backendService === "string") {
|
|
327
|
+
tree.backendServiceRef = { name: lastSegment(tree.backendService) };
|
|
328
|
+
delete tree.backendService;
|
|
329
|
+
}
|
|
185
330
|
},
|
|
186
331
|
};
|
|
187
332
|
|
|
333
|
+
function lastSegment(url: string): string {
|
|
334
|
+
return url.slice(url.lastIndexOf("/") + 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Compute's `region` URL as CNRM's `location`; no region means a global resource. */
|
|
338
|
+
function computeLocation(tree: CnrmTree): void {
|
|
339
|
+
tree.location = typeof tree.region === "string" ? lastSegment(tree.region) : "global";
|
|
340
|
+
delete tree.region;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The `*Ref` a compute URL names, by its collection (`.../backendServices/web` -> `{ backendServiceRef: { name: "web" } }`). */
|
|
344
|
+
const REF_KEY_BY_COLLECTION: Record<string, string> = {
|
|
345
|
+
networkEndpointGroups: "networkEndpointGroupRef",
|
|
346
|
+
backendServices: "backendServiceRef",
|
|
347
|
+
backendBuckets: "backendBucketRef",
|
|
348
|
+
targetHttpProxies: "targetHTTPProxyRef",
|
|
349
|
+
targetHttpsProxies: "targetHTTPSProxyRef",
|
|
350
|
+
instanceGroups: "instanceGroupRef",
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
function refOf(url: string): unknown {
|
|
354
|
+
const parts = url.split("/");
|
|
355
|
+
const key = REF_KEY_BY_COLLECTION[parts[parts.length - 2] ?? ""];
|
|
356
|
+
return key ? { [key]: { name: parts[parts.length - 1] } } : url;
|
|
357
|
+
}
|
|
358
|
+
|
|
188
359
|
/**
|
|
189
360
|
* A REST payload as a CNRM-shaped property tree, in the declared side's
|
|
190
361
|
* vocabulary. The generic reshape runs first, then the full resource path is
|
|
@@ -80,7 +80,7 @@ describe("gcp describeResources — direct REST (#1209)", () => {
|
|
|
80
80
|
|
|
81
81
|
test("a kind the applier has no mapper for is unsupported-kind, not absent", async () => {
|
|
82
82
|
const out = await read(
|
|
83
|
-
makeEntities([{ name: "e", entityType: "GCP::Compute::
|
|
83
|
+
makeEntities([{ name: "e", entityType: "GCP::Compute::Firewall", props: { metadata: { name: "net" } } }]),
|
|
84
84
|
);
|
|
85
85
|
expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
|
|
86
86
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
bucketInsertBody,
|
|
6
6
|
pubSubTopicBody,
|
|
7
7
|
cloudRunServiceBody,
|
|
8
|
+
networkInterface,
|
|
9
|
+
serviceAccountEmail,
|
|
8
10
|
resolveGcpProject,
|
|
9
11
|
resolveGcpEndpoint,
|
|
10
12
|
applyResource,
|
|
@@ -118,6 +120,15 @@ describe("mappers build correct plans (#706)", () => {
|
|
|
118
120
|
|
|
119
121
|
test("registry keys match each mapper's kind", () => {
|
|
120
122
|
expect(Object.keys(MAPPERS).sort()).toEqual([
|
|
123
|
+
"ComputeAddress",
|
|
124
|
+
"ComputeBackendService",
|
|
125
|
+
"ComputeForwardingRule",
|
|
126
|
+
"ComputeNetwork",
|
|
127
|
+
"ComputeRegionNetworkEndpointGroup",
|
|
128
|
+
"ComputeSubnetwork",
|
|
129
|
+
"ComputeTargetHTTPProxy",
|
|
130
|
+
"ComputeURLMap",
|
|
131
|
+
"IAMPolicyMember",
|
|
121
132
|
"IAMServiceAccount",
|
|
122
133
|
"PubSubSubscription",
|
|
123
134
|
"PubSubTopic",
|
|
@@ -136,6 +147,70 @@ describe("Cloud Run mapper + LRO (#706)", () => {
|
|
|
136
147
|
});
|
|
137
148
|
});
|
|
138
149
|
|
|
150
|
+
test("cloudRunServiceBody sends serviceAccountRef as template.serviceAccount (#4240)", () => {
|
|
151
|
+
const withRef = (ref: Record<string, string>) => ({
|
|
152
|
+
...RUN_SERVICE,
|
|
153
|
+
spec: { ...(RUN_SERVICE.spec as object), template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccountRef: ref } },
|
|
154
|
+
});
|
|
155
|
+
expect(cloudRunServiceBody(withRef({ name: "run-sa" }), "p")).toEqual({
|
|
156
|
+
template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccount: "run-sa@p.iam.gserviceaccount.com" },
|
|
157
|
+
});
|
|
158
|
+
expect(cloudRunServiceBody(withRef({ external: "x@other.iam.gserviceaccount.com" }), "p")).toEqual({
|
|
159
|
+
template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccount: "x@other.iam.gserviceaccount.com" },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("serviceAccountEmail: external as given or from a path, name in the project", () => {
|
|
164
|
+
expect(serviceAccountEmail({ external: "projects/o/serviceAccounts/x@o.iam.gserviceaccount.com" })).toBe("x@o.iam.gserviceaccount.com");
|
|
165
|
+
expect(serviceAccountEmail({ name: "run-sa" }, "p")).toBe("run-sa@p.iam.gserviceaccount.com");
|
|
166
|
+
expect(serviceAccountEmail(undefined, "p")).toBeUndefined();
|
|
167
|
+
expect(() => serviceAccountEmail({ name: "run-sa" })).toThrow(/needs a project/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("cloudRunServiceBody sends vpcAccess refs as network and subnetwork paths (#4249)", () => {
|
|
171
|
+
const resource = {
|
|
172
|
+
...RUN_SERVICE,
|
|
173
|
+
spec: {
|
|
174
|
+
...(RUN_SERVICE.spec as object),
|
|
175
|
+
location: "europe-west1",
|
|
176
|
+
template: {
|
|
177
|
+
containers: [{ image: "i" }],
|
|
178
|
+
vpcAccess: {
|
|
179
|
+
egress: "PRIVATE_RANGES_ONLY",
|
|
180
|
+
networkInterfaces: [{ networkRef: { name: "vpc" }, subnetworkRef: { name: "apps" }, tags: ["run"] }],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
expect(cloudRunServiceBody(resource, "p")).toEqual({
|
|
186
|
+
template: {
|
|
187
|
+
containers: [{ image: "i" }],
|
|
188
|
+
vpcAccess: {
|
|
189
|
+
egress: "PRIVATE_RANGES_ONLY",
|
|
190
|
+
networkInterfaces: [
|
|
191
|
+
{ tags: ["run"], network: "projects/p/global/networks/vpc", subnetwork: "projects/p/regions/europe-west1/subnetworks/apps" },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("networkInterface: external as given, name needs a project", () => {
|
|
199
|
+
expect(networkInterface({ networkRef: { external: "projects/o/global/networks/shared" } }, "p", "r")).toEqual({
|
|
200
|
+
network: "projects/o/global/networks/shared",
|
|
201
|
+
});
|
|
202
|
+
expect(() => networkInterface({ subnetworkRef: { name: "apps" } }, undefined, "r")).toThrow(/needs a project/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("RunService plan sends the service account email on create and update", () => {
|
|
206
|
+
const resource = {
|
|
207
|
+
...RUN_SERVICE,
|
|
208
|
+
spec: { ...(RUN_SERVICE.spec as object), template: { containers: [{ image: "i" }], serviceAccountRef: { name: "run-sa" } } },
|
|
209
|
+
};
|
|
210
|
+
const plan = cloudRunServiceMapper.plan(resource, { base: "http://x", project: "p" });
|
|
211
|
+
expect((plan.create.body as { template: { serviceAccount: string } }).template.serviceAccount).toBe("run-sa@p.iam.gserviceaccount.com");
|
|
212
|
+
});
|
|
213
|
+
|
|
139
214
|
test("RunService plan → POST v2 services?serviceId, async operation present", () => {
|
|
140
215
|
const plan = cloudRunServiceMapper.plan(RUN_SERVICE, { base: "http://x", project: "p" });
|
|
141
216
|
expect(plan.getUrl).toBe("http://x/v2/projects/p/locations/us-central1/services/hello-svc");
|
|
@@ -3,6 +3,17 @@ import { parseYAML } from "@intentius/chant/yaml";
|
|
|
3
3
|
import { sleep } from "@intentius/chant/op";
|
|
4
4
|
import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
5
5
|
import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
|
|
6
|
+
import { iamPolicyMemberMapper } from "./gcp-iam";
|
|
7
|
+
import {
|
|
8
|
+
backendServiceMapper,
|
|
9
|
+
computeAddressMapper,
|
|
10
|
+
forwardingRuleMapper,
|
|
11
|
+
networkMapper,
|
|
12
|
+
subnetworkMapper,
|
|
13
|
+
regionNetworkEndpointGroupMapper,
|
|
14
|
+
targetHttpProxyMapper,
|
|
15
|
+
urlMapMapper,
|
|
16
|
+
} from "./gcp-compute";
|
|
6
17
|
import {
|
|
7
18
|
applyResult,
|
|
8
19
|
type ApplyResult,
|
|
@@ -120,6 +131,29 @@ export interface ListSpec {
|
|
|
120
131
|
items(responseBody: unknown): Array<{ name: string; labels?: Record<string, string> | null }>;
|
|
121
132
|
}
|
|
122
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Where a mapper works: the API base, the project, and the rest of the applied
|
|
136
|
+
* manifest, so a `*Ref` to a resource declared alongside can take that
|
|
137
|
+
* resource's scope. The read path has no manifest.
|
|
138
|
+
*/
|
|
139
|
+
export interface MapperCtx {
|
|
140
|
+
base: string;
|
|
141
|
+
project: string;
|
|
142
|
+
manifest?: readonly GcpResource[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A kind whose apply is not GET-then-create (#4233). IAMPolicyMember is a
|
|
147
|
+
* member inside another resource's policy, so it reads, merges and writes that
|
|
148
|
+
* policy instead. `read` answers like a GET: 200 with a body when present, 404
|
|
149
|
+
* when not.
|
|
150
|
+
*/
|
|
151
|
+
export interface CustomOps {
|
|
152
|
+
apply(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ created: boolean; updated: boolean }>;
|
|
153
|
+
delete(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<boolean>;
|
|
154
|
+
read(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ status: number; text: string }>;
|
|
155
|
+
}
|
|
156
|
+
|
|
123
157
|
/** Maps one CNRM kind to its GCP REST operations. The unit of the dispatch table. */
|
|
124
158
|
export interface ResourceMapper {
|
|
125
159
|
/** CNRM kind this handles. */
|
|
@@ -127,7 +161,11 @@ export interface ResourceMapper {
|
|
|
127
161
|
/** Real-GCP API host, used when no `endpoint` override is given. */
|
|
128
162
|
defaultHost: string;
|
|
129
163
|
/** Build the GET + create plan for one resource against `base`. */
|
|
130
|
-
plan(resource: GcpResource, ctx:
|
|
164
|
+
plan(resource: GcpResource, ctx: MapperCtx): ApplyPlan;
|
|
165
|
+
/** False for a resource of this kind chant cannot act on (an IAM binding on a target with no policy endpoint): reported, not attempted. */
|
|
166
|
+
accepts?(resource: GcpResource, ctx: MapperCtx): boolean;
|
|
167
|
+
/** Present when apply, delete and read are not the plan's GET-then-create. */
|
|
168
|
+
custom?: CustomOps;
|
|
131
169
|
/** Present for async resources whose create returns a long-running operation. */
|
|
132
170
|
operation?: OperationSpec;
|
|
133
171
|
/** Present when the kind can be pruned (list live → delete chant-owned orphans). */
|
|
@@ -245,14 +283,66 @@ export const pubSubTopicMapper: ResourceMapper = {
|
|
|
245
283
|
},
|
|
246
284
|
};
|
|
247
285
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Map a CNRM RunService to a Cloud Run v2 Service body. Pure. CNRM names the
|
|
288
|
+
* service account with `template.serviceAccountRef`; v2 takes
|
|
289
|
+
* `template.serviceAccount` as an email (#4240).
|
|
290
|
+
*/
|
|
291
|
+
export function cloudRunServiceBody(resource: GcpResource, project?: string): { template?: unknown } {
|
|
292
|
+
const spec = (resource.spec ?? {}) as { template?: Record<string, unknown> };
|
|
251
293
|
const body: { template?: unknown } = {};
|
|
252
|
-
if (spec.template) body
|
|
294
|
+
if (!spec.template) return body;
|
|
295
|
+
const { serviceAccountRef, ...template } = spec.template as { serviceAccountRef?: { name?: string; external?: string } };
|
|
296
|
+
const email = serviceAccountEmail(serviceAccountRef, project);
|
|
297
|
+
const out: Record<string, unknown> = email ? { ...template, serviceAccount: email } : { ...template };
|
|
298
|
+
const vpcAccess = out.vpcAccess as { networkInterfaces?: unknown } | undefined;
|
|
299
|
+
if (vpcAccess && Array.isArray(vpcAccess.networkInterfaces)) {
|
|
300
|
+
const region = ((resource.spec ?? {}) as { location?: string }).location ?? "us-central1";
|
|
301
|
+
out.vpcAccess = { ...vpcAccess, networkInterfaces: vpcAccess.networkInterfaces.map((i) => networkInterface(i, project, region)) };
|
|
302
|
+
}
|
|
303
|
+
body.template = out;
|
|
253
304
|
return body;
|
|
254
305
|
}
|
|
255
306
|
|
|
307
|
+
type NameRef = { name?: string; external?: string };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A CNRM Direct VPC egress interface as Cloud Run v2 takes it (#4249):
|
|
311
|
+
* `networkRef` and `subnetworkRef` become `network` and `subnetwork` paths,
|
|
312
|
+
* `name` in the project (the subnetwork in the service's region) and
|
|
313
|
+
* `external` as given. `tags` and anything else pass through. Pure.
|
|
314
|
+
*/
|
|
315
|
+
export function networkInterface(iface: unknown, project: string | undefined, region: string): unknown {
|
|
316
|
+
if (!iface || typeof iface !== "object") return iface;
|
|
317
|
+
const { networkRef, subnetworkRef, ...rest } = iface as { networkRef?: NameRef; subnetworkRef?: NameRef };
|
|
318
|
+
const path = (ref: NameRef | undefined, scoped: (p: string, n: string) => string, key: string): string | undefined => {
|
|
319
|
+
if (!ref) return undefined;
|
|
320
|
+
if (ref.external) return ref.external;
|
|
321
|
+
if (!ref.name) return undefined;
|
|
322
|
+
if (!project) throw new Error(`${key} { name: "${ref.name}" } needs a project to form its path`);
|
|
323
|
+
return scoped(project, ref.name);
|
|
324
|
+
};
|
|
325
|
+
const network = path(networkRef, (p, n) => `projects/${p}/global/networks/${n}`, "networkRef");
|
|
326
|
+
const subnetwork = path(subnetworkRef, (p, n) => `projects/${p}/regions/${region}/subnetworks/${n}`, "subnetworkRef");
|
|
327
|
+
return { ...rest, ...(network ? { network } : {}), ...(subnetwork ? { subnetwork } : {}) };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The email a CNRM `serviceAccountRef` names: `external` as given (an email,
|
|
332
|
+
* or a `projects/<p>/serviceAccounts/<email>` path), `name` as an account id
|
|
333
|
+
* in the project.
|
|
334
|
+
*/
|
|
335
|
+
export function serviceAccountEmail(ref: { name?: string; external?: string } | undefined, project?: string): string | undefined {
|
|
336
|
+
if (!ref) return undefined;
|
|
337
|
+
if (ref.external) return ref.external.split("/").pop();
|
|
338
|
+
if (ref.name) {
|
|
339
|
+
if (ref.name.includes("@")) return ref.name;
|
|
340
|
+
if (!project) throw new Error(`serviceAccountRef { name: "${ref.name}" } needs a project to form the account's email`);
|
|
341
|
+
return `${ref.name}@${project}.iam.gserviceaccount.com`;
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
256
346
|
export const cloudRunServiceMapper: ResourceMapper = {
|
|
257
347
|
kind: "RunService",
|
|
258
348
|
defaultHost: "https://run.googleapis.com",
|
|
@@ -269,11 +359,11 @@ export const cloudRunServiceMapper: ResourceMapper = {
|
|
|
269
359
|
create: {
|
|
270
360
|
method: "POST",
|
|
271
361
|
url: `${services}?serviceId=${encodeURIComponent(name)}`,
|
|
272
|
-
body: cloudRunServiceBody(resource),
|
|
362
|
+
body: cloudRunServiceBody(resource, project),
|
|
273
363
|
},
|
|
274
364
|
// Cloud Run reconciles with a PATCH of the service; like create it returns
|
|
275
365
|
// a long-running operation, polled via the mapper's `operation`.
|
|
276
|
-
update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
|
|
366
|
+
update: { method: "PATCH", url, body: cloudRunServiceBody(resource, project) },
|
|
277
367
|
};
|
|
278
368
|
},
|
|
279
369
|
};
|
|
@@ -368,6 +458,16 @@ export const MAPPERS: Record<string, ResourceMapper> = {
|
|
|
368
458
|
SecretManagerSecret: secretManagerSecretMapper,
|
|
369
459
|
IAMServiceAccount: gcpServiceAccountMapper,
|
|
370
460
|
RunService: cloudRunServiceMapper,
|
|
461
|
+
// #4233: a binding member, and the Cloud Run load balancer.
|
|
462
|
+
IAMPolicyMember: iamPolicyMemberMapper,
|
|
463
|
+
ComputeAddress: computeAddressMapper,
|
|
464
|
+
ComputeRegionNetworkEndpointGroup: regionNetworkEndpointGroupMapper,
|
|
465
|
+
ComputeBackendService: backendServiceMapper,
|
|
466
|
+
ComputeURLMap: urlMapMapper,
|
|
467
|
+
ComputeTargetHTTPProxy: targetHttpProxyMapper,
|
|
468
|
+
ComputeForwardingRule: forwardingRuleMapper,
|
|
469
|
+
ComputeNetwork: networkMapper,
|
|
470
|
+
ComputeSubnetwork: subnetworkMapper,
|
|
371
471
|
};
|
|
372
472
|
|
|
373
473
|
/**
|
|
@@ -376,7 +476,31 @@ export const MAPPERS: Record<string, ResourceMapper> = {
|
|
|
376
476
|
* refs point outside the manifest and are ignored for ordering. Pure.
|
|
377
477
|
*/
|
|
378
478
|
export function referencedNames(resource: GcpResource): string[] {
|
|
379
|
-
|
|
479
|
+
return [...new Set(referencedResources(resource).map((r) => r.name))];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The CNRM kind a `*Ref` key points at, where the key alone says. A ref that
|
|
484
|
+
* carries its own `kind` (`resourceRef`) uses that. Two resources of different
|
|
485
|
+
* kinds may share a name (a RunService `web` and its NEG `web`), so ordering
|
|
486
|
+
* matches on kind when it can.
|
|
487
|
+
*/
|
|
488
|
+
const REF_KINDS: Record<string, string> = {
|
|
489
|
+
topicRef: "PubSubTopic",
|
|
490
|
+
serviceRef: "RunService",
|
|
491
|
+
serviceAccountRef: "IAMServiceAccount",
|
|
492
|
+
addressRef: "ComputeAddress",
|
|
493
|
+
networkEndpointGroupRef: "ComputeRegionNetworkEndpointGroup",
|
|
494
|
+
backendServiceRef: "ComputeBackendService",
|
|
495
|
+
urlMapRef: "ComputeURLMap",
|
|
496
|
+
targetHTTPProxyRef: "ComputeTargetHTTPProxy",
|
|
497
|
+
networkRef: "ComputeNetwork",
|
|
498
|
+
subnetworkRef: "ComputeSubnetwork",
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
/** The local resources this resource references via `*Ref` fields, each with its kind when known. Pure. */
|
|
502
|
+
export function referencedResources(resource: GcpResource): Array<{ name: string; kind?: string }> {
|
|
503
|
+
const refs: Array<{ name: string; kind?: string }> = [];
|
|
380
504
|
const visit = (v: unknown, key?: string): void => {
|
|
381
505
|
if (Array.isArray(v)) {
|
|
382
506
|
for (const x of v) visit(x, key);
|
|
@@ -385,13 +509,14 @@ export function referencedNames(resource: GcpResource): string[] {
|
|
|
385
509
|
if (v && typeof v === "object") {
|
|
386
510
|
const obj = v as Record<string, unknown>;
|
|
387
511
|
if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
|
|
388
|
-
|
|
512
|
+
const kind = typeof obj.kind === "string" ? obj.kind : REF_KINDS[key];
|
|
513
|
+
refs.push(kind ? { name: obj.name, kind } : { name: obj.name });
|
|
389
514
|
}
|
|
390
515
|
for (const [k, val] of Object.entries(obj)) visit(val, k);
|
|
391
516
|
}
|
|
392
517
|
};
|
|
393
518
|
visit(resource.spec);
|
|
394
|
-
return
|
|
519
|
+
return refs;
|
|
395
520
|
}
|
|
396
521
|
|
|
397
522
|
/**
|
|
@@ -401,10 +526,10 @@ export function referencedNames(resource: GcpResource): string[] {
|
|
|
401
526
|
* Pure — a DFS post-order.
|
|
402
527
|
*/
|
|
403
528
|
export function orderByReferences(resources: GcpResource[]): GcpResource[] {
|
|
404
|
-
const byName = new Map<string, GcpResource>();
|
|
529
|
+
const byName = new Map<string, GcpResource[]>();
|
|
405
530
|
for (const r of resources) {
|
|
406
531
|
const n = r.metadata?.name;
|
|
407
|
-
if (n) byName.set(n, r);
|
|
532
|
+
if (n) byName.set(n, [...(byName.get(n) ?? []), r]);
|
|
408
533
|
}
|
|
409
534
|
const ordered: GcpResource[] = [];
|
|
410
535
|
const done = new Set<GcpResource>();
|
|
@@ -413,9 +538,10 @@ export function orderByReferences(resources: GcpResource[]): GcpResource[] {
|
|
|
413
538
|
if (done.has(r)) return;
|
|
414
539
|
if (active.has(r)) throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
|
|
415
540
|
active.add(r);
|
|
416
|
-
for (const ref of
|
|
417
|
-
const
|
|
418
|
-
|
|
541
|
+
for (const ref of referencedResources(r)) {
|
|
542
|
+
const named = (byName.get(ref.name) ?? []).filter((d) => d !== r);
|
|
543
|
+
const deps = ref.kind ? named.filter((d) => d.kind === ref.kind) : named;
|
|
544
|
+
for (const dep of deps) visit(dep);
|
|
419
545
|
}
|
|
420
546
|
active.delete(r);
|
|
421
547
|
done.add(r);
|
|
@@ -503,12 +629,16 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
|
|
|
503
629
|
export async function applyResource(
|
|
504
630
|
mapper: ResourceMapper,
|
|
505
631
|
resource: GcpResource,
|
|
506
|
-
ctx:
|
|
632
|
+
ctx: MapperCtx,
|
|
507
633
|
http: GcpHttp = defaultHttp,
|
|
508
634
|
signal?: AbortSignal,
|
|
509
635
|
): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
|
|
510
|
-
const plan = mapper.plan(resource, ctx);
|
|
511
636
|
const name = resource.metadata?.name ?? "?";
|
|
637
|
+
if (mapper.custom) {
|
|
638
|
+
const result = await mapper.custom.apply(resource, ctx, http, signal);
|
|
639
|
+
return { kind: mapper.kind, name, ...result };
|
|
640
|
+
}
|
|
641
|
+
const plan = mapper.plan(resource, ctx);
|
|
512
642
|
const get = await http("GET", plan.getUrl, undefined, signal);
|
|
513
643
|
|
|
514
644
|
if (get.status === 200) {
|
|
@@ -712,7 +842,7 @@ export interface GcpNotPrunable {
|
|
|
712
842
|
*/
|
|
713
843
|
export async function pruneOrphans(
|
|
714
844
|
desired: GcpResource[],
|
|
715
|
-
resolve: (mapper: ResourceMapper, resource?: GcpResource) =>
|
|
845
|
+
resolve: (mapper: ResourceMapper, resource?: GcpResource) => MapperCtx,
|
|
716
846
|
http: GcpHttp = defaultHttp,
|
|
717
847
|
signal?: AbortSignal,
|
|
718
848
|
): Promise<{
|
|
@@ -780,9 +910,10 @@ export async function gcpApply(
|
|
|
780
910
|
}> {
|
|
781
911
|
const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
|
|
782
912
|
const endpoint = resolveGcpEndpoint(args);
|
|
783
|
-
const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
|
|
913
|
+
const resolve = (mapper: ResourceMapper, resource?: GcpResource): MapperCtx => ({
|
|
784
914
|
base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
|
|
785
915
|
project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
|
|
916
|
+
manifest: resources,
|
|
786
917
|
});
|
|
787
918
|
|
|
788
919
|
const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
|
|
@@ -796,6 +927,11 @@ export async function gcpApply(
|
|
|
796
927
|
continue;
|
|
797
928
|
}
|
|
798
929
|
const ctx = resolve(mapper, r);
|
|
930
|
+
if (mapper.accepts && !mapper.accepts(r, ctx)) {
|
|
931
|
+
console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can apply it to`);
|
|
932
|
+
notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
799
935
|
const result = await applyResource(mapper, r, ctx, http, signal);
|
|
800
936
|
const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
|
|
801
937
|
console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
|
|
@@ -816,12 +952,15 @@ export async function gcpApply(
|
|
|
816
952
|
export async function deleteResource(
|
|
817
953
|
mapper: ResourceMapper,
|
|
818
954
|
resource: GcpResource,
|
|
819
|
-
ctx:
|
|
955
|
+
ctx: MapperCtx,
|
|
820
956
|
http: GcpHttp = defaultHttp,
|
|
821
957
|
signal?: AbortSignal,
|
|
822
958
|
): Promise<{ kind: string; name: string; deleted: boolean }> {
|
|
823
|
-
const plan = mapper.plan(resource, ctx);
|
|
824
959
|
const name = resource.metadata?.name ?? "?";
|
|
960
|
+
if (mapper.custom) {
|
|
961
|
+
return { kind: mapper.kind, name, deleted: await mapper.custom.delete(resource, ctx, http, signal) };
|
|
962
|
+
}
|
|
963
|
+
const plan = mapper.plan(resource, ctx);
|
|
825
964
|
const res = await http("DELETE", plan.getUrl, undefined, signal);
|
|
826
965
|
if (res.status === 404) {
|
|
827
966
|
return { kind: mapper.kind, name, deleted: false };
|
|
@@ -870,7 +1009,13 @@ export async function gcpDelete(
|
|
|
870
1009
|
}
|
|
871
1010
|
const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
|
|
872
1011
|
const project = args.project ?? resolveGcpProject(r);
|
|
873
|
-
const
|
|
1012
|
+
const ctx: MapperCtx = { base, project, manifest: resources };
|
|
1013
|
+
if (mapper.accepts && !mapper.accepts(r, ctx)) {
|
|
1014
|
+
console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can delete it from`);
|
|
1015
|
+
notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const result = await deleteResource(mapper, r, ctx, http, signal);
|
|
874
1019
|
console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
|
|
875
1020
|
deleted.push(result);
|
|
876
1021
|
}
|