1import * as vmwareengine from "@distilled.cloud/gcp/vmwareengine_v1";
2import * as Data from "effect/Data";
3import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12} from "../Labels.ts";
13import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
14
15export const DEFAULT_GLOBAL = "global";
16// Zonal default for private clouds: a zone can't be derived from the stack
17// region (not every region has an `-a` zone), so this stays fixed.
18export const DEFAULT_ZONE = "us-central1-a";
19export const MAX_NAME_LENGTH = 63;
20
21export { createInternalLabels, hasAlchemyLabels };
22
23export class VmwareengineNotResolved extends Data.TaggedError(
24 "GCP.VMwareEngine.NotResolved",
25)<{
26 name: string;
27}> {}
28
29export class VmwareengineStillExists extends Data.TaggedError(
30 "GCP.VMwareEngine.StillExists",
31)<{
32 name: string;
33}> {}
34
35export class VmwareengineFailed extends Data.TaggedError(
36 "GCP.VMwareEngine.Failed",
37)<{
38 name: string;
39 state: string | undefined;
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 = (name: string, fallback = "resource"): string => {
49 let next = name
50 .toLowerCase()
51 .replace(/[^a-z0-9-]/g, "-")
52 .replace(/-+/g, "-")
53 .replace(/^-+/, "")
54 .replace(/-+$/, "");
55 if (!/^[a-z]/.test(next)) {
56 next = `v${next}`;
57 }
58 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
59 return next.length > 0 ? next : fallback;
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/${location}`;
69
70export const canonicalizeLink = (value: string | undefined) => {
71 if (value === undefined || value.length === 0) return "";
72 return value
73 .replace(/^https?:\/\/[^/]+\//, "")
74 .replace(/^\/+/, "")
75 .replace(/\/+$/, "");
76};
77
78export const parseName = (
79 name: string,
80 collection: string,
81 fallbackLocation: string,
82) => {
83 const parts = name.split("/").filter((part) => part.length > 0);
84 const collectionAt = parts.lastIndexOf(collection);
85 const locationsAt = parts.lastIndexOf("locations");
86 const projectsAt = parts.lastIndexOf("projects");
87 return {
88 project:
89 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
90 location:
91 locationsAt >= 0 && parts[locationsAt + 1]
92 ? parts[locationsAt + 1]!
93 : fallbackLocation,
94 id:
95 collectionAt >= 0 && parts[collectionAt + 1]
96 ? parts[collectionAt + 1]!
97 : lastSegment(name),
98 parent: collectionAt > 0 ? parts.slice(0, collectionAt).join("/") : "",
99 };
100};
101
102export const locationFromName = (name: string, fallback: string) => {
103 const canonical = canonicalizeLink(name);
104 if (!canonical.includes("/locations/")) return fallback;
105 return parseName(canonical, "locations", fallback).location;
106};
107
108export const expandName = (
109 value: string,
110 project: string,
111 location: string,
112 collection: string,
113) => {
114 const canonical = canonicalizeLink(value);
115 if (
116 canonical.includes(`/${collection}/`) ||
117 canonical.includes("/locations/")
118 ) {
119 return canonical;
120 }
121 return `${parentOf(project, location)}/${collection}/${rfc1035(canonical, collection)}`;
122};
123
124export const toPhysicalId = (
125 id: string,
126 explicit: string | undefined,
127 existing: string | undefined,
128 fallback = "resource",
129) =>
130 Effect.gen(function* () {
131 if (explicit !== undefined) return rfc1035(explicit, fallback);
132 if (existing !== undefined) return existing;
133 return rfc1035(
134 yield* createPhysicalName({
135 id,
136 maxLength: MAX_NAME_LENGTH,
137 lowercase: true,
138 }),
139 fallback,
140 );
141 });
142
143export const encodeOwnership = (
144 labels: Record<string, string>,
145 text: string | undefined,
146): string => {
147 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
148 const trimmed = text?.trim();
149 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
150};
151
152export const parseOwnership = (
153 text: string | undefined,
154): {
155 labels: Record<string, string>;
156 text: string | undefined;
157} => {
158 if (!text?.startsWith("[alchemy ")) {
159 return { labels: {}, text };
160 }
161 const end = text.indexOf("]");
162 if (end < 0) return { labels: {}, text };
163 const labels: Record<string, string> = {};
164 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
165 const eq = part.indexOf("=");
166 if (eq > 0) {
167 labels[part.slice(0, eq)] = part.slice(eq + 1);
168 }
169 }
170 const rest = text.slice(end + 1).replace(/^\s+/, "");
171 return { labels, text: rest.length > 0 ? rest : undefined };
172};
173
174export const hasOwnershipMarker = (text: string | undefined) =>
175 Object.keys(parseOwnership(text).labels).some((key) =>
176 key.startsWith("alchemy-"),
177 );
178
179export const sameJson = (left: unknown, right: unknown) =>
180 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
181
182export const changedFields = (
183 pairs: ReadonlyArray<readonly [string, boolean]>,
184) => pairs.filter(([, changed]) => changed).map(([field]) => field);
185
186export const replaceOnIdentity = (input: {
187 previousId: string | undefined;
188 nextId: string | undefined;
189 previousLocation: string;
190 nextLocation: string;
191 extra?: boolean;
192 previousParent?: string;
193 nextParent?: string;
194}) => {
195 const parentChanged =
196 (input.previousParent ?? "") !== "" &&
197 (input.nextParent ?? "") !== "" &&
198 canonicalizeLink(input.previousParent) !==
199 canonicalizeLink(input.nextParent);
200 const replace =
201 (input.extra ?? false) ||
202 parentChanged ||
203 (input.previousId !== undefined &&
204 input.nextId !== undefined &&
205 input.nextId !== input.previousId) ||
206 input.previousLocation !== input.nextLocation;
207 if (!replace) return undefined;
208 const samePhysical =
209 input.previousLocation === input.nextLocation &&
210 !parentChanged &&
211 input.previousId !== undefined &&
212 input.nextId === input.previousId;
213 return {
214 action: "replace" as const,
215 deleteFirst: samePhysical,
216 };
217};
218
219/**
220 * Wait on a VMware Engine long-running operation. Private clouds take 1–2 hours; networks and policies minutes.
221 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
222 * when `notFoundOk` (deletes). Returns the final operation.
223 */
224export const waitForOperation = (
225 operation: vmwareengine.Operation,
226 options?: { notFoundOk?: boolean },
227) =>
228 Effect.suspend(() => {
229 let latest = operation;
230 return waitForLongRunningOperation(
231 operation,
232 (name) => {
233 const get = vmwareengine.getProjectsLocationsOperations({ name }).pipe(
234 Effect.tap((current) =>
235 Effect.sync(() => {
236 latest = current;
237 }),
238 ),
239 );
240 const observe: Effect.Effect<
241 vmwareengine.Operation,
242 vmwareengine.GetProjectsLocationsOperationsError,
243 GcpOpContext
244 > =
245 options?.notFoundOk === true
246 ? get.pipe(
247 Effect.catchTag("NotFound", () =>
248 Effect.succeed<vmwareengine.Operation>({ name, done: true }),
249 ),
250 )
251 : get.pipe(
252 // A just-returned operation can briefly 404 on read.
253 Effect.retry({
254 while: (error) => error._tag === "NotFound",
255 times: 5,
256 schedule: Schedule.exponential("250 millis"),
257 }),
258 );
259 return observe;
260 },
261 { budget: "3 hours", interval: "30 seconds" },
262 ).pipe(
263 Effect.map(() => latest),
264 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
265 Effect.catchTag("GCP.OperationFailed", (error) =>
266 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
267 ? Effect.succeed(latest)
268 : Effect.fail(error),
269 ),
270 );
271 });
272
273export const waitUntilPresent = <A extends object, E, R>(
274 get: Effect.Effect<A | undefined, E, R>,
275 name: string,
276): Effect.Effect<A, E | VmwareengineNotResolved, R> =>
277 get.pipe(
278 Effect.filterOrFail(
279 (value): value is A => value !== undefined,
280 () => new VmwareengineNotResolved({ name }),
281 ),
282 Effect.retry({
283 while: (error) => error instanceof VmwareengineNotResolved,
284 times: 8,
285 schedule: Schedule.spaced("1 second"),
286 }),
287 );
288
289export const waitUntilGone = <A, E, R>(
290 get: Effect.Effect<A | undefined, E, R>,
291 name: string,
292): Effect.Effect<void, E | VmwareengineStillExists, R> =>
293 get.pipe(
294 Effect.filterOrFail(
295 (value): value is undefined => value === undefined,
296 () => new VmwareengineStillExists({ name }),
297 ),
298 Effect.as(undefined as void),
299 Effect.retry({
300 while: (error) => error instanceof VmwareengineStillExists,
301 times: 10,
302 schedule: Schedule.spaced("2 seconds"),
303 }),
304 );
305
306const PENDING_STATES = new Set([
307 "STATE_UNSPECIFIED",
308 "CREATING",
309 "UPDATING",
310 "DELETING",
311 "PURGING",
312 "REPAIRING",
313 "RECONCILING",
314]);
315
316const FAILED_STATES = new Set(["FAILED", "DELETE_FAILED"]);
317
318export const waitUntilReady = <A extends object, E, R>(
319 get: Effect.Effect<A | undefined, E, R>,
320 name: string,
321 stateOf: (value: A) => string | undefined,
322): Effect.Effect<A, E | VmwareengineNotResolved | VmwareengineFailed, R> =>
323 get.pipe(
324 Effect.filterOrFail(
325 (value): value is A => {
326 if (value === undefined) return false;
327 return !PENDING_STATES.has(stateOf(value) ?? "");
328 },
329 () => new VmwareengineNotResolved({ name }),
330 ),
331 Effect.filterOrFail(
332 (value) => !FAILED_STATES.has(stateOf(value) ?? ""),
333 (value) => new VmwareengineFailed({ name, state: stateOf(value) }),
334 ),
335 Effect.retry({
336 while: (error) => error instanceof VmwareengineNotResolved,
337 // Private clouds and clusters take 1–2 hours to provision.
338 times: 360,
339 schedule: Schedule.spaced("30 seconds"),
340 }),
341 );
342
343export const collectPages = <Page, Item, E, R>(
344 stream: Stream.Stream<Page, E, R>,
345 pick: (page: Page) => readonly Item[] | undefined,
346): Effect.Effect<Item[], E, R> =>
347 stream.pipe(
348 Stream.flatMap((page) =>
349 Stream.fromIterable(pick(page) ?? ([] as readonly Item[])),
350 ),
351 Stream.runCollect,
352 Effect.map((chunk) => Array.from(chunk)),
353 );
354
355export const listAcrossLocations = <A, E, R>(
356 project: string,
357 region: string,
358 list: (parent: string) => Effect.Effect<A[], E, R>,
359): Effect.Effect<A[], E, R> =>
360 // Some collections reject the `-` location wildcard; fall back to the
361 // well-known locations.
362 Effect.firstSuccessOf([
363 list(parentOf(project, "-")),
364 Effect.forEach(
365 [...new Set<string>([DEFAULT_GLOBAL, region, DEFAULT_ZONE])],
366 (location) => list(parentOf(project, location)),
367 { concurrency: 3 },
368 ).pipe(Effect.map((chunks) => chunks.flat())),
369 ]);