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