1import * as networkconnectivity from "@distilled.cloud/gcp/networkconnectivity_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 { waitForOperation } from "./internal.ts";
22
23const DEFAULT_LOCATION = "global";
24const MAX_NAME_LENGTH = 63;
25
26export type SpokeState = networkconnectivity.SpokeStateEnum | (string & {});
27export type SpokeType = networkconnectivity.SpokeSpokeTypeEnum | (string & {});
28export type SpokeGatewayCapacity =
29 | networkconnectivity.GatewayCapacityEnum
30 | (string & {});
31
32export type SpokeStateReason = {
33 /** Reason code (`PENDING_REVIEW`, `REJECTED`, `FAILED`, …). */
34 code: string | undefined;
35 /** Human-readable details. */
36 message: string | undefined;
37 /** Extra text supplied when a hub admin rejected the spoke. */
38 userDetails: string | undefined;
39};
40
41export type LinkedVpcNetwork = {
42 /**
43 * URI of the VPC network
44 * (`projects/{project}/global/networks/{network}` or a Compute
45 * selfLink). Immutable — changing it replaces the spoke.
46 */
47 uri: string;
48 /**
49 * Subnet CIDRs excluded from export to the hub.
50 */
51 excludeExportRanges?: ReadonlyArray<string>;
52 /**
53 * Subnet CIDRs included when exporting to the hub.
54 */
55 includeExportRanges?: ReadonlyArray<string>;
56};
57
58export type LinkedVpnTunnels = {
59 /**
60 * URIs of HA VPN tunnels. Immutable — changing them replaces the
61 * spoke.
62 */
63 uris: ReadonlyArray<string>;
64 /**
65 * Enable site-to-site data transfer. Immutable — changing it replaces
66 * the spoke.
67 */
68 siteToSiteDataTransfer?: boolean;
69 /** Hub routes included when importing from the hub. */
70 includeImportRanges?: ReadonlyArray<string>;
71 /** Hub routes excluded when importing from the hub. */
72 excludeImportRanges?: ReadonlyArray<string>;
73 /** Dynamic routes included when exporting to the hub. */
74 includeExportRanges?: ReadonlyArray<string>;
75 /** Dynamic routes excluded when exporting to the hub. */
76 excludeExportRanges?: ReadonlyArray<string>;
77};
78
79export type LinkedInterconnectAttachments = {
80 /**
81 * URIs of VLAN attachments. Immutable — changing them replaces the
82 * spoke.
83 */
84 uris: ReadonlyArray<string>;
85 /**
86 * Enable site-to-site data transfer. Immutable — changing it replaces
87 * the spoke.
88 */
89 siteToSiteDataTransfer?: boolean;
90 /** Hub routes included when importing from the hub. */
91 includeImportRanges?: ReadonlyArray<string>;
92 /** Hub routes excluded when importing from the hub. */
93 excludeImportRanges?: ReadonlyArray<string>;
94 /** Dynamic routes included when exporting to the hub. */
95 includeExportRanges?: ReadonlyArray<string>;
96 /** Dynamic routes excluded when exporting to the hub. */
97 excludeExportRanges?: ReadonlyArray<string>;
98};
99
100export type RouterApplianceInstance = {
101 /** URI of the VM that speaks BGP. */
102 virtualMachine: string;
103 /** Internal IP on the VM used for peering. */
104 ipAddress: string;
105};
106
107export type LinkedRouterApplianceInstances = {
108 /**
109 * Router appliance VMs. Replacing the set of VMs updates in place;
110 * changing `siteToSiteDataTransfer` replaces the spoke.
111 */
112 instances: ReadonlyArray<RouterApplianceInstance>;
113 /**
114 * Enable site-to-site data transfer. Immutable — changing it replaces
115 * the spoke.
116 */
117 siteToSiteDataTransfer?: boolean;
118 /** Hub routes included when importing from the hub. */
119 includeImportRanges?: ReadonlyArray<string>;
120 /** Hub routes excluded when importing from the hub. */
121 excludeImportRanges?: ReadonlyArray<string>;
122 /** Dynamic routes included when exporting to the hub. */
123 includeExportRanges?: ReadonlyArray<string>;
124 /** Dynamic routes excluded when exporting to the hub. */
125 excludeExportRanges?: ReadonlyArray<string>;
126};
127
128export type LinkedProducerVpcNetwork = {
129 /**
130 * Service-consumer VPC the producer VPC is peered with. Immutable —
131 * changing it replaces the spoke.
132 */
133 network: string;
134 /**
135 * Name of the ACTIVE VPC peering to the producer (tenant) VPC.
136 * Immutable — changing it replaces the spoke.
137 */
138 peering: string;
139 /** Subnet CIDRs excluded from export to the hub. */
140 excludeExportRanges?: ReadonlyArray<string>;
141 /** Subnet CIDRs included when exporting to the hub. */
142 includeExportRanges?: ReadonlyArray<string>;
143};
144
145export type SpokeGatewayIpRangeReservation = {
146 /**
147 * `/23` CIDR reserved for gateway infrastructure (e.g. `10.1.2.0/23`).
148 * Immutable — changing it replaces the spoke.
149 */
150 ipRange: string;
151};
152
153export type SpokeGateway = {
154 /**
155 * Aggregate processing capacity. Immutable — changing it replaces the
156 * spoke.
157 */
158 capacity?: SpokeGatewayCapacity;
159 /**
160 * CIDRs reserved for gateway infrastructure. Immutable — changing
161 * them replaces the spoke.
162 */
163 ipRangeReservations?: ReadonlyArray<SpokeGatewayIpRangeReservation>;
164};
165
166export type SpokeProps = {
167 /**
168 * Spoke id (the `{spoke}` segment of
169 * `projects/{project}/locations/{location}/spokes/{spoke}`). If
170 * omitted, a unique RFC1035 name is generated from the stack, stage,
171 * and logical id. Must be 1-63 characters and match
172 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable — changing it replaces the
173 * spoke.
174 */
175 spokeId?: string;
176 /**
177 * Location of the spoke. VPC and producer-VPC spokes must use
178 * `global`; VPN, VLAN-attachment, router-appliance, and gateway
179 * spokes use a region (`us-central1`, …). `US-CENTRAL1` is accepted
180 * and normalized to `us-central1`. Immutable — changing it replaces
181 * the spoke.
182 * @default "global"
183 */
184 location?: string;
185 /**
186 * Hub this spoke attaches to — a full resource name
187 * `projects/{project}/locations/global/hubs/{hub}` or a hub id.
188 * Immutable — changing it replaces the spoke.
189 */
190 hub: string;
191 /**
192 * Group this spoke belongs to (star / hybrid-inspection topologies).
193 * Immutable — changing it replaces the spoke.
194 */
195 group?: string;
196 /**
197 * Human-readable description of the spoke.
198 */
199 description?: string;
200 /**
201 * User labels. Alchemy ownership labels are merged in automatically.
202 */
203 labels?: Record<string, string>;
204 /**
205 * VPC network associated with the spoke. Exactly one of the `linked*`
206 * / `gateway` fields must be set.
207 */
208 linkedVpcNetwork?: LinkedVpcNetwork;
209 /**
210 * HA VPN tunnels associated with the spoke.
211 */
212 linkedVpnTunnels?: LinkedVpnTunnels;
213 /**
214 * VLAN attachments associated with the spoke.
215 */
216 linkedInterconnectAttachments?: LinkedInterconnectAttachments;
217 /**
218 * Router appliance VMs associated with the spoke.
219 */
220 linkedRouterApplianceInstances?: LinkedRouterApplianceInstances;
221 /**
222 * Producer VPC peered to a service-consumer VPC spoke.
223 */
224 linkedProducerVpcNetwork?: LinkedProducerVpcNetwork;
225 /**
226 * NCC gateway that inspects traffic.
227 */
228 gateway?: SpokeGateway;
229};
230
231export type Spoke = Resource<
232 "GCP.NetworkConnectivity.Spoke",
233 SpokeProps,
234 {
235 /** Full resource name `projects/{project}/locations/{location}/spokes/{spoke}`. */
236 name: string;
237 /** Spoke id (last path segment). */
238 spokeId: string;
239 /** Project id. */
240 project: string;
241 /** Location id (`global`, `us-central1`, …). */
242 location: string;
243 /** Hub resource name this spoke is attached to. */
244 hub: string | undefined;
245 /** Group resource name, if any. */
246 group: string | undefined;
247 /** User-provided description. */
248 description: string | undefined;
249 /** User labels (Alchemy ownership labels stripped). */
250 labels: Record<string, string>;
251 /** Linked VPC network, if this is a VPC spoke. */
252 linkedVpcNetwork: LinkedVpcNetwork | undefined;
253 /** Linked VPN tunnels, if this is a VPN spoke. */
254 linkedVpnTunnels: LinkedVpnTunnels | undefined;
255 /** Linked VLAN attachments, if this is an interconnect spoke. */
256 linkedInterconnectAttachments: LinkedInterconnectAttachments | undefined;
257 /** Linked router appliances, if this is a router-appliance spoke. */
258 linkedRouterApplianceInstances: LinkedRouterApplianceInstances | undefined;
259 /** Linked producer VPC, if this is a producer-VPC spoke. */
260 linkedProducerVpcNetwork: LinkedProducerVpcNetwork | undefined;
261 /** Gateway config, if this is a gateway spoke. */
262 gateway: SpokeGateway | undefined;
263 /** Server-reported spoke type (`VPC_NETWORK`, `VPN_TUNNEL`, …). */
264 spokeType: string | undefined;
265 /** Server-generated UUID, unique across spokes. */
266 uniqueId: string | undefined;
267 /** Server-reported lifecycle state (`ACTIVE`, `CREATING`, …). */
268 state: string | undefined;
269 /** Reasons the spoke is not `ACTIVE`, if any. */
270 reasons: ReadonlyArray<SpokeStateReason>;
271 /** RFC3339 creation timestamp. */
272 createTime: string | undefined;
273 /** RFC3339 last-update timestamp. */
274 updateTime: string | undefined;
275 },
276 never,
277 Providers
278>;
279
280/**
281 * A Network Connectivity Center spoke — one VPC, VPN tunnel set, VLAN
282 * attachment set, router-appliance set, producer VPC, or gateway
283 * attached to a hub.
284 *
285 * Set exactly one of `linkedVpcNetwork`, `linkedVpnTunnels`,
286 * `linkedInterconnectAttachments`, `linkedRouterApplianceInstances`,
287 * `linkedProducerVpcNetwork`, or `gateway`. VPC spokes live at
288 * `location: "global"`; hybrid spokes are regional.
289 *
290 * Changing `spokeId`, `location`, `hub`, `group`, or the linked
291 * resource identity replaces the spoke. Description, labels, and
292 * include/exclude CIDR ranges update in place.
293 *
294 * ### Creating a Spoke
295 * **Example:** VPC spoke on a generated name
296 * ```typescript
297 * const network = yield* GCP.Compute.Network("AppVpc", {
298 * autoCreateSubnetworks: false,
299 * });
300 * const hub = yield* GCP.NetworkConnectivity.Hub("Mesh", {});
301 * const spoke = yield* GCP.NetworkConnectivity.Spoke("AppVpcSpoke", {
302 * hub: hub.name,
303 * linkedVpcNetwork: { uri: network.selfLink! },
304 * });
305 * ```
306 *
307 * **Example:** Named VPC spoke with labels and export filters
308 * ```typescript
309 * const spoke = yield* GCP.NetworkConnectivity.Spoke("AppVpcSpoke", {
310 * spokeId: "app-vpc",
311 * location: "global",
312 * hub: hub.name,
313 * description: "app vpc",
314 * labels: { env: "prod" },
315 * linkedVpcNetwork: {
316 * uri: network.selfLink!,
317 * includeExportRanges: ["10.0.0.0/8"],
318 * },
319 * });
320 * ```
321 *
322 * ### Updating a Spoke
323 * **Example:** Description, labels, and export filters
324 * ```typescript
325 * const spoke = yield* GCP.NetworkConnectivity.Spoke("AppVpcSpoke", {
326 * spokeId: "app-vpc",
327 * location: "global",
328 * hub: hub.name,
329 * description: "app vpc v2",
330 * labels: { env: "prod", role: "spoke" },
331 * linkedVpcNetwork: {
332 * uri: network.selfLink!,
333 * includeExportRanges: ["10.0.0.0/8", "192.168.0.0/16"],
334 * },
335 * });
336 * ```
337 *
338 * @resource
339 * @category NetworkConnectivity
340 */
341export const Spoke = Resource<Spoke>("GCP.NetworkConnectivity.Spoke");
342
343export class SpokeNotResolved extends Data.TaggedError(
344 "GCP.NetworkConnectivity.SpokeNotResolved",
345)<{
346 name: string;
347}> {}
348
349export class SpokeFailed extends Data.TaggedError(
350 "GCP.NetworkConnectivity.SpokeFailed",
351)<{
352 name: string;
353 state: string | undefined;
354}> {}
355
356export class SpokeStillExists extends Data.TaggedError(
357 "GCP.NetworkConnectivity.SpokeStillExists",
358)<{
359 name: string;
360}> {}
361
362const lastSegment = (value: string) => {
363 const trimmed = value.replace(/\/+$/, "");
364 const parts = trimmed.split("/");
365 return parts[parts.length - 1] || trimmed;
366};
367
368const rfc1035 = (name: string): string => {
369 let next = name
370 .toLowerCase()
371 .replace(/[^a-z0-9-]/g, "-")
372 .replace(/-+/g, "-")
373 .replace(/^-+/, "")
374 .replace(/-+$/, "");
375 if (!/^[a-z]/.test(next)) {
376 next = `s${next}`;
377 }
378 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
379 return next.length > 0 ? next : "spoke";
380};
381
382const normalizeLocation = (location: string | undefined) =>
383 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
384
385const resourceName = (project: string, location: string, spokeId: string) =>
386 `projects/${project}/locations/${location}/spokes/${spokeId}`;
387
388const parentOf = (project: string, location: string) =>
389 `projects/${project}/locations/${location}`;
390
391const parseName = (name: string) => {
392 const parts = name.split("/").filter((part) => part.length > 0);
393 const spokesAt = parts.lastIndexOf("spokes");
394 const locationsAt = parts.lastIndexOf("locations");
395 const projectsAt = parts.lastIndexOf("projects");
396 return {
397 project:
398 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
399 location:
400 locationsAt >= 0 && parts[locationsAt + 1]
401 ? parts[locationsAt + 1]!
402 : DEFAULT_LOCATION,
403 spokeId:
404 spokesAt >= 0 && parts[spokesAt + 1]
405 ? parts[spokesAt + 1]!
406 : lastSegment(name),
407 };
408};
409
410const userLabels = (
411 labels: Record<string, string | undefined> | null | undefined,
412): Record<string, string> => stripInternalLabels(tagRecord(labels));
413
414const toId = (id: string, spokeId: string | undefined, existing?: string) =>
415 Effect.gen(function* () {
416 if (spokeId !== undefined) return spokeId;
417 if (existing !== undefined) return existing;
418 return rfc1035(
419 yield* createPhysicalName({
420 id,
421 maxLength: MAX_NAME_LENGTH,
422 lowercase: true,
423 }),
424 );
425 });
426
427const resolveHub = (hub: string, project: string) =>
428 hub.includes("/")
429 ? hub
430 : `projects/${project}/locations/${DEFAULT_LOCATION}/hubs/${hub}`;
431
432const rangesKey = (ranges: ReadonlyArray<string> | undefined) =>
433 JSON.stringify([...(ranges ?? [])].slice().sort());
434
435const resourceKey = (uri: string | undefined) => {
436 if (uri === undefined || uri.length === 0) return "";
437 return lastSegment(uri);
438};
439
440const resourceKeys = (uris: ReadonlyArray<string> | undefined) =>
441 JSON.stringify([...(uris ?? [])].map(resourceKey).sort());
442
443const instancesKey = (
444 instances: ReadonlyArray<RouterApplianceInstance> | undefined,
445) =>
446 JSON.stringify(
447 [...(instances ?? [])]
448 .map((instance) => ({
449 vm: resourceKey(instance.virtualMachine),
450 ip: instance.ipAddress,
451 }))
452 .sort((left, right) =>
453 left.vm === right.vm
454 ? left.ip.localeCompare(right.ip)
455 : left.vm.localeCompare(right.vm),
456 ),
457 );
458
459const gatewayKey = (gateway: SpokeGateway | undefined) =>
460 JSON.stringify({
461 capacity: gateway?.capacity ?? "",
462 reservations: [...(gateway?.ipRangeReservations ?? [])]
463 .map((reservation) => reservation.ipRange)
464 .sort(),
465 });
466
467const linkedIdentity = (props: {
468 linkedVpcNetwork?: LinkedVpcNetwork;
469 linkedVpnTunnels?: LinkedVpnTunnels;
470 linkedInterconnectAttachments?: LinkedInterconnectAttachments;
471 linkedRouterApplianceInstances?: LinkedRouterApplianceInstances;
472 linkedProducerVpcNetwork?: LinkedProducerVpcNetwork;
473 gateway?: SpokeGateway;
474}) => {
475 if (props.linkedVpcNetwork) {
476 return `vpc:${resourceKey(props.linkedVpcNetwork.uri)}`;
477 }
478 if (props.linkedVpnTunnels) {
479 return `vpn:${resourceKeys(props.linkedVpnTunnels.uris)}:${
480 props.linkedVpnTunnels.siteToSiteDataTransfer === true
481 }`;
482 }
483 if (props.linkedInterconnectAttachments) {
484 return `interconnect:${resourceKeys(props.linkedInterconnectAttachments.uris)}:${
485 props.linkedInterconnectAttachments.siteToSiteDataTransfer === true
486 }`;
487 }
488 if (props.linkedRouterApplianceInstances) {
489 return `appliance:${
490 props.linkedRouterApplianceInstances.siteToSiteDataTransfer === true
491 }`;
492 }
493 if (props.linkedProducerVpcNetwork) {
494 return `producer:${resourceKey(props.linkedProducerVpcNetwork.network)}:${
495 props.linkedProducerVpcNetwork.peering
496 }`;
497 }
498 if (props.gateway) {
499 return `gateway:${gatewayKey(props.gateway)}`;
500 }
501 return "";
502};
503
504const toReasons = (
505 reasons: networkconnectivity.StateReasonList | undefined,
506): SpokeStateReason[] =>
507 (reasons ?? []).map((reason) => ({
508 code: reason.code,
509 message: reason.message,
510 userDetails: reason.userDetails,
511 }));
512
513const toLinkedVpc = (
514 value: networkconnectivity.LinkedVpcNetwork | undefined,
515): LinkedVpcNetwork | undefined => {
516 if (value?.uri === undefined || value.uri.length === 0) return undefined;
517 return {
518 uri: value.uri,
519 excludeExportRanges: value.excludeExportRanges,
520 includeExportRanges: value.includeExportRanges,
521 };
522};
523
524const toLinkedVpn = (
525 value: networkconnectivity.LinkedVpnTunnels | undefined,
526): LinkedVpnTunnels | undefined => {
527 if (value === undefined) return undefined;
528 const uris = value.uris ?? [];
529 if (uris.length === 0 && value.vpcNetwork === undefined) return undefined;
530 return {
531 uris,
532 siteToSiteDataTransfer: value.siteToSiteDataTransfer,
533 includeImportRanges: value.includeImportRanges,
534 excludeImportRanges: value.excludeImportRanges,
535 includeExportRanges: value.includeExportRanges,
536 excludeExportRanges: value.excludeExportRanges,
537 };
538};
539
540const toLinkedInterconnect = (
541 value: networkconnectivity.LinkedInterconnectAttachments | undefined,
542): LinkedInterconnectAttachments | undefined => {
543 if (value === undefined) return undefined;
544 const uris = value.uris ?? [];
545 if (uris.length === 0 && value.vpcNetwork === undefined) return undefined;
546 return {
547 uris,
548 siteToSiteDataTransfer: value.siteToSiteDataTransfer,
549 includeImportRanges: value.includeImportRanges,
550 excludeImportRanges: value.excludeImportRanges,
551 includeExportRanges: value.includeExportRanges,
552 excludeExportRanges: value.excludeExportRanges,
553 };
554};
555
556const toLinkedAppliances = (
557 value: networkconnectivity.LinkedRouterApplianceInstances | undefined,
558): LinkedRouterApplianceInstances | undefined => {
559 if (value === undefined) return undefined;
560 const instances = (value.instances ?? [])
561 .filter(
562 (
563 instance,
564 ): instance is networkconnectivity.RouterApplianceInstance & {
565 virtualMachine: string;
566 ipAddress: string;
567 } =>
568 typeof instance.virtualMachine === "string" &&
569 typeof instance.ipAddress === "string",
570 )
571 .map((instance) => ({
572 virtualMachine: instance.virtualMachine,
573 ipAddress: instance.ipAddress,
574 }));
575 if (instances.length === 0 && value.vpcNetwork === undefined) {
576 return undefined;
577 }
578 return {
579 instances,
580 siteToSiteDataTransfer: value.siteToSiteDataTransfer,
581 includeImportRanges: value.includeImportRanges,
582 excludeImportRanges: value.excludeImportRanges,
583 includeExportRanges: value.includeExportRanges,
584 excludeExportRanges: value.excludeExportRanges,
585 };
586};
587
588const toLinkedProducer = (
589 value: networkconnectivity.LinkedProducerVpcNetwork | undefined,
590): LinkedProducerVpcNetwork | undefined => {
591 if (value?.network === undefined || value.peering === undefined) {
592 return undefined;
593 }
594 return {
595 network: value.network,
596 peering: value.peering,
597 excludeExportRanges: value.excludeExportRanges,
598 includeExportRanges: value.includeExportRanges,
599 };
600};
601
602const toGateway = (
603 value: networkconnectivity.Gateway | undefined,
604): SpokeGateway | undefined => {
605 if (value === undefined) return undefined;
606 const ipRangeReservations = (value.ipRangeReservations ?? [])
607 .filter(
608 (
609 reservation,
610 ): reservation is networkconnectivity.IpRangeReservation & {
611 ipRange: string;
612 } => typeof reservation.ipRange === "string",
613 )
614 .map((reservation) => ({ ipRange: reservation.ipRange }));
615 if (
616 value.capacity === undefined &&
617 ipRangeReservations.length === 0 &&
618 (value.cloudRouters ?? []).length === 0
619 ) {
620 return undefined;
621 }
622 return {
623 capacity: value.capacity,
624 ipRangeReservations:
625 ipRangeReservations.length > 0 ? ipRangeReservations : undefined,
626 };
627};
628
629const toAttrs = (spoke: networkconnectivity.Spoke, project: string) => {
630 const name = spoke.name ?? "";
631 const parsed = parseName(name);
632 return {
633 name,
634 spokeId: parsed.spokeId,
635 project: parsed.project || project,
636 location: parsed.location || DEFAULT_LOCATION,
637 hub: spoke.hub,
638 group: spoke.group,
639 description: spoke.description,
640 labels: userLabels(spoke.labels),
641 linkedVpcNetwork: toLinkedVpc(spoke.linkedVpcNetwork),
642 linkedVpnTunnels: toLinkedVpn(spoke.linkedVpnTunnels),
643 linkedInterconnectAttachments: toLinkedInterconnect(
644 spoke.linkedInterconnectAttachments,
645 ),
646 linkedRouterApplianceInstances: toLinkedAppliances(
647 spoke.linkedRouterApplianceInstances,
648 ),
649 linkedProducerVpcNetwork: toLinkedProducer(spoke.linkedProducerVpcNetwork),
650 gateway: toGateway(spoke.gateway),
651 spokeType: spoke.spokeType,
652 uniqueId: spoke.uniqueId,
653 state: spoke.state,
654 reasons: toReasons(spoke.reasons),
655 createTime: spoke.createTime,
656 updateTime: spoke.updateTime,
657 };
658};
659
660const fromLinkedVpc = (
661 value: LinkedVpcNetwork,
662): networkconnectivity.LinkedVpcNetwork => ({
663 uri: value.uri,
664 excludeExportRanges: value.excludeExportRanges
665 ? [...value.excludeExportRanges]
666 : undefined,
667 includeExportRanges: value.includeExportRanges
668 ? [...value.includeExportRanges]
669 : undefined,
670});
671
672const fromLinkedVpn = (
673 value: LinkedVpnTunnels,
674): networkconnectivity.LinkedVpnTunnels => ({
675 uris: [...value.uris],
676 siteToSiteDataTransfer: value.siteToSiteDataTransfer,
677 includeImportRanges: value.includeImportRanges
678 ? [...value.includeImportRanges]
679 : undefined,
680 excludeImportRanges: value.excludeImportRanges
681 ? [...value.excludeImportRanges]
682 : undefined,
683 includeExportRanges: value.includeExportRanges
684 ? [...value.includeExportRanges]
685 : undefined,
686 excludeExportRanges: value.excludeExportRanges
687 ? [...value.excludeExportRanges]
688 : undefined,
689});
690
691const fromLinkedInterconnect = (
692 value: LinkedInterconnectAttachments,
693): networkconnectivity.LinkedInterconnectAttachments => ({
694 uris: [...value.uris],
695 siteToSiteDataTransfer: value.siteToSiteDataTransfer,
696 includeImportRanges: value.includeImportRanges
697 ? [...value.includeImportRanges]
698 : undefined,
699 excludeImportRanges: value.excludeImportRanges
700 ? [...value.excludeImportRanges]
701 : undefined,
702 includeExportRanges: value.includeExportRanges
703 ? [...value.includeExportRanges]
704 : undefined,
705 excludeExportRanges: value.excludeExportRanges
706 ? [...value.excludeExportRanges]
707 : undefined,
708});
709
710const fromLinkedAppliances = (
711 value: LinkedRouterApplianceInstances,
712): networkconnectivity.LinkedRouterApplianceInstances => ({
713 instances: value.instances.map((instance) => ({
714 virtualMachine: instance.virtualMachine,
715 ipAddress: instance.ipAddress,
716 })),
717 siteToSiteDataTransfer: value.siteToSiteDataTransfer,
718 includeImportRanges: value.includeImportRanges
719 ? [...value.includeImportRanges]
720 : undefined,
721 excludeImportRanges: value.excludeImportRanges
722 ? [...value.excludeImportRanges]
723 : undefined,
724 includeExportRanges: value.includeExportRanges
725 ? [...value.includeExportRanges]
726 : undefined,
727 excludeExportRanges: value.excludeExportRanges
728 ? [...value.excludeExportRanges]
729 : undefined,
730});
731
732const fromLinkedProducer = (
733 value: LinkedProducerVpcNetwork,
734): networkconnectivity.LinkedProducerVpcNetwork => ({
735 network: value.network,
736 peering: value.peering,
737 excludeExportRanges: value.excludeExportRanges
738 ? [...value.excludeExportRanges]
739 : undefined,
740 includeExportRanges: value.includeExportRanges
741 ? [...value.includeExportRanges]
742 : undefined,
743});
744
745const fromGateway = (value: SpokeGateway): networkconnectivity.Gateway => ({
746 capacity: value.capacity,
747 ipRangeReservations: value.ipRangeReservations?.map((reservation) => ({
748 ipRange: reservation.ipRange,
749 })),
750});
751
752const desiredSpokeBody = (
753 news: SpokeProps,
754 project: string,
755 labels: Record<string, string>,
756): networkconnectivity.Spoke => ({
757 hub: resolveHub(news.hub, project),
758 group: news.group,
759 description: news.description,
760 labels,
761 linkedVpcNetwork: news.linkedVpcNetwork
762 ? fromLinkedVpc(news.linkedVpcNetwork)
763 : undefined,
764 linkedVpnTunnels: news.linkedVpnTunnels
765 ? fromLinkedVpn(news.linkedVpnTunnels)
766 : undefined,
767 linkedInterconnectAttachments: news.linkedInterconnectAttachments
768 ? fromLinkedInterconnect(news.linkedInterconnectAttachments)
769 : undefined,
770 linkedRouterApplianceInstances: news.linkedRouterApplianceInstances
771 ? fromLinkedAppliances(news.linkedRouterApplianceInstances)
772 : undefined,
773 linkedProducerVpcNetwork: news.linkedProducerVpcNetwork
774 ? fromLinkedProducer(news.linkedProducerVpcNetwork)
775 : undefined,
776 gateway: news.gateway ? fromGateway(news.gateway) : undefined,
777});
778
779const getByName = (name: string) =>
780 networkconnectivity
781 .getProjectsLocationsSpokes({ name })
782 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
783
784const isPendingState = (state: string | undefined) =>
785 state === "CREATING" ||
786 state === "UPDATING" ||
787 state === "DELETING" ||
788 state === "ACCEPTING" ||
789 state === "REJECTING" ||
790 state === "INACTIVE" ||
791 state === "STATE_UNSPECIFIED";
792
793const isFailedState = (spoke: networkconnectivity.Spoke) =>
794 spoke.state === "FAILED" ||
795 spoke.state === "OBSOLETE" ||
796 (spoke.reasons ?? []).some((reason) => reason.code === "REJECTED");
797
798const waitUntilReady = (name: string) =>
799 getByName(name).pipe(
800 Effect.filterOrFail(
801 (spoke): spoke is networkconnectivity.Spoke => spoke !== undefined,
802 () => new SpokeNotResolved({ name }),
803 ),
804 Effect.filterOrFail(
805 (spoke) => !isFailedState(spoke),
806 (spoke) => new SpokeFailed({ name, state: spoke.state }),
807 ),
808 Effect.filterOrFail(
809 (spoke) => !isPendingState(spoke.state),
810 () => new SpokeNotResolved({ name }),
811 ),
812 Effect.retry({
813 while: (error) =>
814 error._tag === "GCP.NetworkConnectivity.SpokeNotResolved",
815 times: 120,
816 schedule: Schedule.spaced("10 seconds"),
817 }),
818 );
819
820const waitUntilGone = (name: string) =>
821 getByName(name).pipe(
822 Effect.flatMap((spoke) =>
823 spoke === undefined
824 ? Effect.void
825 : Effect.fail(new SpokeStillExists({ name })),
826 ),
827 Effect.retry({
828 while: (error) =>
829 error._tag === "GCP.NetworkConnectivity.SpokeStillExists",
830 times: 60,
831 schedule: Schedule.spaced("10 seconds"),
832 }),
833 );
834
835const waitUntilHubDropsSpoke = (hub: string, spokeName: string) =>
836 networkconnectivity
837 .listSpokesProjectsLocationsGlobalHubs({
838 name: hub,
839 pageSize: 100,
840 })
841 .pipe(
842 Effect.flatMap((page) => {
843 const still = (page.spokes ?? []).some(
844 (spoke) =>
845 spoke.name === spokeName ||
846 resourceKey(spoke.name) === resourceKey(spokeName),
847 );
848 return still
849 ? Effect.fail(new SpokeStillExists({ name: spokeName }))
850 : Effect.void;
851 }),
852 Effect.retry({
853 while: (error) =>
854 error._tag === "GCP.NetworkConnectivity.SpokeStillExists",
855 times: 8,
856 schedule: Schedule.spaced("3 seconds"),
857 }),
858 Effect.catchTag(
859 "GCP.NetworkConnectivity.SpokeStillExists",
860 () => Effect.void,
861 ),
862 Effect.catchTag("NotFound", () => Effect.void),
863 );
864
865const waitUntilHubDropsVpc = (hub: string, networkKey: string) => {
866 if (networkKey.length === 0) return Effect.void;
867 return networkconnectivity.getProjectsLocationsGlobalHubs({ name: hub }).pipe(
868 Effect.flatMap((hubResource) => {
869 const still = (hubResource.routingVpcs ?? []).some(
870 (vpc) => resourceKey(vpc.uri) === networkKey,
871 );
872 return still
873 ? Effect.fail(new SpokeStillExists({ name: hub }))
874 : Effect.void;
875 }),
876 Effect.retry({
877 while: (error) =>
878 error._tag === "GCP.NetworkConnectivity.SpokeStillExists",
879 times: 8,
880 schedule: Schedule.spaced("3 seconds"),
881 }),
882 Effect.catchTag(
883 "GCP.NetworkConnectivity.SpokeStillExists",
884 () => Effect.void,
885 ),
886 Effect.catchTag("NotFound", () => Effect.void),
887 );
888};
889
890const listOwnedSpokes = (project: string, location: string) =>
891 networkconnectivity.listProjectsLocationsSpokes
892 .pages({
893 parent: parentOf(project, location),
894 pageSize: 1000,
895 })
896 .pipe(
897 Stream.flatMap((page) => Stream.fromIterable(page.spokes ?? [])),
898 Stream.filter((spoke) =>
899 Object.keys(spoke.labels ?? {}).some((key) =>
900 key.startsWith("alchemy-"),
901 ),
902 ),
903 Stream.map((spoke) => toAttrs(spoke, project)),
904 Stream.runCollect,
905 Effect.map((chunk) => Array.from(chunk)),
906 Effect.catchTag("NotFound", () => Effect.succeed([])),
907 );
908
909const rangeFieldsChanged = (
910 current: ReturnType<typeof toAttrs>,
911 news: SpokeProps,
912) => {
913 const masks: string[] = [];
914 if (news.linkedVpcNetwork) {
915 const observed = current.linkedVpcNetwork;
916 if (
917 rangesKey(observed?.includeExportRanges) !==
918 rangesKey(news.linkedVpcNetwork.includeExportRanges)
919 ) {
920 masks.push("linkedVpcNetwork.includeExportRanges");
921 }
922 if (
923 rangesKey(observed?.excludeExportRanges) !==
924 rangesKey(news.linkedVpcNetwork.excludeExportRanges)
925 ) {
926 masks.push("linkedVpcNetwork.excludeExportRanges");
927 }
928 }
929 if (news.linkedVpnTunnels) {
930 const observed = current.linkedVpnTunnels;
931 if (
932 rangesKey(observed?.includeImportRanges) !==
933 rangesKey(news.linkedVpnTunnels.includeImportRanges)
934 ) {
935 masks.push("linkedVpnTunnels.includeImportRanges");
936 }
937 if (
938 rangesKey(observed?.excludeImportRanges) !==
939 rangesKey(news.linkedVpnTunnels.excludeImportRanges)
940 ) {
941 masks.push("linkedVpnTunnels.excludeImportRanges");
942 }
943 if (
944 rangesKey(observed?.includeExportRanges) !==
945 rangesKey(news.linkedVpnTunnels.includeExportRanges)
946 ) {
947 masks.push("linkedVpnTunnels.includeExportRanges");
948 }
949 if (
950 rangesKey(observed?.excludeExportRanges) !==
951 rangesKey(news.linkedVpnTunnels.excludeExportRanges)
952 ) {
953 masks.push("linkedVpnTunnels.excludeExportRanges");
954 }
955 }
956 if (news.linkedInterconnectAttachments) {
957 const observed = current.linkedInterconnectAttachments;
958 if (
959 rangesKey(observed?.includeImportRanges) !==
960 rangesKey(news.linkedInterconnectAttachments.includeImportRanges)
961 ) {
962 masks.push("linkedInterconnectAttachments.includeImportRanges");
963 }
964 if (
965 rangesKey(observed?.excludeImportRanges) !==
966 rangesKey(news.linkedInterconnectAttachments.excludeImportRanges)
967 ) {
968 masks.push("linkedInterconnectAttachments.excludeImportRanges");
969 }
970 if (
971 rangesKey(observed?.includeExportRanges) !==
972 rangesKey(news.linkedInterconnectAttachments.includeExportRanges)
973 ) {
974 masks.push("linkedInterconnectAttachments.includeExportRanges");
975 }
976 if (
977 rangesKey(observed?.excludeExportRanges) !==
978 rangesKey(news.linkedInterconnectAttachments.excludeExportRanges)
979 ) {
980 masks.push("linkedInterconnectAttachments.excludeExportRanges");
981 }
982 }
983 if (news.linkedRouterApplianceInstances) {
984 const observed = current.linkedRouterApplianceInstances;
985 if (
986 instancesKey(observed?.instances) !==
987 instancesKey(news.linkedRouterApplianceInstances.instances)
988 ) {
989 masks.push("linkedRouterApplianceInstances.instances");
990 }
991 if (
992 rangesKey(observed?.includeImportRanges) !==
993 rangesKey(news.linkedRouterApplianceInstances.includeImportRanges)
994 ) {
995 masks.push("linkedRouterApplianceInstances.includeImportRanges");
996 }
997 if (
998 rangesKey(observed?.excludeImportRanges) !==
999 rangesKey(news.linkedRouterApplianceInstances.excludeImportRanges)
1000 ) {
1001 masks.push("linkedRouterApplianceInstances.excludeImportRanges");
1002 }
1003 if (
1004 rangesKey(observed?.includeExportRanges) !==
1005 rangesKey(news.linkedRouterApplianceInstances.includeExportRanges)
1006 ) {
1007 masks.push("linkedRouterApplianceInstances.includeExportRanges");
1008 }
1009 if (
1010 rangesKey(observed?.excludeExportRanges) !==
1011 rangesKey(news.linkedRouterApplianceInstances.excludeExportRanges)
1012 ) {
1013 masks.push("linkedRouterApplianceInstances.excludeExportRanges");
1014 }
1015 }
1016 if (news.linkedProducerVpcNetwork) {
1017 const observed = current.linkedProducerVpcNetwork;
1018 if (
1019 rangesKey(observed?.includeExportRanges) !==
1020 rangesKey(news.linkedProducerVpcNetwork.includeExportRanges)
1021 ) {
1022 masks.push("linkedProducerVpcNetwork.includeExportRanges");
1023 }
1024 if (
1025 rangesKey(observed?.excludeExportRanges) !==
1026 rangesKey(news.linkedProducerVpcNetwork.excludeExportRanges)
1027 ) {
1028 masks.push("linkedProducerVpcNetwork.excludeExportRanges");
1029 }
1030 }
1031 return masks;
1032};
1033
1034export const SpokeProvider = () =>
1035 Provider.succeed(Spoke, {
1036 stables: [
1037 "name",
1038 "spokeId",
1039 "project",
1040 "location",
1041 "hub",
1042 "uniqueId",
1043 "createTime",
1044 ],
1045
1046 diff: Effect.fn(function* ({ news, olds, output }) {
1047 if (!isResolved(news)) return undefined;
1048 const previousId = olds?.spokeId ?? output?.spokeId;
1049 const nextId = news.spokeId ?? previousId;
1050 const idChanged =
1051 previousId !== undefined &&
1052 nextId !== undefined &&
1053 nextId !== previousId;
1054
1055 const previousLocation = normalizeLocation(
1056 olds?.location ?? output?.location,
1057 );
1058 const nextLocation = normalizeLocation(news.location ?? output?.location);
1059 const locationChanged = previousLocation !== nextLocation;
1060
1061 const previousHub = resourceKey(olds?.hub ?? output?.hub);
1062 const nextHub = resourceKey(news.hub);
1063 const hubChanged =
1064 previousHub.length > 0 && nextHub.length > 0 && previousHub !== nextHub;
1065
1066 const previousGroup = resourceKey(olds?.group ?? output?.group);
1067 const nextGroup = resourceKey(news.group);
1068 const groupChanged =
1069 news.group !== undefined &&
1070 previousGroup.length > 0 &&
1071 nextGroup.length > 0 &&
1072 previousGroup !== nextGroup;
1073
1074 const previousLinked = linkedIdentity({
1075 linkedVpcNetwork: olds?.linkedVpcNetwork ?? output?.linkedVpcNetwork,
1076 linkedVpnTunnels: olds?.linkedVpnTunnels ?? output?.linkedVpnTunnels,
1077 linkedInterconnectAttachments:
1078 olds?.linkedInterconnectAttachments ??
1079 output?.linkedInterconnectAttachments,
1080 linkedRouterApplianceInstances:
1081 olds?.linkedRouterApplianceInstances ??
1082 output?.linkedRouterApplianceInstances,
1083 linkedProducerVpcNetwork:
1084 olds?.linkedProducerVpcNetwork ?? output?.linkedProducerVpcNetwork,
1085 gateway: olds?.gateway ?? output?.gateway,
1086 });
1087 const nextLinked = linkedIdentity(news);
1088 const linkedChanged =
1089 nextLinked.length > 0 &&
1090 previousLinked.length > 0 &&
1091 nextLinked !== previousLinked;
1092
1093 if (
1094 !idChanged &&
1095 !locationChanged &&
1096 !hubChanged &&
1097 !groupChanged &&
1098 !linkedChanged
1099 ) {
1100 return undefined;
1101 }
1102 return {
1103 action: "replace" as const,
1104 deleteFirst:
1105 !idChanged &&
1106 !locationChanged &&
1107 (hubChanged || groupChanged || linkedChanged),
1108 };
1109 }),
1110
1111 read: Effect.fn(function* ({ id, olds, output }) {
1112 const env = yield* GcpEnvironment.current;
1113 const spokeId = yield* toId(id, olds?.spokeId, output?.spokeId);
1114 const location = normalizeLocation(olds?.location ?? output?.location);
1115 const name = output?.name ?? resourceName(env.project, location, spokeId);
1116 const existing = yield* getByName(name);
1117 if (existing === undefined) return undefined;
1118 const attrs = toAttrs(existing, env.project);
1119 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
1120 ? attrs
1121 : Unowned(attrs);
1122 }),
1123
1124 list: () =>
1125 Effect.gen(function* () {
1126 const env = yield* GcpEnvironment.current;
1127 const aggregated = yield* listOwnedSpokes(env.project, "-");
1128 if (aggregated.length > 0) return aggregated;
1129 return yield* listOwnedSpokes(env.project, DEFAULT_LOCATION);
1130 }),
1131
1132 reconcile: Effect.fn(function* ({ id, news, output }) {
1133 const env = yield* GcpEnvironment.current;
1134 const spokeId = yield* toId(id, news.spokeId, output?.spokeId);
1135 const location = normalizeLocation(news.location ?? output?.location);
1136 const name = output?.name ?? resourceName(env.project, location, spokeId);
1137 const desiredLabels = {
1138 ...toLabels(news.labels),
1139 ...(yield* createInternalLabels(id)),
1140 };
1141 const body = desiredSpokeBody(news, env.project, desiredLabels);
1142
1143 let current = yield* getByName(name);
1144
1145 if (current === undefined) {
1146 const created = yield* networkconnectivity
1147 .createProjectsLocationsSpokes({
1148 parent: parentOf(env.project, location),
1149 spokeId,
1150 body,
1151 })
1152 .pipe(
1153 Effect.retry({
1154 while: (error) => error._tag === "Conflict",
1155 times: 5,
1156 schedule: Schedule.spaced("2 seconds"),
1157 }),
1158 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
1159 );
1160 if (created !== undefined) {
1161 yield* waitForOperation(created);
1162 }
1163 current = yield* waitUntilReady(name);
1164 }
1165
1166 if (current === undefined) {
1167 return yield* new SpokeNotResolved({ name });
1168 }
1169
1170 const attrs = toAttrs(current, env.project);
1171 const observedLabels = tagRecord(current.labels);
1172 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
1173 const labelsChanged = upsert.length > 0 || removed.length > 0;
1174 const descriptionChanged =
1175 (current.description ?? "") !== (news.description ?? "");
1176 const rangeMasks = rangeFieldsChanged(attrs, news);
1177
1178 if (labelsChanged || descriptionChanged || rangeMasks.length > 0) {
1179 const updateMask = [
1180 labelsChanged ? "labels" : undefined,
1181 descriptionChanged ? "description" : undefined,
1182 ...rangeMasks,
1183 ].filter((field): field is string => field !== undefined);
1184
1185 const operation =
1186 yield* networkconnectivity.patchProjectsLocationsSpokes({
1187 name: current.name ?? name,
1188 updateMask: updateMask.join(","),
1189 body: {
1190 name: current.name ?? name,
1191 etag: current.etag,
1192 ...body,
1193 },
1194 });
1195 yield* waitForOperation(operation);
1196 current = yield* waitUntilReady(current.name ?? name);
1197 }
1198
1199 return toAttrs(current, env.project);
1200 }),
1201
1202 delete: Effect.fn(function* ({ output }) {
1203 const operation = yield* networkconnectivity
1204 .deleteProjectsLocationsSpokes({ name: output.name })
1205 .pipe(
1206 Effect.retry({
1207 while: (error) => error._tag === "Conflict",
1208 times: 8,
1209 schedule: Schedule.spaced("2 seconds"),
1210 }),
1211 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1212 );
1213 if (operation !== undefined) {
1214 yield* waitForOperation(operation, { notFoundOk: true });
1215 }
1216 yield* waitUntilGone(output.name);
1217 if (output.hub) {
1218 yield* waitUntilHubDropsSpoke(output.hub, output.name);
1219 yield* waitUntilHubDropsVpc(
1220 output.hub,
1221 resourceKey(output.linkedVpcNetwork?.uri),
1222 );
1223 }
1224 }),
1225 });