1import * as bigtable from "@distilled.cloud/gcp/bigtableadmin_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 clusterIdOf,
13 clusterNameOf,
14 instanceIdOf,
15 listAlchemyInstances,
16 MAX_BACKUP_ID_LENGTH,
17 parentOwned,
18 parseResourceName,
19 tableNameOf,
20 toPhysicalId,
21 waitForOperation,
22 collectPages,
23} from "./operations.ts";
24
25const DEFAULT_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000;
26
27export type InstancesClustersBackupProps = {
28 /**
29 * Parent instance. Full name `projects/{project}/instances/{instance}`
30 * or the instance id. Immutable — changing it replaces the backup.
31 */
32 instance: string;
33 /**
34 * Cluster that stores the backup. Full name
35 * `projects/{project}/instances/{instance}/clusters/{cluster}` or the
36 * cluster id. Immutable — changing it replaces the backup.
37 */
38 cluster: string;
39 /**
40 * Source table. Full name
41 * `projects/{project}/instances/{instance}/tables/{table}` or the table
42 * id. Immutable — changing it replaces the backup.
43 */
44 sourceTable: string;
45 /**
46 * Backup id (the `{backup}` segment of
47 * `.../clusters/{cluster}/backups/{backup}`). If omitted, a unique name
48 * is generated from the stack, stage, and logical id. Must be 1-50
49 * characters. Immutable — changing it replaces the backup.
50 */
51 backupId?: string;
52 /**
53 * RFC3339 expiration. Must be at least 6 hours and at most 90 days
54 * after creation. Defaults to 7 days from reconcile time when omitted
55 * on create.
56 */
57 expireTime?: string;
58 /**
59 * Backup type (`STANDARD` or `HOT`). Immutable — changing it replaces
60 * the backup.
61 */
62 backupType?: bigtable.BackupBackupTypeEnum | (string & {});
63 /**
64 * Time at which a hot backup converts to standard. Only valid for
65 * `HOT` backups; must be at least 24 hours after creation.
66 */
67 hotToStandardTime?: string;
68};
69
70export type InstancesClustersBackup = Resource<
71 "GCP.Bigtable.InstancesClustersBackup",
72 InstancesClustersBackupProps,
73 {
74 /** Full resource name `.../clusters/{cluster}/backups/{backup}`. */
75 name: string;
76 /** Backup id (last path segment). */
77 backupId: string;
78 /** Parent cluster resource name. */
79 cluster: string;
80 /** Parent cluster id. */
81 clusterId: string;
82 /** Parent instance resource name. */
83 instance: string;
84 /** Parent instance id. */
85 instanceId: string;
86 /** Project id. */
87 project: string;
88 /** Source table resource name. */
89 sourceTable: string | undefined;
90 /** Copied-from backup name, if this backup is a copy. */
91 sourceBackup: string | undefined;
92 /** RFC3339 expiration. */
93 expireTime: string | undefined;
94 /** Backup type (`STANDARD`, `HOT`). */
95 backupType: string | undefined;
96 /** Hot-to-standard conversion time. */
97 hotToStandardTime: string | undefined;
98 /** Server-reported state (`CREATING`, `READY`). */
99 state: string | undefined;
100 /** RFC3339 start time. */
101 startTime: string | undefined;
102 /** RFC3339 end time. */
103 endTime: string | undefined;
104 /** Size in bytes (decimal string). */
105 sizeBytes: string | undefined;
106 /** Encryption info reported by the API. */
107 encryptionInfo: bigtable.EncryptionInfo | undefined;
108 },
109 never,
110 Providers
111>;
112
113/**
114 * A Cloud Bigtable backup of a table, stored on one cluster.
115 *
116 * The parent instance, cluster, and source table must already exist.
117 * Changing `backupId`, `instance`, `cluster`, `sourceTable`, or
118 * `backupType` replaces the backup. `expireTime` and
119 * `hotToStandardTime` update in place.
120 *
121 * Backups have no labels field. Alchemy treats a backup as owned when
122 * its parent instance carries Alchemy labels, so `list` /
123 * `pnpm nuke:gcp` can find it.
124 *
125 * ### Creating a Backup
126 * **Example:** 7-day backup of an existing table
127 * ```typescript
128 * const backup = yield* GCP.Bigtable.InstancesClustersBackup("Nightly", {
129 * instance: instance.name,
130 * cluster: "cluster",
131 * sourceTable: table.name,
132 * });
133 * ```
134 *
135 * **Example:** Explicit id and expiration
136 * ```typescript
137 * const backup = yield* GCP.Bigtable.InstancesClustersBackup("Nightly", {
138 * instance: instance.name,
139 * cluster: replica.clusterId,
140 * sourceTable: table.name,
141 * backupId: "nightly",
142 * expireTime: "2026-12-31T00:00:00Z",
143 * backupType: "STANDARD",
144 * });
145 * ```
146 *
147 * @resource
148 * @category Bigtable
149 */
150export const InstancesClustersBackup = Resource<InstancesClustersBackup>(
151 "GCP.Bigtable.InstancesClustersBackup",
152);
153
154export class BackupNotResolved extends Data.TaggedError(
155 "GCP.Bigtable.BackupNotResolved",
156)<{
157 name: string;
158}> {}
159
160export class BackupNotReady extends Data.TaggedError(
161 "GCP.Bigtable.BackupNotReady",
162)<{
163 name: string;
164 state: string;
165}> {}
166
167export class BackupStillExists extends Data.TaggedError(
168 "GCP.Bigtable.BackupStillExists",
169)<{
170 name: string;
171}> {}
172
173const toId = (id: string, backupId: string | undefined, existing?: string) =>
174 toPhysicalId(id, backupId, existing, MAX_BACKUP_ID_LENGTH);
175
176const normalizeType = (value: string | undefined) => {
177 const next = (value ?? "").toUpperCase();
178 return next === "BACKUP_TYPE_UNSPECIFIED" ? "" : next;
179};
180
181const defaultExpireTime = () =>
182 Effect.sync(() => new Date(Date.now() + DEFAULT_EXPIRE_MS).toISOString());
183
184const toAttrs = (backup: bigtable.Backup, project: string) => {
185 const name = backup.name ?? "";
186 const parsed = parseResourceName(name);
187 return {
188 name,
189 backupId: parsed.backupId,
190 cluster: parsed.cluster,
191 clusterId: parsed.clusterId,
192 instance: parsed.instance,
193 instanceId: parsed.instanceId,
194 project: parsed.project || project,
195 sourceTable: backup.sourceTable,
196 sourceBackup: backup.sourceBackup,
197 expireTime: backup.expireTime,
198 backupType: backup.backupType,
199 hotToStandardTime: backup.hotToStandardTime,
200 state: backup.state,
201 startTime: backup.startTime,
202 endTime: backup.endTime,
203 sizeBytes: backup.sizeBytes,
204 encryptionInfo: backup.encryptionInfo,
205 };
206};
207
208const getByName = (name: string) =>
209 bigtable
210 .getProjectsInstancesClustersBackups({ name })
211 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
212
213const isBusy = (state: string | undefined) =>
214 state === "CREATING" || state === "STATE_UNSPECIFIED" || state === undefined;
215
216const waitUntilExists = (name: string) =>
217 getByName(name).pipe(
218 Effect.flatMap((backup) =>
219 backup
220 ? Effect.succeed(backup)
221 : Effect.fail(new BackupNotResolved({ name })),
222 ),
223 Effect.retry({
224 while: (error) => error._tag === "GCP.Bigtable.BackupNotResolved",
225 times: 8,
226 schedule: Schedule.spaced("1 second"),
227 }),
228 );
229
230const waitUntilReady = (name: string) =>
231 getByName(name).pipe(
232 Effect.filterOrFail(
233 (backup): backup is bigtable.Backup => backup !== undefined,
234 () => new BackupNotResolved({ name }),
235 ),
236 Effect.filterOrFail(
237 (backup) => !isBusy(backup.state),
238 (backup) =>
239 new BackupNotReady({
240 name,
241 state: backup.state ?? "STATE_UNSPECIFIED",
242 }),
243 ),
244 Effect.retry({
245 while: (error) =>
246 error._tag === "GCP.Bigtable.BackupNotReady" ||
247 error._tag === "GCP.Bigtable.BackupNotResolved",
248 times: 10,
249 schedule: Schedule.spaced("8 seconds"),
250 }),
251 );
252
253const waitUntilGone = (name: string) =>
254 getByName(name).pipe(
255 Effect.flatMap((backup) =>
256 backup === undefined
257 ? Effect.void
258 : Effect.fail(new BackupStillExists({ name })),
259 ),
260 Effect.retry({
261 while: (error) => error._tag === "GCP.Bigtable.BackupStillExists",
262 times: 10,
263 schedule: Schedule.spaced("5 seconds"),
264 }),
265 );
266
267export const InstancesClustersBackupProvider = () =>
268 Provider.succeed(InstancesClustersBackup, {
269 stables: [
270 "name",
271 "backupId",
272 "cluster",
273 "clusterId",
274 "instance",
275 "instanceId",
276 "project",
277 "sourceTable",
278 "startTime",
279 ],
280
281 diff: Effect.fn(function* ({ news, olds, output }) {
282 if (!isResolved(news)) return undefined;
283 const previousId = olds?.backupId ?? output?.backupId;
284 const nextId = news.backupId ?? previousId;
285 const previousInstance = instanceIdOf(
286 olds?.instance ?? output?.instance ?? output?.instanceId ?? "",
287 );
288 const nextInstance = instanceIdOf(news.instance);
289 const previousCluster = clusterIdOf(
290 olds?.cluster ?? output?.cluster ?? output?.clusterId ?? "",
291 );
292 const nextCluster = clusterIdOf(news.cluster);
293 const previousTable = parseResourceName(
294 olds?.sourceTable ?? output?.sourceTable ?? "",
295 ).tableId;
296 const nextTable = parseResourceName(
297 news.sourceTable.includes("/tables/")
298 ? news.sourceTable
299 : `projects/_/instances/_/tables/${news.sourceTable}`,
300 ).tableId;
301 const previousType = normalizeType(
302 olds?.backupType ?? output?.backupType,
303 );
304 const nextType = normalizeType(news.backupType ?? output?.backupType);
305 if (
306 (previousId !== undefined &&
307 nextId !== undefined &&
308 previousId !== nextId) ||
309 (previousInstance.length > 0 && previousInstance !== nextInstance) ||
310 (previousCluster.length > 0 && previousCluster !== nextCluster) ||
311 (previousTable.length > 0 && previousTable !== nextTable) ||
312 (news.backupType !== undefined &&
313 previousType.length > 0 &&
314 nextType.length > 0 &&
315 previousType !== nextType)
316 ) {
317 return { action: "replace" as const };
318 }
319 return undefined;
320 }),
321
322 read: Effect.fn(function* ({ id, olds, output }) {
323 const env = yield* GcpEnvironment.current;
324 const backupId = yield* toId(id, olds?.backupId, output?.backupId);
325 const instanceRef = olds?.instance ?? output?.instance;
326 const clusterRef = olds?.cluster ?? output?.cluster;
327 const name =
328 output?.name ??
329 (instanceRef && clusterRef
330 ? `${clusterNameOf(env.project, instanceRef, clusterRef)}/backups/${backupId}`
331 : undefined);
332 if (name === undefined) return undefined;
333 const existing = yield* getByName(name);
334 if (existing === undefined) return undefined;
335 const attrs = toAttrs(existing, env.project);
336 return (yield* parentOwned(attrs.instance)) ? attrs : Unowned(attrs);
337 }),
338
339 list: () =>
340 Effect.gen(function* () {
341 const env = yield* GcpEnvironment.current;
342 const instances = (yield* listAlchemyInstances(env.project)).filter(
343 (instance): instance is bigtable.Instance & { name: string } =>
344 typeof instance.name === "string" && instance.name.length > 0,
345 );
346 const pages = yield* Effect.forEach(
347 instances,
348 (instance) =>
349 collectPages(
350 bigtable.listProjectsInstancesClustersBackups.pages({
351 parent: `${instance.name}/clusters/-`,
352 pageSize: 1000,
353 }),
354 (page) => page.backups,
355 ).pipe(
356 Effect.catchTag("NotFound", () =>
357 Effect.succeed([] as bigtable.Backup[]),
358 ),
359 ),
360 { concurrency: 4 },
361 );
362 return pages.flat().map((backup) => toAttrs(backup, env.project));
363 }),
364
365 reconcile: Effect.fn(function* ({ id, news, output }) {
366 const env = yield* GcpEnvironment.current;
367 const backupId = yield* toId(id, news.backupId, output?.backupId);
368 const parent = clusterNameOf(env.project, news.instance, news.cluster);
369 const name = `${parent}/backups/${backupId}`;
370 const sourceTable = tableNameOf(
371 env.project,
372 news.instance,
373 news.sourceTable,
374 );
375 const expireTime = news.expireTime ?? (yield* defaultExpireTime());
376
377 let current = yield* getByName(output?.name ?? name);
378
379 if (current === undefined) {
380 const created = yield* bigtable
381 .createProjectsInstancesClustersBackups({
382 parent,
383 backupId,
384 body: {
385 sourceTable,
386 expireTime,
387 backupType: news.backupType,
388 hotToStandardTime: news.hotToStandardTime,
389 },
390 })
391 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
392 if (created !== undefined) {
393 yield* waitForOperation(created, { alreadyExistsOk: true });
394 }
395 current = yield* waitUntilExists(name);
396 }
397
398 if (isBusy(current.state)) {
399 current = yield* waitUntilReady(name);
400 }
401
402 const mask: string[] = [];
403 const patchBody: bigtable.Backup = {};
404 if (
405 news.expireTime !== undefined &&
406 (current.expireTime ?? "") !== news.expireTime
407 ) {
408 patchBody.expireTime = news.expireTime;
409 mask.push("expire_time");
410 }
411 if (
412 news.hotToStandardTime !== undefined &&
413 (current.hotToStandardTime ?? "") !== news.hotToStandardTime
414 ) {
415 patchBody.hotToStandardTime = news.hotToStandardTime;
416 mask.push("hot_to_standard_time");
417 }
418
419 if (mask.length > 0) {
420 current = yield* bigtable.patchProjectsInstancesClustersBackups({
421 name,
422 updateMask: mask.join(","),
423 body: patchBody,
424 });
425 }
426
427 return toAttrs(current, env.project);
428 }),
429
430 delete: Effect.fn(function* ({ output }) {
431 yield* bigtable
432 .deleteProjectsInstancesClustersBackups({ name: output.name })
433 .pipe(
434 Effect.catchTag("NotFound", () => Effect.void),
435 Effect.retry({
436 while: (error) => error._tag === "Conflict",
437 times: 8,
438 schedule: Schedule.spaced("2 seconds"),
439 }),
440 );
441 yield* waitUntilGone(output.name);
442 }),
443 });