1import * as networkconnectivity from "@distilled.cloud/gcp/networkconnectivity_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForOperation } from "./internal.ts";
22
23const MAX_NAME_LENGTH = 63;
24
25export type TransportBandwidth =
26 | networkconnectivity.TransportBandwidthEnum
27 | (string & {});
28export type TransportStackType =
29 | networkconnectivity.TransportStackTypeEnum
30 | (string & {});
31export type TransportState =
32 | networkconnectivity.TransportStateEnum
33 | (string & {});
34
35export type TransportProps = {
36 /**
37 * Transport id (the `{transport}` segment of
38 * `projects/{project}/locations/{location}/transports/{transport}`).
39 * If omitted, a unique RFC1035 name is generated from the stack, stage,
40 * and logical id. Must be 1-63 characters and match
41 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable — changing it replaces the
42 * transport.
43 */
44 transportId?: string;
45 /**
46 * Region of the transport (e.g. `us-east4`). Remote transport profiles
47 * are location-scoped, so this must match the profile's location.
48 * `US-CENTRAL1` is accepted and normalized to `us-central1`. Immutable
49 * — changing it replaces the transport.
50 * @default the stack's GCP region (`GCP.Region`, else the profile
51 * region, else `us-central1`)
52 */
53 location?: string;
54 /**
55 * VPC network peered with this transport. Accepts a name (`app-vpc`), a
56 * resource path (`projects/{project}/global/networks/{network}`), or a
57 * Compute self-link. Immutable — changing it replaces the transport.
58 */
59 network: string;
60 /**
61 * Remote transport profile this transport connects to. Accepts a
62 * profile id (`aws-us-east-1`), a resource name, or a full API URL.
63 * Immutable — changing it replaces the transport. Required when
64 * `providedActivationKey` is omitted.
65 */
66 remoteProfile?: string;
67 /**
68 * Partner-supplied activation key (INPUT key flow). Only valid while
69 * the transport is `PENDING_KEY`. Immutable — changing it replaces the
70 * transport. Required when `remoteProfile` and `bandwidth` are omitted.
71 */
72 providedActivationKey?: string;
73 /**
74 * Cloud-service-provider account id associated with `remoteProfile`.
75 * Immutable — changing it replaces the transport.
76 */
77 remoteAccountId?: string;
78 /**
79 * Bandwidth. Must be one of the profile's supported values, and is
80 * required when no activation key is provided.
81 */
82 bandwidth?: TransportBandwidth;
83 /**
84 * IP version stack for the established connectivity.
85 */
86 stackType?: TransportStackType;
87 /**
88 * IPv4 and IPv6 prefixes advertised to the remote provider.
89 */
90 advertisedRoutes?: string[];
91 /**
92 * Human-readable description of the transport.
93 */
94 description?: string;
95 /**
96 * User labels. Alchemy ownership labels are merged in automatically.
97 */
98 labels?: Record<string, string>;
99};
100
101export type Transport = Resource<
102 "GCP.NetworkConnectivity.Transport",
103 TransportProps,
104 {
105 /**
106 * Full resource name
107 * `projects/{project}/locations/{location}/transports/{transport}`.
108 */
109 name: string;
110 /** Transport id (last path segment). */
111 transportId: string;
112 /** Project id. */
113 project: string;
114 /** Location id (region). */
115 location: string;
116 /** Network URI as reported by the API. */
117 network: string | undefined;
118 /** VPC network id (last path segment). */
119 networkName: string | undefined;
120 /** Remote transport profile URI as reported by the API. */
121 remoteProfile: string | undefined;
122 /** Remote profile id (last path segment). */
123 remoteProfileId: string | undefined;
124 /** Cloud-service-provider account id. */
125 remoteAccountId: string | undefined;
126 /** Configured bandwidth enum. */
127 bandwidth: string | undefined;
128 /** Configured IP stack. */
129 stackType: string | undefined;
130 /** Prefixes advertised to the remote provider. */
131 advertisedRoutes: ReadonlyArray<string>;
132 /** User-provided description. */
133 description: string | undefined;
134 /** User labels (Alchemy ownership labels stripped). */
135 labels: Record<string, string>;
136 /** VPC created for peering to `network` (output-only). */
137 peeringNetwork: string | undefined;
138 /** Packet MTU in bytes (output-only). */
139 mtuLimit: number | undefined;
140 /**
141 * Google-generated activation key (OUTPUT key flow). Only present
142 * while the transport is `PENDING_KEY`.
143 */
144 generatedActivationKey: string | undefined;
145 /** Server-reported lifecycle state (`PENDING_KEY`, `ACTIVE`, …). */
146 state: string | undefined;
147 /** RFC3339 creation timestamp. */
148 createTime: string | undefined;
149 /** RFC3339 last-update timestamp. */
150 updateTime: string | undefined;
151 },
152 never,
153 Providers
154>;
155
156/**
157 * A Network Connectivity Partner Cross-Cloud Interconnect transport.
158 *
159 * Transports attach a VPC (or NCC hub, on the beta API) to a remote
160 * cloud-service-provider profile such as AWS or Azure. `transportId`,
161 * `location`, `network`, `remoteProfile`, `providedActivationKey`, and
162 * `remoteAccountId` are immutable. Description, labels, bandwidth,
163 * stack type, and advertised routes update in place. Creating a
164 * transport without an activation key typically leaves it in
165 * `PENDING_KEY` until the remote provider accepts the generated key.
166 *
167 * ### Creating a Transport
168 * **Example:** Generated name with an AWS profile
169 * ```typescript
170 * const network = yield* GCP.Compute.Network("Vpc", {
171 * autoCreateSubnetworks: false,
172 * });
173 * const transport = yield* GCP.NetworkConnectivity.Transport("Aws", {
174 * location: "us-east4",
175 * network: network.selfLink ?? network.networkName,
176 * remoteProfile: "aws-us-east-1",
177 * bandwidth: "BPS_1G",
178 * remoteAccountId: "123456789012",
179 * advertisedRoutes: ["10.0.0.0/8"],
180 * });
181 * ```
182 *
183 * **Example:** Named transport with labels
184 * ```typescript
185 * const transport = yield* GCP.NetworkConnectivity.Transport("Aws", {
186 * transportId: "app-aws",
187 * location: "us-east4",
188 * network: "projects/{project}/global/networks/app-vpc",
189 * remoteProfile: "aws-us-east-1",
190 * bandwidth: "BPS_1G",
191 * remoteAccountId: "123456789012",
192 * description: "prod aws interconnect",
193 * labels: { env: "prod" },
194 * });
195 * ```
196 *
197 * ### Activation key
198 * **Example:** Accept a partner-supplied key
199 * ```typescript
200 * const transport = yield* GCP.NetworkConnectivity.Transport("Azure", {
201 * location: "us-east4",
202 * network: network.networkName,
203 * providedActivationKey: "ABC1234",
204 * });
205 * ```
206 *
207 * ### Updating a Transport
208 * **Example:** Description, labels, and advertised routes
209 * ```typescript
210 * const transport = yield* GCP.NetworkConnectivity.Transport("Aws", {
211 * transportId: "app-aws",
212 * location: "us-east4",
213 * network: "app-vpc",
214 * remoteProfile: "aws-us-east-1",
215 * bandwidth: "BPS_2G",
216 * remoteAccountId: "123456789012",
217 * advertisedRoutes: ["10.0.0.0/8", "192.168.0.0/16"],
218 * description: "prod aws interconnect v2",
219 * labels: { env: "prod", role: "cci" },
220 * });
221 * ```
222 *
223 * @resource
224 * @category NetworkConnectivity
225 */
226export const Transport = Resource<Transport>(
227 "GCP.NetworkConnectivity.Transport",
228);
229
230export class TransportNotResolved extends Data.TaggedError(
231 "GCP.NetworkConnectivity.TransportNotResolved",
232)<{
233 name: string;
234}> {}
235
236export class TransportFailed extends Data.TaggedError(
237 "GCP.NetworkConnectivity.TransportFailed",
238)<{
239 name: string;
240 state: string | undefined;
241}> {}
242
243export class TransportStillExists extends Data.TaggedError(
244 "GCP.NetworkConnectivity.TransportStillExists",
245)<{
246 name: string;
247}> {}
248
249const lastSegment = (value: string) => {
250 const trimmed = value.replace(/\/+$/, "");
251 const parts = trimmed.split("/");
252 return parts[parts.length - 1] || trimmed;
253};
254
255const rfc1035 = (name: string): string => {
256 let next = name
257 .toLowerCase()
258 .replace(/[^a-z0-9-]/g, "-")
259 .replace(/-+/g, "-")
260 .replace(/^-+/, "")
261 .replace(/-+$/, "");
262 if (!/^[a-z]/.test(next)) {
263 next = `t${next}`;
264 }
265 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
266 return next.length > 0 ? next : "transport";
267};
268
269const normalizeLocation = (
270 location: string | undefined,
271 defaultLocation: string,
272) => lastSegment(location ?? defaultLocation).toLowerCase();
273
274const networkNameOf = (network: string | undefined) =>
275 network === undefined || network.length === 0
276 ? undefined
277 : lastSegment(network);
278
279const profileIdOf = (profile: string | undefined) =>
280 profile === undefined || profile.length === 0
281 ? undefined
282 : lastSegment(profile);
283
284const toNetworkResource = (project: string, network: string) => {
285 const trimmed = network.trim();
286 if (trimmed.includes("/")) return trimmed;
287 return `projects/${project}/global/networks/${trimmed}`;
288};
289
290const toRemoteProfileResource = (
291 project: string,
292 location: string,
293 profile: string | undefined,
294) => {
295 if (profile === undefined || profile.length === 0) return undefined;
296 const trimmed = profile.trim();
297 if (trimmed.includes("/")) return trimmed;
298 return `projects/${project}/locations/${location}/remoteTransportProfiles/${trimmed}`;
299};
300
301const resourceName = (project: string, location: string, transportId: string) =>
302 `projects/${project}/locations/${location}/transports/${transportId}`;
303
304const parentOf = (project: string, location: string) =>
305 `projects/${project}/locations/${location}`;
306
307const parseName = (name: string, defaultLocation: string) => {
308 const parts = name.split("/").filter((part) => part.length > 0);
309 const transportsAt = parts.lastIndexOf("transports");
310 const locationsAt = parts.lastIndexOf("locations");
311 const projectsAt = parts.lastIndexOf("projects");
312 return {
313 project:
314 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
315 location:
316 locationsAt >= 0 && parts[locationsAt + 1]
317 ? parts[locationsAt + 1]!
318 : defaultLocation,
319 transportId:
320 transportsAt >= 0 && parts[transportsAt + 1]
321 ? parts[transportsAt + 1]!
322 : lastSegment(name),
323 };
324};
325
326const userLabels = (
327 labels: Record<string, string | undefined> | null | undefined,
328): Record<string, string> => stripInternalLabels(tagRecord(labels));
329
330const toId = (id: string, transportId: string | undefined, existing?: string) =>
331 Effect.gen(function* () {
332 if (transportId !== undefined) return transportId;
333 if (existing !== undefined) return existing;
334 return rfc1035(
335 yield* createPhysicalName({
336 id,
337 maxLength: MAX_NAME_LENGTH,
338 lowercase: true,
339 }),
340 );
341 });
342
343const sameList = (left?: readonly string[], right?: readonly string[]) =>
344 [...(left ?? [])].sort().join("\0") === [...(right ?? [])].sort().join("\0");
345
346const normalizeBandwidth = (value: string | undefined) =>
347 value === undefined || value.length === 0 || value === "BANDWIDTH_UNSPECIFIED"
348 ? ""
349 : value;
350
351const normalizeStackType = (value: string | undefined) =>
352 value === undefined ||
353 value.length === 0 ||
354 value === "STACK_TYPE_UNSPECIFIED"
355 ? ""
356 : value;
357
358const toAttrs = (
359 transport: networkconnectivity.Transport,
360 project: string,
361 region: string,
362) => {
363 const name = transport.name ?? "";
364 const parsed = parseName(name, region);
365 return {
366 name,
367 transportId: parsed.transportId,
368 project: parsed.project || project,
369 location: parsed.location || region,
370 network: transport.network,
371 networkName: networkNameOf(transport.network),
372 remoteProfile: transport.remoteProfile,
373 remoteProfileId: profileIdOf(transport.remoteProfile),
374 remoteAccountId: transport.remoteAccountId,
375 bandwidth: transport.bandwidth,
376 stackType: transport.stackType,
377 advertisedRoutes: transport.advertisedRoutes ?? [],
378 description: transport.description,
379 labels: userLabels(transport.labels),
380 peeringNetwork: transport.peeringNetwork,
381 mtuLimit: transport.mtuLimit,
382 generatedActivationKey: transport.generatedActivationKey,
383 state: transport.state,
384 createTime: transport.createTime,
385 updateTime: transport.updateTime,
386 };
387};
388
389const getByName = (name: string) =>
390 networkconnectivity
391 .getProjectsLocationsTransports({ name })
392 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
393
394const isPendingState = (state: string | undefined) =>
395 state === "CREATING" ||
396 state === "DELETING" ||
397 state === "STATE_UNSPECIFIED" ||
398 state === undefined;
399
400const waitUntilReady = (name: string) =>
401 getByName(name).pipe(
402 Effect.filterOrFail(
403 (transport): transport is networkconnectivity.Transport =>
404 transport !== undefined,
405 () => new TransportNotResolved({ name }),
406 ),
407 Effect.filterOrFail(
408 (transport) => transport.state !== "DEPROVISIONED",
409 (transport) => new TransportFailed({ name, state: transport.state }),
410 ),
411 Effect.filterOrFail(
412 (transport) => !isPendingState(transport.state),
413 () => new TransportNotResolved({ name }),
414 ),
415 Effect.retry({
416 while: (error) =>
417 error._tag === "GCP.NetworkConnectivity.TransportNotResolved",
418 times: 10,
419 schedule: Schedule.spaced("3 seconds"),
420 }),
421 );
422
423const waitUntilGone = (name: string) =>
424 getByName(name).pipe(
425 Effect.flatMap((transport) =>
426 transport === undefined
427 ? Effect.void
428 : Effect.fail(new TransportStillExists({ name })),
429 ),
430 Effect.retry({
431 while: (error) =>
432 error._tag === "GCP.NetworkConnectivity.TransportStillExists",
433 times: 10,
434 schedule: Schedule.spaced("3 seconds"),
435 }),
436 );
437
438const listOwnedTransports = (parent: string, project: string, region: string) =>
439 networkconnectivity.listProjectsLocationsTransports
440 .pages({
441 parent,
442 pageSize: 1000,
443 })
444 .pipe(
445 Stream.flatMap((page) => Stream.fromIterable(page.transports ?? [])),
446 Stream.filter((transport) =>
447 Object.keys(transport.labels ?? {}).some((key) =>
448 key.startsWith("alchemy-"),
449 ),
450 ),
451 Stream.map((transport) => toAttrs(transport, project, region)),
452 Stream.runCollect,
453 Effect.map((chunk) => Array.from(chunk)),
454 Effect.catchTag("NotFound", () => Effect.succeed([])),
455 );
456
457const toCreateBody = (
458 news: TransportProps,
459 project: string,
460 location: string,
461 desiredLabels: Record<string, string>,
462): networkconnectivity.Transport => ({
463 network: toNetworkResource(project, news.network),
464 remoteProfile: toRemoteProfileResource(project, location, news.remoteProfile),
465 providedActivationKey: news.providedActivationKey,
466 remoteAccountId: news.remoteAccountId,
467 bandwidth: news.bandwidth,
468 stackType: news.stackType,
469 advertisedRoutes: news.advertisedRoutes,
470 description: news.description,
471 labels: desiredLabels,
472});
473
474export const TransportProvider = () =>
475 Provider.succeed(Transport, {
476 stables: [
477 "name",
478 "transportId",
479 "project",
480 "location",
481 "networkName",
482 "remoteProfileId",
483 "remoteAccountId",
484 "createTime",
485 ],
486
487 diff: Effect.fn(function* ({ news, olds, output }) {
488 if (!isResolved(news)) return undefined;
489 const env = yield* GcpEnvironment.current;
490
491 const previousId = olds?.transportId ?? output?.transportId;
492 const nextId = news.transportId ?? previousId;
493 const idChanged =
494 previousId !== undefined &&
495 nextId !== undefined &&
496 nextId !== previousId;
497
498 const previousLocation = normalizeLocation(
499 olds?.location ?? output?.location,
500 env.region,
501 );
502 const nextLocation = normalizeLocation(
503 news.location ?? olds?.location ?? output?.location,
504 env.region,
505 );
506 const locationChanged = previousLocation !== nextLocation;
507
508 const previousNetwork = networkNameOf(
509 olds?.network ?? output?.networkName ?? output?.network,
510 );
511 const nextNetwork = networkNameOf(news.network ?? previousNetwork);
512 const networkChanged =
513 previousNetwork !== undefined &&
514 nextNetwork !== undefined &&
515 previousNetwork !== nextNetwork;
516
517 const previousProfile = profileIdOf(
518 olds?.remoteProfile ?? output?.remoteProfileId ?? output?.remoteProfile,
519 );
520 const nextProfile = profileIdOf(news.remoteProfile ?? previousProfile);
521 const profileChanged =
522 previousProfile !== undefined &&
523 nextProfile !== undefined &&
524 previousProfile !== nextProfile;
525
526 const previousAccount =
527 olds?.remoteAccountId ?? output?.remoteAccountId ?? "";
528 const nextAccount = news.remoteAccountId ?? previousAccount;
529 const accountChanged = previousAccount !== nextAccount;
530
531 const previousKey = olds?.providedActivationKey ?? "";
532 const nextKey = news.providedActivationKey ?? previousKey;
533 const keyChanged =
534 previousKey.length > 0 && nextKey.length > 0 && previousKey !== nextKey;
535
536 if (
537 !idChanged &&
538 !locationChanged &&
539 !networkChanged &&
540 !profileChanged &&
541 !accountChanged &&
542 !keyChanged
543 ) {
544 return undefined;
545 }
546
547 return {
548 action: "replace" as const,
549 deleteFirst:
550 !idChanged &&
551 !locationChanged &&
552 previousId !== undefined &&
553 nextId === previousId,
554 };
555 }),
556
557 read: Effect.fn(function* ({ id, olds, output }) {
558 const env = yield* GcpEnvironment.current;
559 const transportId = yield* toId(
560 id,
561 olds?.transportId,
562 output?.transportId,
563 );
564 const location = normalizeLocation(
565 olds?.location ?? output?.location,
566 env.region,
567 );
568 const name =
569 output?.name ?? resourceName(env.project, location, transportId);
570 const existing = yield* getByName(name);
571 if (existing === undefined) return undefined;
572 const attrs = toAttrs(existing, env.project, env.region);
573 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
574 ? attrs
575 : Unowned(attrs);
576 }),
577
578 list: () =>
579 Effect.gen(function* () {
580 const env = yield* GcpEnvironment.current;
581 const aggregated = yield* listOwnedTransports(
582 parentOf(env.project, "-"),
583 env.project,
584 env.region,
585 );
586 if (aggregated.length > 0) return aggregated;
587 return yield* listOwnedTransports(
588 parentOf(env.project, env.region),
589 env.project,
590 env.region,
591 );
592 }),
593
594 reconcile: Effect.fn(function* ({ id, news, output }) {
595 const env = yield* GcpEnvironment.current;
596 const transportId = yield* toId(
597 id,
598 news.transportId,
599 output?.transportId,
600 );
601 const location = normalizeLocation(
602 news.location ?? output?.location,
603 env.region,
604 );
605 const name = resourceName(env.project, location, transportId);
606 const desiredLabels = {
607 ...toLabels(news.labels),
608 ...(yield* createInternalLabels(id)),
609 };
610
611 let current = yield* getByName(name);
612 if (current?.state === "DELETING") {
613 yield* waitUntilGone(name);
614 current = undefined;
615 }
616
617 if (current === undefined) {
618 const created = yield* networkconnectivity
619 .createProjectsLocationsTransports({
620 parent: parentOf(env.project, location),
621 transportId,
622 body: toCreateBody(news, env.project, location, desiredLabels),
623 })
624 .pipe(
625 Effect.retry({
626 while: (error) => error._tag === "Conflict",
627 times: 5,
628 schedule: Schedule.spaced("2 seconds"),
629 }),
630 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
631 );
632 if (created !== undefined) {
633 yield* waitForOperation(created);
634 }
635 current = yield* waitUntilReady(name);
636 }
637
638 if (current === undefined) {
639 return yield* new TransportNotResolved({ name });
640 }
641
642 const observedLabels = tagRecord(current.labels);
643 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
644 const labelsChanged = upsert.length > 0 || removed.length > 0;
645 const descriptionChanged =
646 (current.description ?? "") !== (news.description ?? "");
647 const bandwidthChanged =
648 news.bandwidth !== undefined &&
649 normalizeBandwidth(current.bandwidth) !==
650 normalizeBandwidth(news.bandwidth);
651 const stackTypeChanged =
652 news.stackType !== undefined &&
653 normalizeStackType(current.stackType) !==
654 normalizeStackType(news.stackType);
655 const routesChanged =
656 news.advertisedRoutes !== undefined &&
657 !sameList(news.advertisedRoutes, current.advertisedRoutes);
658
659 if (
660 labelsChanged ||
661 descriptionChanged ||
662 bandwidthChanged ||
663 stackTypeChanged ||
664 routesChanged
665 ) {
666 const updateMask = [
667 labelsChanged ? "labels" : undefined,
668 descriptionChanged ? "description" : undefined,
669 bandwidthChanged ? "bandwidth" : undefined,
670 stackTypeChanged ? "stackType" : undefined,
671 routesChanged ? "advertisedRoutes" : undefined,
672 ].filter((field): field is string => field !== undefined);
673
674 const operation =
675 yield* networkconnectivity.patchProjectsLocationsTransports({
676 name: current.name ?? name,
677 updateMask: updateMask.join(","),
678 body: {
679 name: current.name ?? name,
680 labels: desiredLabels,
681 description: news.description,
682 bandwidth: news.bandwidth,
683 stackType: news.stackType,
684 advertisedRoutes: news.advertisedRoutes,
685 },
686 });
687 yield* waitForOperation(operation);
688 current = yield* waitUntilReady(current.name ?? name);
689 }
690
691 return toAttrs(current, env.project, env.region);
692 }),
693
694 delete: Effect.fn(function* ({ output }) {
695 const operation = yield* networkconnectivity
696 .deleteProjectsLocationsTransports({ name: output.name })
697 .pipe(
698 Effect.retry({
699 while: (error) => error._tag === "Conflict",
700 times: 8,
701 schedule: Schedule.spaced("2 seconds"),
702 }),
703 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
704 );
705 if (operation !== undefined) {
706 yield* waitForOperation(operation, { notFoundOk: true });
707 }
708 yield* waitUntilGone(output.name);
709 }),
710 });