1import * as netapp from "@distilled.cloud/gcp/netapp_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 {
10 waitForOperation as waitForLongRunning,
11 type LongRunningOperation,
12} from "../Operation.ts";
13
14export const MAX_NAME_LENGTH = 63;
15
16export class ResourceNotResolved extends Data.TaggedError(
17 "GCP.NetApp.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export class ResourceStillExists extends Data.TaggedError(
23 "GCP.NetApp.ResourceStillExists",
24)<{
25 name: string;
26}> {}
27
28export class ResourceNotReady extends Data.TaggedError(
29 "GCP.NetApp.ResourceNotReady",
30)<{
31 name: string;
32 state: string;
33}> {}
34
35export class ResourceFailed extends Data.TaggedError(
36 "GCP.NetApp.ResourceFailed",
37)<{
38 name: string;
39 state: string;
40 details: string | undefined;
41}> {}
42
43export const lastSegment = (value: string) => {
44 const trimmed = value.replace(/\/+$/, "");
45 const parts = trimmed.split("/");
46 return parts[parts.length - 1] || trimmed;
47};
48
49export const rfc1035 = (name: string, fallback = "netapp"): string => {
50 let next = name
51 .toLowerCase()
52 .replace(/[^a-z0-9-]/g, "-")
53 .replace(/-+/g, "-")
54 .replace(/^-+|-+$/g, "");
55 if (!/^[a-z]/.test(next)) next = `n${next}`;
56 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
57 if (next.length === 0) return fallback;
58 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
59 return next.slice(0, MAX_NAME_LENGTH);
60};
61
62export const normalizeLocation = (
63 location: string | undefined,
64 fallback: string,
65) => lastSegment(location ?? fallback).toLowerCase();
66
67export const parentOf = (project: string, location: string) =>
68 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
69
70export const toPhysicalId = (
71 id: string,
72 explicit: string | undefined,
73 existing: string | undefined,
74 fallback = "netapp",
75) =>
76 Effect.gen(function* () {
77 if (explicit !== undefined) return rfc1035(explicit, fallback);
78 if (existing !== undefined) return existing;
79 return rfc1035(
80 yield* createPhysicalName({
81 id,
82 maxLength: MAX_NAME_LENGTH,
83 lowercase: true,
84 }),
85 fallback,
86 );
87 });
88
89export const parseName = (
90 name: string,
91 collection: string,
92 fallbackLocation: string,
93) => {
94 const parts = name.split("/").filter((part) => part.length > 0);
95 const collectionAt = parts.lastIndexOf(collection);
96 const locationsAt = parts.lastIndexOf("locations");
97 const projectsAt = parts.lastIndexOf("projects");
98 return {
99 project:
100 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
101 location:
102 locationsAt >= 0 && parts[locationsAt + 1]
103 ? parts[locationsAt + 1]!
104 : fallbackLocation,
105 id:
106 collectionAt >= 0 && parts[collectionAt + 1]
107 ? parts.slice(collectionAt + 1).join("/")
108 : lastSegment(name),
109 parent:
110 collectionAt > 0
111 ? parts.slice(0, collectionAt).join("/")
112 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
113 };
114};
115
116export const expandParent = (
117 value: string,
118 project: string,
119 location: string,
120 collection: string,
121) => {
122 if (value.includes("/")) return value.replace(/\/+$/, "");
123 return `projects/${project}/locations/${location}/${collection}/${value}`;
124};
125
126export const networkName = (project: string, network: string | undefined) => {
127 const value = network ?? "default";
128 if (value.includes("/")) return value;
129 return `projects/${project}/global/networks/${value}`;
130};
131
132export const gibOf = (
133 value: number | string | undefined,
134): string | undefined => (value === undefined ? undefined : String(value));
135
136export const userLabels = (
137 labels: Record<string, string | undefined> | null | undefined,
138): Record<string, string> => stripInternalLabels(tagRecord(labels));
139
140export const hasAlchemyLabelMap = (
141 labels: Record<string, string | undefined> | null | undefined,
142) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
143
144export const sortedStrings = (values: ReadonlyArray<string> | undefined) =>
145 [...(values ?? [])].map((value) => value).sort();
146
147export const sameStringList = (
148 left: readonly string[] | undefined,
149 right: readonly string[] | undefined,
150) =>
151 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
152
153export const canonical = (value: unknown): unknown => {
154 if (value === undefined || value === null) return undefined;
155 if (typeof value === "boolean" || typeof value === "number") return value;
156 if (typeof value === "string") {
157 return value.length === 0 ? undefined : value;
158 }
159 if (Array.isArray(value)) {
160 const items = value.map(canonical).filter((item) => item !== undefined);
161 return items.length === 0 ? undefined : items;
162 }
163 if (typeof value === "object") {
164 const entries = Object.entries(value as Record<string, unknown>)
165 .map(([key, item]) => [key, canonical(item)] as const)
166 .filter(([, item]) => item !== undefined)
167 .sort(([left], [right]) => left.localeCompare(right));
168 if (entries.length === 0) return undefined;
169 return Object.fromEntries(entries);
170 }
171 return undefined;
172};
173
174export const fingerprint = (value: unknown): string =>
175 JSON.stringify(canonical(value) ?? null);
176
177export const fieldMask = (fields: Array<string | false | undefined>) =>
178 fields
179 .filter((field): field is string => typeof field === "string")
180 .join(",");
181
182export const replaceOnIdentity = (input: {
183 previousId: string | undefined;
184 nextId: string | undefined;
185 previousLocation: string;
186 nextLocation: string;
187 extra?: boolean;
188 previousParent?: string;
189 nextParent?: string;
190}) => {
191 const parentChanged =
192 (input.previousParent ?? "") !== "" &&
193 (input.nextParent ?? "") !== "" &&
194 (input.previousParent ?? "") !== (input.nextParent ?? "");
195 const replace =
196 (input.extra ?? false) ||
197 parentChanged ||
198 (input.previousId !== undefined &&
199 input.nextId !== undefined &&
200 input.nextId !== input.previousId) ||
201 input.previousLocation !== input.nextLocation;
202 if (!replace) return undefined;
203 const samePhysical =
204 input.previousLocation === input.nextLocation &&
205 !parentChanged &&
206 input.previousId !== undefined &&
207 input.nextId === input.previousId;
208 return {
209 action: "replace" as const,
210 deleteFirst: samePhysical,
211 };
212};
213
214// A KMS config parks in KEY_CHECK_PENDING until the NetApp service agent is
215// granted the key and `verify` is called — a settled state, not provisioning.
216const READY_STATES = new Set([
217 "READY",
218 "IN_USE",
219 "READ_ONLY",
220 "KEY_CHECK_PENDING",
221]);
222const FAILED_STATES = new Set(["ERROR", "KEY_NOT_REACHABLE"]);
223
224export const isReadyState = (state: string | undefined) =>
225 READY_STATES.has((state ?? "").toUpperCase());
226
227export const isFailedState = (state: string | undefined) =>
228 FAILED_STATES.has((state ?? "").toUpperCase());
229
230// NetApp pool, volume, backup, and replication operations routinely run for
231// several minutes; poll for up to ~10 minutes before giving up.
232export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
233 get: Effect.Effect<A, E, R>,
234 name: string,
235): Effect.Effect<A & {}, E | ResourceNotResolved, R> =>
236 get.pipe(
237 Effect.filterOrFail(
238 (value): value is A & {} => value !== undefined,
239 () => new ResourceNotResolved({ name }),
240 ),
241 Effect.retry({
242 while: (error) => error._tag === "GCP.NetApp.ResourceNotResolved",
243 times: 8,
244 schedule: Schedule.spaced("1 second"),
245 }),
246 );
247
248export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
249 get: Effect.Effect<A, E, R>,
250 name: string,
251) =>
252 get.pipe(
253 Effect.filterOrFail(
254 (value) => value === undefined,
255 () => new ResourceStillExists({ name }),
256 ),
257 Effect.retry({
258 while: (error) => error._tag === "GCP.NetApp.ResourceStillExists",
259 times: OPERATION_POLLS,
260 schedule: Schedule.spaced("5 seconds"),
261 }),
262 Effect.asVoid,
263 );
264
265export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
266 get: Effect.Effect<A, E, R>,
267 name: string,
268 stateOf: (value: A & {}) => string | undefined,
269 detailsOf?: (value: A & {}) => string | undefined,
270 options?: {
271 times?: number;
272 interval?: `${number} seconds`;
273 },
274): Effect.Effect<
275 A & {},
276 E | ResourceNotResolved | ResourceNotReady | ResourceFailed,
277 R
278> =>
279 get.pipe(
280 Effect.filterOrFail(
281 (value): value is A & {} => value !== undefined,
282 () => new ResourceNotResolved({ name }),
283 ),
284 Effect.filterOrFail(
285 (value) => !isFailedState(stateOf(value)),
286 (value) =>
287 new ResourceFailed({
288 name,
289 state: stateOf(value) ?? "",
290 details: detailsOf?.(value),
291 }),
292 ),
293 Effect.filterOrFail(
294 (value) => {
295 const state = stateOf(value) ?? "";
296 return isReadyState(state) || state.length === 0;
297 },
298 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
299 ),
300 Effect.retry({
301 while: (error) =>
302 error._tag === "GCP.NetApp.ResourceNotReady" ||
303 error._tag === "GCP.NetApp.ResourceNotResolved",
304 times: options?.times ?? OPERATION_POLLS,
305 schedule: Schedule.spaced(options?.interval ?? "5 seconds"),
306 }),
307 );
308
309export const collectPages = <Page, A, E, R>(
310 pages: Stream.Stream<Page, E, R>,
311 items: (page: Page) => readonly A[] | undefined,
312) =>
313 pages.pipe(
314 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
315 Stream.runCollect,
316 Effect.map((chunk) => Array.from(chunk)),
317 );
318
319/** Every location at once: the API accepts the `locations/-` wildcard. */
320export const listAtLocation = <A, E, R>(
321 project: string,
322 list: (parent: string) => Effect.Effect<A[], E, R>,
323) => list(`projects/${project}/locations/-`);
324
325export const volumeOf = (value: string, project: string, location: string) =>
326 expandParent(value, project, location, "volumes");
327
328export const storagePoolOf = (
329 value: string,
330 project: string,
331 location: string,
332) => expandParent(value, project, location, "storagePools");
333
334export const listVolumes = (project: string) =>
335 listAtLocation(project, (parent) =>
336 collectPages(
337 netapp.listProjectsLocationsVolumes.pages({ parent, pageSize: 1000 }),
338 (page) => page.volumes,
339 ).pipe(
340 Effect.catchTag("NotFound", () => Effect.succeed([] as netapp.Volume[])),
341 ),
342 );
343
344export const listVolumeChildren = <A, E, R>(
345 project: string,
346 region: string,
347 list: (volumeName: string) => Effect.Effect<A[], E, R>,
348) =>
349 listVolumes(project).pipe(
350 Effect.flatMap((volumes) =>
351 Effect.forEach(
352 volumes.filter((volume) => (volume.name ?? "").length > 0),
353 (volume) => list(volume.name!),
354 { concurrency: 4 },
355 ).pipe(Effect.map((groups) => groups.flat())),
356 ),
357 );
358
359/** Children of every parent in every location (`locations/-/{nested}`). */
360export const listAtNested = <A, E, R>(
361 project: string,
362 nested: string,
363 list: (parent: string) => Effect.Effect<A[], E, R>,
364) => list(`projects/${project}/locations/-/${nested}`);
365
366export const listLabeledPages = <Page, A, E, R>(
367 pages: Stream.Stream<Page, E, R>,
368 items: (page: Page) => readonly A[] | undefined,
369 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
370) =>
371 pages.pipe(
372 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
373 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
374 Stream.runCollect,
375 Effect.map((chunk) => Array.from(chunk)),
376 );
377
378/** Readiness / gone polls: 360 × 5s = 30 minutes. */
379const OPERATION_POLLS = 360;
380
381/** Storage pool, volume and replication operations take up to ~20 minutes. */
382const OPERATION_BUDGET = "30 minutes";
383
384/**
385 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
386 * concurrent create won the race; reconcile observes the resource next.
387 */
388export const waitForOperation = (operation: LongRunningOperation) =>
389 waitForLongRunning(
390 operation,
391 (name) => netapp.getProjectsLocationsOperations({ name }),
392 {
393 budget: OPERATION_BUDGET,
394 },
395 ).pipe(
396 Effect.catchIf(
397 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
398 () => Effect.succeed(operation),
399 ),
400 );
401
402/**
403 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
404 * means the resource is already gone.
405 */
406export const waitForDeleteOperation = (operation: LongRunningOperation) =>
407 waitForOperation(operation).pipe(
408 Effect.catchIf(
409 (error) =>
410 error._tag === "NotFound" ||
411 (error._tag === "GCP.OperationFailed" && error.code === 5),
412 () => Effect.succeed(operation),
413 ),
414 );