1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } 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
20export type NetworkEndpointType =
21 | "GCE_VM_IP"
22 | "GCE_VM_IP_PORT"
23 | "GCE_VM_IP_PORTMAP"
24 | "INTERNET_FQDN_PORT"
25 | "INTERNET_IP_PORT"
26 | "NON_GCP_PRIVATE_IP_PORT"
27 | "PRIVATE_SERVICE_CONNECT"
28 | "SERVERLESS";
29
30export type NetworkEndpointSpec = {
31 /** VM instance name or URL. Required for `GCE_VM_IP` / `GCE_VM_IP_PORT`. */
32 instance?: string;
33 /** IPv4 address of the endpoint. */
34 ipAddress?: string;
35 /** IPv6 address of the endpoint. */
36 ipv6Address?: string;
37 /** Port. Omitted endpoints use the group's `defaultPort`. */
38 port?: number;
39 /** FQDN. Only valid for FQDN internet endpoints. */
40 fqdn?: string;
41 /** PSC consumer destination port (`GCE_VM_IP_PORTMAP` only). */
42 clientDestinationPort?: number;
43};
44
45export type NetworkEndpointGroupProps = {
46 /**
47 * Name of the network endpoint group. Must be 1–63 characters and comply
48 * with RFC1035. If omitted, a unique name is generated from the stack,
49 * stage, and logical id. Changing this replaces the group.
50 */
51 networkEndpointGroupName?: string;
52 /**
53 * Zone of the group (e.g. `"us-central1-a"`). Immutable — changing it
54 * replaces the group. This resource maps to the zonal
55 * `networkEndpointGroups` collection (regional/global NEGs are separate).
56 * @default "us-central1-a"
57 */
58 zone?: string;
59 /**
60 * Optional description. Zonal NEGs have no labels API, so Alchemy stamps
61 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) into this
62 * field for `list` / nuke. Changing the user-facing description replaces
63 * the group.
64 */
65 description?: string;
66 /**
67 * VPC network URL or name (`default`, `global/networks/default`, or
68 * `projects/{project}/global/networks/{name}`). Immutable — changing it
69 * replaces the group.
70 * @default "default"
71 */
72 network?: string;
73 /**
74 * Subnetwork URL or name. Immutable — changing it replaces the group.
75 * Optional for auto-mode networks such as `default`.
76 */
77 subnetwork?: string;
78 /**
79 * Type of endpoints in this group. Immutable — changing it replaces the
80 * group.
81 * @default "GCE_VM_IP_PORT"
82 */
83 networkEndpointType?: NetworkEndpointType;
84 /**
85 * Default port used when an endpoint omits `port`. Must not be set for
86 * `GCE_VM_IP`. Immutable — changing it replaces the group.
87 */
88 defaultPort?: number;
89 /**
90 * Member endpoints. When omitted, membership is left as-is. When set
91 * (including `[]`), observed endpoints are attached/detached to match.
92 */
93 networkEndpoints?: NetworkEndpointSpec[];
94};
95
96export type NetworkEndpointGroup = Resource<
97 "GCP.Compute.NetworkEndpointGroup",
98 NetworkEndpointGroupProps,
99 {
100 /** Network endpoint group name. */
101 networkEndpointGroupName: string;
102 /** Zone (short name, e.g. `"us-central1-a"`). */
103 zone: string;
104 /** Project id. */
105 project: string;
106 /** User-facing description (ownership marker stripped). */
107 description: string | undefined;
108 /** Network URL, if set. */
109 network: string | undefined;
110 /** Subnetwork URL, if set. */
111 subnetwork: string | undefined;
112 /** Endpoint type. */
113 networkEndpointType: NetworkEndpointType;
114 /** Default port, if set. */
115 defaultPort: number | undefined;
116 /** Number of member endpoints. */
117 size: number;
118 /** Server-generated resource URL. */
119 selfLink: string | undefined;
120 /** Server-generated numeric id. */
121 id: string | undefined;
122 /** RFC3339 creation timestamp. */
123 creationTimestamp: string | undefined;
124 /** Resource kind. */
125 kind: string | undefined;
126 },
127 never,
128 Providers
129>;
130
131/**
132 * A zonal Compute Engine network endpoint group (NEG).
133 *
134 * Zonal NEGs hold VM IP/port endpoints (or hybrid `NON_GCP_PRIVATE_IP_PORT`
135 * endpoints) for load balancing. They have no labels API — Alchemy records
136 * ownership in the description so `list` / `pnpm nuke:gcp` can find them.
137 * Serverless, PSC, and internet NEGs use the regional/global collections
138 * and are not this resource.
139 *
140 * ### Creating a Network Endpoint Group
141 * **Example:** Generated name
142 * ```typescript
143 * const neg = yield* GCP.Compute.NetworkEndpointGroup("web", {
144 * defaultPort: 80,
145 * });
146 * ```
147 *
148 * **Example:** Explicit name, zone, and default port
149 * ```typescript
150 * const neg = yield* GCP.Compute.NetworkEndpointGroup("web", {
151 * networkEndpointGroupName: "web-neg",
152 * zone: "us-central1-a",
153 * network: "default",
154 * defaultPort: 80,
155 * description: "HTTP backends",
156 * });
157 * ```
158 *
159 * ### Endpoints
160 * **Example:** Attach VM endpoints
161 * ```typescript
162 * const neg = yield* GCP.Compute.NetworkEndpointGroup("web", {
163 * zone: "us-central1-a",
164 * defaultPort: 80,
165 * networkEndpoints: [
166 * { instance: vm.instanceName, port: 80 },
167 * ],
168 * });
169 * ```
170 *
171 * @resource
172 * @category Compute
173 */
174export const NetworkEndpointGroup = Resource<NetworkEndpointGroup>(
175 "GCP.Compute.NetworkEndpointGroup",
176);
177
178export class NetworkEndpointGroupNotResolved extends Data.TaggedError(
179 "GCP.Compute.NetworkEndpointGroupNotResolved",
180)<{
181 networkEndpointGroupName: string;
182 zone: string;
183}> {}
184
185export class NetworkEndpointGroupStillExists extends Data.TaggedError(
186 "GCP.Compute.NetworkEndpointGroupStillExists",
187)<{
188 networkEndpointGroupName: string;
189 zone: string;
190}> {}
191
192const DEFAULT_ZONE = "us-central1-a";
193const DEFAULT_NETWORK = "default";
194const DEFAULT_TYPE: NetworkEndpointType = "GCE_VM_IP_PORT";
195
196const lastSegment = (value: string) => {
197 const parts = value.split("/").filter((part) => part.length > 0);
198 return parts[parts.length - 1] ?? value;
199};
200
201const rfc1035Name = (name: string) => {
202 let next = name
203 .toLowerCase()
204 .replace(/[^a-z0-9-]/g, "-")
205 .replace(/-+/g, "-")
206 .replace(/^-+|-+$/g, "");
207 if (!/^[a-z]/.test(next)) {
208 next = `n${next}`;
209 }
210 next = next.slice(0, 63).replace(/-+$/g, "");
211 return next.length > 0 ? next : "neg";
212};
213
214const toName = (id: string, name: string | undefined, existing?: string) =>
215 Effect.gen(function* () {
216 return rfc1035Name(
217 name ??
218 existing ??
219 (yield* createPhysicalName({
220 id,
221 maxLength: 63,
222 lowercase: true,
223 })),
224 );
225 });
226
227const encodeDescription = (
228 user: string | undefined,
229 labels: Record<string, string>,
230) => {
231 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
232 const trimmed = user?.trim();
233 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
234};
235
236const parseDescription = (description: string | undefined) => {
237 if (!description?.startsWith("[alchemy ")) {
238 return { user: description, labels: {} as Record<string, string> };
239 }
240 const end = description.indexOf("]");
241 if (end < 0) {
242 return { user: description, labels: {} as Record<string, string> };
243 }
244 const labels: Record<string, string> = {};
245 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
246 const eq = part.indexOf("=");
247 if (eq > 0) {
248 labels[part.slice(0, eq)] = part.slice(eq + 1);
249 }
250 }
251 const rest = description
252 .slice(end + 1)
253 .replace(/^\n/, "")
254 .trim();
255 return {
256 user: rest.length > 0 ? rest : undefined,
257 labels,
258 };
259};
260
261const hasOwnershipMarker = (description: string | undefined) =>
262 Object.keys(parseDescription(description).labels).some((key) =>
263 key.startsWith("alchemy-"),
264 );
265
266const zoneToRegion = (zone: string) => {
267 const idx = zone.lastIndexOf("-");
268 return idx > 0 ? zone.slice(0, idx) : zone;
269};
270
271const toNetworkUrl = (project: string, network: string | undefined) => {
272 if (network === undefined || network.length === 0) {
273 return `projects/${project}/global/networks/${DEFAULT_NETWORK}`;
274 }
275 if (network.includes("/")) return network;
276 return `projects/${project}/global/networks/${network}`;
277};
278
279const toSubnetworkUrl = (
280 project: string,
281 zone: string,
282 subnetwork: string | undefined,
283) => {
284 if (subnetwork === undefined || subnetwork.length === 0) return undefined;
285 if (subnetwork.includes("/")) return subnetwork;
286 return `projects/${project}/regions/${zoneToRegion(zone)}/subnetworks/${subnetwork}`;
287};
288
289const toInstanceUrl = (project: string, zone: string, instance: string) =>
290 instance.includes("/")
291 ? instance
292 : `projects/${project}/zones/${zone}/instances/${instance}`;
293
294const asType = (value: string | undefined): NetworkEndpointType => {
295 switch (value) {
296 case "GCE_VM_IP":
297 case "GCE_VM_IP_PORT":
298 case "GCE_VM_IP_PORTMAP":
299 case "INTERNET_FQDN_PORT":
300 case "INTERNET_IP_PORT":
301 case "NON_GCP_PRIVATE_IP_PORT":
302 case "PRIVATE_SERVICE_CONNECT":
303 case "SERVERLESS":
304 return value;
305 default:
306 return DEFAULT_TYPE;
307 }
308};
309
310const toAttrs = (
311 group: compute.NetworkEndpointGroup,
312 project: string,
313): NetworkEndpointGroup["Attributes"] => {
314 const { user } = parseDescription(group.description);
315 return {
316 networkEndpointGroupName: group.name ?? lastSegment(group.selfLink ?? ""),
317 zone: lastSegment(group.zone ?? DEFAULT_ZONE),
318 project,
319 description: user,
320 network: group.network,
321 subnetwork: group.subnetwork,
322 networkEndpointType: asType(group.networkEndpointType),
323 defaultPort: group.defaultPort,
324 size: group.size ?? 0,
325 selfLink: group.selfLink,
326 id: group.id,
327 creationTimestamp: group.creationTimestamp,
328 kind: group.kind,
329 };
330};
331
332const getByName = (
333 project: string,
334 zone: string,
335 networkEndpointGroup: string,
336) =>
337 compute
338 .getNetworkEndpointGroups({ project, zone, networkEndpointGroup })
339 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
340
341const toApiEndpoint = (
342 project: string,
343 zone: string,
344 endpoint: NetworkEndpointSpec,
345): compute.NetworkEndpoint => ({
346 instance:
347 endpoint.instance !== undefined
348 ? toInstanceUrl(project, zone, endpoint.instance)
349 : undefined,
350 ipAddress: endpoint.ipAddress,
351 ipv6Address: endpoint.ipv6Address,
352 port: endpoint.port,
353 fqdn: endpoint.fqdn,
354 clientDestinationPort: endpoint.clientDestinationPort,
355});
356
357const endpointKey = (endpoint: {
358 instance?: string;
359 ipAddress?: string;
360 ipv6Address?: string;
361 port?: number;
362 fqdn?: string;
363 clientDestinationPort?: number;
364}) =>
365 [
366 endpoint.instance ? lastSegment(endpoint.instance) : "",
367 endpoint.ipAddress ?? "",
368 endpoint.ipv6Address ?? "",
369 endpoint.port ?? "",
370 endpoint.fqdn ?? "",
371 endpoint.clientDestinationPort ?? "",
372 ].join("|");
373
374const listEndpoints = (
375 project: string,
376 zone: string,
377 networkEndpointGroup: string,
378) =>
379 compute.listNetworkEndpointsNetworkEndpointGroups
380 .items({
381 project,
382 zone,
383 networkEndpointGroup,
384 body: { healthStatus: "SKIP" },
385 })
386 .pipe(
387 Stream.runCollect,
388 Effect.map((chunk) =>
389 Array.from(chunk)
390 .map((item) => item.networkEndpoint)
391 .filter(
392 (endpoint): endpoint is compute.NetworkEndpoint =>
393 endpoint !== undefined,
394 ),
395 ),
396 Effect.catchTag("NotFound", () =>
397 Effect.succeed([] as compute.NetworkEndpoint[]),
398 ),
399 );
400
401const waitGone = (
402 project: string,
403 zone: string,
404 networkEndpointGroupName: string,
405) =>
406 getByName(project, zone, networkEndpointGroupName).pipe(
407 Effect.flatMap((group) =>
408 group === undefined
409 ? Effect.void
410 : Effect.fail(
411 new NetworkEndpointGroupStillExists({
412 networkEndpointGroupName,
413 zone,
414 }),
415 ),
416 ),
417 Effect.retry({
418 while: (error) =>
419 error._tag === "GCP.Compute.NetworkEndpointGroupStillExists",
420 times: 8,
421 schedule: Schedule.spaced("1 second"),
422 }),
423 );
424
425const runOp = <E, R>(
426 project: string,
427 zone: string,
428 start: Effect.Effect<compute.Operation, E, R>,
429) =>
430 start.pipe(
431 Effect.flatMap((operation) =>
432 waitZoneOperation(project, zone, operation, {
433 ignore: ["RESOURCE_ALREADY_EXISTS"],
434 }),
435 ),
436 );
437
438export const NetworkEndpointGroupProvider = () =>
439 Provider.succeed(NetworkEndpointGroup, {
440 stables: [
441 "networkEndpointGroupName",
442 "zone",
443 "project",
444 "networkEndpointType",
445 "id",
446 "selfLink",
447 "creationTimestamp",
448 ],
449
450 diff: Effect.fn(function* ({ news, olds, output }) {
451 if (!isResolved(news)) return undefined;
452 const previousName =
453 olds?.networkEndpointGroupName ?? output?.networkEndpointGroupName;
454 const nextName = news.networkEndpointGroupName ?? previousName;
455 const previousZone = olds?.zone ?? output?.zone;
456 const nextZone = news.zone ?? DEFAULT_ZONE;
457 const previousNetwork = olds?.network ?? output?.network;
458 const nextNetwork = news.network;
459 const previousSubnetwork = olds?.subnetwork ?? output?.subnetwork;
460 const nextSubnetwork = news.subnetwork;
461 const previousType =
462 olds?.networkEndpointType ?? output?.networkEndpointType;
463 const nextType = news.networkEndpointType ?? previousType ?? DEFAULT_TYPE;
464 const previousPort = olds?.defaultPort ?? output?.defaultPort;
465 const nextPort = news.defaultPort;
466 const nameChanged =
467 previousName !== undefined &&
468 nextName !== undefined &&
469 previousName !== nextName;
470 const zoneChanged =
471 previousZone !== undefined &&
472 lastSegment(previousZone) !== lastSegment(nextZone);
473 const networkChanged =
474 nextNetwork !== undefined &&
475 previousNetwork !== undefined &&
476 lastSegment(previousNetwork) !== lastSegment(nextNetwork);
477 const subnetworkChanged =
478 nextSubnetwork !== undefined &&
479 previousSubnetwork !== undefined &&
480 lastSegment(previousSubnetwork) !== lastSegment(nextSubnetwork);
481 const typeChanged =
482 previousType !== undefined && previousType !== nextType;
483 const portChanged =
484 nextPort !== undefined &&
485 previousPort !== undefined &&
486 nextPort !== previousPort;
487 const descriptionChanged =
488 olds !== undefined &&
489 (olds.description ?? "") !== (news.description ?? "");
490 if (
491 !nameChanged &&
492 !zoneChanged &&
493 !networkChanged &&
494 !subnetworkChanged &&
495 !typeChanged &&
496 !portChanged &&
497 !descriptionChanged
498 ) {
499 return undefined;
500 }
501 const sameIdentity =
502 previousName === nextName &&
503 lastSegment(previousZone ?? "") === lastSegment(nextZone);
504 return {
505 action: "replace" as const,
506 deleteFirst: sameIdentity,
507 };
508 }),
509
510 read: Effect.fn(function* ({ id, olds, output }) {
511 const env = yield* GcpEnvironment.current;
512 const networkEndpointGroupName = yield* toName(
513 id,
514 olds?.networkEndpointGroupName,
515 output?.networkEndpointGroupName,
516 );
517 const zone = lastSegment(olds?.zone ?? output?.zone ?? DEFAULT_ZONE);
518 const existing = yield* getByName(
519 env.project,
520 zone,
521 networkEndpointGroupName,
522 );
523 if (existing === undefined) return undefined;
524 const attrs = toAttrs(existing, env.project);
525 const { labels } = parseDescription(existing.description);
526 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
527 }),
528
529 list: () =>
530 Effect.gen(function* () {
531 const env = yield* GcpEnvironment.current;
532 const pages = yield* compute.aggregatedListNetworkEndpointGroups
533 .pages({
534 project: env.project,
535 maxResults: 500,
536 returnPartialSuccess: true,
537 })
538 .pipe(Stream.runCollect);
539 return Array.from(pages).flatMap((page) =>
540 Object.values(page.items ?? {}).flatMap((scoped) =>
541 (scoped?.networkEndpointGroups ?? [])
542 .filter((group) => hasOwnershipMarker(group.description))
543 .map((group) => toAttrs(group, env.project)),
544 ),
545 );
546 }),
547
548 reconcile: Effect.fn(function* ({ id, news, output }) {
549 const env = yield* GcpEnvironment.current;
550 const networkEndpointGroupName = yield* toName(
551 id,
552 news.networkEndpointGroupName,
553 output?.networkEndpointGroupName,
554 );
555 const zone = lastSegment(news.zone ?? output?.zone ?? DEFAULT_ZONE);
556 const networkEndpointType = asType(
557 news.networkEndpointType ?? output?.networkEndpointType,
558 );
559 const network = toNetworkUrl(
560 env.project,
561 news.network ??
562 (output?.network !== undefined
563 ? lastSegment(output.network)
564 : DEFAULT_NETWORK),
565 );
566 const subnetwork = toSubnetworkUrl(
567 env.project,
568 zone,
569 news.subnetwork ??
570 (output?.subnetwork !== undefined
571 ? lastSegment(output.subnetwork)
572 : undefined),
573 );
574 const desiredLabels = yield* createInternalLabels(id);
575 const description = encodeDescription(news.description, desiredLabels);
576
577 let current = yield* getByName(
578 env.project,
579 zone,
580 networkEndpointGroupName,
581 );
582
583 if (current === undefined) {
584 yield* compute
585 .insertNetworkEndpointGroups({
586 project: env.project,
587 zone,
588 body: {
589 name: networkEndpointGroupName,
590 description,
591 network,
592 subnetwork,
593 networkEndpointType,
594 defaultPort: news.defaultPort ?? output?.defaultPort,
595 },
596 })
597 .pipe(
598 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
599 Effect.flatMap((operation) =>
600 operation === undefined
601 ? Effect.void
602 : waitZoneOperation(env.project, zone, operation, {
603 ignore: ["RESOURCE_ALREADY_EXISTS"],
604 }).pipe(Effect.asVoid),
605 ),
606 );
607 current = yield* getByName(
608 env.project,
609 zone,
610 networkEndpointGroupName,
611 ).pipe(
612 Effect.flatMap((group) =>
613 group === undefined
614 ? Effect.fail(
615 new NetworkEndpointGroupNotResolved({
616 networkEndpointGroupName,
617 zone,
618 }),
619 )
620 : Effect.succeed(group),
621 ),
622 Effect.retry({
623 while: (error) =>
624 error._tag === "GCP.Compute.NetworkEndpointGroupNotResolved",
625 times: 8,
626 schedule: Schedule.exponential("250 millis"),
627 }),
628 Effect.catchTag("GCP.Compute.NetworkEndpointGroupNotResolved", () =>
629 Effect.succeed(undefined),
630 ),
631 );
632 }
633
634 if (current === undefined) {
635 return yield* new NetworkEndpointGroupNotResolved({
636 networkEndpointGroupName,
637 zone,
638 });
639 }
640
641 if (news.networkEndpoints !== undefined) {
642 const observed = yield* listEndpoints(
643 env.project,
644 zone,
645 networkEndpointGroupName,
646 );
647 const desired = news.networkEndpoints.map((endpoint) =>
648 toApiEndpoint(env.project, zone, endpoint),
649 );
650 const observedKeys = new Set(observed.map(endpointKey));
651 const desiredKeys = new Set(desired.map(endpointKey));
652 const toAdd = desired.filter(
653 (endpoint) => !observedKeys.has(endpointKey(endpoint)),
654 );
655 const toRemove = observed.filter(
656 (endpoint) => !desiredKeys.has(endpointKey(endpoint)),
657 );
658 if (toAdd.length > 0) {
659 yield* runOp(
660 env.project,
661 zone,
662 compute.attachNetworkEndpointsNetworkEndpointGroups({
663 project: env.project,
664 zone,
665 networkEndpointGroup: networkEndpointGroupName,
666 body: { networkEndpoints: toAdd },
667 }),
668 ).pipe(Effect.catchTag("Conflict", () => Effect.void));
669 }
670 if (toRemove.length > 0) {
671 yield* runOp(
672 env.project,
673 zone,
674 compute.detachNetworkEndpointsNetworkEndpointGroups({
675 project: env.project,
676 zone,
677 networkEndpointGroup: networkEndpointGroupName,
678 body: { networkEndpoints: toRemove },
679 }),
680 ).pipe(Effect.catchTag(["NotFound", "Conflict"], () => Effect.void));
681 }
682 current =
683 (yield* getByName(env.project, zone, networkEndpointGroupName)) ??
684 current;
685 }
686
687 return toAttrs(current, env.project);
688 }),
689
690 delete: Effect.fn(function* ({ output }) {
691 yield* compute
692 .deleteNetworkEndpointGroups({
693 project: output.project,
694 zone: output.zone,
695 networkEndpointGroup: output.networkEndpointGroupName,
696 })
697 .pipe(
698 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
699 Effect.flatMap((operation) =>
700 operation === undefined
701 ? Effect.void
702 : waitZoneOperation(output.project, output.zone, operation, {
703 ignore: ["RESOURCE_NOT_FOUND"],
704 }).pipe(Effect.asVoid),
705 ),
706 Effect.catchTag("NotFound", () => Effect.void),
707 );
708 yield* waitGone(
709 output.project,
710 output.zone,
711 output.networkEndpointGroupName,
712 );
713 }),
714 });