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 { waitForSqlOperation } from "./operations.ts";
19import {
20 encodeDescription,
21 hasOwnershipMarker,
22 instanceIdOf,
23 lastSegment,
24 matchesOwnership,
25 parseDescription,
26} from "./ownership.ts";
27
28export type BackupProps = {
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.
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.
40 */
41 location?: string;
42 /**
43 * Human-readable description. Cloud SQL backups have no labels field,
44 * so Alchemy stamps ownership into the description at create. On-demand
45 * backups cannot update description in place (`updateBackupBackups`
46 * applies to FINAL backups only).
47 */
48 description?: string;
49 /**
50 * Time-to-live in days (1–365). Input-only on create. Ignored on
51 * subsequent reconciles of on-demand backups.
52 */
53 ttlDays?: number | string;
54 /**
55 * RFC3339 expiration. Writable only on FINAL backups; ignored for
56 * on-demand backups after create.
57 */
58 expiryTime?: string;
59 /**
60 * Existing backup id (the `{backup}` segment of
61 * `projects/{project}/backups/{backup}`) used to observe a backup when
62 * state is missing. The create API assigns ids — this is not sent on
63 * insert.
64 */
65 backupId?: string;
66};
67
68export type Backup = Resource<
69 "GCP.SQL.Backup",
70 BackupProps,
71 {
72 /** Full resource name `projects/{project}/backups/{backup}`. */
73 name: string;
74 /** Backup id (last path segment). */
75 backupId: string;
76 /** Project id. */
77 project: string;
78 /** Source Cloud SQL instance id. */
79 instance: string;
80 /** Storage location. */
81 location: string | undefined;
82 /** User description with the Alchemy ownership prefix stripped. */
83 description: string | undefined;
84 /** Backup type (`ON_DEMAND`, `AUTOMATED`, `FINAL`). */
85 type: string | undefined;
86 /** Server-reported state (`ENQUEUED`, `RUNNING`, `SUCCESSFUL`, …). */
87 state: string | undefined;
88 /** Kind of backup (`SNAPSHOT`, `PHYSICAL`). */
89 backupKind: string | undefined;
90 /** Input-only TTL in days, if the API echoes it. */
91 ttlDays: string | undefined;
92 /** RFC3339 expiration. */
93 expiryTime: string | undefined;
94 /** Database version at backup time. */
95 databaseVersion: string | undefined;
96 /** Mapped backup-run resource name, if any. */
97 backupRun: string | undefined;
98 /** SQL Admin self-link. */
99 selfLink: string | undefined;
100 /** Backup time zone (SQL Server). */
101 timeZone: string | undefined;
102 /** KMS key used to encrypt the backup. */
103 kmsKey: string | undefined;
104 /** Maximum chargeable bytes. */
105 maxChargeableBytes: string | undefined;
106 /** Inclusive/exclusive backup interval. */
107 backupInterval: sqladmin.Interval | undefined;
108 },
109 never,
110 Providers
111>;
112
113/**
114 * An on-demand Cloud SQL backup (`projects/{project}/backups/{backup}`).
115 *
116 * The create API assigns the backup id. Alchemy stamps ownership into
117 * `description` so `list` / `pnpm nuke:gcp` can find leaked rows.
118 * Changing `instance` or `location` replaces the backup. Description,
119 * `ttlDays`, and `expiryTime` are create-time for on-demand backups;
120 * FINAL backups can update description and expiration in place.
121 *
122 * ### Creating a Backup
123 * **Example:** On-demand backup of a Cloud SQL instance
124 * ```typescript
125 * const instance = yield* GCP.SQL.Instance("AppDb", {
126 * tier: "db-f1-micro",
127 * backupEnabled: true,
128 * });
129 * const backup = yield* GCP.SQL.Backup("Nightly", {
130 * instance: instance.instanceName,
131 * });
132 * ```
133 *
134 * **Example:** Description, location, and TTL
135 * ```typescript
136 * const backup = yield* GCP.SQL.Backup("Nightly", {
137 * instance: instance.instanceName,
138 * location: "us",
139 * description: "pre-release",
140 * ttlDays: 7,
141 * });
142 * ```
143 *
144 * @resource
145 * @category SQL
146 */
147export const Backup = Resource<Backup>("GCP.SQL.Backup");
148
149export class BackupNotResolved extends Data.TaggedError(
150 "GCP.SQL.BackupNotResolved",
151)<{
152 name: string;
153}> {}
154
155export class BackupNotReady extends Data.TaggedError("GCP.SQL.BackupNotReady")<{
156 name: string;
157 state: string | undefined;
158}> {}
159
160export class BackupStillExists extends Data.TaggedError(
161 "GCP.SQL.BackupStillExists",
162)<{
163 name: string;
164}> {}
165
166const backupNameOf = (project: string, backupId: string) =>
167 backupId.includes("/") ? backupId : `projects/${project}/backups/${backupId}`;
168
169const normalizeLocation = (value: string | undefined) =>
170 value === undefined || value.length === 0
171 ? undefined
172 : lastSegment(value).toLowerCase();
173
174const ttlDaysOf = (value: number | string | undefined) =>
175 value === undefined ? undefined : String(value);
176
177const toUserDescription = (
178 id: string,
179 description: string | undefined,
180 existing?: string,
181) =>
182 Effect.gen(function* () {
183 if (description !== undefined) return description;
184 if (existing !== undefined) return existing;
185 return yield* createPhysicalName({
186 id,
187 maxLength: 63,
188 lowercase: true,
189 });
190 });
191
192const toAttrs = (backup: sqladmin.Backup, project: string) => {
193 const name = backup.name ?? "";
194 return {
195 name,
196 backupId: lastSegment(name),
197 project,
198 instance: instanceIdOf(backup.instance ?? ""),
199 location: backup.location,
200 description: parseDescription(backup.description).description,
201 type: backup.type,
202 state: backup.state,
203 backupKind: backup.backupKind,
204 ttlDays: backup.ttlDays,
205 expiryTime: backup.expiryTime,
206 databaseVersion: backup.databaseVersion,
207 backupRun: backup.backupRun,
208 selfLink: backup.selfLink,
209 timeZone: backup.timeZone,
210 kmsKey: backup.kmsKey,
211 maxChargeableBytes: backup.maxChargeableBytes,
212 backupInterval: backup.backupInterval,
213 };
214};
215
216/**
217 * Cloud SQL answers `SqlBackupAccessDenied` (403) both for a backup uid that
218 * does not exist and for callers without permission. Recover with
219 * `onMissing` only once the project's backup list confirms it is gone.
220 */
221const recoverIfBackupMissing =
222 <B>(name: string, onMissing: () => B) =>
223 <A, E extends { readonly _tag: string }, R>(effect: Effect.Effect<A, E, R>) =>
224 effect.pipe(
225 Effect.catchIf(
226 (
227 error,
228 ): error is Extract<E, { readonly _tag: "SqlBackupAccessDenied" }> =>
229 error._tag === "SqlBackupAccessDenied",
230 (error) =>
231 listBackups(name.split("/")[1] ?? "").pipe(
232 Effect.flatMap((backups) =>
233 backups.some((backup) => backup.name === name)
234 ? Effect.fail(error)
235 : Effect.succeed(onMissing()),
236 ),
237 ),
238 ),
239 );
240
241const getByName = (name: string) =>
242 name.length === 0
243 ? Effect.succeed(undefined)
244 : sqladmin.getBackupBackups({ name }).pipe(
245 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
246 recoverIfBackupMissing(name, () => undefined),
247 );
248
249const listBackups = (project: string) =>
250 sqladmin.listBackupsBackups
251 .pages({
252 parent: `projects/${project}`,
253 pageSize: 500,
254 })
255 .pipe(
256 Stream.flatMap((page) => Stream.fromIterable(page.backups ?? [])),
257 Stream.runCollect,
258 Effect.map((chunk) => Array.from(chunk)),
259 );
260
261const findByOwnership = (project: string, labels: Record<string, string>) =>
262 listBackups(project).pipe(
263 Effect.map((backups) =>
264 backups.find((backup) => matchesOwnership(backup.description, labels)),
265 ),
266 );
267
268const observe = (
269 project: string,
270 name: string | undefined,
271 labels: Record<string, string>,
272) =>
273 Effect.gen(function* () {
274 if (name !== undefined && name.length > 0) {
275 const existing = yield* getByName(name);
276 if (existing !== undefined) return existing;
277 }
278 return yield* findByOwnership(project, labels);
279 });
280
281const waitForOperation = (
282 project: string,
283 operation: sqladmin.Operation,
284 options?: { notFoundOk?: boolean },
285) =>
286 waitForSqlOperation(project, operation, {
287 budget: "20 minutes",
288 notFoundOk: options?.notFoundOk,
289 });
290
291const waitUntilExists = (name: string) =>
292 getByName(name).pipe(
293 Effect.flatMap((backup) =>
294 backup
295 ? Effect.succeed(backup)
296 : Effect.fail(new BackupNotResolved({ name })),
297 ),
298 Effect.retry({
299 while: (error) => error._tag === "GCP.SQL.BackupNotResolved",
300 times: 8,
301 schedule: Schedule.spaced("2 seconds"),
302 }),
303 );
304
305const isBusy = (state: string | undefined) =>
306 state === "ENQUEUED" ||
307 state === "RUNNING" ||
308 state === "SQL_BACKUP_STATE_UNSPECIFIED" ||
309 state === undefined;
310
311const waitUntilReady = (name: string) =>
312 getByName(name).pipe(
313 Effect.filterOrFail(
314 (backup): backup is sqladmin.Backup => backup !== undefined,
315 () => new BackupNotResolved({ name }),
316 ),
317 Effect.filterOrFail(
318 (backup) => !isBusy(backup.state),
319 (backup) =>
320 new BackupNotReady({
321 name,
322 state: backup.state,
323 }),
324 ),
325 Effect.retry({
326 while: (error) =>
327 error._tag === "GCP.SQL.BackupNotReady" ||
328 error._tag === "GCP.SQL.BackupNotResolved",
329 times: 10,
330 schedule: Schedule.spaced("8 seconds"),
331 }),
332 );
333
334const waitUntilGone = (name: string) =>
335 getByName(name).pipe(
336 Effect.flatMap((backup) =>
337 backup === undefined
338 ? Effect.void
339 : Effect.fail(new BackupStillExists({ name })),
340 ),
341 Effect.retry({
342 while: (error) => error._tag === "GCP.SQL.BackupStillExists",
343 times: 10,
344 schedule: Schedule.spaced("5 seconds"),
345 }),
346 );
347
348const nameFromOperation = (project: string, operation: sqladmin.Operation) => {
349 const fromContext = operation.backupContext?.name;
350 if (fromContext !== undefined && fromContext.length > 0) return fromContext;
351 const backupId = operation.backupContext?.backupId;
352 if (backupId !== undefined && backupId.length > 0) {
353 return backupNameOf(project, backupId);
354 }
355 return undefined;
356};
357
358export const BackupProvider = () =>
359 Provider.succeed(Backup, {
360 stables: [
361 "name",
362 "backupId",
363 "project",
364 "instance",
365 "selfLink",
366 "backupRun",
367 ],
368
369 diff: Effect.fn(function* ({ news, olds, output }) {
370 if (!isResolved(news)) return undefined;
371 const previousInstance = olds?.instance ?? output?.instance;
372 const nextInstance = news.instance;
373 const previousLocation = normalizeLocation(
374 olds?.location ?? output?.location,
375 );
376 const nextLocation = normalizeLocation(news.location ?? output?.location);
377 const instanceChanged =
378 previousInstance !== undefined &&
379 instanceIdOf(previousInstance) !== instanceIdOf(nextInstance);
380 const locationChanged =
381 news.location !== undefined &&
382 previousLocation !== undefined &&
383 nextLocation !== undefined &&
384 previousLocation !== nextLocation;
385 if (!instanceChanged && !locationChanged) return undefined;
386 return {
387 action: "replace" as const,
388 deleteFirst: false,
389 };
390 }),
391
392 read: Effect.fn(function* ({ id, olds, output }) {
393 const env = yield* GcpEnvironment.current;
394 const ownership = yield* createInternalLabels(id);
395 const name =
396 output?.name ??
397 (olds?.backupId || output?.backupId
398 ? backupNameOf(env.project, olds?.backupId ?? output!.backupId)
399 : undefined);
400 const existing = yield* observe(env.project, name, ownership);
401 if (existing === undefined) return undefined;
402 const attrs = toAttrs(existing, env.project);
403 const { labels } = parseDescription(existing.description);
404 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
405 }),
406
407 list: () =>
408 Effect.gen(function* () {
409 const env = yield* GcpEnvironment.current;
410 const backups = yield* listBackups(env.project);
411 return backups
412 .filter((backup) => hasOwnershipMarker(backup.description))
413 .map((backup) => toAttrs(backup, env.project));
414 }),
415
416 reconcile: Effect.fn(function* ({ id, news, output }) {
417 const env = yield* GcpEnvironment.current;
418 const instance = instanceIdOf(news.instance);
419 const ownership = yield* createInternalLabels(id);
420 const userDescription = yield* toUserDescription(
421 id,
422 news.description,
423 output?.description,
424 );
425 const desiredDescription = encodeDescription(ownership, userDescription);
426 const name =
427 output?.name ??
428 (news.backupId ? backupNameOf(env.project, news.backupId) : undefined);
429
430 let current = yield* observe(env.project, name, ownership);
431
432 if (current === undefined) {
433 const created = yield* sqladmin
434 .createBackupBackups({
435 parent: `projects/${env.project}`,
436 body: {
437 instance,
438 description: desiredDescription,
439 location: news.location,
440 ttlDays: ttlDaysOf(news.ttlDays),
441 },
442 })
443 .pipe(
444 Effect.catchTag("Conflict", (error) =>
445 findByOwnership(env.project, ownership).pipe(
446 Effect.flatMap((existing) =>
447 existing ? Effect.succeed(undefined) : Effect.fail(error),
448 ),
449 ),
450 ),
451 Effect.retry({
452 while: (error) => error._tag === "Conflict",
453 times: 8,
454 schedule: Schedule.spaced("5 seconds"),
455 }),
456 );
457
458 if (created !== undefined) {
459 const done = yield* waitForOperation(env.project, created);
460 const createdName =
461 nameFromOperation(env.project, done) ??
462 nameFromOperation(env.project, created);
463 if (createdName !== undefined) {
464 current = yield* waitUntilExists(createdName);
465 }
466 }
467 if (current === undefined) {
468 current = yield* findByOwnership(env.project, ownership).pipe(
469 Effect.flatMap((existing) =>
470 existing
471 ? Effect.succeed(existing)
472 : Effect.fail(
473 new BackupNotResolved({
474 name:
475 name ??
476 `projects/${env.project}/backups/${ownership[alchemyLabelKeys.id]}`,
477 }),
478 ),
479 ),
480 Effect.retry({
481 while: (error) => error._tag === "GCP.SQL.BackupNotResolved",
482 times: 8,
483 schedule: Schedule.spaced("2 seconds"),
484 }),
485 );
486 }
487 }
488
489 const currentName = current.name ?? "";
490 if (currentName.length === 0) {
491 return yield* new BackupNotResolved({ name: currentName });
492 }
493
494 if (isBusy(current.state)) {
495 current = yield* waitUntilReady(currentName);
496 }
497
498 const isFinal = (current.type ?? "").toUpperCase() === "FINAL";
499 const descriptionChanged =
500 (current.description ?? "") !== desiredDescription;
501 const expiryChanged =
502 news.expiryTime !== undefined &&
503 (current.expiryTime ?? "") !== news.expiryTime;
504
505 if (isFinal && (descriptionChanged || expiryChanged)) {
506 const patched = yield* sqladmin.updateBackupBackups({
507 name: currentName,
508 updateMask: [
509 descriptionChanged ? "description" : undefined,
510 expiryChanged ? "expiryTime" : undefined,
511 ]
512 .filter((field): field is string => field !== undefined)
513 .join(","),
514 body: {
515 description: desiredDescription,
516 expiryTime: news.expiryTime,
517 },
518 });
519 yield* waitForOperation(env.project, patched);
520 current = yield* waitUntilExists(currentName);
521 }
522
523 return toAttrs(current, env.project);
524 }),
525
526 delete: Effect.fn(function* ({ output }) {
527 const name = output.name;
528 if (name.length === 0) return;
529 yield* sqladmin.deleteBackupBackups({ name }).pipe(
530 Effect.flatMap((operation) =>
531 waitForOperation(output.project, operation, { notFoundOk: true }),
532 ),
533 Effect.catchTag("NotFound", () => Effect.void),
534 recoverIfBackupMissing(name, () => undefined),
535 Effect.retry({
536 while: (error) => error._tag === "Conflict",
537 times: 8,
538 schedule: Schedule.spaced("5 seconds"),
539 }),
540 );
541 yield* waitUntilGone(name);
542 }),
543 });