1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23const DEFAULT_SCHEME = "EXTERNAL";
24const DEFAULT_NETWORK_TIER = "PREMIUM";
25const MAX_NAME_LENGTH = 63;
26
27export type ForwardingRuleServiceDirectoryRegistration =
28 compute.ForwardingRuleServiceDirectoryRegistration;
29
30export type ForwardingRuleProps = {
31 /**
32 * Name of the forwarding rule. If omitted, a unique RFC1035 name is
33 * generated from the stack, stage, and logical id. Immutable — changing
34 * it replaces the rule.
35 */
36 forwardingRuleName?: string;
37 /**
38 * Region the rule lives in. Immutable — changing it replaces the rule.
39 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
40 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
41 */
42 region?: string;
43 /**
44 * Optional description. Immutable — changing it replaces the rule.
45 */
46 description?: string;
47 /**
48 * IP address or Address URL this rule accepts traffic on. When omitted,
49 * GCP assigns an ephemeral address. Immutable — changing a literal IP
50 * replaces the rule.
51 */
52 ipAddress?: string;
53 /**
54 * IP protocol (`TCP`, `UDP`, `ESP`, `AH`, `SCTP`, `ICMP`, `L3_DEFAULT`).
55 * Immutable — changing it replaces the rule.
56 */
57 ipProtocol?: compute.ForwardingRuleIPProtocolEnum | (string & {});
58 /**
59 * Single port or `start-end` range. Mutually exclusive with `ports` and
60 * `allPorts`. Immutable — changing it replaces the rule.
61 */
62 portRange?: string;
63 /**
64 * Up to five discrete ports. Mutually exclusive with `portRange` and
65 * `allPorts`. Immutable — changing it replaces the rule.
66 */
67 ports?: string[];
68 /**
69 * Forward every port (and packets with no port). Mutually exclusive with
70 * `portRange` and `ports`. Immutable — changing it replaces the rule.
71 */
72 allPorts?: boolean;
73 /**
74 * Target resource URL (target pool, target instance, target HTTP(S)/TCP
75 * proxy, target VPN gateway, or a PSC service attachment / Google API
76 * bundle). Updated in place via `setTarget`.
77 */
78 target?: string;
79 /**
80 * Backend service URL. Required for internal and external passthrough
81 * Network Load Balancers; omitted for other products. Immutable —
82 * changing it replaces the rule.
83 */
84 backendService?: string;
85 /**
86 * Forwarding rule type (`EXTERNAL`, `EXTERNAL_MANAGED`, `INTERNAL`,
87 * `INTERNAL_MANAGED`). Use `""` for some PSC endpoints. Immutable —
88 * changing it replaces the rule.
89 * @default "EXTERNAL"
90 */
91 loadBalancingScheme?:
92 | compute.ForwardingRuleLoadBalancingSchemeEnum
93 | ""
94 | (string & {});
95 /**
96 * VPC network URL or name. Used for internal load balancing and PSC.
97 * Immutable — changing it replaces the rule.
98 */
99 network?: string;
100 /**
101 * Subnetwork URL or name. Required for custom-mode VPCs and IPv6
102 * external rules. Immutable — changing it replaces the rule.
103 */
104 subnetwork?: string;
105 /**
106 * Networking tier (`PREMIUM` or `STANDARD`). Immutable — changing it
107 * replaces the rule.
108 * @default "PREMIUM"
109 */
110 networkTier?: compute.ForwardingRuleNetworkTierEnum | (string & {});
111 /**
112 * `IPV4` or `IPV6`. Immutable — changing it replaces the rule.
113 */
114 ipVersion?: compute.ForwardingRuleIpVersionEnum | (string & {});
115 /**
116 * Allow clients in other regions to reach this internal load balancer.
117 * Updated in place via `patch`, except `INTERNAL_MANAGED` where a change
118 * replaces the rule.
119 */
120 allowGlobalAccess?: boolean;
121 /**
122 * Allow a PSC consumer endpoint to be reached from another region.
123 * Updated in place via `patch`.
124 */
125 allowPscGlobalAccess?: boolean;
126 /**
127 * Skip auto-creating a DNS zone for a PSC consumer endpoint. Immutable
128 * — changing it replaces the rule.
129 */
130 noAutomateDnsZone?: boolean;
131 /**
132 * Use this INTERNAL load balancer as a packet-mirroring collector.
133 * Immutable — changing it replaces the rule.
134 */
135 isMirroringCollector?: boolean;
136 /**
137 * Optional prefix of the fully-qualified INTERNAL service name.
138 * Immutable — changing it replaces the rule.
139 */
140 serviceLabel?: string;
141 /**
142 * Restrict EXTERNAL regional rules to these source IPs or CIDRs (max
143 * 64). Immutable — changing it replaces the rule.
144 */
145 sourceIpRanges?: string[];
146 /**
147 * Public delegated prefix used to draw a BYOIP IPv6 address. Immutable
148 * — changing it replaces the rule.
149 */
150 ipCollection?: string;
151 /**
152 * Service Directory registrations. Currently a single entry. Immutable
153 * — changing it replaces the rule.
154 */
155 serviceDirectoryRegistrations?: ForwardingRuleServiceDirectoryRegistration[];
156 /**
157 * User labels. Alchemy ownership labels are merged in automatically
158 * and synced via `setLabels` (labels cannot be set on insert).
159 */
160 labels?: Record<string, string>;
161};
162
163export type ForwardingRule = Resource<
164 "GCP.Compute.ForwardingRule",
165 ForwardingRuleProps,
166 {
167 /** Forwarding rule name. */
168 forwardingRuleName: string;
169 /** Project id. */
170 project: string;
171 /** Region short name (`us-central1`). */
172 region: string;
173 /** Server-assigned numeric id. */
174 forwardingRuleId: string | undefined;
175 /** Resource self-link. */
176 selfLink: string | undefined;
177 /** Assigned IP address number. */
178 ipAddress: string | undefined;
179 /** IP protocol. */
180 ipProtocol: string | undefined;
181 /** Port range, if set. */
182 portRange: string | undefined;
183 /** Discrete ports, if set. */
184 ports: ReadonlyArray<string>;
185 /** Whether every port is forwarded. */
186 allPorts: boolean;
187 /** Target resource URL, if set. */
188 target: string | undefined;
189 /** Backend service URL, if set. */
190 backendService: string | undefined;
191 /** Load balancing scheme. */
192 loadBalancingScheme: string | undefined;
193 /** Network URL, if set. */
194 network: string | undefined;
195 /** Subnetwork URL, if set. */
196 subnetwork: string | undefined;
197 /** Networking tier. */
198 networkTier: string | undefined;
199 /** IP version. */
200 ipVersion: string | undefined;
201 /** Cross-region ILB access. */
202 allowGlobalAccess: boolean;
203 /** Cross-region PSC access. */
204 allowPscGlobalAccess: boolean;
205 /** PSC DNS-zone automation disabled. */
206 noAutomateDnsZone: boolean;
207 /** Packet-mirroring collector. */
208 isMirroringCollector: boolean;
209 /** INTERNAL service-name prefix, if set. */
210 serviceLabel: string | undefined;
211 /** Fully-qualified INTERNAL service name, if any. */
212 serviceName: string | undefined;
213 /** Source IP filters. */
214 sourceIpRanges: ReadonlyArray<string>;
215 /** BYOIP public delegated prefix, if any. */
216 ipCollection: string | undefined;
217 /** PSC connection id, if any. */
218 pscConnectionId: string | undefined;
219 /** PSC connection status, if any. */
220 pscConnectionStatus: string | undefined;
221 /** Corresponding base forwarding rule, if any. */
222 baseForwardingRule: string | undefined;
223 /** Description. */
224 description: string | undefined;
225 /** User labels (Alchemy ownership labels stripped). */
226 labels: Record<string, string>;
227 /** RFC3339 creation timestamp. */
228 creationTimestamp: string | undefined;
229 /** Resource kind. */
230 kind: string | undefined;
231 },
232 never,
233 Providers
234>;
235
236/**
237 * A regional Compute Engine forwarding rule.
238 *
239 * Forwarding rules are the frontend of a Google Cloud load balancer: an
240 * IP address, protocol, and port that send traffic to a target (pool,
241 * instance, HTTP(S)/TCP proxy, VPN gateway) or a backend service. This
242 * resource maps to the regional `forwardingRules` collection
243 * (`globalForwardingRules` is a separate resource).
244 *
245 * Labels cannot be set on insert — Alchemy applies them with `setLabels`
246 * after the rule exists. `target`, `allowGlobalAccess`, and
247 * `allowPscGlobalAccess` update in place; name, region, IP, protocol,
248 * ports, scheme, network, subnetwork, backend service, and description
249 * replace the rule.
250 *
251 * ### Creating a Forwarding Rule
252 * **Example:** Classic Network Load Balancer frontend
253 * ```typescript
254 * const rule = yield* GCP.Compute.ForwardingRule("Frontend", {
255 * region: "us-central1",
256 * target: targetPool.selfLink,
257 * portRange: "80",
258 * });
259 * ```
260 *
261 * **Example:** Named rule with labels
262 * ```typescript
263 * const rule = yield* GCP.Compute.ForwardingRule("Frontend", {
264 * forwardingRuleName: "app-frontend",
265 * region: "us-central1",
266 * ipProtocol: "TCP",
267 * portRange: "80-80",
268 * target: targetPool.selfLink,
269 * labels: { env: "prod" },
270 * });
271 * ```
272 *
273 * ### Internal Load Balancing
274 * **Example:** Regional internal passthrough NLB
275 * ```typescript
276 * const rule = yield* GCP.Compute.ForwardingRule("Ilb", {
277 * region: "us-central1",
278 * loadBalancingScheme: "INTERNAL",
279 * backendService: backend.selfLink,
280 * network: vpc.selfLink,
281 * subnetwork: subnet.selfLink,
282 * ipProtocol: "TCP",
283 * ports: ["80"],
284 * allowGlobalAccess: true,
285 * });
286 * ```
287 *
288 * @resource
289 * @category Compute
290 */
291export const ForwardingRule = Resource<ForwardingRule>(
292 "GCP.Compute.ForwardingRule",
293);
294
295export class ForwardingRuleNotResolved extends Data.TaggedError(
296 "GCP.Compute.ForwardingRuleNotResolved",
297)<{
298 forwardingRuleName: string;
299 region: string;
300}> {}
301
302export class ForwardingRulePending extends Data.TaggedError(
303 "GCP.Compute.ForwardingRulePending",
304)<{
305 forwardingRuleName: string;
306 status: string;
307}> {}
308
309const userLabels = (
310 labels: Record<string, string | undefined> | null | undefined,
311): Record<string, string> => stripInternalLabels(tagRecord(labels));
312
313const lastSegment = (value: string) => {
314 const trimmed = value.replace(/\/+$/, "");
315 const parts = trimmed.split("/");
316 return parts[parts.length - 1] || trimmed;
317};
318
319const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
320 lastSegment(region ?? defaultRegion).toLowerCase();
321
322const resourceRefOf = (value: string | undefined) => {
323 if (!value) return "";
324 return lastSegment(value);
325};
326
327const schemeOf = (value: string | undefined) =>
328 value === undefined ? DEFAULT_SCHEME : value;
329
330const networkTierOf = (value: string | undefined) =>
331 value ?? DEFAULT_NETWORK_TIER;
332
333const ipVersionOf = (value: string | undefined) =>
334 value && value !== "UNSPECIFIED_VERSION" ? value : "";
335
336const normalizePortRange = (value: string | undefined) => {
337 if (value === undefined || value.length === 0) return "";
338 const trimmed = value.trim();
339 return /^\d+$/.test(trimmed) ? `${trimmed}-${trimmed}` : trimmed;
340};
341
342const portsKey = (ports: ReadonlyArray<string> | undefined) =>
343 [...(ports ?? [])].map(String).sort().join(",");
344
345const rangesKey = (ranges: ReadonlyArray<string> | undefined) =>
346 [...(ranges ?? [])].map(String).sort().join(",");
347
348const registrationsKey = (
349 registrations:
350 | ReadonlyArray<ForwardingRuleServiceDirectoryRegistration>
351 | undefined,
352) =>
353 JSON.stringify(
354 (registrations ?? []).map((item) => ({
355 namespace: item.namespace ?? "",
356 service: item.service ?? "",
357 serviceDirectoryRegion: item.serviceDirectoryRegion ?? "",
358 })),
359 );
360
361const isLiteralIp = (value: string) => {
362 if (value.includes("/")) return false;
363 if (/^[0-9.]+$/.test(value)) return true;
364 return value.includes(":");
365};
366
367const toName = (id: string, name: string | undefined, existing?: string) =>
368 Effect.gen(function* () {
369 if (name !== undefined) return name;
370 if (existing !== undefined) return existing;
371 const generated = yield* createPhysicalName({
372 id,
373 maxLength: MAX_NAME_LENGTH,
374 lowercase: true,
375 });
376 return /^[a-z]/.test(generated)
377 ? generated
378 : `f${generated}`.slice(0, MAX_NAME_LENGTH);
379 });
380
381const toAttrs = (rule: compute.ForwardingRule, project: string) => ({
382 forwardingRuleName: rule.name ?? "",
383 project,
384 region: lastSegment(rule.region ?? "").toLowerCase(),
385 forwardingRuleId: rule.id,
386 selfLink: rule.selfLink,
387 ipAddress: rule.IPAddress,
388 ipProtocol: rule.IPProtocol,
389 portRange: rule.portRange,
390 ports: rule.ports ?? [],
391 allPorts: rule.allPorts === true,
392 target: rule.target,
393 backendService: rule.backendService,
394 loadBalancingScheme: rule.loadBalancingScheme,
395 network: rule.network,
396 subnetwork: rule.subnetwork,
397 networkTier: rule.networkTier,
398 ipVersion: rule.ipVersion,
399 allowGlobalAccess: rule.allowGlobalAccess === true,
400 allowPscGlobalAccess: rule.allowPscGlobalAccess === true,
401 noAutomateDnsZone: rule.noAutomateDnsZone === true,
402 isMirroringCollector: rule.isMirroringCollector === true,
403 serviceLabel: rule.serviceLabel,
404 serviceName: rule.serviceName,
405 sourceIpRanges: rule.sourceIpRanges ?? [],
406 ipCollection: rule.ipCollection,
407 pscConnectionId: rule.pscConnectionId,
408 pscConnectionStatus: rule.pscConnectionStatus,
409 baseForwardingRule: rule.baseForwardingRule,
410 description: rule.description,
411 labels: userLabels(rule.labels),
412 creationTimestamp: rule.creationTimestamp,
413 kind: rule.kind,
414});
415
416const toInsertBody = (
417 forwardingRuleName: string,
418 news: ForwardingRuleProps,
419): compute.ForwardingRule => ({
420 name: forwardingRuleName,
421 description: news.description,
422 IPAddress: news.ipAddress,
423 IPProtocol: news.ipProtocol,
424 portRange:
425 news.allPorts === true || news.ports !== undefined
426 ? undefined
427 : news.portRange,
428 ports:
429 news.allPorts === true || news.portRange !== undefined
430 ? undefined
431 : news.ports,
432 allPorts: news.allPorts,
433 target: news.target,
434 backendService: news.backendService,
435 loadBalancingScheme: news.loadBalancingScheme,
436 network: news.network,
437 subnetwork: news.subnetwork,
438 networkTier: news.networkTier,
439 ipVersion: news.ipVersion,
440 allowGlobalAccess: news.allowGlobalAccess,
441 allowPscGlobalAccess: news.allowPscGlobalAccess,
442 noAutomateDnsZone: news.noAutomateDnsZone,
443 isMirroringCollector: news.isMirroringCollector,
444 serviceLabel: news.serviceLabel,
445 sourceIpRanges: news.sourceIpRanges,
446 ipCollection: news.ipCollection,
447 serviceDirectoryRegistrations: news.serviceDirectoryRegistrations,
448});
449
450const getByName = (project: string, region: string, forwardingRule: string) =>
451 compute
452 .getForwardingRules({ project, region, forwardingRule })
453 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
454
455const requireForwardingRule = (
456 project: string,
457 region: string,
458 forwardingRuleName: string,
459) =>
460 getByName(project, region, forwardingRuleName).pipe(
461 Effect.flatMap((rule) =>
462 rule
463 ? Effect.succeed(rule)
464 : Effect.fail(
465 new ForwardingRuleNotResolved({ forwardingRuleName, region }),
466 ),
467 ),
468 Effect.retry({
469 while: (e) => e._tag === "GCP.Compute.ForwardingRuleNotResolved",
470 schedule: Schedule.spaced("1 second"),
471 times: 8,
472 }),
473 );
474
475const waitUntilGone = (
476 project: string,
477 region: string,
478 forwardingRuleName: string,
479) =>
480 getByName(project, region, forwardingRuleName).pipe(
481 Effect.flatMap((rule) =>
482 rule === undefined
483 ? Effect.void
484 : Effect.fail(
485 new ForwardingRulePending({
486 forwardingRuleName,
487 status: "EXISTS",
488 }),
489 ),
490 ),
491 Effect.retry({
492 while: (e) => e._tag === "GCP.Compute.ForwardingRulePending",
493 times: 10,
494 schedule: Schedule.spaced("2 seconds"),
495 }),
496 );
497
498const immutableChanged = (
499 news: ForwardingRuleProps,
500 previous: {
501 description: string;
502 ipAddress: string;
503 ipProtocol: string;
504 portRange: string;
505 ports: string;
506 allPorts: boolean;
507 backendService: string;
508 loadBalancingScheme: string;
509 network: string;
510 subnetwork: string;
511 networkTier: string;
512 ipVersion: string;
513 noAutomateDnsZone: boolean;
514 isMirroringCollector: boolean;
515 serviceLabel: string;
516 sourceIpRanges: string;
517 ipCollection: string;
518 serviceDirectoryRegistrations: string;
519 allowGlobalAccess: boolean;
520 },
521) => {
522 if (
523 news.description !== undefined &&
524 (news.description ?? "") !== previous.description
525 ) {
526 return true;
527 }
528 if (
529 news.ipAddress !== undefined &&
530 isLiteralIp(news.ipAddress) &&
531 previous.ipAddress.length > 0 &&
532 news.ipAddress !== previous.ipAddress
533 ) {
534 return true;
535 }
536 if (
537 news.ipProtocol !== undefined &&
538 news.ipProtocol.toUpperCase() !==
539 (previous.ipProtocol || "TCP").toUpperCase()
540 ) {
541 return true;
542 }
543 if (
544 news.portRange !== undefined &&
545 normalizePortRange(news.portRange) !== previous.portRange
546 ) {
547 return true;
548 }
549 if (news.ports !== undefined && portsKey(news.ports) !== previous.ports) {
550 return true;
551 }
552 if (news.allPorts !== undefined && news.allPorts !== previous.allPorts) {
553 return true;
554 }
555 if (
556 news.backendService !== undefined &&
557 resourceRefOf(news.backendService) !== previous.backendService
558 ) {
559 return true;
560 }
561 if (
562 news.loadBalancingScheme !== undefined &&
563 schemeOf(news.loadBalancingScheme) !== previous.loadBalancingScheme
564 ) {
565 return true;
566 }
567 if (
568 news.network !== undefined &&
569 resourceRefOf(news.network) !== previous.network
570 ) {
571 return true;
572 }
573 if (
574 news.subnetwork !== undefined &&
575 resourceRefOf(news.subnetwork) !== previous.subnetwork
576 ) {
577 return true;
578 }
579 if (
580 news.networkTier !== undefined &&
581 networkTierOf(news.networkTier) !== previous.networkTier
582 ) {
583 return true;
584 }
585 if (
586 news.ipVersion !== undefined &&
587 ipVersionOf(news.ipVersion) !== previous.ipVersion
588 ) {
589 return true;
590 }
591 if (
592 news.noAutomateDnsZone !== undefined &&
593 news.noAutomateDnsZone !== previous.noAutomateDnsZone
594 ) {
595 return true;
596 }
597 if (
598 news.isMirroringCollector !== undefined &&
599 news.isMirroringCollector !== previous.isMirroringCollector
600 ) {
601 return true;
602 }
603 if (
604 news.serviceLabel !== undefined &&
605 (news.serviceLabel ?? "") !== previous.serviceLabel
606 ) {
607 return true;
608 }
609 if (
610 news.sourceIpRanges !== undefined &&
611 rangesKey(news.sourceIpRanges) !== previous.sourceIpRanges
612 ) {
613 return true;
614 }
615 if (
616 news.ipCollection !== undefined &&
617 resourceRefOf(news.ipCollection) !== previous.ipCollection
618 ) {
619 return true;
620 }
621 if (
622 news.serviceDirectoryRegistrations !== undefined &&
623 registrationsKey(news.serviceDirectoryRegistrations) !==
624 previous.serviceDirectoryRegistrations
625 ) {
626 return true;
627 }
628 if (
629 schemeOf(news.loadBalancingScheme ?? previous.loadBalancingScheme) ===
630 "INTERNAL_MANAGED" &&
631 news.allowGlobalAccess !== undefined &&
632 news.allowGlobalAccess !== previous.allowGlobalAccess
633 ) {
634 return true;
635 }
636 return false;
637};
638
639export const ForwardingRuleProvider = () =>
640 Provider.succeed(ForwardingRule, {
641 stables: [
642 "forwardingRuleName",
643 "project",
644 "region",
645 "forwardingRuleId",
646 "selfLink",
647 "ipAddress",
648 "ipProtocol",
649 "portRange",
650 "loadBalancingScheme",
651 "network",
652 "subnetwork",
653 "networkTier",
654 "creationTimestamp",
655 ],
656
657 diff: Effect.fn(function* ({ news, olds, output }) {
658 if (!isResolved(news)) return undefined;
659 const env = yield* GcpEnvironment.current;
660 const previousName =
661 olds.forwardingRuleName ?? output?.forwardingRuleName;
662 const nextName = news.forwardingRuleName ?? previousName;
663 const nameChanged =
664 previousName !== undefined &&
665 nextName !== undefined &&
666 nextName !== previousName;
667
668 const previousRegion = normalizeRegion(
669 olds.region ?? output?.region,
670 env.region,
671 );
672 const nextRegion = normalizeRegion(
673 news.region ?? output?.region,
674 env.region,
675 );
676 const regionChanged = previousRegion !== nextRegion;
677
678 const previous = {
679 description: olds.description ?? output?.description ?? "",
680 ipAddress: olds.ipAddress ?? output?.ipAddress ?? "",
681 ipProtocol: olds.ipProtocol ?? output?.ipProtocol ?? "",
682 portRange: normalizePortRange(olds.portRange ?? output?.portRange),
683 ports: portsKey(olds.ports ?? output?.ports),
684 allPorts: olds.allPorts ?? output?.allPorts ?? false,
685 backendService: resourceRefOf(
686 olds.backendService ?? output?.backendService,
687 ),
688 loadBalancingScheme: schemeOf(
689 olds.loadBalancingScheme ?? output?.loadBalancingScheme,
690 ),
691 network: resourceRefOf(olds.network ?? output?.network),
692 subnetwork: resourceRefOf(olds.subnetwork ?? output?.subnetwork),
693 networkTier: networkTierOf(olds.networkTier ?? output?.networkTier),
694 ipVersion: ipVersionOf(olds.ipVersion ?? output?.ipVersion),
695 noAutomateDnsZone:
696 olds.noAutomateDnsZone ?? output?.noAutomateDnsZone ?? false,
697 isMirroringCollector:
698 olds.isMirroringCollector ?? output?.isMirroringCollector ?? false,
699 serviceLabel: olds.serviceLabel ?? output?.serviceLabel ?? "",
700 sourceIpRanges: rangesKey(
701 olds.sourceIpRanges ?? output?.sourceIpRanges,
702 ),
703 ipCollection: resourceRefOf(olds.ipCollection ?? output?.ipCollection),
704 serviceDirectoryRegistrations: registrationsKey(
705 olds.serviceDirectoryRegistrations,
706 ),
707 allowGlobalAccess:
708 olds.allowGlobalAccess ?? output?.allowGlobalAccess ?? false,
709 };
710
711 if (nameChanged || regionChanged) {
712 return { action: "replace" as const, deleteFirst: false };
713 }
714 if (immutableChanged(news, previous)) {
715 return { action: "replace" as const, deleteFirst: true };
716 }
717 return undefined;
718 }),
719
720 read: Effect.fn(function* ({ id, olds, output }) {
721 const env = yield* GcpEnvironment.current;
722 const forwardingRuleName = yield* toName(
723 id,
724 olds?.forwardingRuleName,
725 output?.forwardingRuleName,
726 );
727 const region = normalizeRegion(
728 olds?.region ?? output?.region,
729 env.region,
730 );
731 const existing = yield* getByName(
732 env.project,
733 region,
734 forwardingRuleName,
735 );
736 if (existing === undefined) return undefined;
737 const attrs = toAttrs(existing, env.project);
738 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
739 ? attrs
740 : Unowned(attrs);
741 }),
742
743 list: () =>
744 Effect.gen(function* () {
745 const env = yield* GcpEnvironment.current;
746 const pages = yield* compute.aggregatedListForwardingRules
747 .pages({
748 project: env.project,
749 filter: "labels.alchemy-id:*",
750 returnPartialSuccess: true,
751 maxResults: 500,
752 })
753 .pipe(Stream.runCollect);
754 return Array.from(pages).flatMap((page) =>
755 Object.values(page.items ?? {}).flatMap((scoped) =>
756 (scoped?.forwardingRules ?? [])
757 .filter((item) => item.region !== undefined)
758 .filter((item) =>
759 Object.keys(item.labels ?? {}).some((key) =>
760 key.startsWith("alchemy-"),
761 ),
762 )
763 .map((item) => toAttrs(item, env.project)),
764 ),
765 );
766 }),
767
768 reconcile: Effect.fn(function* ({ id, news, output }) {
769 const env = yield* GcpEnvironment.current;
770 const forwardingRuleName = yield* toName(
771 id,
772 news.forwardingRuleName,
773 output?.forwardingRuleName,
774 );
775 const region = normalizeRegion(news.region ?? output?.region, env.region);
776 const desiredLabels = {
777 ...toLabels(news.labels),
778 ...(yield* createInternalLabels(id)),
779 };
780
781 let current = yield* getByName(env.project, region, forwardingRuleName);
782
783 if (current === undefined) {
784 const created = yield* compute
785 .insertForwardingRules({
786 project: env.project,
787 region,
788 body: toInsertBody(forwardingRuleName, news),
789 })
790 .pipe(
791 Effect.flatMap((operation) =>
792 waitRegionOperation(env.project, region, operation, {
793 ignore: ["RESOURCE_ALREADY_EXISTS"],
794 }).pipe(
795 Effect.flatMap(() =>
796 requireForwardingRule(
797 env.project,
798 region,
799 forwardingRuleName,
800 ),
801 ),
802 ),
803 ),
804 Effect.catchTag("Conflict", () =>
805 requireForwardingRule(env.project, region, forwardingRuleName),
806 ),
807 );
808 current = created ?? undefined;
809 }
810
811 if (current === undefined) {
812 return yield* new ForwardingRuleNotResolved({
813 forwardingRuleName,
814 region,
815 });
816 }
817 const resolved = current;
818
819 if (
820 news.target !== undefined &&
821 resourceRefOf(news.target) !== resourceRefOf(current.target)
822 ) {
823 yield* compute
824 .setTargetForwardingRules({
825 project: env.project,
826 region,
827 forwardingRule: forwardingRuleName,
828 body: { target: news.target },
829 })
830 .pipe(
831 Effect.flatMap((operation) =>
832 waitRegionOperation(env.project, region, operation),
833 ),
834 );
835 current =
836 (yield* getByName(env.project, region, forwardingRuleName)) ??
837 current;
838 }
839
840 const scheme = schemeOf(current.loadBalancingScheme);
841 if (
842 news.allowGlobalAccess !== undefined &&
843 scheme !== "INTERNAL_MANAGED" &&
844 news.allowGlobalAccess !== (current.allowGlobalAccess === true)
845 ) {
846 yield* compute
847 .patchForwardingRules({
848 project: env.project,
849 region,
850 forwardingRule: forwardingRuleName,
851 body: { allowGlobalAccess: news.allowGlobalAccess },
852 })
853 .pipe(
854 Effect.flatMap((operation) =>
855 waitRegionOperation(env.project, region, operation),
856 ),
857 );
858 current =
859 (yield* getByName(env.project, region, forwardingRuleName)) ??
860 current;
861 }
862
863 if (
864 news.allowPscGlobalAccess !== undefined &&
865 news.allowPscGlobalAccess !== (current.allowPscGlobalAccess === true)
866 ) {
867 yield* compute
868 .patchForwardingRules({
869 project: env.project,
870 region,
871 forwardingRule: forwardingRuleName,
872 body: {
873 fingerprint: current.fingerprint,
874 allowPscGlobalAccess: news.allowPscGlobalAccess,
875 },
876 })
877 .pipe(
878 Effect.flatMap((operation) =>
879 waitRegionOperation(env.project, region, operation),
880 ),
881 );
882 current =
883 (yield* getByName(env.project, region, forwardingRuleName)) ??
884 current;
885 }
886
887 const observedLabels = tagRecord(current.labels);
888 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
889 if (upsert.length > 0 || removed.length > 0) {
890 yield* Effect.gen(function* () {
891 const latest =
892 (yield* getByName(env.project, region, forwardingRuleName)) ??
893 resolved;
894 yield* compute
895 .setLabelsForwardingRules({
896 project: env.project,
897 region,
898 resource: forwardingRuleName,
899 body: {
900 labels: desiredLabels,
901 labelFingerprint: latest.labelFingerprint,
902 },
903 })
904 .pipe(
905 Effect.flatMap((operation) =>
906 waitRegionOperation(env.project, region, operation),
907 ),
908 );
909 }).pipe(
910 Effect.retry({
911 while: (e) => e._tag === "Conflict",
912 times: 5,
913 schedule: Schedule.spaced("1 second"),
914 }),
915 );
916 current =
917 (yield* getByName(env.project, region, forwardingRuleName)) ??
918 resolved;
919 }
920
921 return toAttrs(current ?? resolved, env.project);
922 }),
923
924 delete: Effect.fn(function* ({ output }) {
925 const env = yield* GcpEnvironment.current;
926 const project = output.project || env.project;
927 const region = normalizeRegion(output.region, env.region);
928 yield* compute
929 .deleteForwardingRules({
930 project,
931 region,
932 forwardingRule: output.forwardingRuleName,
933 })
934 .pipe(
935 Effect.flatMap((operation) =>
936 waitRegionOperation(project, region, operation, {
937 ignore: ["RESOURCE_NOT_FOUND"],
938 }),
939 ),
940 Effect.catchTag("NotFound", () => Effect.void),
941 Effect.retry({
942 while: (e) => e._tag === "Conflict",
943 times: 8,
944 schedule: Schedule.spaced("2 seconds"),
945 }),
946 );
947 yield* waitUntilGone(project, region, output.forwardingRuleName);
948 }),
949 });