1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import {
22 encodeDescription,
23 hasOwnershipMarker,
24 lastSegment,
25 parseDescription,
26} from "./internal.ts";
27import { waitGlobalOperation } from "./operations.ts";
28
29const DEFAULT_PROTOCOL = "TCP";
30const DEFAULT_SCHEME = "EXTERNAL";
31const DEFAULT_TIER = "PREMIUM";
32const MAX_NAME_LENGTH = 63;
33
34export type GlobalForwardingRuleProps = {
35 /**
36 * Name of the forwarding rule. If omitted, a unique RFC1035 name is
37 * generated from the stack, stage, and logical id. Immutable —
38 * changing it replaces the rule.
39 */
40 forwardingRuleName?: string;
41 /**
42 * Optional description. Immutable — changing it replaces the rule.
43 * Alchemy stamps ownership into the stored description so `list` /
44 * nuke can find a rule even if `setLabels` never ran (labels cannot
45 * be set on insert).
46 */
47 description?: string;
48 /**
49 * URL of the target that receives matched traffic (a global target
50 * HTTP/HTTPS/SSL/TCP/gRPC proxy self-link, a PSC service attachment,
51 * or `all-apis` / `vpc-sc`).
52 */
53 target: string;
54 /**
55 * IP this rule accepts traffic on. A reserved global address
56 * (self-link, `global/addresses/name`, or the IP itself). If omitted,
57 * GCP assigns an ephemeral address. Immutable.
58 */
59 ipAddress?: string;
60 /**
61 * IP protocol (`TCP`, `UDP`, `ESP`, `AH`, `SCTP`, `ICMP`,
62 * `L3_DEFAULT`). Immutable.
63 * @default "TCP"
64 */
65 ipProtocol?: compute.ForwardingRuleIPProtocolEnum | (string & {});
66 /**
67 * `IPV4` or `IPV6`. Immutable.
68 */
69 ipVersion?: compute.ForwardingRuleIpVersionEnum | (string & {});
70 /**
71 * Port or range (`80` or `80-80`). Required for HTTP(S) / proxy NLB
72 * frontends. Immutable.
73 */
74 portRange?: string;
75 /**
76 * Forwarding-rule type. `EXTERNAL` is classic HTTP(S) LB;
77 * `EXTERNAL_MANAGED` is the global external Application Load
78 * Balancer. Immutable — changing it replaces the rule.
79 * @default "EXTERNAL"
80 */
81 loadBalancingScheme?:
82 | compute.ForwardingRuleLoadBalancingSchemeEnum
83 | (string & {});
84 /**
85 * VPC network URL. Unused for external HTTP(S) LB; required for
86 * PSC-to-Google-APIs. Immutable.
87 */
88 network?: string;
89 /**
90 * Networking tier. Global forwarding rules only support `PREMIUM`.
91 * Mutable via `patch`.
92 * @default "PREMIUM"
93 */
94 networkTier?: compute.ForwardingRuleNetworkTierEnum | (string & {});
95 /**
96 * User labels. Alchemy ownership labels are merged in and synced via
97 * `setLabels` (labels cannot be set on insert).
98 */
99 labels?: Record<string, string>;
100 /**
101 * xDS metadata filters (`INTERNAL_SELF_MANAGED` only). Immutable.
102 */
103 metadataFilters?: compute.MetadataFilter[];
104 /**
105 * Restrict forwarding to these source IPs / CIDRs (regional EXTERNAL
106 * only). Immutable.
107 */
108 sourceIpRanges?: string[];
109 /**
110 * Skip auto-creating a DNS zone on a PSC consumer rule. Immutable.
111 */
112 noAutomateDnsZone?: boolean;
113 /**
114 * Service Directory registration (PSC for Google APIs). Immutable.
115 */
116 serviceDirectoryRegistrations?: compute.ForwardingRuleServiceDirectoryRegistration[];
117 /**
118 * Public delegated prefix URL for BYOIP IPv6. Immutable.
119 */
120 ipCollection?: string;
121 /**
122 * Canary state when migrating backend buckets from `EXTERNAL` to
123 * `EXTERNAL_MANAGED` (`PREPARE`, `TEST_BY_PERCENTAGE`,
124 * `TEST_ALL_TRAFFIC`).
125 */
126 externalManagedBackendBucketMigrationState?:
127 | compute.ForwardingRuleExternalManagedBackendBucketMigrationStateEnum
128 | (string & {});
129 /**
130 * Percent of backend-bucket traffic on the new scheme when the
131 * migration state is `TEST_BY_PERCENTAGE` (`0`–`100`).
132 */
133 externalManagedBackendBucketMigrationTestingPercentage?: number;
134};
135
136export type GlobalForwardingRule = Resource<
137 "GCP.Compute.GlobalForwardingRule",
138 GlobalForwardingRuleProps,
139 {
140 /** RFC1035 resource name. */
141 forwardingRuleName: string;
142 /** Project id. */
143 project: string;
144 /** Server-assigned numeric id. */
145 forwardingRuleId: string | undefined;
146 /** Compute self-link. */
147 selfLink: string | undefined;
148 /** Assigned IP address number. */
149 ipAddress: string | undefined;
150 /** IP protocol. */
151 ipProtocol: string | undefined;
152 /** `IPV4` or `IPV6`. */
153 ipVersion: string | undefined;
154 /** Port or range. */
155 portRange: string | undefined;
156 /** Target proxy / PSC URL. */
157 target: string | undefined;
158 /** Load balancer type. */
159 loadBalancingScheme: string | undefined;
160 /** Networking tier. */
161 networkTier: string | undefined;
162 /** Network URL, if set. */
163 network: string | undefined;
164 /** Description. */
165 description: string | undefined;
166 /** User labels (Alchemy ownership labels stripped). */
167 labels: Record<string, string>;
168 /** Optimistic-locking fingerprint. */
169 fingerprint: string | undefined;
170 /** Label fingerprint for `setLabels`. */
171 labelFingerprint: string | undefined;
172 /** RFC3339 creation timestamp. */
173 creationTimestamp: string | undefined;
174 /** PSC connection id, if this is a PSC rule. */
175 pscConnectionId: string | undefined;
176 /** PSC connection status, if this is a PSC rule. */
177 pscConnectionStatus: string | undefined;
178 /** Base forwarding-rule URL when `sourceIpRanges` is set. */
179 baseForwardingRule: string | undefined;
180 /** Resource kind (`compute#forwardingRule`). */
181 kind: string | undefined;
182 },
183 never,
184 Providers
185>;
186
187/**
188 * A global Compute Engine forwarding rule — the frontend of a global
189 * load balancer. It binds an IP and port range to a target HTTP/HTTPS
190 * proxy (or SSL/TCP/gRPC proxy, or a Private Service Connect bundle).
191 *
192 * Labels cannot be set on insert — Alchemy applies them with
193 * `setLabels` after the rule exists and also stamps ownership into the
194 * description so `list` / `pnpm nuke:gcp` can find a rule if labeling
195 * is interrupted. `list` also includes unlabeled rules whose `target`
196 * is an Alchemy-owned HTTP(S) proxy. `target` and `networkTier` update
197 * in place (`setTarget` / `patch`). Name, IP, protocol, port range,
198 * description, network, and load-balancing scheme replace the rule.
199 *
200 * ### Creating a Global Forwarding Rule
201 * **Example:** HTTP frontend in front of a target HTTP proxy
202 * ```typescript
203 * const map = yield* GCP.Compute.UrlMap("web", {
204 * defaultUrlRedirect: {
205 * httpsRedirect: true,
206 * hostRedirect: "example.com",
207 * stripQuery: false,
208 * },
209 * });
210 * const proxy = yield* GCP.Compute.TargetHttpProxy("http", {
211 * urlMap: map.urlMapName,
212 * });
213 * const rule = yield* GCP.Compute.GlobalForwardingRule("frontend", {
214 * target: proxy.selfLink,
215 * portRange: "80",
216 * });
217 * ```
218 *
219 * **Example:** Named rule with labels and a reserved IP
220 * ```typescript
221 * const ip = yield* GCP.Compute.GlobalAddress("FrontendIp", {});
222 * const rule = yield* GCP.Compute.GlobalForwardingRule("frontend", {
223 * forwardingRuleName: "app-http",
224 * description: "public HTTP frontend",
225 * target: proxy.selfLink,
226 * ipAddress: ip.address,
227 * portRange: "80",
228 * loadBalancingScheme: "EXTERNAL",
229 * labels: { env: "prod" },
230 * });
231 * ```
232 *
233 * ### Updating a Global Forwarding Rule
234 * **Example:** Retarget and relabel
235 * ```typescript
236 * const rule = yield* GCP.Compute.GlobalForwardingRule("frontend", {
237 * forwardingRuleName: "app-http",
238 * target: otherProxy.selfLink,
239 * portRange: "80",
240 * labels: { env: "prod", role: "edge" },
241 * });
242 * ```
243 *
244 * @resource
245 * @category Compute
246 */
247export const GlobalForwardingRule = Resource<GlobalForwardingRule>(
248 "GCP.Compute.GlobalForwardingRule",
249);
250
251export class GlobalForwardingRuleNotResolved extends Data.TaggedError(
252 "GCP.Compute.GlobalForwardingRuleNotResolved",
253)<{
254 forwardingRuleName: string;
255}> {}
256
257export class GlobalForwardingRulePending extends Data.TaggedError(
258 "GCP.Compute.GlobalForwardingRulePending",
259)<{
260 forwardingRuleName: string;
261 status: string;
262}> {}
263
264const userLabels = (
265 labels: Record<string, string | undefined> | null | undefined,
266): Record<string, string> => stripInternalLabels(tagRecord(labels));
267
268const toName = (id: string, name: string | undefined, existing?: string) =>
269 Effect.gen(function* () {
270 if (name !== undefined) return name;
271 if (existing !== undefined) return existing;
272 const generated = yield* createPhysicalName({
273 id,
274 maxLength: MAX_NAME_LENGTH,
275 lowercase: true,
276 });
277 return /^[a-z]/.test(generated)
278 ? generated
279 : `f${generated}`.slice(0, MAX_NAME_LENGTH);
280 });
281
282const resourceTail = (value: string | undefined): string =>
283 lastSegment(value).toLowerCase();
284
285const proxyName = (proxy: { name?: string; description?: string }) =>
286 hasOwnershipMarker(proxy.description)
287 ? lastSegment(proxy.name).toLowerCase()
288 : "";
289
290const ownedProxyNames = (project: string) =>
291 Effect.gen(function* () {
292 const http = yield* compute.listTargetHttpProxies
293 .items({ project, maxResults: 500 })
294 .pipe(
295 Stream.map(proxyName),
296 Stream.filter((name) => name.length > 0),
297 Stream.runCollect,
298 Effect.map((chunk) => Array.from(chunk)),
299 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
300 );
301 const https = yield* compute.listTargetHttpsProxies
302 .items({ project, maxResults: 500 })
303 .pipe(
304 Stream.map(proxyName),
305 Stream.filter((name) => name.length > 0),
306 Stream.runCollect,
307 Effect.map((chunk) => Array.from(chunk)),
308 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
309 );
310 return new Set<string>([...http, ...https]);
311 });
312
313const isOwnedForwardingRule = (
314 rule: compute.ForwardingRule,
315 ownedTargets: ReadonlySet<string>,
316) => {
317 if (
318 Object.keys(rule.labels ?? {}).some((key) => key.startsWith("alchemy-"))
319 ) {
320 return true;
321 }
322 if (hasOwnershipMarker(rule.description)) return true;
323 const target = resourceTail(rule.target);
324 if (target.length > 0 && ownedTargets.has(target)) return true;
325 // Insert cannot set labels; a crash before setLabels leaves an unlabeled
326 // rule whose name is still the engine physical name (`gcp-…` in tests).
327 const name = (rule.name ?? "").toLowerCase();
328 return (
329 name.startsWith("gcp-") &&
330 (rule.description === undefined || rule.description.length === 0)
331 );
332};
333
334const normalizePortRange = (value: string | undefined): string => {
335 if (value === undefined || value.length === 0) return "";
336 const trimmed = value.trim();
337 return /^\d+$/.test(trimmed) ? `${trimmed}-${trimmed}` : trimmed;
338};
339
340const isRawIp = (value: string): boolean =>
341 !value.includes("/") && (value.includes(".") || value.includes(":"));
342
343const sameJson = (left: unknown, right: unknown) =>
344 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
345
346const schemeOf = (value: string | undefined) => value ?? DEFAULT_SCHEME;
347
348const protocolOf = (value: string | undefined) =>
349 (value ?? DEFAULT_PROTOCOL).toUpperCase();
350
351const tierOf = (value: string | undefined) => value ?? DEFAULT_TIER;
352
353const ipVersionOf = (value: string | undefined) =>
354 value && value !== "UNSPECIFIED_VERSION" ? value : "";
355
356const toAttrs = (rule: compute.ForwardingRule, project: string) => ({
357 forwardingRuleName: rule.name ?? "",
358 project,
359 forwardingRuleId: rule.id,
360 selfLink: rule.selfLink,
361 ipAddress: rule.IPAddress,
362 ipProtocol: rule.IPProtocol,
363 ipVersion: rule.ipVersion,
364 portRange: rule.portRange,
365 target: rule.target,
366 loadBalancingScheme: rule.loadBalancingScheme,
367 networkTier: rule.networkTier,
368 network: rule.network,
369 description: parseDescription(rule.description).description,
370 labels: userLabels(rule.labels),
371 fingerprint: rule.fingerprint,
372 labelFingerprint: rule.labelFingerprint,
373 creationTimestamp: rule.creationTimestamp,
374 pscConnectionId: rule.pscConnectionId,
375 pscConnectionStatus: rule.pscConnectionStatus,
376 baseForwardingRule: rule.baseForwardingRule,
377 kind: rule.kind,
378});
379
380const toInsertBody = (
381 forwardingRuleName: string,
382 news: GlobalForwardingRuleProps,
383 description: string,
384): compute.ForwardingRule => {
385 const body: compute.ForwardingRule = {
386 name: forwardingRuleName,
387 target: news.target,
388 description,
389 IPProtocol: news.ipProtocol ?? DEFAULT_PROTOCOL,
390 loadBalancingScheme: news.loadBalancingScheme ?? DEFAULT_SCHEME,
391 networkTier: news.networkTier ?? DEFAULT_TIER,
392 };
393 if (news.ipAddress !== undefined) body.IPAddress = news.ipAddress;
394 if (news.ipVersion !== undefined) body.ipVersion = news.ipVersion;
395 if (news.portRange !== undefined) body.portRange = news.portRange;
396 if (news.network !== undefined) body.network = news.network;
397 if (news.metadataFilters !== undefined) {
398 body.metadataFilters = news.metadataFilters;
399 }
400 if (news.sourceIpRanges !== undefined) {
401 body.sourceIpRanges = news.sourceIpRanges;
402 }
403 if (news.noAutomateDnsZone !== undefined) {
404 body.noAutomateDnsZone = news.noAutomateDnsZone;
405 }
406 if (news.serviceDirectoryRegistrations !== undefined) {
407 body.serviceDirectoryRegistrations = news.serviceDirectoryRegistrations;
408 }
409 if (news.ipCollection !== undefined) {
410 body.ipCollection = news.ipCollection;
411 }
412 if (news.externalManagedBackendBucketMigrationState !== undefined) {
413 body.externalManagedBackendBucketMigrationState =
414 news.externalManagedBackendBucketMigrationState;
415 }
416 if (
417 news.externalManagedBackendBucketMigrationTestingPercentage !== undefined
418 ) {
419 body.externalManagedBackendBucketMigrationTestingPercentage =
420 news.externalManagedBackendBucketMigrationTestingPercentage;
421 }
422 return body;
423};
424
425const getByName = (project: string, forwardingRule: string) =>
426 compute
427 .getGlobalForwardingRules({ project, forwardingRule })
428 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
429
430const awaitResource = (project: string, forwardingRuleName: string) =>
431 getByName(project, forwardingRuleName).pipe(
432 Effect.flatMap((rule) =>
433 rule === undefined
434 ? Effect.fail(
435 new GlobalForwardingRulePending({
436 forwardingRuleName,
437 status: "MISSING",
438 }),
439 )
440 : Effect.succeed(rule),
441 ),
442 Effect.retry({
443 while: (error) =>
444 error._tag === "GCP.Compute.GlobalForwardingRulePending",
445 times: 8,
446 schedule: Schedule.spaced("1 second"),
447 }),
448 );
449
450const waitUntilGone = (project: string, forwardingRuleName: string) =>
451 getByName(project, forwardingRuleName).pipe(
452 Effect.flatMap((rule) =>
453 rule === undefined
454 ? Effect.void
455 : Effect.fail(
456 new GlobalForwardingRulePending({
457 forwardingRuleName,
458 status: "EXISTS",
459 }),
460 ),
461 ),
462 Effect.retry({
463 while: (error) =>
464 error._tag === "GCP.Compute.GlobalForwardingRulePending",
465 times: 10,
466 schedule: Schedule.spaced("1 second"),
467 }),
468 Effect.catchTag(
469 "GCP.Compute.GlobalForwardingRulePending",
470 () => Effect.void,
471 ),
472 );
473
474export const GlobalForwardingRuleProvider = () =>
475 Provider.succeed(GlobalForwardingRule, {
476 nuke: {
477 dependsOn: [
478 "GCP.Compute.TargetHttpProxy",
479 "GCP.Compute.TargetHttpsProxy",
480 ],
481 },
482 stables: [
483 "forwardingRuleName",
484 "project",
485 "forwardingRuleId",
486 "selfLink",
487 "ipAddress",
488 "ipProtocol",
489 "ipVersion",
490 "portRange",
491 "loadBalancingScheme",
492 "network",
493 "creationTimestamp",
494 ],
495
496 diff: Effect.fn(function* ({ news, olds, output }) {
497 if (!isResolved(news)) return undefined;
498 const previousName =
499 olds?.forwardingRuleName ?? output?.forwardingRuleName;
500 const nextName = news.forwardingRuleName ?? previousName;
501 const nameChanged =
502 previousName !== undefined &&
503 nextName !== undefined &&
504 nextName !== previousName;
505
506 const previousProtocol = protocolOf(
507 olds?.ipProtocol ?? output?.ipProtocol,
508 );
509 const previousPort = normalizePortRange(
510 olds?.portRange ?? output?.portRange,
511 );
512 const nextPort = normalizePortRange(news.portRange);
513 const previousNetwork = resourceTail(olds?.network ?? output?.network);
514 const previousDescription =
515 olds?.description ?? output?.description ?? "";
516 const previousVersion = ipVersionOf(olds?.ipVersion ?? output?.ipVersion);
517 const previousCollection = resourceTail(olds?.ipCollection);
518 const previousDns = olds?.noAutomateDnsZone;
519 const previousScheme = schemeOf(
520 olds?.loadBalancingScheme ?? output?.loadBalancingScheme,
521 );
522
523 const previousIp = output?.ipAddress;
524 const ipChanged =
525 news.ipAddress !== undefined &&
526 isRawIp(news.ipAddress) &&
527 previousIp !== undefined &&
528 news.ipAddress !== previousIp;
529
530 const immutableChanged =
531 (news.description !== undefined &&
532 (news.description ?? "") !== previousDescription) ||
533 ipChanged ||
534 (news.ipProtocol !== undefined &&
535 protocolOf(news.ipProtocol) !== previousProtocol) ||
536 (news.ipVersion !== undefined &&
537 ipVersionOf(news.ipVersion) !== previousVersion) ||
538 (news.portRange !== undefined && nextPort !== previousPort) ||
539 (news.loadBalancingScheme !== undefined &&
540 schemeOf(news.loadBalancingScheme) !== previousScheme) ||
541 (news.network !== undefined &&
542 resourceTail(news.network) !== previousNetwork) ||
543 (news.ipCollection !== undefined &&
544 resourceTail(news.ipCollection) !== previousCollection) ||
545 (news.noAutomateDnsZone !== undefined &&
546 news.noAutomateDnsZone !== previousDns) ||
547 (news.metadataFilters !== undefined &&
548 !sameJson(news.metadataFilters, olds?.metadataFilters)) ||
549 (news.sourceIpRanges !== undefined &&
550 !sameJson(
551 [...news.sourceIpRanges].sort(),
552 [...(olds?.sourceIpRanges ?? [])].sort(),
553 )) ||
554 (news.serviceDirectoryRegistrations !== undefined &&
555 !sameJson(
556 news.serviceDirectoryRegistrations,
557 olds?.serviceDirectoryRegistrations,
558 ));
559
560 if (nameChanged) {
561 return { action: "replace" as const, deleteFirst: false };
562 }
563 if (immutableChanged) {
564 return {
565 action: "replace" as const,
566 deleteFirst:
567 previousName !== undefined &&
568 (nextName === undefined || nextName === previousName),
569 };
570 }
571 return undefined;
572 }),
573
574 read: Effect.fn(function* ({ id, olds, output }) {
575 const env = yield* GcpEnvironment.current;
576 const forwardingRuleName = yield* toName(
577 id,
578 olds?.forwardingRuleName,
579 output?.forwardingRuleName,
580 );
581 const existing = yield* getByName(env.project, forwardingRuleName);
582 if (existing === undefined) return undefined;
583 const attrs = toAttrs(existing, env.project);
584 const { labels: descriptionLabels } = parseDescription(
585 existing.description,
586 );
587 return (yield* hasAlchemyLabels(id, {
588 ...descriptionLabels,
589 ...tagRecord(existing.labels),
590 }))
591 ? attrs
592 : Unowned(attrs);
593 }),
594
595 list: () =>
596 Effect.gen(function* () {
597 const env = yield* GcpEnvironment.current;
598 const ownedTargets = yield* ownedProxyNames(env.project);
599 return yield* compute.listGlobalForwardingRules
600 .items({ project: env.project, maxResults: 500 })
601 .pipe(
602 Stream.filter((rule) => isOwnedForwardingRule(rule, ownedTargets)),
603 Stream.map((rule) => toAttrs(rule, env.project)),
604 Stream.runCollect,
605 Effect.map((chunk) => Array.from(chunk)),
606 Effect.catchTag("NotFound", () =>
607 Effect.succeed([] as GlobalForwardingRule["Attributes"][]),
608 ),
609 );
610 }),
611
612 reconcile: Effect.fn(function* ({ id, news, output }) {
613 const env = yield* GcpEnvironment.current;
614 const forwardingRuleName = yield* toName(
615 id,
616 news.forwardingRuleName,
617 output?.forwardingRuleName,
618 );
619 const ownership = yield* createInternalLabels(id);
620 const desiredDescription = encodeDescription(ownership, news.description);
621 const desiredLabels = {
622 ...toLabels(news.labels),
623 ...ownership,
624 };
625
626 let current = yield* getByName(env.project, forwardingRuleName);
627
628 if (current === undefined) {
629 const created = yield* compute
630 .insertGlobalForwardingRules({
631 project: env.project,
632 body: toInsertBody(forwardingRuleName, news, desiredDescription),
633 })
634 .pipe(
635 Effect.flatMap((operation) =>
636 waitGlobalOperation(env.project, operation, {
637 ignore: ["RESOURCE_ALREADY_EXISTS"],
638 }).pipe(
639 Effect.flatMap(() =>
640 getByName(env.project, forwardingRuleName),
641 ),
642 ),
643 ),
644 Effect.catchTag("Conflict", () =>
645 getByName(env.project, forwardingRuleName),
646 ),
647 );
648 current = created ?? undefined;
649 if (current === undefined) {
650 current = yield* awaitResource(env.project, forwardingRuleName).pipe(
651 Effect.catchTag("GCP.Compute.GlobalForwardingRulePending", () =>
652 Effect.succeed(undefined),
653 ),
654 );
655 }
656 }
657
658 if (current === undefined) {
659 return yield* new GlobalForwardingRuleNotResolved({
660 forwardingRuleName,
661 });
662 }
663 const resolved = current;
664
665 if (
666 news.target !== undefined &&
667 resourceTail(current.target) !== resourceTail(news.target)
668 ) {
669 yield* compute
670 .setTargetGlobalForwardingRules({
671 project: env.project,
672 forwardingRule: forwardingRuleName,
673 body: { target: news.target },
674 })
675 .pipe(
676 Effect.flatMap((operation) =>
677 waitGlobalOperation(env.project, operation),
678 ),
679 Effect.retry({
680 while: (error) => error._tag === "Conflict",
681 times: 8,
682 schedule: Schedule.spaced("2 seconds"),
683 }),
684 );
685 current =
686 (yield* getByName(env.project, forwardingRuleName)) ?? current;
687 }
688
689 const tierChanged =
690 news.networkTier !== undefined &&
691 tierOf(current.networkTier) !== tierOf(news.networkTier);
692 const migrationStateChanged =
693 news.externalManagedBackendBucketMigrationState !== undefined &&
694 current.externalManagedBackendBucketMigrationState !==
695 news.externalManagedBackendBucketMigrationState;
696 const migrationPctChanged =
697 news.externalManagedBackendBucketMigrationTestingPercentage !==
698 undefined &&
699 current.externalManagedBackendBucketMigrationTestingPercentage !==
700 news.externalManagedBackendBucketMigrationTestingPercentage;
701
702 if (tierChanged || migrationStateChanged || migrationPctChanged) {
703 const patch: compute.ForwardingRule = {
704 fingerprint: current.fingerprint,
705 };
706 if (tierChanged) patch.networkTier = news.networkTier;
707 if (migrationStateChanged) {
708 patch.externalManagedBackendBucketMigrationState =
709 news.externalManagedBackendBucketMigrationState;
710 }
711 if (migrationPctChanged) {
712 patch.externalManagedBackendBucketMigrationTestingPercentage =
713 news.externalManagedBackendBucketMigrationTestingPercentage;
714 }
715 yield* compute
716 .patchGlobalForwardingRules({
717 project: env.project,
718 forwardingRule: forwardingRuleName,
719 body: patch,
720 })
721 .pipe(
722 Effect.flatMap((operation) =>
723 waitGlobalOperation(env.project, operation),
724 ),
725 );
726 current =
727 (yield* getByName(env.project, forwardingRuleName)) ?? current;
728 }
729
730 const observedLabels = tagRecord(current.labels);
731 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
732 if (upsert.length > 0 || removed.length > 0) {
733 yield* Effect.gen(function* () {
734 const latest =
735 (yield* getByName(env.project, forwardingRuleName)) ?? resolved;
736 yield* compute
737 .setLabelsGlobalForwardingRules({
738 project: env.project,
739 resource: forwardingRuleName,
740 body: {
741 labels: desiredLabels,
742 labelFingerprint: latest.labelFingerprint,
743 },
744 })
745 .pipe(
746 Effect.flatMap((operation) =>
747 waitGlobalOperation(env.project, operation),
748 ),
749 );
750 }).pipe(
751 Effect.retry({
752 while: (error) => error._tag === "Conflict",
753 times: 8,
754 schedule: Schedule.spaced("2 seconds"),
755 }),
756 );
757 current =
758 (yield* getByName(env.project, forwardingRuleName)) ?? resolved;
759 }
760
761 return toAttrs(current ?? resolved, env.project);
762 }),
763
764 delete: Effect.fn(function* ({ output }) {
765 const env = yield* GcpEnvironment.current;
766 yield* compute
767 .deleteGlobalForwardingRules({
768 project: env.project,
769 forwardingRule: output.forwardingRuleName,
770 })
771 .pipe(
772 Effect.flatMap((operation) =>
773 waitGlobalOperation(env.project, operation, {
774 ignore: ["RESOURCE_NOT_FOUND"],
775 }),
776 ),
777 Effect.catchTag("NotFound", () => Effect.void),
778 Effect.retry({
779 while: (error) => error._tag === "Conflict",
780 times: 8,
781 schedule: Schedule.spaced("2 seconds"),
782 }),
783 );
784 yield* waitUntilGone(env.project, output.forwardingRuleName);
785 }),
786 });