| 1 | import * as compute from "@distilled.cloud/gcp/compute_v1"; |
| 2 | import { waitRegionOperation } from "./operations.ts"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { GcpEnvironment } from "../Environment.ts"; |
| 12 | import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | |
| 15 | const DEFAULT_ADVERTISE_MODE = "DEFAULT"; |
| 16 | const DEFAULT_KEEPALIVE = 20; |
| 17 | const MAX_NAME_LENGTH = 63; |
| 18 | |
| 19 | const OWNERSHIP_KEYS = [ |
| 20 | "alchemy-stack", |
| 21 | "alchemy-stage", |
| 22 | "alchemy-id", |
| 23 | ] as const; |
| 24 | |
| 25 | export 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 | |
| 32 | export 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 | |
| 65 | export 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 | |
| 81 | export 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 | |
| 91 | export 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 | |
| 133 | export 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 | |
| 180 | export 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 | */ |
| 289 | export const Router = Resource<Router>("GCP.Compute.Router"); |
| 290 | |
| 291 | export class RouterNotResolved extends Data.TaggedError( |
| 292 | "GCP.Compute.RouterNotResolved", |
| 293 | )<{ |
| 294 | routerName: string; |
| 295 | region: string; |
| 296 | }> {} |
| 297 | |
| 298 | export class RouterOperationPending extends Data.TaggedError( |
| 299 | "GCP.Compute.RouterOperationPending", |
| 300 | )<{ |
| 301 | operation: string; |
| 302 | status: string | undefined; |
| 303 | }> {} |
| 304 | |
| 305 | const lastSegment = (value: string) => { |
| 306 | const trimmed = value.replace(/\/+$/, ""); |
| 307 | const parts = trimmed.split("/"); |
| 308 | return parts[parts.length - 1] || trimmed; |
| 309 | }; |
| 310 | |
| 311 | const normalizeRegion = (region: string | undefined, defaultRegion: string) => |
| 312 | lastSegment(region ?? defaultRegion).toLowerCase(); |
| 313 | |
| 314 | const linkKey = (value: string | undefined) => |
| 315 | value === undefined || value === "" ? "" : lastSegment(value).toLowerCase(); |
| 316 | |
| 317 | const 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 | |
| 326 | const 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 | |
| 336 | const 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 | |
| 359 | const hasAlchemyMarker = (description: string | undefined) => |
| 360 | Object.keys(parseDescription(description).labels).some((key) => |
| 361 | key.startsWith("alchemy-"), |
| 362 | ); |
| 363 | |
| 364 | const 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 | |
| 382 | const groupsKey = (groups: ReadonlyArray<string> | undefined) => |
| 383 | [...(groups ?? [])] |
| 384 | .map((group) => group.toUpperCase()) |
| 385 | .sort() |
| 386 | .join("\0"); |
| 387 | |
| 388 | const 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 | |
| 400 | const 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 | |
| 412 | const 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 | |
| 432 | const 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 | |
| 452 | const 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 | |
| 480 | const 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 | |
| 493 | const 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 | |
| 525 | const 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 | |
| 554 | const 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. |
| 559 | const 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 | |
| 608 | const 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 | |
| 620 | const 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 | |
| 641 | const getByName = (project: string, region: string, router: string) => |
| 642 | compute |
| 643 | .getRouters({ project, region, router }) |
| 644 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 645 | |
| 646 | const 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 | |
| 660 | const 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 | |
| 683 | export 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 | }); |