1import * as gkeonprem from "@distilled.cloud/gcp/gkeonprem_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 { tagRecord } from "../../Tags.ts";
9import {
10 alchemyLabelKeys,
11 createInternalLabels,
12 hasAlchemyLabels,
13 stripInternalLabels,
14 toLabels,
15} from "../Labels.ts";
16import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
17
18export const MAX_NAME_LENGTH = 63;
19export const VMWARE_NAME_LENGTH = 40;
20
21export {
22 createInternalLabels,
23 hasAlchemyLabels,
24 stripInternalLabels,
25 toLabels,
26};
27
28export class ResourceNotResolved extends Data.TaggedError(
29 "GCP.GKEOnPrem.ResourceNotResolved",
30)<{
31 name: string;
32}> {}
33
34export class ResourceStillExists extends Data.TaggedError(
35 "GCP.GKEOnPrem.ResourceStillExists",
36)<{
37 name: string;
38}> {}
39
40export class ResourceNotReady extends Data.TaggedError(
41 "GCP.GKEOnPrem.ResourceNotReady",
42)<{
43 name: string;
44 state: string;
45}> {}
46
47export class ResourceFailed extends Data.TaggedError(
48 "GCP.GKEOnPrem.ResourceFailed",
49)<{
50 name: string;
51 state: string;
52 details: string | undefined;
53}> {}
54
55export const lastSegment = (value: string) => {
56 const trimmed = value.replace(/\/+$/, "");
57 const parts = trimmed.split("/");
58 return parts[parts.length - 1] || trimmed;
59};
60
61export const rfc1035 = (
62 name: string,
63 fallback = "gkeonprem",
64 maxLength = MAX_NAME_LENGTH,
65): string => {
66 let next = name
67 .toLowerCase()
68 .replace(/[^a-z0-9-]/g, "-")
69 .replace(/-+/g, "-")
70 .replace(/^-+|-+$/g, "");
71 if (!/^[a-z]/.test(next)) next = `g${next}`;
72 next = next.slice(0, maxLength).replace(/-+$/g, "");
73 if (next.length === 0) return fallback.slice(0, maxLength);
74 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, maxLength - 1)}0`;
75 return next.slice(0, maxLength);
76};
77
78export const normalizeLocation = (
79 location: string | undefined,
80 fallback: string,
81) => lastSegment(location ?? fallback).toLowerCase();
82
83export const parentOf = (project: string, location: string) =>
84 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
85
86export const toPhysicalId = (
87 id: string,
88 explicit: string | undefined,
89 existing: string | undefined,
90 fallback = "gkeonprem",
91 maxLength = MAX_NAME_LENGTH,
92) =>
93 Effect.gen(function* () {
94 if (explicit !== undefined) return rfc1035(explicit, fallback, maxLength);
95 if (existing !== undefined) return existing;
96 return rfc1035(
97 yield* createPhysicalName({
98 id,
99 maxLength,
100 lowercase: true,
101 }),
102 fallback,
103 maxLength,
104 );
105 });
106
107export const parseName = (
108 name: string,
109 collection: string,
110 fallbackLocation: string,
111) => {
112 const parts = name.split("/").filter((part) => part.length > 0);
113 const collectionAt = parts.lastIndexOf(collection);
114 const locationsAt = parts.lastIndexOf("locations");
115 const projectsAt = parts.lastIndexOf("projects");
116 return {
117 project:
118 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
119 location:
120 locationsAt >= 0 && parts[locationsAt + 1]
121 ? parts[locationsAt + 1]!
122 : fallbackLocation,
123 id:
124 collectionAt >= 0 && parts[collectionAt + 1]
125 ? parts[collectionAt + 1]!
126 : lastSegment(name),
127 parent:
128 collectionAt > 0
129 ? parts.slice(0, collectionAt).join("/")
130 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
131 };
132};
133
134export const expandParent = (
135 value: string,
136 project: string,
137 location: string,
138 collection: string,
139) => {
140 if (value.includes("/")) return value.replace(/\/+$/, "");
141 return `projects/${project}/locations/${location}/${collection}/${rfc1035(value, collection)}`;
142};
143
144export const membershipName = (value: string, project: string) => {
145 const next = value.replace(/\/+$/, "");
146 if (next.includes("/")) return next;
147 return `projects/${project}/locations/global/memberships/${rfc1035(next, "membership")}`;
148};
149
150export const stringMap = (
151 value: Record<string, string | undefined> | null | undefined,
152): Record<string, string> =>
153 Object.fromEntries(
154 Object.entries(value ?? {}).filter(
155 (entry): entry is [string, string] =>
156 typeof entry[1] === "string" && entry[1].length > 0,
157 ),
158 );
159
160export const userLabels = (
161 labels: Record<string, string | undefined> | null | undefined,
162): Record<string, string> => stripInternalLabels(tagRecord(labels));
163
164export const hasAlchemyLabelMap = (
165 labels: Record<string, string | undefined> | null | undefined,
166) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
167
168export const encodeOwnership = (
169 labels: Record<string, string>,
170 text: string | undefined,
171): string => {
172 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
173 const trimmed = text?.trim();
174 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
175};
176
177export const parseOwnership = (
178 text: string | undefined,
179): {
180 labels: Record<string, string>;
181 text: string | undefined;
182} => {
183 if (!text?.startsWith("[alchemy ")) {
184 return { labels: {}, text };
185 }
186 const end = text.indexOf("]");
187 if (end < 0) return { labels: {}, text };
188 const labels: Record<string, string> = {};
189 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
190 const eq = part.indexOf("=");
191 if (eq > 0) {
192 labels[part.slice(0, eq)] = part.slice(eq + 1);
193 }
194 }
195 const rest = text.slice(end + 1).replace(/^\s+/, "");
196 return { labels, text: rest.length > 0 ? rest : undefined };
197};
198
199export const hasOwnershipMarker = (text: string | undefined) =>
200 Object.keys(parseOwnership(text).labels).some((key) =>
201 key.startsWith("alchemy-"),
202 );
203
204export const isOwned = (
205 annotations: Record<string, string | undefined> | null | undefined,
206 text: string | undefined,
207) => hasAlchemyLabelMap(annotations) || hasOwnershipMarker(text);
208
209export const desiredAnnotations = (
210 ownership: Record<string, string>,
211 labels: Record<string, string> | undefined,
212 annotations: Record<string, string> | undefined,
213): Record<string, string> => ({
214 ...toLabels(labels),
215 ...stringMap(annotations),
216 ...ownership,
217});
218
219export const canonical = (value: unknown): unknown => {
220 if (value === undefined || value === null) return undefined;
221 if (typeof value === "boolean" || typeof value === "number") return value;
222 if (typeof value === "string") {
223 return value.length === 0 ? undefined : value;
224 }
225 if (Array.isArray(value)) {
226 const items = value.map(canonical).filter((item) => item !== undefined);
227 return items.length === 0 ? undefined : items;
228 }
229 if (typeof value === "object") {
230 const entries = Object.entries(value as Record<string, unknown>)
231 .map(([key, item]) => [key, canonical(item)] as const)
232 .filter(([, item]) => item !== undefined)
233 .sort(([left], [right]) => left.localeCompare(right));
234 if (entries.length === 0) return undefined;
235 return Object.fromEntries(entries);
236 }
237 return undefined;
238};
239
240export const fingerprint = (value: unknown): string =>
241 JSON.stringify(canonical(value) ?? null);
242
243const pickDefined = (observed: unknown, desired: unknown): unknown => {
244 if (desired === undefined || desired === null) return undefined;
245 if (Array.isArray(desired)) {
246 if (!Array.isArray(observed)) return observed;
247 return desired.map((item, index) => pickDefined(observed[index], item));
248 }
249 if (typeof desired === "object") {
250 if (typeof observed !== "object" || observed === null) return observed;
251 const rec = observed as Record<string, unknown>;
252 return Object.fromEntries(
253 Object.entries(desired as Record<string, unknown>).map(([key, item]) => [
254 key,
255 pickDefined(rec[key], item),
256 ]),
257 );
258 }
259 return observed;
260};
261
262export const differs = (observed: unknown, desired: unknown): boolean => {
263 if (desired === undefined) return false;
264 return fingerprint(pickDefined(observed, desired)) !== fingerprint(desired);
265};
266
267export const fieldMask = (fields: Array<string | false | undefined>) =>
268 fields
269 .filter((field): field is string => typeof field === "string")
270 .join(",");
271
272export const sameText = (left: string | undefined, right: string | undefined) =>
273 (left ?? "") === (right ?? "");
274
275export const textState = (state: string | undefined) =>
276 state === undefined ? undefined : `${state}`;
277
278export const replaceOnIdentity = (input: {
279 previousId: string | undefined;
280 nextId: string | undefined;
281 previousLocation: string;
282 nextLocation: string;
283 extra?: boolean;
284 previousParent?: string;
285 nextParent?: string;
286}) => {
287 const parentChanged =
288 (input.previousParent ?? "") !== "" &&
289 (input.nextParent ?? "") !== "" &&
290 (input.previousParent ?? "") !== (input.nextParent ?? "");
291 const replace =
292 (input.extra ?? false) ||
293 parentChanged ||
294 (input.previousId !== undefined &&
295 input.nextId !== undefined &&
296 input.nextId !== input.previousId) ||
297 input.previousLocation !== input.nextLocation;
298 if (!replace) return undefined;
299 const samePhysical =
300 input.previousLocation === input.nextLocation &&
301 !parentChanged &&
302 input.previousId !== undefined &&
303 input.nextId === input.previousId;
304 return {
305 action: "replace" as const,
306 deleteFirst: samePhysical,
307 };
308};
309
310const READY_STATES = new Set(["RUNNING", "READY", "DEGRADED"]);
311const FAILED_STATES = new Set(["ERROR", "FAILED"]);
312
313export const isReadyState = (state: string | undefined) =>
314 READY_STATES.has((state ?? "").toUpperCase());
315
316export const isFailedState = (state: string | undefined) =>
317 FAILED_STATES.has((state ?? "").toUpperCase());
318
319/**
320 * Wait on a GKE On-Prem long-running operation. Cluster and node-pool enrollment takes up to ~30 minutes.
321 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
322 * when `notFoundOk` (deletes). Returns the final operation.
323 */
324export const waitForOperation = (
325 operation: gkeonprem.Operation,
326 options?: { notFoundOk?: boolean },
327) =>
328 Effect.suspend(() => {
329 let latest = operation;
330 return waitForLongRunningOperation(
331 operation,
332 (name) => {
333 const get = gkeonprem.getProjectsLocationsOperations({ name }).pipe(
334 Effect.tap((current) =>
335 Effect.sync(() => {
336 latest = current;
337 }),
338 ),
339 );
340 const observe: Effect.Effect<
341 gkeonprem.Operation,
342 gkeonprem.GetProjectsLocationsOperationsError,
343 GcpOpContext
344 > =
345 options?.notFoundOk === true
346 ? get.pipe(
347 Effect.catchTag("NotFound", () =>
348 Effect.succeed<gkeonprem.Operation>({ name, done: true }),
349 ),
350 )
351 : get.pipe(
352 // A just-returned operation can briefly 404 on read.
353 Effect.retry({
354 while: (error) => error._tag === "NotFound",
355 times: 5,
356 schedule: Schedule.exponential("250 millis"),
357 }),
358 );
359 return observe;
360 },
361 { budget: "30 minutes", interval: "10 seconds" },
362 ).pipe(
363 Effect.map(() => latest),
364 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
365 Effect.catchTag("GCP.OperationFailed", (error) =>
366 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
367 ? Effect.succeed(latest)
368 : Effect.fail(error),
369 ),
370 );
371 });
372
373export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
374 get: Effect.Effect<A, E, R>,
375 name: string,
376): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
377 get.pipe(
378 Effect.filterOrFail(
379 (value): value is Exclude<A, undefined> => value !== undefined,
380 () => new ResourceNotResolved({ name }),
381 ),
382 Effect.retry({
383 while: (error) => error._tag === "GCP.GKEOnPrem.ResourceNotResolved",
384 times: 8,
385 schedule: Schedule.spaced("1 second"),
386 }),
387 );
388
389export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
390 get: Effect.Effect<A, E, R>,
391 name: string,
392): Effect.Effect<void, E | ResourceStillExists, R> =>
393 get.pipe(
394 Effect.filterOrFail(
395 (value) => value === undefined,
396 () => new ResourceStillExists({ name }),
397 ),
398 Effect.asVoid,
399 Effect.retry({
400 while: (error) => error._tag === "GCP.GKEOnPrem.ResourceStillExists",
401 times: 10,
402 schedule: Schedule.spaced("2 seconds"),
403 }),
404 );
405
406export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
407 get: Effect.Effect<A, E, R>,
408 name: string,
409 stateOf: (value: Exclude<A, undefined>) => string | undefined,
410 detailsOf?: (value: Exclude<A, undefined>) => string | undefined,
411 options?: {
412 times?: number;
413 interval?: `${number} seconds`;
414 },
415): Effect.Effect<
416 Exclude<A, undefined>,
417 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
418 R
419> =>
420 get.pipe(
421 Effect.filterOrFail(
422 (value): value is Exclude<A, undefined> => value !== undefined,
423 () => new ResourceNotResolved({ name }),
424 ),
425 Effect.filterOrFail(
426 (value) => !isFailedState(stateOf(value)),
427 (value) =>
428 new ResourceFailed({
429 name,
430 state: stateOf(value) ?? "",
431 details: detailsOf?.(value),
432 }),
433 ),
434 Effect.filterOrFail(
435 (value) => {
436 const state = stateOf(value) ?? "";
437 return isReadyState(state) || state.length === 0;
438 },
439 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
440 ),
441 Effect.retry({
442 while: (error) =>
443 error._tag === "GCP.GKEOnPrem.ResourceNotReady" ||
444 error._tag === "GCP.GKEOnPrem.ResourceNotResolved",
445 // Cluster enrollment takes up to ~30 minutes.
446 times: options?.times ?? 180,
447 schedule: Schedule.spaced(options?.interval ?? "10 seconds"),
448 }),
449 );
450
451export const collectPages = <
452 Page,
453 Item,
454 E extends { readonly _tag: string },
455 R,
456>(
457 stream: Stream.Stream<Page, E, R>,
458 pick: (page: Page) => readonly Item[] | undefined,
459) =>
460 stream.pipe(
461 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
462 Stream.runCollect,
463 Effect.map((chunk) => Array.from(chunk)),
464 // A missing parent lists as empty.
465 Effect.catchIf(
466 (error) => error._tag === "NotFound",
467 () => Effect.succeed([] as Item[]),
468 ),
469 );
470
471export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
472 project: string,
473 region: string,
474 list: (parent: string) => Effect.Effect<A[], E, R>,
475) =>
476 list(`projects/${project}/locations/-`).pipe(
477 Effect.catchIf(
478 // Some collections reject the `-` location wildcard.
479 (error) => error._tag === "NotFound",
480 () => list(`projects/${project}/locations/${region}`),
481 ),
482 );
483
484export const listAtNested = <A, E extends { readonly _tag: string }, R>(
485 project: string,
486 region: string,
487 nested: string,
488 list: (parent: string) => Effect.Effect<A[], E, R>,
489) =>
490 list(`projects/${project}/locations/-/${nested}`).pipe(
491 Effect.catchIf(
492 // Some collections reject the `-` location wildcard.
493 (error) => error._tag === "NotFound",
494 () => list(`projects/${project}/locations/${region}/${nested}`),
495 ),
496 );
497
498export const listChildrenOf = <Parent, Child, E1, E2, R>(
499 parents: Effect.Effect<Parent[], E1, R>,
500 nameOf: (parent: Parent) => string | undefined,
501 listChildren: (parent: string) => Effect.Effect<Child[], E2, R>,
502) =>
503 parents.pipe(
504 Effect.flatMap((items) =>
505 Effect.forEach(
506 items,
507 (item) => {
508 const name = nameOf(item);
509 return name ? listChildren(name) : Effect.succeed([] as Child[]);
510 },
511 { concurrency: 5 },
512 ).pipe(Effect.map((chunks) => chunks.flat())),
513 ),
514 );