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_GATEWAY_IP_VERSION = "IPV4";
24const DEFAULT_STACK_TYPE = "IPV4_ONLY";
25const MAX_NAME_LENGTH = 63;
26
27export type VpnGatewayInterfaceProps = {
28 /**
29 * Interface id (`0` or `1`). Required when attaching a VLAN
30 * attachment for HA VPN over Cloud Interconnect.
31 */
32 id?: number;
33 /**
34 * VLAN attachment URL for HA VPN over Cloud Interconnect. Immutable —
35 * changing it replaces the gateway.
36 */
37 interconnectAttachment?: string;
38};
39
40export type VpnGatewayInterface = {
41 /** Numeric interface id (`0` or `1`). */
42 id: number | undefined;
43 /** Regional external or internal IPv4 address. */
44 ipAddress: string | undefined;
45 /** Regional external IPv6 address, if any. */
46 ipv6Address: string | undefined;
47 /** VLAN attachment URL, if this interface is over Cloud Interconnect. */
48 interconnectAttachment: string | undefined;
49};
50
51export type VpnGatewayProps = {
52 /**
53 * Gateway name. If omitted, a unique RFC1035 name is generated from
54 * the stack, stage, and logical id. Immutable — changing it replaces
55 * the gateway.
56 */
57 vpnGatewayName?: string;
58 /**
59 * Region the HA VPN gateway lives in. Immutable — changing it
60 * replaces the gateway. `US-CENTRAL1` is accepted and normalized to
61 * `us-central1`.
62 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
63 */
64 region?: string;
65 /**
66 * VPC network this gateway is attached to. Accepts a name
67 * (`default`), a partial URL (`global/networks/default`), or a full
68 * resource URL. Immutable — changing it replaces the gateway.
69 */
70 network: string;
71 /**
72 * Optional description. Immutable — changing it replaces the
73 * gateway.
74 */
75 description?: string;
76 /**
77 * IP family of the gateway interface addresses (`IPV4` or `IPV6`).
78 * Immutable — changing it replaces the gateway.
79 * @default "IPV4"
80 */
81 gatewayIpVersion?: compute.VpnGatewayGatewayIpVersionEnum | (string & {});
82 /**
83 * IP stack enabled on the gateway (`IPV4_ONLY`, `IPV4_IPV6`,
84 * `IPV6_ONLY`). Immutable — changing it replaces the gateway.
85 * @default "IPV4_ONLY"
86 */
87 stackType?: compute.VpnGatewayStackTypeEnum | (string & {});
88 /**
89 * VPN interfaces. Omit for a regular HA VPN (GCP allocates two
90 * regional external IPs). Set `interconnectAttachment` for HA VPN
91 * over Cloud Interconnect. Immutable — changing it replaces the
92 * gateway.
93 */
94 vpnInterfaces?: VpnGatewayInterfaceProps[];
95 /**
96 * User labels. Alchemy ownership labels are merged in automatically
97 * and synced via `setLabels` (labels cannot be set on insert).
98 */
99 labels?: Record<string, string>;
100};
101
102export type VpnGateway = Resource<
103 "GCP.Compute.VpnGateway",
104 VpnGatewayProps,
105 {
106 /** Gateway name. */
107 vpnGatewayName: string;
108 /** Project id. */
109 project: string;
110 /** Region short name (`us-central1`). */
111 region: string;
112 /** Network URL. */
113 network: string | undefined;
114 /** Server-assigned numeric id. */
115 vpnGatewayId: string | undefined;
116 /** Resource self-link. */
117 selfLink: string | undefined;
118 /** Description. */
119 description: string | undefined;
120 /** Gateway interface IP family. */
121 gatewayIpVersion: string | undefined;
122 /** Enabled IP stack. */
123 stackType: string | undefined;
124 /** VPN interfaces (ids, IPs, optional VLAN attachments). */
125 vpnInterfaces: ReadonlyArray<VpnGatewayInterface>;
126 /** User labels (Alchemy ownership labels stripped). */
127 labels: Record<string, string>;
128 /** RFC3339 creation timestamp. */
129 creationTimestamp: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A regional Compute Engine HA VPN gateway.
137 *
138 * HA VPN is a high-availability Cloud VPN that attaches two interfaces
139 * to a VPC in a single region. Labels are the only in-place update
140 * (`vpnGateways.setLabels`); name, region, network, description, IP
141 * version, stack type, and interconnect attachments replace the
142 * gateway.
143 *
144 * ### Creating a VpnGateway
145 * **Example:** Generated name on a custom VPC
146 * ```typescript
147 * const network = yield* GCP.Compute.Network("Vpc", {
148 * autoCreateSubnetworks: false,
149 * });
150 * const gateway = yield* GCP.Compute.VpnGateway("Gateway", {
151 * network: network.networkName,
152 * });
153 * ```
154 *
155 * **Example:** Named gateway with labels
156 * ```typescript
157 * const gateway = yield* GCP.Compute.VpnGateway("Gateway", {
158 * vpnGatewayName: "app-vpn",
159 * region: "us-central1",
160 * network: "default",
161 * description: "ha vpn",
162 * labels: { env: "prod" },
163 * });
164 * ```
165 *
166 * ### Dual-stack HA VPN
167 * **Example:** IPv4/IPv6 stack
168 * ```typescript
169 * const gateway = yield* GCP.Compute.VpnGateway("Gateway", {
170 * network: "default",
171 * stackType: "IPV4_IPV6",
172 * });
173 * ```
174 *
175 * @resource
176 * @category Compute
177 */
178export const VpnGateway = Resource<VpnGateway>("GCP.Compute.VpnGateway");
179
180export class VpnGatewayNotResolved extends Data.TaggedError(
181 "GCP.Compute.VpnGatewayNotResolved",
182)<{
183 vpnGatewayName: string;
184 region: string;
185}> {}
186
187export class VpnGatewayPending extends Data.TaggedError(
188 "GCP.Compute.VpnGatewayPending",
189)<{
190 vpnGatewayName: string;
191 status: string;
192}> {}
193
194const userLabels = (
195 labels: Record<string, string | undefined> | null | undefined,
196): Record<string, string> => stripInternalLabels(tagRecord(labels));
197
198const lastSegment = (value: string) => {
199 const trimmed = value.replace(/\/+$/, "");
200 const parts = trimmed.split("/");
201 return parts[parts.length - 1] || trimmed;
202};
203
204const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
205 lastSegment(region ?? defaultRegion).toLowerCase();
206
207const resourceRefOf = (value: string | undefined) => {
208 if (!value) return "";
209 return lastSegment(value);
210};
211
212const gatewayIpVersionOf = (value: string | undefined) =>
213 value ?? DEFAULT_GATEWAY_IP_VERSION;
214
215const stackTypeOf = (value: string | undefined) => value ?? DEFAULT_STACK_TYPE;
216
217const networkUrl = (project: string, network: string) => {
218 if (network.includes("/")) {
219 return network.startsWith("projects/") || network.startsWith("http")
220 ? network
221 : `projects/${project}/${network.replace(/^\//, "")}`;
222 }
223 return `projects/${project}/global/networks/${network}`;
224};
225
226const toName = (id: string, name: string | undefined, existing?: string) =>
227 Effect.gen(function* () {
228 if (name !== undefined) return name;
229 if (existing !== undefined) return existing;
230 const generated = yield* createPhysicalName({
231 id,
232 maxLength: MAX_NAME_LENGTH,
233 lowercase: true,
234 });
235 const rfc = generated.replace(/^[^a-z]+/, "v").replace(/-+$/g, "");
236 return rfc.slice(0, MAX_NAME_LENGTH);
237 });
238
239const toInterfaces = (
240 interfaces: compute.VpnGatewayVpnGatewayInterfaceList | undefined,
241): VpnGatewayInterface[] =>
242 (interfaces ?? []).map((iface) => ({
243 id: iface.id,
244 ipAddress: iface.ipAddress,
245 ipv6Address: iface.ipv6Address,
246 interconnectAttachment: iface.interconnectAttachment,
247 }));
248
249const attachmentKey = (
250 interfaces: ReadonlyArray<{
251 id?: number;
252 interconnectAttachment?: string;
253 }>,
254) =>
255 interfaces
256 .map(
257 (iface) =>
258 `${iface.id ?? ""}:${resourceRefOf(iface.interconnectAttachment)}`,
259 )
260 .sort()
261 .join("|");
262
263const toAttrs = (gateway: compute.VpnGateway, project: string) => ({
264 vpnGatewayName: gateway.name ?? "",
265 project,
266 region: lastSegment(gateway.region ?? "").toLowerCase(),
267 network: gateway.network,
268 vpnGatewayId: gateway.id,
269 selfLink: gateway.selfLink,
270 description: gateway.description,
271 gatewayIpVersion: gateway.gatewayIpVersion,
272 stackType: gateway.stackType,
273 vpnInterfaces: toInterfaces(gateway.vpnInterfaces),
274 labels: userLabels(gateway.labels),
275 creationTimestamp: gateway.creationTimestamp,
276});
277
278const getByName = (project: string, region: string, vpnGateway: string) =>
279 compute
280 .getVpnGateways({ project, region, vpnGateway })
281 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
282
283const requireGateway = (
284 project: string,
285 region: string,
286 vpnGatewayName: string,
287) =>
288 getByName(project, region, vpnGatewayName).pipe(
289 Effect.flatMap((gateway) =>
290 gateway
291 ? Effect.succeed(gateway)
292 : Effect.fail(new VpnGatewayNotResolved({ vpnGatewayName, region })),
293 ),
294 Effect.retry({
295 while: (e) => e._tag === "GCP.Compute.VpnGatewayNotResolved",
296 schedule: Schedule.spaced("1 second"),
297 times: 8,
298 }),
299 );
300
301const waitUntilGone = (
302 project: string,
303 region: string,
304 vpnGatewayName: string,
305) =>
306 getByName(project, region, vpnGatewayName).pipe(
307 Effect.flatMap((gateway) =>
308 gateway === undefined
309 ? Effect.void
310 : Effect.fail(
311 new VpnGatewayPending({
312 vpnGatewayName,
313 status: "EXISTS",
314 }),
315 ),
316 ),
317 Effect.retry({
318 while: (e) => e._tag === "GCP.Compute.VpnGatewayPending",
319 times: 10,
320 schedule: Schedule.spaced("2 seconds"),
321 }),
322 );
323
324export const VpnGatewayProvider = () =>
325 Provider.succeed(VpnGateway, {
326 stables: [
327 "vpnGatewayName",
328 "project",
329 "region",
330 "network",
331 "vpnGatewayId",
332 "selfLink",
333 "gatewayIpVersion",
334 "stackType",
335 "creationTimestamp",
336 ],
337
338 diff: Effect.fn(function* ({ news, olds, output }) {
339 if (!isResolved(news)) return undefined;
340 const env = yield* GcpEnvironment.current;
341 const previousName = olds.vpnGatewayName ?? output?.vpnGatewayName;
342 const nextName = news.vpnGatewayName ?? previousName;
343 const nameChanged =
344 previousName !== undefined &&
345 nextName !== undefined &&
346 nextName !== previousName;
347
348 const previousRegion = normalizeRegion(
349 olds.region ?? output?.region,
350 env.region,
351 );
352 const nextRegion = normalizeRegion(
353 news.region ?? output?.region,
354 env.region,
355 );
356 const regionChanged = previousRegion !== nextRegion;
357
358 const previousNetwork = resourceRefOf(olds.network ?? output?.network);
359 const nextNetwork = resourceRefOf(news.network);
360 const previousDescription = olds.description ?? output?.description ?? "";
361 const previousIpVersion = gatewayIpVersionOf(
362 olds.gatewayIpVersion ?? output?.gatewayIpVersion,
363 );
364 const previousStack = stackTypeOf(olds.stackType ?? output?.stackType);
365 const previousAttachments = attachmentKey(
366 output?.vpnInterfaces ?? olds.vpnInterfaces ?? [],
367 );
368
369 const immutableChanged =
370 nextNetwork !== previousNetwork ||
371 (news.description !== undefined &&
372 (news.description ?? "") !== previousDescription) ||
373 (news.gatewayIpVersion !== undefined &&
374 gatewayIpVersionOf(news.gatewayIpVersion) !== previousIpVersion) ||
375 (news.stackType !== undefined &&
376 stackTypeOf(news.stackType) !== previousStack) ||
377 (news.vpnInterfaces !== undefined &&
378 attachmentKey(news.vpnInterfaces) !== previousAttachments);
379
380 if (nameChanged || regionChanged) {
381 return { action: "replace" as const, deleteFirst: false };
382 }
383 if (immutableChanged) {
384 return { action: "replace" as const, deleteFirst: true };
385 }
386 return undefined;
387 }),
388
389 read: Effect.fn(function* ({ id, olds, output }) {
390 const env = yield* GcpEnvironment.current;
391 const vpnGatewayName = yield* toName(
392 id,
393 olds?.vpnGatewayName,
394 output?.vpnGatewayName,
395 );
396 const region = normalizeRegion(
397 olds?.region ?? output?.region,
398 env.region,
399 );
400 const existing = yield* getByName(env.project, region, vpnGatewayName);
401 if (existing === undefined) return undefined;
402 const attrs = toAttrs(existing, env.project);
403 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
404 ? attrs
405 : Unowned(attrs);
406 }),
407
408 list: () =>
409 Effect.gen(function* () {
410 const env = yield* GcpEnvironment.current;
411 const pages = yield* compute.aggregatedListVpnGateways
412 .pages({
413 project: env.project,
414 filter: "labels.alchemy-id:*",
415 returnPartialSuccess: true,
416 maxResults: 500,
417 })
418 .pipe(Stream.runCollect);
419 return Array.from(pages).flatMap((page) =>
420 Object.values(page.items ?? {}).flatMap((scoped) =>
421 (scoped?.vpnGateways ?? [])
422 .filter((gateway) =>
423 Object.keys(gateway.labels ?? {}).some((key) =>
424 key.startsWith("alchemy-"),
425 ),
426 )
427 .map((gateway) => toAttrs(gateway, env.project)),
428 ),
429 );
430 }),
431
432 reconcile: Effect.fn(function* ({ id, news, output }) {
433 const env = yield* GcpEnvironment.current;
434 const vpnGatewayName = yield* toName(
435 id,
436 news.vpnGatewayName,
437 output?.vpnGatewayName,
438 );
439 const region = normalizeRegion(news.region ?? output?.region, env.region);
440 const desiredLabels = {
441 ...toLabels(news.labels),
442 ...(yield* createInternalLabels(id)),
443 };
444
445 let current = yield* getByName(env.project, region, vpnGatewayName);
446
447 if (current === undefined) {
448 const body: compute.VpnGateway = {
449 name: vpnGatewayName,
450 network: networkUrl(env.project, news.network),
451 description: news.description,
452 gatewayIpVersion: news.gatewayIpVersion,
453 stackType: news.stackType,
454 };
455 if (news.vpnInterfaces !== undefined && news.vpnInterfaces.length > 0) {
456 body.vpnInterfaces = news.vpnInterfaces.map((iface) => ({
457 id: iface.id,
458 interconnectAttachment: iface.interconnectAttachment,
459 }));
460 }
461 const created = yield* compute
462 .insertVpnGateways({
463 project: env.project,
464 region,
465 body,
466 })
467 .pipe(
468 Effect.flatMap((operation) =>
469 waitRegionOperation(env.project, region, operation, {
470 ignore: ["RESOURCE_ALREADY_EXISTS"],
471 }).pipe(
472 Effect.flatMap(() =>
473 requireGateway(env.project, region, vpnGatewayName),
474 ),
475 ),
476 ),
477 Effect.catchTag("Conflict", () =>
478 getByName(env.project, region, vpnGatewayName),
479 ),
480 );
481 current = created ?? undefined;
482 }
483
484 if (current === undefined) {
485 return yield* new VpnGatewayNotResolved({ vpnGatewayName, region });
486 }
487
488 const resolved = current;
489 const observedLabels = tagRecord(resolved.labels);
490 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
491 if (upsert.length > 0 || removed.length > 0) {
492 yield* Effect.gen(function* () {
493 const latest =
494 (yield* getByName(env.project, region, vpnGatewayName)) ?? resolved;
495 yield* compute
496 .setLabelsVpnGateways({
497 project: env.project,
498 region,
499 resource: vpnGatewayName,
500 body: {
501 labels: desiredLabels,
502 labelFingerprint: latest.labelFingerprint,
503 },
504 })
505 .pipe(
506 Effect.flatMap((operation) =>
507 waitRegionOperation(env.project, region, operation),
508 ),
509 );
510 }).pipe(
511 Effect.retry({
512 while: (e) => e._tag === "Conflict",
513 times: 5,
514 schedule: Schedule.spaced("1 second"),
515 }),
516 );
517 current =
518 (yield* getByName(env.project, region, vpnGatewayName)) ?? resolved;
519 }
520
521 return toAttrs(current ?? resolved, env.project);
522 }),
523
524 delete: Effect.fn(function* ({ output }) {
525 const env = yield* GcpEnvironment.current;
526 const project = output.project || env.project;
527 const region = normalizeRegion(output.region, env.region);
528 if (!output.vpnGatewayName) return;
529 yield* compute
530 .deleteVpnGateways({
531 project,
532 region,
533 vpnGateway: output.vpnGatewayName,
534 })
535 .pipe(
536 Effect.flatMap((operation) =>
537 waitRegionOperation(project, region, operation, {
538 ignore: ["RESOURCE_NOT_FOUND"],
539 }),
540 ),
541 Effect.catchTag("NotFound", () => Effect.void),
542 Effect.retry({
543 while: (e) => e._tag === "Conflict",
544 times: 8,
545 schedule: Schedule.spaced("2 seconds"),
546 }),
547 );
548 yield* waitUntilGone(project, region, output.vpnGatewayName);
549 }),
550 });