1import * as gkebackup from "@distilled.cloud/gcp/gkebackup_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 {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 clusterName,
19 fieldMask,
20 fingerprint,
21 listAtLocation,
22 listLabeledPages,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 ResourceNotResolved,
28 sameText,
29 toPhysicalId,
30 userLabels,
31 waitForDeleteOperation,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35 waitUntilReady,
36} from "./internal.ts";
37
38export type RetentionPolicy = {
39 /**
40 * Default maximum age of a Backup created via this plan, in days
41 * (`0`–`365`). `0` disables automatic deletion.
42 */
43 backupRetainDays?: number;
44 /**
45 * When true, the retention policy cannot be updated further.
46 */
47 locked?: boolean;
48 /**
49 * Minimum age before a Backup can be deleted, in days (`0`–`90`).
50 */
51 backupDeleteLockDays?: number;
52};
53
54export type TimeOfDay = {
55 /** Hours of day in 24-hour format (`0`–`23`). */
56 hours?: number;
57 /** Minutes of hour (`0`–`59`). */
58 minutes?: number;
59 /** Seconds of minute (`0`–`59`). */
60 seconds?: number;
61 /** Fractional seconds, in nanoseconds. */
62 nanos?: number;
63};
64
65export type CalendarDate = {
66 /** Year (`1`–`9999`), or `0` to omit. */
67 year?: number;
68 /** Month (`1`–`12`), or `0` to omit. */
69 month?: number;
70 /** Day (`1`–`31`), or `0` to omit. */
71 day?: number;
72};
73
74export type DayOfWeekList = {
75 /** Days of week in UTC. */
76 daysOfWeek?: Array<gkebackup.DayOfWeekListDaysOfWeekItemEnum | (string & {})>;
77};
78
79export type ExclusionWindow = {
80 /** Weekly recurrence days in UTC. */
81 daysOfWeek?: DayOfWeekList;
82 /** Start time of the window in UTC. */
83 startTime?: TimeOfDay;
84 /** When true, the window recurs every day. */
85 daily?: boolean;
86 /** Window duration (at least 5 minutes). */
87 duration?: string;
88 /** Single-occurrence date in UTC (no recurrence). */
89 singleOccurrenceDate?: CalendarDate;
90};
91
92export type RpoConfig = {
93 /**
94 * Target RPO in minutes (`60`–`86400`).
95 */
96 targetRpoMinutes?: number;
97 /**
98 * Windows during which backups must not run.
99 */
100 exclusionWindows?: ExclusionWindow[];
101};
102
103export type BackupSchedule = {
104 /**
105 * When true, automatic Backup creation is paused.
106 */
107 paused?: boolean;
108 /**
109 * Cron schedule for creating Backups. Mutually exclusive with
110 * `rpoConfig`.
111 */
112 cronSchedule?: string;
113 /**
114 * RPO schedule. Mutually exclusive with `cronSchedule`.
115 */
116 rpoConfig?: RpoConfig;
117};
118
119export type NamespacedName = {
120 /** Kubernetes namespace. */
121 namespace?: string;
122 /** Kubernetes resource name. */
123 name?: string;
124};
125
126export type EncryptionKey = {
127 /**
128 * Cloud KMS key used to encrypt config backup artifacts.
129 * Format: `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`.
130 */
131 gcpKmsEncryptionKey?: string;
132};
133
134export type ResourceLabels = {
135 /** Kubernetes label-value pairs. */
136 resourceLabels?: Array<{
137 /** Label key. */
138 key?: string;
139 /** Label value. */
140 value?: string;
141 }>;
142};
143
144export type BackupConfig = {
145 /** When true, include all namespaced resources. */
146 allNamespaces?: boolean;
147 /** Include namespaces matching these Kubernetes labels. */
148 selectedNamespaceLabels?: ResourceLabels;
149 /**
150 * Whether Kubernetes Secret resources are included.
151 * @default false
152 */
153 includeSecrets?: boolean;
154 /**
155 * Whether volume data is backed up when PVCs are in scope.
156 * @default false
157 */
158 includeVolumeData?: boolean;
159 /**
160 * When false, backups fail on non-standard Kubernetes configuration.
161 * @default false
162 */
163 permissiveMode?: boolean;
164 /** Include only resources in these namespaces. */
165 selectedNamespaces?: { namespaces?: string[] };
166 /** Include only resources referenced by these ProtectedApplications. */
167 selectedApplications?: { namespacedNames?: NamespacedName[] };
168 /** Customer-managed key that encrypts config backup artifacts. */
169 encryptionKey?: EncryptionKey;
170};
171
172export type BackupPlanProps = {
173 /**
174 * Backup plan id (the `{backupPlan}` segment of
175 * `projects/{project}/locations/{location}/backupPlans/{backupPlan}`).
176 * If omitted, a unique RFC1035 name is generated from the stack, stage,
177 * and logical id. Immutable — changing it replaces the plan.
178 */
179 backupPlanId?: string;
180 /**
181 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
182 * replaces the plan. `US-CENTRAL1` is accepted and normalized to
183 * `us-central1`. Must match the source cluster region.
184 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
185 */
186 location?: string;
187 /**
188 * Source GKE cluster. Full name
189 * `projects/{project}/locations/{location}/clusters/{cluster}` (or the
190 * zonal `zones/{zone}` form) or a cluster id combined with `location`.
191 * Immutable — changing it replaces the plan.
192 */
193 cluster: string;
194 /**
195 * Retention policy for Backups created via this plan.
196 */
197 retentionPolicy?: RetentionPolicy;
198 /**
199 * Schedule for automatic Backup creation.
200 */
201 backupSchedule?: BackupSchedule;
202 /**
203 * Configuration of Backups created via this plan.
204 */
205 backupConfig?: BackupConfig;
206 /**
207 * When true, the plan is locked and no further Backups are created.
208 * @default false
209 */
210 deactivated?: boolean;
211 /**
212 * Human-readable description.
213 */
214 description?: string;
215 /**
216 * User labels. Alchemy ownership labels are merged in automatically.
217 */
218 labels?: Record<string, string>;
219};
220
221export type BackupPlan = Resource<
222 "GCP.GKEBackup.BackupPlan",
223 BackupPlanProps,
224 {
225 /** Full resource name. */
226 name: string;
227 /** Backup plan id (last path segment). */
228 backupPlanId: string;
229 /** Project id. */
230 project: string;
231 /** Location id. */
232 location: string;
233 /** Source cluster name. */
234 cluster: string | undefined;
235 /** Cross-project backup channel, if any. */
236 backupChannel: string | undefined;
237 /** Retention policy. */
238 retentionPolicy: RetentionPolicy | undefined;
239 /** Automatic backup schedule. */
240 backupSchedule: BackupSchedule | undefined;
241 /** Backup configuration. */
242 backupConfig: BackupConfig | undefined;
243 /** Whether the plan is deactivated. */
244 deactivated: boolean;
245 /** Human-readable description. */
246 description: string | undefined;
247 /** User labels (Alchemy ownership labels stripped). */
248 labels: Record<string, string>;
249 /** Server-reported state. */
250 state: string | undefined;
251 /** Human-readable state reason. */
252 stateReason: string | undefined;
253 /** Server-generated resource uid. */
254 uid: string | undefined;
255 /** RFC3339 creation timestamp. */
256 createTime: string | undefined;
257 /** RFC3339 last-update timestamp. */
258 updateTime: string | undefined;
259 /** Last successful Backup completion time. */
260 lastSuccessfulBackupTime: string | undefined;
261 },
262 never,
263 Providers
264>;
265
266/**
267 * A Backup for GKE backup plan — the configuration and schedule for a
268 * line of cluster backups.
269 *
270 * Changing `backupPlanId`, `location`, or `cluster` replaces the plan.
271 * Description, labels, retention, schedule, backup config, and
272 * `deactivated` update in place.
273 *
274 * ### Creating a Backup Plan
275 * **Example:** Generated name
276 * ```typescript
277 * const plan = yield* GCP.GKEBackup.BackupPlan("Nightly", {
278 * cluster: cluster.name,
279 * backupConfig: { allNamespaces: true },
280 * });
281 * ```
282 *
283 * **Example:** Cron schedule and retention
284 * ```typescript
285 * const plan = yield* GCP.GKEBackup.BackupPlan("Nightly", {
286 * cluster: cluster.name,
287 * backupSchedule: { cronSchedule: "0 2 * * *" },
288 * retentionPolicy: { backupRetainDays: 14, backupDeleteLockDays: 1 },
289 * labels: { env: "prod" },
290 * });
291 * ```
292 *
293 * ### Updating a Backup Plan
294 * **Example:** Description and labels
295 * ```typescript
296 * const plan = yield* GCP.GKEBackup.BackupPlan("Nightly", {
297 * cluster: cluster.name,
298 * description: "nightly backups v2",
299 * labels: { env: "prod", team: "platform" },
300 * });
301 * ```
302 *
303 * @resource
304 * @category GKEBackup
305 */
306export const BackupPlan = Resource<BackupPlan>("GCP.GKEBackup.BackupPlan");
307
308const resourceName = (
309 project: string,
310 location: string,
311 backupPlanId: string,
312) => `projects/${project}/locations/${location}/backupPlans/${backupPlanId}`;
313
314const toRetention = (
315 policy: gkebackup.RetentionPolicy | undefined,
316): RetentionPolicy | undefined =>
317 policy === undefined
318 ? undefined
319 : {
320 backupRetainDays: policy.backupRetainDays,
321 locked: policy.locked,
322 backupDeleteLockDays: policy.backupDeleteLockDays,
323 };
324
325const toSchedule = (
326 schedule: gkebackup.Schedule | undefined,
327): BackupSchedule | undefined =>
328 schedule === undefined
329 ? undefined
330 : {
331 paused: schedule.paused,
332 cronSchedule: schedule.cronSchedule,
333 rpoConfig: schedule.rpoConfig,
334 };
335
336const toBackupConfig = (
337 config: gkebackup.BackupConfig | undefined,
338): BackupConfig | undefined =>
339 config === undefined
340 ? undefined
341 : {
342 allNamespaces: config.allNamespaces,
343 selectedNamespaceLabels: config.selectedNamespaceLabels,
344 includeSecrets: config.includeSecrets,
345 includeVolumeData: config.includeVolumeData,
346 permissiveMode: config.permissiveMode,
347 selectedNamespaces: config.selectedNamespaces,
348 selectedApplications: config.selectedApplications,
349 encryptionKey: config.encryptionKey,
350 };
351
352const desiredSchedule = (schedule: BackupSchedule | undefined) =>
353 schedule === undefined
354 ? undefined
355 : {
356 paused: schedule.paused,
357 cronSchedule: schedule.cronSchedule,
358 rpoConfig: schedule.rpoConfig,
359 };
360
361const toAttrs = (
362 item: gkebackup.BackupPlan,
363 project: string,
364 region: string,
365) => {
366 const name = item.name ?? "";
367 const parsed = parseName(name, "backupPlans", region);
368 return {
369 name,
370 backupPlanId: parsed.id,
371 project: parsed.project || project,
372 location: parsed.location,
373 cluster: item.cluster,
374 backupChannel: item.backupChannel,
375 retentionPolicy: toRetention(item.retentionPolicy),
376 backupSchedule: toSchedule(item.backupSchedule),
377 backupConfig: toBackupConfig(item.backupConfig),
378 deactivated: item.deactivated === true,
379 description: item.description,
380 labels: userLabels(item.labels),
381 state: item.state,
382 stateReason: item.stateReason,
383 uid: item.uid,
384 createTime: item.createTime,
385 updateTime: item.updateTime,
386 lastSuccessfulBackupTime: item.lastSuccessfulBackupTime,
387 };
388};
389
390const getByName = (name: string) =>
391 gkebackup
392 .getProjectsLocationsBackupPlans({ name })
393 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
394
395const listOwned = (project: string) =>
396 listAtLocation(project, (parent) =>
397 listLabeledPages(
398 gkebackup.listProjectsLocationsBackupPlans.pages({
399 parent,
400 pageSize: 1000,
401 }),
402 (page) => page.backupPlans,
403 (item) => item.labels,
404 ),
405 );
406
407export const BackupPlanProvider = () =>
408 Provider.succeed(BackupPlan, {
409 stables: [
410 "name",
411 "backupPlanId",
412 "project",
413 "location",
414 "uid",
415 "createTime",
416 ],
417
418 diff: Effect.fn(function* ({ news, olds, output }) {
419 if (!isResolved(news)) return undefined;
420 const env = yield* GcpEnvironment.current;
421 const previousCluster = olds?.cluster ?? output?.cluster;
422 return replaceOnIdentity({
423 previousId: olds?.backupPlanId ?? output?.backupPlanId,
424 nextId: news.backupPlanId ?? olds?.backupPlanId ?? output?.backupPlanId,
425 previousLocation: normalizeLocation(
426 olds?.location ?? output?.location,
427 env.region,
428 ),
429 nextLocation: normalizeLocation(
430 news.location ?? olds?.location ?? output?.location,
431 env.region,
432 ),
433 extra:
434 previousCluster !== undefined &&
435 news.cluster !== undefined &&
436 previousCluster !== news.cluster &&
437 !previousCluster.endsWith(`/${news.cluster}`),
438 });
439 }),
440
441 read: Effect.fn(function* ({ id, olds, output }) {
442 const env = yield* GcpEnvironment.current;
443 const backupPlanId = yield* toPhysicalId(
444 id,
445 olds?.backupPlanId,
446 output?.backupPlanId,
447 "backupplan",
448 );
449 const location = normalizeLocation(
450 olds?.location ?? output?.location,
451 env.region,
452 );
453 const name =
454 output?.name ?? resourceName(env.project, location, backupPlanId);
455 const existing = yield* getByName(name);
456 if (existing === undefined) return undefined;
457 const attrs = toAttrs(existing, env.project, env.region);
458 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
459 ? attrs
460 : Unowned(attrs);
461 }),
462
463 list: () =>
464 Effect.gen(function* () {
465 const env = yield* GcpEnvironment.current;
466 const items = yield* listOwned(env.project);
467 return items.map((item) => toAttrs(item, env.project, env.region));
468 }),
469
470 reconcile: Effect.fn(function* ({ id, news, output }) {
471 const env = yield* GcpEnvironment.current;
472 const backupPlanId = yield* toPhysicalId(
473 id,
474 news.backupPlanId,
475 output?.backupPlanId,
476 "backupplan",
477 );
478 const location = normalizeLocation(
479 news.location ?? output?.location,
480 env.region,
481 );
482 const name = resourceName(env.project, location, backupPlanId);
483 const cluster = clusterName(news.cluster, env.project, location);
484 const desiredLabels = {
485 ...toLabels(news.labels),
486 ...(yield* createInternalLabels(id)),
487 };
488
489 let current = yield* getByName(output?.name ?? name);
490
491 if (current === undefined) {
492 const created = yield* gkebackup
493 .createProjectsLocationsBackupPlans({
494 parent: parentOf(env.project, location),
495 backupPlanId,
496 body: {
497 cluster,
498 retentionPolicy: news.retentionPolicy,
499 backupSchedule: news.backupSchedule,
500 backupConfig: news.backupConfig,
501 deactivated: news.deactivated,
502 description: news.description,
503 labels: desiredLabels,
504 },
505 })
506 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
507 if (created !== undefined) {
508 yield* waitForOperation(created);
509 }
510 current = yield* waitUntilExists(getByName(name), name);
511 }
512
513 if (current === undefined) {
514 return yield* new ResourceNotResolved({ name });
515 }
516
517 current = yield* waitUntilReady(
518 getByName(current.name ?? name),
519 current.name ?? name,
520 (item) => item.state,
521 (item) => item.stateReason,
522 );
523 if (current === undefined) {
524 return yield* new ResourceNotResolved({ name });
525 }
526
527 const observedLabels = tagRecord(current.labels);
528 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
529 const mask = fieldMask([
530 (upsert.length > 0 || removed.length > 0) && "labels",
531 !sameText(current.description, news.description) && "description",
532 fingerprint(toRetention(current.retentionPolicy)) !==
533 fingerprint(news.retentionPolicy) && "retentionPolicy",
534 fingerprint(desiredSchedule(toSchedule(current.backupSchedule))) !==
535 fingerprint(desiredSchedule(news.backupSchedule)) && "backupSchedule",
536 fingerprint(toBackupConfig(current.backupConfig)) !==
537 fingerprint(news.backupConfig) && "backupConfig",
538 (current.deactivated === true) !== (news.deactivated === true) &&
539 "deactivated",
540 ]);
541
542 if (mask.length > 0) {
543 const operation = yield* gkebackup.patchProjectsLocationsBackupPlans({
544 name: current.name ?? name,
545 updateMask: mask,
546 body: {
547 etag: current.etag,
548 labels: desiredLabels,
549 description: news.description,
550 retentionPolicy: news.retentionPolicy,
551 backupSchedule: news.backupSchedule,
552 backupConfig: news.backupConfig,
553 deactivated: news.deactivated,
554 },
555 });
556 yield* waitForOperation(operation);
557 current = yield* waitUntilReady(
558 getByName(current.name ?? name),
559 current.name ?? name,
560 (item) => item.state,
561 (item) => item.stateReason,
562 );
563 if (current === undefined) {
564 return yield* new ResourceNotResolved({ name });
565 }
566 }
567
568 return toAttrs(current, env.project, env.region);
569 }),
570
571 delete: Effect.fn(function* ({ output }) {
572 const operation = yield* gkebackup
573 .deleteProjectsLocationsBackupPlans({ name: output.name })
574 .pipe(
575 Effect.retry({
576 while: (error) => error._tag === "Conflict",
577 times: 8,
578 schedule: Schedule.spaced("2 seconds"),
579 }),
580 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
581 );
582 if (operation !== undefined) {
583 yield* waitForDeleteOperation(operation);
584 }
585 yield* waitUntilGone(getByName(output.name), output.name);
586 }),
587 });