@intentius/chant-lexicon-gcp 0.124.33 → 0.124.35

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 (47) hide show
  1. package/dist/api/read-client.d.ts +7 -3
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/api/read-client.js +36 -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 +125 -1
  9. package/dist/describe-resources.d.ts +4 -3
  10. package/dist/describe-resources.d.ts.map +1 -1
  11. package/dist/describe-resources.js +8 -7
  12. package/dist/health.js +1 -1
  13. package/dist/integrity.json +2 -2
  14. package/dist/manifest.json +1 -1
  15. package/dist/op/activities/gcp-apply.d.ts +62 -18
  16. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  17. package/dist/op/activities/gcp-apply.js +118 -18
  18. package/dist/op/activities/gcp-compute.d.ts +56 -0
  19. package/dist/op/activities/gcp-compute.d.ts.map +1 -0
  20. package/dist/op/activities/gcp-compute.js +313 -0
  21. package/dist/op/activities/gcp-iam.d.ts +82 -0
  22. package/dist/op/activities/gcp-iam.d.ts.map +1 -0
  23. package/dist/op/activities/gcp-iam.js +215 -0
  24. package/dist/op/activities/index.d.ts +1 -1
  25. package/dist/op/activities/index.d.ts.map +1 -1
  26. package/dist/op/activities/index.js +1 -1
  27. package/dist/serializer.d.ts.map +1 -1
  28. package/dist/serializer.js +36 -1
  29. package/package.json +2 -2
  30. package/src/api/read-client.ts +35 -16
  31. package/src/composites/cloud-run-service.ts +6 -0
  32. package/src/composites/composites.test.ts +7 -0
  33. package/src/deep-observe.test.ts +16 -1
  34. package/src/deep-observe.ts +125 -1
  35. package/src/describe-resources.test.ts +1 -1
  36. package/src/describe-resources.ts +8 -6
  37. package/src/health.ts +1 -1
  38. package/src/op/activities/gcp-apply.test.ts +53 -0
  39. package/src/op/activities/gcp-apply.ts +157 -24
  40. package/src/op/activities/gcp-compute.test.ts +212 -0
  41. package/src/op/activities/gcp-compute.ts +319 -0
  42. package/src/op/activities/gcp-iam.test.ts +152 -0
  43. package/src/op/activities/gcp-iam.ts +284 -0
  44. package/src/op/activities/index.ts +1 -0
  45. package/src/project-resolution.test.ts +41 -0
  46. package/src/serializer-pseudo.test.ts +38 -0
  47. package/src/serializer.ts +43 -3
@@ -182,9 +182,133 @@ 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
+ },
195
+ // #4233: the inverse of gcp-compute.ts's body builders. Compute returns refs
196
+ // as full URLs and a region as a URL; CNRM writes `*Ref: { name }` and a
197
+ // bare `location` (`"global"` when the resource has no region).
198
+ IAMPolicyMember(tree) {
199
+ delete tree.etag;
200
+ delete tree.resource;
201
+ },
202
+ ComputeAddress(tree) {
203
+ computeLocation(tree);
204
+ },
205
+ ComputeRegionNetworkEndpointGroup(tree) {
206
+ if (typeof tree.region === "string") tree.region = lastSegment(tree.region);
207
+ const cloudRun = tree.cloudRun as Record<string, unknown> | undefined;
208
+ if (cloudRun && typeof cloudRun.service === "string") {
209
+ cloudRun.serviceRef = { name: cloudRun.service };
210
+ delete cloudRun.service;
211
+ }
212
+ },
213
+ ComputeBackendService(tree) {
214
+ computeLocation(tree);
215
+ if (tree.enableCDN !== undefined) {
216
+ tree.enableCdn = tree.enableCDN;
217
+ delete tree.enableCDN;
218
+ }
219
+ const draining = tree.connectionDraining as { drainingTimeoutSec?: unknown } | undefined;
220
+ if (draining) {
221
+ if (draining.drainingTimeoutSec !== undefined) tree.connectionDrainingTimeoutSec = draining.drainingTimeoutSec;
222
+ delete tree.connectionDraining;
223
+ }
224
+ if (Array.isArray(tree.backends)) {
225
+ tree.backend = (tree.backends as Array<Record<string, unknown>>).map((b) => {
226
+ const { group, ...rest } = b;
227
+ return { ...rest, ...(typeof group === "string" ? { group: refOf(group) } : {}) };
228
+ });
229
+ delete tree.backends;
230
+ }
231
+ if (Array.isArray(tree.healthChecks)) {
232
+ tree.healthChecks = (tree.healthChecks as unknown[]).map((h) => (typeof h === "string" ? { healthCheckRef: { name: lastSegment(h) } } : h));
233
+ }
234
+ if (typeof tree.securityPolicy === "string") {
235
+ tree.securityPolicyRef = { name: lastSegment(tree.securityPolicy) };
236
+ delete tree.securityPolicy;
237
+ }
238
+ },
239
+ ComputeURLMap(tree) {
240
+ computeLocation(tree);
241
+ if (typeof tree.defaultService === "string") tree.defaultService = refOf(tree.defaultService);
242
+ if (Array.isArray(tree.hostRules)) {
243
+ tree.hostRule = tree.hostRules;
244
+ delete tree.hostRules;
245
+ }
246
+ if (Array.isArray(tree.pathMatchers)) {
247
+ tree.pathMatcher = (tree.pathMatchers as Array<Record<string, unknown>>).map((m) => {
248
+ const { defaultService, pathRules, ...rest } = m;
249
+ return {
250
+ ...rest,
251
+ ...(typeof defaultService === "string" ? { defaultService: refOf(defaultService) } : {}),
252
+ ...(Array.isArray(pathRules)
253
+ ? {
254
+ pathRule: (pathRules as Array<Record<string, unknown>>).map(({ service, ...r }) => ({
255
+ ...r,
256
+ ...(typeof service === "string" ? { service: refOf(service) } : {}),
257
+ })),
258
+ }
259
+ : {}),
260
+ };
261
+ });
262
+ delete tree.pathMatchers;
263
+ }
264
+ },
265
+ ComputeTargetHTTPProxy(tree) {
266
+ computeLocation(tree);
267
+ if (typeof tree.urlMap === "string") {
268
+ tree.urlMapRef = { name: lastSegment(tree.urlMap) };
269
+ delete tree.urlMap;
270
+ }
185
271
  },
272
+ ComputeForwardingRule(tree) {
273
+ computeLocation(tree);
274
+ if (tree.IPProtocol !== undefined) {
275
+ tree.ipProtocol = tree.IPProtocol;
276
+ delete tree.IPProtocol;
277
+ }
278
+ if (typeof tree.target === "string") tree.target = refOf(tree.target);
279
+ if (typeof tree.backendService === "string") {
280
+ tree.backendServiceRef = { name: lastSegment(tree.backendService) };
281
+ delete tree.backendService;
282
+ }
283
+ },
284
+ };
285
+
286
+ function lastSegment(url: string): string {
287
+ return url.slice(url.lastIndexOf("/") + 1);
288
+ }
289
+
290
+ /** Compute's `region` URL as CNRM's `location`; no region means a global resource. */
291
+ function computeLocation(tree: CnrmTree): void {
292
+ tree.location = typeof tree.region === "string" ? lastSegment(tree.region) : "global";
293
+ delete tree.region;
294
+ }
295
+
296
+ /** The `*Ref` a compute URL names, by its collection (`.../backendServices/web` -> `{ backendServiceRef: { name: "web" } }`). */
297
+ const REF_KEY_BY_COLLECTION: Record<string, string> = {
298
+ networkEndpointGroups: "networkEndpointGroupRef",
299
+ backendServices: "backendServiceRef",
300
+ backendBuckets: "backendBucketRef",
301
+ targetHttpProxies: "targetHTTPProxyRef",
302
+ targetHttpsProxies: "targetHTTPSProxyRef",
303
+ instanceGroups: "instanceGroupRef",
186
304
  };
187
305
 
306
+ function refOf(url: string): unknown {
307
+ const parts = url.split("/");
308
+ const key = REF_KEY_BY_COLLECTION[parts[parts.length - 2] ?? ""];
309
+ return key ? { [key]: { name: parts[parts.length - 1] } } : url;
310
+ }
311
+
188
312
  /**
189
313
  * A REST payload as a CNRM-shaped property tree, in the declared side's
190
314
  * vocabulary. The generic reshape runs first, then the full resource path is
@@ -244,7 +368,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
244
368
  let client: GcpReadClientOptions;
245
369
  try {
246
370
  client = {
247
- project: resolveReadProject(gvk.kind, name, metadata, manifestProjects),
371
+ project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
248
372
  ...(endpoint ? { endpoint } : {}),
249
373
  };
250
374
  } catch (err) {
@@ -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::Network", props: { metadata: { name: "net" } } }]),
84
84
  );
85
85
  expect(out.unobserved?.e?.reason).toBe("unsupported-kind");
86
86
  expect(fetchMock).not.toHaveBeenCalled();
@@ -40,7 +40,7 @@ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@int
40
40
  import { observation } from "@intentius/chant/observation";
41
41
  import { hasOwnershipMarker, classifyOwnership, readOwnership, type ChannelKeys } from "@intentius/chant/ownership";
42
42
  import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
43
- import { resolveGcpProject, parseManifest } from "./op/activities/gcp-apply";
43
+ import { resolveGcpProject, parseManifest, projectRefProject } from "./op/activities/gcp-apply";
44
44
  import { resolveGVK } from "./serializer";
45
45
 
46
46
  /**
@@ -105,7 +105,7 @@ export function manifestProjectAnnotations(buildOutput: string | undefined): Map
105
105
  if (!buildOutput) return out;
106
106
  try {
107
107
  for (const doc of parseManifest(buildOutput, "<build output>")) {
108
- const project = doc.metadata?.annotations?.["cnrm.cloud.google.com/project-id"];
108
+ const project = projectRefProject(doc.spec) ?? doc.metadata?.annotations?.["cnrm.cloud.google.com/project-id"];
109
109
  if (doc.kind && doc.metadata?.name && typeof project === "string") {
110
110
  out.set(`${doc.kind}/${doc.metadata.name}`, project);
111
111
  }
@@ -119,8 +119,9 @@ export function manifestProjectAnnotations(buildOutput: string | undefined): Map
119
119
  }
120
120
 
121
121
  /**
122
- * The project a read should target: `GOOGLE_CLOUD_PROJECT` / the entity's own
123
- * annotation (via `resolveGcpProject`, the applier's rule), then the built
122
+ * The project a read should target: the entity's `projectRef.external`,
123
+ * `GOOGLE_CLOUD_PROJECT`, the entity's own annotation (via
124
+ * `resolveGcpProject`, the applier's rule), then the built
124
125
  * manifest's merged annotation. Throws the applier's own error when nothing
125
126
  * resolves, so the `no-binding` detail names both knobs.
126
127
  */
@@ -129,9 +130,10 @@ export function resolveReadProject(
129
130
  name: string,
130
131
  metadata: { annotations?: Record<string, string> } | undefined,
131
132
  manifestProjects: Map<string, string>,
133
+ props?: Record<string, unknown>,
132
134
  ): string {
133
135
  try {
134
- return resolveGcpProject({ kind, metadata });
136
+ return resolveGcpProject({ kind, metadata, ...(props ? { spec: props } : {}) });
135
137
  } catch (err) {
136
138
  const fromManifest = manifestProjects.get(`${kind}/${name}`);
137
139
  if (fromManifest) return fromManifest;
@@ -230,7 +232,7 @@ export async function describeResources(options: {
230
232
  let client: GcpReadClientOptions;
231
233
  try {
232
234
  client = {
233
- project: resolveReadProject(gvk.kind, name, metadata, manifestProjects),
235
+ project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
234
236
  ...(endpoint ? { endpoint } : {}),
235
237
  };
236
238
  } catch (err) {
package/src/health.ts CHANGED
@@ -91,7 +91,7 @@ export async function observeHealthGcp(options: GcpHealthOptions): Promise<Healt
91
91
  }
92
92
  let project: string;
93
93
  try {
94
- project = resolveReadProject(gvk.kind, name, metadata, manifestProjects);
94
+ project = resolveReadProject(gvk.kind, name, metadata, manifestProjects, props);
95
95
  } catch (err) {
96
96
  unobserved[entityName] = { type: entityType, reason: "no-binding", detail: err instanceof Error ? err.message : String(err) };
97
97
  return;
@@ -5,6 +5,7 @@ import {
5
5
  bucketInsertBody,
6
6
  pubSubTopicBody,
7
7
  cloudRunServiceBody,
8
+ serviceAccountEmail,
8
9
  resolveGcpProject,
9
10
  resolveGcpEndpoint,
10
11
  applyResource,
@@ -118,6 +119,13 @@ describe("mappers build correct plans (#706)", () => {
118
119
 
119
120
  test("registry keys match each mapper's kind", () => {
120
121
  expect(Object.keys(MAPPERS).sort()).toEqual([
122
+ "ComputeAddress",
123
+ "ComputeBackendService",
124
+ "ComputeForwardingRule",
125
+ "ComputeRegionNetworkEndpointGroup",
126
+ "ComputeTargetHTTPProxy",
127
+ "ComputeURLMap",
128
+ "IAMPolicyMember",
121
129
  "IAMServiceAccount",
122
130
  "PubSubSubscription",
123
131
  "PubSubTopic",
@@ -136,6 +144,35 @@ describe("Cloud Run mapper + LRO (#706)", () => {
136
144
  });
137
145
  });
138
146
 
147
+ test("cloudRunServiceBody sends serviceAccountRef as template.serviceAccount (#4240)", () => {
148
+ const withRef = (ref: Record<string, string>) => ({
149
+ ...RUN_SERVICE,
150
+ spec: { ...(RUN_SERVICE.spec as object), template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccountRef: ref } },
151
+ });
152
+ expect(cloudRunServiceBody(withRef({ name: "run-sa" }), "p")).toEqual({
153
+ template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccount: "run-sa@p.iam.gserviceaccount.com" },
154
+ });
155
+ expect(cloudRunServiceBody(withRef({ external: "x@other.iam.gserviceaccount.com" }), "p")).toEqual({
156
+ template: { containers: [{ image: "gcr.io/x/hello" }], serviceAccount: "x@other.iam.gserviceaccount.com" },
157
+ });
158
+ });
159
+
160
+ test("serviceAccountEmail: external as given or from a path, name in the project", () => {
161
+ expect(serviceAccountEmail({ external: "projects/o/serviceAccounts/x@o.iam.gserviceaccount.com" })).toBe("x@o.iam.gserviceaccount.com");
162
+ expect(serviceAccountEmail({ name: "run-sa" }, "p")).toBe("run-sa@p.iam.gserviceaccount.com");
163
+ expect(serviceAccountEmail(undefined, "p")).toBeUndefined();
164
+ expect(() => serviceAccountEmail({ name: "run-sa" })).toThrow(/needs a project/);
165
+ });
166
+
167
+ test("RunService plan sends the service account email on create and update", () => {
168
+ const resource = {
169
+ ...RUN_SERVICE,
170
+ spec: { ...(RUN_SERVICE.spec as object), template: { containers: [{ image: "i" }], serviceAccountRef: { name: "run-sa" } } },
171
+ };
172
+ const plan = cloudRunServiceMapper.plan(resource, { base: "http://x", project: "p" });
173
+ expect((plan.create.body as { template: { serviceAccount: string } }).template.serviceAccount).toBe("run-sa@p.iam.gserviceaccount.com");
174
+ });
175
+
139
176
  test("RunService plan → POST v2 services?serviceId, async operation present", () => {
140
177
  const plan = cloudRunServiceMapper.plan(RUN_SERVICE, { base: "http://x", project: "p" });
141
178
  expect(plan.getUrl).toBe("http://x/v2/projects/p/locations/us-central1/services/hello-svc");
@@ -234,6 +271,22 @@ describe("resolveGcpProject (#711)", () => {
234
271
  expect(() => resolveGcpProject({ metadata: { name: "b" } }, {} as NodeJS.ProcessEnv))
235
272
  .toThrow(/no GCP project/);
236
273
  });
274
+
275
+ test("spec.projectRef.external wins over env and the annotation (#4201)", () => {
276
+ const run = { ...BUCKET, spec: { projectRef: { external: "ref-project" } } };
277
+ expect(resolveGcpProject(run, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv)).toBe("ref-project");
278
+ });
279
+
280
+ test("projectRef.external may be projects/<id>", () => {
281
+ const run = { metadata: { name: "r" }, spec: { projectRef: { external: "projects/three-cloud" } } };
282
+ expect(resolveGcpProject(run, {} as NodeJS.ProcessEnv)).toBe("three-cloud");
283
+ });
284
+
285
+ test("an unresolved GCP.ProjectId in projectRef reads GCP_PROJECT_ID, else falls through", () => {
286
+ const run = { ...BUCKET, spec: { projectRef: { external: { Ref: "GCP::ProjectId" } } } };
287
+ expect(resolveGcpProject(run, { GCP_PROJECT_ID: "pseudo-project" } as NodeJS.ProcessEnv)).toBe("pseudo-project");
288
+ expect(resolveGcpProject(run, {} as NodeJS.ProcessEnv)).toBe("annotated-project");
289
+ });
237
290
  });
238
291
 
239
292
  describe("resolveGcpEndpoint (#1449)", () => {
@@ -3,6 +3,15 @@ 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
+ regionNetworkEndpointGroupMapper,
12
+ targetHttpProxyMapper,
13
+ urlMapMapper,
14
+ } from "./gcp-compute";
6
15
  import {
7
16
  applyResult,
8
17
  type ApplyResult,
@@ -120,6 +129,29 @@ export interface ListSpec {
120
129
  items(responseBody: unknown): Array<{ name: string; labels?: Record<string, string> | null }>;
121
130
  }
122
131
 
132
+ /**
133
+ * Where a mapper works: the API base, the project, and the rest of the applied
134
+ * manifest, so a `*Ref` to a resource declared alongside can take that
135
+ * resource's scope. The read path has no manifest.
136
+ */
137
+ export interface MapperCtx {
138
+ base: string;
139
+ project: string;
140
+ manifest?: readonly GcpResource[];
141
+ }
142
+
143
+ /**
144
+ * A kind whose apply is not GET-then-create (#4233). IAMPolicyMember is a
145
+ * member inside another resource's policy, so it reads, merges and writes that
146
+ * policy instead. `read` answers like a GET: 200 with a body when present, 404
147
+ * when not.
148
+ */
149
+ export interface CustomOps {
150
+ apply(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ created: boolean; updated: boolean }>;
151
+ delete(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<boolean>;
152
+ read(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ status: number; text: string }>;
153
+ }
154
+
123
155
  /** Maps one CNRM kind to its GCP REST operations. The unit of the dispatch table. */
124
156
  export interface ResourceMapper {
125
157
  /** CNRM kind this handles. */
@@ -127,7 +159,11 @@ export interface ResourceMapper {
127
159
  /** Real-GCP API host, used when no `endpoint` override is given. */
128
160
  defaultHost: string;
129
161
  /** Build the GET + create plan for one resource against `base`. */
130
- plan(resource: GcpResource, ctx: { base: string; project: string }): ApplyPlan;
162
+ plan(resource: GcpResource, ctx: MapperCtx): ApplyPlan;
163
+ /** False for a resource of this kind chant cannot act on (an IAM binding on a target with no policy endpoint): reported, not attempted. */
164
+ accepts?(resource: GcpResource, ctx: MapperCtx): boolean;
165
+ /** Present when apply, delete and read are not the plan's GET-then-create. */
166
+ custom?: CustomOps;
131
167
  /** Present for async resources whose create returns a long-running operation. */
132
168
  operation?: OperationSpec;
133
169
  /** Present when the kind can be pruned (list live → delete chant-owned orphans). */
@@ -245,14 +281,37 @@ export const pubSubTopicMapper: ResourceMapper = {
245
281
  },
246
282
  };
247
283
 
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 };
284
+ /**
285
+ * Map a CNRM RunService to a Cloud Run v2 Service body. Pure. CNRM names the
286
+ * service account with `template.serviceAccountRef`; v2 takes
287
+ * `template.serviceAccount` as an email (#4240).
288
+ */
289
+ export function cloudRunServiceBody(resource: GcpResource, project?: string): { template?: unknown } {
290
+ const spec = (resource.spec ?? {}) as { template?: Record<string, unknown> };
251
291
  const body: { template?: unknown } = {};
252
- if (spec.template) body.template = spec.template;
292
+ if (!spec.template) return body;
293
+ const { serviceAccountRef, ...template } = spec.template as { serviceAccountRef?: { name?: string; external?: string } };
294
+ const email = serviceAccountEmail(serviceAccountRef, project);
295
+ body.template = email ? { ...template, serviceAccount: email } : template;
253
296
  return body;
254
297
  }
255
298
 
299
+ /**
300
+ * The email a CNRM `serviceAccountRef` names: `external` as given (an email,
301
+ * or a `projects/<p>/serviceAccounts/<email>` path), `name` as an account id
302
+ * in the project.
303
+ */
304
+ export function serviceAccountEmail(ref: { name?: string; external?: string } | undefined, project?: string): string | undefined {
305
+ if (!ref) return undefined;
306
+ if (ref.external) return ref.external.split("/").pop();
307
+ if (ref.name) {
308
+ if (ref.name.includes("@")) return ref.name;
309
+ if (!project) throw new Error(`serviceAccountRef { name: "${ref.name}" } needs a project to form the account's email`);
310
+ return `${ref.name}@${project}.iam.gserviceaccount.com`;
311
+ }
312
+ return undefined;
313
+ }
314
+
256
315
  export const cloudRunServiceMapper: ResourceMapper = {
257
316
  kind: "RunService",
258
317
  defaultHost: "https://run.googleapis.com",
@@ -269,11 +328,11 @@ export const cloudRunServiceMapper: ResourceMapper = {
269
328
  create: {
270
329
  method: "POST",
271
330
  url: `${services}?serviceId=${encodeURIComponent(name)}`,
272
- body: cloudRunServiceBody(resource),
331
+ body: cloudRunServiceBody(resource, project),
273
332
  },
274
333
  // Cloud Run reconciles with a PATCH of the service; like create it returns
275
334
  // a long-running operation, polled via the mapper's `operation`.
276
- update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
335
+ update: { method: "PATCH", url, body: cloudRunServiceBody(resource, project) },
277
336
  };
278
337
  },
279
338
  };
@@ -368,6 +427,14 @@ export const MAPPERS: Record<string, ResourceMapper> = {
368
427
  SecretManagerSecret: secretManagerSecretMapper,
369
428
  IAMServiceAccount: gcpServiceAccountMapper,
370
429
  RunService: cloudRunServiceMapper,
430
+ // #4233: a binding member, and the Cloud Run load balancer.
431
+ IAMPolicyMember: iamPolicyMemberMapper,
432
+ ComputeAddress: computeAddressMapper,
433
+ ComputeRegionNetworkEndpointGroup: regionNetworkEndpointGroupMapper,
434
+ ComputeBackendService: backendServiceMapper,
435
+ ComputeURLMap: urlMapMapper,
436
+ ComputeTargetHTTPProxy: targetHttpProxyMapper,
437
+ ComputeForwardingRule: forwardingRuleMapper,
371
438
  };
372
439
 
373
440
  /**
@@ -376,7 +443,29 @@ export const MAPPERS: Record<string, ResourceMapper> = {
376
443
  * refs point outside the manifest and are ignored for ordering. Pure.
377
444
  */
378
445
  export function referencedNames(resource: GcpResource): string[] {
379
- const names: string[] = [];
446
+ return [...new Set(referencedResources(resource).map((r) => r.name))];
447
+ }
448
+
449
+ /**
450
+ * The CNRM kind a `*Ref` key points at, where the key alone says. A ref that
451
+ * carries its own `kind` (`resourceRef`) uses that. Two resources of different
452
+ * kinds may share a name (a RunService `web` and its NEG `web`), so ordering
453
+ * matches on kind when it can.
454
+ */
455
+ const REF_KINDS: Record<string, string> = {
456
+ topicRef: "PubSubTopic",
457
+ serviceRef: "RunService",
458
+ serviceAccountRef: "IAMServiceAccount",
459
+ addressRef: "ComputeAddress",
460
+ networkEndpointGroupRef: "ComputeRegionNetworkEndpointGroup",
461
+ backendServiceRef: "ComputeBackendService",
462
+ urlMapRef: "ComputeURLMap",
463
+ targetHTTPProxyRef: "ComputeTargetHTTPProxy",
464
+ };
465
+
466
+ /** The local resources this resource references via `*Ref` fields, each with its kind when known. Pure. */
467
+ export function referencedResources(resource: GcpResource): Array<{ name: string; kind?: string }> {
468
+ const refs: Array<{ name: string; kind?: string }> = [];
380
469
  const visit = (v: unknown, key?: string): void => {
381
470
  if (Array.isArray(v)) {
382
471
  for (const x of v) visit(x, key);
@@ -385,13 +474,14 @@ export function referencedNames(resource: GcpResource): string[] {
385
474
  if (v && typeof v === "object") {
386
475
  const obj = v as Record<string, unknown>;
387
476
  if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
388
- names.push(obj.name);
477
+ const kind = typeof obj.kind === "string" ? obj.kind : REF_KINDS[key];
478
+ refs.push(kind ? { name: obj.name, kind } : { name: obj.name });
389
479
  }
390
480
  for (const [k, val] of Object.entries(obj)) visit(val, k);
391
481
  }
392
482
  };
393
483
  visit(resource.spec);
394
- return [...new Set(names)];
484
+ return refs;
395
485
  }
396
486
 
397
487
  /**
@@ -401,10 +491,10 @@ export function referencedNames(resource: GcpResource): string[] {
401
491
  * Pure — a DFS post-order.
402
492
  */
403
493
  export function orderByReferences(resources: GcpResource[]): GcpResource[] {
404
- const byName = new Map<string, GcpResource>();
494
+ const byName = new Map<string, GcpResource[]>();
405
495
  for (const r of resources) {
406
496
  const n = r.metadata?.name;
407
- if (n) byName.set(n, r);
497
+ if (n) byName.set(n, [...(byName.get(n) ?? []), r]);
408
498
  }
409
499
  const ordered: GcpResource[] = [];
410
500
  const done = new Set<GcpResource>();
@@ -413,9 +503,10 @@ export function orderByReferences(resources: GcpResource[]): GcpResource[] {
413
503
  if (done.has(r)) return;
414
504
  if (active.has(r)) throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
415
505
  active.add(r);
416
- for (const ref of referencedNames(r)) {
417
- const dep = byName.get(ref);
418
- if (dep && dep !== r) visit(dep);
506
+ for (const ref of referencedResources(r)) {
507
+ const named = (byName.get(ref.name) ?? []).filter((d) => d !== r);
508
+ const deps = ref.kind ? named.filter((d) => d.kind === ref.kind) : named;
509
+ for (const dep of deps) visit(dep);
419
510
  }
420
511
  active.delete(r);
421
512
  done.add(r);
@@ -443,11 +534,34 @@ export function resolveGcpEndpoint(
443
534
  }
444
535
 
445
536
  /** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
537
+ /**
538
+ * The project a resource names in `spec.projectRef.external` (#4201): a
539
+ * project id or `projects/<id>`. A `GCP.ProjectId` pseudo-parameter there
540
+ * (`{ Ref: "GCP::ProjectId" }`, unresolved in discovery output) reads
541
+ * `GCP_PROJECT_ID`, as the serializer does.
542
+ */
543
+ export function projectRefProject(spec: Record<string, unknown> | undefined, env: NodeJS.ProcessEnv = process.env): string | undefined {
544
+ const ref = spec?.projectRef;
545
+ if (ref === null || typeof ref !== "object") return undefined;
546
+ let external = (ref as Record<string, unknown>).external;
547
+ if (external !== null && typeof external === "object" && typeof (external as { toJSON?: unknown }).toJSON === "function") {
548
+ external = (external as { toJSON(): unknown }).toJSON();
549
+ }
550
+ if (external !== null && typeof external === "object" && (external as Record<string, unknown>).Ref === "GCP::ProjectId") {
551
+ return env.GCP_PROJECT_ID || undefined;
552
+ }
553
+ if (typeof external !== "string" || external === "") return undefined;
554
+ return external.startsWith("projects/") ? external.slice("projects/".length).split("/")[0] : external;
555
+ }
556
+
446
557
  export function resolveGcpProject(resource: GcpResource, env: NodeJS.ProcessEnv = process.env): string {
447
- const project = env.GOOGLE_CLOUD_PROJECT || resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
558
+ const project =
559
+ projectRefProject(resource.spec, env) ||
560
+ env.GOOGLE_CLOUD_PROJECT ||
561
+ resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
448
562
  if (!project) {
449
563
  throw new Error(
450
- `no GCP project — set GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
564
+ `no GCP project — set spec.projectRef.external, GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
451
565
  );
452
566
  }
453
567
  return project;
@@ -480,12 +594,16 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
480
594
  export async function applyResource(
481
595
  mapper: ResourceMapper,
482
596
  resource: GcpResource,
483
- ctx: { base: string; project: string },
597
+ ctx: MapperCtx,
484
598
  http: GcpHttp = defaultHttp,
485
599
  signal?: AbortSignal,
486
600
  ): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
487
- const plan = mapper.plan(resource, ctx);
488
601
  const name = resource.metadata?.name ?? "?";
602
+ if (mapper.custom) {
603
+ const result = await mapper.custom.apply(resource, ctx, http, signal);
604
+ return { kind: mapper.kind, name, ...result };
605
+ }
606
+ const plan = mapper.plan(resource, ctx);
489
607
  const get = await http("GET", plan.getUrl, undefined, signal);
490
608
 
491
609
  if (get.status === 200) {
@@ -689,7 +807,7 @@ export interface GcpNotPrunable {
689
807
  */
690
808
  export async function pruneOrphans(
691
809
  desired: GcpResource[],
692
- resolve: (mapper: ResourceMapper, resource?: GcpResource) => { base: string; project: string },
810
+ resolve: (mapper: ResourceMapper, resource?: GcpResource) => MapperCtx,
693
811
  http: GcpHttp = defaultHttp,
694
812
  signal?: AbortSignal,
695
813
  ): Promise<{
@@ -757,9 +875,10 @@ export async function gcpApply(
757
875
  }> {
758
876
  const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
759
877
  const endpoint = resolveGcpEndpoint(args);
760
- const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
878
+ const resolve = (mapper: ResourceMapper, resource?: GcpResource): MapperCtx => ({
761
879
  base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
762
880
  project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
881
+ manifest: resources,
763
882
  });
764
883
 
765
884
  const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
@@ -773,6 +892,11 @@ export async function gcpApply(
773
892
  continue;
774
893
  }
775
894
  const ctx = resolve(mapper, r);
895
+ if (mapper.accepts && !mapper.accepts(r, ctx)) {
896
+ console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can apply it to`);
897
+ notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
898
+ continue;
899
+ }
776
900
  const result = await applyResource(mapper, r, ctx, http, signal);
777
901
  const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
778
902
  console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
@@ -793,12 +917,15 @@ export async function gcpApply(
793
917
  export async function deleteResource(
794
918
  mapper: ResourceMapper,
795
919
  resource: GcpResource,
796
- ctx: { base: string; project: string },
920
+ ctx: MapperCtx,
797
921
  http: GcpHttp = defaultHttp,
798
922
  signal?: AbortSignal,
799
923
  ): Promise<{ kind: string; name: string; deleted: boolean }> {
800
- const plan = mapper.plan(resource, ctx);
801
924
  const name = resource.metadata?.name ?? "?";
925
+ if (mapper.custom) {
926
+ return { kind: mapper.kind, name, deleted: await mapper.custom.delete(resource, ctx, http, signal) };
927
+ }
928
+ const plan = mapper.plan(resource, ctx);
802
929
  const res = await http("DELETE", plan.getUrl, undefined, signal);
803
930
  if (res.status === 404) {
804
931
  return { kind: mapper.kind, name, deleted: false };
@@ -847,7 +974,13 @@ export async function gcpDelete(
847
974
  }
848
975
  const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
849
976
  const project = args.project ?? resolveGcpProject(r);
850
- const result = await deleteResource(mapper, r, { base, project }, http, signal);
977
+ const ctx: MapperCtx = { base, project, manifest: resources };
978
+ if (mapper.accepts && !mapper.accepts(r, ctx)) {
979
+ console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can delete it from`);
980
+ notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
981
+ continue;
982
+ }
983
+ const result = await deleteResource(mapper, r, ctx, http, signal);
851
984
  console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
852
985
  deleted.push(result);
853
986
  }