1import * as gkebackup from "@distilled.cloud/gcp/gkebackup_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.GKEBackup.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export class ResourceStillExists extends Data.TaggedError(
23 "GCP.GKEBackup.ResourceStillExists",
24)<{
25 name: string;
26}> {}
27
28export class ResourceNotReady extends Data.TaggedError(
29 "GCP.GKEBackup.ResourceNotReady",
30)<{
31 name: string;
32 state: string;
33}> {}
34
35export class ResourceFailed extends Data.TaggedError(
36 "GCP.GKEBackup.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 = "gkebackup"): 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 = `g${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 = "gkebackup",
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[collectionAt + 1]!
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 projectName = (value: string | undefined, project: string) => {
127 const next = (value ?? project).replace(/\/+$/, "");
128 if (next.startsWith("projects/")) return next;
129 return `projects/${next}`;
130};
131
132export const clusterName = (
133 value: string,
134 project: string,
135 location: string,
136) => {
137 if (value.includes("/")) return value.replace(/\/+$/, "");
138 return `projects/${project}/locations/${location}/clusters/${value}`;
139};
140
141export const userLabels = (
142 labels: Record<string, string | undefined> | null | undefined,
143): Record<string, string> => stripInternalLabels(tagRecord(labels));
144
145export const hasAlchemyLabelMap = (
146 labels: Record<string, string | undefined> | null | undefined,
147) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
148
149export const canonical = (value: unknown): unknown => {
150 if (value === undefined || value === null) return undefined;
151 if (typeof value === "boolean" || typeof value === "number") return value;
152 if (typeof value === "string") {
153 return value.length === 0 ? undefined : value;
154 }
155 if (Array.isArray(value)) {
156 const items = value.map(canonical).filter((item) => item !== undefined);
157 return items.length === 0 ? undefined : items;
158 }
159 if (typeof value === "object") {
160 const entries = Object.entries(value as Record<string, unknown>)
161 .map(([key, item]) => [key, canonical(item)] as const)
162 .filter(([, item]) => item !== undefined)
163 .sort(([left], [right]) => left.localeCompare(right));
164 if (entries.length === 0) return undefined;
165 return Object.fromEntries(entries);
166 }
167 return undefined;
168};
169
170export const fingerprint = (value: unknown): string =>
171 JSON.stringify(canonical(value) ?? null);
172
173export const fieldMask = (fields: Array<string | false | undefined>) =>
174 fields
175 .filter((field): field is string => typeof field === "string")
176 .join(",");
177
178export const sameText = (left: string | undefined, right: string | undefined) =>
179 (left ?? "") === (right ?? "");
180
181export const replaceOnIdentity = (input: {
182 previousId: string | undefined;
183 nextId: string | undefined;
184 previousLocation: string;
185 nextLocation: string;
186 extra?: boolean;
187 previousParent?: string;
188 nextParent?: string;
189}) => {
190 const parentChanged =
191 (input.previousParent ?? "") !== "" &&
192 (input.nextParent ?? "") !== "" &&
193 (input.previousParent ?? "") !== (input.nextParent ?? "");
194 const replace =
195 (input.extra ?? false) ||
196 parentChanged ||
197 (input.previousId !== undefined &&
198 input.nextId !== undefined &&
199 input.nextId !== input.previousId) ||
200 input.previousLocation !== input.nextLocation;
201 if (!replace) return undefined;
202 const samePhysical =
203 input.previousLocation === input.nextLocation &&
204 !parentChanged &&
205 input.previousId !== undefined &&
206 input.nextId === input.previousId;
207 return {
208 action: "replace" as const,
209 deleteFirst: samePhysical,
210 };
211};
212
213const READY_STATES = new Set(["READY", "SUCCEEDED", "DEACTIVATED", "IN_USE"]);
214const FAILED_STATES = new Set(["FAILED", "ERROR"]);
215
216export const isReadyState = (state: string | undefined) =>
217 READY_STATES.has((state ?? "").toUpperCase());
218
219export const isFailedState = (state: string | undefined) =>
220 FAILED_STATES.has((state ?? "").toUpperCase());
221
222export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
223 get: Effect.Effect<A, E, R>,
224 name: string,
225): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
226 get.pipe(
227 Effect.filterOrFail(
228 (value): value is Exclude<A, undefined> => value !== undefined,
229 () => new ResourceNotResolved({ name }),
230 ),
231 Effect.retry({
232 while: (error) => error._tag === "GCP.GKEBackup.ResourceNotResolved",
233 times: 8,
234 schedule: Schedule.spaced("1 second"),
235 }),
236 );
237
238export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
239 get: Effect.Effect<A, E, R>,
240 name: string,
241): Effect.Effect<void, E | ResourceStillExists, R> =>
242 get.pipe(
243 Effect.filterOrFail(
244 (value) => value === undefined,
245 () => new ResourceStillExists({ name }),
246 ),
247 Effect.asVoid,
248 Effect.retry({
249 while: (error) => error._tag === "GCP.GKEBackup.ResourceStillExists",
250 times: 10,
251 schedule: Schedule.spaced("2 seconds"),
252 }),
253 );
254
255export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
256 get: Effect.Effect<A, E, R>,
257 name: string,
258 stateOf: (value: Exclude<A, undefined>) => string | undefined,
259 detailsOf?: (value: Exclude<A, undefined>) => string | undefined,
260 options?: {
261 times?: number;
262 interval?: `${number} seconds`;
263 },
264): Effect.Effect<
265 Exclude<A, undefined>,
266 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
267 R
268> =>
269 get.pipe(
270 Effect.filterOrFail(
271 (value): value is Exclude<A, undefined> => value !== undefined,
272 () => new ResourceNotResolved({ name }),
273 ),
274 Effect.filterOrFail(
275 (value) => !isFailedState(stateOf(value)),
276 (value) =>
277 new ResourceFailed({
278 name,
279 state: stateOf(value) ?? "",
280 details: detailsOf?.(value),
281 }),
282 ),
283 Effect.filterOrFail(
284 (value) => {
285 const state = stateOf(value) ?? "";
286 return isReadyState(state) || state.length === 0;
287 },
288 (value) => new ResourceNotReady({ name, state: stateOf(value) ?? "" }),
289 ),
290 Effect.retry({
291 while: (error) =>
292 error._tag === "GCP.GKEBackup.ResourceNotReady" ||
293 error._tag === "GCP.GKEBackup.ResourceNotResolved",
294 times: options?.times ?? 10,
295 schedule: Schedule.spaced(options?.interval ?? "3 seconds"),
296 }),
297 );
298
299/** Every location at once: the API accepts the `locations/-` wildcard. */
300export const listAtLocation = <A, E, R>(
301 project: string,
302 list: (parent: string) => Effect.Effect<A[], E, R>,
303) => list(`projects/${project}/locations/-`);
304
305/** Children of every parent in every location (`locations/-/{nested}`). */
306export const listAtNested = <A, E, R>(
307 project: string,
308 nested: string,
309 list: (parent: string) => Effect.Effect<A[], E, R>,
310) => list(`projects/${project}/locations/-/${nested}`);
311
312export const listLabeledPages = <Page, A, E, R>(
313 pages: Stream.Stream<Page, E, R>,
314 items: (page: Page) => readonly A[] | undefined,
315 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
316) =>
317 pages.pipe(
318 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
319 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
320 Stream.runCollect,
321 Effect.map((chunk) => Array.from(chunk)),
322 );
323
324/** Backups and restores of cluster workloads take up to ~20 minutes. */
325const OPERATION_BUDGET = "30 minutes";
326
327/**
328 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
329 * concurrent create won the race; reconcile observes the resource next.
330 */
331export const waitForOperation = (operation: LongRunningOperation) =>
332 waitForLongRunning(
333 operation,
334 (name) => gkebackup.getProjectsLocationsOperations({ name }),
335 {
336 budget: OPERATION_BUDGET,
337 },
338 ).pipe(
339 Effect.catchIf(
340 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
341 () => Effect.succeed(operation),
342 ),
343 );
344
345/**
346 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
347 * means the resource is already gone.
348 */
349export const waitForDeleteOperation = (operation: LongRunningOperation) =>
350 waitForOperation(operation).pipe(
351 Effect.catchIf(
352 (error) =>
353 error._tag === "NotFound" ||
354 (error._tag === "GCP.OperationFailed" && error.code === 5),
355 () => Effect.succeed(operation),
356 ),
357 );