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 DEFAULT_LOCATION = "global";
24const DEFAULT_POLICY_MODE =
25 "PRESET" satisfies networkconnectivity.HubPolicyModeEnum;
26const DEFAULT_PRESET_TOPOLOGY =
27 "MESH" satisfies networkconnectivity.HubPresetTopologyEnum;
28const MAX_NAME_LENGTH = 63;
29
30export type HubState = networkconnectivity.HubStateEnum | (string & {});
31export type HubPolicyMode =
32 | networkconnectivity.HubPolicyModeEnum
33 | (string & {});
34export type HubPresetTopology =
35 | networkconnectivity.HubPresetTopologyEnum
36 | (string & {});
37
38export type HubRoutingVpc = {
39 /** URI of the VPC network attached via a spoke. */
40 uri: string | undefined;
41 /**
42 * Whether this VPC must be used for new site-to-site data-transfer
43 * spokes.
44 */
45 requiredForNewSiteToSiteDataTransferSpokes: boolean | undefined;
46};
47
48export type HubProps = {
49 /**
50 * Hub id (the `{hub}` segment of
51 * `projects/{project}/locations/global/hubs/{hub}`). If omitted, a
52 * unique RFC1035 name is generated from the stack, stage, and logical
53 * id. Must be 1-63 characters and match `[a-z]([-a-z0-9]*[a-z0-9])?`.
54 * Immutable — changing it replaces the hub.
55 */
56 hubId?: string;
57 /**
58 * Human-readable description of the hub.
59 */
60 description?: string;
61 /**
62 * Whether Private Service Connect connection propagation is enabled.
63 * When true, PSC endpoints in VPC spokes are reachable from other VPC
64 * spokes on this hub.
65 * @default false
66 */
67 exportPsc?: boolean;
68 /**
69 * Policy mode. `PRESET` applies `presetTopology`; `CUSTOM` leaves
70 * topology unspecified.
71 * @default "PRESET"
72 */
73 policyMode?: HubPolicyMode;
74 /**
75 * Preset topology used when `policyMode` is `PRESET`. Ignored (and
76 * reported as `PRESET_TOPOLOGY_UNSPECIFIED`) when `policyMode` is
77 * `CUSTOM`.
78 * @default "MESH"
79 */
80 presetTopology?: HubPresetTopology;
81 /**
82 * User labels. Alchemy ownership labels are merged in automatically.
83 */
84 labels?: Record<string, string>;
85};
86
87export type Hub = Resource<
88 "GCP.NetworkConnectivity.Hub",
89 HubProps,
90 {
91 /** Full resource name `projects/{project}/locations/global/hubs/{hub}`. */
92 name: string;
93 /** Hub id (last path segment). */
94 hubId: string;
95 /** Project id. */
96 project: string;
97 /** Location id. Hubs are global — always `"global"`. */
98 location: string;
99 /** User-provided description. */
100 description: string | undefined;
101 /** Whether PSC connection propagation is enabled. */
102 exportPsc: boolean;
103 /** Policy mode currently configured on the hub. */
104 policyMode: string | undefined;
105 /** Preset topology currently configured on the hub. */
106 presetTopology: string | undefined;
107 /** User labels (Alchemy ownership labels stripped). */
108 labels: Record<string, string>;
109 /** Server-generated UUID, unique across hubs. */
110 uniqueId: string | undefined;
111 /** Server-reported lifecycle state (`ACTIVE`, `CREATING`, …). */
112 state: string | undefined;
113 /** VPC networks associated with this hub's spokes (output-only). */
114 routingVpcs: HubRoutingVpc[];
115 /** Route table names nested under this hub (output-only). */
116 routeTables: ReadonlyArray<string>;
117 /** RFC3339 creation timestamp. */
118 createTime: string | undefined;
119 /** RFC3339 last-update timestamp. */
120 updateTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * A Network Connectivity Center hub — the global attachment point for
128 * VPC, VPN, and interconnect spokes.
129 *
130 * Hubs live at `locations/global`. Changing `hubId` replaces the hub.
131 * Description, labels, `exportPsc`, `policyMode`, and `presetTopology`
132 * update in place. A hub can only be deleted when it has no spokes.
133 *
134 * ### Creating a Hub
135 * **Example:** Generated name
136 * ```typescript
137 * const hub = yield* GCP.NetworkConnectivity.Hub("Mesh", {});
138 * ```
139 *
140 * **Example:** Named hub with labels
141 * ```typescript
142 * const hub = yield* GCP.NetworkConnectivity.Hub("Mesh", {
143 * hubId: "app-mesh",
144 * description: "prod mesh",
145 * labels: { env: "prod" },
146 * });
147 * ```
148 *
149 * ### Preset topology
150 * **Example:** STAR hub
151 * ```typescript
152 * const hub = yield* GCP.NetworkConnectivity.Hub("Star", {
153 * policyMode: "PRESET",
154 * presetTopology: "STAR",
155 * });
156 * ```
157 *
158 * ### Private Service Connect
159 * **Example:** Enable PSC transitivity
160 * ```typescript
161 * const hub = yield* GCP.NetworkConnectivity.Hub("Mesh", {
162 * exportPsc: true,
163 * });
164 * ```
165 *
166 * ### Updating a Hub
167 * **Example:** Description, labels, and PSC
168 * ```typescript
169 * const hub = yield* GCP.NetworkConnectivity.Hub("Mesh", {
170 * hubId: "app-mesh",
171 * description: "prod mesh v2",
172 * exportPsc: true,
173 * labels: { env: "prod", role: "ncc" },
174 * });
175 * ```
176 *
177 * @resource
178 * @category NetworkConnectivity
179 */
180export const Hub = Resource<Hub>("GCP.NetworkConnectivity.Hub");
181
182export class HubNotResolved extends Data.TaggedError(
183 "GCP.NetworkConnectivity.HubNotResolved",
184)<{
185 name: string;
186}> {}
187
188export class HubFailed extends Data.TaggedError(
189 "GCP.NetworkConnectivity.HubFailed",
190)<{
191 name: string;
192 state: string | undefined;
193}> {}
194
195export class HubStillExists extends Data.TaggedError(
196 "GCP.NetworkConnectivity.HubStillExists",
197)<{
198 name: string;
199}> {}
200
201const lastSegment = (value: string) => {
202 const trimmed = value.replace(/\/+$/, "");
203 const parts = trimmed.split("/");
204 return parts[parts.length - 1] || trimmed;
205};
206
207const rfc1035 = (name: string): string => {
208 let next = name
209 .toLowerCase()
210 .replace(/[^a-z0-9-]/g, "-")
211 .replace(/-+/g, "-")
212 .replace(/^-+/, "")
213 .replace(/-+$/, "");
214 if (!/^[a-z]/.test(next)) {
215 next = `h${next}`;
216 }
217 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
218 return next.length > 0 ? next : "hub";
219};
220
221const resourceName = (project: string, hubId: string) =>
222 `projects/${project}/locations/${DEFAULT_LOCATION}/hubs/${hubId}`;
223
224const parentOf = (project: string) =>
225 `projects/${project}/locations/${DEFAULT_LOCATION}`;
226
227const parseName = (name: string) => {
228 const parts = name.split("/").filter((part) => part.length > 0);
229 const hubsAt = parts.lastIndexOf("hubs");
230 const locationsAt = parts.lastIndexOf("locations");
231 const projectsAt = parts.lastIndexOf("projects");
232 return {
233 project:
234 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
235 location:
236 locationsAt >= 0 && parts[locationsAt + 1]
237 ? parts[locationsAt + 1]!
238 : DEFAULT_LOCATION,
239 hubId:
240 hubsAt >= 0 && parts[hubsAt + 1] ? parts[hubsAt + 1]! : lastSegment(name),
241 };
242};
243
244const userLabels = (
245 labels: Record<string, string | undefined> | null | undefined,
246): Record<string, string> => stripInternalLabels(tagRecord(labels));
247
248const toId = (id: string, hubId: string | undefined, existing?: string) =>
249 Effect.gen(function* () {
250 if (hubId !== undefined) return hubId;
251 if (existing !== undefined) return existing;
252 return rfc1035(
253 yield* createPhysicalName({
254 id,
255 maxLength: MAX_NAME_LENGTH,
256 lowercase: true,
257 }),
258 );
259 });
260
261const desiredPolicyMode = (news: HubProps): HubPolicyMode =>
262 news.policyMode ?? DEFAULT_POLICY_MODE;
263
264const desiredPresetTopology = (news: HubProps): HubPresetTopology => {
265 if (desiredPolicyMode(news) === "CUSTOM") {
266 return "PRESET_TOPOLOGY_UNSPECIFIED";
267 }
268 return news.presetTopology ?? DEFAULT_PRESET_TOPOLOGY;
269};
270
271const toRoutingVpcs = (
272 vpcs: networkconnectivity.RoutingVPCList | undefined,
273): HubRoutingVpc[] =>
274 (vpcs ?? []).map((vpc) => ({
275 uri: vpc.uri,
276 requiredForNewSiteToSiteDataTransferSpokes:
277 vpc.requiredForNewSiteToSiteDataTransferSpokes,
278 }));
279
280const toAttrs = (hub: networkconnectivity.Hub, project: string) => {
281 const name = hub.name ?? "";
282 const parsed = parseName(name);
283 return {
284 name,
285 hubId: parsed.hubId,
286 project: parsed.project || project,
287 location: parsed.location || DEFAULT_LOCATION,
288 description: hub.description,
289 exportPsc: hub.exportPsc === true,
290 policyMode: hub.policyMode,
291 presetTopology: hub.presetTopology,
292 labels: userLabels(hub.labels),
293 uniqueId: hub.uniqueId,
294 state: hub.state,
295 routingVpcs: toRoutingVpcs(hub.routingVpcs),
296 routeTables: hub.routeTables ?? [],
297 createTime: hub.createTime,
298 updateTime: hub.updateTime,
299 };
300};
301
302const getByName = (name: string) =>
303 networkconnectivity
304 .getProjectsLocationsGlobalHubs({ name })
305 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
306
307const isPendingState = (state: string | undefined) =>
308 state === "CREATING" ||
309 state === "UPDATING" ||
310 state === "DELETING" ||
311 state === "ACCEPTING" ||
312 state === "REJECTING" ||
313 state === "STATE_UNSPECIFIED";
314
315const waitUntilReady = (name: string) =>
316 getByName(name).pipe(
317 Effect.filterOrFail(
318 (hub): hub is networkconnectivity.Hub => hub !== undefined,
319 () => new HubNotResolved({ name }),
320 ),
321 Effect.filterOrFail(
322 (hub) => hub.state !== "FAILED" && hub.state !== "OBSOLETE",
323 (hub) => new HubFailed({ name, state: hub.state }),
324 ),
325 Effect.filterOrFail(
326 (hub) => !isPendingState(hub.state),
327 () => new HubNotResolved({ name }),
328 ),
329 Effect.retry({
330 while: (error) => error._tag === "GCP.NetworkConnectivity.HubNotResolved",
331 times: 10,
332 schedule: Schedule.spaced("2 seconds"),
333 }),
334 );
335
336const waitUntilGone = (name: string) =>
337 getByName(name).pipe(
338 Effect.flatMap((hub) =>
339 hub === undefined
340 ? Effect.void
341 : Effect.fail(new HubStillExists({ name })),
342 ),
343 Effect.retry({
344 while: (error) => error._tag === "GCP.NetworkConnectivity.HubStillExists",
345 times: 10,
346 schedule: Schedule.spaced("3 seconds"),
347 }),
348 );
349
350const listOwnedHubs = (project: string) =>
351 networkconnectivity.listProjectsLocationsGlobalHubs
352 .pages({
353 parent: parentOf(project),
354 pageSize: 1000,
355 })
356 .pipe(
357 Stream.flatMap((page) => Stream.fromIterable(page.hubs ?? [])),
358 Stream.filter((hub) =>
359 Object.keys(hub.labels ?? {}).some((key) => key.startsWith("alchemy-")),
360 ),
361 Stream.map((hub) => toAttrs(hub, project)),
362 Stream.runCollect,
363 Effect.map((chunk) => Array.from(chunk)),
364 Effect.catchTag("NotFound", () => Effect.succeed([])),
365 );
366
367export const HubProvider = () =>
368 Provider.succeed(Hub, {
369 stables: ["name", "hubId", "project", "location", "uniqueId", "createTime"],
370
371 diff: Effect.fn(function* ({ news, olds, output }) {
372 if (!isResolved(news)) return undefined;
373 const previousId = olds?.hubId ?? output?.hubId;
374 const nextId = news.hubId ?? previousId;
375 if (
376 previousId !== undefined &&
377 nextId !== undefined &&
378 nextId !== previousId
379 ) {
380 return { action: "replace" as const };
381 }
382 return undefined;
383 }),
384
385 read: Effect.fn(function* ({ id, olds, output }) {
386 const env = yield* GcpEnvironment.current;
387 const hubId = yield* toId(id, olds?.hubId, output?.hubId);
388 const name = output?.name ?? resourceName(env.project, hubId);
389 const existing = yield* getByName(name);
390 if (existing === undefined) return undefined;
391 const attrs = toAttrs(existing, env.project);
392 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
393 ? attrs
394 : Unowned(attrs);
395 }),
396
397 list: () =>
398 Effect.gen(function* () {
399 const env = yield* GcpEnvironment.current;
400 return yield* listOwnedHubs(env.project);
401 }),
402
403 reconcile: Effect.fn(function* ({ id, news, output }) {
404 const env = yield* GcpEnvironment.current;
405 const hubId = yield* toId(id, news.hubId, output?.hubId);
406 const name = output?.name ?? resourceName(env.project, hubId);
407 const desiredLabels = {
408 ...toLabels(news.labels),
409 ...(yield* createInternalLabels(id)),
410 };
411 const policyMode = desiredPolicyMode(news);
412 const presetTopology = desiredPresetTopology(news);
413 const exportPsc = news.exportPsc === true;
414
415 let current = yield* getByName(name);
416
417 if (current === undefined) {
418 const created = yield* networkconnectivity
419 .createProjectsLocationsGlobalHubs({
420 parent: parentOf(env.project),
421 hubId,
422 body: {
423 description: news.description,
424 labels: desiredLabels,
425 exportPsc,
426 policyMode,
427 presetTopology,
428 },
429 })
430 .pipe(
431 Effect.retry({
432 while: (error) => error._tag === "Conflict",
433 times: 5,
434 schedule: Schedule.spaced("2 seconds"),
435 }),
436 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
437 );
438 if (created !== undefined) {
439 yield* waitForOperation(created);
440 }
441 current = yield* waitUntilReady(name);
442 }
443
444 if (current === undefined) {
445 return yield* new HubNotResolved({ name });
446 }
447
448 const observedLabels = tagRecord(current.labels);
449 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
450 const labelsChanged = upsert.length > 0 || removed.length > 0;
451 const descriptionChanged =
452 (current.description ?? "") !== (news.description ?? "");
453 const exportPscChanged = (current.exportPsc === true) !== exportPsc;
454 const policyModeChanged =
455 (current.policyMode ?? DEFAULT_POLICY_MODE) !== policyMode;
456 const presetTopologyChanged =
457 (current.presetTopology ?? DEFAULT_PRESET_TOPOLOGY) !== presetTopology;
458
459 if (
460 labelsChanged ||
461 descriptionChanged ||
462 exportPscChanged ||
463 policyModeChanged ||
464 presetTopologyChanged
465 ) {
466 const updateMask = [
467 labelsChanged ? "labels" : undefined,
468 descriptionChanged ? "description" : undefined,
469 exportPscChanged ? "exportPsc" : undefined,
470 policyModeChanged ? "policyMode" : undefined,
471 presetTopologyChanged ? "presetTopology" : undefined,
472 ].filter((field): field is string => field !== undefined);
473
474 const operation =
475 yield* networkconnectivity.patchProjectsLocationsGlobalHubs({
476 name: current.name ?? name,
477 updateMask: updateMask.join(","),
478 body: {
479 name: current.name ?? name,
480 labels: desiredLabels,
481 description: news.description,
482 exportPsc,
483 policyMode,
484 presetTopology,
485 },
486 });
487 yield* waitForOperation(operation);
488 current = yield* waitUntilReady(current.name ?? name);
489 }
490
491 return toAttrs(current, env.project);
492 }),
493
494 delete: Effect.fn(function* ({ output }) {
495 const operation = yield* networkconnectivity
496 .deleteProjectsLocationsGlobalHubs({ name: output.name })
497 .pipe(
498 Effect.retry({
499 while: (error) => error._tag === "Conflict",
500 times: 8,
501 schedule: Schedule.spaced("2 seconds"),
502 }),
503 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
504 );
505 if (operation !== undefined) {
506 yield* waitForOperation(operation, { notFoundOk: true });
507 }
508 yield* waitUntilGone(output.name);
509 }),
510 });