1import * as backupdr from "@distilled.cloud/gcp/backupdr_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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { tagRecord } from "../../Tags.ts";
9import { stripInternalLabels } from "../Labels.ts";
10
11export const MAX_NAME_LENGTH = 63;
12export const DEFAULT_RETENTION = "86400s";
13
14export class ResourceNotResolved extends Data.TaggedError(
15 "GCP.BackupDR.ResourceNotResolved",
16)<{
17 name: string;
18}> {}
19
20export class ResourceStillExists extends Data.TaggedError(
21 "GCP.BackupDR.ResourceStillExists",
22)<{
23 name: string;
24}> {}
25
26export class ResourceNotReady extends Data.TaggedError(
27 "GCP.BackupDR.ResourceNotReady",
28)<{
29 name: string;
30 state: string;
31}> {}
32
33export class ResourceFailed extends Data.TaggedError(
34 "GCP.BackupDR.ResourceFailed",
35)<{
36 name: string;
37 state: string;
38 details: string | undefined;
39}> {}
40
41export const lastSegment = (value: string) => {
42 const trimmed = value.replace(/\/+$/, "");
43 const parts = trimmed.split("/");
44 return parts[parts.length - 1] || trimmed;
45};
46
47export const rfc1035 = (name: string, fallback = "backupdr"): string => {
48 let next = name
49 .toLowerCase()
50 .replace(/[^a-z0-9-]/g, "-")
51 .replace(/-+/g, "-")
52 .replace(/^-+|-+$/g, "");
53 if (!/^[a-z]/.test(next)) next = `b${next}`;
54 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
55 if (next.length === 0) return fallback;
56 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
57 if (next.length < 3) next = `${next}xxx`.slice(0, 3);
58 return next.slice(0, MAX_NAME_LENGTH);
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/${lastSegment(location).toLowerCase()}`;
68
69export const toPhysicalId = (
70 id: string,
71 explicit: string | undefined,
72 existing: string | undefined,
73 fallback = "backupdr",
74) =>
75 Effect.gen(function* () {
76 if (explicit !== undefined) return rfc1035(explicit, fallback);
77 if (existing !== undefined) return existing;
78 return rfc1035(
79 yield* createPhysicalName({
80 id,
81 maxLength: MAX_NAME_LENGTH,
82 lowercase: true,
83 }),
84 fallback,
85 );
86 });
87
88export const parseName = (
89 name: string,
90 collection: string,
91 fallbackLocation: string,
92) => {
93 const parts = name.split("/").filter((part) => part.length > 0);
94 const collectionAt = parts.lastIndexOf(collection);
95 const locationsAt = parts.lastIndexOf("locations");
96 const projectsAt = parts.lastIndexOf("projects");
97 return {
98 project:
99 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
100 location:
101 locationsAt >= 0 && parts[locationsAt + 1]
102 ? parts[locationsAt + 1]!
103 : fallbackLocation,
104 id:
105 collectionAt >= 0 && parts[collectionAt + 1]
106 ? parts[collectionAt + 1]!
107 : lastSegment(name),
108 parent:
109 collectionAt > 0
110 ? parts.slice(0, collectionAt).join("/")
111 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
112 };
113};
114
115export const expandParent = (
116 value: string,
117 project: string,
118 location: string,
119 collection: string,
120) => {
121 if (value.includes("/")) return value.replace(/\/+$/, "");
122 return `projects/${project}/locations/${location}/${collection}/${value}`;
123};
124
125export const backupVaultOf = (
126 value: string,
127 project: string,
128 location: string,
129) => expandParent(value, project, location, "backupVaults");
130
131export const backupPlanOf = (
132 value: string,
133 project: string,
134 location: string,
135) => expandParent(value, project, location, "backupPlans");
136
137export const networkName = (project: string, network: string | undefined) => {
138 const value = (network ?? "").replace(/\/+$/, "");
139 if (value.length === 0) return undefined;
140 if (value.includes("/")) return value;
141 return `projects/${project}/global/networks/${value}`;
142};
143
144export const userLabels = (
145 labels: Record<string, string | undefined> | null | undefined,
146): Record<string, string> => stripInternalLabels(tagRecord(labels));
147
148export const hasAlchemyLabelMap = (
149 labels: Record<string, string | undefined> | null | undefined,
150) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
151
152export const canonical = (value: unknown): unknown => {
153 if (value === undefined || value === null) return undefined;
154 if (typeof value === "boolean" || typeof value === "number") return value;
155 if (typeof value === "string") {
156 return value.length === 0 ? undefined : value;
157 }
158 if (Array.isArray(value)) {
159 const items = value.map(canonical).filter((item) => item !== undefined);
160 return items.length === 0 ? undefined : items;
161 }
162 if (typeof value === "object") {
163 const entries = Object.entries(value as Record<string, unknown>)
164 .map(([key, item]) => [key, canonical(item)] as const)
165 .filter(([, item]) => item !== undefined)
166 .sort(([left], [right]) => left.localeCompare(right));
167 if (entries.length === 0) return undefined;
168 return Object.fromEntries(entries);
169 }
170 return undefined;
171};
172
173export const fingerprint = (value: unknown): string =>
174 JSON.stringify(canonical(value) ?? null);
175
176export const fieldMask = (fields: Array<string | false | undefined>) =>
177 fields
178 .filter((field): field is string => typeof field === "string")
179 .join(",");
180
181export const sameText = (left: string | undefined, right: string | undefined) =>
182 (left ?? "") === (right ?? "");
183
184export const replaceOnIdentity = (input: {
185 previousId: string | undefined;
186 nextId: string | undefined;
187 previousLocation: string;
188 nextLocation: string;
189 extra?: boolean;
190 previousParent?: string;
191 nextParent?: string;
192}) => {
193 const parentChanged =
194 (input.previousParent ?? "") !== "" &&
195 (input.nextParent ?? "") !== "" &&
196 (input.previousParent ?? "") !== (input.nextParent ?? "");
197 const replace =
198 (input.extra ?? false) ||
199 parentChanged ||
200 (input.previousId !== undefined &&
201 input.nextId !== undefined &&
202 input.nextId !== input.previousId) ||
203 input.previousLocation !== input.nextLocation;
204 if (!replace) return undefined;
205 const samePhysical =
206 input.previousLocation === input.nextLocation &&
207 !parentChanged &&
208 input.previousId !== undefined &&
209 input.nextId === input.previousId;
210 return {
211 action: "replace" as const,
212 deleteFirst: samePhysical,
213 };
214};
215
216const READY_STATES = new Set([
217 "READY",
218 "ACTIVE",
219 "INACTIVE",
220 "SUCCEEDED",
221 "IN_USE",
222]);
223const FAILED_STATES = new Set(["ERROR", "FAILED"]);
224
225export const isReadyState = (state: string | undefined) =>
226 READY_STATES.has((state ?? "").toUpperCase());
227
228export const isFailedState = (state: string | undefined) =>
229 FAILED_STATES.has((state ?? "").toUpperCase());
230
231/**
232 * Wait for a Backup and DR long-running operation. Management servers take
233 * up to ~30 minutes; vaults, plans, and associations a few minutes.
234 */
235export const waitForOperation = (operation: backupdr.Operation) =>
236 waitForGcpOperation(
237 operation,
238 (name) => backupdr.getProjectsLocationsOperations({ name }),
239 { budget: "40 minutes", interval: "10 seconds" },
240 );
241
242export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
243 get: Effect.Effect<A | undefined, E, R>,
244 name: string,
245): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
246 get.pipe(
247 Effect.filterOrFail(
248 (value): value is NonNullable<A> => value != null,
249 () => new ResourceNotResolved({ name }),
250 ),
251 Effect.retry({
252 while: (error) => error._tag === "GCP.BackupDR.ResourceNotResolved",
253 times: 8,
254 schedule: Schedule.spaced("1 second"),
255 }),
256 );
257
258export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
259 get: Effect.Effect<A | undefined, E, R>,
260 name: string,
261): Effect.Effect<void, E | ResourceStillExists, R> =>
262 get.pipe(
263 Effect.filterOrFail(
264 (value) => value === undefined,
265 () => new ResourceStillExists({ name }),
266 ),
267 Effect.retry({
268 while: (error) => error._tag === "GCP.BackupDR.ResourceStillExists",
269 times: 10,
270 schedule: Schedule.spaced("2 seconds"),
271 }),
272 Effect.asVoid,
273 );
274
275export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
276 get: Effect.Effect<A | undefined, E, R>,
277 name: string,
278 stateOf: (value: NonNullable<A>) => string | undefined,
279 detailsOf?: (value: NonNullable<A>) => string | undefined,
280 options?: {
281 times?: number;
282 interval?: `${number} seconds`;
283 },
284): Effect.Effect<
285 NonNullable<A>,
286 E | ResourceNotResolved | ResourceNotReady | ResourceFailed,
287 R
288> =>
289 get.pipe(
290 Effect.filterOrFail(
291 (value): value is NonNullable<A> => value != null,
292 () => new ResourceNotResolved({ name }),
293 ),
294 Effect.filterOrFail(
295 (value) => {
296 const state = stateOf(value) ?? "";
297 return isReadyState(state) || state.length === 0;
298 },
299 (value) => {
300 const state = stateOf(value) ?? "";
301 if (isFailedState(state)) {
302 return new ResourceFailed({
303 name,
304 state,
305 details: detailsOf?.(value),
306 });
307 }
308 return new ResourceNotReady({ name, state });
309 },
310 ),
311 Effect.retry({
312 while: (error) =>
313 error._tag === "GCP.BackupDR.ResourceNotReady" ||
314 error._tag === "GCP.BackupDR.ResourceNotResolved",
315 times: options?.times ?? 10,
316 schedule: Schedule.spaced(options?.interval ?? "5 seconds"),
317 }),
318 );
319
320export const collectPages = <Page, A, E, R>(
321 pages: Stream.Stream<Page, E, R>,
322 items: (page: Page) => readonly A[] | undefined,
323) =>
324 pages.pipe(
325 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
326 Stream.runCollect,
327 Effect.map((chunk) => Array.from(chunk)),
328 );
329
330// Backup and DR accepts the `locations/-` wildcard: one call covers every region.
331export const listAtLocation = <A, E, R>(
332 project: string,
333 _region: string,
334 list: (parent: string) => Effect.Effect<A[], E, R>,
335) => list(`projects/${project}/locations/-`);
336
337export const listLabeledPages = <Page, A, E, R>(
338 pages: Stream.Stream<Page, E, R>,
339 items: (page: Page) => readonly A[] | undefined,
340 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
341) =>
342 pages.pipe(
343 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
344 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
345 Stream.runCollect,
346 Effect.map((chunk) => Array.from(chunk)),
347 );