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