1import * as networkmanagement from "@distilled.cloud/gcp/networkmanagement_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
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 { createPhysicalName } from "../../PhysicalName.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { stripInternalLabels } from "../Labels.ts";
11import { waitForOperation as waitForLongRunning } from "../Operation.ts";
12
13export const DEFAULT_GLOBAL = "global";
14export const MAX_NAME_LENGTH = 63;
15export const MAX_CONNECTIVITY_TEST_ID_LENGTH = 40;
16
17export class NetworkmanagementNotResolved extends Data.TaggedError(
18 "GCP.NetworkManagement.NotResolved",
19)<{
20 name: string;
21}> {}
22
23export class NetworkmanagementStillExists extends Data.TaggedError(
24 "GCP.NetworkManagement.StillExists",
25)<{
26 name: string;
27}> {}
28
29export class NetworkmanagementFailed extends Data.TaggedError(
30 "GCP.NetworkManagement.Failed",
31)<{
32 name: string;
33 state: string | undefined;
34}> {}
35
36export class OrganizationRequired extends Data.TaggedError(
37 "GCP.NetworkManagement.OrganizationRequired",
38)<{
39 project: string;
40}> {}
41
42export const lastSegment = (value: string) => {
43 const trimmed = value.replace(/\/+$/, "");
44 const parts = trimmed.split("/");
45 return parts[parts.length - 1] || trimmed;
46};
47
48export const rfc1035 = (
49 name: string,
50 fallback = "resource",
51 maxLength = MAX_NAME_LENGTH,
52): string => {
53 let next = name
54 .toLowerCase()
55 .replace(/[^a-z0-9-]/g, "-")
56 .replace(/-+/g, "-")
57 .replace(/^-+/, "")
58 .replace(/-+$/, "");
59 if (!/^[a-z]/.test(next)) {
60 next = `${fallback[0] ?? "n"}${next}`;
61 }
62 next = next.slice(0, maxLength).replace(/-+$/, "");
63 if (next.length > 0 && !/[a-z0-9]$/.test(next)) {
64 next = next.slice(0, -1);
65 }
66 return next.length > 0 ? next : fallback;
67};
68
69export const normalizeLocation = (
70 location: string | undefined,
71 fallback: string,
72) => lastSegment(location ?? fallback).toLowerCase();
73
74export const parentOf = (project: string, location: string) =>
75 `projects/${project}/locations/${location}`;
76
77export const organizationParent = (organization: string, location: string) =>
78 `organizations/${lastSegment(organization)}/locations/${location}`;
79
80export const organizationIdOf = (value: string) => lastSegment(value);
81
82export const resourceName = (
83 project: string,
84 location: string,
85 collection: string,
86 id: string,
87) => `projects/${project}/locations/${location}/${collection}/${id}`;
88
89export const organizationResourceName = (
90 organization: string,
91 location: string,
92 collection: string,
93 id: string,
94) =>
95 `organizations/${lastSegment(organization)}/locations/${location}/${collection}/${id}`;
96
97export const parseName = (
98 name: string,
99 collection: string,
100 fallbackLocation: string,
101) => {
102 const parts = name.split("/").filter((part) => part.length > 0);
103 const collectionAt = parts.lastIndexOf(collection);
104 const locationsAt = parts.lastIndexOf("locations");
105 const projectsAt = parts.lastIndexOf("projects");
106 const organizationsAt = parts.lastIndexOf("organizations");
107 return {
108 project:
109 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
110 organization:
111 organizationsAt >= 0 && parts[organizationsAt + 1]
112 ? parts[organizationsAt + 1]!
113 : "",
114 location:
115 locationsAt >= 0 && parts[locationsAt + 1]
116 ? parts[locationsAt + 1]!
117 : fallbackLocation,
118 id:
119 collectionAt >= 0 && parts[collectionAt + 1]
120 ? parts[collectionAt + 1]!
121 : lastSegment(name),
122 };
123};
124
125export const toPhysicalId = (
126 id: string,
127 explicit: string | undefined,
128 existing: string | undefined,
129 fallback = "resource",
130 maxLength = MAX_NAME_LENGTH,
131) =>
132 Effect.gen(function* () {
133 if (explicit !== undefined) return rfc1035(explicit, fallback, maxLength);
134 if (existing !== undefined) return existing;
135 return rfc1035(
136 yield* createPhysicalName({
137 id,
138 maxLength,
139 lowercase: true,
140 }),
141 fallback,
142 maxLength,
143 );
144 });
145
146export const userLabels = (
147 labels: Record<string, string | undefined> | null | undefined,
148): Record<string, string> => stripInternalLabels(tagRecord(labels));
149
150export const hasAlchemyLabelKeys = (
151 labels: Record<string, string | undefined> | null | undefined,
152) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
153
154export const sameJson = (left: unknown, right: unknown) =>
155 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
156
157export const canonicalizeLink = (value: string | undefined) => {
158 if (value === undefined || value.length === 0) return "";
159 return value
160 .replace(/^https?:\/\/[^/]+\//, "")
161 .replace(/^compute\/v1\//, "")
162 .replace(/\/+$/, "");
163};
164
165export const sameStringList = (
166 left: readonly string[] | undefined,
167 right: readonly string[] | undefined,
168) =>
169 JSON.stringify([...(left ?? [])].map(canonicalizeLink).sort()) ===
170 JSON.stringify([...(right ?? [])].map(canonicalizeLink).sort());
171
172export const changedFields = (
173 pairs: ReadonlyArray<readonly [string, boolean]>,
174) => pairs.filter(([, changed]) => changed).map(([field]) => field);
175
176export const toNetworkResource = (project: string, network: string) => {
177 const trimmed = canonicalizeLink(network);
178 const id = lastSegment(trimmed);
179 const parts = trimmed.split("/").filter((part) => part.length > 0);
180 const projectsAt = parts.lastIndexOf("projects");
181 const proj =
182 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : project;
183 return `projects/${proj}/global/networks/${id || trimmed}`;
184};
185
186export const toSubnetworkResource = (
187 project: string,
188 region: string,
189 subnetwork: string,
190) => {
191 const trimmed = canonicalizeLink(subnetwork);
192 if (trimmed.includes("/subnetworks/") || trimmed.includes("/subNetworks/")) {
193 const parts = trimmed.split("/").filter((part) => part.length > 0);
194 const projectsAt = parts.lastIndexOf("projects");
195 const regionsAt = parts.lastIndexOf("regions");
196 const proj =
197 projectsAt >= 0 && parts[projectsAt + 1]
198 ? parts[projectsAt + 1]!
199 : project;
200 const loc =
201 regionsAt >= 0 && parts[regionsAt + 1] ? parts[regionsAt + 1]! : region;
202 return `projects/${proj}/regions/${loc}/subnetworks/${lastSegment(trimmed)}`;
203 }
204 return `projects/${project}/regions/${region}/subnetworks/${lastSegment(trimmed)}`;
205};
206
207export const toRegionalComputeResource = (
208 project: string,
209 region: string,
210 collection: string,
211 value: string,
212) => {
213 const trimmed = canonicalizeLink(value);
214 if (trimmed.includes(`/${collection}/`)) {
215 const parts = trimmed.split("/").filter((part) => part.length > 0);
216 const projectsAt = parts.lastIndexOf("projects");
217 const regionsAt = parts.lastIndexOf("regions");
218 const proj =
219 projectsAt >= 0 && parts[projectsAt + 1]
220 ? parts[projectsAt + 1]!
221 : project;
222 const loc =
223 regionsAt >= 0 && parts[regionsAt + 1] ? parts[regionsAt + 1]! : region;
224 return `projects/${proj}/regions/${loc}/${collection}/${lastSegment(trimmed)}`;
225 }
226 return `projects/${project}/regions/${region}/${collection}/${lastSegment(trimmed)}`;
227};
228
229const getOperation = (name: string) =>
230 name.startsWith("organizations/")
231 ? networkmanagement.getOrganizationsLocationsGlobalOperations({ name })
232 : networkmanagement.getProjectsLocationsGlobalOperations({ name });
233
234/**
235 * Wait for a Network Management long-running operation. Connectivity tests
236 * and flow-log configs settle within minutes.
237 * `ALREADY_EXISTS` (a concurrent create won) always succeeds;
238 * `notFoundOk` also accepts a `NOT_FOUND` result or an operation that has
239 * already been garbage-collected (deletes).
240 */
241export const waitForOperation = (
242 operation: networkmanagement.Operation,
243 options?: { notFoundOk?: boolean },
244) =>
245 waitForLongRunning(
246 operation,
247 getOperation,
248 { budget: "10 minutes" },
249 ).pipe(
250 Effect.catchIf(
251 (error) =>
252 error._tag === "GCP.OperationFailed" &&
253 (error.code === 6 ||
254 (options?.notFoundOk === true && error.code === 5)),
255 () => Effect.void,
256 ),
257 Effect.catchIf(
258 (error) => options?.notFoundOk === true && error._tag === "NotFound",
259 () => Effect.void,
260 ),
261 );
262
263export const waitUntilPresent = <A, E, R>(
264 get: Effect.Effect<A | undefined, E, R>,
265 name: string,
266) =>
267 get.pipe(
268 Effect.flatMap((value) =>
269 value
270 ? Effect.succeed(value)
271 : Effect.fail(new NetworkmanagementNotResolved({ name })),
272 ),
273 Effect.retry({
274 while: (error) => error instanceof NetworkmanagementNotResolved,
275 times: 8,
276 schedule: Schedule.spaced("1 second"),
277 }),
278 );
279
280export const waitUntilGone = <A, E, R>(
281 get: Effect.Effect<A | undefined, E, R>,
282 name: string,
283) =>
284 get.pipe(
285 Effect.flatMap((value) =>
286 value === undefined
287 ? Effect.void
288 : Effect.fail(new NetworkmanagementStillExists({ name })),
289 ),
290 Effect.retry({
291 while: (error) => error instanceof NetworkmanagementStillExists,
292 times: 10,
293 schedule: Schedule.spaced("2 seconds"),
294 }),
295 );
296
297const PENDING_STATES = new Set([
298 "STATE_UNSPECIFIED",
299 "ACTIVATING",
300 "SUSPENDING",
301 "DELETING",
302]);
303
304const FAILED_STATES = new Set(["DELETED"]);
305
306export const waitUntilReady = <A extends { state?: string }, E, R>(
307 get: Effect.Effect<A | undefined, E, R>,
308 name: string,
309) =>
310 get.pipe(
311 Effect.filterOrFail(
312 (value): value is A => value !== undefined,
313 () => new NetworkmanagementNotResolved({ name }),
314 ),
315 Effect.filterOrFail(
316 (value) => !FAILED_STATES.has(value.state ?? ""),
317 (value) => new NetworkmanagementFailed({ name, state: value.state }),
318 ),
319 Effect.filterOrFail(
320 (value) => !PENDING_STATES.has(value.state ?? ""),
321 () => new NetworkmanagementNotResolved({ name }),
322 ),
323 Effect.retry({
324 while: (error) => error instanceof NetworkmanagementNotResolved,
325 times: 10,
326 schedule: Schedule.spaced("4 seconds"),
327 }),
328 );
329
330/** Collect every page; a missing parent (`NotFound`) lists as empty. */
331export const collectPages = <
332 Page,
333 Item,
334 E extends { readonly _tag: string },
335 R,
336>(
337 stream: Stream.Stream<Page, E, R>,
338 pick: (page: Page) => readonly Item[] | undefined,
339) =>
340 stream.pipe(
341 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
342 Stream.runCollect,
343 Effect.map((chunk): Item[] => Array.from(chunk)),
344 Effect.catchIf(
345 (error) => error._tag === "NotFound",
346 () => Effect.succeed<Item[]>([]),
347 ),
348 );
349
350const parentOfResource = (name: string) =>
351 name.startsWith("projects/")
352 ? resourcemanager.getProjects({ name }).pipe(
353 Effect.map((resource) => resource.parent),
354 Effect.catchTag("NotFound", () =>
355 Effect.succeed(undefined),
356 ),
357 )
358 : name.startsWith("folders/")
359 ? resourcemanager.getFolders({ name }).pipe(
360 Effect.map((folder) => folder.parent),
361 Effect.catchTag("NotFound", () =>
362 Effect.succeed(undefined),
363 ),
364 )
365 : Effect.succeed(undefined);
366
367const tryResolveOrganizationId = (project: string) =>
368 Effect.gen(function* () {
369 let current: string | undefined = `projects/${project}`;
370 for (let i = 0; i < 8; i++) {
371 if (current === undefined) return undefined;
372 if (current.startsWith("organizations/")) return lastSegment(current);
373 current = yield* parentOfResource(current);
374 }
375 return undefined;
376 });
377
378export const resolveOrganization = (
379 explicit: string | undefined,
380 existing: string | undefined,
381) =>
382 Effect.gen(function* () {
383 if (explicit !== undefined) return lastSegment(explicit);
384 if (existing !== undefined) return lastSegment(existing);
385 const env = yield* GcpEnvironment.current;
386 const resolved = yield* tryResolveOrganizationId(env.project);
387 if (resolved === undefined) {
388 return yield* new OrganizationRequired({ project: env.project });
389 }
390 return resolved;
391 });
392
393export const listOrganizations = (project: string) =>
394 Effect.gen(function* () {
395 const resolved = yield* tryResolveOrganizationId(project);
396 return resolved === undefined ? [] : [resolved];
397 });