1import * as networkservices from "@distilled.cloud/gcp/networkservices_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 { alchemyLabelKeys, stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForLongRunning } from "../Operation.ts";
10
11export const DEFAULT_GLOBAL = "global";
12// Multicast resources are zonal; a zone cannot be derived from the stack
13// region (not every region has an `-a` zone), so this default stays fixed.
14export const DEFAULT_ZONE = "us-central1-a";
15export const MAX_NAME_LENGTH = 63;
16export const MAX_MULTICAST_NAME_LENGTH = 48;
17
18export class NetworkservicesNotResolved extends Data.TaggedError(
19 "GCP.NetworkServices.NotResolved",
20)<{
21 name: string;
22}> {}
23
24export class NetworkservicesStillExists extends Data.TaggedError(
25 "GCP.NetworkServices.StillExists",
26)<{
27 name: string;
28}> {}
29
30export class NetworkservicesFailed extends Data.TaggedError(
31 "GCP.NetworkServices.Failed",
32)<{
33 name: string;
34 state: string | undefined;
35}> {}
36
37export const lastSegment = (value: string) => {
38 const trimmed = value.replace(/\/+$/, "");
39 const parts = trimmed.split("/");
40 return parts[parts.length - 1] || trimmed;
41};
42
43export const rfc1035 = (
44 name: string,
45 fallback = "resource",
46 maxLength = MAX_NAME_LENGTH,
47): string => {
48 let next = name
49 .toLowerCase()
50 .replace(/[^a-z0-9-]/g, "-")
51 .replace(/-+/g, "-")
52 .replace(/^-+/, "")
53 .replace(/-+$/, "");
54 if (!/^[a-z]/.test(next)) {
55 next = `n${next}`;
56 }
57 next = next.slice(0, maxLength).replace(/-+$/, "");
58 return next.length > 0 ? next : fallback;
59};
60
61export const normalizeLocation = (
62 location: string | undefined,
63 fallback: string,
64) => lastSegment(location ?? fallback).toLowerCase();
65
66export const parentOf = (project: string, location: string) =>
67 `projects/${project}/locations/${location}`;
68
69export const resourceName = (
70 project: string,
71 location: string,
72 collection: string,
73 id: string,
74) => `projects/${project}/locations/${location}/${collection}/${id}`;
75
76export const parseName = (
77 name: string,
78 collection: string,
79 fallbackLocation: string,
80) => {
81 const parts = name.split("/").filter((part) => part.length > 0);
82 const collectionAt = parts.lastIndexOf(collection);
83 const locationsAt = parts.lastIndexOf("locations");
84 const projectsAt = parts.lastIndexOf("projects");
85 return {
86 project:
87 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
88 location:
89 locationsAt >= 0 && parts[locationsAt + 1]
90 ? parts[locationsAt + 1]!
91 : fallbackLocation,
92 id:
93 collectionAt >= 0 && parts[collectionAt + 1]
94 ? parts[collectionAt + 1]!
95 : lastSegment(name),
96 };
97};
98
99export const toPhysicalId = (
100 id: string,
101 explicit: string | undefined,
102 existing: string | undefined,
103 fallback = "resource",
104 maxLength = MAX_NAME_LENGTH,
105) =>
106 Effect.gen(function* () {
107 if (explicit !== undefined) return rfc1035(explicit, fallback, maxLength);
108 if (existing !== undefined) return existing;
109 return rfc1035(
110 yield* createPhysicalName({
111 id,
112 maxLength,
113 lowercase: true,
114 }),
115 fallback,
116 maxLength,
117 );
118 });
119
120export const userLabels = (
121 labels: Record<string, string | undefined> | null | undefined,
122): Record<string, string> => stripInternalLabels(tagRecord(labels));
123
124export const hasAlchemyLabelKeys = (
125 labels: Record<string, string | undefined> | null | undefined,
126) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
127
128export const encodeOwnership = (
129 labels: Record<string, string>,
130 text: string | undefined,
131): string => {
132 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
133 const trimmed = text?.trim();
134 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
135};
136
137export const parseOwnership = (
138 text: string | undefined,
139): {
140 labels: Record<string, string>;
141 text: string | undefined;
142} => {
143 if (!text?.startsWith("[alchemy ")) {
144 return { labels: {}, text };
145 }
146 const end = text.indexOf("]");
147 if (end < 0) return { labels: {}, text };
148 const labels: Record<string, string> = {};
149 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
150 const eq = part.indexOf("=");
151 if (eq > 0) {
152 labels[part.slice(0, eq)] = part.slice(eq + 1);
153 }
154 }
155 const rest = text.slice(end + 1).replace(/^\s+/, "");
156 return { labels, text: rest.length > 0 ? rest : undefined };
157};
158
159export const hasOwnershipMarker = (text: string | undefined) =>
160 Object.keys(parseOwnership(text).labels).some((key) =>
161 key.startsWith("alchemy-"),
162 );
163
164export const sameJson = (left: unknown, right: unknown) =>
165 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
166
167export const canonicalizeLink = (value: string | undefined) => {
168 if (value === undefined || value.length === 0) return "";
169 return value
170 .replace(/^https?:\/\/[^/]+\//, "")
171 .replace(/^compute\/v1\//, "")
172 .replace(/\/+$/, "");
173};
174
175export const sameStringList = (
176 left: readonly string[] | undefined,
177 right: readonly string[] | undefined,
178) =>
179 JSON.stringify([...(left ?? [])].map(canonicalizeLink).sort()) ===
180 JSON.stringify([...(right ?? [])].map(canonicalizeLink).sort());
181
182export const sameNumberList = (
183 left: readonly number[] | undefined,
184 right: readonly number[] | undefined,
185) => JSON.stringify([...(left ?? [])]) === JSON.stringify([...(right ?? [])]);
186
187export const linkKey = (value: string | undefined) =>
188 lastSegment(canonicalizeLink(value)).toLowerCase();
189
190export const toMulticastNetwork = (project: string, network: string) => {
191 const trimmed = canonicalizeLink(network);
192 const parts = trimmed.split("/").filter((part) => part.length > 0);
193 const projectsAt = parts.lastIndexOf("projects");
194 const proj =
195 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : project;
196 const id = lastSegment(trimmed);
197 return `projects/${proj}/locations/global/networks/${id || trimmed}`;
198};
199
200export const toBackendServiceResource = (project: string, service: string) => {
201 const trimmed = canonicalizeLink(service);
202 if (
203 trimmed.includes("/backendServices/") ||
204 trimmed.includes("/backendservices/")
205 ) {
206 if (trimmed.includes("/locations/")) return trimmed;
207 const parts = trimmed.split("/").filter((part) => part.length > 0);
208 const projectsAt = parts.lastIndexOf("projects");
209 const proj =
210 projectsAt >= 0 && parts[projectsAt + 1]
211 ? parts[projectsAt + 1]!
212 : project;
213 return `projects/${proj}/locations/global/backendServices/${lastSegment(trimmed)}`;
214 }
215 return `projects/${project}/locations/global/backendServices/${lastSegment(trimmed)}`;
216};
217
218export const toNamedResource = (
219 project: string,
220 location: string,
221 collection: string,
222 value: string,
223) => {
224 const trimmed = canonicalizeLink(value);
225 if (trimmed.includes(`/${collection}/`)) return trimmed;
226 return resourceName(project, location, collection, lastSegment(trimmed));
227};
228
229export const changedFields = (
230 pairs: ReadonlyArray<readonly [string, boolean]>,
231) => pairs.filter(([, changed]) => changed).map(([field]) => field);
232
233/**
234 * Wait for a Network Services long-running operation. Agent gateways and
235 * multicast resources take several minutes.
236 * `ALREADY_EXISTS` (a concurrent create won) always succeeds;
237 * `notFoundOk` also accepts a `NOT_FOUND` result or an operation that has
238 * already been garbage-collected (deletes).
239 */
240export const waitForOperation = (
241 operation: networkservices.Operation,
242 options?: { notFoundOk?: boolean },
243) =>
244 waitForLongRunning(
245 operation,
246 (name) => networkservices.getProjectsLocationsOperations({ name }),
247 { budget: "20 minutes" },
248 ).pipe(
249 Effect.catchIf(
250 (error) =>
251 error._tag === "GCP.OperationFailed" &&
252 (error.code === 6 ||
253 (options?.notFoundOk === true && error.code === 5)),
254 () => Effect.void,
255 ),
256 Effect.catchIf(
257 (error) => options?.notFoundOk === true && error._tag === "NotFound",
258 () => Effect.void,
259 ),
260 );
261
262export const waitUntilPresent = <A, E, R>(
263 get: Effect.Effect<A | undefined, E, R>,
264 name: string,
265) =>
266 get.pipe(
267 Effect.flatMap((value) =>
268 value
269 ? Effect.succeed(value)
270 : Effect.fail(new NetworkservicesNotResolved({ name })),
271 ),
272 Effect.retry({
273 while: (error) => error instanceof NetworkservicesNotResolved,
274 times: 8,
275 schedule: Schedule.spaced("1 second"),
276 }),
277 );
278
279export const waitUntilGone = <A, E, R>(
280 get: Effect.Effect<A | undefined, E, R>,
281 name: string,
282) =>
283 get.pipe(
284 Effect.flatMap((value) =>
285 value === undefined
286 ? Effect.void
287 : Effect.fail(new NetworkservicesStillExists({ name })),
288 ),
289 Effect.retry({
290 while: (error) => error instanceof NetworkservicesStillExists,
291 times: 10,
292 schedule: Schedule.spaced("2 seconds"),
293 }),
294 );
295
296const PENDING_STATES = new Set([
297 "STATE_UNSPECIFIED",
298 "STATE_ENUM_UNSPECIFIED",
299 "CREATING",
300 "UPDATING",
301 "DELETING",
302]);
303
304const FAILED_STATES = new Set([
305 "FAILED",
306 "DELETE_FAILED",
307 "UPDATE_FAILED",
308 "OBSOLETE",
309]);
310
311export const waitUntilReady = <A extends { state?: string }, E, R>(
312 get: Effect.Effect<A | undefined, E, R>,
313 name: string,
314) =>
315 get.pipe(
316 Effect.filterOrFail(
317 (value): value is A => value !== undefined,
318 () => new NetworkservicesNotResolved({ name }),
319 ),
320 Effect.filterOrFail(
321 (value) => !FAILED_STATES.has(value.state ?? ""),
322 (value) => new NetworkservicesFailed({ name, state: value.state }),
323 ),
324 Effect.filterOrFail(
325 (value) => !PENDING_STATES.has(value.state ?? ""),
326 () => new NetworkservicesNotResolved({ name }),
327 ),
328 Effect.retry({
329 while: (error) => error instanceof NetworkservicesNotResolved,
330 times: 10,
331 schedule: Schedule.spaced("4 seconds"),
332 }),
333 );
334
335/** Collect every page; a missing parent (`NotFound`) lists as empty. */
336export const collectPages = <
337 Page,
338 Item,
339 E extends { readonly _tag: string },
340 R,
341>(
342 stream: Stream.Stream<Page, E, R>,
343 pick: (page: Page) => readonly Item[] | undefined,
344) =>
345 stream.pipe(
346 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
347 Stream.runCollect,
348 Effect.map((chunk): Item[] => Array.from(chunk)),
349 Effect.catchIf(
350 (error) => error._tag === "NotFound",
351 () => Effect.succeed<Item[]>([]),
352 ),
353 );