1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } 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 { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_PRIORITY = 1000;
21const DEFAULT_INTERNET_GATEWAY = "default-internet-gateway";
22
23export type RouteProps = {
24 /**
25 * Route name (RFC1035, 1-63 chars). If omitted, a unique name is generated
26 * from the stack, stage, and logical id. Immutable — changing it replaces
27 * the route.
28 */
29 routeName?: string;
30 /**
31 * Destination range of outgoing packets this route applies to (IPv4 or
32 * IPv6 CIDR). Immutable — changing it replaces the route.
33 */
34 destRange: string;
35 /**
36 * VPC network this route belongs to. Accepts a name (`default`), a
37 * partial URL (`global/networks/default`), or a full resource URL.
38 * Immutable — changing it replaces the route.
39 */
40 network: string;
41 /**
42 * Optional description. Compute routes have no labels field, so Alchemy
43 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
44 * in a `[alchemy …]` prefix for `list` / nuke. Immutable — changing the
45 * user-facing description replaces the route.
46 */
47 description?: string;
48 /**
49 * Priority used to break ties among equal-prefix matches. Lower wins.
50 * Range `0`–`65535`.
51 * @default 1000
52 */
53 priority?: number;
54 /**
55 * Instance tags this route applies to. Empty means every instance.
56 * Immutable — changing it replaces the route.
57 */
58 tags?: string[];
59 /**
60 * Next hop gateway URL or `default-internet-gateway`. Exactly one next
61 * hop must be set. Immutable.
62 */
63 nextHopGateway?: string;
64 /**
65 * Next hop IP address (IPv4 or IPv6). Exactly one next hop must be set.
66 * Immutable.
67 */
68 nextHopIp?: string;
69 /**
70 * Next hop instance URL. Exactly one next hop must be set. Immutable.
71 */
72 nextHopInstance?: string;
73 /**
74 * Next hop VPN tunnel URL. Exactly one next hop must be set. Immutable.
75 */
76 nextHopVpnTunnel?: string;
77 /**
78 * Next hop internal load balancer forwarding rule URL or IP. Exactly one
79 * next hop must be set. Immutable.
80 */
81 nextHopIlb?: string;
82};
83
84export type Route = Resource<
85 "GCP.Compute.Route",
86 RouteProps,
87 {
88 /** Route name. */
89 routeName: string;
90 /** Project id. */
91 project: string;
92 /** Destination CIDR. */
93 destRange: string;
94 /** Network URL. */
95 network: string;
96 /** User description with the Alchemy ownership prefix stripped. */
97 description: string | undefined;
98 /** Route priority. */
99 priority: number;
100 /** Instance tags this route applies to. */
101 tags: string[];
102 /** Next hop gateway URL, if set. */
103 nextHopGateway: string | undefined;
104 /** Next hop IP, if set. */
105 nextHopIp: string | undefined;
106 /** Next hop instance URL, if set. */
107 nextHopInstance: string | undefined;
108 /** Next hop VPN tunnel URL, if set. */
109 nextHopVpnTunnel: string | undefined;
110 /** Next hop internal load balancer, if set. */
111 nextHopIlb: string | undefined;
112 /** Next hop network URL (output-only, typically subnet routes). */
113 nextHopNetwork: string | undefined;
114 /** Server-defined URL. */
115 selfLink: string | undefined;
116 /** Server-assigned numeric id. */
117 id: string | undefined;
118 /** RFC3339 creation timestamp. */
119 creationTimestamp: string | undefined;
120 /** Route type (`STATIC`, `SUBNET`, `BGP`, `TRANSIT`). */
121 routeType: string | undefined;
122 /** Route status. */
123 routeStatus: string | undefined;
124 },
125 never,
126 Providers
127>;
128
129/**
130 * A VPC static route.
131 *
132 * Routes tell instances where to send packets whose destination matches
133 * `destRange`. Compute Engine has no update API for routes — every
134 * user-facing field is immutable and changing it replaces the resource.
135 * Ownership is stamped into the description because routes have no labels.
136 *
137 * ### Creating a Route
138 * **Example:** Default internet route on the default network
139 * ```typescript
140 * const route = yield* GCP.Compute.Route("internet", {
141 * destRange: "0.0.0.0/0",
142 * network: "default",
143 * nextHopGateway: "default-internet-gateway",
144 * });
145 * ```
146 *
147 * **Example:** Named route with priority and tags
148 * ```typescript
149 * const route = yield* GCP.Compute.Route("tagged", {
150 * routeName: "app-egress",
151 * destRange: "192.0.2.0/24",
152 * network: "default",
153 * nextHopGateway: "default-internet-gateway",
154 * priority: 100,
155 * tags: ["egress"],
156 * description: "test-net egress",
157 * });
158 * ```
159 *
160 * **Example:** Next hop IP
161 * ```typescript
162 * const route = yield* GCP.Compute.Route("appliance", {
163 * destRange: "10.200.0.0/16",
164 * network: "default",
165 * nextHopIp: "10.128.0.5",
166 * });
167 * ```
168 *
169 * @resource
170 * @category Compute
171 */
172export const Route = Resource<Route>("GCP.Compute.Route");
173
174export class RouteNotResolved extends Data.TaggedError(
175 "GCP.Compute.RouteNotResolved",
176)<{
177 routeName: string;
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: 63,
187 lowercase: true,
188 });
189 const rfc = generated.replace(/^[^a-z]+/, "r").replace(/-+$/g, "");
190 return rfc.slice(0, 63);
191 });
192
193const encodeDescription = (
194 labels: Record<string, string>,
195 description: string | undefined,
196): string => {
197 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
198 return description ? `${marker}\n${description}` : marker;
199};
200
201const parseDescription = (
202 description: string | undefined,
203): {
204 labels: Record<string, string>;
205 description: string | undefined;
206} => {
207 if (!description?.startsWith("[alchemy ")) {
208 return { labels: {}, description };
209 }
210 const end = description.indexOf("]");
211 if (end < 0) return { labels: {}, description };
212 const labels: Record<string, string> = {};
213 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
214 const eq = part.indexOf("=");
215 if (eq > 0) {
216 labels[part.slice(0, eq)] = part.slice(eq + 1);
217 }
218 }
219 const rest = description.slice(end + 1).replace(/^\n/, "");
220 return { labels, description: rest.length > 0 ? rest : undefined };
221};
222
223const lastToken = (value: string | undefined) => {
224 if (!value) return "";
225 const parts = value.split("/").filter((part) => part.length > 0);
226 return parts[parts.length - 1] ?? "";
227};
228
229const sameRef = (left: string | undefined, right: string | undefined) =>
230 lastToken(left) === lastToken(right);
231
232const sameTags = (left: string[] | undefined, right: string[] | undefined) =>
233 [...(left ?? [])].sort().join("\0") === [...(right ?? [])].sort().join("\0");
234
235const networkUrl = (project: string, network: string) => {
236 if (network.includes("/")) {
237 return network.startsWith("projects/") || network.startsWith("http")
238 ? network
239 : `projects/${project}/${network.replace(/^\//, "")}`;
240 }
241 return `projects/${project}/global/networks/${network}`;
242};
243
244const gatewayUrl = (project: string, gateway: string) => {
245 if (gateway.includes("/")) return gateway;
246 if (gateway === DEFAULT_INTERNET_GATEWAY) {
247 return `projects/${project}/global/gateways/${DEFAULT_INTERNET_GATEWAY}`;
248 }
249 return gateway;
250};
251
252const hopOf = (props: {
253 nextHopGateway?: string;
254 nextHopIp?: string;
255 nextHopInstance?: string;
256 nextHopVpnTunnel?: string;
257 nextHopIlb?: string;
258}) => {
259 if (props.nextHopGateway)
260 return { kind: "gateway" as const, value: lastToken(props.nextHopGateway) };
261 if (props.nextHopIp) return { kind: "ip" as const, value: props.nextHopIp };
262 if (props.nextHopInstance)
263 return {
264 kind: "instance" as const,
265 value: lastToken(props.nextHopInstance),
266 };
267 if (props.nextHopVpnTunnel)
268 return {
269 kind: "vpn" as const,
270 value: lastToken(props.nextHopVpnTunnel),
271 };
272 if (props.nextHopIlb)
273 return { kind: "ilb" as const, value: lastToken(props.nextHopIlb) };
274 return undefined;
275};
276
277const toBody = (
278 project: string,
279 routeName: string,
280 props: RouteProps,
281 ownership: Record<string, string>,
282): compute.Route => ({
283 name: routeName,
284 destRange: props.destRange,
285 network: networkUrl(project, props.network),
286 description: encodeDescription(ownership, props.description),
287 priority: props.priority ?? DEFAULT_PRIORITY,
288 tags: props.tags,
289 nextHopGateway: props.nextHopGateway
290 ? gatewayUrl(project, props.nextHopGateway)
291 : undefined,
292 nextHopIp: props.nextHopIp,
293 nextHopInstance: props.nextHopInstance,
294 nextHopVpnTunnel: props.nextHopVpnTunnel,
295 nextHopIlb: props.nextHopIlb,
296});
297
298const toAttrs = (route: compute.Route, project: string) => {
299 const parsed = parseDescription(route.description);
300 return {
301 routeName: route.name ?? "",
302 project,
303 destRange: route.destRange ?? "",
304 network: route.network ?? "",
305 description: parsed.description,
306 priority: route.priority ?? DEFAULT_PRIORITY,
307 tags: route.tags ?? [],
308 nextHopGateway: route.nextHopGateway,
309 nextHopIp: route.nextHopIp,
310 nextHopInstance: route.nextHopInstance,
311 nextHopVpnTunnel: route.nextHopVpnTunnel,
312 nextHopIlb: route.nextHopIlb,
313 nextHopNetwork: route.nextHopNetwork,
314 selfLink: route.selfLink,
315 id: route.id,
316 creationTimestamp: route.creationTimestamp,
317 routeType: route.routeType,
318 routeStatus: route.routeStatus,
319 };
320};
321
322const getByName = (project: string, route: string) =>
323 compute
324 .getRoutes({ project, route })
325 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
326
327const requireRoute = (project: string, routeName: string) =>
328 getByName(project, routeName).pipe(
329 Effect.flatMap((route) =>
330 route
331 ? Effect.succeed(route)
332 : Effect.fail(new RouteNotResolved({ routeName })),
333 ),
334 Effect.retry({
335 while: (error) => error._tag === "GCP.Compute.RouteNotResolved",
336 schedule: Schedule.spaced("1 second"),
337 times: 8,
338 }),
339 );
340
341const removeRoute = (project: string, routeName: string) =>
342 Effect.gen(function* () {
343 const operation = yield* compute
344 .deleteRoutes({
345 project,
346 route: routeName,
347 })
348 .pipe(
349 Effect.retry({
350 while: (error) => error._tag === "Conflict",
351 times: 8,
352 schedule: Schedule.spaced("1 second"),
353 }),
354 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
355 );
356 if (operation !== undefined) {
357 yield* waitGlobalOperation(project, operation, {
358 ignore: ["RESOURCE_NOT_FOUND"],
359 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
360 }
361 });
362
363const matchesDesired = (route: compute.Route, news: RouteProps) => {
364 if ((route.destRange ?? "") !== news.destRange) return false;
365 if (!sameRef(route.network, news.network)) return false;
366 if (
367 (route.priority ?? DEFAULT_PRIORITY) !== (news.priority ?? DEFAULT_PRIORITY)
368 ) {
369 return false;
370 }
371 const parsed = parseDescription(route.description);
372 if ((parsed.description ?? "") !== (news.description ?? "")) return false;
373 if (!sameTags(route.tags, news.tags)) return false;
374 const previousHop = hopOf({
375 nextHopGateway: route.nextHopGateway,
376 nextHopIp: route.nextHopIp,
377 nextHopInstance: route.nextHopInstance,
378 nextHopVpnTunnel: route.nextHopVpnTunnel,
379 nextHopIlb: route.nextHopIlb,
380 });
381 const nextHop = hopOf(news);
382 if (nextHop === undefined) return previousHop === undefined;
383 return (
384 previousHop !== undefined &&
385 previousHop.kind === nextHop.kind &&
386 previousHop.value === nextHop.value
387 );
388};
389
390const immutableChanged = (
391 news: RouteProps,
392 olds: Partial<RouteProps> | undefined,
393 output: Route["Attributes"] | undefined,
394) => {
395 const previousDest = olds?.destRange ?? output?.destRange;
396 if (previousDest !== undefined && news.destRange !== previousDest) {
397 return true;
398 }
399 const previousNetwork = olds?.network ?? output?.network;
400 if (
401 previousNetwork !== undefined &&
402 !sameRef(news.network, previousNetwork)
403 ) {
404 return true;
405 }
406 const previousPriority =
407 olds?.priority ?? output?.priority ?? DEFAULT_PRIORITY;
408 if ((news.priority ?? DEFAULT_PRIORITY) !== previousPriority) {
409 return true;
410 }
411 const previousDescription = olds?.description ?? output?.description ?? "";
412 if ((news.description ?? "") !== previousDescription) {
413 return true;
414 }
415 if (!sameTags(news.tags, olds?.tags ?? output?.tags)) {
416 return true;
417 }
418 const previousHop = hopOf(olds ?? {}) ?? hopOf(output ?? {});
419 const nextHop = hopOf(news);
420 if (
421 nextHop !== undefined &&
422 (previousHop === undefined ||
423 previousHop.kind !== nextHop.kind ||
424 previousHop.value !== nextHop.value)
425 ) {
426 return true;
427 }
428 return false;
429};
430
431export const RouteProvider = () =>
432 Provider.succeed(Route, {
433 stables: [
434 "routeName",
435 "project",
436 "id",
437 "selfLink",
438 "creationTimestamp",
439 "network",
440 "destRange",
441 ],
442
443 diff: Effect.fn(function* ({ news, olds, output }) {
444 if (!isResolved(news)) return undefined;
445 const previousName = olds?.routeName ?? output?.routeName;
446 const nextName = news.routeName ?? previousName;
447 if (previousName === undefined && output === undefined) {
448 return undefined;
449 }
450 const nameChanged =
451 news.routeName !== undefined &&
452 previousName !== undefined &&
453 news.routeName !== previousName;
454 if (nameChanged || immutableChanged(news, olds, output)) {
455 return {
456 action: "replace" as const,
457 deleteFirst: nextName !== undefined && nextName === previousName,
458 };
459 }
460 return undefined;
461 }),
462
463 read: Effect.fn(function* ({ id, olds, output }) {
464 const env = yield* GcpEnvironment.current;
465 const routeName = yield* toName(id, olds?.routeName, output?.routeName);
466 const existing = yield* getByName(env.project, routeName);
467 if (existing === undefined) return undefined;
468 const attrs = toAttrs(existing, env.project);
469 const { labels } = parseDescription(existing.description);
470 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
471 }),
472
473 list: () =>
474 Effect.gen(function* () {
475 const env = yield* GcpEnvironment.current;
476 return yield* compute.listRoutes.items({ project: env.project }).pipe(
477 Stream.filter((route) => {
478 const { labels } = parseDescription(route.description);
479 return Object.keys(labels).some((key) =>
480 key.startsWith("alchemy-"),
481 );
482 }),
483 Stream.map((route) => toAttrs(route, env.project)),
484 Stream.runCollect,
485 Effect.map((chunk) => Array.from(chunk)),
486 );
487 }),
488
489 reconcile: Effect.fn(function* ({ id, news, output }) {
490 const env = yield* GcpEnvironment.current;
491 const routeName = yield* toName(id, news.routeName, output?.routeName);
492 const ownership = yield* createInternalLabels(id);
493 const desired = toBody(env.project, routeName, news, ownership);
494
495 let current = yield* getByName(env.project, routeName);
496
497 if (current !== undefined && !matchesDesired(current, news)) {
498 yield* removeRoute(env.project, routeName);
499 current = undefined;
500 }
501
502 if (current === undefined) {
503 yield* compute
504 .insertRoutes({
505 project: env.project,
506 body: desired,
507 })
508 .pipe(
509 Effect.flatMap((operation) =>
510 waitGlobalOperation(env.project, operation, {
511 ignore: ["RESOURCE_ALREADY_EXISTS"],
512 }),
513 ),
514 Effect.catchTag("Conflict", () => Effect.void),
515 );
516 current = yield* requireRoute(env.project, routeName);
517 }
518
519 return toAttrs(current, env.project);
520 }),
521
522 delete: Effect.fn(function* ({ output }) {
523 if (!output.routeName) return;
524 const env = yield* GcpEnvironment.current;
525 yield* removeRoute(env.project, output.routeName);
526 }),
527 });