1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const MAX_NAME_LENGTH = 63;
21
22export type WireGroupWireProperties = compute.WireProperties;
23export type WireGroupEndpoint = compute.WireGroupEndpoint;
24export type WireGroupEndpointMap = compute.WireGroupEndpointMap;
25
26export type WireGroupProps = {
27 /**
28 * Wire group name (RFC1035, 1-63 characters). If omitted, a unique name
29 * is generated from the stack, stage, and logical id. Changing the name
30 * replaces the group.
31 */
32 wireGroupName?: string;
33 /**
34 * Parent cross-site network name. Immutable — changing it replaces the
35 * group.
36 */
37 crossSiteNetwork: string;
38 /**
39 * Optional description. Compute wire groups have no labels field, so
40 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
41 * is stored in a `[alchemy …]` prefix for `list` / nuke.
42 */
43 description?: string;
44 /**
45 * Whether wires in the group are enabled. Mutable in place.
46 * @default true
47 */
48 adminEnabled?: boolean;
49 /**
50 * Properties applied to every wire (bandwidth, fault response).
51 */
52 wireProperties?: WireGroupWireProperties;
53 /**
54 * Logical endpoints keyed by RFC1035 labels. Each endpoint lists
55 * interconnects and VLAN tags.
56 */
57 endpoints?: WireGroupEndpointMap;
58};
59
60export type WireGroup = Resource<
61 "GCP.Compute.WireGroup",
62 WireGroupProps,
63 {
64 /** Wire group name. */
65 wireGroupName: string;
66 /** Parent cross-site network name. */
67 crossSiteNetwork: string;
68 /** Server-assigned numeric id. */
69 wireGroupId: string | undefined;
70 /** Project id. */
71 project: string;
72 /** User description with the Alchemy ownership prefix stripped. */
73 description: string | undefined;
74 /** Whether wires in the group are enabled. */
75 adminEnabled: boolean;
76 /** Wire properties. */
77 wireProperties: WireGroupWireProperties | undefined;
78 /** Logical endpoints. */
79 endpoints: WireGroupEndpointMap | undefined;
80 /** Whether the group still has pending wire changes. */
81 reconciling: boolean;
82 /** Compute Engine self-link. */
83 selfLink: string | undefined;
84 /** RFC3339 creation timestamp. */
85 creationTimestamp: string | undefined;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * A global Compute Engine wire group on a cross-site network.
93 *
94 * Wire groups connect Interconnect endpoints across metros. Compute Engine
95 * has no labels on this resource, so Alchemy stamps ownership into the
96 * description so `list` / `pnpm nuke:gcp` can find them.
97 *
98 * Name and parent `crossSiteNetwork` are immutable — changing them
99 * replaces the group. `adminEnabled`, `wireProperties`, `endpoints`, and
100 * description update in place via `wireGroups.patch`.
101 *
102 * ### Creating a Wire Group
103 * **Example:** Two-endpoint VLAN wire group
104 * ```typescript
105 * const wires = yield* GCP.Compute.WireGroup("metro", {
106 * crossSiteNetwork: network.crossSiteNetworkName,
107 * description: "nyc-to-sfo",
108 * wireProperties: {
109 * bandwidthUnmetered: "10",
110 * bandwidthAllocation: "SHARED_WITH_WIRE_GROUP",
111 * },
112 * endpoints: {
113 * nyc: {
114 * interconnects: {
115 * a: { interconnect: "global/interconnects/nyc-a", vlanTags: [100] },
116 * },
117 * },
118 * sfo: {
119 * interconnects: {
120 * a: { interconnect: "global/interconnects/sfo-a", vlanTags: [100] },
121 * },
122 * },
123 * },
124 * });
125 * ```
126 *
127 * @resource
128 * @category Compute
129 */
130export const WireGroup = Resource<WireGroup>("GCP.Compute.WireGroup");
131
132export class WireGroupNotResolved extends Data.TaggedError(
133 "GCP.Compute.WireGroupNotResolved",
134)<{
135 wireGroupName: string;
136 crossSiteNetwork: string;
137}> {}
138
139export class WireGroupStillExists extends Data.TaggedError(
140 "GCP.Compute.WireGroupStillExists",
141)<{
142 wireGroupName: string;
143}> {}
144
145const lastSegment = (value: string | undefined): string => {
146 if (value === undefined || value.length === 0) return "";
147 const trimmed = value.replace(/\/+$/, "");
148 const parts = trimmed.split("/");
149 return parts[parts.length - 1] || trimmed;
150};
151
152const toName = (id: string, name: string | undefined, existing?: string) =>
153 Effect.gen(function* () {
154 if (name !== undefined) return name;
155 if (existing !== undefined) return existing;
156 const generated = yield* createPhysicalName({
157 id,
158 maxLength: MAX_NAME_LENGTH,
159 lowercase: true,
160 });
161 return /^[a-z]/.test(generated)
162 ? generated
163 : `w${generated}`.slice(0, MAX_NAME_LENGTH);
164 });
165
166const encodeDescription = (
167 labels: Record<string, string>,
168 description: string | undefined,
169): string => {
170 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
171 return description ? `${marker}\n${description}` : marker;
172};
173
174const parseDescription = (
175 description: string | undefined,
176): {
177 labels: Record<string, string>;
178 description: string | undefined;
179} => {
180 if (!description?.startsWith("[alchemy ")) {
181 return { labels: {}, description };
182 }
183 const end = description.indexOf("]");
184 if (end < 0) return { labels: {}, description };
185 const labels: Record<string, string> = {};
186 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
187 const eq = part.indexOf("=");
188 if (eq > 0) {
189 labels[part.slice(0, eq)] = part.slice(eq + 1);
190 }
191 }
192 const rest = description.slice(end + 1).replace(/^\n/, "");
193 return { labels, description: rest.length > 0 ? rest : undefined };
194};
195
196const hasOwnershipMarker = (description: string | undefined) =>
197 Object.keys(parseDescription(description).labels).some((key) =>
198 key.startsWith("alchemy-"),
199 );
200
201const sameJson = (left: unknown, right: unknown) =>
202 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
203
204const toBody = (
205 wireGroupName: string,
206 props: WireGroupProps,
207 ownership: Record<string, string>,
208): compute.WireGroup => ({
209 name: wireGroupName,
210 description: encodeDescription(ownership, props.description),
211 adminEnabled: props.adminEnabled ?? true,
212 wireProperties: props.wireProperties,
213 endpoints: props.endpoints,
214});
215
216const toAttrs = (
217 group: compute.WireGroup,
218 project: string,
219 crossSiteNetwork: string,
220): WireGroup["Attributes"] => {
221 const parsed = parseDescription(group.description);
222 return {
223 wireGroupName: group.name ?? group.id ?? "",
224 crossSiteNetwork: lastSegment(crossSiteNetwork),
225 wireGroupId: group.id,
226 project,
227 description: parsed.description,
228 adminEnabled: group.adminEnabled !== false,
229 wireProperties: group.wireProperties,
230 endpoints: group.endpoints,
231 reconciling: group.reconciling === true,
232 selfLink: group.selfLink,
233 creationTimestamp: group.creationTimestamp,
234 };
235};
236
237const getByName = (
238 project: string,
239 crossSiteNetwork: string,
240 wireGroup: string,
241) =>
242 compute
243 .getWireGroups({ project, crossSiteNetwork, wireGroup })
244 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
245
246const waitGroupGone = (
247 project: string,
248 crossSiteNetwork: string,
249 wireGroupName: string,
250) =>
251 getByName(project, crossSiteNetwork, wireGroupName).pipe(
252 Effect.flatMap((group) =>
253 group === undefined
254 ? Effect.void
255 : Effect.fail(
256 new WireGroupStillExists({
257 wireGroupName,
258 }),
259 ),
260 ),
261 Effect.retry({
262 while: (error) => error instanceof WireGroupStillExists,
263 times: 10,
264 schedule: Schedule.spaced("2 seconds"),
265 }),
266 );
267
268const listParents = (project: string) =>
269 compute.listCrossSiteNetworks
270 .items({ project, maxResults: 500, returnPartialSuccess: true })
271 .pipe(
272 Stream.runCollect,
273 Effect.map((chunk) => Array.from(chunk)),
274 Effect.catchTag("NotFound", () => Effect.succeed([])),
275 );
276
277export const WireGroupProvider = () =>
278 Provider.succeed(WireGroup, {
279 stables: [
280 "wireGroupName",
281 "wireGroupId",
282 "project",
283 "crossSiteNetwork",
284 "selfLink",
285 "creationTimestamp",
286 ],
287
288 diff: Effect.fn(function* ({ news, olds, output }) {
289 if (!isResolved(news)) return undefined;
290
291 const previousName = olds?.wireGroupName ?? output?.wireGroupName;
292 const nextName = news.wireGroupName ?? previousName;
293 const nameChanged =
294 previousName !== undefined &&
295 nextName !== undefined &&
296 previousName !== nextName;
297
298 const previousParent = lastSegment(
299 olds?.crossSiteNetwork ?? output?.crossSiteNetwork,
300 );
301 const nextParent = lastSegment(news.crossSiteNetwork);
302 const parentChanged =
303 previousParent.length > 0 &&
304 nextParent.length > 0 &&
305 previousParent !== nextParent;
306
307 if (nameChanged || parentChanged) {
308 return {
309 action: "replace" as const,
310 deleteFirst: !parentChanged && !nameChanged ? true : parentChanged,
311 };
312 }
313 return undefined;
314 }),
315
316 read: Effect.fn(function* ({ id, olds, output }) {
317 const env = yield* GcpEnvironment.current;
318 const wireGroupName = yield* toName(
319 id,
320 olds?.wireGroupName,
321 output?.wireGroupName,
322 );
323 const preferredParent = lastSegment(
324 olds?.crossSiteNetwork ?? output?.crossSiteNetwork,
325 );
326
327 if (preferredParent.length > 0) {
328 const existing = yield* getByName(
329 env.project,
330 preferredParent,
331 wireGroupName,
332 );
333 if (existing !== undefined) {
334 const attrs = toAttrs(existing, env.project, preferredParent);
335 const { labels } = parseDescription(existing.description);
336 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
337 }
338 }
339
340 const parents = yield* listParents(env.project);
341 for (const parent of parents) {
342 const parentName = parent.name;
343 if (parentName === undefined) continue;
344 const existing = yield* getByName(
345 env.project,
346 parentName,
347 wireGroupName,
348 );
349 if (existing === undefined) continue;
350 const attrs = toAttrs(existing, env.project, parentName);
351 const { labels } = parseDescription(existing.description);
352 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
353 }
354 return undefined;
355 }),
356
357 list: () =>
358 Effect.gen(function* () {
359 const env = yield* GcpEnvironment.current;
360 const parents = yield* listParents(env.project);
361 const groups = yield* Effect.forEach(
362 parents,
363 (parent) => {
364 const crossSiteNetwork = parent.name;
365 if (crossSiteNetwork === undefined) {
366 return Effect.succeed([] as WireGroup["Attributes"][]);
367 }
368 return compute.listWireGroups
369 .items({
370 project: env.project,
371 crossSiteNetwork,
372 maxResults: 500,
373 returnPartialSuccess: true,
374 })
375 .pipe(
376 Stream.filter((group) => hasOwnershipMarker(group.description)),
377 Stream.map((group) =>
378 toAttrs(group, env.project, crossSiteNetwork),
379 ),
380 Stream.runCollect,
381 Effect.map((chunk) => Array.from(chunk)),
382 Effect.catchTag("NotFound", () => Effect.succeed([])),
383 );
384 },
385 { concurrency: 8 },
386 );
387 return groups.flat();
388 }),
389
390 reconcile: Effect.fn(function* ({ id, news, output }) {
391 const env = yield* GcpEnvironment.current;
392 const wireGroupName = yield* toName(
393 id,
394 news.wireGroupName,
395 output?.wireGroupName,
396 );
397 const crossSiteNetwork = lastSegment(
398 news.crossSiteNetwork ?? output?.crossSiteNetwork,
399 );
400 const ownership = yield* createInternalLabels(id);
401 const desired = toBody(wireGroupName, news, ownership);
402
403 let current = yield* getByName(
404 env.project,
405 crossSiteNetwork,
406 wireGroupName,
407 );
408
409 if (current === undefined) {
410 const inserted = yield* compute
411 .insertWireGroups({
412 project: env.project,
413 crossSiteNetwork,
414 body: desired,
415 })
416 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
417 if (inserted !== undefined) {
418 yield* waitGlobalOperation(env.project, inserted, {
419 ignore: ["RESOURCE_ALREADY_EXISTS"],
420 });
421 }
422 current = yield* getByName(
423 env.project,
424 crossSiteNetwork,
425 wireGroupName,
426 ).pipe(
427 Effect.filterOrFail(
428 (group): group is compute.WireGroup => group !== undefined,
429 () =>
430 new WireGroupNotResolved({
431 wireGroupName,
432 crossSiteNetwork,
433 }),
434 ),
435 Effect.retry({
436 while: (error) => error._tag === "GCP.Compute.WireGroupNotResolved",
437 times: 8,
438 schedule: Schedule.spaced("1 second"),
439 }),
440 );
441 }
442
443 if (current === undefined) {
444 return yield* new WireGroupNotResolved({
445 wireGroupName,
446 crossSiteNetwork,
447 });
448 }
449
450 const descriptionChanged =
451 (current.description ?? "") !== (desired.description ?? "");
452 const adminChanged =
453 (current.adminEnabled !== false) !== (desired.adminEnabled !== false);
454 const propertiesChanged = !sameJson(
455 current.wireProperties,
456 desired.wireProperties,
457 );
458 const endpointsChanged = !sameJson(current.endpoints, desired.endpoints);
459
460 if (
461 descriptionChanged ||
462 adminChanged ||
463 propertiesChanged ||
464 endpointsChanged
465 ) {
466 const patched = yield* compute.patchWireGroups({
467 project: env.project,
468 crossSiteNetwork,
469 wireGroup: wireGroupName,
470 updateMask: [
471 descriptionChanged ? "description" : undefined,
472 adminChanged ? "adminEnabled" : undefined,
473 propertiesChanged ? "wireProperties" : undefined,
474 endpointsChanged ? "endpoints" : undefined,
475 ]
476 .filter((field): field is string => field !== undefined)
477 .join(","),
478 body: desired,
479 });
480 yield* waitGlobalOperation(env.project, patched);
481 current =
482 (yield* getByName(env.project, crossSiteNetwork, wireGroupName)) ??
483 current;
484 }
485
486 return toAttrs(current, env.project, crossSiteNetwork);
487 }),
488
489 delete: Effect.fn(function* ({ output }) {
490 const env = yield* GcpEnvironment.current;
491 const project = output.project || env.project;
492 const crossSiteNetwork = lastSegment(output.crossSiteNetwork);
493 const deleted = yield* compute
494 .deleteWireGroups({
495 project,
496 crossSiteNetwork,
497 wireGroup: output.wireGroupName,
498 })
499 .pipe(
500 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
501 Effect.retry({
502 while: (error) => error._tag === "Conflict",
503 times: 8,
504 schedule: Schedule.spaced("2 seconds"),
505 }),
506 );
507 if (deleted !== undefined) {
508 yield* waitGlobalOperation(project, deleted, {
509 ignore: ["RESOURCE_NOT_FOUND"],
510 });
511 }
512 yield* waitGroupGone(project, crossSiteNetwork, output.wireGroupName);
513 }),
514 });