1import * as compute from "@distilled.cloud/gcp/compute_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 { waitGlobalOperation } from "./operations.ts";
22
23const DEFAULT_REDUNDANCY_TYPE = "TWO_IPS_REDUNDANCY";
24const MAX_NAME_LENGTH = 63;
25
26export type ExternalVpnGatewayInterfaceProps = {
27 /**
28 * Numeric interface id. `0` for `SINGLE_IP_INTERNALLY_REDUNDANT`,
29 * `0` and `1` for `TWO_IPS_REDUNDANCY`, `0`–`3` for
30 * `FOUR_IPS_REDUNDANCY`.
31 */
32 id?: number;
33 /**
34 * IPv4 address of the peer VPN gateway interface. Must not be a
35 * Compute Engine IP. Immutable — changing it replaces the gateway.
36 */
37 ipAddress?: string;
38 /**
39 * IPv6 address of the peer VPN gateway interface (RFC 4291). Must
40 * not be a Compute Engine IP. Immutable — changing it replaces the
41 * gateway.
42 */
43 ipv6Address?: string;
44};
45
46export type ExternalVpnGatewayInterface = {
47 /** Numeric interface id. */
48 id: number | undefined;
49 /** Peer IPv4 address. */
50 ipAddress: string | undefined;
51 /** Peer IPv6 address, if any. */
52 ipv6Address: string | undefined;
53};
54
55export type ExternalVpnGatewayProps = {
56 /**
57 * Gateway name. If omitted, a unique RFC1035 name is generated from
58 * the stack, stage, and logical id. Immutable — changing it replaces
59 * the gateway.
60 */
61 externalVpnGatewayName?: string;
62 /**
63 * Optional description. Immutable — changing it replaces the
64 * gateway.
65 */
66 description?: string;
67 /**
68 * Peer redundancy. `SINGLE_IP_INTERNALLY_REDUNDANT` (one interface),
69 * `TWO_IPS_REDUNDANCY` (two interfaces), or `FOUR_IPS_REDUNDANCY`
70 * (four interfaces, typically AWS). Immutable — changing it
71 * replaces the gateway.
72 * @default "TWO_IPS_REDUNDANCY"
73 */
74 redundancyType?: compute.ExternalVpnGatewayRedundancyTypeEnum | (string & {});
75 /**
76 * Peer interfaces. Count must match `redundancyType`. Immutable —
77 * changing them replaces the gateway.
78 */
79 interfaces?: ExternalVpnGatewayInterfaceProps[];
80 /**
81 * User labels. Alchemy ownership labels are merged in automatically
82 * and synced via `setLabels` (labels cannot be set on insert).
83 */
84 labels?: Record<string, string>;
85};
86
87export type ExternalVpnGateway = Resource<
88 "GCP.Compute.ExternalVpnGateway",
89 ExternalVpnGatewayProps,
90 {
91 /** Gateway name. */
92 externalVpnGatewayName: string;
93 /** Project id. */
94 project: string;
95 /** Server-assigned numeric id. */
96 externalVpnGatewayId: string | undefined;
97 /** Resource self-link. */
98 selfLink: string | undefined;
99 /** Description. */
100 description: string | undefined;
101 /** Peer redundancy type. */
102 redundancyType: string | undefined;
103 /** Peer interfaces (ids and IPs). */
104 interfaces: ReadonlyArray<ExternalVpnGatewayInterface>;
105 /** User labels (Alchemy ownership labels stripped). */
106 labels: Record<string, string>;
107 /** RFC3339 creation timestamp. */
108 creationTimestamp: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * A global Compute Engine external VPN gateway — the on-premises or
116 * other-cloud peer that an HA VPN gateway connects to.
117 *
118 * Labels are the only in-place update (`externalVpnGateways.setLabels`).
119 * Name, description, redundancy type, and interfaces replace the
120 * gateway.
121 *
122 * ### Creating an ExternalVpnGateway
123 * **Example:** Generated name with two peer IPs
124 * ```typescript
125 * const peer = yield* GCP.Compute.ExternalVpnGateway("Peer", {
126 * redundancyType: "TWO_IPS_REDUNDANCY",
127 * interfaces: [
128 * { id: 0, ipAddress: "203.0.113.1" },
129 * { id: 1, ipAddress: "203.0.113.2" },
130 * ],
131 * });
132 * ```
133 *
134 * **Example:** Named gateway with labels
135 * ```typescript
136 * const peer = yield* GCP.Compute.ExternalVpnGateway("Peer", {
137 * externalVpnGatewayName: "onprem-vpn",
138 * description: "on-prem peer",
139 * redundancyType: "SINGLE_IP_INTERNALLY_REDUNDANT",
140 * interfaces: [{ id: 0, ipAddress: "203.0.113.10" }],
141 * labels: { env: "prod" },
142 * });
143 * ```
144 *
145 * @resource
146 * @category Compute
147 */
148export const ExternalVpnGateway = Resource<ExternalVpnGateway>(
149 "GCP.Compute.ExternalVpnGateway",
150);
151
152export class ExternalVpnGatewayNotResolved extends Data.TaggedError(
153 "GCP.Compute.ExternalVpnGatewayNotResolved",
154)<{
155 externalVpnGatewayName: string;
156}> {}
157
158export class ExternalVpnGatewayPending extends Data.TaggedError(
159 "GCP.Compute.ExternalVpnGatewayPending",
160)<{
161 externalVpnGatewayName: string;
162 status: string;
163}> {}
164
165const userLabels = (
166 labels: Record<string, string | undefined> | null | undefined,
167): Record<string, string> => stripInternalLabels(tagRecord(labels));
168
169const redundancyOf = (
170 value: string | undefined,
171 interfaces?: ReadonlyArray<unknown>,
172) => {
173 if (value) return value;
174 const n = interfaces?.length ?? 0;
175 if (n === 1) return "SINGLE_IP_INTERNALLY_REDUNDANT";
176 if (n === 4) return "FOUR_IPS_REDUNDANCY";
177 return DEFAULT_REDUNDANCY_TYPE;
178};
179
180const toName = (id: string, name: string | undefined, existing?: string) =>
181 Effect.gen(function* () {
182 if (name !== undefined) return name;
183 if (existing !== undefined) return existing;
184 const generated = yield* createPhysicalName({
185 id,
186 maxLength: MAX_NAME_LENGTH,
187 lowercase: true,
188 });
189 const rfc = generated.replace(/^[^a-z]+/, "e").replace(/-+$/g, "");
190 return rfc.slice(0, MAX_NAME_LENGTH);
191 });
192
193const toInterfaces = (
194 interfaces: compute.ExternalVpnGatewayInterfaceList | undefined,
195): ExternalVpnGatewayInterface[] =>
196 (interfaces ?? []).map((iface) => ({
197 id: iface.id,
198 ipAddress: iface.ipAddress,
199 ipv6Address: iface.ipv6Address,
200 }));
201
202const interfaceKey = (
203 interfaces: ReadonlyArray<{
204 id?: number;
205 ipAddress?: string;
206 ipv6Address?: string;
207 }>,
208) =>
209 interfaces
210 .map(
211 (iface) =>
212 `${iface.id ?? ""}:${iface.ipAddress ?? ""}:${iface.ipv6Address ?? ""}`,
213 )
214 .sort()
215 .join("|");
216
217const toAttrs = (gateway: compute.ExternalVpnGateway, project: string) => ({
218 externalVpnGatewayName: gateway.name ?? "",
219 project,
220 externalVpnGatewayId: gateway.id,
221 selfLink: gateway.selfLink,
222 description: gateway.description,
223 redundancyType: gateway.redundancyType,
224 interfaces: toInterfaces(gateway.interfaces),
225 labels: userLabels(gateway.labels),
226 creationTimestamp: gateway.creationTimestamp,
227});
228
229const getByName = (project: string, externalVpnGateway: string) =>
230 compute
231 .getExternalVpnGateways({ project, externalVpnGateway })
232 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
233
234const requireGateway = (project: string, externalVpnGatewayName: string) =>
235 getByName(project, externalVpnGatewayName).pipe(
236 Effect.flatMap((gateway) =>
237 gateway
238 ? Effect.succeed(gateway)
239 : Effect.fail(
240 new ExternalVpnGatewayNotResolved({ externalVpnGatewayName }),
241 ),
242 ),
243 Effect.retry({
244 while: (e) => e._tag === "GCP.Compute.ExternalVpnGatewayNotResolved",
245 schedule: Schedule.spaced("1 second"),
246 times: 8,
247 }),
248 );
249
250const waitUntilGone = (project: string, externalVpnGatewayName: string) =>
251 getByName(project, externalVpnGatewayName).pipe(
252 Effect.flatMap((gateway) =>
253 gateway === undefined
254 ? Effect.void
255 : Effect.fail(
256 new ExternalVpnGatewayPending({
257 externalVpnGatewayName,
258 status: "EXISTS",
259 }),
260 ),
261 ),
262 Effect.retry({
263 while: (e) => e._tag === "GCP.Compute.ExternalVpnGatewayPending",
264 times: 10,
265 schedule: Schedule.spaced("2 seconds"),
266 }),
267 );
268
269export const ExternalVpnGatewayProvider = () =>
270 Provider.succeed(ExternalVpnGateway, {
271 stables: [
272 "externalVpnGatewayName",
273 "project",
274 "externalVpnGatewayId",
275 "selfLink",
276 "redundancyType",
277 "creationTimestamp",
278 ],
279
280 diff: Effect.fn(function* ({ news, olds, output }) {
281 if (!isResolved(news)) return undefined;
282 const previousName =
283 olds.externalVpnGatewayName ?? output?.externalVpnGatewayName;
284 const nextName = news.externalVpnGatewayName ?? previousName;
285 const nameChanged =
286 previousName !== undefined &&
287 nextName !== undefined &&
288 nextName !== previousName;
289
290 const previousDescription = olds.description ?? output?.description ?? "";
291 const previousRedundancy = redundancyOf(
292 olds.redundancyType ?? output?.redundancyType,
293 olds.interfaces ?? output?.interfaces,
294 );
295 const previousInterfaces = interfaceKey(
296 output?.interfaces ?? olds.interfaces ?? [],
297 );
298
299 const immutableChanged =
300 (news.description !== undefined &&
301 (news.description ?? "") !== previousDescription) ||
302 (news.redundancyType !== undefined &&
303 redundancyOf(news.redundancyType, news.interfaces) !==
304 previousRedundancy) ||
305 (news.interfaces !== undefined &&
306 interfaceKey(news.interfaces) !== previousInterfaces);
307
308 if (nameChanged) {
309 return { action: "replace" as const, deleteFirst: false };
310 }
311 if (immutableChanged) {
312 return { action: "replace" as const, deleteFirst: true };
313 }
314 return undefined;
315 }),
316
317 read: Effect.fn(function* ({ id, olds, output }) {
318 const env = yield* GcpEnvironment.current;
319 const externalVpnGatewayName = yield* toName(
320 id,
321 olds?.externalVpnGatewayName,
322 output?.externalVpnGatewayName,
323 );
324 const existing = yield* getByName(env.project, externalVpnGatewayName);
325 if (existing === undefined) return undefined;
326 const attrs = toAttrs(existing, env.project);
327 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
328 ? attrs
329 : Unowned(attrs);
330 }),
331
332 list: () =>
333 Effect.gen(function* () {
334 const env = yield* GcpEnvironment.current;
335 return yield* compute.listExternalVpnGateways
336 .items({
337 project: env.project,
338 filter: "labels.alchemy-id:*",
339 maxResults: 500,
340 })
341 .pipe(
342 Stream.filter((gateway) =>
343 Object.keys(gateway.labels ?? {}).some((key) =>
344 key.startsWith("alchemy-"),
345 ),
346 ),
347 Stream.map((gateway) => toAttrs(gateway, env.project)),
348 Stream.runCollect,
349 Effect.map((chunk) => Array.from(chunk)),
350 );
351 }),
352
353 reconcile: Effect.fn(function* ({ id, news, output }) {
354 const env = yield* GcpEnvironment.current;
355 const externalVpnGatewayName = yield* toName(
356 id,
357 news.externalVpnGatewayName,
358 output?.externalVpnGatewayName,
359 );
360 const desiredLabels = {
361 ...toLabels(news.labels),
362 ...(yield* createInternalLabels(id)),
363 };
364
365 let current = yield* getByName(env.project, externalVpnGatewayName);
366
367 if (current === undefined) {
368 const body: compute.ExternalVpnGateway = {
369 name: externalVpnGatewayName,
370 description: news.description,
371 redundancyType: redundancyOf(news.redundancyType, news.interfaces),
372 };
373 if (news.interfaces !== undefined && news.interfaces.length > 0) {
374 body.interfaces = news.interfaces.map((iface) => ({
375 id: iface.id,
376 ipAddress: iface.ipAddress,
377 ipv6Address: iface.ipv6Address,
378 }));
379 }
380 const created = yield* compute
381 .insertExternalVpnGateways({
382 project: env.project,
383 body,
384 })
385 .pipe(
386 Effect.flatMap((operation) =>
387 waitGlobalOperation(env.project, operation, {
388 ignore: ["RESOURCE_ALREADY_EXISTS"],
389 }).pipe(
390 Effect.flatMap(() =>
391 requireGateway(env.project, externalVpnGatewayName),
392 ),
393 ),
394 ),
395 Effect.catchTag("Conflict", () =>
396 getByName(env.project, externalVpnGatewayName),
397 ),
398 );
399 current = created ?? undefined;
400 }
401
402 if (current === undefined) {
403 return yield* new ExternalVpnGatewayNotResolved({
404 externalVpnGatewayName,
405 });
406 }
407
408 const resolved = current;
409 const observedLabels = tagRecord(resolved.labels);
410 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
411 if (upsert.length > 0 || removed.length > 0) {
412 yield* Effect.gen(function* () {
413 const latest =
414 (yield* getByName(env.project, externalVpnGatewayName)) ?? resolved;
415 yield* compute
416 .setLabelsExternalVpnGateways({
417 project: env.project,
418 resource: externalVpnGatewayName,
419 body: {
420 labels: desiredLabels,
421 labelFingerprint: latest.labelFingerprint,
422 },
423 })
424 .pipe(
425 Effect.flatMap((operation) =>
426 waitGlobalOperation(env.project, operation),
427 ),
428 );
429 }).pipe(
430 Effect.retry({
431 while: (e) => e._tag === "Conflict",
432 times: 5,
433 schedule: Schedule.spaced("1 second"),
434 }),
435 );
436 current =
437 (yield* getByName(env.project, externalVpnGatewayName)) ?? resolved;
438 }
439
440 return toAttrs(current ?? resolved, env.project);
441 }),
442
443 delete: Effect.fn(function* ({ output }) {
444 const env = yield* GcpEnvironment.current;
445 const project = output.project || env.project;
446 if (!output.externalVpnGatewayName) return;
447 yield* compute
448 .deleteExternalVpnGateways({
449 project,
450 externalVpnGateway: output.externalVpnGatewayName,
451 })
452 .pipe(
453 Effect.flatMap((operation) =>
454 waitGlobalOperation(project, operation, {
455 ignore: ["RESOURCE_NOT_FOUND"],
456 }),
457 ),
458 Effect.catchTag("NotFound", () => Effect.void),
459 Effect.retry({
460 while: (e) => e._tag === "Conflict",
461 times: 8,
462 schedule: Schedule.spaced("2 seconds"),
463 }),
464 );
465 yield* waitUntilGone(project, output.externalVpnGatewayName);
466 }),
467 });