1import * as backupdr from "@distilled.cloud/gcp/backupdr_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 backupVaultOf,
14 fieldMask,
15 fingerprint,
16 listAtLocation,
17 listLabeledPages,
18 normalizeLocation,
19 parentOf,
20 parseName,
21 replaceOnIdentity,
22 ResourceNotResolved,
23 sameText,
24 toPhysicalId,
25 userLabels,
26 waitForOperation,
27 waitUntilExists,
28 waitUntilGone,
29 waitUntilReady,
30} from "./internal.ts";
31
32export type BackupWindow = {
33 /**
34 * Hour of day (`0`–`23`) when the window starts.
35 */
36 startHourOfDay?: number;
37 /**
38 * Hour of day (`1`–`24`) when the window ends. Must be greater than
39 * `startHourOfDay`. The end hour is exclusive.
40 */
41 endHourOfDay?: number;
42};
43
44export type WeekDayOfMonth = {
45 /**
46 * Day of week.
47 */
48 dayOfWeek?: backupdr.WeekDayOfMonthDayOfWeekEnum | (string & {});
49 /**
50 * Week of month (`FIRST`, `SECOND`, `THIRD`, `FOURTH`, `LAST`).
51 */
52 weekOfMonth?: backupdr.WeekDayOfMonthWeekOfMonthEnum | (string & {});
53};
54
55export type StandardSchedule = {
56 /**
57 * Recurrence (`HOURLY`, `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`).
58 */
59 recurrenceType?: backupdr.StandardScheduleRecurrenceTypeEnum | (string & {});
60 /**
61 * Days of month (`1`–`31`). `MONTHLY` and `YEARLY` only.
62 */
63 daysOfMonth?: number[];
64 /**
65 * Hourly frequency. Required for `HOURLY`. Workload-specific min/max
66 * apply (Compute Instance: 4–23).
67 */
68 hourlyFrequency?: number;
69 /**
70 * Days of week. Required for `WEEKLY`.
71 */
72 daysOfWeek?: Array<
73 backupdr.StandardScheduleDaysOfWeekItemEnum | (string & {})
74 >;
75 /**
76 * IANA time zone used to interpret the schedule (`UTC`, `America/New_York`).
77 */
78 timeZone?: string;
79 /**
80 * Week day of the month. `MONTHLY` and `YEARLY` only.
81 */
82 weekDayOfMonth?: WeekDayOfMonth;
83 /**
84 * Months of year. `YEARLY` only.
85 */
86 months?: Array<backupdr.StandardScheduleMonthsItemEnum | (string & {})>;
87 /**
88 * Window of day during which backup jobs may start.
89 */
90 backupWindow?: BackupWindow;
91};
92
93export type BackupRule = {
94 /**
95 * Unique id of this rule within the plan. RFC1035: lowercase letter
96 * followed by up to 62 letters, digits, or hyphens. Immutable.
97 */
98 ruleId?: string;
99 /**
100 * Retention in days. Must be at least the vault's minimum enforced
101 * retention. Minimum 1.
102 */
103 backupRetentionDays?: number;
104 /**
105 * Schedule that runs inside a backup window.
106 */
107 standardSchedule?: StandardSchedule;
108};
109
110export type DiskBackupPlanProperties = {
111 /**
112 * Whether to guest-flush before a disk backup. `false` produces a
113 * crash-consistent backup.
114 * @default false
115 */
116 guestFlush?: boolean;
117};
118
119export type ComputeInstanceBackupPlanProperties = {
120 /**
121 * Whether to guest-flush before a compute backup. `false` produces a
122 * crash-consistent backup.
123 * @default false
124 */
125 guestFlush?: boolean;
126};
127
128export type BackupPlanProps = {
129 /**
130 * Backup plan id (the `{backupPlan}` segment of
131 * `projects/{project}/locations/{location}/backupPlans/{backupPlan}`).
132 * If omitted, a unique RFC1035 name is generated from the stack, stage,
133 * and logical id. Immutable — changing it replaces the plan.
134 */
135 backupPlanId?: string;
136 /**
137 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
138 * replaces the plan. `US-CENTRAL1` is accepted and normalized to
139 * `us-central1`.
140 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
141 */
142 location?: string;
143 /**
144 * Backup vault that stores backups created by this plan. Full name
145 * `projects/{project}/locations/{location}/backupVaults/{backupvault}`
146 * or a vault id combined with `location`. Immutable — changing it
147 * replaces the plan.
148 */
149 backupVault: string;
150 /**
151 * Resource type the plan applies to, e.g. `compute.googleapis.com/Instance`,
152 * `compute.googleapis.com/Disk`, `sqladmin.googleapis.com/Instance`,
153 * `alloydb.googleapis.com/Cluster`, `file.googleapis.com/Instance`.
154 * Immutable — changing it replaces the plan.
155 */
156 resourceType: string;
157 /**
158 * Backup rules (schedule + retention). At least one rule is required
159 * in practice.
160 */
161 backupRules?: BackupRule[];
162 /**
163 * Cloud SQL only. Log retention in days. Must be at least the vault's
164 * minimum log retention.
165 */
166 logRetentionDays?: string;
167 /**
168 * Maximum days an on-demand backup with custom retention may be kept.
169 */
170 maxCustomOnDemandRetentionDays?: number;
171 /**
172 * Disk-specific options (`compute.googleapis.com/Disk`).
173 */
174 diskBackupPlanProperties?: DiskBackupPlanProperties;
175 /**
176 * Compute instance-specific options (`compute.googleapis.com/Instance`).
177 */
178 computeInstanceBackupPlanProperties?: ComputeInstanceBackupPlanProperties;
179 /**
180 * Human-readable description (2048 characters or less).
181 */
182 description?: string;
183 /**
184 * User labels. Alchemy ownership labels are merged in automatically.
185 */
186 labels?: Record<string, string>;
187};
188
189export type BackupPlan = Resource<
190 "GCP.BackupDR.BackupPlan",
191 BackupPlanProps,
192 {
193 /** Full resource name. */
194 name: string;
195 /** Backup plan id (last path segment). */
196 backupPlanId: string;
197 /** Project id. */
198 project: string;
199 /** Location id. */
200 location: string;
201 /** Backup vault name. */
202 backupVault: string | undefined;
203 /** Backup vault service account. */
204 backupVaultServiceAccount: string | undefined;
205 /** Resource type this plan applies to. */
206 resourceType: string | undefined;
207 /** Resource types this plan can be applied to. */
208 supportedResourceTypes: string[];
209 /** Backup rules. */
210 backupRules: BackupRule[];
211 /** Cloud SQL log retention days. */
212 logRetentionDays: string | undefined;
213 /** Max custom on-demand retention days. */
214 maxCustomOnDemandRetentionDays: number | undefined;
215 /** Disk-specific options. */
216 diskBackupPlanProperties: DiskBackupPlanProperties | undefined;
217 /** Compute instance-specific options. */
218 computeInstanceBackupPlanProperties:
219 | ComputeInstanceBackupPlanProperties
220 | undefined;
221 /** Human-readable description. */
222 description: string | undefined;
223 /** User labels (Alchemy ownership labels stripped). */
224 labels: Record<string, string>;
225 /** Server-reported state. */
226 state: string | undefined;
227 /** Revision resource name. */
228 revisionName: string | undefined;
229 /** User-friendly revision id (`v0`, `v1`, …). */
230 revisionId: string | undefined;
231 /** RFC3339 creation timestamp. */
232 createTime: string | undefined;
233 /** RFC3339 last-update timestamp. */
234 updateTime: string | undefined;
235 },
236 never,
237 Providers
238>;
239
240/**
241 * A Backup and DR backup plan — schedule and retention for a workload
242 * type, stored in a Backup Vault.
243 *
244 * Changing `backupPlanId`, `location`, `backupVault`, `resourceType`, or
245 * `labels` replaces the plan. Description, rules, log retention, and
246 * workload properties update in place.
247 *
248 * ### Creating a Backup Plan
249 * **Example:** Daily compute backups
250 * ```typescript
251 * const backupRules = [
252 * {
253 * ruleId: "daily",
254 * backupRetentionDays: 7,
255 * standardSchedule: {
256 * recurrenceType: "DAILY",
257 * timeZone: "UTC",
258 * backupWindow: { startHourOfDay: 1, endHourOfDay: 5 },
259 * },
260 * },
261 * ];
262 * const plan = yield* GCP.BackupDR.BackupPlan("Nightly", {
263 * backupVault: vault.name,
264 * resourceType: "compute.googleapis.com/Instance",
265 * backupRules,
266 * });
267 * ```
268 *
269 * ### Updating a Backup Plan
270 * **Example:** Description and labels
271 * ```typescript
272 * // Same logical id, changed props: the engine updates it in place.
273 * const plan = yield* GCP.BackupDR.BackupPlan("Nightly", {
274 * backupVault: vault.name,
275 * resourceType: "compute.googleapis.com/Instance",
276 * description: "nightly backups v2",
277 * labels: { env: "prod" },
278 * backupRules,
279 * });
280 * ```
281 *
282 * @resource
283 * @category BackupDR
284 */
285export const BackupPlan = Resource<BackupPlan>("GCP.BackupDR.BackupPlan");
286
287const resourceName = (
288 project: string,
289 location: string,
290 backupPlanId: string,
291) => `projects/${project}/locations/${location}/backupPlans/${backupPlanId}`;
292
293const toRule = (rule: backupdr.BackupRule | BackupRule): BackupRule => ({
294 ruleId: rule.ruleId,
295 backupRetentionDays: rule.backupRetentionDays,
296 standardSchedule: rule.standardSchedule,
297});
298
299const toRules = (
300 rules: readonly (backupdr.BackupRule | BackupRule)[] | undefined,
301): BackupRule[] => (rules ?? []).map(toRule);
302
303const toAttrs = (
304 item: backupdr.BackupPlan,
305 project: string,
306 region: string,
307) => {
308 const name = item.name ?? "";
309 const parsed = parseName(name, "backupPlans", region);
310 return {
311 name,
312 backupPlanId: parsed.id,
313 project: parsed.project || project,
314 location: parsed.location,
315 backupVault: item.backupVault,
316 backupVaultServiceAccount: item.backupVaultServiceAccount,
317 resourceType: item.resourceType,
318 supportedResourceTypes: item.supportedResourceTypes ?? [],
319 backupRules: toRules(item.backupRules),
320 logRetentionDays: item.logRetentionDays,
321 maxCustomOnDemandRetentionDays: item.maxCustomOnDemandRetentionDays,
322 diskBackupPlanProperties: item.diskBackupPlanProperties,
323 computeInstanceBackupPlanProperties:
324 item.computeInstanceBackupPlanProperties,
325 description: item.description,
326 labels: userLabels(item.labels),
327 state: item.state,
328 revisionName: item.revisionName,
329 revisionId: item.revisionId,
330 createTime: item.createTime,
331 updateTime: item.updateTime,
332 };
333};
334
335const getByName = (name: string) =>
336 backupdr
337 .getProjectsLocationsBackupPlans({ name })
338 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
339
340const listOwned = (project: string, region: string) =>
341 listAtLocation(project, region, (parent) =>
342 listLabeledPages(
343 backupdr.listProjectsLocationsBackupPlans.pages({
344 parent,
345 pageSize: 1000,
346 }),
347 (page) => page.backupPlans,
348 (item) => item.labels,
349 ),
350 );
351
352export const BackupPlanProvider = () =>
353 Provider.succeed(BackupPlan, {
354 stables: ["name", "backupPlanId", "project", "location", "createTime"],
355
356 diff: Effect.fn(function* ({ news, olds, output }) {
357 if (!isResolved(news)) return undefined;
358 const env = yield* GcpEnvironment.current;
359 const previousVault = olds?.backupVault ?? output?.backupVault;
360 const previousType = olds?.resourceType ?? output?.resourceType;
361 return replaceOnIdentity({
362 previousId: olds?.backupPlanId ?? output?.backupPlanId,
363 nextId: news.backupPlanId ?? olds?.backupPlanId ?? output?.backupPlanId,
364 previousLocation: normalizeLocation(
365 olds?.location ?? output?.location,
366 env.region,
367 ),
368 nextLocation: normalizeLocation(
369 news.location ?? olds?.location ?? output?.location,
370 env.region,
371 ),
372 extra:
373 (previousType !== undefined &&
374 news.resourceType !== undefined &&
375 previousType !== news.resourceType) ||
376 (previousVault !== undefined &&
377 news.backupVault !== undefined &&
378 previousVault !== news.backupVault &&
379 !previousVault.endsWith(`/${news.backupVault}`)) ||
380 // Backup plan labels are immutable ("labels can not be updated").
381 (olds !== undefined &&
382 fingerprint(olds.labels ?? {}) !== fingerprint(news.labels ?? {})),
383 });
384 }),
385
386 read: Effect.fn(function* ({ id, olds, output }) {
387 const env = yield* GcpEnvironment.current;
388 const backupPlanId = yield* toPhysicalId(
389 id,
390 olds?.backupPlanId,
391 output?.backupPlanId,
392 "backupplan",
393 );
394 const location = normalizeLocation(
395 olds?.location ?? output?.location,
396 env.region,
397 );
398 const name =
399 output?.name ?? resourceName(env.project, location, backupPlanId);
400 const existing = yield* getByName(name);
401 if (existing === undefined) return undefined;
402 const attrs = toAttrs(existing, env.project, env.region);
403 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
404 ? attrs
405 : Unowned(attrs);
406 }),
407
408 list: () =>
409 Effect.gen(function* () {
410 const env = yield* GcpEnvironment.current;
411 const items = yield* listOwned(env.project, env.region);
412 return items.map((item) => toAttrs(item, env.project, env.region));
413 }),
414
415 reconcile: Effect.fn(function* ({ id, news, output }) {
416 const env = yield* GcpEnvironment.current;
417 const backupPlanId = yield* toPhysicalId(
418 id,
419 news.backupPlanId,
420 output?.backupPlanId,
421 "backupplan",
422 );
423 const location = normalizeLocation(
424 news.location ?? output?.location,
425 env.region,
426 );
427 const name = resourceName(env.project, location, backupPlanId);
428 const backupVault = backupVaultOf(
429 news.backupVault,
430 env.project,
431 location,
432 );
433 const desiredLabels = {
434 ...toLabels(news.labels),
435 ...(yield* createInternalLabels(id)),
436 };
437
438 let current = yield* getByName(output?.name ?? name);
439
440 if (current === undefined) {
441 const created = yield* backupdr
442 .createProjectsLocationsBackupPlans({
443 parent: parentOf(env.project, location),
444 backupPlanId,
445 body: {
446 backupVault,
447 resourceType: news.resourceType,
448 backupRules: news.backupRules,
449 logRetentionDays: news.logRetentionDays,
450 maxCustomOnDemandRetentionDays:
451 news.maxCustomOnDemandRetentionDays,
452 diskBackupPlanProperties: news.diskBackupPlanProperties,
453 computeInstanceBackupPlanProperties:
454 news.computeInstanceBackupPlanProperties,
455 description: news.description,
456 labels: desiredLabels,
457 },
458 })
459 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
460 if (created !== undefined) {
461 // ALREADY_EXISTS (6): a concurrent create won the race.
462 yield* waitForOperation(created).pipe(
463 Effect.catchTag("GCP.OperationFailed", (error) =>
464 error.code === 6 ? Effect.void : Effect.fail(error),
465 ),
466 );
467 }
468 current = yield* waitUntilExists(getByName(name), name);
469 }
470
471 if (current === undefined) {
472 return yield* new ResourceNotResolved({ name });
473 }
474
475 current = yield* waitUntilReady(
476 getByName(current.name ?? name),
477 current.name ?? name,
478 (item) => item.state,
479 );
480
481 const mask = fieldMask([
482 !sameText(current.description, news.description) && "description",
483 fingerprint(toRules(current.backupRules)) !==
484 fingerprint(toRules(news.backupRules)) && "backupRules",
485 news.logRetentionDays !== undefined &&
486 !sameText(current.logRetentionDays, news.logRetentionDays) &&
487 "logRetentionDays",
488 news.maxCustomOnDemandRetentionDays !== undefined &&
489 current.maxCustomOnDemandRetentionDays !==
490 news.maxCustomOnDemandRetentionDays &&
491 "maxCustomOnDemandRetentionDays",
492 news.diskBackupPlanProperties !== undefined &&
493 fingerprint(current.diskBackupPlanProperties) !==
494 fingerprint(news.diskBackupPlanProperties) &&
495 "diskBackupPlanProperties",
496 news.computeInstanceBackupPlanProperties !== undefined &&
497 fingerprint(current.computeInstanceBackupPlanProperties) !==
498 fingerprint(news.computeInstanceBackupPlanProperties) &&
499 "computeInstanceBackupPlanProperties",
500 ]);
501
502 if (mask.length > 0) {
503 const operation = yield* backupdr.patchProjectsLocationsBackupPlans({
504 name: current.name ?? name,
505 updateMask: mask,
506 body: {
507 etag: current.etag,
508 description: news.description,
509 backupRules: news.backupRules,
510 logRetentionDays: news.logRetentionDays,
511 maxCustomOnDemandRetentionDays: news.maxCustomOnDemandRetentionDays,
512 diskBackupPlanProperties: news.diskBackupPlanProperties,
513 computeInstanceBackupPlanProperties:
514 news.computeInstanceBackupPlanProperties,
515 },
516 });
517 yield* waitForOperation(operation);
518 current = yield* waitUntilReady(
519 getByName(current.name ?? name),
520 current.name ?? name,
521 (item) => item.state,
522 );
523 }
524
525 return toAttrs(current, env.project, env.region);
526 }),
527
528 delete: Effect.fn(function* ({ output }) {
529 const operation = yield* backupdr
530 .deleteProjectsLocationsBackupPlans({ name: output.name })
531 .pipe(
532 Effect.retry({
533 while: (error) => error._tag === "Conflict",
534 times: 8,
535 schedule: Schedule.spaced("2 seconds"),
536 }),
537 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
538 );
539 if (operation !== undefined) {
540 // NOT_FOUND (5): already gone.
541 yield* waitForOperation(operation).pipe(
542 Effect.catchTag("GCP.OperationFailed", (error) =>
543 error.code === 5 ? Effect.void : Effect.fail(error),
544 ),
545 );
546 }
547 yield* waitUntilGone(getByName(output.name), output.name);
548 }),
549 });