@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.
Files changed (37) hide show
  1. package/dist/api/read-client.d.ts +8 -3
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/api/read-client.js +37 -15
  4. package/dist/composites/cloud-run-service.d.ts +2 -0
  5. package/dist/composites/cloud-run-service.d.ts.map +1 -1
  6. package/dist/composites/cloud-run-service.js +4 -1
  7. package/dist/deep-observe.d.ts.map +1 -1
  8. package/dist/deep-observe.js +176 -0
  9. package/dist/integrity.json +2 -2
  10. package/dist/manifest.json +1 -1
  11. package/dist/op/activities/gcp-apply.d.ts +62 -18
  12. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  13. package/dist/op/activities/gcp-apply.js +128 -16
  14. package/dist/op/activities/gcp-compute.d.ts +66 -0
  15. package/dist/op/activities/gcp-compute.d.ts.map +1 -0
  16. package/dist/op/activities/gcp-compute.js +349 -0
  17. package/dist/op/activities/gcp-iam.d.ts +82 -0
  18. package/dist/op/activities/gcp-iam.d.ts.map +1 -0
  19. package/dist/op/activities/gcp-iam.js +215 -0
  20. package/dist/serializer.d.ts +6 -0
  21. package/dist/serializer.d.ts.map +1 -1
  22. package/dist/serializer.js +21 -9
  23. package/package.json +2 -2
  24. package/src/api/read-client.ts +36 -16
  25. package/src/composites/cloud-run-service.ts +6 -0
  26. package/src/composites/composites.test.ts +7 -0
  27. package/src/deep-observe.test.ts +42 -1
  28. package/src/deep-observe.ts +171 -0
  29. package/src/describe-resources.test.ts +1 -1
  30. package/src/op/activities/gcp-apply.test.ts +75 -0
  31. package/src/op/activities/gcp-apply.ts +167 -22
  32. package/src/op/activities/gcp-compute.test.ts +306 -0
  33. package/src/op/activities/gcp-compute.ts +354 -0
  34. package/src/op/activities/gcp-iam.test.ts +152 -0
  35. package/src/op/activities/gcp-iam.ts +284 -0
  36. package/src/serializer.test.ts +26 -0
  37. package/src/serializer.ts +22 -9
@@ -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::Address", props: { metadata: { name: "addr" } } }]),
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: { base: string; project: string }): ApplyPlan;
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
- /** Map a CNRM RunService to a Cloud Run v2 Service body. Pure. */
249
- export function cloudRunServiceBody(resource: GcpResource): { template?: unknown } {
250
- const spec = (resource.spec ?? {}) as { template?: unknown };
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.template = spec.template;
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
- const names: string[] = [];
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
- names.push(obj.name);
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 [...new Set(names)];
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 referencedNames(r)) {
417
- const dep = byName.get(ref);
418
- if (dep && dep !== r) visit(dep);
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: { base: string; project: string },
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) => { base: string; project: string },
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: { base: string; project: string },
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 result = await deleteResource(mapper, r, { base, project }, http, signal);
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
  }