1import { ignoredCodes } from "./internal.ts";
2import * as compute from "@distilled.cloud/gcp/compute_v1";
3import { waitGlobalOperation } from "./operations.ts";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 alchemyLabelKeys,
16 createInternalLabels,
17 hasAlchemyLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const MAX_NAME_LENGTH = 63;
22const DEFAULT_CAPABILITY = "NO_SLA";
23
24export type InterconnectGroupIntent = compute.InterconnectGroupIntent;
25export type InterconnectGroupInterconnectMap =
26 compute.InterconnectGroupInterconnectMap;
27
28export type InterconnectGroupProps = {
29 /**
30 * Group name (RFC1035, 1-63 characters). If omitted, a unique name is
31 * generated from the stack, stage, and logical id. Immutable — changing
32 * it replaces the group.
33 */
34 interconnectGroupName?: string;
35 /**
36 * Optional description. Interconnect groups have no labels field, so
37 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
38 * is stored in a `[alchemy …]` prefix for `list` / nuke. Updated in
39 * place via `patch`.
40 */
41 description?: string;
42 /**
43 * Intended topology capability (`NO_SLA`, `PRODUCTION_NON_CRITICAL`,
44 * `PRODUCTION_CRITICAL`). Required on create. Updated in place.
45 * @default "NO_SLA"
46 */
47 intent?: InterconnectGroupIntent;
48 /**
49 * Member interconnects. Keys are arbitrary; values are `{ interconnect }`
50 * URLs or names. Updated in place via `patch`.
51 */
52 interconnects?: InterconnectGroupInterconnectMap;
53};
54
55export type InterconnectGroup = Resource<
56 "GCP.Compute.InterconnectGroup",
57 InterconnectGroupProps,
58 {
59 /** Group name. */
60 interconnectGroupName: string;
61 /** Project id. */
62 project: string;
63 /** User description with the Alchemy ownership prefix stripped. */
64 description: string | undefined;
65 /** User intent. */
66 intent: InterconnectGroupIntent | undefined;
67 /** Member interconnects. */
68 interconnects: InterconnectGroupInterconnectMap;
69 /** Effective topology capability reported by GCP. */
70 configured: compute.InterconnectGroupConfigured | undefined;
71 /** Physical structure of member interconnects. */
72 physicalStructure: compute.InterconnectGroupPhysicalStructure | undefined;
73 /** Optimistic-locking etag. */
74 etag: string | undefined;
75 /** Server-assigned numeric id. */
76 interconnectGroupId: string | undefined;
77 /** Resource self-link. */
78 selfLink: string | undefined;
79 /** RFC3339 creation timestamp. */
80 creationTimestamp: string | undefined;
81 /** Resource kind. */
82 kind: string | undefined;
83 },
84 never,
85 Providers
86>;
87
88/**
89 * A global Compute Engine Interconnect group.
90 *
91 * Groups collect Dedicated Interconnects so GCP can report the topology
92 * capability (SLA) they actually provide. Name is immutable. Description,
93 * intent, and member interconnects update in place via
94 * `interconnectGroups.patch`. Compute InterconnectGroup has no labels
95 * field — Alchemy stamps ownership into the description so nuke can find
96 * leaked groups.
97 *
98 * ### Creating an Interconnect Group
99 * **Example:** Generated name, no SLA
100 * ```typescript
101 * const group = yield* GCP.Compute.InterconnectGroup("Bundle", {
102 * description: "dev interconnects",
103 * intent: { topologyCapability: "NO_SLA" },
104 * });
105 * ```
106 *
107 * **Example:** Named group with a member interconnect
108 * ```typescript
109 * const group = yield* GCP.Compute.InterconnectGroup("Bundle", {
110 * interconnectGroupName: "app-ix-group",
111 * intent: { topologyCapability: "PRODUCTION_NON_CRITICAL" },
112 * interconnects: {
113 * primary: { interconnect: interconnect.selfLink },
114 * },
115 * });
116 * ```
117 *
118 * @resource
119 * @category Compute
120 */
121export const InterconnectGroup = Resource<InterconnectGroup>(
122 "GCP.Compute.InterconnectGroup",
123);
124
125export class InterconnectGroupNotResolved extends Data.TaggedError(
126 "GCP.Compute.InterconnectGroupNotResolved",
127)<{
128 interconnectGroupName: string;
129}> {}
130
131export class InterconnectGroupStillExists extends Data.TaggedError(
132 "GCP.Compute.InterconnectGroupStillExists",
133)<{
134 interconnectGroupName: string;
135}> {}
136
137const lastSegment = (value: string | undefined): string => {
138 if (value === undefined || value.length === 0) return "";
139 const trimmed = value.replace(/\/+$/, "");
140 const parts = trimmed.split("/");
141 return parts[parts.length - 1] || trimmed;
142};
143
144const rfc1035 = (name: string): string => {
145 let next = name
146 .toLowerCase()
147 .replace(/[^a-z0-9-]/g, "-")
148 .replace(/-+/g, "-")
149 .replace(/^-+/, "")
150 .replace(/-+$/, "");
151 if (!/^[a-z]/.test(next)) next = `g${next}`;
152 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
153 return next.length > 0 ? next : "group";
154};
155
156const toName = (id: string, name: string | undefined, existing?: string) =>
157 Effect.gen(function* () {
158 if (name !== undefined) return name;
159 if (existing !== undefined) return existing;
160 return rfc1035(
161 yield* createPhysicalName({
162 id,
163 maxLength: MAX_NAME_LENGTH,
164 lowercase: true,
165 }),
166 );
167 });
168
169const encodeDescription = (
170 labels: Record<string, string>,
171 description: string | undefined,
172): string => {
173 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
174 return description ? `${marker}\n${description}` : marker;
175};
176
177const parseDescription = (
178 description: string | undefined,
179): {
180 labels: Record<string, string>;
181 description: string | undefined;
182} => {
183 if (!description?.startsWith("[alchemy ")) {
184 return { labels: {}, description };
185 }
186 const end = description.indexOf("]");
187 if (end < 0) return { labels: {}, description };
188 const labels: Record<string, string> = {};
189 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
190 const eq = part.indexOf("=");
191 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
192 }
193 const rest = description.slice(end + 1).replace(/^\n/, "");
194 return { labels, description: rest.length > 0 ? rest : undefined };
195};
196
197const hasOwnershipMarker = (description: string | undefined) =>
198 Object.keys(parseDescription(description).labels).some((key) =>
199 key.startsWith("alchemy-"),
200 );
201
202const capabilityOf = (intent: InterconnectGroupIntent | undefined) =>
203 (intent?.topologyCapability ?? DEFAULT_CAPABILITY).toUpperCase();
204
205const interconnectUrl = (project: string, value: string) => {
206 if (value.includes("/")) return value;
207 return `projects/${project}/global/interconnects/${value}`;
208};
209
210const membersKey = (
211 project: string,
212 members: InterconnectGroupInterconnectMap | undefined,
213) =>
214 Object.entries(members ?? {})
215 .map(([key, value]) => {
216 const url = value?.interconnect;
217 return `${key}:${url ? lastSegment(interconnectUrl(project, url)) : ""}`;
218 })
219 .sort()
220 .join("|");
221
222const toMembers = (
223 project: string,
224 members: InterconnectGroupInterconnectMap | undefined,
225): InterconnectGroupInterconnectMap | undefined => {
226 if (members === undefined) return undefined;
227 return Object.fromEntries(
228 Object.entries(members).map(([key, value]) => [
229 key,
230 {
231 interconnect: value?.interconnect
232 ? interconnectUrl(project, value.interconnect)
233 : value?.interconnect,
234 },
235 ]),
236 );
237};
238
239const toAttrs = (
240 group: compute.InterconnectGroup,
241 project: string,
242): InterconnectGroup["Attributes"] => {
243 const parsed = parseDescription(group.description);
244 return {
245 interconnectGroupName: group.name ?? "",
246 project,
247 description: parsed.description,
248 intent: group.intent,
249 interconnects: group.interconnects ?? {},
250 configured: group.configured,
251 physicalStructure: group.physicalStructure,
252 etag: group.etag,
253 interconnectGroupId: group.id,
254 selfLink: group.selfLink,
255 creationTimestamp: group.creationTimestamp,
256 kind: group.kind,
257 };
258};
259
260const getByName = (project: string, interconnectGroup: string) =>
261 compute
262 .getInterconnectGroups({ project, interconnectGroup })
263 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
264
265const awaitResource = (project: string, interconnectGroupName: string) =>
266 getByName(project, interconnectGroupName).pipe(
267 Effect.flatMap((group) =>
268 group !== undefined
269 ? Effect.succeed(group)
270 : Effect.fail(
271 new InterconnectGroupNotResolved({ interconnectGroupName }),
272 ),
273 ),
274 Effect.retry({
275 while: (error) =>
276 error._tag === "GCP.Compute.InterconnectGroupNotResolved",
277 times: 8,
278 schedule: Schedule.spaced("1 second"),
279 }),
280 );
281
282const waitUntilGone = (project: string, interconnectGroupName: string) =>
283 getByName(project, interconnectGroupName).pipe(
284 Effect.flatMap((group) =>
285 group === undefined
286 ? Effect.void
287 : Effect.fail(
288 new InterconnectGroupStillExists({ interconnectGroupName }),
289 ),
290 ),
291 Effect.retry({
292 while: (error) =>
293 error._tag === "GCP.Compute.InterconnectGroupStillExists",
294 times: 10,
295 schedule: Schedule.spaced("1 second"),
296 }),
297 Effect.catchTag(
298 "GCP.Compute.InterconnectGroupStillExists",
299 () => Effect.void,
300 ),
301 );
302
303const runOp = <E extends { readonly _tag: string }, R>(
304 project: string,
305 interconnectGroupName: string,
306 start: Effect.Effect<compute.Operation, E, R>,
307 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
308) =>
309 start.pipe(
310 Effect.flatMap((operation) =>
311 waitGlobalOperation(project, operation, {
312 ignore: ignoredCodes(options),
313 }),
314 ),
315 Effect.retry({
316 while: (error) => error._tag === "Conflict",
317 times: 5,
318 schedule: Schedule.spaced("1 second"),
319 }),
320 );
321
322export const InterconnectGroupProvider = () =>
323 Provider.succeed(InterconnectGroup, {
324 stables: [
325 "interconnectGroupName",
326 "project",
327 "interconnectGroupId",
328 "selfLink",
329 "creationTimestamp",
330 ],
331
332 diff: Effect.fn(function* ({ news, olds, output }) {
333 if (!isResolved(news)) return undefined;
334 const previousName =
335 olds?.interconnectGroupName ?? output?.interconnectGroupName;
336 const nextName = news.interconnectGroupName ?? previousName;
337 if (
338 previousName !== undefined &&
339 nextName !== undefined &&
340 previousName !== nextName
341 ) {
342 return { action: "replace" as const, deleteFirst: false };
343 }
344 return undefined;
345 }),
346
347 read: Effect.fn(function* ({ id, olds, output }) {
348 const env = yield* GcpEnvironment.current;
349 const interconnectGroupName = yield* toName(
350 id,
351 olds?.interconnectGroupName,
352 output?.interconnectGroupName,
353 );
354 const existing = yield* getByName(env.project, interconnectGroupName);
355 if (existing === undefined) return undefined;
356 const attrs = toAttrs(existing, env.project);
357 const { labels } = parseDescription(existing.description);
358 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
359 }),
360
361 list: () =>
362 Effect.gen(function* () {
363 const env = yield* GcpEnvironment.current;
364 return yield* compute.listInterconnectGroups
365 .items({
366 project: env.project,
367 maxResults: 500,
368 returnPartialSuccess: true,
369 })
370 .pipe(
371 Stream.filter((group) => hasOwnershipMarker(group.description)),
372 Stream.map((group) => toAttrs(group, env.project)),
373 Stream.runCollect,
374 Effect.map((items) => Array.from(items)),
375 Effect.catchTag("NotFound", () =>
376 Effect.succeed([] as InterconnectGroup["Attributes"][]),
377 ),
378 );
379 }),
380
381 reconcile: Effect.fn(function* ({ id, news, output }) {
382 const env = yield* GcpEnvironment.current;
383 const interconnectGroupName = yield* toName(
384 id,
385 news.interconnectGroupName,
386 output?.interconnectGroupName,
387 );
388 const ownership = yield* createInternalLabels(id);
389 const desiredDescription = encodeDescription(ownership, news.description);
390 const intent: InterconnectGroupIntent = {
391 topologyCapability: capabilityOf(news.intent ?? output?.intent),
392 };
393 const interconnects = toMembers(env.project, news.interconnects);
394
395 let current = yield* getByName(env.project, interconnectGroupName);
396
397 if (current === undefined) {
398 yield* compute
399 .insertInterconnectGroups({
400 project: env.project,
401 body: {
402 name: interconnectGroupName,
403 description: desiredDescription,
404 intent,
405 interconnects,
406 },
407 })
408 .pipe(
409 Effect.flatMap((operation) =>
410 waitGlobalOperation(env.project, operation, {
411 ignore: ["RESOURCE_ALREADY_EXISTS"],
412 }),
413 ),
414 Effect.catchTag("Conflict", () => Effect.void),
415 );
416 current = yield* awaitResource(env.project, interconnectGroupName);
417 }
418
419 const descriptionChanged =
420 (current.description ?? "") !== desiredDescription;
421 const intentChanged =
422 capabilityOf(current.intent) !== capabilityOf(intent);
423 const membersChanged =
424 news.interconnects !== undefined &&
425 membersKey(env.project, current.interconnects) !==
426 membersKey(env.project, interconnects);
427
428 if (descriptionChanged || intentChanged || membersChanged) {
429 yield* runOp(
430 env.project,
431 interconnectGroupName,
432 compute.patchInterconnectGroups({
433 project: env.project,
434 interconnectGroup: interconnectGroupName,
435 updateMask: "description,intent,interconnects",
436 body: {
437 etag: current.etag,
438 description: desiredDescription,
439 intent,
440 interconnects:
441 news.interconnects !== undefined
442 ? interconnects
443 : current.interconnects,
444 },
445 }),
446 );
447 current =
448 (yield* getByName(env.project, interconnectGroupName)) ?? current;
449 }
450
451 return toAttrs(current, env.project);
452 }),
453
454 delete: Effect.fn(function* ({ output }) {
455 if (!output.interconnectGroupName) return;
456 const env = yield* GcpEnvironment.current;
457 const project = output.project || env.project;
458 yield* compute
459 .deleteInterconnectGroups({
460 project,
461 interconnectGroup: output.interconnectGroupName,
462 })
463 .pipe(
464 Effect.flatMap((operation) =>
465 waitGlobalOperation(project, operation, {
466 ignore: ["RESOURCE_NOT_FOUND"],
467 }),
468 ),
469 Effect.catchTag("NotFound", () => Effect.void),
470 Effect.retry({
471 while: (error) => error._tag === "Conflict",
472 times: 8,
473 schedule: Schedule.spaced("2 seconds"),
474 }),
475 );
476 yield* waitUntilGone(project, output.interconnectGroupName);
477 }),
478 });