@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
@@ -3,6 +3,8 @@ 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.js";
6
+ import { iamPolicyMemberMapper } from "./gcp-iam.js";
7
+ import { backendServiceMapper, computeAddressMapper, forwardingRuleMapper, regionNetworkEndpointGroupMapper, targetHttpProxyMapper, urlMapMapper, } from "./gcp-compute.js";
6
8
  import { applyResult, } from "@intentius/chant/apply";
7
9
  const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
8
10
  /**
@@ -143,14 +145,40 @@ export const pubSubTopicMapper = {
143
145
  items: (body) => (body?.topics ?? []).map((t) => ({ name: t.name.split("/").pop() ?? t.name, labels: t.labels })),
144
146
  },
145
147
  };
146
- /** Map a CNRM RunService to a Cloud Run v2 Service body. Pure. */
147
- export function cloudRunServiceBody(resource) {
148
+ /**
149
+ * Map a CNRM RunService to a Cloud Run v2 Service body. Pure. CNRM names the
150
+ * service account with `template.serviceAccountRef`; v2 takes
151
+ * `template.serviceAccount` as an email (#4240).
152
+ */
153
+ export function cloudRunServiceBody(resource, project) {
148
154
  const spec = (resource.spec ?? {});
149
155
  const body = {};
150
- if (spec.template)
151
- body.template = spec.template;
156
+ if (!spec.template)
157
+ return body;
158
+ const { serviceAccountRef, ...template } = spec.template;
159
+ const email = serviceAccountEmail(serviceAccountRef, project);
160
+ body.template = email ? { ...template, serviceAccount: email } : template;
152
161
  return body;
153
162
  }
163
+ /**
164
+ * The email a CNRM `serviceAccountRef` names: `external` as given (an email,
165
+ * or a `projects/<p>/serviceAccounts/<email>` path), `name` as an account id
166
+ * in the project.
167
+ */
168
+ export function serviceAccountEmail(ref, project) {
169
+ if (!ref)
170
+ return undefined;
171
+ if (ref.external)
172
+ return ref.external.split("/").pop();
173
+ if (ref.name) {
174
+ if (ref.name.includes("@"))
175
+ return ref.name;
176
+ if (!project)
177
+ throw new Error(`serviceAccountRef { name: "${ref.name}" } needs a project to form the account's email`);
178
+ return `${ref.name}@${project}.iam.gserviceaccount.com`;
179
+ }
180
+ return undefined;
181
+ }
154
182
  export const cloudRunServiceMapper = {
155
183
  kind: "RunService",
156
184
  defaultHost: "https://run.googleapis.com",
@@ -168,11 +196,11 @@ export const cloudRunServiceMapper = {
168
196
  create: {
169
197
  method: "POST",
170
198
  url: `${services}?serviceId=${encodeURIComponent(name)}`,
171
- body: cloudRunServiceBody(resource),
199
+ body: cloudRunServiceBody(resource, project),
172
200
  },
173
201
  // Cloud Run reconciles with a PATCH of the service; like create it returns
174
202
  // a long-running operation, polled via the mapper's `operation`.
175
- update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
203
+ update: { method: "PATCH", url, body: cloudRunServiceBody(resource, project) },
176
204
  };
177
205
  },
178
206
  };
@@ -259,6 +287,14 @@ export const MAPPERS = {
259
287
  SecretManagerSecret: secretManagerSecretMapper,
260
288
  IAMServiceAccount: gcpServiceAccountMapper,
261
289
  RunService: cloudRunServiceMapper,
290
+ // #4233: a binding member, and the Cloud Run load balancer.
291
+ IAMPolicyMember: iamPolicyMemberMapper,
292
+ ComputeAddress: computeAddressMapper,
293
+ ComputeRegionNetworkEndpointGroup: regionNetworkEndpointGroupMapper,
294
+ ComputeBackendService: backendServiceMapper,
295
+ ComputeURLMap: urlMapMapper,
296
+ ComputeTargetHTTPProxy: targetHttpProxyMapper,
297
+ ComputeForwardingRule: forwardingRuleMapper,
262
298
  };
263
299
  /**
264
300
  * Collect the local resource names this resource references via CNRM `*Ref`
@@ -266,7 +302,27 @@ export const MAPPERS = {
266
302
  * refs point outside the manifest and are ignored for ordering. Pure.
267
303
  */
268
304
  export function referencedNames(resource) {
269
- const names = [];
305
+ return [...new Set(referencedResources(resource).map((r) => r.name))];
306
+ }
307
+ /**
308
+ * The CNRM kind a `*Ref` key points at, where the key alone says. A ref that
309
+ * carries its own `kind` (`resourceRef`) uses that. Two resources of different
310
+ * kinds may share a name (a RunService `web` and its NEG `web`), so ordering
311
+ * matches on kind when it can.
312
+ */
313
+ const REF_KINDS = {
314
+ topicRef: "PubSubTopic",
315
+ serviceRef: "RunService",
316
+ serviceAccountRef: "IAMServiceAccount",
317
+ addressRef: "ComputeAddress",
318
+ networkEndpointGroupRef: "ComputeRegionNetworkEndpointGroup",
319
+ backendServiceRef: "ComputeBackendService",
320
+ urlMapRef: "ComputeURLMap",
321
+ targetHTTPProxyRef: "ComputeTargetHTTPProxy",
322
+ };
323
+ /** The local resources this resource references via `*Ref` fields, each with its kind when known. Pure. */
324
+ export function referencedResources(resource) {
325
+ const refs = [];
270
326
  const visit = (v, key) => {
271
327
  if (Array.isArray(v)) {
272
328
  for (const x of v)
@@ -276,14 +332,15 @@ export function referencedNames(resource) {
276
332
  if (v && typeof v === "object") {
277
333
  const obj = v;
278
334
  if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
279
- names.push(obj.name);
335
+ const kind = typeof obj.kind === "string" ? obj.kind : REF_KINDS[key];
336
+ refs.push(kind ? { name: obj.name, kind } : { name: obj.name });
280
337
  }
281
338
  for (const [k, val] of Object.entries(obj))
282
339
  visit(val, k);
283
340
  }
284
341
  };
285
342
  visit(resource.spec);
286
- return [...new Set(names)];
343
+ return refs;
287
344
  }
288
345
  /**
289
346
  * Topologically order resources so a referenced resource comes before the
@@ -296,7 +353,7 @@ export function orderByReferences(resources) {
296
353
  for (const r of resources) {
297
354
  const n = r.metadata?.name;
298
355
  if (n)
299
- byName.set(n, r);
356
+ byName.set(n, [...(byName.get(n) ?? []), r]);
300
357
  }
301
358
  const ordered = [];
302
359
  const done = new Set();
@@ -307,9 +364,10 @@ export function orderByReferences(resources) {
307
364
  if (active.has(r))
308
365
  throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
309
366
  active.add(r);
310
- for (const ref of referencedNames(r)) {
311
- const dep = byName.get(ref);
312
- if (dep && dep !== r)
367
+ for (const ref of referencedResources(r)) {
368
+ const named = (byName.get(ref.name) ?? []).filter((d) => d !== r);
369
+ const deps = ref.kind ? named.filter((d) => d.kind === ref.kind) : named;
370
+ for (const dep of deps)
313
371
  visit(dep);
314
372
  }
315
373
  active.delete(r);
@@ -333,10 +391,33 @@ export function resolveGcpEndpoint(args = {}, env = process.env) {
333
391
  return args.endpoint || env.GCP_ENDPOINT_URL || undefined;
334
392
  }
335
393
  /** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
394
+ /**
395
+ * The project a resource names in `spec.projectRef.external` (#4201): a
396
+ * project id or `projects/<id>`. A `GCP.ProjectId` pseudo-parameter there
397
+ * (`{ Ref: "GCP::ProjectId" }`, unresolved in discovery output) reads
398
+ * `GCP_PROJECT_ID`, as the serializer does.
399
+ */
400
+ export function projectRefProject(spec, env = process.env) {
401
+ const ref = spec?.projectRef;
402
+ if (ref === null || typeof ref !== "object")
403
+ return undefined;
404
+ let external = ref.external;
405
+ if (external !== null && typeof external === "object" && typeof external.toJSON === "function") {
406
+ external = external.toJSON();
407
+ }
408
+ if (external !== null && typeof external === "object" && external.Ref === "GCP::ProjectId") {
409
+ return env.GCP_PROJECT_ID || undefined;
410
+ }
411
+ if (typeof external !== "string" || external === "")
412
+ return undefined;
413
+ return external.startsWith("projects/") ? external.slice("projects/".length).split("/")[0] : external;
414
+ }
336
415
  export function resolveGcpProject(resource, env = process.env) {
337
- const project = env.GOOGLE_CLOUD_PROJECT || resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
416
+ const project = projectRefProject(resource.spec, env) ||
417
+ env.GOOGLE_CLOUD_PROJECT ||
418
+ resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
338
419
  if (!project) {
339
- throw new Error(`no GCP project — set GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`);
420
+ throw new Error(`no GCP project — set spec.projectRef.external, GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`);
340
421
  }
341
422
  return project;
342
423
  }
@@ -356,8 +437,12 @@ const defaultHttp = async (method, url, body, signal) => {
356
437
  * completion. `http` is injectable for tests.
357
438
  */
358
439
  export async function applyResource(mapper, resource, ctx, http = defaultHttp, signal) {
359
- const plan = mapper.plan(resource, ctx);
360
440
  const name = resource.metadata?.name ?? "?";
441
+ if (mapper.custom) {
442
+ const result = await mapper.custom.apply(resource, ctx, http, signal);
443
+ return { kind: mapper.kind, name, ...result };
444
+ }
445
+ const plan = mapper.plan(resource, ctx);
361
446
  const get = await http("GET", plan.getUrl, undefined, signal);
362
447
  if (get.status === 200) {
363
448
  // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
@@ -543,6 +628,7 @@ export async function gcpApply(args, signal, http = defaultHttp) {
543
628
  const resolve = (mapper, resource) => ({
544
629
  base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
545
630
  project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
631
+ manifest: resources,
546
632
  });
547
633
  const applied = [];
548
634
  const notAttempted = [];
@@ -555,6 +641,11 @@ export async function gcpApply(args, signal, http = defaultHttp) {
555
641
  continue;
556
642
  }
557
643
  const ctx = resolve(mapper, r);
644
+ if (mapper.accepts && !mapper.accepts(r, ctx)) {
645
+ console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can apply it to`);
646
+ notAttempted.push({ kind: r.kind, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
647
+ continue;
648
+ }
558
649
  const result = await applyResource(mapper, r, ctx, http, signal);
559
650
  const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
560
651
  console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
@@ -571,8 +662,11 @@ export async function gcpApply(args, signal, http = defaultHttp) {
571
662
  * that is polled to completion. `http` is injectable for tests.
572
663
  */
573
664
  export async function deleteResource(mapper, resource, ctx, http = defaultHttp, signal) {
574
- const plan = mapper.plan(resource, ctx);
575
665
  const name = resource.metadata?.name ?? "?";
666
+ if (mapper.custom) {
667
+ return { kind: mapper.kind, name, deleted: await mapper.custom.delete(resource, ctx, http, signal) };
668
+ }
669
+ const plan = mapper.plan(resource, ctx);
576
670
  const res = await http("DELETE", plan.getUrl, undefined, signal);
577
671
  if (res.status === 404) {
578
672
  return { kind: mapper.kind, name, deleted: false };
@@ -610,7 +704,13 @@ export async function gcpDelete(args, signal, http = defaultHttp) {
610
704
  }
611
705
  const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
612
706
  const project = args.project ?? resolveGcpProject(r);
613
- const result = await deleteResource(mapper, r, { base, project }, http, signal);
707
+ const ctx = { base, project, manifest: resources };
708
+ if (mapper.accepts && !mapper.accepts(r, ctx)) {
709
+ console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can delete it from`);
710
+ notAttempted.push({ kind: r.kind, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
711
+ continue;
712
+ }
713
+ const result = await deleteResource(mapper, r, ctx, http, signal);
614
714
  console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
615
715
  deleted.push(result);
616
716
  }
@@ -0,0 +1,56 @@
1
+ import type { GcpResource, OperationSpec, ResourceMapper } from "./gcp-apply.js";
2
+ export declare const COMPUTE_HOST = "https://compute.googleapis.com";
3
+ type Ref = {
4
+ name?: string;
5
+ external?: string;
6
+ kind?: string;
7
+ };
8
+ type Obj = Record<string, unknown>;
9
+ export interface ComputeCtx {
10
+ base: string;
11
+ project: string;
12
+ manifest?: readonly GcpResource[];
13
+ }
14
+ /** The region or `"global"` a CNRM compute spec declares. A NEG names `region`; the rest `location`. */
15
+ export declare function scopeOf(spec: Obj | undefined, env?: NodeJS.ProcessEnv): string;
16
+ /** The compute name: CNRM's `resourceID` when set, else `metadata.name`. */
17
+ export declare function computeName(resource: GcpResource): string;
18
+ /**
19
+ * The partial URL a `*Ref` resolves to. `external` is used as given when it is
20
+ * a path or URL; a bare name (or `name`) is scoped by the referenced resource's
21
+ * own spec when the manifest holds it, else by `fallbackScope`. Pure.
22
+ */
23
+ export declare function refUrl(key: string, ref: Ref, ctx: ComputeCtx, fallbackScope: string): string;
24
+ /**
25
+ * The resource's labels GCP accepts, plus the ownership marker. Composites put
26
+ * Kubernetes-style keys (`app.kubernetes.io/component`) in `metadata.labels`,
27
+ * which a compute label key can't hold, so those are left out.
28
+ */
29
+ export declare function computeLabels(labels: Record<string, string> | undefined): Record<string, string>;
30
+ /** ComputeAddress → addresses.insert body. Pure. */
31
+ export declare function addressBody(resource: GcpResource, ctx: ComputeCtx): Obj;
32
+ /** ComputeRegionNetworkEndpointGroup → regionNetworkEndpointGroups.insert body (serverless NEGs). Pure. */
33
+ export declare function negBody(resource: GcpResource, ctx: ComputeCtx): Obj;
34
+ /** ComputeBackendService → backendServices.insert body. Pure. */
35
+ export declare function backendServiceBody(resource: GcpResource, ctx: ComputeCtx): Obj;
36
+ /** ComputeURLMap → urlMaps.insert body. Pure. */
37
+ export declare function urlMapBody(resource: GcpResource, ctx: ComputeCtx): Obj;
38
+ /** ComputeTargetHTTPProxy → targetHttpProxies.insert body. Pure. */
39
+ export declare function targetHttpProxyBody(resource: GcpResource, ctx: ComputeCtx): Obj;
40
+ /** ComputeForwardingRule → forwardingRules.insert body. Pure. */
41
+ export declare function forwardingRuleBody(resource: GcpResource, ctx: ComputeCtx): Obj;
42
+ /**
43
+ * A compute `Operation`: the insert or delete returns one with a `selfLink`,
44
+ * polled until `status: "DONE"`. The link is rebased onto `base`, so an
45
+ * endpoint override (floci-gcp) is polled too. A create that returned the
46
+ * resource itself has nothing to poll.
47
+ */
48
+ export declare const computeOperation: OperationSpec;
49
+ export declare const computeAddressMapper: ResourceMapper;
50
+ export declare const regionNetworkEndpointGroupMapper: ResourceMapper;
51
+ export declare const backendServiceMapper: ResourceMapper;
52
+ export declare const urlMapMapper: ResourceMapper;
53
+ export declare const targetHttpProxyMapper: ResourceMapper;
54
+ export declare const forwardingRuleMapper: ResourceMapper;
55
+ export {};
56
+ //# sourceMappingURL=gcp-compute.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gcp-compute.d.ts","sourceRoot":"","sources":["../../../src/op/activities/gcp-compute.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE9E,eAAO,MAAM,YAAY,mCAAmC,CAAC;AAI7D,KAAK,GAAG,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAC/D,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAqBnC,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;CACnC;AAED,wGAAwG;AACxG,wBAAgB,OAAO,CAAC,IAAI,EAAE,GAAG,GAAG,SAAS,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAG3F;AAMD,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,QAAQ,EAAE,WAAW,GAAG,MAAM,CAKzD;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAmB5F;AAkBD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMhG;AAED,oDAAoD;AACpD,wBAAgB,WAAW,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,GAAG,CAWvE;AAED,2GAA2G;AAC3G,wBAAgB,OAAO,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,GAAG,CA0BnE;AAED,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,GAAG,CAoC9E;AAED,iDAAiD;AACjD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,GAAG,CA4BtE;AAED,oEAAoE;AACpE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,GAAG,CAK/E;AAED,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,GAAG,CAoB9E;AAED;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,aAe9B,CAAC;AAqBF,eAAO,MAAM,oBAAoB,gBAA4D,CAAC;AAC9F,eAAO,MAAM,gCAAgC,gBAAuF,CAAC;AACrI,eAAO,MAAM,oBAAoB,gBAAgF,CAAC;AAClH,eAAO,MAAM,YAAY,gBAAwD,CAAC;AAClF,eAAO,MAAM,qBAAqB,gBAAoF,CAAC;AACvH,eAAO,MAAM,oBAAoB,gBAAgF,CAAC"}
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Compute Engine mappers for the Cloud Run load balancer (#4233): ComputeAddress,
3
+ * ComputeRegionNetworkEndpointGroup (serverless NEGs), ComputeBackendService,
4
+ * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule.
5
+ *
6
+ * Each maps a CNRM spec onto the compute v1 insert body. A resource is global
7
+ * or regional by its CNRM `location` (`"global"` or a region; a NEG has
8
+ * `region`), and that picks `projects/<p>/global/<collection>` or
9
+ * `projects/<p>/regions/<r>/<collection>`. `*Ref` fields become the referenced
10
+ * resource's partial URL, which compute accepts; a ref to a resource in the
11
+ * same manifest takes its scope from that resource's own spec, so a global
12
+ * backend service can point at a regional NEG.
13
+ *
14
+ * Inserts return a compute `Operation`, polled at its `selfLink` (rebased onto
15
+ * the endpoint override, so floci-gcp answers). These kinds reconcile by
16
+ * existence only: an existing resource is left as it is, since compute updates
17
+ * need the live `fingerprint` and most of these fields are immutable.
18
+ */
19
+ import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership.js";
20
+ import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
21
+ export const COMPUTE_HOST = "https://compute.googleapis.com";
22
+ const enc = encodeURIComponent;
23
+ function isObj(v) {
24
+ return v !== null && typeof v === "object" && !Array.isArray(v);
25
+ }
26
+ /** What a `*Ref` key points at: the CNRM kind (for a manifest lookup), the collection, and whether it is global only. */
27
+ const REF_TARGETS = {
28
+ addressRef: { kind: "ComputeAddress", collection: "addresses", scope: "either" },
29
+ networkEndpointGroupRef: { kind: "ComputeRegionNetworkEndpointGroup", collection: "networkEndpointGroups", scope: "regional" },
30
+ backendServiceRef: { kind: "ComputeBackendService", collection: "backendServices", scope: "either" },
31
+ backendBucketRef: { kind: "ComputeBackendBucket", collection: "backendBuckets", scope: "global" },
32
+ urlMapRef: { kind: "ComputeURLMap", collection: "urlMaps", scope: "either" },
33
+ targetHTTPProxyRef: { kind: "ComputeTargetHTTPProxy", collection: "targetHttpProxies", scope: "either" },
34
+ targetHTTPSProxyRef: { kind: "ComputeTargetHTTPSProxy", collection: "targetHttpsProxies", scope: "either" },
35
+ healthCheckRef: { kind: "ComputeHealthCheck", collection: "healthChecks", scope: "either" },
36
+ securityPolicyRef: { kind: "ComputeSecurityPolicy", collection: "securityPolicies", scope: "global" },
37
+ networkRef: { kind: "ComputeNetwork", collection: "networks", scope: "global" },
38
+ subnetworkRef: { kind: "ComputeSubnetwork", collection: "subnetworks", scope: "regional" },
39
+ };
40
+ /** The region or `"global"` a CNRM compute spec declares. A NEG names `region`; the rest `location`. */
41
+ export function scopeOf(spec, env = process.env) {
42
+ const loc = spec?.location ?? spec?.region;
43
+ return typeof loc === "string" && loc !== "" ? loc : (env.GCP_REGION || "us-central1");
44
+ }
45
+ function scopePath(project, scope) {
46
+ return scope === "global" ? `projects/${enc(project)}/global` : `projects/${enc(project)}/regions/${enc(scope)}`;
47
+ }
48
+ /** The compute name: CNRM's `resourceID` when set, else `metadata.name`. */
49
+ export function computeName(resource) {
50
+ const id = resource.spec?.resourceID;
51
+ const name = typeof id === "string" && id !== "" ? id : resource.metadata?.name;
52
+ if (!name)
53
+ throw new Error(`${resource.kind ?? "compute resource"} has no metadata.name`);
54
+ return name;
55
+ }
56
+ /**
57
+ * The partial URL a `*Ref` resolves to. `external` is used as given when it is
58
+ * a path or URL; a bare name (or `name`) is scoped by the referenced resource's
59
+ * own spec when the manifest holds it, else by `fallbackScope`. Pure.
60
+ */
61
+ export function refUrl(key, ref, ctx, fallbackScope) {
62
+ const target = REF_TARGETS[key];
63
+ if (!target)
64
+ throw new Error(`no compute collection for ${key}`);
65
+ if (typeof ref.external === "string" && ref.external !== "") {
66
+ if (ref.external.startsWith("projects/") || ref.external.startsWith("https://"))
67
+ return ref.external;
68
+ }
69
+ const name = ref.name ?? ref.external;
70
+ if (!name)
71
+ throw new Error(`${key} has no name or external`);
72
+ const declared = ctx.manifest?.find((r) => r.kind === target.kind && (r.metadata?.name === name || r.spec?.resourceID === name));
73
+ const scope = target.scope === "global"
74
+ ? "global"
75
+ : declared
76
+ ? scopeOf(declared.spec)
77
+ : target.scope === "regional" && fallbackScope === "global"
78
+ ? scopeOf(undefined)
79
+ : fallbackScope;
80
+ const resolvedName = declared ? computeName(declared) : name;
81
+ return `${scopePath(ctx.project, scope)}/${target.collection}/${resolvedName}`;
82
+ }
83
+ /** Resolve the one `*Ref` key an object holds (`{ backendServiceRef: {...} }` → URL), or undefined. */
84
+ function oneRef(value, ctx, scope) {
85
+ if (!isObj(value))
86
+ return undefined;
87
+ for (const [k, v] of Object.entries(value)) {
88
+ if (REF_TARGETS[k] && isObj(v))
89
+ return refUrl(k, v, ctx, scope);
90
+ }
91
+ return undefined;
92
+ }
93
+ /** Copy the listed fields that are set. */
94
+ function pick(spec, keys) {
95
+ const out = {};
96
+ for (const k of keys)
97
+ if (spec[k] !== undefined)
98
+ out[k] = spec[k];
99
+ return out;
100
+ }
101
+ /**
102
+ * The resource's labels GCP accepts, plus the ownership marker. Composites put
103
+ * Kubernetes-style keys (`app.kubernetes.io/component`) in `metadata.labels`,
104
+ * which a compute label key can't hold, so those are left out.
105
+ */
106
+ export function computeLabels(labels) {
107
+ const out = {};
108
+ for (const [k, v] of Object.entries(labels ?? {})) {
109
+ if (/^[a-z][a-z0-9_-]{0,62}$/.test(k) && typeof v === "string" && /^[a-z0-9_-]{0,63}$/.test(v))
110
+ out[k] = v;
111
+ }
112
+ return { ...out, [GCP_RESOURCE_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
113
+ }
114
+ /** ComputeAddress → addresses.insert body. Pure. */
115
+ export function addressBody(resource, ctx) {
116
+ const spec = (resource.spec ?? {});
117
+ const scope = scopeOf(spec);
118
+ const body = {
119
+ name: computeName(resource),
120
+ ...pick(spec, ["address", "addressType", "description", "ipVersion", "ipv6EndpointType", "networkTier", "prefixLength", "purpose"]),
121
+ labels: computeLabels(resource.metadata?.labels),
122
+ };
123
+ if (isObj(spec.networkRef))
124
+ body.network = refUrl("networkRef", spec.networkRef, ctx, scope);
125
+ if (isObj(spec.subnetworkRef))
126
+ body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef, ctx, scope);
127
+ return body;
128
+ }
129
+ /** ComputeRegionNetworkEndpointGroup → regionNetworkEndpointGroups.insert body (serverless NEGs). Pure. */
130
+ export function negBody(resource, ctx) {
131
+ const spec = (resource.spec ?? {});
132
+ const scope = scopeOf(spec);
133
+ const body = {
134
+ name: computeName(resource),
135
+ networkEndpointType: spec.networkEndpointType ?? "SERVERLESS",
136
+ ...pick(spec, ["description", "pscTargetService"]),
137
+ };
138
+ const serviceName = (ref) => {
139
+ if (!isObj(ref))
140
+ return undefined;
141
+ const r = ref;
142
+ // A serverless NEG names the service, not its path.
143
+ return (r.external ?? r.name)?.split("/").pop();
144
+ };
145
+ if (isObj(spec.cloudRun)) {
146
+ const c = spec.cloudRun;
147
+ body.cloudRun = { ...pick(c, ["tag", "urlMask"]), ...(serviceName(c.serviceRef) ? { service: serviceName(c.serviceRef) } : {}) };
148
+ }
149
+ if (isObj(spec.cloudFunction)) {
150
+ const c = spec.cloudFunction;
151
+ body.cloudFunction = { ...pick(c, ["urlMask"]), ...(serviceName(c.functionRef) ? { function: serviceName(c.functionRef) } : {}) };
152
+ }
153
+ if (isObj(spec.appEngine))
154
+ body.appEngine = spec.appEngine;
155
+ if (isObj(spec.networkRef))
156
+ body.network = refUrl("networkRef", spec.networkRef, ctx, scope);
157
+ if (isObj(spec.subnetworkRef))
158
+ body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef, ctx, scope);
159
+ return body;
160
+ }
161
+ /** ComputeBackendService → backendServices.insert body. Pure. */
162
+ export function backendServiceBody(resource, ctx) {
163
+ const spec = (resource.spec ?? {});
164
+ const scope = scopeOf(spec);
165
+ const body = {
166
+ name: computeName(resource),
167
+ ...pick(spec, [
168
+ "affinityCookieTtlSec",
169
+ "cdnPolicy",
170
+ "compressionMode",
171
+ "customRequestHeaders",
172
+ "customResponseHeaders",
173
+ "description",
174
+ "loadBalancingScheme",
175
+ "localityLbPolicy",
176
+ "logConfig",
177
+ "portName",
178
+ "protocol",
179
+ "sessionAffinity",
180
+ "timeoutSec",
181
+ ]),
182
+ };
183
+ if (spec.enableCdn !== undefined)
184
+ body.enableCDN = spec.enableCdn;
185
+ if (spec.connectionDrainingTimeoutSec !== undefined)
186
+ body.connectionDraining = { drainingTimeoutSec: spec.connectionDrainingTimeoutSec };
187
+ if (Array.isArray(spec.backend)) {
188
+ body.backends = spec.backend.map((b) => {
189
+ const { group, ...rest } = b;
190
+ const groupUrl = isObj(group) ? oneRef(group, ctx, scope) ?? group.instanceGroupRef?.external : undefined;
191
+ return { ...rest, ...(groupUrl ? { group: groupUrl } : {}) };
192
+ });
193
+ }
194
+ if (Array.isArray(spec.healthChecks)) {
195
+ body.healthChecks = spec.healthChecks.map((h) => oneRef(h, ctx, scope)).filter((u) => !!u);
196
+ }
197
+ if (isObj(spec.securityPolicyRef))
198
+ body.securityPolicy = refUrl("securityPolicyRef", spec.securityPolicyRef, ctx, scope);
199
+ else if (typeof spec.securityPolicy === "string")
200
+ body.securityPolicy = spec.securityPolicy;
201
+ return body;
202
+ }
203
+ /** ComputeURLMap → urlMaps.insert body. Pure. */
204
+ export function urlMapBody(resource, ctx) {
205
+ const spec = (resource.spec ?? {});
206
+ const scope = scopeOf(spec);
207
+ const service = (v) => oneRef(v, ctx, scope);
208
+ const body = {
209
+ name: computeName(resource),
210
+ ...pick(spec, ["description", "defaultUrlRedirect", "headerAction"]),
211
+ };
212
+ const def = service(spec.defaultService);
213
+ if (def)
214
+ body.defaultService = def;
215
+ if (Array.isArray(spec.hostRule))
216
+ body.hostRules = spec.hostRule;
217
+ if (Array.isArray(spec.pathMatcher)) {
218
+ body.pathMatchers = spec.pathMatcher.map((m) => {
219
+ const { defaultService, pathRule, ...rest } = m;
220
+ const out = { ...rest };
221
+ const d = service(defaultService);
222
+ if (d)
223
+ out.defaultService = d;
224
+ if (Array.isArray(pathRule)) {
225
+ out.pathRules = pathRule.map((r) => {
226
+ const { service: s, ...rr } = r;
227
+ const u = service(s);
228
+ return { ...rr, ...(u ? { service: u } : {}) };
229
+ });
230
+ }
231
+ return out;
232
+ });
233
+ }
234
+ return body;
235
+ }
236
+ /** ComputeTargetHTTPProxy → targetHttpProxies.insert body. Pure. */
237
+ export function targetHttpProxyBody(resource, ctx) {
238
+ const spec = (resource.spec ?? {});
239
+ const body = { name: computeName(resource), ...pick(spec, ["description", "proxyBind", "httpKeepAliveTimeoutSec"]) };
240
+ if (isObj(spec.urlMapRef))
241
+ body.urlMap = refUrl("urlMapRef", spec.urlMapRef, ctx, scopeOf(spec));
242
+ return body;
243
+ }
244
+ /** ComputeForwardingRule → forwardingRules.insert body. Pure. */
245
+ export function forwardingRuleBody(resource, ctx) {
246
+ const spec = (resource.spec ?? {});
247
+ const scope = scopeOf(spec);
248
+ const body = {
249
+ name: computeName(resource),
250
+ ...pick(spec, ["allowGlobalAccess", "description", "ipVersion", "isMirroringCollector", "loadBalancingScheme", "networkTier", "portRange", "ports", "allPorts"]),
251
+ labels: computeLabels(resource.metadata?.labels),
252
+ };
253
+ if (spec.ipProtocol !== undefined)
254
+ body.IPProtocol = spec.ipProtocol;
255
+ if (isObj(spec.ipAddress)) {
256
+ const ip = spec.ipAddress;
257
+ const addr = isObj(ip.addressRef) ? refUrl("addressRef", ip.addressRef, ctx, scope) : ip.ip;
258
+ if (addr)
259
+ body.IPAddress = addr;
260
+ }
261
+ const target = oneRef(spec.target, ctx, scope);
262
+ if (target)
263
+ body.target = target;
264
+ if (isObj(spec.backendServiceRef))
265
+ body.backendService = refUrl("backendServiceRef", spec.backendServiceRef, ctx, scope);
266
+ if (isObj(spec.networkRef))
267
+ body.network = refUrl("networkRef", spec.networkRef, ctx, scope);
268
+ if (isObj(spec.subnetworkRef))
269
+ body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef, ctx, scope);
270
+ return body;
271
+ }
272
+ /**
273
+ * A compute `Operation`: the insert or delete returns one with a `selfLink`,
274
+ * polled until `status: "DONE"`. The link is rebased onto `base`, so an
275
+ * endpoint override (floci-gcp) is polled too. A create that returned the
276
+ * resource itself has nothing to poll.
277
+ */
278
+ export const computeOperation = {
279
+ pollUrl(body, { base }) {
280
+ const op = body;
281
+ const link = op?.selfLink;
282
+ if (!link || !link.includes("/operations/"))
283
+ return undefined;
284
+ const at = link.indexOf("/compute/v1/");
285
+ return at >= 0 ? `${base}${link.slice(at)}` : link;
286
+ },
287
+ isDone(body) {
288
+ return body?.status === "DONE";
289
+ },
290
+ error(body) {
291
+ const errors = body?.error?.errors;
292
+ return errors?.length ? errors.map((e) => e.message ?? e.code ?? "error").join("; ") : undefined;
293
+ },
294
+ };
295
+ function computeMapper(kind, collection, build, regionalCollection = collection) {
296
+ return {
297
+ kind,
298
+ defaultHost: COMPUTE_HOST,
299
+ operation: computeOperation,
300
+ plan(resource, ctx) {
301
+ const scope = scopeOf(resource.spec);
302
+ const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
303
+ const name = computeName(resource);
304
+ return { getUrl: `${coll}/${enc(name)}`, create: { method: "POST", url: coll, body: build(resource, ctx) } };
305
+ },
306
+ };
307
+ }
308
+ export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody);
309
+ export const regionNetworkEndpointGroupMapper = computeMapper("ComputeRegionNetworkEndpointGroup", "networkEndpointGroups", negBody);
310
+ export const backendServiceMapper = computeMapper("ComputeBackendService", "backendServices", backendServiceBody);
311
+ export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody);
312
+ export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody);
313
+ export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody);