1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23const DEFAULT_IKE_VERSION = 2;
24const MAX_NAME_LENGTH = 63;
25
26export type VpnTunnelCipherSuitePhase1 = {
27 /** Phase 1 encryption algorithms (for example `AES-CBC-256`). */
28 encryption?: string[];
29 /** Phase 1 integrity algorithms (for example `HMAC-SHA2-256-128`). */
30 integrity?: string[];
31 /** Phase 1 PRF algorithms (for example `PRF-HMAC-SHA2-256`). */
32 prf?: string[];
33 /** Phase 1 Diffie-Hellman groups (for example `Group-14`). */
34 dh?: string[];
35};
36
37export type VpnTunnelCipherSuitePhase2 = {
38 /** Phase 2 encryption algorithms (for example `AES-CBC-128`). */
39 encryption?: string[];
40 /** Phase 2 integrity algorithms (for example `HMAC-SHA2-256-128`). */
41 integrity?: string[];
42 /** Phase 2 PFS groups (for example `Group-14`). */
43 pfs?: string[];
44};
45
46export type VpnTunnelCipherSuiteProps = {
47 /** Cipher configuration for IKE phase 1. */
48 phase1?: VpnTunnelCipherSuitePhase1;
49 /** Cipher configuration for IKE phase 2. */
50 phase2?: VpnTunnelCipherSuitePhase2;
51};
52
53export type VpnTunnelProps = {
54 /**
55 * Tunnel name. If omitted, a unique RFC1035 name is generated from
56 * the stack, stage, and logical id. Immutable — changing it replaces
57 * the tunnel.
58 */
59 vpnTunnelName?: string;
60 /**
61 * Region the tunnel lives in. Immutable — changing it replaces the
62 * tunnel. `US-CENTRAL1` is accepted and normalized to `us-central1`.
63 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
64 */
65 region?: string;
66 /**
67 * Optional description. Immutable — changing it replaces the tunnel.
68 */
69 description?: string;
70 /**
71 * HA VPN gateway this tunnel attaches to. Accepts a name, a partial
72 * URL (`regions/{region}/vpnGateways/{name}`), or a full resource URL.
73 * Mutually exclusive with `targetVpnGateway`. Immutable.
74 */
75 vpnGateway?: string;
76 /**
77 * Interface id on `vpnGateway` (`0` or `1`). Defaults to `0` when
78 * `vpnGateway` is set. Immutable.
79 */
80 vpnGatewayInterface?: number;
81 /**
82 * Peer HA VPN gateway in another VPC. Exclusive with
83 * `peerExternalGateway`. Immutable.
84 */
85 peerGcpGateway?: string;
86 /**
87 * Peer external VPN gateway (on-prem / other cloud). Exclusive with
88 * `peerGcpGateway`. Immutable.
89 */
90 peerExternalGateway?: string;
91 /**
92 * Interface id on `peerExternalGateway` (`0`–`3`). Defaults to `0`
93 * when `peerExternalGateway` is set. Immutable.
94 */
95 peerExternalGatewayInterface?: number;
96 /**
97 * Classic VPN target gateway. Mutually exclusive with `vpnGateway`.
98 * Immutable.
99 */
100 targetVpnGateway?: string;
101 /**
102 * Peer VPN gateway IPv4. Classic VPN only. Immutable.
103 */
104 peerIp?: string;
105 /**
106 * Cloud Router used for dynamic routing (required for HA VPN).
107 * Accepts a name or URL. Immutable.
108 */
109 router?: string;
110 /**
111 * IKE pre-shared key (1–96 printable ASCII characters). Write-only —
112 * the API never returns it. Immutable — changing it replaces the
113 * tunnel.
114 */
115 sharedSecret: string;
116 /**
117 * IKE protocol version (`1` or `2`).
118 * @default 2
119 */
120 ikeVersion?: number;
121 /**
122 * Local traffic selector CIDRs. Classic VPN only (output-only on HA
123 * VPN). Immutable.
124 */
125 localTrafficSelector?: string[];
126 /**
127 * Remote traffic selector CIDRs. Classic VPN only (output-only on HA
128 * VPN). Immutable.
129 */
130 remoteTrafficSelector?: string[];
131 /**
132 * User-specified IKE cipher suite. Immutable.
133 */
134 cipherSuite?: VpnTunnelCipherSuiteProps;
135 /**
136 * User labels. Alchemy ownership labels are merged in automatically
137 * and synced via `setLabels` (labels cannot be set on insert).
138 */
139 labels?: Record<string, string>;
140};
141
142export type VpnTunnel = Resource<
143 "GCP.Compute.VpnTunnel",
144 VpnTunnelProps,
145 {
146 /** Tunnel name. */
147 vpnTunnelName: string;
148 /** Project id. */
149 project: string;
150 /** Region short name (`us-central1`). */
151 region: string;
152 /** Description. */
153 description: string | undefined;
154 /** HA VPN gateway URL, if this is an HA tunnel. */
155 vpnGateway: string | undefined;
156 /** HA VPN gateway interface id. */
157 vpnGatewayInterface: number | undefined;
158 /** Peer HA VPN gateway URL. */
159 peerGcpGateway: string | undefined;
160 /** Peer external VPN gateway URL. */
161 peerExternalGateway: string | undefined;
162 /** Peer external VPN gateway interface id. */
163 peerExternalGatewayInterface: number | undefined;
164 /** Classic VPN target gateway URL. */
165 targetVpnGateway: string | undefined;
166 /** Peer IPv4 (Classic VPN). */
167 peerIp: string | undefined;
168 /** Cloud Router URL. */
169 router: string | undefined;
170 /** IKE protocol version. */
171 ikeVersion: number | undefined;
172 /** Local traffic selector CIDRs. */
173 localTrafficSelector: ReadonlyArray<string>;
174 /** Remote traffic selector CIDRs. */
175 remoteTrafficSelector: ReadonlyArray<string>;
176 /** Configured IKE cipher suite, if any. */
177 cipherSuite: VpnTunnelCipherSuiteProps | undefined;
178 /** Hash of the shared secret. */
179 sharedSecretHash: string | undefined;
180 /** Server-reported tunnel status. */
181 status: string | undefined;
182 /** Human-readable status detail. */
183 detailedStatus: string | undefined;
184 /** Server-assigned numeric id. */
185 vpnTunnelId: string | undefined;
186 /** Resource self-link. */
187 selfLink: string | undefined;
188 /** User labels (Alchemy ownership labels stripped). */
189 labels: Record<string, string>;
190 /** RFC3339 creation timestamp. */
191 creationTimestamp: string | undefined;
192 },
193 never,
194 Providers
195>;
196
197/**
198 * A regional Compute Engine Cloud VPN tunnel.
199 *
200 * HA VPN tunnels attach to a `VpnGateway` plus a Cloud Router and a
201 * peer (`peerExternalGateway` or `peerGcpGateway`). Classic VPN
202 * tunnels attach to a `TargetVpnGateway` and a `peerIp`. Labels are
203 * the only in-place update (`vpnTunnels.setLabels`); every other
204 * field replaces the tunnel.
205 *
206 * ### Creating an HA VPN tunnel
207 * **Example:** HA VPN to an external peer
208 * ```typescript
209 * const network = yield* GCP.Compute.Network("Vpc", {
210 * autoCreateSubnetworks: false,
211 * });
212 * const gateway = yield* GCP.Compute.VpnGateway("Gateway", {
213 * network: network.networkName,
214 * });
215 * const router = yield* GCP.Compute.Router("Edge", {
216 * network: network.networkName,
217 * bgp: { asn: 64514 },
218 * });
219 * const peer = yield* GCP.Compute.ExternalVpnGateway("Peer", {
220 * redundancyType: "SINGLE_IP_INTERNALLY_REDUNDANT",
221 * interfaces: [{ id: 0, ipAddress: "15.0.0.120" }],
222 * });
223 * const tunnel = yield* GCP.Compute.VpnTunnel("Tunnel", {
224 * vpnGateway: gateway.vpnGatewayName,
225 * vpnGatewayInterface: 0,
226 * peerExternalGateway: peer.externalVpnGatewayName,
227 * peerExternalGatewayInterface: 0,
228 * router: router.routerName,
229 * sharedSecret: "replace-me-with-a-secret",
230 * });
231 * ```
232 *
233 * **Example:** Named tunnel with labels
234 * ```typescript
235 * const tunnel = yield* GCP.Compute.VpnTunnel("Tunnel", {
236 * vpnTunnelName: "app-tunnel",
237 * region: "us-central1",
238 * vpnGateway: "app-vpn",
239 * vpnGatewayInterface: 0,
240 * peerGcpGateway: "peer-vpn",
241 * router: "app-router",
242 * sharedSecret: "replace-me-with-a-secret",
243 * labels: { env: "prod" },
244 * });
245 * ```
246 *
247 * ### Classic VPN
248 * **Example:** Tunnel on a TargetVpnGateway
249 * ```typescript
250 * const tunnel = yield* GCP.Compute.VpnTunnel("Tunnel", {
251 * targetVpnGateway: gateway.targetVpnGatewayName,
252 * peerIp: "15.0.0.120",
253 * sharedSecret: "replace-me-with-a-secret",
254 * localTrafficSelector: ["10.0.0.0/16"],
255 * remoteTrafficSelector: ["172.16.0.0/16"],
256 * });
257 * ```
258 *
259 * @resource
260 * @category Compute
261 */
262export const VpnTunnel = Resource<VpnTunnel>("GCP.Compute.VpnTunnel");
263
264export class VpnTunnelNotResolved extends Data.TaggedError(
265 "GCP.Compute.VpnTunnelNotResolved",
266)<{
267 vpnTunnelName: string;
268 region: string;
269}> {}
270
271export class VpnTunnelPending extends Data.TaggedError(
272 "GCP.Compute.VpnTunnelPending",
273)<{
274 vpnTunnelName: string;
275 status: string;
276}> {}
277
278const userLabels = (
279 labels: Record<string, string | undefined> | null | undefined,
280): Record<string, string> => stripInternalLabels(tagRecord(labels));
281
282const lastSegment = (value: string) => {
283 const trimmed = value.replace(/\/+$/, "");
284 const parts = trimmed.split("/");
285 return parts[parts.length - 1] || trimmed;
286};
287
288const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
289 lastSegment(region ?? defaultRegion).toLowerCase();
290
291const resourceRefOf = (value: string | undefined) => {
292 if (!value) return "";
293 return lastSegment(value);
294};
295
296const ikeVersionOf = (value: number | undefined) =>
297 value ?? DEFAULT_IKE_VERSION;
298
299const selectorsKey = (values: ReadonlyArray<string> | undefined) =>
300 [...(values ?? [])]
301 .map((value) => value.trim())
302 .filter((value) => value.length > 0)
303 .sort()
304 .join(",");
305
306const cipherKey = (suite: VpnTunnelCipherSuiteProps | undefined) =>
307 JSON.stringify({
308 phase1: {
309 encryption: [...(suite?.phase1?.encryption ?? [])].sort(),
310 integrity: [...(suite?.phase1?.integrity ?? [])].sort(),
311 prf: [...(suite?.phase1?.prf ?? [])].sort(),
312 dh: [...(suite?.phase1?.dh ?? [])].sort(),
313 },
314 phase2: {
315 encryption: [...(suite?.phase2?.encryption ?? [])].sort(),
316 integrity: [...(suite?.phase2?.integrity ?? [])].sort(),
317 pfs: [...(suite?.phase2?.pfs ?? [])].sort(),
318 },
319 });
320
321const toCipherSuite = (
322 suite: compute.VpnTunnelCipherSuite | undefined,
323): VpnTunnelCipherSuiteProps | undefined => {
324 if (suite === undefined) return undefined;
325 const phase1 = suite.phase1;
326 const phase2 = suite.phase2;
327 if (phase1 === undefined && phase2 === undefined) return undefined;
328 return {
329 phase1: phase1
330 ? {
331 encryption: phase1.encryption,
332 integrity: phase1.integrity,
333 prf: phase1.prf,
334 dh: phase1.dh,
335 }
336 : undefined,
337 phase2: phase2
338 ? {
339 encryption: phase2.encryption,
340 integrity: phase2.integrity,
341 pfs: phase2.pfs,
342 }
343 : undefined,
344 };
345};
346
347const regionalUrl = (
348 project: string,
349 region: string,
350 collection: string,
351 value: string,
352) => {
353 if (value.includes("/")) {
354 return value.startsWith("projects/") || value.startsWith("http")
355 ? value
356 : `projects/${project}/${value.replace(/^\//, "")}`;
357 }
358 return `projects/${project}/regions/${region}/${collection}/${value}`;
359};
360
361const globalUrl = (project: string, collection: string, value: string) => {
362 if (value.includes("/")) {
363 return value.startsWith("projects/") || value.startsWith("http")
364 ? value
365 : `projects/${project}/${value.replace(/^\//, "")}`;
366 }
367 return `projects/${project}/global/${collection}/${value}`;
368};
369
370const toName = (id: string, name: string | undefined, existing?: string) =>
371 Effect.gen(function* () {
372 if (name !== undefined) return name;
373 if (existing !== undefined) return existing;
374 const generated = yield* createPhysicalName({
375 id,
376 maxLength: MAX_NAME_LENGTH,
377 lowercase: true,
378 });
379 const rfc = generated.replace(/^[^a-z]+/, "t").replace(/-+$/g, "");
380 return rfc.slice(0, MAX_NAME_LENGTH);
381 });
382
383const toAttrs = (tunnel: compute.VpnTunnel, project: string) => ({
384 vpnTunnelName: tunnel.name ?? "",
385 project,
386 region: lastSegment(tunnel.region ?? "").toLowerCase(),
387 description: tunnel.description,
388 vpnGateway: tunnel.vpnGateway,
389 vpnGatewayInterface: tunnel.vpnGatewayInterface,
390 peerGcpGateway: tunnel.peerGcpGateway,
391 peerExternalGateway: tunnel.peerExternalGateway,
392 peerExternalGatewayInterface: tunnel.peerExternalGatewayInterface,
393 targetVpnGateway: tunnel.targetVpnGateway,
394 peerIp: tunnel.peerIp,
395 router: tunnel.router,
396 ikeVersion: tunnel.ikeVersion,
397 localTrafficSelector: tunnel.localTrafficSelector ?? [],
398 remoteTrafficSelector: tunnel.remoteTrafficSelector ?? [],
399 cipherSuite: toCipherSuite(tunnel.cipherSuite),
400 sharedSecretHash: tunnel.sharedSecretHash,
401 status: tunnel.status,
402 detailedStatus: tunnel.detailedStatus,
403 vpnTunnelId: tunnel.id,
404 selfLink: tunnel.selfLink,
405 labels: userLabels(tunnel.labels),
406 creationTimestamp: tunnel.creationTimestamp,
407});
408
409const toInsertBody = (
410 project: string,
411 region: string,
412 vpnTunnelName: string,
413 news: VpnTunnelProps,
414): compute.VpnTunnel => {
415 const body: compute.VpnTunnel = {
416 name: vpnTunnelName,
417 sharedSecret: news.sharedSecret,
418 ikeVersion: ikeVersionOf(news.ikeVersion),
419 description: news.description,
420 };
421 if (news.vpnGateway !== undefined) {
422 body.vpnGateway = regionalUrl(
423 project,
424 region,
425 "vpnGateways",
426 news.vpnGateway,
427 );
428 body.vpnGatewayInterface = news.vpnGatewayInterface ?? 0;
429 }
430 if (news.targetVpnGateway !== undefined) {
431 body.targetVpnGateway = regionalUrl(
432 project,
433 region,
434 "targetVpnGateways",
435 news.targetVpnGateway,
436 );
437 }
438 if (news.peerGcpGateway !== undefined) {
439 body.peerGcpGateway = regionalUrl(
440 project,
441 region,
442 "vpnGateways",
443 news.peerGcpGateway,
444 );
445 }
446 if (news.peerExternalGateway !== undefined) {
447 body.peerExternalGateway = globalUrl(
448 project,
449 "externalVpnGateways",
450 news.peerExternalGateway,
451 );
452 body.peerExternalGatewayInterface = news.peerExternalGatewayInterface ?? 0;
453 }
454 if (news.peerIp !== undefined) {
455 body.peerIp = news.peerIp;
456 }
457 if (news.router !== undefined) {
458 body.router = regionalUrl(project, region, "routers", news.router);
459 }
460 if (news.localTrafficSelector !== undefined) {
461 body.localTrafficSelector = news.localTrafficSelector;
462 }
463 if (news.remoteTrafficSelector !== undefined) {
464 body.remoteTrafficSelector = news.remoteTrafficSelector;
465 }
466 if (news.cipherSuite !== undefined) {
467 body.cipherSuite = news.cipherSuite;
468 }
469 return body;
470};
471
472const getByName = (project: string, region: string, vpnTunnel: string) =>
473 compute
474 .getVpnTunnels({ project, region, vpnTunnel })
475 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
476
477const requireTunnel = (
478 project: string,
479 region: string,
480 vpnTunnelName: string,
481) =>
482 getByName(project, region, vpnTunnelName).pipe(
483 Effect.flatMap((tunnel) =>
484 tunnel
485 ? Effect.succeed(tunnel)
486 : Effect.fail(new VpnTunnelNotResolved({ vpnTunnelName, region })),
487 ),
488 Effect.retry({
489 while: (e) => e._tag === "GCP.Compute.VpnTunnelNotResolved",
490 schedule: Schedule.spaced("1 second"),
491 times: 8,
492 }),
493 );
494
495const waitUntilGone = (
496 project: string,
497 region: string,
498 vpnTunnelName: string,
499) =>
500 getByName(project, region, vpnTunnelName).pipe(
501 Effect.flatMap((tunnel) =>
502 tunnel === undefined
503 ? Effect.void
504 : Effect.fail(
505 new VpnTunnelPending({
506 vpnTunnelName,
507 status: "EXISTS",
508 }),
509 ),
510 ),
511 Effect.retry({
512 while: (e) => e._tag === "GCP.Compute.VpnTunnelPending",
513 times: 10,
514 schedule: Schedule.spaced("2 seconds"),
515 }),
516 );
517
518export const VpnTunnelProvider = () =>
519 Provider.succeed(VpnTunnel, {
520 stables: [
521 "vpnTunnelName",
522 "project",
523 "region",
524 "vpnGateway",
525 "vpnGatewayInterface",
526 "peerGcpGateway",
527 "peerExternalGateway",
528 "peerExternalGatewayInterface",
529 "targetVpnGateway",
530 "peerIp",
531 "router",
532 "ikeVersion",
533 "vpnTunnelId",
534 "selfLink",
535 "creationTimestamp",
536 ],
537
538 diff: Effect.fn(function* ({ news, olds, output }) {
539 if (!isResolved(news)) return undefined;
540 const env = yield* GcpEnvironment.current;
541 const previousName = olds.vpnTunnelName ?? output?.vpnTunnelName;
542 const nextName = news.vpnTunnelName ?? previousName;
543 const nameChanged =
544 previousName !== undefined &&
545 nextName !== undefined &&
546 nextName !== previousName;
547
548 const previousRegion = normalizeRegion(
549 olds.region ?? output?.region,
550 env.region,
551 );
552 const nextRegion = normalizeRegion(
553 news.region ?? output?.region,
554 env.region,
555 );
556 const regionChanged = previousRegion !== nextRegion;
557
558 const previousDescription = olds.description ?? output?.description ?? "";
559 const previousVpnGateway = resourceRefOf(
560 olds.vpnGateway ?? output?.vpnGateway,
561 );
562 const previousVpnGatewayInterface =
563 olds.vpnGatewayInterface ?? output?.vpnGatewayInterface ?? 0;
564 const previousPeerGcp = resourceRefOf(
565 olds.peerGcpGateway ?? output?.peerGcpGateway,
566 );
567 const previousPeerExternal = resourceRefOf(
568 olds.peerExternalGateway ?? output?.peerExternalGateway,
569 );
570 const previousPeerExternalInterface =
571 olds.peerExternalGatewayInterface ??
572 output?.peerExternalGatewayInterface ??
573 0;
574 const previousTarget = resourceRefOf(
575 olds.targetVpnGateway ?? output?.targetVpnGateway,
576 );
577 const previousPeerIp = olds.peerIp ?? output?.peerIp ?? "";
578 const previousRouter = resourceRefOf(olds.router ?? output?.router);
579 const previousIke = ikeVersionOf(olds.ikeVersion ?? output?.ikeVersion);
580 const previousLocal = selectorsKey(
581 olds.localTrafficSelector ?? output?.localTrafficSelector,
582 );
583 const previousRemote = selectorsKey(
584 olds.remoteTrafficSelector ?? output?.remoteTrafficSelector,
585 );
586 const previousCipher = cipherKey(olds.cipherSuite ?? output?.cipherSuite);
587
588 const immutableChanged =
589 (news.description !== undefined &&
590 (news.description ?? "") !== previousDescription) ||
591 (news.vpnGateway !== undefined &&
592 resourceRefOf(news.vpnGateway) !== previousVpnGateway) ||
593 (news.vpnGatewayInterface !== undefined &&
594 news.vpnGatewayInterface !== previousVpnGatewayInterface) ||
595 (news.peerGcpGateway !== undefined &&
596 resourceRefOf(news.peerGcpGateway) !== previousPeerGcp) ||
597 (news.peerExternalGateway !== undefined &&
598 resourceRefOf(news.peerExternalGateway) !== previousPeerExternal) ||
599 (news.peerExternalGatewayInterface !== undefined &&
600 news.peerExternalGatewayInterface !==
601 previousPeerExternalInterface) ||
602 (news.targetVpnGateway !== undefined &&
603 resourceRefOf(news.targetVpnGateway) !== previousTarget) ||
604 (news.peerIp !== undefined && news.peerIp !== previousPeerIp) ||
605 (news.router !== undefined &&
606 resourceRefOf(news.router) !== previousRouter) ||
607 (news.ikeVersion !== undefined &&
608 ikeVersionOf(news.ikeVersion) !== previousIke) ||
609 (olds.sharedSecret !== undefined &&
610 news.sharedSecret !== olds.sharedSecret) ||
611 (news.localTrafficSelector !== undefined &&
612 selectorsKey(news.localTrafficSelector) !== previousLocal) ||
613 (news.remoteTrafficSelector !== undefined &&
614 selectorsKey(news.remoteTrafficSelector) !== previousRemote) ||
615 (news.cipherSuite !== undefined &&
616 cipherKey(news.cipherSuite) !== previousCipher);
617
618 if (nameChanged || regionChanged) {
619 return { action: "replace" as const, deleteFirst: false };
620 }
621 if (immutableChanged) {
622 return { action: "replace" as const, deleteFirst: true };
623 }
624 return undefined;
625 }),
626
627 read: Effect.fn(function* ({ id, olds, output }) {
628 const env = yield* GcpEnvironment.current;
629 const vpnTunnelName = yield* toName(
630 id,
631 olds?.vpnTunnelName,
632 output?.vpnTunnelName,
633 );
634 const region = normalizeRegion(
635 olds?.region ?? output?.region,
636 env.region,
637 );
638 const existing = yield* getByName(env.project, region, vpnTunnelName);
639 if (existing === undefined) return undefined;
640 const attrs = toAttrs(existing, env.project);
641 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
642 ? attrs
643 : Unowned(attrs);
644 }),
645
646 list: () =>
647 Effect.gen(function* () {
648 const env = yield* GcpEnvironment.current;
649 const pages = yield* compute.aggregatedListVpnTunnels
650 .pages({
651 project: env.project,
652 filter: "labels.alchemy-id:*",
653 returnPartialSuccess: true,
654 maxResults: 500,
655 })
656 .pipe(Stream.runCollect);
657 return Array.from(pages).flatMap((page) =>
658 Object.values(page.items ?? {}).flatMap((scoped) =>
659 (scoped?.vpnTunnels ?? [])
660 .filter((tunnel) =>
661 Object.keys(tunnel.labels ?? {}).some((key) =>
662 key.startsWith("alchemy-"),
663 ),
664 )
665 .map((tunnel) => toAttrs(tunnel, env.project)),
666 ),
667 );
668 }),
669
670 reconcile: Effect.fn(function* ({ id, news, output }) {
671 const env = yield* GcpEnvironment.current;
672 const vpnTunnelName = yield* toName(
673 id,
674 news.vpnTunnelName,
675 output?.vpnTunnelName,
676 );
677 const region = normalizeRegion(news.region ?? output?.region, env.region);
678 const desiredLabels = {
679 ...toLabels(news.labels),
680 ...(yield* createInternalLabels(id)),
681 };
682
683 let current = yield* getByName(env.project, region, vpnTunnelName);
684
685 if (current === undefined) {
686 const created = yield* compute
687 .insertVpnTunnels({
688 project: env.project,
689 region,
690 body: toInsertBody(env.project, region, vpnTunnelName, news),
691 })
692 .pipe(
693 Effect.flatMap((operation) =>
694 waitRegionOperation(env.project, region, operation, {
695 ignore: ["RESOURCE_ALREADY_EXISTS"],
696 }).pipe(
697 Effect.flatMap(() =>
698 requireTunnel(env.project, region, vpnTunnelName),
699 ),
700 ),
701 ),
702 Effect.catchTag("Conflict", () =>
703 getByName(env.project, region, vpnTunnelName),
704 ),
705 );
706 current = created ?? undefined;
707 }
708
709 if (current === undefined) {
710 return yield* new VpnTunnelNotResolved({ vpnTunnelName, region });
711 }
712
713 const resolved = current;
714 const observedLabels = tagRecord(resolved.labels);
715 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
716 if (upsert.length > 0 || removed.length > 0) {
717 yield* Effect.gen(function* () {
718 const latest =
719 (yield* getByName(env.project, region, vpnTunnelName)) ?? resolved;
720 yield* compute
721 .setLabelsVpnTunnels({
722 project: env.project,
723 region,
724 resource: vpnTunnelName,
725 body: {
726 labels: desiredLabels,
727 labelFingerprint: latest.labelFingerprint,
728 },
729 })
730 .pipe(
731 Effect.flatMap((operation) =>
732 waitRegionOperation(env.project, region, operation),
733 ),
734 );
735 }).pipe(
736 Effect.retry({
737 while: (e) => e._tag === "Conflict",
738 times: 5,
739 schedule: Schedule.spaced("1 second"),
740 }),
741 );
742 current =
743 (yield* getByName(env.project, region, vpnTunnelName)) ?? resolved;
744 }
745
746 return toAttrs(current ?? resolved, env.project);
747 }),
748
749 delete: Effect.fn(function* ({ output }) {
750 const env = yield* GcpEnvironment.current;
751 const project = output.project || env.project;
752 const region = normalizeRegion(output.region, env.region);
753 if (!output.vpnTunnelName) return;
754 yield* compute
755 .deleteVpnTunnels({
756 project,
757 region,
758 vpnTunnel: output.vpnTunnelName,
759 })
760 .pipe(
761 Effect.flatMap((operation) =>
762 waitRegionOperation(project, region, operation, {
763 ignore: ["RESOURCE_NOT_FOUND"],
764 }),
765 ),
766 Effect.catchTag("NotFound", () => Effect.void),
767 Effect.retry({
768 while: (e) => e._tag === "Conflict",
769 times: 8,
770 schedule: Schedule.spaced("2 seconds"),
771 }),
772 );
773 yield* waitUntilGone(project, region, output.vpnTunnelName);
774 }),
775 });