1import * as sqladmin from "@distilled.cloud/gcp/sqladmin_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 { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import { recoverIfInstanceMissing, waitForSqlOperation } from "./operations.ts";
19import {
20 encodeDescription,
21 hasOwnershipMarker,
22 instanceIdOf,
23 lastSegment,
24 matchesOwnership,
25 parseDescription,
26} from "./ownership.ts";
27
28export type BackupRunProps = {
29 /**
30 * Cloud SQL instance id (the `{instance}` segment of
31 * `projects/{project}/instances/{instance}`). Full resource names are
32 * accepted and reduced to the last path segment. Immutable — changing
33 * it replaces the backup run.
34 */
35 instance: string;
36 /**
37 * Storage location of the backup (`us`, `us-central1`, …). If omitted,
38 * Cloud SQL picks the closest multi-region. Immutable — changing it
39 * replaces the backup run.
40 */
41 location?: string;
42 /**
43 * Human-readable description. Cloud SQL backup runs have no labels
44 * field and no update API, so Alchemy stamps ownership into the
45 * description at create and cannot change it in place.
46 */
47 description?: string;
48 /**
49 * Existing backup-run id used to observe a run when state is missing.
50 * The insert API assigns ids — this is not sent on insert.
51 */
52 backupRunId?: string;
53};
54
55export type BackupRun = Resource<
56 "GCP.SQL.BackupRun",
57 BackupRunProps,
58 {
59 /** Backup-run id unique to the instance. */
60 backupRunId: string;
61 /** Cloud SQL instance id. */
62 instance: string;
63 /** Project id. */
64 project: string;
65 /** User description with the Alchemy ownership prefix stripped. */
66 description: string | undefined;
67 /** Run type (`ON_DEMAND`, `AUTOMATED`). */
68 type: string | undefined;
69 /** Server-reported status (`ENQUEUED`, `RUNNING`, `SUCCESSFUL`, …). */
70 status: string | undefined;
71 /** Storage location. */
72 location: string | undefined;
73 /** SQL Admin self-link. */
74 selfLink: string | undefined;
75 /** RFC3339 enqueue time. */
76 enqueuedTime: string | undefined;
77 /** RFC3339 start time. */
78 startTime: string | undefined;
79 /** RFC3339 end time. */
80 endTime: string | undefined;
81 /** RFC3339 backup-window start. */
82 windowStartTime: string | undefined;
83 /** Database version at backup time. */
84 databaseVersion: string | undefined;
85 /** Kind of backup (`SNAPSHOT`, `PHYSICAL`). */
86 backupKind: string | undefined;
87 /** Backup time zone (SQL Server). */
88 timeZone: string | undefined;
89 /** Maximum chargeable bytes. */
90 maxChargeableBytes: string | undefined;
91 },
92 never,
93 Providers
94>;
95
96/**
97 * An on-demand Cloud SQL backup run
98 * (`projects/{project}/instances/{instance}/backupRuns/{id}`).
99 *
100 * Insert assigns the run id. Alchemy stamps ownership into `description`
101 * so `list` / `pnpm nuke:gcp` can find leaked rows. Changing `instance`
102 * or `location` replaces the run. Description is create-only — backup
103 * runs have no update API.
104 *
105 * ### Creating a Backup Run
106 * **Example:** On-demand backup of a Cloud SQL instance
107 * ```typescript
108 * const instance = yield* GCP.SQL.Instance("AppDb", {
109 * tier: "db-f1-micro",
110 * backupEnabled: true,
111 * });
112 * const backup = yield* GCP.SQL.BackupRun("Nightly", {
113 * instance: instance.instanceName,
114 * });
115 * ```
116 *
117 * **Example:** Description and location
118 * ```typescript
119 * const backup = yield* GCP.SQL.BackupRun("Nightly", {
120 * instance: instance.instanceName,
121 * location: "us",
122 * description: "pre-release",
123 * });
124 * ```
125 *
126 * @resource
127 * @category SQL
128 */
129export const BackupRun = Resource<BackupRun>("GCP.SQL.BackupRun");
130
131export class BackupRunNotResolved extends Data.TaggedError(
132 "GCP.SQL.BackupRunNotResolved",
133)<{
134 instance: string;
135 backupRunId: string;
136}> {}
137
138export class BackupRunNotReady extends Data.TaggedError(
139 "GCP.SQL.BackupRunNotReady",
140)<{
141 instance: string;
142 backupRunId: string;
143 status: string | undefined;
144}> {}
145
146export class BackupRunStillExists extends Data.TaggedError(
147 "GCP.SQL.BackupRunStillExists",
148)<{
149 instance: string;
150 backupRunId: string;
151}> {}
152
153const normalizeLocation = (value: string | undefined) =>
154 value === undefined || value.length === 0
155 ? undefined
156 : lastSegment(value).toLowerCase();
157
158const idOf = (value: string | number | undefined) =>
159 value === undefined ? "" : String(value);
160
161const toUserDescription = (
162 id: string,
163 description: string | undefined,
164 existing?: string,
165) =>
166 Effect.gen(function* () {
167 if (description !== undefined) return description;
168 if (existing !== undefined) return existing;
169 return yield* createPhysicalName({
170 id,
171 maxLength: 63,
172 lowercase: true,
173 });
174 });
175
176const toAttrs = (
177 run: sqladmin.BackupRun,
178 project: string,
179 instance: string,
180) => ({
181 backupRunId: idOf(run.id),
182 instance: instanceIdOf(run.instance ?? instance),
183 project,
184 description: parseDescription(run.description).description,
185 type: run.type,
186 status: run.status,
187 location: run.location,
188 selfLink: run.selfLink,
189 enqueuedTime: run.enqueuedTime,
190 startTime: run.startTime,
191 endTime: run.endTime,
192 windowStartTime: run.windowStartTime,
193 databaseVersion: run.databaseVersion,
194 backupKind: run.backupKind,
195 timeZone: run.timeZone,
196 maxChargeableBytes: run.maxChargeableBytes,
197});
198
199const getById = (project: string, instance: string, backupRunId: string) =>
200 backupRunId.length === 0
201 ? Effect.succeed(undefined)
202 : sqladmin
203 .getBackupRuns({
204 project,
205 instance,
206 id: backupRunId,
207 })
208 .pipe(
209 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
210 recoverIfInstanceMissing(project, instance, () => undefined),
211 );
212
213const listRuns = (project: string, instance: string) =>
214 sqladmin.listBackupRuns
215 .items({
216 project,
217 instance,
218 maxResults: 1000,
219 })
220 .pipe(
221 Stream.runCollect,
222 Effect.map((chunk) => Array.from(chunk)),
223 Effect.catchTag("NotFound", () =>
224 Effect.succeed([] as sqladmin.BackupRun[]),
225 ),
226 recoverIfInstanceMissing(
227 project,
228 instance,
229 () => [] as sqladmin.BackupRun[],
230 ),
231 );
232
233const findByOwnership = (
234 project: string,
235 instance: string,
236 labels: Record<string, string>,
237) =>
238 listRuns(project, instance).pipe(
239 Effect.map((runs) =>
240 runs.find((run) => matchesOwnership(run.description, labels)),
241 ),
242 );
243
244const observe = (
245 project: string,
246 instance: string,
247 backupRunId: string | undefined,
248 labels: Record<string, string>,
249) =>
250 Effect.gen(function* () {
251 if (backupRunId !== undefined && backupRunId.length > 0) {
252 const existing = yield* getById(project, instance, backupRunId);
253 if (existing !== undefined) return existing;
254 }
255 return yield* findByOwnership(project, instance, labels);
256 });
257
258const waitForOperation = (
259 project: string,
260 operation: sqladmin.Operation,
261 options?: { notFoundOk?: boolean },
262) =>
263 waitForSqlOperation(project, operation, {
264 budget: "20 minutes",
265 notFoundOk: options?.notFoundOk,
266 });
267
268const waitUntilExists = (
269 project: string,
270 instance: string,
271 backupRunId: string,
272) =>
273 getById(project, instance, backupRunId).pipe(
274 Effect.flatMap((run) =>
275 run
276 ? Effect.succeed(run)
277 : Effect.fail(new BackupRunNotResolved({ instance, backupRunId })),
278 ),
279 Effect.retry({
280 while: (error) => error._tag === "GCP.SQL.BackupRunNotResolved",
281 times: 8,
282 schedule: Schedule.spaced("2 seconds"),
283 }),
284 );
285
286const isBusy = (status: string | undefined) =>
287 status === "ENQUEUED" ||
288 status === "OVERDUE" ||
289 status === "RUNNING" ||
290 status === "SQL_BACKUP_RUN_STATUS_UNSPECIFIED" ||
291 status === undefined;
292
293const waitUntilReady = (
294 project: string,
295 instance: string,
296 backupRunId: string,
297) =>
298 getById(project, instance, backupRunId).pipe(
299 Effect.filterOrFail(
300 (run): run is sqladmin.BackupRun => run !== undefined,
301 () => new BackupRunNotResolved({ instance, backupRunId }),
302 ),
303 Effect.filterOrFail(
304 (run) => !isBusy(run.status),
305 (run) =>
306 new BackupRunNotReady({
307 instance,
308 backupRunId,
309 status: run.status,
310 }),
311 ),
312 Effect.retry({
313 while: (error) =>
314 error._tag === "GCP.SQL.BackupRunNotReady" ||
315 error._tag === "GCP.SQL.BackupRunNotResolved",
316 times: 10,
317 schedule: Schedule.spaced("8 seconds"),
318 }),
319 );
320
321const waitUntilGone = (
322 project: string,
323 instance: string,
324 backupRunId: string,
325) =>
326 getById(project, instance, backupRunId).pipe(
327 Effect.flatMap((run) =>
328 run === undefined || run.status === "DELETED"
329 ? Effect.void
330 : Effect.fail(new BackupRunStillExists({ instance, backupRunId })),
331 ),
332 Effect.retry({
333 while: (error) => error._tag === "GCP.SQL.BackupRunStillExists",
334 times: 10,
335 schedule: Schedule.spaced("5 seconds"),
336 }),
337 );
338
339const idFromOperation = (operation: sqladmin.Operation) =>
340 idOf(operation.backupContext?.backupId);
341
342export const BackupRunProvider = () =>
343 Provider.succeed(BackupRun, {
344 stables: ["backupRunId", "instance", "project", "selfLink"],
345
346 diff: Effect.fn(function* ({ news, olds, output }) {
347 if (!isResolved(news)) return undefined;
348 const previousInstance = olds?.instance ?? output?.instance;
349 const nextInstance = news.instance;
350 const previousLocation = normalizeLocation(
351 olds?.location ?? output?.location,
352 );
353 const nextLocation = normalizeLocation(news.location ?? output?.location);
354 const instanceChanged =
355 previousInstance !== undefined &&
356 instanceIdOf(previousInstance) !== instanceIdOf(nextInstance);
357 const locationChanged =
358 news.location !== undefined &&
359 previousLocation !== undefined &&
360 nextLocation !== undefined &&
361 previousLocation !== nextLocation;
362 if (!instanceChanged && !locationChanged) return undefined;
363 return {
364 action: "replace" as const,
365 deleteFirst: false,
366 };
367 }),
368
369 read: Effect.fn(function* ({ id, olds, output }) {
370 const env = yield* GcpEnvironment.current;
371 const instance = instanceIdOf(olds?.instance ?? output?.instance ?? "");
372 if (instance.length === 0) return undefined;
373 const ownership = yield* createInternalLabels(id);
374 const existing = yield* observe(
375 env.project,
376 instance,
377 olds?.backupRunId ?? output?.backupRunId,
378 ownership,
379 );
380 if (existing === undefined) return undefined;
381 const attrs = toAttrs(existing, env.project, instance);
382 const { labels } = parseDescription(existing.description);
383 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
384 }),
385
386 list: () =>
387 Effect.gen(function* () {
388 const env = yield* GcpEnvironment.current;
389 const runs = yield* listRuns(env.project, "-");
390 return runs
391 .filter((run) => hasOwnershipMarker(run.description))
392 .map((run) =>
393 toAttrs(run, env.project, instanceIdOf(run.instance ?? "")),
394 );
395 }),
396
397 reconcile: Effect.fn(function* ({ id, news, output }) {
398 const env = yield* GcpEnvironment.current;
399 const instance = instanceIdOf(news.instance);
400 const ownership = yield* createInternalLabels(id);
401 const userDescription = yield* toUserDescription(
402 id,
403 news.description,
404 output?.description,
405 );
406 const desiredDescription = encodeDescription(ownership, userDescription);
407
408 let current = yield* observe(
409 env.project,
410 instance,
411 news.backupRunId ?? output?.backupRunId,
412 ownership,
413 );
414
415 if (current === undefined) {
416 const created = yield* sqladmin
417 .insertBackupRuns({
418 project: env.project,
419 instance,
420 body: {
421 instance,
422 description: desiredDescription,
423 location: news.location,
424 },
425 })
426 .pipe(
427 Effect.catchTag("Conflict", (error) =>
428 findByOwnership(env.project, instance, ownership).pipe(
429 Effect.flatMap((existing) =>
430 existing ? Effect.succeed(undefined) : Effect.fail(error),
431 ),
432 ),
433 ),
434 Effect.retry({
435 while: (error) => error._tag === "Conflict",
436 times: 8,
437 schedule: Schedule.spaced("5 seconds"),
438 }),
439 );
440
441 if (created !== undefined) {
442 const done = yield* waitForOperation(env.project, created);
443 const backupRunId = idFromOperation(done) || idFromOperation(created);
444 if (backupRunId.length > 0) {
445 current = yield* waitUntilExists(
446 env.project,
447 instance,
448 backupRunId,
449 );
450 }
451 }
452 if (current === undefined) {
453 current = yield* findByOwnership(
454 env.project,
455 instance,
456 ownership,
457 ).pipe(
458 Effect.flatMap((existing) =>
459 existing
460 ? Effect.succeed(existing)
461 : Effect.fail(
462 new BackupRunNotResolved({
463 instance,
464 backupRunId:
465 output?.backupRunId ??
466 ownership[alchemyLabelKeys.id] ??
467 "",
468 }),
469 ),
470 ),
471 Effect.retry({
472 while: (error) => error._tag === "GCP.SQL.BackupRunNotResolved",
473 times: 8,
474 schedule: Schedule.spaced("2 seconds"),
475 }),
476 );
477 }
478 }
479
480 const backupRunId = idOf(current.id);
481 if (backupRunId.length === 0) {
482 return yield* new BackupRunNotResolved({
483 instance,
484 backupRunId,
485 });
486 }
487
488 if (isBusy(current.status)) {
489 current = yield* waitUntilReady(env.project, instance, backupRunId);
490 }
491
492 return toAttrs(current, env.project, instance);
493 }),
494
495 delete: Effect.fn(function* ({ output }) {
496 const project = output.project;
497 const instance = instanceIdOf(output.instance);
498 const backupRunId = output.backupRunId;
499 if (backupRunId.length === 0) return;
500 yield* sqladmin
501 .deleteBackupRuns({
502 project,
503 instance,
504 id: backupRunId,
505 })
506 .pipe(
507 Effect.flatMap((operation) =>
508 waitForOperation(project, operation, { notFoundOk: true }),
509 ),
510 Effect.catchTag("NotFound", () => Effect.void),
511 recoverIfInstanceMissing(project, instance, () => undefined),
512 Effect.retry({
513 while: (error) => error._tag === "Conflict",
514 times: 8,
515 schedule: Schedule.spaced("5 seconds"),
516 }),
517 );
518 yield* waitUntilGone(project, instance, backupRunId);
519 }),
520 });