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 { 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 { GcpEnvironment } from "../Environment.ts";
12import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
13import type { Providers } from "../Providers.ts";
14
15const DEFAULT_ADVERTISE_MODE = "DEFAULT";
16const DEFAULT_KEEPALIVE = 20;
17const MAX_NAME_LENGTH = 63;
18
19const OWNERSHIP_KEYS = [
20 "alchemy-stack",
21 "alchemy-stage",
22 "alchemy-id",
23] as const;
24
25export type RouterAdvertisedIpRange = {
26 /** CIDR to advertise (for example `10.0.0.0/8`). */
27 range: string;
28 /** Optional description of this advertised range. */
29 description?: string;
30};
31
32export type RouterBgp = {
33 /**
34 * Local BGP ASN. Must be an RFC6996 private ASN (16-bit or 32-bit).
35 * Shared by every VPN tunnel attached to this router.
36 */
37 asn: number;
38 /**
39 * Advertisement mode. `CUSTOM` requires `advertisedGroups` and/or
40 * `advertisedIpRanges`.
41 * @default "DEFAULT"
42 */
43 advertiseMode?: compute.RouterBgpAdvertiseModeEnum;
44 /**
45 * Prefix groups to advertise when `advertiseMode` is `CUSTOM`. The only
46 * valid value today is `ALL_SUBNETS`.
47 */
48 advertisedGroups?: string[];
49 /**
50 * Individual CIDRs to advertise when `advertiseMode` is `CUSTOM`.
51 */
52 advertisedIpRanges?: RouterAdvertisedIpRange[];
53 /**
54 * Seconds between BGP keepalives (20–60). Hold time is 3× this value.
55 * @default 20
56 */
57 keepaliveInterval?: number;
58 /**
59 * Link-local IPv4 `/30` or larger from `169.254.0.0/16` used as the BGP
60 * identifier (router ID).
61 */
62 identifierRange?: string;
63};
64
65export type RouterNatSubnetwork = {
66 /** Subnetwork name, partial URL, or full resource URL. */
67 name: string;
68 /**
69 * Ranges of this subnetwork to translate (`ALL_IP_RANGES`,
70 * `PRIMARY_IP_RANGE`, `LIST_OF_SECONDARY_IP_RANGES`).
71 * @default ["ALL_IP_RANGES"]
72 */
73 sourceIpRangesToNat?: compute.RouterNatSubnetworkToNatSourceIpRangesToNatItemEnum[];
74 /**
75 * Secondary range names to translate. Requires
76 * `LIST_OF_SECONDARY_IP_RANGES` in `sourceIpRangesToNat`.
77 */
78 secondaryIpRangeNames?: string[];
79};
80
81export type RouterNatLogConfig = {
82 /** Export NAT logs. */
83 enable: boolean;
84 /**
85 * Which connections to log (`ERRORS_ONLY`, `TRANSLATIONS_ONLY`, `ALL`).
86 * @default "ALL"
87 */
88 filter?: compute.RouterNatLogConfigFilterEnum;
89};
90
91export type RouterNat = {
92 /** NAT name, unique within the router (RFC1035, 1-63 chars). */
93 name: string;
94 /**
95 * Which subnetwork ranges are translated: `ALL_SUBNETWORKS_ALL_IP_RANGES`,
96 * `ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES`, or `LIST_OF_SUBNETWORKS`
97 * (with `subnetworks`).
98 */
99 sourceSubnetworkIpRangesToNat: compute.RouterNatSourceSubnetworkIpRangesToNatEnum;
100 /** Subnetworks to translate when `LIST_OF_SUBNETWORKS` is selected. */
101 subnetworks?: RouterNatSubnetwork[];
102 /**
103 * `AUTO_ONLY` lets Google allocate external IPs; `MANUAL_ONLY` uses
104 * `natIps`.
105 * @default "AUTO_ONLY"
106 */
107 natIpAllocateOption?: compute.RouterNatNatIpAllocateOptionEnum;
108 /**
109 * Static external addresses (`GCP.Compute.Address` self links or names)
110 * used with `MANUAL_ONLY`.
111 */
112 natIps?: string[];
113 /** Minimum ports allocated to each VM. */
114 minPortsPerVm?: number;
115 /** Maximum ports per VM when dynamic port allocation is enabled. */
116 maxPortsPerVm?: number;
117 /** Enable dynamic port allocation. */
118 enableDynamicPortAllocation?: boolean;
119 /** Enable endpoint-independent mapping. */
120 enableEndpointIndependentMapping?: boolean;
121 /** UDP idle timeout in seconds. */
122 udpIdleTimeoutSec?: number;
123 /** ICMP idle timeout in seconds. */
124 icmpIdleTimeoutSec?: number;
125 /** TCP established-connection idle timeout in seconds. */
126 tcpEstablishedIdleTimeoutSec?: number;
127 /** TCP transitory-connection idle timeout in seconds. */
128 tcpTransitoryIdleTimeoutSec?: number;
129 /** NAT logging. */
130 logConfig?: RouterNatLogConfig;
131};
132
133export type RouterProps = {
134 /**
135 * Router name (RFC1035, 1-63 chars). If omitted, a unique name is
136 * generated from the stack, stage, and logical id. Immutable — changing
137 * it replaces the router.
138 */
139 routerName?: string;
140 /**
141 * Region the router lives in. Immutable — changing it replaces the
142 * router. `US-CENTRAL1` is accepted and normalized to `us-central1`.
143 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
144 */
145 region?: string;
146 /**
147 * VPC network this router belongs to. Accepts a name (`default`), a
148 * partial URL (`global/networks/default`), or a full resource URL.
149 * Immutable — changing it replaces the router.
150 */
151 network: string;
152 /**
153 * Optional description. Compute Router has no `labels` field, so Alchemy
154 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
155 * in the description for `list` / nuke.
156 */
157 description?: string;
158 /**
159 * BGP configuration. Omit when the router is NAT-only.
160 */
161 bgp?: RouterBgp;
162 /**
163 * Cloud NAT gateways hosted on this router. Updated in place. Omit to
164 * leave the router's NATs unmanaged; `[]` removes all of them.
165 */
166 nats?: RouterNat[];
167 /**
168 * Dedicated for encrypted VLAN attachments. Immutable — changing it
169 * replaces the router.
170 * @default false
171 */
172 encryptedInterconnectRouter?: boolean;
173 /**
174 * NCC Gateway spoke URI. Immutable — changing it replaces the router.
175 * Mutually exclusive with `network` at the API.
176 */
177 nccGateway?: string;
178};
179
180export type Router = Resource<
181 "GCP.Compute.Router",
182 RouterProps,
183 {
184 /** RFC1035 router name. */
185 routerName: string;
186 /** Project id. */
187 project: string;
188 /** Region short name (`us-central1`). */
189 region: string;
190 /** Parent VPC network URL. */
191 network: string;
192 /** User description (Alchemy ownership marker stripped). */
193 description: string | undefined;
194 /** BGP configuration, if set. */
195 bgp: RouterBgp | undefined;
196 /** Cloud NAT gateways on this router. */
197 nats: RouterNat[];
198 /** Whether this router is dedicated to encrypted interconnect. */
199 encryptedInterconnectRouter: boolean;
200 /** NCC Gateway spoke URI, if any. */
201 nccGateway: string | undefined;
202 /** Server-defined resource URL. */
203 selfLink: string | undefined;
204 /** Server-assigned numeric id. */
205 routerId: string | undefined;
206 /** RFC3339 creation timestamp. */
207 creationTimestamp: string | undefined;
208 },
209 never,
210 Providers
211>;
212
213/**
214 * A regional Cloud Router.
215 *
216 * Cloud Routers advertise VPC routes over BGP to VPN tunnels and
217 * interconnects, and they host Cloud NAT. Compute Router has no `labels`
218 * field — Alchemy stamps `alchemy-stack` / `alchemy-stage` / `alchemy-id`
219 * into the description so `list` and `pnpm nuke:gcp` can find owned
220 * routers.
221 *
222 * Name, region, network, `encryptedInterconnectRouter`, and `nccGateway`
223 * are immutable. Description and BGP (ASN, advertise mode, advertised
224 * ranges, keepalive) and Cloud NAT gateways (`nats`) update in place via
225 * `routers.patch`.
226 *
227 * ### Creating a Router
228 * **Example:** Generated name on a custom-mode VPC
229 * ```typescript
230 * const network = yield* GCP.Compute.Network("Vpc", {
231 * autoCreateSubnetworks: false,
232 * });
233 * const router = yield* GCP.Compute.Router("Edge", {
234 * network: network.networkName,
235 * });
236 * ```
237 *
238 * **Example:** Named router with BGP
239 * ```typescript
240 * const router = yield* GCP.Compute.Router("Edge", {
241 * routerName: "app-router",
242 * region: "us-central1",
243 * network: "app-vpc",
244 * description: "edge bgp",
245 * bgp: { asn: 65001, advertiseMode: "DEFAULT" },
246 * });
247 * ```
248 *
249 * ### Cloud NAT
250 * **Example:** NAT a subnet through a static egress IP
251 * ```typescript
252 * const egressIp = yield* GCP.Compute.Address("EgressIp", {
253 * addressType: "EXTERNAL",
254 * });
255 * const router = yield* GCP.Compute.Router("Nat", {
256 * network: network.networkName,
257 * nats: [
258 * {
259 * name: "egress",
260 * sourceSubnetworkIpRangesToNat: "LIST_OF_SUBNETWORKS",
261 * subnetworks: [{ name: subnet.subnetworkName }],
262 * natIpAllocateOption: "MANUAL_ONLY",
263 * natIps: [egressIp.selfLink.as<string>()],
264 * logConfig: { enable: true, filter: "ERRORS_ONLY" },
265 * },
266 * ],
267 * });
268 * ```
269 *
270 * ### Custom advertisements
271 * **Example:** Advertise all subnets plus a CIDR
272 * ```typescript
273 * const router = yield* GCP.Compute.Router("Edge", {
274 * network: network.networkName,
275 * bgp: {
276 * asn: 65001,
277 * advertiseMode: "CUSTOM",
278 * advertisedGroups: ["ALL_SUBNETS"],
279 * advertisedIpRanges: [
280 * { range: "10.0.0.0/8", description: "rfc1918" },
281 * ],
282 * },
283 * });
284 * ```
285 *
286 * @resource
287 * @category Compute
288 */
289export const Router = Resource<Router>("GCP.Compute.Router");
290
291export class RouterNotResolved extends Data.TaggedError(
292 "GCP.Compute.RouterNotResolved",
293)<{
294 routerName: string;
295 region: string;
296}> {}
297
298export class RouterOperationPending extends Data.TaggedError(
299 "GCP.Compute.RouterOperationPending",
300)<{
301 operation: string;
302 status: string | undefined;
303}> {}
304
305const lastSegment = (value: string) => {
306 const trimmed = value.replace(/\/+$/, "");
307 const parts = trimmed.split("/");
308 return parts[parts.length - 1] || trimmed;
309};
310
311const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
312 lastSegment(region ?? defaultRegion).toLowerCase();
313
314const linkKey = (value: string | undefined) =>
315 value === undefined || value === "" ? "" : lastSegment(value).toLowerCase();
316
317const networkRef = (project: string, network: string) => {
318 if (network.includes("/")) {
319 return network.startsWith("projects/") || network.startsWith("http")
320 ? network
321 : `projects/${project}/${network.replace(/^\//, "")}`;
322 }
323 return `projects/${project}/global/networks/${network}`;
324};
325
326const encodeDescription = (
327 internal: Record<string, string>,
328 user?: string,
329): string => {
330 const marker = OWNERSHIP_KEYS.map(
331 (key) => `${key}=${internal[key] ?? ""}`,
332 ).join(" ");
333 return user && user.length > 0 ? `${marker}\n${user}` : marker;
334};
335
336const parseDescription = (description: string | undefined) => {
337 if (!description) {
338 return { labels: {} as Record<string, string>, description: undefined };
339 }
340 const newline = description.indexOf("\n");
341 const first = newline === -1 ? description : description.slice(0, newline);
342 const rest = newline === -1 ? undefined : description.slice(newline + 1);
343 if (!first.includes("alchemy-id=") || !first.includes("alchemy-stack=")) {
344 return { labels: {} as Record<string, string>, description };
345 }
346 const labels: Record<string, string> = {};
347 for (const part of first.split(/\s+/)) {
348 const eq = part.indexOf("=");
349 if (eq > 0) {
350 labels[part.slice(0, eq)] = part.slice(eq + 1);
351 }
352 }
353 return {
354 labels,
355 description: rest && rest.length > 0 ? rest : undefined,
356 };
357};
358
359const hasAlchemyMarker = (description: string | undefined) =>
360 Object.keys(parseDescription(description).labels).some((key) =>
361 key.startsWith("alchemy-"),
362 );
363
364const toRouterName = (
365 id: string,
366 name: string | undefined,
367 existing?: string,
368) =>
369 Effect.gen(function* () {
370 if (name !== undefined) return name;
371 if (existing !== undefined) return existing;
372 const generated = yield* createPhysicalName({
373 id,
374 maxLength: MAX_NAME_LENGTH,
375 lowercase: true,
376 });
377 return /^[a-z]/.test(generated)
378 ? generated
379 : `r${generated}`.slice(0, MAX_NAME_LENGTH);
380 });
381
382const groupsKey = (groups: ReadonlyArray<string> | undefined) =>
383 [...(groups ?? [])]
384 .map((group) => group.toUpperCase())
385 .sort()
386 .join("\0");
387
388const rangesKey = (
389 ranges: ReadonlyArray<RouterAdvertisedIpRange> | undefined,
390) =>
391 JSON.stringify(
392 [...(ranges ?? [])]
393 .map((range) => ({
394 range: range.range,
395 description: range.description ?? "",
396 }))
397 .sort((a, b) => a.range.localeCompare(b.range)),
398 );
399
400const toAdvertiseMode = (
401 value: string | undefined,
402): compute.RouterBgpAdvertiseModeEnum | undefined => {
403 switch (value) {
404 case "CUSTOM":
405 case "DEFAULT":
406 return value;
407 default:
408 return undefined;
409 }
410};
411
412const toBgp = (bgp: compute.RouterBgp | undefined): RouterBgp | undefined => {
413 if (bgp === undefined || bgp.asn === undefined) return undefined;
414 return {
415 asn: bgp.asn,
416 advertiseMode: toAdvertiseMode(bgp.advertiseMode),
417 advertisedGroups: bgp.advertisedGroups,
418 advertisedIpRanges: (bgp.advertisedIpRanges ?? [])
419 .filter(
420 (range): range is compute.RouterAdvertisedIpRange & { range: string } =>
421 typeof range.range === "string" && range.range.length > 0,
422 )
423 .map((range) => ({
424 range: range.range,
425 description: range.description,
426 })),
427 keepaliveInterval: bgp.keepaliveInterval,
428 identifierRange: bgp.identifierRange,
429 };
430};
431
432const desiredBgp = (bgp: RouterBgp): compute.RouterBgp => {
433 const advertiseMode = bgp.advertiseMode ?? DEFAULT_ADVERTISE_MODE;
434 const body: compute.RouterBgp = {
435 asn: bgp.asn,
436 advertiseMode,
437 keepaliveInterval: bgp.keepaliveInterval ?? DEFAULT_KEEPALIVE,
438 };
439 if (advertiseMode === "CUSTOM") {
440 body.advertisedGroups = bgp.advertisedGroups ?? [];
441 body.advertisedIpRanges = (bgp.advertisedIpRanges ?? []).map((range) => ({
442 range: range.range,
443 description: range.description,
444 }));
445 }
446 if (bgp.identifierRange !== undefined) {
447 body.identifierRange = bgp.identifierRange;
448 }
449 return body;
450};
451
452const bgpEqual = (observed: RouterBgp | undefined, desired: RouterBgp) => {
453 if (observed === undefined) return false;
454 const advertiseMode = desired.advertiseMode ?? DEFAULT_ADVERTISE_MODE;
455 if (observed.asn !== desired.asn) return false;
456 if ((observed.advertiseMode ?? DEFAULT_ADVERTISE_MODE) !== advertiseMode) {
457 return false;
458 }
459 if (
460 (observed.keepaliveInterval ?? DEFAULT_KEEPALIVE) !==
461 (desired.keepaliveInterval ?? DEFAULT_KEEPALIVE)
462 ) {
463 return false;
464 }
465 if (
466 desired.identifierRange !== undefined &&
467 observed.identifierRange !== desired.identifierRange
468 ) {
469 return false;
470 }
471 if (advertiseMode !== "CUSTOM") return true;
472 return (
473 groupsKey(observed.advertisedGroups) ===
474 groupsKey(desired.advertisedGroups) &&
475 rangesKey(observed.advertisedIpRanges) ===
476 rangesKey(desired.advertisedIpRanges)
477 );
478};
479
480const regionalRef = (
481 project: string,
482 region: string,
483 collection: "subnetworks" | "addresses",
484 value: string,
485) => {
486 if (value.startsWith("http") || value.startsWith("projects/")) return value;
487 if (value.includes("/")) {
488 return `projects/${project}/${value.replace(/^\//, "")}`;
489 }
490 return `projects/${project}/regions/${region}/${collection}/${value}`;
491};
492
493const toNats = (nats: compute.RouterNat[] | undefined): RouterNat[] =>
494 (nats ?? []).map((nat) => ({
495 name: nat.name ?? "",
496 sourceSubnetworkIpRangesToNat:
497 nat.sourceSubnetworkIpRangesToNat as compute.RouterNatSourceSubnetworkIpRangesToNatEnum,
498 subnetworks: nat.subnetworks?.map((subnetwork) => ({
499 name: subnetwork.name ?? "",
500 sourceIpRangesToNat:
501 subnetwork.sourceIpRangesToNat as RouterNatSubnetwork["sourceIpRangesToNat"],
502 secondaryIpRangeNames: subnetwork.secondaryIpRangeNames,
503 })),
504 natIpAllocateOption:
505 nat.natIpAllocateOption as compute.RouterNatNatIpAllocateOptionEnum,
506 natIps: nat.natIps,
507 minPortsPerVm: nat.minPortsPerVm,
508 maxPortsPerVm: nat.maxPortsPerVm,
509 enableDynamicPortAllocation: nat.enableDynamicPortAllocation,
510 enableEndpointIndependentMapping: nat.enableEndpointIndependentMapping,
511 udpIdleTimeoutSec: nat.udpIdleTimeoutSec,
512 icmpIdleTimeoutSec: nat.icmpIdleTimeoutSec,
513 tcpEstablishedIdleTimeoutSec: nat.tcpEstablishedIdleTimeoutSec,
514 tcpTransitoryIdleTimeoutSec: nat.tcpTransitoryIdleTimeoutSec,
515 logConfig:
516 nat.logConfig === undefined
517 ? undefined
518 : {
519 enable: nat.logConfig.enable === true,
520 filter: nat.logConfig
521 .filter as compute.RouterNatLogConfigFilterEnum,
522 },
523 }));
524
525const desiredNats = (
526 project: string,
527 region: string,
528 nats: ReadonlyArray<RouterNat>,
529): compute.RouterNat[] =>
530 nats.map((nat) => ({
531 ...nat,
532 natIpAllocateOption:
533 nat.natIpAllocateOption ??
534 (nat.natIps !== undefined && nat.natIps.length > 0
535 ? "MANUAL_ONLY"
536 : "AUTO_ONLY"),
537 subnetworks: nat.subnetworks?.map((subnetwork) => ({
538 ...subnetwork,
539 name: regionalRef(project, region, "subnetworks", subnetwork.name),
540 sourceIpRangesToNat: subnetwork.sourceIpRangesToNat ?? ["ALL_IP_RANGES"],
541 })),
542 natIps: nat.natIps?.map((ip) =>
543 regionalRef(project, region, "addresses", ip),
544 ),
545 logConfig:
546 nat.logConfig === undefined
547 ? undefined
548 : {
549 enable: nat.logConfig.enable,
550 filter: nat.logConfig.filter ?? "ALL",
551 },
552 }));
553
554const sortedKey = (values: ReadonlyArray<string> | undefined) =>
555 [...(values ?? [])].sort().join("\0");
556
557// Compare only fields the desired NAT specifies: the API fills in defaults
558// (timeouts, endpoint types, tier) that the user never declared.
559const natMatches = (
560 desired: compute.RouterNat,
561 observed: compute.RouterNat,
562) => {
563 for (const [key, value] of Object.entries(desired)) {
564 if (value === undefined) continue;
565 const current = observed[key as keyof compute.RouterNat];
566 switch (key) {
567 case "natIps":
568 if (
569 sortedKey((value as string[]).map(linkKey)) !==
570 sortedKey(((current as string[] | undefined) ?? []).map(linkKey))
571 ) {
572 return false;
573 }
574 break;
575 case "subnetworks": {
576 const subnetKey = (list: compute.RouterNatSubnetworkToNat[]) =>
577 JSON.stringify(
578 list
579 .map((subnetwork) => ({
580 name: linkKey(subnetwork.name),
581 ranges: sortedKey(subnetwork.sourceIpRangesToNat),
582 secondary: sortedKey(subnetwork.secondaryIpRangeNames),
583 }))
584 .sort((a, b) => a.name.localeCompare(b.name)),
585 );
586 if (
587 subnetKey(value as compute.RouterNatSubnetworkToNat[]) !==
588 subnetKey((current as compute.RouterNatSubnetworkToNat[]) ?? [])
589 ) {
590 return false;
591 }
592 break;
593 }
594 case "logConfig": {
595 const log = current as compute.RouterNatLogConfig | undefined;
596 const want = value as compute.RouterNatLogConfig;
597 if ((log?.enable === true) !== (want.enable === true)) return false;
598 if (want.enable === true && log?.filter !== want.filter) return false;
599 break;
600 }
601 default:
602 if (current !== value) return false;
603 }
604 }
605 return true;
606};
607
608const natsEqual = (
609 observed: compute.RouterNat[] | undefined,
610 desired: compute.RouterNat[],
611) => {
612 const byName = new Map((observed ?? []).map((nat) => [nat.name, nat]));
613 if (byName.size !== desired.length) return false;
614 return desired.every((nat) => {
615 const current = byName.get(nat.name);
616 return current !== undefined && natMatches(nat, current);
617 });
618};
619
620const toAttrs = (
621 router: compute.Router,
622 project: string,
623): Router["Attributes"] => {
624 const parsed = parseDescription(router.description);
625 return {
626 routerName: router.name ?? "",
627 project,
628 region: lastSegment(router.region ?? "").toLowerCase(),
629 network: router.network ?? "",
630 description: parsed.description,
631 bgp: toBgp(router.bgp),
632 nats: toNats(router.nats),
633 encryptedInterconnectRouter: router.encryptedInterconnectRouter === true,
634 nccGateway: router.nccGateway,
635 selfLink: router.selfLink,
636 routerId: router.id,
637 creationTimestamp: router.creationTimestamp,
638 };
639};
640
641const getByName = (project: string, region: string, router: string) =>
642 compute
643 .getRouters({ project, region, router })
644 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
645
646const requireRouter = (project: string, region: string, routerName: string) =>
647 getByName(project, region, routerName).pipe(
648 Effect.flatMap((router) =>
649 router
650 ? Effect.succeed(router)
651 : Effect.fail(new RouterNotResolved({ routerName, region })),
652 ),
653 Effect.retry({
654 while: (error) => error._tag === "GCP.Compute.RouterNotResolved",
655 schedule: Schedule.spaced("1 second"),
656 times: 8,
657 }),
658 );
659
660const waitUntilRouterGone = (
661 project: string,
662 region: string,
663 routerName: string,
664) =>
665 getByName(project, region, routerName).pipe(
666 Effect.flatMap((router) =>
667 router === undefined
668 ? Effect.void
669 : Effect.fail(
670 new RouterOperationPending({
671 operation: `delete:${routerName}`,
672 status: "EXISTS",
673 }),
674 ),
675 ),
676 Effect.retry({
677 while: (error) => error._tag === "GCP.Compute.RouterOperationPending",
678 schedule: Schedule.spaced("2 seconds"),
679 times: 20,
680 }),
681 );
682
683export const RouterProvider = () =>
684 Provider.succeed(Router, {
685 nuke: {
686 dependsOn: ["GCP.Compute.Network"],
687 },
688 stables: [
689 "routerName",
690 "project",
691 "region",
692 "network",
693 "encryptedInterconnectRouter",
694 "nccGateway",
695 "routerId",
696 "selfLink",
697 "creationTimestamp",
698 ],
699
700 diff: Effect.fn(function* ({ news, olds, output }) {
701 if (!isResolved(news)) return undefined;
702 const env = yield* GcpEnvironment.current;
703 const previousName = olds?.routerName ?? output?.routerName;
704 const nextName = news.routerName ?? previousName;
705 const previousRegion = normalizeRegion(
706 olds?.region ?? output?.region,
707 env.region,
708 );
709 const nextRegion = normalizeRegion(
710 news.region ?? output?.region,
711 env.region,
712 );
713 const identityChanged =
714 previousRegion !== nextRegion ||
715 (previousName !== undefined &&
716 nextName !== undefined &&
717 previousName !== nextName);
718 const previousEncrypted =
719 olds?.encryptedInterconnectRouter ??
720 output?.encryptedInterconnectRouter ??
721 false;
722 const nextEncrypted = news.encryptedInterconnectRouter ?? false;
723 const previousAsn = olds?.bgp?.asn ?? output?.bgp?.asn;
724 const nextAsn = news.bgp?.asn;
725 const replace =
726 identityChanged ||
727 linkKey(news.network) !== linkKey(olds?.network ?? output?.network) ||
728 previousEncrypted !== nextEncrypted ||
729 (previousAsn !== undefined &&
730 nextAsn !== undefined &&
731 previousAsn !== nextAsn) ||
732 (news.nccGateway !== undefined &&
733 linkKey(news.nccGateway) !==
734 linkKey(olds?.nccGateway ?? output?.nccGateway));
735 if (!replace) return undefined;
736 return {
737 action: "replace" as const,
738 deleteFirst:
739 !identityChanged &&
740 nextName !== undefined &&
741 previousName !== undefined &&
742 nextName === previousName,
743 };
744 }),
745
746 read: Effect.fn(function* ({ id, olds, output }) {
747 const env = yield* GcpEnvironment.current;
748 const routerName = yield* toRouterName(
749 id,
750 olds?.routerName,
751 output?.routerName,
752 );
753 const region = normalizeRegion(
754 olds?.region ?? output?.region,
755 env.region,
756 );
757 const existing = yield* getByName(env.project, region, routerName);
758 if (existing === undefined) return undefined;
759 const attrs = toAttrs(existing, env.project);
760 const parsed = parseDescription(existing.description);
761 return (yield* hasAlchemyLabels(id, parsed.labels))
762 ? attrs
763 : Unowned(attrs);
764 }),
765
766 list: () =>
767 Effect.gen(function* () {
768 const env = yield* GcpEnvironment.current;
769 const found: Router["Attributes"][] = [];
770 let pageToken: string | undefined;
771 for (let page = 0; page < 10; page++) {
772 const response = yield* compute.aggregatedListRouters({
773 project: env.project,
774 returnPartialSuccess: true,
775 maxResults: 500,
776 pageToken,
777 });
778 for (const scoped of Object.values(response.items ?? {})) {
779 for (const item of scoped?.routers ?? []) {
780 if (!hasAlchemyMarker(item.description)) continue;
781 found.push(toAttrs(item, env.project));
782 }
783 }
784 pageToken = response.nextPageToken;
785 if (pageToken === undefined || pageToken === "") break;
786 }
787 return found;
788 }),
789
790 reconcile: Effect.fn(function* ({ id, news, output }) {
791 const env = yield* GcpEnvironment.current;
792 const routerName = yield* toRouterName(
793 id,
794 news.routerName,
795 output?.routerName,
796 );
797 const region = normalizeRegion(news.region ?? output?.region, env.region);
798 const network = networkRef(env.project, news.network);
799 const internal = yield* createInternalLabels(id);
800 const desiredDescription = encodeDescription(internal, news.description);
801 const encrypted = news.encryptedInterconnectRouter === true;
802 const nats =
803 news.nats === undefined
804 ? undefined
805 : desiredNats(env.project, region, news.nats);
806
807 let current = yield* getByName(env.project, region, routerName);
808
809 if (current === undefined) {
810 const body: compute.Router = {
811 name: routerName,
812 network,
813 description: desiredDescription,
814 };
815 if (news.bgp !== undefined) {
816 body.bgp = desiredBgp(news.bgp);
817 }
818 if (nats !== undefined && nats.length > 0) {
819 body.nats = nats;
820 }
821 if (encrypted) {
822 body.encryptedInterconnectRouter = true;
823 }
824 if (news.nccGateway !== undefined) {
825 body.nccGateway = news.nccGateway;
826 }
827 yield* compute
828 .insertRouters({
829 project: env.project,
830 region,
831 body,
832 })
833 .pipe(
834 Effect.flatMap((operation) =>
835 waitRegionOperation(env.project, region, operation, {
836 ignore: ["RESOURCE_ALREADY_EXISTS"],
837 }),
838 ),
839 Effect.catchTag("Conflict", () => Effect.void),
840 );
841 current = yield* requireRouter(env.project, region, routerName);
842 }
843
844 const patchBody: compute.Router = {};
845 if ((current.description ?? "") !== desiredDescription) {
846 patchBody.description = desiredDescription;
847 }
848 if (news.bgp !== undefined && !bgpEqual(toBgp(current.bgp), news.bgp)) {
849 patchBody.bgp = desiredBgp(news.bgp);
850 }
851 if (nats !== undefined && !natsEqual(current.nats, nats)) {
852 patchBody.nats = nats;
853 }
854 if (Object.keys(patchBody).length > 0) {
855 const patched = yield* compute.patchRouters({
856 project: env.project,
857 region,
858 router: routerName,
859 body: patchBody,
860 });
861 yield* waitRegionOperation(env.project, region, patched);
862 current = yield* requireRouter(env.project, region, routerName);
863 }
864
865 return toAttrs(current, env.project);
866 }),
867
868 delete: Effect.fn(function* ({ output }) {
869 const env = yield* GcpEnvironment.current;
870 const project = output.project || env.project;
871 const region = normalizeRegion(output.region, env.region);
872 const routerName = output.routerName;
873 if (!routerName) return;
874 yield* compute
875 .deleteRouters({
876 project,
877 region,
878 router: routerName,
879 })
880 .pipe(
881 Effect.retry({
882 while: (error) =>
883 error._tag === "Conflict" || error._tag === "BadRequest",
884 times: 15,
885 schedule: Schedule.spaced("3 seconds"),
886 }),
887 Effect.flatMap((operation) =>
888 waitRegionOperation(project, region, operation, {
889 ignore: ["RESOURCE_NOT_FOUND"],
890 }),
891 ),
892 Effect.catchTag("NotFound", () => Effect.void),
893 Effect.retry({
894 while: (error) =>
895 error._tag === "GCP.OperationFailed" &&
896 error.reason === "RESOURCE_IN_USE_BY_ANOTHER_RESOURCE",
897 times: 10,
898 schedule: Schedule.spaced("3 seconds"),
899 }),
900 );
901 yield* waitUntilRouterGone(project, region, routerName);
902 }),
903 });