@intentius/chant-lexicon-gcp 0.124.34 → 0.124.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/api/read-client.d.ts +8 -3
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/api/read-client.js +37 -15
  4. package/dist/composites/cloud-run-service.d.ts +2 -0
  5. package/dist/composites/cloud-run-service.d.ts.map +1 -1
  6. package/dist/composites/cloud-run-service.js +4 -1
  7. package/dist/deep-observe.d.ts.map +1 -1
  8. package/dist/deep-observe.js +176 -0
  9. package/dist/integrity.json +2 -2
  10. package/dist/manifest.json +1 -1
  11. package/dist/op/activities/gcp-apply.d.ts +62 -18
  12. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  13. package/dist/op/activities/gcp-apply.js +128 -16
  14. package/dist/op/activities/gcp-compute.d.ts +66 -0
  15. package/dist/op/activities/gcp-compute.d.ts.map +1 -0
  16. package/dist/op/activities/gcp-compute.js +349 -0
  17. package/dist/op/activities/gcp-iam.d.ts +82 -0
  18. package/dist/op/activities/gcp-iam.d.ts.map +1 -0
  19. package/dist/op/activities/gcp-iam.js +215 -0
  20. package/dist/serializer.d.ts +6 -0
  21. package/dist/serializer.d.ts.map +1 -1
  22. package/dist/serializer.js +21 -9
  23. package/package.json +2 -2
  24. package/src/api/read-client.ts +36 -16
  25. package/src/composites/cloud-run-service.ts +6 -0
  26. package/src/composites/composites.test.ts +7 -0
  27. package/src/deep-observe.test.ts +42 -1
  28. package/src/deep-observe.ts +171 -0
  29. package/src/describe-resources.test.ts +1 -1
  30. package/src/op/activities/gcp-apply.test.ts +75 -0
  31. package/src/op/activities/gcp-apply.ts +167 -22
  32. package/src/op/activities/gcp-compute.test.ts +306 -0
  33. package/src/op/activities/gcp-compute.ts +354 -0
  34. package/src/op/activities/gcp-iam.test.ts +152 -0
  35. package/src/op/activities/gcp-iam.ts +284 -0
  36. package/src/serializer.test.ts +26 -0
  37. package/src/serializer.ts +22 -9
@@ -0,0 +1,306 @@
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
+ });
213
+
214
+ describe("network and subnetwork mappers (#4248)", () => {
215
+ const net: GcpResource[] = [
216
+ {
217
+ kind: "ComputeSubnetwork",
218
+ metadata: { name: "apps" },
219
+ spec: {
220
+ region: "europe-west1",
221
+ ipCidrRange: "10.0.0.0/24",
222
+ networkRef: { name: "vpc" },
223
+ privateIpGoogleAccess: true,
224
+ secondaryIpRange: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
225
+ },
226
+ },
227
+ { kind: "ComputeNetwork", metadata: { name: "vpc" }, spec: { routingMode: "REGIONAL" } },
228
+ {
229
+ kind: "RunService",
230
+ metadata: { name: "web" },
231
+ spec: { location: "europe-west1", template: { vpcAccess: { networkInterfaces: [{ networkRef: { name: "vpc" }, subnetworkRef: { name: "apps" } }] } } },
232
+ },
233
+ ];
234
+ const nctx = { base: "https://compute.googleapis.com", project: "p1", manifest: net };
235
+ const find = (kind: string) => net.find((r) => r.kind === kind)!;
236
+
237
+ test("a network is global, with autoCreateSubnetworks false unless declared and the routing mode nested", () => {
238
+ process.env.GCP_REGION = "us-east1";
239
+ const plan = MAPPERS.ComputeNetwork.plan(find("ComputeNetwork"), nctx);
240
+ expect(plan.create.url).toBe("https://compute.googleapis.com/compute/v1/projects/p1/global/networks");
241
+ expect(plan.getUrl).toBe("https://compute.googleapis.com/compute/v1/projects/p1/global/networks/vpc");
242
+ expect(plan.create.body).toEqual({ name: "vpc", autoCreateSubnetworks: false, routingConfig: { routingMode: "REGIONAL" } });
243
+ });
244
+
245
+ test("a subnetwork is regional and names its network by partial URL", () => {
246
+ const plan = MAPPERS.ComputeSubnetwork.plan(find("ComputeSubnetwork"), nctx);
247
+ expect(plan.create.url).toBe("https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/subnetworks");
248
+ expect(plan.create.body).toEqual({
249
+ name: "apps",
250
+ ipCidrRange: "10.0.0.0/24",
251
+ privateIpGoogleAccess: true,
252
+ network: "projects/p1/global/networks/vpc",
253
+ secondaryIpRanges: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
254
+ });
255
+ });
256
+
257
+ test("apply order: network, then subnetwork, then the Cloud Run service that egresses into them", () => {
258
+ const order = orderByReferences(net).map((r) => r.kind);
259
+ expect(order.indexOf("ComputeNetwork")).toBeLessThan(order.indexOf("ComputeSubnetwork"));
260
+ expect(order.indexOf("ComputeSubnetwork")).toBeLessThan(order.indexOf("RunService"));
261
+ });
262
+
263
+ test("the reader reads a network at its global URL", async () => {
264
+ const http = async (_m: string, url: string) =>
265
+ url.endsWith("/global/networks/vpc") ? { status: 200, text: JSON.stringify({ name: "vpc" }) } : { status: 404, text: "" };
266
+ expect(await getResource({ project: "p1", http }, "ComputeNetwork", "vpc", {})).toEqual({ name: "vpc" });
267
+ });
268
+
269
+ test("deep observe inverts both body builders", async () => {
270
+ const { toCnrmTree } = await import("../../deep-observe");
271
+ const c = { project: "p1" };
272
+ expect(
273
+ toCnrmTree(
274
+ "ComputeNetwork",
275
+ {
276
+ name: "vpc",
277
+ autoCreateSubnetworks: false,
278
+ routingConfig: { routingMode: "REGIONAL" },
279
+ subnetworks: ["https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/subnetworks/apps"],
280
+ },
281
+ c,
282
+ ),
283
+ ).toEqual({ metadata: { name: "vpc" }, autoCreateSubnetworks: false, routingMode: "REGIONAL" });
284
+ expect(
285
+ toCnrmTree(
286
+ "ComputeSubnetwork",
287
+ {
288
+ name: "apps",
289
+ region: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1",
290
+ network: "https://compute.googleapis.com/compute/v1/projects/p1/global/networks/vpc",
291
+ ipCidrRange: "10.0.0.0/24",
292
+ gatewayAddress: "10.0.0.1",
293
+ fingerprint: "abc",
294
+ secondaryIpRanges: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
295
+ },
296
+ c,
297
+ ),
298
+ ).toEqual({
299
+ metadata: { name: "apps" },
300
+ region: "europe-west1",
301
+ networkRef: { name: "vpc" },
302
+ ipCidrRange: "10.0.0.0/24",
303
+ secondaryIpRange: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
304
+ });
305
+ });
306
+ });
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Compute Engine mappers for the Cloud Run load balancer (#4233): ComputeAddress,
3
+ * ComputeRegionNetworkEndpointGroup (serverless NEGs), ComputeBackendService,
4
+ * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule; and for a
5
+ * declared network (#4248): ComputeNetwork (always global) and
6
+ * ComputeSubnetwork (regional, by its CNRM `region`).
7
+ *
8
+ * Each maps a CNRM spec onto the compute v1 insert body. A resource is global
9
+ * or regional by its CNRM `location` (`"global"` or a region; a NEG has
10
+ * `region`), and that picks `projects/<p>/global/<collection>` or
11
+ * `projects/<p>/regions/<r>/<collection>`. `*Ref` fields become the referenced
12
+ * resource's partial URL, which compute accepts; a ref to a resource in the
13
+ * same manifest takes its scope from that resource's own spec, so a global
14
+ * backend service can point at a regional NEG.
15
+ *
16
+ * Inserts return a compute `Operation`, polled at its `selfLink` (rebased onto
17
+ * the endpoint override, so floci-gcp answers). These kinds reconcile by
18
+ * existence only: an existing resource is left as it is, since compute updates
19
+ * need the live `fingerprint` and most of these fields are immutable.
20
+ */
21
+ import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
22
+ import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
23
+ import type { GcpResource, OperationSpec, ResourceMapper } from "./gcp-apply";
24
+
25
+ export const COMPUTE_HOST = "https://compute.googleapis.com";
26
+
27
+ const enc = encodeURIComponent;
28
+
29
+ type Ref = { name?: string; external?: string; kind?: string };
30
+ type Obj = Record<string, unknown>;
31
+
32
+ function isObj(v: unknown): v is Obj {
33
+ return v !== null && typeof v === "object" && !Array.isArray(v);
34
+ }
35
+
36
+ /** What a `*Ref` key points at: the CNRM kind (for a manifest lookup), the collection, and whether it is global only. */
37
+ const REF_TARGETS: Record<string, { kind: string; collection: string; scope: "global" | "regional" | "either" }> = {
38
+ addressRef: { kind: "ComputeAddress", collection: "addresses", scope: "either" },
39
+ networkEndpointGroupRef: { kind: "ComputeRegionNetworkEndpointGroup", collection: "networkEndpointGroups", scope: "regional" },
40
+ backendServiceRef: { kind: "ComputeBackendService", collection: "backendServices", scope: "either" },
41
+ backendBucketRef: { kind: "ComputeBackendBucket", collection: "backendBuckets", scope: "global" },
42
+ urlMapRef: { kind: "ComputeURLMap", collection: "urlMaps", scope: "either" },
43
+ targetHTTPProxyRef: { kind: "ComputeTargetHTTPProxy", collection: "targetHttpProxies", scope: "either" },
44
+ targetHTTPSProxyRef: { kind: "ComputeTargetHTTPSProxy", collection: "targetHttpsProxies", scope: "either" },
45
+ healthCheckRef: { kind: "ComputeHealthCheck", collection: "healthChecks", scope: "either" },
46
+ securityPolicyRef: { kind: "ComputeSecurityPolicy", collection: "securityPolicies", scope: "global" },
47
+ networkRef: { kind: "ComputeNetwork", collection: "networks", scope: "global" },
48
+ subnetworkRef: { kind: "ComputeSubnetwork", collection: "subnetworks", scope: "regional" },
49
+ };
50
+
51
+ export interface ComputeCtx {
52
+ base: string;
53
+ project: string;
54
+ manifest?: readonly GcpResource[];
55
+ }
56
+
57
+ /** The region or `"global"` a CNRM compute spec declares. A NEG names `region`; the rest `location`. */
58
+ export function scopeOf(spec: Obj | undefined, env: NodeJS.ProcessEnv = process.env): string {
59
+ const loc = spec?.location ?? spec?.region;
60
+ return typeof loc === "string" && loc !== "" ? loc : (env.GCP_REGION || "us-central1");
61
+ }
62
+
63
+ function scopePath(project: string, scope: string): string {
64
+ return scope === "global" ? `projects/${enc(project)}/global` : `projects/${enc(project)}/regions/${enc(scope)}`;
65
+ }
66
+
67
+ /** The compute name: CNRM's `resourceID` when set, else `metadata.name`. */
68
+ export function computeName(resource: GcpResource): string {
69
+ const id = (resource.spec as Obj | undefined)?.resourceID;
70
+ const name = typeof id === "string" && id !== "" ? id : resource.metadata?.name;
71
+ if (!name) throw new Error(`${resource.kind ?? "compute resource"} has no metadata.name`);
72
+ return name;
73
+ }
74
+
75
+ /**
76
+ * The partial URL a `*Ref` resolves to. `external` is used as given when it is
77
+ * a path or URL; a bare name (or `name`) is scoped by the referenced resource's
78
+ * own spec when the manifest holds it, else by `fallbackScope`. Pure.
79
+ */
80
+ export function refUrl(key: string, ref: Ref, ctx: ComputeCtx, fallbackScope: string): string {
81
+ const target = REF_TARGETS[key];
82
+ if (!target) throw new Error(`no compute collection for ${key}`);
83
+ if (typeof ref.external === "string" && ref.external !== "") {
84
+ if (ref.external.startsWith("projects/") || ref.external.startsWith("https://")) return ref.external;
85
+ }
86
+ const name = ref.name ?? ref.external;
87
+ if (!name) throw new Error(`${key} has no name or external`);
88
+ const declared = ctx.manifest?.find((r) => r.kind === target.kind && (r.metadata?.name === name || (r.spec as Obj | undefined)?.resourceID === name));
89
+ const scope =
90
+ target.scope === "global"
91
+ ? "global"
92
+ : declared
93
+ ? scopeOf(declared.spec as Obj)
94
+ : target.scope === "regional" && fallbackScope === "global"
95
+ ? scopeOf(undefined)
96
+ : fallbackScope;
97
+ const resolvedName = declared ? computeName(declared) : name;
98
+ return `${scopePath(ctx.project, scope)}/${target.collection}/${resolvedName}`;
99
+ }
100
+
101
+ /** Resolve the one `*Ref` key an object holds (`{ backendServiceRef: {...} }` → URL), or undefined. */
102
+ function oneRef(value: unknown, ctx: ComputeCtx, scope: string): string | undefined {
103
+ if (!isObj(value)) return undefined;
104
+ for (const [k, v] of Object.entries(value)) {
105
+ if (REF_TARGETS[k] && isObj(v)) return refUrl(k, v as Ref, ctx, scope);
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Copy the listed fields that are set. */
111
+ function pick(spec: Obj, keys: readonly string[]): Obj {
112
+ const out: Obj = {};
113
+ for (const k of keys) if (spec[k] !== undefined) out[k] = spec[k];
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * The resource's labels GCP accepts, plus the ownership marker. Composites put
119
+ * Kubernetes-style keys (`app.kubernetes.io/component`) in `metadata.labels`,
120
+ * which a compute label key can't hold, so those are left out.
121
+ */
122
+ export function computeLabels(labels: Record<string, string> | undefined): Record<string, string> {
123
+ const out: Record<string, string> = {};
124
+ for (const [k, v] of Object.entries(labels ?? {})) {
125
+ if (/^[a-z][a-z0-9_-]{0,62}$/.test(k) && typeof v === "string" && /^[a-z0-9_-]{0,63}$/.test(v)) out[k] = v;
126
+ }
127
+ return { ...out, [GCP_RESOURCE_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
128
+ }
129
+
130
+ /** ComputeAddress → addresses.insert body. Pure. */
131
+ export function addressBody(resource: GcpResource, ctx: ComputeCtx): Obj {
132
+ const spec = (resource.spec ?? {}) as Obj;
133
+ const scope = scopeOf(spec);
134
+ const body: Obj = {
135
+ name: computeName(resource),
136
+ ...pick(spec, ["address", "addressType", "description", "ipVersion", "ipv6EndpointType", "networkTier", "prefixLength", "purpose"]),
137
+ labels: computeLabels(resource.metadata?.labels),
138
+ };
139
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
140
+ if (isObj(spec.subnetworkRef)) body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef as Ref, ctx, scope);
141
+ return body;
142
+ }
143
+
144
+ /** ComputeRegionNetworkEndpointGroup → regionNetworkEndpointGroups.insert body (serverless NEGs). Pure. */
145
+ export function negBody(resource: GcpResource, ctx: ComputeCtx): Obj {
146
+ const spec = (resource.spec ?? {}) as Obj;
147
+ const scope = scopeOf(spec);
148
+ const body: Obj = {
149
+ name: computeName(resource),
150
+ networkEndpointType: (spec.networkEndpointType as string | undefined) ?? "SERVERLESS",
151
+ ...pick(spec, ["description", "pscTargetService"]),
152
+ };
153
+ const serviceName = (ref: unknown): string | undefined => {
154
+ if (!isObj(ref)) return undefined;
155
+ const r = ref as Ref;
156
+ // A serverless NEG names the service, not its path.
157
+ return (r.external ?? r.name)?.split("/").pop();
158
+ };
159
+ if (isObj(spec.cloudRun)) {
160
+ const c = spec.cloudRun as Obj;
161
+ body.cloudRun = { ...pick(c, ["tag", "urlMask"]), ...(serviceName(c.serviceRef) ? { service: serviceName(c.serviceRef) } : {}) };
162
+ }
163
+ if (isObj(spec.cloudFunction)) {
164
+ const c = spec.cloudFunction as Obj;
165
+ body.cloudFunction = { ...pick(c, ["urlMask"]), ...(serviceName(c.functionRef) ? { function: serviceName(c.functionRef) } : {}) };
166
+ }
167
+ if (isObj(spec.appEngine)) body.appEngine = spec.appEngine;
168
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
169
+ if (isObj(spec.subnetworkRef)) body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef as Ref, ctx, scope);
170
+ return body;
171
+ }
172
+
173
+ /**
174
+ * ComputeNetwork → networks.insert body. A network is global. CNRM's
175
+ * `autoCreateSubnetworks` defaults to false, where compute's default is true,
176
+ * so it is always sent. Pure.
177
+ */
178
+ export function networkBody(resource: GcpResource): Obj {
179
+ const spec = (resource.spec ?? {}) as Obj;
180
+ const body: Obj = {
181
+ name: computeName(resource),
182
+ autoCreateSubnetworks: spec.autoCreateSubnetworks === true,
183
+ ...pick(spec, ["description", "mtu", "networkFirewallPolicyEnforcementOrder"]),
184
+ };
185
+ if (typeof spec.routingMode === "string") body.routingConfig = { routingMode: spec.routingMode };
186
+ return body;
187
+ }
188
+
189
+ /** ComputeSubnetwork → subnetworks.insert body. CNRM's `secondaryIpRange` and `logConfig` become compute's `secondaryIpRanges` and an enabled `logConfig`. Pure. */
190
+ export function subnetworkBody(resource: GcpResource, ctx: ComputeCtx): Obj {
191
+ const spec = (resource.spec ?? {}) as Obj;
192
+ const scope = scopeOf(spec);
193
+ const body: Obj = {
194
+ name: computeName(resource),
195
+ ...pick(spec, ["ipCidrRange", "description", "privateIpGoogleAccess", "purpose", "role", "stackType"]),
196
+ };
197
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
198
+ if (Array.isArray(spec.secondaryIpRange)) body.secondaryIpRanges = spec.secondaryIpRange;
199
+ if (isObj(spec.logConfig)) body.logConfig = { enable: true, ...spec.logConfig };
200
+ return body;
201
+ }
202
+
203
+ /** ComputeBackendService → backendServices.insert body. Pure. */
204
+ export function backendServiceBody(resource: GcpResource, ctx: ComputeCtx): Obj {
205
+ const spec = (resource.spec ?? {}) as Obj;
206
+ const scope = scopeOf(spec);
207
+ const body: Obj = {
208
+ name: computeName(resource),
209
+ ...pick(spec, [
210
+ "affinityCookieTtlSec",
211
+ "cdnPolicy",
212
+ "compressionMode",
213
+ "customRequestHeaders",
214
+ "customResponseHeaders",
215
+ "description",
216
+ "loadBalancingScheme",
217
+ "localityLbPolicy",
218
+ "logConfig",
219
+ "portName",
220
+ "protocol",
221
+ "sessionAffinity",
222
+ "timeoutSec",
223
+ ]),
224
+ };
225
+ if (spec.enableCdn !== undefined) body.enableCDN = spec.enableCdn;
226
+ if (spec.connectionDrainingTimeoutSec !== undefined) body.connectionDraining = { drainingTimeoutSec: spec.connectionDrainingTimeoutSec };
227
+ if (Array.isArray(spec.backend)) {
228
+ body.backends = (spec.backend as Obj[]).map((b) => {
229
+ const { group, ...rest } = b;
230
+ const groupUrl = isObj(group) ? oneRef(group, ctx, scope) ?? ((group as Obj).instanceGroupRef as Ref | undefined)?.external : undefined;
231
+ return { ...rest, ...(groupUrl ? { group: groupUrl } : {}) };
232
+ });
233
+ }
234
+ if (Array.isArray(spec.healthChecks)) {
235
+ body.healthChecks = (spec.healthChecks as Obj[]).map((h) => oneRef(h, ctx, scope)).filter((u): u is string => !!u);
236
+ }
237
+ if (isObj(spec.securityPolicyRef)) body.securityPolicy = refUrl("securityPolicyRef", spec.securityPolicyRef as Ref, ctx, scope);
238
+ else if (typeof spec.securityPolicy === "string") body.securityPolicy = spec.securityPolicy;
239
+ return body;
240
+ }
241
+
242
+ /** ComputeURLMap → urlMaps.insert body. Pure. */
243
+ export function urlMapBody(resource: GcpResource, ctx: ComputeCtx): Obj {
244
+ const spec = (resource.spec ?? {}) as Obj;
245
+ const scope = scopeOf(spec);
246
+ const service = (v: unknown) => oneRef(v, ctx, scope);
247
+ const body: Obj = {
248
+ name: computeName(resource),
249
+ ...pick(spec, ["description", "defaultUrlRedirect", "headerAction"]),
250
+ };
251
+ const def = service(spec.defaultService);
252
+ if (def) body.defaultService = def;
253
+ if (Array.isArray(spec.hostRule)) body.hostRules = spec.hostRule;
254
+ if (Array.isArray(spec.pathMatcher)) {
255
+ body.pathMatchers = (spec.pathMatcher as Obj[]).map((m) => {
256
+ const { defaultService, pathRule, ...rest } = m;
257
+ const out: Obj = { ...rest };
258
+ const d = service(defaultService);
259
+ if (d) out.defaultService = d;
260
+ if (Array.isArray(pathRule)) {
261
+ out.pathRules = (pathRule as Obj[]).map((r) => {
262
+ const { service: s, ...rr } = r;
263
+ const u = service(s);
264
+ return { ...rr, ...(u ? { service: u } : {}) };
265
+ });
266
+ }
267
+ return out;
268
+ });
269
+ }
270
+ return body;
271
+ }
272
+
273
+ /** ComputeTargetHTTPProxy → targetHttpProxies.insert body. Pure. */
274
+ export function targetHttpProxyBody(resource: GcpResource, ctx: ComputeCtx): Obj {
275
+ const spec = (resource.spec ?? {}) as Obj;
276
+ const body: Obj = { name: computeName(resource), ...pick(spec, ["description", "proxyBind", "httpKeepAliveTimeoutSec"]) };
277
+ if (isObj(spec.urlMapRef)) body.urlMap = refUrl("urlMapRef", spec.urlMapRef as Ref, ctx, scopeOf(spec));
278
+ return body;
279
+ }
280
+
281
+ /** ComputeForwardingRule → forwardingRules.insert body. Pure. */
282
+ export function forwardingRuleBody(resource: GcpResource, ctx: ComputeCtx): Obj {
283
+ const spec = (resource.spec ?? {}) as Obj;
284
+ const scope = scopeOf(spec);
285
+ const body: Obj = {
286
+ name: computeName(resource),
287
+ ...pick(spec, ["allowGlobalAccess", "description", "ipVersion", "isMirroringCollector", "loadBalancingScheme", "networkTier", "portRange", "ports", "allPorts"]),
288
+ labels: computeLabels(resource.metadata?.labels),
289
+ };
290
+ if (spec.ipProtocol !== undefined) body.IPProtocol = spec.ipProtocol;
291
+ if (isObj(spec.ipAddress)) {
292
+ const ip = spec.ipAddress as Obj;
293
+ const addr = isObj(ip.addressRef) ? refUrl("addressRef", ip.addressRef as Ref, ctx, scope) : (ip.ip as string | undefined);
294
+ if (addr) body.IPAddress = addr;
295
+ }
296
+ const target = oneRef(spec.target, ctx, scope);
297
+ if (target) body.target = target;
298
+ if (isObj(spec.backendServiceRef)) body.backendService = refUrl("backendServiceRef", spec.backendServiceRef as Ref, ctx, scope);
299
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
300
+ if (isObj(spec.subnetworkRef)) body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef as Ref, ctx, scope);
301
+ return body;
302
+ }
303
+
304
+ /**
305
+ * A compute `Operation`: the insert or delete returns one with a `selfLink`,
306
+ * polled until `status: "DONE"`. The link is rebased onto `base`, so an
307
+ * endpoint override (floci-gcp) is polled too. A create that returned the
308
+ * resource itself has nothing to poll.
309
+ */
310
+ export const computeOperation: OperationSpec = {
311
+ pollUrl(body, { base }) {
312
+ const op = body as { kind?: string; selfLink?: string } | undefined;
313
+ const link = op?.selfLink;
314
+ if (!link || !link.includes("/operations/")) return undefined;
315
+ const at = link.indexOf("/compute/v1/");
316
+ return at >= 0 ? `${base}${link.slice(at)}` : link;
317
+ },
318
+ isDone(body) {
319
+ return (body as { status?: string })?.status === "DONE";
320
+ },
321
+ error(body) {
322
+ const errors = (body as { error?: { errors?: Array<{ message?: string; code?: string }> } })?.error?.errors;
323
+ return errors?.length ? errors.map((e) => e.message ?? e.code ?? "error").join("; ") : undefined;
324
+ },
325
+ };
326
+
327
+ function computeMapper(
328
+ kind: string,
329
+ collection: string,
330
+ build: (resource: GcpResource, ctx: ComputeCtx) => Obj,
331
+ regionalCollection = collection,
332
+ scopeFor: (spec: Obj | undefined) => string = (spec) => scopeOf(spec),
333
+ ): ResourceMapper {
334
+ return {
335
+ kind,
336
+ defaultHost: COMPUTE_HOST,
337
+ operation: computeOperation,
338
+ plan(resource, ctx) {
339
+ const scope = scopeFor(resource.spec as Obj | undefined);
340
+ const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
341
+ const name = computeName(resource);
342
+ return { getUrl: `${coll}/${enc(name)}`, create: { method: "POST", url: coll, body: build(resource, ctx) } };
343
+ },
344
+ };
345
+ }
346
+
347
+ export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody);
348
+ export const regionNetworkEndpointGroupMapper = computeMapper("ComputeRegionNetworkEndpointGroup", "networkEndpointGroups", negBody);
349
+ export const backendServiceMapper = computeMapper("ComputeBackendService", "backendServices", backendServiceBody);
350
+ export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody);
351
+ export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody);
352
+ export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody);
353
+ export const networkMapper = computeMapper("ComputeNetwork", "networks", networkBody, "networks", () => "global");
354
+ export const subnetworkMapper = computeMapper("ComputeSubnetwork", "subnetworks", subnetworkBody);