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 { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
14import type { Providers } from "../Providers.ts";
15
16export type NetworkProps = {
17 /**
18 * Network name. If omitted, a unique RFC1035 name is generated from the
19 * stack, stage, and logical id. Must be 1-63 characters and match
20 * `[a-z]([-a-z0-9]*[a-z0-9])?`.
21 */
22 networkName?: string;
23 /**
24 * User description. Immutable — changing it replaces the network.
25 * Alchemy ownership labels are stamped into the description (VPC networks
26 * have no `labels` field) so `list` / nuke can find the resource.
27 */
28 description?: string;
29 /**
30 * When `true`, GCP creates a subnet in every region (auto mode). When
31 * `false`, the VPC is custom mode and you add subnets yourself. Immutable
32 * — changing it replaces the network.
33 * @default false
34 */
35 autoCreateSubnetworks?: boolean;
36 /**
37 * Maximum transmission unit in bytes (1300–8896).
38 * @default 1460
39 */
40 mtu?: number;
41 /**
42 * Network-wide Cloud Router advertisement mode.
43 * @default "REGIONAL"
44 */
45 routingMode?: compute.NetworkRoutingConfigRoutingModeEnum;
46 /**
47 * BGP best-path selection algorithm (`LEGACY` or `STANDARD`).
48 */
49 bgpBestPathSelectionMode?: compute.NetworkRoutingConfigBgpBestPathSelectionModeEnum;
50 /**
51 * Compare MED across routes with different neighbor ASNs (STANDARD BGP).
52 */
53 bgpAlwaysCompareMed?: boolean;
54 /**
55 * Inter-region cost handling for STANDARD BGP (`DEFAULT` or
56 * `ADD_COST_TO_MED`).
57 */
58 bgpInterRegionCost?: compute.NetworkRoutingConfigBgpInterRegionCostEnum;
59 /**
60 * When to enforce hierarchical firewall policies relative to classic
61 * VPC firewall rules.
62 * @default "AFTER_CLASSIC_FIREWALL"
63 */
64 networkFirewallPolicyEnforcementOrder?: compute.NetworkNetworkFirewallPolicyEnforcementOrderEnum;
65 /**
66 * Enable ULA internal IPv6 (`fd20::/20`). Immutable — changing it
67 * replaces the network.
68 * @default false
69 */
70 enableUlaInternalIpv6?: boolean;
71 /**
72 * Optional `/48` from `fd20::/20` used when ULA IPv6 is enabled.
73 * Immutable — changing it replaces the network.
74 */
75 internalIpv6Range?: string;
76 /**
77 * Full or partial URL of a network profile. Set only at create time.
78 * Immutable — changing it replaces the network.
79 */
80 networkProfile?: string;
81};
82
83export type Network = Resource<
84 "GCP.Compute.Network",
85 NetworkProps,
86 {
87 /** RFC1035 network name. */
88 networkName: string;
89 /** Server-assigned numeric id. */
90 networkId: string | undefined;
91 /** Project id. */
92 project: string;
93 /** Server-defined resource URL. */
94 selfLink: string | undefined;
95 /** Server-defined URL including the numeric id. */
96 selfLinkWithId: string | undefined;
97 /** RFC3339 creation timestamp. */
98 creationTimestamp: string | undefined;
99 /** Whether auto-mode subnets were created. */
100 autoCreateSubnetworks: boolean;
101 /** User description (Alchemy ownership marker stripped). */
102 description: string | undefined;
103 /** Configured MTU in bytes. */
104 mtu: number | undefined;
105 /** Network-wide routing mode. */
106 routingMode: string | undefined;
107 /** BGP best-path selection mode. */
108 bgpBestPathSelectionMode: string | undefined;
109 /** Whether MED is compared across neighbor ASNs. */
110 bgpAlwaysCompareMed: boolean | undefined;
111 /** Inter-region cost handling for STANDARD BGP. */
112 bgpInterRegionCost: string | undefined;
113 /** Hierarchical firewall policy enforcement order. */
114 networkFirewallPolicyEnforcementOrder: string | undefined;
115 /** Whether ULA internal IPv6 is enabled. */
116 enableUlaInternalIpv6: boolean;
117 /** Allocated ULA `/48`, if any. */
118 internalIpv6Range: string | undefined;
119 /** Network profile URL, if any. */
120 networkProfile: string | undefined;
121 /** Gateway address selected by Google Cloud. */
122 gatewayIPv4: string | undefined;
123 /** Associated firewall policy URL, if any. */
124 firewallPolicy: string | undefined;
125 /** Subnetwork self-links. */
126 subnetworks: ReadonlyArray<string> | undefined;
127 },
128 never,
129 Providers
130>;
131
132/**
133 * A Google Compute Engine VPC network.
134 *
135 * VPC networks have no `labels` field. Alchemy stamps
136 * `alchemy-stack` / `alchemy-stage` / `alchemy-id` into the description so
137 * `list` and `pnpm nuke:gcp` can still identify owned networks.
138 *
139 * ### Creating a Network
140 * **Example:** Custom-mode VPC (generated name)
141 * ```typescript
142 * const network = yield* GCP.Compute.Network("Vpc", {});
143 * ```
144 *
145 * **Example:** Explicit name, MTU, and routing mode
146 * ```typescript
147 * const network = yield* GCP.Compute.Network("Vpc", {
148 * networkName: "app-vpc",
149 * description: "application vpc",
150 * autoCreateSubnetworks: false,
151 * mtu: 1500,
152 * routingMode: "GLOBAL",
153 * });
154 * ```
155 *
156 * ### Auto-mode VPC
157 * **Example:** Auto-create a subnet in every region
158 * ```typescript
159 * const network = yield* GCP.Compute.Network("Vpc", {
160 * autoCreateSubnetworks: true,
161 * });
162 * ```
163 *
164 * Auto mode is slower to create and delete (one subnet per region). Prefer
165 * custom mode (`autoCreateSubnetworks: false`, the default) unless you
166 * specifically want the pre-created ranges.
167 *
168 * @resource
169 * @category Compute
170 */
171export const Network = Resource<Network>("GCP.Compute.Network");
172
173export class NetworkNotResolved extends Data.TaggedError(
174 "GCP.Compute.NetworkNotResolved",
175)<{
176 networkName: string;
177}> {}
178
179const DEFAULT_AUTO_CREATE = false;
180const DEFAULT_MTU = 1460;
181const DEFAULT_ROUTING_MODE = "REGIONAL";
182const DEFAULT_ENFORCEMENT = "AFTER_CLASSIC_FIREWALL";
183
184const OWNERSHIP_KEYS = [
185 "alchemy-stack",
186 "alchemy-stage",
187 "alchemy-id",
188] as const;
189
190const encodeDescription = (
191 internal: Record<string, string>,
192 user?: string,
193): string => {
194 const marker = OWNERSHIP_KEYS.map(
195 (key) => `${key}=${internal[key] ?? ""}`,
196 ).join(" ");
197 return user && user.length > 0 ? `${marker}\n${user}` : marker;
198};
199
200const parseDescription = (description: string | undefined) => {
201 if (!description) {
202 return { labels: {} as Record<string, string>, description: undefined };
203 }
204 const newline = description.indexOf("\n");
205 const first = newline === -1 ? description : description.slice(0, newline);
206 const rest = newline === -1 ? undefined : description.slice(newline + 1);
207 if (!first.includes("alchemy-id=") || !first.includes("alchemy-stack=")) {
208 return { labels: {} as Record<string, string>, description };
209 }
210 const labels: Record<string, string> = {};
211 for (const part of first.split(/\s+/)) {
212 const eq = part.indexOf("=");
213 if (eq > 0) {
214 labels[part.slice(0, eq)] = part.slice(eq + 1);
215 }
216 }
217 return {
218 labels,
219 description: rest && rest.length > 0 ? rest : undefined,
220 };
221};
222
223const hasAlchemyMarker = (description: string | undefined) =>
224 Object.keys(parseDescription(description).labels).some((key) =>
225 key.startsWith("alchemy-"),
226 );
227
228const toNetworkName = (
229 id: string,
230 name: string | undefined,
231 existing?: string,
232) =>
233 Effect.gen(function* () {
234 if (name !== undefined) return name;
235 if (existing !== undefined) return existing;
236 const generated = yield* createPhysicalName({
237 id,
238 maxLength: 63,
239 lowercase: true,
240 });
241 const rfc = generated
242 .replace(/^[^a-z]+/, "n")
243 .slice(0, 63)
244 .replace(/-+$/g, "");
245 return rfc.length > 0 ? rfc : "n";
246 });
247
248const toAttrs = (
249 network: compute.Network,
250 project: string,
251): Network["Attributes"] => {
252 const parsed = parseDescription(network.description);
253 return {
254 networkName: network.name ?? "",
255 networkId: network.id,
256 project,
257 selfLink: network.selfLink,
258 selfLinkWithId: network.selfLinkWithId,
259 creationTimestamp: network.creationTimestamp,
260 autoCreateSubnetworks: network.autoCreateSubnetworks === true,
261 description: parsed.description,
262 mtu: network.mtu,
263 routingMode: network.routingConfig?.routingMode,
264 bgpBestPathSelectionMode: network.routingConfig?.bgpBestPathSelectionMode,
265 bgpAlwaysCompareMed: network.routingConfig?.bgpAlwaysCompareMed,
266 bgpInterRegionCost: network.routingConfig?.bgpInterRegionCost,
267 networkFirewallPolicyEnforcementOrder:
268 network.networkFirewallPolicyEnforcementOrder,
269 enableUlaInternalIpv6: network.enableUlaInternalIpv6 === true,
270 internalIpv6Range: network.internalIpv6Range,
271 networkProfile: network.networkProfile,
272 gatewayIPv4: network.gatewayIPv4,
273 firewallPolicy: network.firewallPolicy,
274 subnetworks: network.subnetworks,
275 };
276};
277
278const getByName = (project: string, networkName: string) =>
279 compute
280 .getNetworks({ project, network: networkName })
281 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
282
283const waitInsertedNetwork = (
284 project: string,
285 networkName: string,
286 operation: compute.Operation | undefined,
287) =>
288 Effect.gen(function* () {
289 if (operation === undefined) return;
290 yield* waitGlobalOperation(project, operation, {
291 ignore: ["RESOURCE_NOT_FOUND"],
292 }).pipe(
293 Effect.catchTag("GCP.OperationFailed", (error) =>
294 getByName(project, networkName).pipe(
295 Effect.flatMap((network) =>
296 network !== undefined ? Effect.void : Effect.fail(error),
297 ),
298 ),
299 ),
300 );
301 });
302
303const requireNetwork = (project: string, networkName: string) =>
304 getByName(project, networkName).pipe(
305 Effect.flatMap((network) =>
306 network
307 ? Effect.succeed(network)
308 : Effect.fail(new NetworkNotResolved({ networkName })),
309 ),
310 Effect.retry({
311 while: (e) => e._tag === "GCP.Compute.NetworkNotResolved",
312 schedule: Schedule.spaced("1 second"),
313 times: 8,
314 }),
315 );
316
317export const NetworkProvider = () =>
318 Provider.succeed(Network, {
319 stables: [
320 "networkName",
321 "networkId",
322 "project",
323 "selfLink",
324 "selfLinkWithId",
325 "creationTimestamp",
326 "autoCreateSubnetworks",
327 "enableUlaInternalIpv6",
328 "internalIpv6Range",
329 "networkProfile",
330 "gatewayIPv4",
331 "description",
332 ],
333
334 diff: Effect.fn(function* ({ news, olds, output }) {
335 if (!isResolved(news)) return undefined;
336 const previousName = olds?.networkName ?? output?.networkName;
337 const nextName = news.networkName ?? previousName;
338 const replace =
339 (previousName !== undefined &&
340 news.networkName !== undefined &&
341 news.networkName !== previousName) ||
342 (olds?.autoCreateSubnetworks ??
343 output?.autoCreateSubnetworks ??
344 DEFAULT_AUTO_CREATE) !==
345 (news.autoCreateSubnetworks ?? DEFAULT_AUTO_CREATE) ||
346 (olds?.enableUlaInternalIpv6 ??
347 output?.enableUlaInternalIpv6 ??
348 false) !== (news.enableUlaInternalIpv6 ?? false) ||
349 (news.internalIpv6Range !== undefined &&
350 (olds?.internalIpv6Range ?? output?.internalIpv6Range) !==
351 news.internalIpv6Range) ||
352 (news.networkProfile !== undefined &&
353 (olds?.networkProfile ?? output?.networkProfile) !==
354 news.networkProfile) ||
355 (news.description !== undefined &&
356 (olds?.description ?? output?.description ?? "") !==
357 news.description);
358 if (!replace) return undefined;
359 return {
360 action: "replace" as const,
361 deleteFirst:
362 nextName !== undefined &&
363 previousName !== undefined &&
364 nextName === previousName,
365 };
366 }),
367
368 read: Effect.fn(function* ({ id, olds, output }) {
369 const env = yield* GcpEnvironment.current;
370 const networkName = yield* toNetworkName(
371 id,
372 olds?.networkName,
373 output?.networkName,
374 );
375 const existing = yield* getByName(env.project, networkName);
376 if (existing === undefined) return undefined;
377 const attrs = toAttrs(existing, env.project);
378 const parsed = parseDescription(existing.description);
379 return (yield* hasAlchemyLabels(id, parsed.labels))
380 ? attrs
381 : Unowned(attrs);
382 }),
383
384 list: () =>
385 Effect.gen(function* () {
386 const env = yield* GcpEnvironment.current;
387 return yield* compute.listNetworks
388 .items({ project: env.project, maxResults: 500 })
389 .pipe(
390 Stream.filter((network) => hasAlchemyMarker(network.description)),
391 Stream.map((network) => toAttrs(network, env.project)),
392 Stream.runCollect,
393 Effect.map((chunk) => Array.from(chunk)),
394 );
395 }),
396
397 reconcile: Effect.fn(function* ({ id, news, output }) {
398 const env = yield* GcpEnvironment.current;
399 const networkName = yield* toNetworkName(
400 id,
401 news.networkName,
402 output?.networkName,
403 );
404 const autoCreateSubnetworks =
405 news.autoCreateSubnetworks ?? DEFAULT_AUTO_CREATE;
406 const mtu = news.mtu ?? DEFAULT_MTU;
407 const routingMode = news.routingMode ?? DEFAULT_ROUTING_MODE;
408 const enforcement =
409 news.networkFirewallPolicyEnforcementOrder ?? DEFAULT_ENFORCEMENT;
410 const enableUlaInternalIpv6 = news.enableUlaInternalIpv6 === true;
411 const internal = yield* createInternalLabels(id);
412 const desiredDescription = encodeDescription(internal, news.description);
413
414 let current = yield* getByName(env.project, networkName);
415
416 if (current === undefined) {
417 const routingConfig: compute.NetworkRoutingConfig = {
418 routingMode,
419 };
420 if (news.bgpBestPathSelectionMode !== undefined) {
421 routingConfig.bgpBestPathSelectionMode =
422 news.bgpBestPathSelectionMode;
423 }
424 if (news.bgpAlwaysCompareMed !== undefined) {
425 routingConfig.bgpAlwaysCompareMed = news.bgpAlwaysCompareMed;
426 }
427 if (news.bgpInterRegionCost !== undefined) {
428 routingConfig.bgpInterRegionCost = news.bgpInterRegionCost;
429 }
430 const body: compute.Network = {
431 name: networkName,
432 autoCreateSubnetworks,
433 description: desiredDescription,
434 mtu,
435 routingConfig,
436 networkFirewallPolicyEnforcementOrder: enforcement,
437 };
438 if (enableUlaInternalIpv6) {
439 body.enableUlaInternalIpv6 = true;
440 }
441 if (news.internalIpv6Range !== undefined) {
442 body.internalIpv6Range = news.internalIpv6Range;
443 }
444 if (news.networkProfile !== undefined) {
445 body.networkProfile = news.networkProfile;
446 }
447 yield* compute
448 .insertNetworks({
449 project: env.project,
450 body,
451 })
452 .pipe(
453 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
454 Effect.flatMap((inserted) =>
455 waitInsertedNetwork(env.project, networkName, inserted),
456 ),
457 Effect.retry({
458 while: (error) =>
459 error._tag === "NotFound" ||
460 error._tag === "GCP.OperationFailed",
461 times: 6,
462 schedule: Schedule.spaced("5 seconds"),
463 }),
464 );
465 current = yield* requireNetwork(env.project, networkName);
466 }
467
468 if (current === undefined) {
469 return yield* new NetworkNotResolved({ networkName });
470 }
471
472 const applyPatch = (body: compute.Network) =>
473 Effect.gen(function* () {
474 const patched = yield* compute.patchNetworks({
475 project: env.project,
476 network: networkName,
477 body,
478 });
479 yield* waitGlobalOperation(env.project, patched);
480 return yield* requireNetwork(env.project, networkName);
481 });
482
483 // GCP rejects PATCH bodies that combine an MTU change with any other
484 // field ("Other fields cannot be modified when changing MTU").
485 if ((current.mtu ?? DEFAULT_MTU) !== mtu) {
486 current = yield* applyPatch({ mtu });
487 }
488
489 const patchBody: compute.Network = {};
490 if (
491 (current.networkFirewallPolicyEnforcementOrder ??
492 DEFAULT_ENFORCEMENT) !== enforcement
493 ) {
494 patchBody.networkFirewallPolicyEnforcementOrder = enforcement;
495 }
496 const routingPatch: compute.NetworkRoutingConfig = {};
497 if (
498 (current.routingConfig?.routingMode ?? DEFAULT_ROUTING_MODE) !==
499 routingMode
500 ) {
501 routingPatch.routingMode = routingMode;
502 }
503 if (
504 news.bgpBestPathSelectionMode !== undefined &&
505 current.routingConfig?.bgpBestPathSelectionMode !==
506 news.bgpBestPathSelectionMode
507 ) {
508 routingPatch.bgpBestPathSelectionMode = news.bgpBestPathSelectionMode;
509 }
510 if (
511 news.bgpAlwaysCompareMed !== undefined &&
512 current.routingConfig?.bgpAlwaysCompareMed !== news.bgpAlwaysCompareMed
513 ) {
514 routingPatch.bgpAlwaysCompareMed = news.bgpAlwaysCompareMed;
515 }
516 if (
517 news.bgpInterRegionCost !== undefined &&
518 current.routingConfig?.bgpInterRegionCost !== news.bgpInterRegionCost
519 ) {
520 routingPatch.bgpInterRegionCost = news.bgpInterRegionCost;
521 }
522 if (Object.keys(routingPatch).length > 0) {
523 patchBody.routingConfig = routingPatch;
524 }
525
526 if (Object.keys(patchBody).length > 0) {
527 current = yield* applyPatch(patchBody);
528 }
529
530 return toAttrs(current, env.project);
531 }),
532
533 delete: Effect.fn(function* ({ output }) {
534 const env = yield* GcpEnvironment.current;
535 if (!output.networkName) return;
536 yield* compute
537 .deleteNetworks({
538 project: env.project,
539 network: output.networkName,
540 })
541 .pipe(
542 Effect.flatMap((operation) =>
543 waitGlobalOperation(env.project, operation, {
544 ignore: ["RESOURCE_NOT_FOUND"],
545 }),
546 ),
547 Effect.catchTag("NotFound", () => Effect.void),
548 Effect.retry({
549 while: (error) =>
550 error._tag === "Conflict" ||
551 (error._tag === "GCP.OperationFailed" &&
552 (error.reason === "RESOURCE_IN_USE_BY_ANOTHER_RESOURCE" ||
553 error.reason === "RESOURCE_NOT_READY")),
554 times: 20,
555 schedule: Schedule.spaced("3 seconds"),
556 }),
557 );
558 }),
559 });