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 { tagRecord } from "../../Tags.ts";
14import { GcpEnvironment } from "../Environment.ts";
15import {
16 createInternalLabels,
17 diffLabels,
18 hasAlchemyLabels,
19 stripInternalLabels,
20 toLabels,
21} from "../Labels.ts";
22import type { Providers } from "../Providers.ts";
23
24const MAX_NAME_LENGTH = 63;
25const DEFAULT_TYPE = "DEDICATED";
26const DEFAULT_LINK_TYPE = "LINK_TYPE_ETHERNET_10G_LR";
27
28export type InterconnectType =
29 | compute.InterconnectInterconnectTypeEnum
30 | (string & {});
31export type InterconnectLinkType =
32 | compute.InterconnectLinkTypeEnum
33 | (string & {});
34export type InterconnectMacsec = compute.InterconnectMacsec;
35export type InterconnectApplicationAware =
36 compute.InterconnectApplicationAwareInterconnect;
37
38export type InterconnectProps = {
39 /**
40 * Interconnect name (RFC1035, 1-63 characters). If omitted, a unique
41 * name is generated from the stack, stage, and logical id. Immutable —
42 * changing it replaces the interconnect.
43 */
44 interconnectName?: string;
45 /**
46 * URL or name of the InterconnectLocation (for example
47 * `iad-zone1-1`). Immutable — changing it replaces the interconnect.
48 */
49 location: string;
50 /**
51 * Dedicated physical interconnection or partner-managed. Immutable —
52 * changing it replaces the interconnect.
53 * @default "DEDICATED"
54 */
55 interconnectType?: InterconnectType;
56 /**
57 * Speed of each physical link in the bundle. Immutable — changing it
58 * replaces the interconnect.
59 * @default "LINK_TYPE_ETHERNET_10G_LR"
60 */
61 linkType?: InterconnectLinkType;
62 /**
63 * Target number of physical links in the bundle. Updated in place.
64 * @default 1
65 */
66 requestedLinkCount?: number;
67 /**
68 * Customer name for the Letter of Authorization. Immutable — changing
69 * it replaces the interconnect.
70 */
71 customerName?: string;
72 /**
73 * Optional description. Updated in place via `interconnects.patch`.
74 */
75 description?: string;
76 /**
77 * NOC contact email for operations notifications. Updated in place.
78 */
79 nocContactEmail?: string;
80 /**
81 * Administrative status. When `false`, the interconnect carries no
82 * packets. Updated in place.
83 * @default true
84 */
85 adminEnabled?: boolean;
86 /**
87 * Cross-Cloud Interconnect remote location URL. Immutable — changing
88 * it replaces the interconnect.
89 */
90 remoteLocation?: string;
91 /**
92 * Enable MACsec on this interconnect. Updated in place.
93 * @default false
94 */
95 macsecEnabled?: boolean;
96 /**
97 * MACsec pre-shared keys and configuration. Updated in place.
98 */
99 macsec?: InterconnectMacsec;
100 /**
101 * Features requested at create time (`IF_MACSEC`,
102 * `IF_CROSS_SITE_NETWORK`). Immutable — changing them replaces the
103 * interconnect.
104 */
105 requestedFeatures?: string[];
106 /**
107 * Enable application-aware interconnect. Updated in place.
108 */
109 aaiEnabled?: boolean;
110 /**
111 * Application-aware interconnect configuration. Updated in place.
112 */
113 applicationAwareInterconnect?: InterconnectApplicationAware;
114 /**
115 * User labels. Alchemy ownership labels are merged in automatically
116 * and synced via `setLabels` (labels cannot be set on insert).
117 */
118 labels?: Record<string, string>;
119};
120
121export type Interconnect = Resource<
122 "GCP.Compute.Interconnect",
123 InterconnectProps,
124 {
125 /** Interconnect name. */
126 interconnectName: string;
127 /** Project id. */
128 project: string;
129 /** Interconnect location URL. */
130 location: string | undefined;
131 /** Interconnect type (`DEDICATED` or `PARTNER`). */
132 interconnectType: string | undefined;
133 /** Link type. */
134 linkType: string | undefined;
135 /** Requested number of physical links. */
136 requestedLinkCount: number | undefined;
137 /** Provisioned number of physical links. */
138 provisionedLinkCount: number | undefined;
139 /** Customer name on the LOA. */
140 customerName: string | undefined;
141 /** Description. */
142 description: string | undefined;
143 /** NOC contact email. */
144 nocContactEmail: string | undefined;
145 /** Administrative status. */
146 adminEnabled: boolean;
147 /** Remote location URL for Cross-Cloud Interconnect. */
148 remoteLocation: string | undefined;
149 /** Whether MACsec is enabled. */
150 macsecEnabled: boolean;
151 /** MACsec configuration. */
152 macsec: InterconnectMacsec | undefined;
153 /** Requested features. */
154 requestedFeatures: ReadonlyArray<string>;
155 /** Available features. */
156 availableFeatures: ReadonlyArray<string>;
157 /** Application-aware interconnect enabled. */
158 aaiEnabled: boolean;
159 /** Application-aware interconnect config. */
160 applicationAwareInterconnect: InterconnectApplicationAware | undefined;
161 /** User labels (Alchemy ownership labels stripped). */
162 labels: Record<string, string>;
163 /** Google reference id. */
164 googleReferenceId: string | undefined;
165 /** Operational status. */
166 operationalStatus: string | undefined;
167 /** Resource state. */
168 state: string | undefined;
169 /** Google-side ping IP. */
170 googleIpAddress: string | undefined;
171 /** Customer-side ping IP. */
172 peerIpAddress: string | undefined;
173 /** Attachment URLs using this interconnect. */
174 interconnectAttachments: ReadonlyArray<string>;
175 /** Interconnect group URLs. */
176 interconnectGroups: ReadonlyArray<string>;
177 /** Server-assigned numeric id. */
178 interconnectId: string | undefined;
179 /** Resource self-link. */
180 selfLink: string | undefined;
181 /** Label fingerprint for `setLabels`. */
182 labelFingerprint: string | undefined;
183 /** RFC3339 creation timestamp. */
184 creationTimestamp: string | undefined;
185 /** Resource kind. */
186 kind: string | undefined;
187 },
188 never,
189 Providers
190>;
191
192/**
193 * A global Compute Engine Dedicated Interconnect.
194 *
195 * Dedicated Interconnect is a physical connection between your on-premises
196 * network and Google's network at a colocation facility. Name, location,
197 * interconnect type, link type, customer name, remote location, and
198 * requested features are immutable. Description, admin status, link count,
199 * NOC email, and MACsec update in place via `interconnects.patch`. Labels
200 * are applied with `setLabels` after the interconnect exists.
201 *
202 * ### Creating an Interconnect
203 * **Example:** Dedicated 10G interconnect
204 * ```typescript
205 * const interconnect = yield* GCP.Compute.Interconnect("OnPrem", {
206 * location: "iad-zone1-1",
207 * interconnectType: "DEDICATED",
208 * linkType: "LINK_TYPE_ETHERNET_10G_LR",
209 * requestedLinkCount: 1,
210 * customerName: "Example Corp",
211 * description: "prod interconnect",
212 * });
213 * ```
214 *
215 * **Example:** Named interconnect with labels
216 * ```typescript
217 * const interconnect = yield* GCP.Compute.Interconnect("OnPrem", {
218 * interconnectName: "app-ix",
219 * location: "iad-zone1-1",
220 * customerName: "Example Corp",
221 * labels: { env: "prod" },
222 * });
223 * ```
224 *
225 * @resource
226 * @category Compute
227 */
228export const Interconnect = Resource<Interconnect>("GCP.Compute.Interconnect");
229
230export class InterconnectNotResolved extends Data.TaggedError(
231 "GCP.Compute.InterconnectNotResolved",
232)<{
233 interconnectName: string;
234}> {}
235
236export class InterconnectStillExists extends Data.TaggedError(
237 "GCP.Compute.InterconnectStillExists",
238)<{
239 interconnectName: string;
240}> {}
241
242const lastSegment = (value: string | undefined): string => {
243 if (value === undefined || value.length === 0) return "";
244 const trimmed = value.replace(/\/+$/, "");
245 const parts = trimmed.split("/");
246 return parts[parts.length - 1] || trimmed;
247};
248
249const rfc1035 = (name: string): string => {
250 let next = name
251 .toLowerCase()
252 .replace(/[^a-z0-9-]/g, "-")
253 .replace(/-+/g, "-")
254 .replace(/^-+/, "")
255 .replace(/-+$/, "");
256 if (!/^[a-z]/.test(next)) next = `i${next}`;
257 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
258 return next.length > 0 ? next : "interconnect";
259};
260
261const toName = (id: string, name: string | undefined, existing?: string) =>
262 Effect.gen(function* () {
263 if (name !== undefined) return name;
264 if (existing !== undefined) return existing;
265 return rfc1035(
266 yield* createPhysicalName({
267 id,
268 maxLength: MAX_NAME_LENGTH,
269 lowercase: true,
270 }),
271 );
272 });
273
274const locationUrl = (project: string, location: string) => {
275 if (location.includes("/")) return location;
276 return `projects/${project}/global/interconnectLocations/${location}`;
277};
278
279const typeOf = (value: string | undefined) =>
280 (value ?? DEFAULT_TYPE).toUpperCase();
281
282const linkTypeOf = (value: string | undefined) => value ?? DEFAULT_LINK_TYPE;
283
284const featuresKey = (values: ReadonlyArray<string> | undefined) =>
285 [...(values ?? [])]
286 .map((value) => value.toUpperCase())
287 .sort()
288 .join(",");
289
290const userLabels = (
291 labels: Record<string, string | undefined> | null | undefined,
292): Record<string, string> => stripInternalLabels(tagRecord(labels));
293
294const toAttrs = (
295 interconnect: compute.Interconnect,
296 project: string,
297): Interconnect["Attributes"] => ({
298 interconnectName: interconnect.name ?? "",
299 project,
300 location: interconnect.location,
301 interconnectType: interconnect.interconnectType,
302 linkType: interconnect.linkType,
303 requestedLinkCount: interconnect.requestedLinkCount,
304 provisionedLinkCount: interconnect.provisionedLinkCount,
305 customerName: interconnect.customerName,
306 description: interconnect.description,
307 nocContactEmail: interconnect.nocContactEmail,
308 adminEnabled: interconnect.adminEnabled !== false,
309 remoteLocation: interconnect.remoteLocation,
310 macsecEnabled: interconnect.macsecEnabled === true,
311 macsec: interconnect.macsec,
312 requestedFeatures: interconnect.requestedFeatures ?? [],
313 availableFeatures: interconnect.availableFeatures ?? [],
314 aaiEnabled: interconnect.aaiEnabled === true,
315 applicationAwareInterconnect: interconnect.applicationAwareInterconnect,
316 labels: userLabels(interconnect.labels),
317 googleReferenceId: interconnect.googleReferenceId,
318 operationalStatus: interconnect.operationalStatus,
319 state: interconnect.state,
320 googleIpAddress: interconnect.googleIpAddress,
321 peerIpAddress: interconnect.peerIpAddress,
322 interconnectAttachments: interconnect.interconnectAttachments ?? [],
323 interconnectGroups: interconnect.interconnectGroups ?? [],
324 interconnectId: interconnect.id,
325 selfLink: interconnect.selfLink,
326 labelFingerprint: interconnect.labelFingerprint,
327 creationTimestamp: interconnect.creationTimestamp,
328 kind: interconnect.kind,
329});
330
331const getByName = (project: string, interconnect: string) =>
332 compute
333 .getInterconnects({ project, interconnect })
334 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
335
336const awaitResource = (project: string, interconnectName: string) =>
337 getByName(project, interconnectName).pipe(
338 Effect.flatMap((resource) =>
339 resource !== undefined
340 ? Effect.succeed(resource)
341 : Effect.fail(new InterconnectNotResolved({ interconnectName })),
342 ),
343 Effect.retry({
344 while: (error) => error._tag === "GCP.Compute.InterconnectNotResolved",
345 times: 8,
346 schedule: Schedule.spaced("1 second"),
347 }),
348 );
349
350const waitUntilGone = (project: string, interconnectName: string) =>
351 getByName(project, interconnectName).pipe(
352 Effect.flatMap((resource) =>
353 resource === undefined
354 ? Effect.void
355 : Effect.fail(new InterconnectStillExists({ interconnectName })),
356 ),
357 Effect.retry({
358 while: (error) => error._tag === "GCP.Compute.InterconnectStillExists",
359 times: 10,
360 schedule: Schedule.spaced("2 seconds"),
361 }),
362 Effect.catchTag("GCP.Compute.InterconnectStillExists", () => Effect.void),
363 );
364
365const runOp = <E extends { readonly _tag: string }, R>(
366 project: string,
367 interconnectName: string,
368 start: Effect.Effect<compute.Operation, E, R>,
369 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
370) =>
371 start.pipe(
372 Effect.flatMap((operation) =>
373 waitGlobalOperation(project, operation, {
374 ignore: ignoredCodes(options),
375 }),
376 ),
377 Effect.retry({
378 while: (error) => error._tag === "Conflict",
379 times: 5,
380 schedule: Schedule.spaced("1 second"),
381 }),
382 );
383
384export const InterconnectProvider = () =>
385 Provider.succeed(Interconnect, {
386 stables: [
387 "interconnectName",
388 "project",
389 "interconnectId",
390 "selfLink",
391 "location",
392 "interconnectType",
393 "linkType",
394 "creationTimestamp",
395 ],
396
397 diff: Effect.fn(function* ({ news, olds, output }) {
398 if (!isResolved(news)) return undefined;
399 const previousName = olds?.interconnectName ?? output?.interconnectName;
400 const nextName = news.interconnectName ?? previousName;
401 const nameChanged =
402 previousName !== undefined &&
403 nextName !== undefined &&
404 previousName !== nextName;
405
406 const previousLocation = lastSegment(olds?.location ?? output?.location);
407 const nextLocation = lastSegment(news.location ?? previousLocation);
408 const previousType = typeOf(
409 olds?.interconnectType ?? output?.interconnectType,
410 );
411 const nextType = typeOf(news.interconnectType ?? previousType);
412 const previousLink = linkTypeOf(olds?.linkType ?? output?.linkType);
413 const nextLink = linkTypeOf(news.linkType ?? previousLink);
414 const previousCustomer = olds?.customerName ?? output?.customerName ?? "";
415 const nextCustomer = news.customerName ?? previousCustomer;
416 const previousRemote = lastSegment(
417 olds?.remoteLocation ?? output?.remoteLocation,
418 );
419 const nextRemote = lastSegment(news.remoteLocation ?? previousRemote);
420 const previousFeatures = featuresKey(
421 olds?.requestedFeatures ?? output?.requestedFeatures,
422 );
423 const nextFeatures = featuresKey(
424 news.requestedFeatures ??
425 olds?.requestedFeatures ??
426 output?.requestedFeatures,
427 );
428
429 const immutableChanged =
430 previousLocation !== nextLocation ||
431 previousType !== nextType ||
432 previousLink !== nextLink ||
433 previousCustomer !== nextCustomer ||
434 previousRemote !== nextRemote ||
435 (news.requestedFeatures !== undefined &&
436 previousFeatures !== nextFeatures);
437
438 if (nameChanged) {
439 return { action: "replace" as const, deleteFirst: false };
440 }
441 if (immutableChanged) {
442 return { action: "replace" as const, deleteFirst: true };
443 }
444 return undefined;
445 }),
446
447 read: Effect.fn(function* ({ id, olds, output }) {
448 const env = yield* GcpEnvironment.current;
449 const interconnectName = yield* toName(
450 id,
451 olds?.interconnectName,
452 output?.interconnectName,
453 );
454 const existing = yield* getByName(env.project, interconnectName);
455 if (existing === undefined) return undefined;
456 const attrs = toAttrs(existing, env.project);
457 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
458 ? attrs
459 : Unowned(attrs);
460 }),
461
462 list: () =>
463 Effect.gen(function* () {
464 const env = yield* GcpEnvironment.current;
465 return yield* compute.listInterconnects
466 .items({
467 project: env.project,
468 filter: "labels.alchemy-id:*",
469 maxResults: 500,
470 returnPartialSuccess: true,
471 })
472 .pipe(
473 Stream.filter((item) =>
474 Object.keys(item.labels ?? {}).some((key) =>
475 key.startsWith("alchemy-"),
476 ),
477 ),
478 Stream.map((item) => toAttrs(item, env.project)),
479 Stream.runCollect,
480 Effect.map((items) => Array.from(items)),
481 Effect.catchTag("NotFound", () =>
482 Effect.succeed([] as Interconnect["Attributes"][]),
483 ),
484 );
485 }),
486
487 reconcile: Effect.fn(function* ({ id, news, output }) {
488 const env = yield* GcpEnvironment.current;
489 const interconnectName = yield* toName(
490 id,
491 news.interconnectName,
492 output?.interconnectName,
493 );
494 const desiredLabels = {
495 ...toLabels(news.labels),
496 ...(yield* createInternalLabels(id)),
497 };
498 const location = locationUrl(env.project, news.location);
499 const interconnectType = typeOf(news.interconnectType);
500 const linkType = linkTypeOf(news.linkType);
501 const requestedLinkCount = news.requestedLinkCount ?? 1;
502 const adminEnabled = news.adminEnabled !== false;
503
504 let current = yield* getByName(env.project, interconnectName);
505
506 if (current === undefined) {
507 yield* compute
508 .insertInterconnects({
509 project: env.project,
510 body: {
511 name: interconnectName,
512 location,
513 interconnectType,
514 linkType,
515 requestedLinkCount,
516 customerName: news.customerName,
517 description: news.description,
518 nocContactEmail: news.nocContactEmail,
519 adminEnabled,
520 remoteLocation: news.remoteLocation,
521 macsecEnabled: news.macsecEnabled,
522 macsec: news.macsec,
523 requestedFeatures: news.requestedFeatures,
524 aaiEnabled: news.aaiEnabled,
525 applicationAwareInterconnect: news.applicationAwareInterconnect,
526 },
527 })
528 .pipe(
529 Effect.flatMap((operation) =>
530 waitGlobalOperation(env.project, operation, {
531 ignore: ["RESOURCE_ALREADY_EXISTS"],
532 }),
533 ),
534 Effect.catchTag("Conflict", () => Effect.void),
535 );
536 current = yield* awaitResource(env.project, interconnectName);
537 }
538
539 const needsPatch =
540 (current.description ?? "") !== (news.description ?? "") ||
541 (current.nocContactEmail ?? "") !== (news.nocContactEmail ?? "") ||
542 (current.adminEnabled !== false) !== adminEnabled ||
543 (news.requestedLinkCount !== undefined &&
544 (current.requestedLinkCount ?? 1) !== requestedLinkCount) ||
545 (current.macsecEnabled === true) !== (news.macsecEnabled === true) ||
546 (news.macsec !== undefined &&
547 JSON.stringify(current.macsec ?? null) !==
548 JSON.stringify(news.macsec ?? null)) ||
549 (news.aaiEnabled !== undefined &&
550 (current.aaiEnabled === true) !== news.aaiEnabled);
551
552 if (needsPatch) {
553 yield* runOp(
554 env.project,
555 interconnectName,
556 compute.patchInterconnects({
557 project: env.project,
558 interconnect: interconnectName,
559 body: {
560 description: news.description,
561 nocContactEmail: news.nocContactEmail,
562 adminEnabled,
563 requestedLinkCount,
564 macsecEnabled: news.macsecEnabled,
565 macsec: news.macsec,
566 aaiEnabled: news.aaiEnabled,
567 applicationAwareInterconnect: news.applicationAwareInterconnect,
568 },
569 }),
570 );
571 current = (yield* getByName(env.project, interconnectName)) ?? current;
572 }
573
574 const observedLabels = tagRecord(current.labels);
575 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
576 if (upsert.length > 0 || removed.length > 0) {
577 yield* Effect.gen(function* () {
578 const latest =
579 (yield* getByName(env.project, interconnectName)) ?? current;
580 if (latest === undefined) {
581 return yield* new InterconnectNotResolved({ interconnectName });
582 }
583 yield* runOp(
584 env.project,
585 interconnectName,
586 compute.setLabelsInterconnects({
587 project: env.project,
588 resource: interconnectName,
589 body: {
590 labels: desiredLabels,
591 labelFingerprint: latest.labelFingerprint,
592 },
593 }),
594 );
595 }).pipe(
596 Effect.retry({
597 while: (error) => error._tag === "Conflict",
598 times: 5,
599 schedule: Schedule.spaced("1 second"),
600 }),
601 );
602 current = (yield* getByName(env.project, interconnectName)) ?? current;
603 }
604
605 return toAttrs(current, env.project);
606 }),
607
608 delete: Effect.fn(function* ({ output }) {
609 if (!output.interconnectName) return;
610 const env = yield* GcpEnvironment.current;
611 const project = output.project || env.project;
612 yield* compute
613 .deleteInterconnects({
614 project,
615 interconnect: output.interconnectName,
616 })
617 .pipe(
618 Effect.flatMap((operation) =>
619 waitGlobalOperation(project, operation, {
620 ignore: ["RESOURCE_NOT_FOUND"],
621 }),
622 ),
623 Effect.catchTag("NotFound", () => Effect.void),
624 Effect.retry({
625 while: (error) => error._tag === "Conflict",
626 times: 8,
627 schedule: Schedule.spaced("2 seconds"),
628 }),
629 );
630 yield* waitUntilGone(project, output.interconnectName);
631 }),
632 });