@intentius/chant-lexicon-gcp 0.124.34 → 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.
@@ -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);
@@ -503,12 +594,16 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
503
594
  export async function applyResource(
504
595
  mapper: ResourceMapper,
505
596
  resource: GcpResource,
506
- ctx: { base: string; project: string },
597
+ ctx: MapperCtx,
507
598
  http: GcpHttp = defaultHttp,
508
599
  signal?: AbortSignal,
509
600
  ): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
510
- const plan = mapper.plan(resource, ctx);
511
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);
512
607
  const get = await http("GET", plan.getUrl, undefined, signal);
513
608
 
514
609
  if (get.status === 200) {
@@ -712,7 +807,7 @@ export interface GcpNotPrunable {
712
807
  */
713
808
  export async function pruneOrphans(
714
809
  desired: GcpResource[],
715
- resolve: (mapper: ResourceMapper, resource?: GcpResource) => { base: string; project: string },
810
+ resolve: (mapper: ResourceMapper, resource?: GcpResource) => MapperCtx,
716
811
  http: GcpHttp = defaultHttp,
717
812
  signal?: AbortSignal,
718
813
  ): Promise<{
@@ -780,9 +875,10 @@ export async function gcpApply(
780
875
  }> {
781
876
  const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
782
877
  const endpoint = resolveGcpEndpoint(args);
783
- const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
878
+ const resolve = (mapper: ResourceMapper, resource?: GcpResource): MapperCtx => ({
784
879
  base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
785
880
  project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
881
+ manifest: resources,
786
882
  });
787
883
 
788
884
  const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
@@ -796,6 +892,11 @@ export async function gcpApply(
796
892
  continue;
797
893
  }
798
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
+ }
799
900
  const result = await applyResource(mapper, r, ctx, http, signal);
800
901
  const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
801
902
  console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
@@ -816,12 +917,15 @@ export async function gcpApply(
816
917
  export async function deleteResource(
817
918
  mapper: ResourceMapper,
818
919
  resource: GcpResource,
819
- ctx: { base: string; project: string },
920
+ ctx: MapperCtx,
820
921
  http: GcpHttp = defaultHttp,
821
922
  signal?: AbortSignal,
822
923
  ): Promise<{ kind: string; name: string; deleted: boolean }> {
823
- const plan = mapper.plan(resource, ctx);
824
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);
825
929
  const res = await http("DELETE", plan.getUrl, undefined, signal);
826
930
  if (res.status === 404) {
827
931
  return { kind: mapper.kind, name, deleted: false };
@@ -870,7 +974,13 @@ export async function gcpDelete(
870
974
  }
871
975
  const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
872
976
  const project = args.project ?? resolveGcpProject(r);
873
- 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);
874
984
  console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
875
985
  deleted.push(result);
876
986
  }
@@ -0,0 +1,212 @@
1
+ import { beforeEach, describe, expect, test } from "vitest";
2
+ import { applyResource, deleteResource, MAPPERS, orderByReferences, type GcpHttp, type GcpResource } from "./gcp-apply";
3
+ import { computeLabels, computeOperation } from "./gcp-compute";
4
+ import { getResource } from "../../api/read-client";
5
+
6
+ beforeEach(() => {
7
+ delete process.env.GCP_REGION;
8
+ });
9
+
10
+ /** The serverless load balancer behold's three-cloud demo declares, in the shape the serializer writes. */
11
+ const lb: GcpResource[] = [
12
+ {
13
+ kind: "ComputeForwardingRule",
14
+ metadata: { name: "web-fr", labels: { "app.kubernetes.io/component": "lb", tier: "edge" } },
15
+ spec: {
16
+ location: "global",
17
+ loadBalancingScheme: "EXTERNAL_MANAGED",
18
+ portRange: "80",
19
+ ipProtocol: "TCP",
20
+ ipAddress: { addressRef: { name: "web-ip" } },
21
+ target: { targetHTTPProxyRef: { name: "web-proxy" } },
22
+ },
23
+ },
24
+ { kind: "ComputeTargetHTTPProxy", metadata: { name: "web-proxy" }, spec: { location: "global", urlMapRef: { name: "web-map" } } },
25
+ { kind: "ComputeURLMap", metadata: { name: "web-map" }, spec: { location: "global", defaultService: { backendServiceRef: { name: "web" } } } },
26
+ {
27
+ kind: "ComputeBackendService",
28
+ metadata: { name: "web" },
29
+ spec: {
30
+ location: "global",
31
+ loadBalancingScheme: "EXTERNAL_MANAGED",
32
+ protocol: "HTTPS",
33
+ enableCdn: false,
34
+ backend: [{ group: { networkEndpointGroupRef: { name: "web" } } }],
35
+ },
36
+ },
37
+ {
38
+ kind: "ComputeRegionNetworkEndpointGroup",
39
+ metadata: { name: "web" },
40
+ spec: { region: "europe-west1", networkEndpointType: "SERVERLESS", cloudRun: { serviceRef: { name: "web" } } },
41
+ },
42
+ { kind: "ComputeAddress", metadata: { name: "web-ip" }, spec: { location: "global" } },
43
+ { kind: "RunService", metadata: { name: "web" }, spec: { location: "europe-west1" } },
44
+ ];
45
+
46
+ const ctx = { base: "https://compute.googleapis.com", project: "p1", manifest: lb };
47
+ const byKind = (kind: string) => lb.find((r) => r.kind === kind)!;
48
+ const plan = (kind: string) => MAPPERS[kind].plan(byKind(kind), ctx);
49
+
50
+ describe("compute load balancer mappers (#4233)", () => {
51
+ test("apply order: service, NEG and address before the backend, map, proxy and forwarding rule", () => {
52
+ const order = orderByReferences(lb).map((r) => r.kind);
53
+ const at = (k: string) => order.indexOf(k);
54
+ expect(at("RunService")).toBeLessThan(at("ComputeRegionNetworkEndpointGroup"));
55
+ expect(at("ComputeRegionNetworkEndpointGroup")).toBeLessThan(at("ComputeBackendService"));
56
+ expect(at("ComputeBackendService")).toBeLessThan(at("ComputeURLMap"));
57
+ expect(at("ComputeURLMap")).toBeLessThan(at("ComputeTargetHTTPProxy"));
58
+ expect(at("ComputeTargetHTTPProxy")).toBeLessThan(at("ComputeForwardingRule"));
59
+ expect(at("ComputeAddress")).toBeLessThan(at("ComputeForwardingRule"));
60
+ });
61
+
62
+ test("a serverless NEG is regional and names its Cloud Run service", () => {
63
+ expect(plan("ComputeRegionNetworkEndpointGroup")).toEqual({
64
+ getUrl: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web",
65
+ create: {
66
+ method: "POST",
67
+ url: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups",
68
+ body: { name: "web", networkEndpointType: "SERVERLESS", cloudRun: { service: "web" } },
69
+ },
70
+ });
71
+ });
72
+
73
+ test("a global backend service points at the regional NEG by the NEG's own region", () => {
74
+ expect(plan("ComputeBackendService").create).toEqual({
75
+ method: "POST",
76
+ url: "https://compute.googleapis.com/compute/v1/projects/p1/global/backendServices",
77
+ body: {
78
+ name: "web",
79
+ loadBalancingScheme: "EXTERNAL_MANAGED",
80
+ protocol: "HTTPS",
81
+ enableCDN: false,
82
+ backends: [{ group: "projects/p1/regions/europe-west1/networkEndpointGroups/web" }],
83
+ },
84
+ });
85
+ });
86
+
87
+ test("URL map, proxy and forwarding rule resolve their refs to partial URLs", () => {
88
+ expect(plan("ComputeURLMap").create.body).toEqual({ name: "web-map", defaultService: "projects/p1/global/backendServices/web" });
89
+ expect(plan("ComputeTargetHTTPProxy").create.body).toEqual({ name: "web-proxy", urlMap: "projects/p1/global/urlMaps/web-map" });
90
+ expect(plan("ComputeForwardingRule").create.body).toEqual({
91
+ name: "web-fr",
92
+ loadBalancingScheme: "EXTERNAL_MANAGED",
93
+ portRange: "80",
94
+ IPProtocol: "TCP",
95
+ IPAddress: "projects/p1/global/addresses/web-ip",
96
+ target: "projects/p1/global/targetHttpProxies/web-proxy",
97
+ labels: { tier: "edge", "managed-by": "chant" },
98
+ });
99
+ });
100
+
101
+ test("a global address goes to the global collection, with the ownership label", () => {
102
+ expect(plan("ComputeAddress")).toMatchObject({
103
+ getUrl: "https://compute.googleapis.com/compute/v1/projects/p1/global/addresses/web-ip",
104
+ create: { body: { name: "web-ip", labels: { "managed-by": "chant" } } },
105
+ });
106
+ });
107
+
108
+ test("labels GCP refuses are left out", () => {
109
+ expect(computeLabels({ "app.kubernetes.io/name": "x", Bad: "y", ok: "Value", good: "v-1" })).toEqual({ good: "v-1", "managed-by": "chant" });
110
+ });
111
+
112
+ test("an external ref is used as given", () => {
113
+ const proxy: GcpResource = {
114
+ kind: "ComputeTargetHTTPProxy",
115
+ metadata: { name: "p" },
116
+ spec: { location: "global", urlMapRef: { external: "projects/shared/global/urlMaps/m" } },
117
+ };
118
+ expect(MAPPERS.ComputeTargetHTTPProxy.plan(proxy, { base: "b", project: "p1" }).create.body).toEqual({
119
+ name: "p",
120
+ urlMap: "projects/shared/global/urlMaps/m",
121
+ });
122
+ });
123
+ });
124
+
125
+ describe("compute operations (#4233)", () => {
126
+ test("the selfLink is rebased onto an endpoint override, and DONE ends the poll", () => {
127
+ const op = { kind: "compute#operation", selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1", status: "RUNNING" };
128
+ expect(computeOperation.pollUrl(op, { base: "http://localhost:4588", project: "p1" })).toBe(
129
+ "http://localhost:4588/compute/v1/projects/p1/global/operations/op-1",
130
+ );
131
+ expect(computeOperation.isDone({ status: "DONE" })).toBe(true);
132
+ expect(computeOperation.error({ status: "DONE", error: { errors: [{ message: "quota" }] } })).toBe("quota");
133
+ expect(computeOperation.pollUrl({ name: "web" }, { base: "b", project: "p1" })).toBeUndefined();
134
+ });
135
+
136
+ test("apply inserts, polls the operation, and leaves an existing resource alone", async () => {
137
+ const calls: string[] = [];
138
+ let exists = false;
139
+ const http: GcpHttp = async (method, url) => {
140
+ calls.push(`${method} ${url}`);
141
+ if (method === "GET" && url.endsWith("/addresses/web-ip")) return exists ? { status: 200, text: "{}" } : { status: 404, text: "" };
142
+ if (method === "POST") {
143
+ exists = true;
144
+ return { status: 200, text: JSON.stringify({ kind: "compute#operation", selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1", status: "PENDING" }) };
145
+ }
146
+ if (url.includes("/operations/")) return { status: 200, text: JSON.stringify({ status: "DONE" }) };
147
+ return { status: 500, text: "" };
148
+ };
149
+ const created = await applyResource(MAPPERS.ComputeAddress, byKind("ComputeAddress"), ctx, http);
150
+ expect(created.created).toBe(true);
151
+ expect(calls).toContain("GET https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1");
152
+ const again = await applyResource(MAPPERS.ComputeAddress, byKind("ComputeAddress"), ctx, http);
153
+ expect(again).toMatchObject({ created: false, updated: false });
154
+ });
155
+
156
+ test("delete removes the resource at its scoped URL", async () => {
157
+ const calls: string[] = [];
158
+ const http: GcpHttp = async (method, url) => {
159
+ calls.push(`${method} ${url}`);
160
+ return { status: 200, text: JSON.stringify({ status: "DONE" }) };
161
+ };
162
+ await deleteResource(MAPPERS.ComputeRegionNetworkEndpointGroup, byKind("ComputeRegionNetworkEndpointGroup"), ctx, http);
163
+ expect(calls[0]).toBe("DELETE https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web");
164
+ });
165
+
166
+ test("the reader reads a load balancer kind through the same URL", async () => {
167
+ const http = async (_m: string, url: string) =>
168
+ url.endsWith("/global/urlMaps/web-map") ? { status: 200, text: JSON.stringify({ name: "web-map" }) } : { status: 404, text: "" };
169
+ const body = await getResource({ project: "p1", http }, "ComputeURLMap", "web-map", byKind("ComputeURLMap").spec);
170
+ expect(body).toEqual({ name: "web-map" });
171
+ });
172
+ });
173
+
174
+ describe("deep observe reads a load balancer back in the declared vocabulary (#4233)", () => {
175
+ test("refs, renamed fields and the scope invert the body builders", async () => {
176
+ const { toCnrmTree } = await import("../../deep-observe");
177
+ const c = { project: "p1" };
178
+ expect(
179
+ toCnrmTree(
180
+ "ComputeBackendService",
181
+ {
182
+ name: "web",
183
+ selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/backendServices/web",
184
+ enableCDN: false,
185
+ protocol: "HTTPS",
186
+ backends: [{ group: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web" }],
187
+ },
188
+ c,
189
+ ),
190
+ ).toEqual({
191
+ metadata: { name: "web" },
192
+ selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/backendServices/web",
193
+ enableCdn: false,
194
+ protocol: "HTTPS",
195
+ location: "global",
196
+ backend: [{ group: { networkEndpointGroupRef: { name: "web" } } }],
197
+ });
198
+ expect(
199
+ toCnrmTree(
200
+ "ComputeRegionNetworkEndpointGroup",
201
+ { name: "web", region: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1", cloudRun: { service: "web" } },
202
+ c,
203
+ ),
204
+ ).toMatchObject({ region: "europe-west1", cloudRun: { serviceRef: { name: "web" } } });
205
+ expect(
206
+ toCnrmTree("ComputeForwardingRule", { name: "fr", IPProtocol: "TCP", target: "projects/p1/global/targetHttpProxies/web-proxy" }, c),
207
+ ).toMatchObject({ ipProtocol: "TCP", location: "global", target: { targetHTTPProxyRef: { name: "web-proxy" } } });
208
+ expect(toCnrmTree("ComputeURLMap", { name: "m", defaultService: "projects/p1/global/backendServices/web" }, c)).toMatchObject({
209
+ defaultService: { backendServiceRef: { name: "web" } },
210
+ });
211
+ });
212
+ });