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 { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForLongRunning } from "../Operation.ts";
10
11export const DEFAULT_GLOBAL = "global";
12export const MAX_NAME_LENGTH = 63;
13
14export class NetworkConnectivityNotResolved extends Data.TaggedError(
15 "GCP.NetworkConnectivity.NotResolved",
16)<{
17 name: string;
18}> {}
19
20export class NetworkConnectivityStillExists extends Data.TaggedError(
21 "GCP.NetworkConnectivity.StillExists",
22)<{
23 name: string;
24}> {}
25
26export class NetworkConnectivityFailed extends Data.TaggedError(
27 "GCP.NetworkConnectivity.Failed",
28)<{
29 name: string;
30 state: string | undefined;
31}> {}
32
33export const lastSegment = (value: string) => {
34 const trimmed = value.replace(/\/+$/, "");
35 const parts = trimmed.split("/");
36 return parts[parts.length - 1] || trimmed;
37};
38
39export const rfc1035 = (
40 name: string,
41 fallback = "resource",
42 maxLength = MAX_NAME_LENGTH,
43): string => {
44 let next = name
45 .toLowerCase()
46 .replace(/[^a-z0-9-]/g, "-")
47 .replace(/-+/g, "-")
48 .replace(/^-+/, "")
49 .replace(/-+$/, "");
50 if (!/^[a-z]/.test(next)) {
51 next = `n${next}`;
52 }
53 next = next.slice(0, maxLength).replace(/-+$/, "");
54 return next.length > 0 ? next : fallback;
55};
56
57export const normalizeLocation = (
58 location: string | undefined,
59 fallback: string,
60) => lastSegment(location ?? fallback).toLowerCase();
61
62export const parentOf = (project: string, location: string) =>
63 `projects/${project}/locations/${location}`;
64
65export const parentOfName = (name: string, collection: string) => {
66 const parts = name.split("/").filter((part) => part.length > 0);
67 const at = parts.lastIndexOf(collection);
68 if (at <= 0) return name;
69 return parts.slice(0, at).join("/");
70};
71
72export const parseName = (
73 name: string,
74 collection: string,
75 fallbackLocation: string,
76) => {
77 const parts = name.split("/").filter((part) => part.length > 0);
78 const collectionAt = parts.lastIndexOf(collection);
79 const locationsAt = parts.lastIndexOf("locations");
80 const projectsAt = parts.lastIndexOf("projects");
81 return {
82 project:
83 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
84 location:
85 locationsAt >= 0 && parts[locationsAt + 1]
86 ? parts[locationsAt + 1]!
87 : fallbackLocation,
88 id:
89 collectionAt >= 0 && parts[collectionAt + 1]
90 ? parts[collectionAt + 1]!
91 : lastSegment(name),
92 };
93};
94
95export const toPhysicalId = (
96 id: string,
97 explicit: string | undefined,
98 existing: string | undefined,
99 fallback = "resource",
100 maxLength = MAX_NAME_LENGTH,
101) =>
102 Effect.gen(function* () {
103 if (explicit !== undefined) return explicit;
104 if (existing !== undefined) return existing;
105 return rfc1035(
106 yield* createPhysicalName({
107 id,
108 maxLength,
109 lowercase: true,
110 }),
111 fallback,
112 maxLength,
113 );
114 });
115
116export const userLabels = (
117 labels: Record<string, string | undefined> | null | undefined,
118): Record<string, string> => stripInternalLabels(tagRecord(labels));
119
120export const hasAlchemyLabelKeys = (
121 labels: Record<string, string | undefined> | null | undefined,
122) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
123
124export const sameJson = (left: unknown, right: unknown) =>
125 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
126
127export const sameStringList = (
128 left: readonly string[] | undefined,
129 right: readonly string[] | undefined,
130) =>
131 JSON.stringify([...(left ?? [])].map(canonicalizeLink).sort()) ===
132 JSON.stringify([...(right ?? [])].map(canonicalizeLink).sort());
133
134export const canonicalizeLink = (value: string | undefined) => {
135 if (value === undefined || value.length === 0) return "";
136 return value
137 .replace(/^https?:\/\/[^/]+\//, "")
138 .replace(/^compute\/v1\//, "")
139 .replace(/\/+$/, "");
140};
141
142export const toNetworkResource = (project: string, network: string) => {
143 const trimmed = canonicalizeLink(network);
144 if (trimmed.includes("/")) return trimmed;
145 return `projects/${project}/global/networks/${trimmed}`;
146};
147
148export const toSubnetworkResource = (
149 project: string,
150 region: string,
151 subnetwork: string,
152) => {
153 const trimmed = canonicalizeLink(subnetwork);
154 if (trimmed.includes("/")) return trimmed;
155 return `projects/${project}/regions/${region}/subnetworks/${trimmed}`;
156};
157
158export const changedFields = (
159 pairs: ReadonlyArray<readonly [string, boolean]>,
160) => pairs.filter(([, changed]) => changed).map(([field]) => field);
161
162/**
163 * Wait for a Network Connectivity Center long-running operation. Transports,
164 * gateway spokes and internal ranges can take tens of minutes.
165 * `ALREADY_EXISTS` (a concurrent create won) always succeeds;
166 * `notFoundOk` also accepts a `NOT_FOUND` result or an operation that has
167 * already been garbage-collected (deletes).
168 */
169export const waitForOperation = (
170 operation: networkconnectivity.GoogleLongrunningOperation,
171 options?: { notFoundOk?: boolean },
172) =>
173 waitForLongRunning(
174 operation,
175 (name) => networkconnectivity.getProjectsLocationsOperations({ name }),
176 { budget: "30 minutes" },
177 ).pipe(
178 Effect.catchIf(
179 (error) =>
180 error._tag === "GCP.OperationFailed" &&
181 (error.code === 6 ||
182 (options?.notFoundOk === true && error.code === 5)),
183 () => Effect.void,
184 ),
185 Effect.catchIf(
186 (error) => options?.notFoundOk === true && error._tag === "NotFound",
187 () => Effect.void,
188 ),
189 );
190
191export const waitUntilPresent = <A, E, R>(
192 get: Effect.Effect<A | undefined, E, R>,
193 name: string,
194): Effect.Effect<A, E | NetworkConnectivityNotResolved, R> =>
195 get.pipe(
196 Effect.filterOrFail(
197 (value): value is A => value !== undefined,
198 () => new NetworkConnectivityNotResolved({ name }),
199 ),
200 Effect.retry({
201 while: (error) => error instanceof NetworkConnectivityNotResolved,
202 times: 8,
203 schedule: Schedule.spaced("1 second"),
204 }),
205 );
206
207export const waitUntilGone = <A, E, R>(
208 get: Effect.Effect<A | undefined, E, R>,
209 name: string,
210): Effect.Effect<void, E | NetworkConnectivityStillExists, R> =>
211 get.pipe(
212 Effect.filterOrFail(
213 (value) => value === undefined,
214 () => new NetworkConnectivityStillExists({ name }),
215 ),
216 Effect.retry({
217 while: (error) => error instanceof NetworkConnectivityStillExists,
218 times: 10,
219 schedule: Schedule.spaced("2 seconds"),
220 }),
221 Effect.asVoid,
222 );
223
224const PENDING_STATES = new Set([
225 "STATE_UNSPECIFIED",
226 "CREATING",
227 "UPDATING",
228 "DELETING",
229 "ACCEPTING",
230 "REJECTING",
231]);
232
233const FAILED_STATES = new Set([
234 "FAILED",
235 "DELETE_FAILED",
236 "OBSOLETE",
237 "FAILED_DEPROGRAMMING",
238]);
239
240export const waitUntilReady = <A extends { state?: string }, E, R>(
241 get: Effect.Effect<A | undefined, E, R>,
242 name: string,
243): Effect.Effect<
244 A,
245 E | NetworkConnectivityNotResolved | NetworkConnectivityFailed,
246 R
247> =>
248 get.pipe(
249 Effect.filterOrFail(
250 (value): value is A => value !== undefined,
251 () => new NetworkConnectivityNotResolved({ name }),
252 ),
253 Effect.filterOrFail(
254 (value) => !FAILED_STATES.has(value.state ?? ""),
255 (value) => new NetworkConnectivityFailed({ name, state: value.state }),
256 ),
257 Effect.filterOrFail(
258 (value) => !PENDING_STATES.has(value.state ?? ""),
259 () => new NetworkConnectivityNotResolved({ name }),
260 ),
261 Effect.retry({
262 while: (error) => error instanceof NetworkConnectivityNotResolved,
263 times: 10,
264 schedule: Schedule.spaced("4 seconds"),
265 }),
266 );
267
268/** Collect every page; a missing parent (`NotFound`) lists as empty. */
269export const collectPages = <
270 Page,
271 Item,
272 E extends { readonly _tag: string },
273 R,
274>(
275 stream: Stream.Stream<Page, E, R>,
276 pick: (page: Page) => readonly Item[] | undefined,
277) =>
278 stream.pipe(
279 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
280 Stream.runCollect,
281 Effect.map((chunk): Item[] => Array.from(chunk)),
282 Effect.catchIf(
283 (error) => error._tag === "NotFound",
284 () => Effect.succeed<Item[]>([]),
285 ),
286 );