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
20export type CrossSiteNetworkProps = {
21 /**
22 * Cross-site network name (RFC1035, 1-63 chars). If omitted, a unique
23 * name is generated from the stack, stage, and logical id. Changing it
24 * replaces the resource.
25 */
26 crossSiteNetworkName?: string;
27 /**
28 * Optional description. Compute CrossSiteNetwork has no labels field —
29 * Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
30 * nuke.
31 */
32 description?: string;
33};
34
35export type CrossSiteNetwork = Resource<
36 "GCP.Compute.CrossSiteNetwork",
37 CrossSiteNetworkProps,
38 {
39 /** Cross-site network name. */
40 crossSiteNetworkName: string;
41 /** Project id. */
42 project: string;
43 /** User description with the Alchemy ownership prefix stripped. */
44 description: string | undefined;
45 /** Server-assigned numeric id. */
46 crossSiteNetworkId: string | undefined;
47 /** Server-defined URL. */
48 selfLink: string | undefined;
49 /** RFC3339 creation timestamp. */
50 creationTimestamp: string | undefined;
51 /** Resource kind. */
52 kind: string | undefined;
53 },
54 never,
55 Providers
56>;
57
58/**
59 * A global Compute Engine cross-site network.
60 *
61 * Cross-site networks group Wire Groups that connect on-premises and
62 * cloud sites through Cross-Cloud Interconnect. The resource itself is a
63 * named container — Wire Groups attach later. Compute has no labels
64 * field, so Alchemy stamps ownership into the description.
65 *
66 * ### Creating a Cross-Site Network
67 * **Example:** Generated name
68 * ```typescript
69 * const network = yield* GCP.Compute.CrossSiteNetwork("backbone", {});
70 * ```
71 *
72 * **Example:** Named network with a description
73 * ```typescript
74 * const network = yield* GCP.Compute.CrossSiteNetwork("backbone", {
75 * crossSiteNetworkName: "prod-backbone",
76 * description: "cross-cloud interconnect fabric",
77 * });
78 * ```
79 *
80 * @resource
81 * @category Compute
82 */
83export const CrossSiteNetwork = Resource<CrossSiteNetwork>(
84 "GCP.Compute.CrossSiteNetwork",
85);
86
87export class CrossSiteNetworkNotResolved extends Data.TaggedError(
88 "GCP.Compute.CrossSiteNetworkNotResolved",
89)<{
90 crossSiteNetworkName: string;
91}> {}
92
93const rfc1035 = (name: string): string => {
94 let next = name
95 .toLowerCase()
96 .replace(/[^a-z0-9-]/g, "-")
97 .replace(/-+/g, "-")
98 .replace(/^-+/, "")
99 .replace(/-+$/, "");
100 if (!/^[a-z]/.test(next)) {
101 next = `c${next}`;
102 }
103 next = next.slice(0, 63).replace(/-+$/, "");
104 return next.length > 0 ? next : "crosssitenetwork";
105};
106
107const toName = (id: string, name: string | undefined, existing?: string) =>
108 Effect.gen(function* () {
109 if (name !== undefined) return name;
110 if (existing !== undefined) return existing;
111 return rfc1035(
112 yield* createPhysicalName({
113 id,
114 maxLength: 63,
115 lowercase: true,
116 }),
117 );
118 });
119
120const encodeDescription = (
121 labels: Record<string, string>,
122 description: string | undefined,
123): string => {
124 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
125 return description ? `${marker}\n${description}` : marker;
126};
127
128const parseDescription = (
129 description: string | undefined,
130): {
131 labels: Record<string, string>;
132 description: string | undefined;
133} => {
134 if (!description?.startsWith("[alchemy ")) {
135 return { labels: {}, description };
136 }
137 const end = description.indexOf("]");
138 if (end < 0) return { labels: {}, description };
139 const labels: Record<string, string> = {};
140 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
141 const eq = part.indexOf("=");
142 if (eq > 0) {
143 labels[part.slice(0, eq)] = part.slice(eq + 1);
144 }
145 }
146 const rest = description.slice(end + 1).replace(/^\n/, "");
147 return { labels, description: rest.length > 0 ? rest : undefined };
148};
149
150const toAttrs = (
151 network: compute.CrossSiteNetwork,
152 project: string,
153): CrossSiteNetwork["Attributes"] => {
154 const parsed = parseDescription(network.description);
155 return {
156 crossSiteNetworkName: network.name ?? network.id ?? "",
157 project,
158 description: parsed.description,
159 crossSiteNetworkId: network.id,
160 selfLink: network.selfLink,
161 creationTimestamp: network.creationTimestamp,
162 kind: network.kind,
163 };
164};
165
166const getByName = (project: string, crossSiteNetwork: string) =>
167 compute
168 .getCrossSiteNetworks({ project, crossSiteNetwork })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const awaitResource = (project: string, crossSiteNetworkName: string) =>
172 getByName(project, crossSiteNetworkName).pipe(
173 Effect.repeat({
174 schedule: Schedule.spaced("1 second"),
175 until: (network) => network !== undefined,
176 times: 8,
177 }),
178 );
179
180export const CrossSiteNetworkProvider = () =>
181 Provider.succeed(CrossSiteNetwork, {
182 stables: [
183 "crossSiteNetworkName",
184 "project",
185 "crossSiteNetworkId",
186 "selfLink",
187 "creationTimestamp",
188 ],
189
190 diff: Effect.fn(function* ({ news, olds, output }) {
191 if (!isResolved(news)) return undefined;
192 const previousName =
193 olds?.crossSiteNetworkName ?? output?.crossSiteNetworkName;
194 const nextName = news.crossSiteNetworkName;
195 if (
196 previousName !== undefined &&
197 nextName !== undefined &&
198 previousName !== nextName
199 ) {
200 return { action: "replace" as const };
201 }
202 return undefined;
203 }),
204
205 read: Effect.fn(function* ({ id, olds, output }) {
206 const env = yield* GcpEnvironment.current;
207 const crossSiteNetworkName = yield* toName(
208 id,
209 olds?.crossSiteNetworkName,
210 output?.crossSiteNetworkName,
211 );
212 const existing = yield* getByName(env.project, crossSiteNetworkName);
213 if (existing === undefined) return undefined;
214 const attrs = toAttrs(existing, env.project);
215 const { labels } = parseDescription(existing.description);
216 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
217 }),
218
219 list: () =>
220 Effect.gen(function* () {
221 const env = yield* GcpEnvironment.current;
222 return yield* compute.listCrossSiteNetworks
223 .items({ project: env.project, maxResults: 500 })
224 .pipe(
225 Stream.filter((network) => {
226 const { labels } = parseDescription(network.description);
227 return Object.keys(labels).some((key) =>
228 key.startsWith("alchemy-"),
229 );
230 }),
231 Stream.map((network) => toAttrs(network, env.project)),
232 Stream.runCollect,
233 Effect.map((chunk) => Array.from(chunk)),
234 );
235 }),
236
237 reconcile: Effect.fn(function* ({ id, news, output }) {
238 const env = yield* GcpEnvironment.current;
239 const crossSiteNetworkName = yield* toName(
240 id,
241 news.crossSiteNetworkName,
242 output?.crossSiteNetworkName,
243 );
244 const ownership = yield* createInternalLabels(id);
245 const description = encodeDescription(ownership, news.description);
246
247 let current = yield* getByName(env.project, crossSiteNetworkName);
248
249 if (current === undefined) {
250 yield* compute
251 .insertCrossSiteNetworks({
252 project: env.project,
253 body: {
254 name: crossSiteNetworkName,
255 description,
256 },
257 })
258 .pipe(
259 Effect.flatMap((operation) =>
260 waitGlobalOperation(env.project, operation, {
261 ignore: ["RESOURCE_ALREADY_EXISTS"],
262 }),
263 ),
264 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
265 );
266 current = yield* awaitResource(env.project, crossSiteNetworkName);
267 }
268
269 if (current === undefined) {
270 return yield* new CrossSiteNetworkNotResolved({
271 crossSiteNetworkName,
272 });
273 }
274
275 if ((current.description ?? "") !== description) {
276 yield* compute
277 .patchCrossSiteNetworks({
278 project: env.project,
279 crossSiteNetwork: crossSiteNetworkName,
280 updateMask: "description",
281 body: {
282 name: crossSiteNetworkName,
283 description,
284 },
285 })
286 .pipe(
287 Effect.flatMap((operation) =>
288 waitGlobalOperation(env.project, operation),
289 ),
290 );
291 current = yield* getByName(env.project, crossSiteNetworkName);
292 if (current === undefined) {
293 return yield* new CrossSiteNetworkNotResolved({
294 crossSiteNetworkName,
295 });
296 }
297 }
298
299 return toAttrs(current, env.project);
300 }),
301
302 delete: Effect.fn(function* ({ output }) {
303 const env = yield* GcpEnvironment.current;
304 const operation = yield* compute
305 .deleteCrossSiteNetworks({
306 project: env.project,
307 crossSiteNetwork: output.crossSiteNetworkName,
308 })
309 .pipe(
310 Effect.retry({
311 while: (error) => error._tag === "Conflict",
312 schedule: Schedule.spaced("2 seconds"),
313 times: 8,
314 }),
315 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
316 );
317 if (operation !== undefined) {
318 yield* waitGlobalOperation(env.project, operation).pipe(
319 Effect.catchTag("NotFound", () => Effect.void),
320 );
321 }
322 }),
323 });