1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 encodeDescription,
4 hasOwnershipMarker,
5 lastSegment,
6 parseDescription,
7 runGlobalOp,
8 toPhysicalName,
9} from "./internal.ts";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import { Unowned } from "../../AdoptPolicy.ts";
15import { isResolved } from "../../Diff.ts";
16import * as Provider from "../../Provider.ts";
17import { Resource } from "../../Resource.ts";
18import { tagRecord } from "../../Tags.ts";
19import { GcpEnvironment } from "../Environment.ts";
20import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22
23const DEFAULT_NETWORK_ENDPOINT_TYPE = "INTERNET_FQDN_PORT";
24
25export type GlobalNetworkEndpointType =
26 | "INTERNET_FQDN_PORT"
27 | "INTERNET_IP_PORT"
28 | (string & {});
29
30export type GlobalNetworkEndpointSpec = {
31 /** Fully qualified domain name. Required for `INTERNET_FQDN_PORT`. */
32 fqdn?: string;
33 /** IPv4 address. Required for `INTERNET_IP_PORT`. */
34 ipAddress?: string;
35 /** IPv6 address. */
36 ipv6Address?: string;
37 /** Port. Omitted endpoints use the group's `defaultPort`. */
38 port?: number;
39};
40
41export type GlobalNetworkEndpointGroupProps = {
42 /**
43 * NEG name (RFC1035, 1–63 characters). If omitted, a unique name is
44 * generated from the stack, stage, and logical id. Immutable —
45 * changing it replaces the group.
46 */
47 networkEndpointGroupName?: string;
48 /**
49 * Optional description. Global internet NEGs have no labels field and
50 * no update API, so Alchemy ownership (`alchemy-stack` /
51 * `alchemy-stage` / `alchemy-id`) is stored in a `[alchemy …]` prefix
52 * for `list` / nuke. Immutable — changing the user-facing description
53 * replaces the group.
54 */
55 description?: string;
56 /**
57 * Endpoint type. Global NEGs are internet NEGs:
58 * `INTERNET_FQDN_PORT` or `INTERNET_IP_PORT`. Immutable — changing
59 * it replaces the group.
60 * @default "INTERNET_FQDN_PORT"
61 */
62 networkEndpointType?: GlobalNetworkEndpointType;
63 /**
64 * Default port used when an endpoint omits `port`. Immutable —
65 * changing it replaces the group.
66 */
67 defaultPort?: number;
68 /**
69 * User annotations. Immutable — changing them replaces the group.
70 */
71 annotations?: Record<string, string>;
72 /**
73 * Member endpoints. A global internet NEG accepts at most one
74 * endpoint. When omitted, membership is left as-is. When set
75 * (including `[]`), observed endpoints are attached/detached to match.
76 */
77 networkEndpoints?: GlobalNetworkEndpointSpec[];
78};
79
80export type GlobalNetworkEndpointGroup = Resource<
81 "GCP.Compute.GlobalNetworkEndpointGroup",
82 GlobalNetworkEndpointGroupProps,
83 {
84 /** NEG name. */
85 networkEndpointGroupName: string;
86 /** Project id. */
87 project: string;
88 /** Endpoint type. */
89 networkEndpointType: string | undefined;
90 /** User description with the Alchemy ownership prefix stripped. */
91 description: string | undefined;
92 /** Default port, if any. */
93 defaultPort: number | undefined;
94 /** User annotations. */
95 annotations: Record<string, string>;
96 /** Number of endpoints in the group. */
97 size: number | undefined;
98 /** Server-assigned numeric id. */
99 networkEndpointGroupId: string | undefined;
100 /** Resource self-link. */
101 selfLink: string | undefined;
102 /** RFC3339 creation timestamp. */
103 creationTimestamp: string | undefined;
104 /** Resource kind. */
105 kind: string | undefined;
106 },
107 never,
108 Providers
109>;
110
111/**
112 * A global Compute Engine internet network endpoint group.
113 *
114 * Global NEGs back internet (`INTERNET_FQDN_PORT` / `INTERNET_IP_PORT`)
115 * backends for global external Application Load Balancers. They live in
116 * the `global/networkEndpointGroups` collection (zonal VM NEGs and
117 * regional serverless/PSC NEGs are separate resources). The collection
118 * has no labels field and no update API — Alchemy stamps ownership into
119 * the description so `list` / nuke can find leaked groups. Name, type,
120 * default port, annotations, and description are immutable (changing any
121 * of them replaces the group). Endpoints attach and detach in place.
122 *
123 * ### Creating a GlobalNetworkEndpointGroup
124 * **Example:** FQDN internet NEG
125 * ```typescript
126 * const neg = yield* GCP.Compute.GlobalNetworkEndpointGroup("Internet", {
127 * networkEndpointType: "INTERNET_FQDN_PORT",
128 * defaultPort: 443,
129 * });
130 * ```
131 *
132 * **Example:** Explicit name and a single FQDN endpoint
133 * ```typescript
134 * const neg = yield* GCP.Compute.GlobalNetworkEndpointGroup("Internet", {
135 * networkEndpointGroupName: "www-neg",
136 * networkEndpointType: "INTERNET_FQDN_PORT",
137 * defaultPort: 443,
138 * networkEndpoints: [{ fqdn: "www.example.com", port: 443 }],
139 * });
140 * ```
141 *
142 * ### IP internet NEGs
143 * **Example:** Global internet IP:port NEG
144 * ```typescript
145 * const neg = yield* GCP.Compute.GlobalNetworkEndpointGroup("IpNeg", {
146 * networkEndpointType: "INTERNET_IP_PORT",
147 * defaultPort: 443,
148 * networkEndpoints: [{ ipAddress: "203.0.113.10", port: 443 }],
149 * });
150 * ```
151 *
152 * @resource
153 * @category Compute
154 */
155export const GlobalNetworkEndpointGroup = Resource<GlobalNetworkEndpointGroup>(
156 "GCP.Compute.GlobalNetworkEndpointGroup",
157);
158
159export class GlobalNetworkEndpointGroupNotResolved extends Data.TaggedError(
160 "GCP.Compute.GlobalNetworkEndpointGroupNotResolved",
161)<{
162 networkEndpointGroupName: string;
163}> {}
164
165export class GlobalNetworkEndpointGroupStillExists extends Data.TaggedError(
166 "GCP.Compute.GlobalNetworkEndpointGroupStillExists",
167)<{
168 networkEndpointGroupName: string;
169}> {}
170
171const annotationsOf = (
172 annotations: Record<string, string | undefined> | null | undefined,
173): Record<string, string> => tagRecord(annotations);
174
175const sameAnnotations = (
176 left: Record<string, string> | undefined,
177 right: Record<string, string> | undefined,
178) => {
179 const a = annotationsOf(left);
180 const b = annotationsOf(right);
181 const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
182 for (const key of keys) {
183 if (a[key] !== b[key]) return false;
184 }
185 return true;
186};
187
188const asType = (value: string | undefined) =>
189 value && value.length > 0 ? value : DEFAULT_NETWORK_ENDPOINT_TYPE;
190
191const toAttrs = (
192 group: compute.NetworkEndpointGroup,
193 project: string,
194): GlobalNetworkEndpointGroup["Attributes"] => {
195 const parsed = parseDescription(group.description);
196 return {
197 networkEndpointGroupName: group.name ?? lastSegment(group.selfLink),
198 project,
199 networkEndpointType: group.networkEndpointType,
200 description: parsed.description,
201 defaultPort: group.defaultPort,
202 annotations: annotationsOf(group.annotations),
203 size: group.size,
204 networkEndpointGroupId: group.id,
205 selfLink: group.selfLink,
206 creationTimestamp: group.creationTimestamp,
207 kind: group.kind,
208 };
209};
210
211const toBody = (
212 networkEndpointGroupName: string,
213 props: GlobalNetworkEndpointGroupProps,
214 ownership: Record<string, string>,
215): compute.NetworkEndpointGroup => ({
216 name: networkEndpointGroupName,
217 description: encodeDescription(ownership, props.description),
218 networkEndpointType: asType(props.networkEndpointType),
219 defaultPort: props.defaultPort,
220 annotations:
221 props.annotations !== undefined && Object.keys(props.annotations).length > 0
222 ? props.annotations
223 : undefined,
224});
225
226const toApiEndpoint = (
227 endpoint: GlobalNetworkEndpointSpec,
228): compute.NetworkEndpoint => ({
229 fqdn: endpoint.fqdn,
230 ipAddress: endpoint.ipAddress,
231 ipv6Address: endpoint.ipv6Address,
232 port: endpoint.port,
233});
234
235const endpointKey = (endpoint: {
236 fqdn?: string;
237 ipAddress?: string;
238 ipv6Address?: string;
239 port?: number;
240}) =>
241 [
242 endpoint.fqdn ?? "",
243 endpoint.ipAddress ?? "",
244 endpoint.ipv6Address ?? "",
245 endpoint.port ?? "",
246 ].join("|");
247
248const getByName = (project: string, networkEndpointGroup: string) =>
249 compute
250 .getGlobalNetworkEndpointGroups({ project, networkEndpointGroup })
251 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
252
253const awaitResource = (project: string, networkEndpointGroupName: string) =>
254 getByName(project, networkEndpointGroupName).pipe(
255 Effect.flatMap((group) =>
256 group !== undefined
257 ? Effect.succeed(group)
258 : Effect.fail(
259 new GlobalNetworkEndpointGroupNotResolved({
260 networkEndpointGroupName,
261 }),
262 ),
263 ),
264 Effect.retry({
265 while: (error) =>
266 error._tag === "GCP.Compute.GlobalNetworkEndpointGroupNotResolved",
267 times: 8,
268 schedule: Schedule.spaced("1 second"),
269 }),
270 );
271
272const waitUntilGone = (project: string, networkEndpointGroupName: string) =>
273 getByName(project, networkEndpointGroupName).pipe(
274 Effect.flatMap((group) =>
275 group === undefined
276 ? Effect.void
277 : Effect.fail(
278 new GlobalNetworkEndpointGroupStillExists({
279 networkEndpointGroupName,
280 }),
281 ),
282 ),
283 Effect.retry({
284 while: (error) =>
285 error._tag === "GCP.Compute.GlobalNetworkEndpointGroupStillExists",
286 times: 10,
287 schedule: Schedule.spaced("1 second"),
288 }),
289 Effect.catchTag(
290 "GCP.Compute.GlobalNetworkEndpointGroupStillExists",
291 () => Effect.void,
292 ),
293 );
294
295const listEndpoints = (project: string, networkEndpointGroup: string) =>
296 compute.listNetworkEndpointsGlobalNetworkEndpointGroups
297 .items({
298 project,
299 networkEndpointGroup,
300 maxResults: 500,
301 })
302 .pipe(
303 Stream.runCollect,
304 Effect.map((chunk) =>
305 Array.from(chunk)
306 .map((item) => item.networkEndpoint)
307 .filter(
308 (endpoint): endpoint is compute.NetworkEndpoint =>
309 endpoint !== undefined,
310 ),
311 ),
312 Effect.catchTag(["NotFound", "BadRequest"], () =>
313 Effect.succeed([] as compute.NetworkEndpoint[]),
314 ),
315 );
316
317const immutableChanged = (
318 news: GlobalNetworkEndpointGroupProps,
319 olds: Partial<GlobalNetworkEndpointGroupProps> | undefined,
320 output: GlobalNetworkEndpointGroup["Attributes"] | undefined,
321) => {
322 const previousType = asType(
323 olds?.networkEndpointType ?? output?.networkEndpointType,
324 );
325 if (asType(news.networkEndpointType) !== previousType) return true;
326
327 const previousDescription = olds?.description ?? output?.description ?? "";
328 if ((news.description ?? "") !== previousDescription) return true;
329
330 if (
331 news.defaultPort !== undefined &&
332 news.defaultPort !== (olds?.defaultPort ?? output?.defaultPort)
333 ) {
334 return true;
335 }
336
337 if (
338 news.annotations !== undefined &&
339 !sameAnnotations(news.annotations, olds?.annotations ?? output?.annotations)
340 ) {
341 return true;
342 }
343 return false;
344};
345
346export const GlobalNetworkEndpointGroupProvider = () =>
347 Provider.succeed(GlobalNetworkEndpointGroup, {
348 stables: [
349 "networkEndpointGroupName",
350 "project",
351 "networkEndpointType",
352 "networkEndpointGroupId",
353 "selfLink",
354 "creationTimestamp",
355 ],
356
357 diff: Effect.fn(function* ({ news, olds, output }) {
358 if (!isResolved(news)) return undefined;
359 const previousName =
360 olds.networkEndpointGroupName ?? output?.networkEndpointGroupName;
361 const nextName = news.networkEndpointGroupName ?? previousName;
362 const nameChanged =
363 previousName !== undefined &&
364 nextName !== undefined &&
365 nextName !== previousName;
366 if (nameChanged) {
367 return { action: "replace" as const, deleteFirst: false };
368 }
369 if (immutableChanged(news, olds, output)) {
370 return { action: "replace" as const, deleteFirst: true };
371 }
372 return undefined;
373 }),
374
375 read: Effect.fn(function* ({ id, olds, output }) {
376 const env = yield* GcpEnvironment.current;
377 const networkEndpointGroupName = yield* toPhysicalName(
378 id,
379 olds?.networkEndpointGroupName,
380 output?.networkEndpointGroupName,
381 "neg",
382 );
383 const existing = yield* getByName(env.project, networkEndpointGroupName);
384 if (existing === undefined) return undefined;
385 const attrs = toAttrs(existing, env.project);
386 const { labels } = parseDescription(existing.description);
387 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
388 }),
389
390 list: () =>
391 Effect.gen(function* () {
392 const env = yield* GcpEnvironment.current;
393 return yield* compute.listGlobalNetworkEndpointGroups
394 .items({
395 project: env.project,
396 maxResults: 500,
397 returnPartialSuccess: true,
398 })
399 .pipe(
400 Stream.filter((item) => hasOwnershipMarker(item.description)),
401 Stream.map((item) => toAttrs(item, env.project)),
402 Stream.runCollect,
403 Effect.map((chunk) => Array.from(chunk)),
404 Effect.catchTag("NotFound", () =>
405 Effect.succeed([] as GlobalNetworkEndpointGroup["Attributes"][]),
406 ),
407 );
408 }),
409
410 reconcile: Effect.fn(function* ({ id, news, output }) {
411 const env = yield* GcpEnvironment.current;
412 const networkEndpointGroupName = yield* toPhysicalName(
413 id,
414 news.networkEndpointGroupName,
415 output?.networkEndpointGroupName,
416 "neg",
417 );
418 const ownership = yield* createInternalLabels(id);
419 const desired = toBody(networkEndpointGroupName, news, ownership);
420
421 let current = yield* getByName(env.project, networkEndpointGroupName);
422
423 if (current === undefined) {
424 yield* runGlobalOp(
425 env.project,
426 compute.insertGlobalNetworkEndpointGroups({
427 project: env.project,
428 body: desired,
429 }),
430 { ignoreAlreadyExists: true },
431 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
432 current = yield* awaitResource(env.project, networkEndpointGroupName);
433 }
434
435 if (current === undefined) {
436 return yield* new GlobalNetworkEndpointGroupNotResolved({
437 networkEndpointGroupName,
438 });
439 }
440
441 if (news.networkEndpoints !== undefined) {
442 const observed = yield* listEndpoints(
443 env.project,
444 networkEndpointGroupName,
445 );
446 const wanted = news.networkEndpoints.map(toApiEndpoint);
447 const observedKeys = new Set(observed.map(endpointKey));
448 const wantedKeys = new Set(wanted.map(endpointKey));
449 const toAdd = wanted.filter(
450 (endpoint) => !observedKeys.has(endpointKey(endpoint)),
451 );
452 const toRemove = observed.filter(
453 (endpoint) => !wantedKeys.has(endpointKey(endpoint)),
454 );
455 if (toAdd.length > 0) {
456 yield* runGlobalOp(
457 env.project,
458 compute.attachNetworkEndpointsGlobalNetworkEndpointGroups({
459 project: env.project,
460 networkEndpointGroup: networkEndpointGroupName,
461 body: { networkEndpoints: toAdd },
462 }),
463 { ignoreAlreadyExists: true },
464 ).pipe(Effect.catchTag(["Conflict", "NotFound"], () => Effect.void));
465 }
466 if (toRemove.length > 0) {
467 yield* runGlobalOp(
468 env.project,
469 compute.detachNetworkEndpointsGlobalNetworkEndpointGroups({
470 project: env.project,
471 networkEndpointGroup: networkEndpointGroupName,
472 body: { networkEndpoints: toRemove },
473 }),
474 { ignoreNotFound: true },
475 ).pipe(Effect.catchTag(["Conflict", "NotFound"], () => Effect.void));
476 }
477 current =
478 (yield* getByName(env.project, networkEndpointGroupName)) ?? current;
479 }
480
481 return toAttrs(current, env.project);
482 }),
483
484 delete: Effect.fn(function* ({ output }) {
485 const env = yield* GcpEnvironment.current;
486 const project = output.project || env.project;
487 if (!output.networkEndpointGroupName) return;
488 yield* runGlobalOp(
489 project,
490 compute.deleteGlobalNetworkEndpointGroups({
491 project,
492 networkEndpointGroup: output.networkEndpointGroupName,
493 }),
494 { ignoreNotFound: true },
495 ).pipe(
496 Effect.catchTag(["NotFound", "Conflict"], () =>
497 Effect.succeed(undefined),
498 ),
499 );
500 yield* waitUntilGone(project, output.networkEndpointGroupName);
501 }),
502 });