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 expandParent,
20 fieldMask,
21 fingerprint,
22 listAtLocation,
23 listLabeledPages,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 replaceOnIdentity,
28 ResourceNotResolved,
29 sameText,
30 toPhysicalId,
31 userLabels,
32 waitForDeleteOperation,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36 waitUntilReady,
37} from "./internal.ts";
38
39export type GroupKind = {
40 /** API group string (empty string for the core group). */
41 resourceGroup?: string;
42 /** Kind in UpperCamelCase singular form. */
43 resourceKind?: string;
44};
45
46export type SubstitutionRule = {
47 /** Regex compared against fields matched by `targetJsonPath`. */
48 originalValuePattern?: string;
49 /** JSONPath that selects fields to substitute. */
50 targetJsonPath?: string;
51 /** Replacement value. Empty string removes the field. */
52 newValue?: string;
53 /** Restrict substitution to these group kinds. */
54 targetGroupKinds?: GroupKind[];
55 /** Restrict substitution to these namespaces. */
56 targetNamespaces?: string[];
57};
58
59export type VolumeDataRestorePolicyBinding = {
60 /** Policy applied to volumes in scope. */
61 policy?: gkebackup.VolumeDataRestorePolicyBindingPolicyEnum | (string & {});
62 /** Volume type this binding applies to. */
63 volumeType?:
64 | gkebackup.VolumeDataRestorePolicyBindingVolumeTypeEnum
65 | (string & {});
66};
67
68export type TransformationRuleAction = {
69 /** JSON Pointer for the operation path. */
70 path?: string;
71 /** JSON Patch operation. */
72 op?: gkebackup.TransformationRuleActionOpEnum | (string & {});
73 /** JSON Pointer for move/copy source. */
74 fromPath?: string;
75 /** Desired value for add/replace/test. */
76 value?: string;
77};
78
79export type ResourceFilter = {
80 /** JSONPath filtering expression. */
81 jsonPath?: string;
82 /** Restrict transformation to these group kinds. */
83 groupKinds?: GroupKind[];
84 /** Restrict transformation to these namespaces. */
85 namespaces?: string[];
86};
87
88export type TransformationRule = {
89 /** JSON Patch actions, executed in order. */
90 fieldActions?: TransformationRuleAction[];
91 /** Human-readable description of the rule. */
92 description?: string;
93 /** Filter selecting which restored resources the rule applies to. */
94 resourceFilter?: ResourceFilter;
95};
96
97export type RestoreOrder = {
98 /** Group-kind dependency pairs that order restore. */
99 groupKindDependencies?: Array<{
100 /** Group kind that must be restored first. */
101 satisfying?: GroupKind;
102 /** Group kind that depends on `satisfying`. */
103 requiring?: GroupKind;
104 }>;
105};
106
107export type ClusterResourceRestoreScope = {
108 /** Restore only these cluster-scoped group kinds. */
109 selectedGroupKinds?: GroupKind[];
110 /** When true, restore no cluster-scoped resources. */
111 noGroupKinds?: boolean;
112 /** When true, restore all valid cluster-scoped resources. */
113 allGroupKinds?: boolean;
114 /** Restore all valid cluster-scoped resources except these. */
115 excludedGroupKinds?: GroupKind[];
116};
117
118export type RestoreConfig = {
119 /**
120 * How to handle namespaced resources that already exist in the target
121 * cluster.
122 */
123 namespacedResourceRestoreMode?:
124 | gkebackup.RestoreConfigNamespacedResourceRestoreModeEnum
125 | (string & {});
126 /** Substitution rules applied during restore. */
127 substitutionRules?: SubstitutionRule[];
128 /** Namespaces excluded from restoration. */
129 excludedNamespaces?: { namespaces?: string[] };
130 /** Per-volume-type restore policy bindings. */
131 volumeDataRestorePolicyBindings?: VolumeDataRestorePolicyBinding[];
132 /** When true, restore all namespaced resources in the Backup. */
133 allNamespaces?: boolean;
134 /** Transformation rules applied during restore. */
135 transformationRules?: TransformationRule[];
136 /** Custom restore ordering. */
137 restoreOrder?: RestoreOrder;
138 /**
139 * How volume data is restored.
140 * @default "NO_VOLUME_DATA_RESTORATION"
141 */
142 volumeDataRestorePolicy?:
143 | gkebackup.RestoreConfigVolumeDataRestorePolicyEnum
144 | (string & {});
145 /**
146 * How to handle cluster-scoped resources that already exist.
147 */
148 clusterResourceConflictPolicy?:
149 | gkebackup.RestoreConfigClusterResourceConflictPolicyEnum
150 | (string & {});
151 /** Cluster-scoped resources to restore. */
152 clusterResourceRestoreScope?: ClusterResourceRestoreScope;
153 /** Restore only these namespaces and their resources. */
154 selectedNamespaces?: { namespaces?: string[] };
155 /** Restore only these ProtectedApplications and their resources. */
156 selectedApplications?: {
157 namespacedNames?: Array<{
158 /** Kubernetes namespace. */
159 namespace?: string;
160 /** Kubernetes resource name. */
161 name?: string;
162 }>;
163 };
164 /** When true, restore no namespaced resources. */
165 noNamespaces?: boolean;
166};
167
168export type RestorePlanProps = {
169 /**
170 * Restore plan id (the `{restorePlan}` segment of
171 * `projects/{project}/locations/{location}/restorePlans/{restorePlan}`).
172 * If omitted, a unique RFC1035 name is generated from the stack, stage,
173 * and logical id. Immutable — changing it replaces the plan.
174 */
175 restorePlanId?: string;
176 /**
177 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
178 * replaces the plan. Must match the target cluster region.
179 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
180 */
181 location?: string;
182 /**
183 * Target GKE cluster. Full name or a cluster id combined with
184 * `location`. Immutable — changing it replaces the plan.
185 */
186 cluster: string;
187 /**
188 * Source BackupPlan. Full name
189 * `projects/{project}/locations/{location}/backupPlans/{backupPlan}`
190 * or a plan id combined with `location`. Immutable — changing it
191 * replaces the plan.
192 */
193 backupPlan: string;
194 /**
195 * Configuration of Restores created via this plan.
196 */
197 restoreConfig: RestoreConfig;
198 /**
199 * Human-readable description.
200 */
201 description?: string;
202 /**
203 * User labels. Alchemy ownership labels are merged in automatically.
204 */
205 labels?: Record<string, string>;
206};
207
208export type RestorePlan = Resource<
209 "GCP.GKEBackup.RestorePlan",
210 RestorePlanProps,
211 {
212 /** Full resource name. */
213 name: string;
214 /** Restore plan id (last path segment). */
215 restorePlanId: string;
216 /** Project id. */
217 project: string;
218 /** Location id. */
219 location: string;
220 /** Target cluster name. */
221 cluster: string | undefined;
222 /** Source BackupPlan name. */
223 backupPlan: string | undefined;
224 /** Cross-project restore channel, if any. */
225 restoreChannel: string | undefined;
226 /** Restore configuration. */
227 restoreConfig: RestoreConfig | undefined;
228 /** Human-readable description. */
229 description: string | undefined;
230 /** User labels (Alchemy ownership labels stripped). */
231 labels: Record<string, string>;
232 /** Server-reported state. */
233 state: string | undefined;
234 /** Human-readable state reason. */
235 stateReason: string | undefined;
236 /** Server-generated resource uid. */
237 uid: string | undefined;
238 /** RFC3339 creation timestamp. */
239 createTime: string | undefined;
240 /** RFC3339 last-update timestamp. */
241 updateTime: string | undefined;
242 },
243 never,
244 Providers
245>;
246
247/**
248 * A Backup for GKE restore plan — the configuration for restoring
249 * Backups from a BackupPlan into a target cluster.
250 *
251 * Changing `restorePlanId`, `location`, `cluster`, or `backupPlan`
252 * replaces the plan. Description, labels, and `restoreConfig` update in
253 * place.
254 *
255 * ### Creating a Restore Plan
256 * **Example:** Restore all namespaces
257 * ```typescript
258 * const plan = yield* GCP.GKEBackup.RestorePlan("Rollback", {
259 * cluster: cluster.name,
260 * backupPlan: backupPlan.name,
261 * restoreConfig: {
262 * allNamespaces: true,
263 * namespacedResourceRestoreMode: "FAIL_ON_CONFLICT",
264 * volumeDataRestorePolicy: "NO_VOLUME_DATA_RESTORATION",
265 * },
266 * });
267 * ```
268 *
269 * **Example:** Selected namespaces
270 * ```typescript
271 * const plan = yield* GCP.GKEBackup.RestorePlan("Rollback", {
272 * cluster: cluster.name,
273 * backupPlan: backupPlan.name,
274 * restoreConfig: {
275 * selectedNamespaces: { namespaces: ["app"] },
276 * namespacedResourceRestoreMode: "MERGE_SKIP_ON_CONFLICT",
277 * },
278 * labels: { env: "prod" },
279 * });
280 * ```
281 *
282 * ### Updating a Restore Plan
283 * **Example:** Description and labels
284 * ```typescript
285 * const plan = yield* GCP.GKEBackup.RestorePlan("Rollback", {
286 * cluster: cluster.name,
287 * backupPlan: backupPlan.name,
288 * restoreConfig: { allNamespaces: true },
289 * description: "rollback v2",
290 * labels: { env: "prod", team: "platform" },
291 * });
292 * ```
293 *
294 * @resource
295 * @category GKEBackup
296 */
297export const RestorePlan = Resource<RestorePlan>("GCP.GKEBackup.RestorePlan");
298
299const resourceName = (
300 project: string,
301 location: string,
302 restorePlanId: string,
303) => `projects/${project}/locations/${location}/restorePlans/${restorePlanId}`;
304
305const toRestoreConfig = (
306 config: gkebackup.RestoreConfig | undefined,
307): RestoreConfig | undefined =>
308 config === undefined
309 ? undefined
310 : {
311 namespacedResourceRestoreMode: config.namespacedResourceRestoreMode,
312 substitutionRules: config.substitutionRules,
313 excludedNamespaces: config.excludedNamespaces,
314 volumeDataRestorePolicyBindings: config.volumeDataRestorePolicyBindings,
315 allNamespaces: config.allNamespaces,
316 transformationRules: config.transformationRules,
317 restoreOrder: config.restoreOrder,
318 volumeDataRestorePolicy: config.volumeDataRestorePolicy,
319 clusterResourceConflictPolicy: config.clusterResourceConflictPolicy,
320 clusterResourceRestoreScope: config.clusterResourceRestoreScope,
321 selectedNamespaces: config.selectedNamespaces,
322 selectedApplications: config.selectedApplications,
323 noNamespaces: config.noNamespaces,
324 };
325
326const toAttrs = (
327 item: gkebackup.RestorePlan,
328 project: string,
329 region: string,
330) => {
331 const name = item.name ?? "";
332 const parsed = parseName(name, "restorePlans", region);
333 return {
334 name,
335 restorePlanId: parsed.id,
336 project: parsed.project || project,
337 location: parsed.location,
338 cluster: item.cluster,
339 backupPlan: item.backupPlan,
340 restoreChannel: item.restoreChannel,
341 restoreConfig: toRestoreConfig(item.restoreConfig),
342 description: item.description,
343 labels: userLabels(item.labels),
344 state: item.state,
345 stateReason: item.stateReason,
346 uid: item.uid,
347 createTime: item.createTime,
348 updateTime: item.updateTime,
349 };
350};
351
352const getByName = (name: string) =>
353 gkebackup
354 .getProjectsLocationsRestorePlans({ name })
355 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
356
357const listOwned = (project: string) =>
358 listAtLocation(project, (parent) =>
359 listLabeledPages(
360 gkebackup.listProjectsLocationsRestorePlans.pages({
361 parent,
362 pageSize: 1000,
363 }),
364 (page) => page.restorePlans,
365 (item) => item.labels,
366 ),
367 );
368
369export const RestorePlanProvider = () =>
370 Provider.succeed(RestorePlan, {
371 stables: [
372 "name",
373 "restorePlanId",
374 "project",
375 "location",
376 "uid",
377 "createTime",
378 ],
379
380 diff: Effect.fn(function* ({ news, olds, output }) {
381 if (!isResolved(news)) return undefined;
382 const env = yield* GcpEnvironment.current;
383 const previousCluster = olds?.cluster ?? output?.cluster;
384 const previousPlan = olds?.backupPlan ?? output?.backupPlan;
385 return replaceOnIdentity({
386 previousId: olds?.restorePlanId ?? output?.restorePlanId,
387 nextId:
388 news.restorePlanId ?? olds?.restorePlanId ?? output?.restorePlanId,
389 previousLocation: normalizeLocation(
390 olds?.location ?? output?.location,
391 env.region,
392 ),
393 nextLocation: normalizeLocation(
394 news.location ?? olds?.location ?? output?.location,
395 env.region,
396 ),
397 extra:
398 (previousCluster !== undefined &&
399 news.cluster !== undefined &&
400 previousCluster !== news.cluster &&
401 !previousCluster.endsWith(`/${news.cluster}`)) ||
402 (previousPlan !== undefined &&
403 news.backupPlan !== undefined &&
404 previousPlan !== news.backupPlan &&
405 !previousPlan.endsWith(`/${news.backupPlan}`)),
406 });
407 }),
408
409 read: Effect.fn(function* ({ id, olds, output }) {
410 const env = yield* GcpEnvironment.current;
411 const restorePlanId = yield* toPhysicalId(
412 id,
413 olds?.restorePlanId,
414 output?.restorePlanId,
415 "restoreplan",
416 );
417 const location = normalizeLocation(
418 olds?.location ?? output?.location,
419 env.region,
420 );
421 const name =
422 output?.name ?? resourceName(env.project, location, restorePlanId);
423 const existing = yield* getByName(name);
424 if (existing === undefined) return undefined;
425 const attrs = toAttrs(existing, env.project, env.region);
426 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
427 ? attrs
428 : Unowned(attrs);
429 }),
430
431 list: () =>
432 Effect.gen(function* () {
433 const env = yield* GcpEnvironment.current;
434 const items = yield* listOwned(env.project);
435 return items.map((item) => toAttrs(item, env.project, env.region));
436 }),
437
438 reconcile: Effect.fn(function* ({ id, news, output }) {
439 const env = yield* GcpEnvironment.current;
440 const restorePlanId = yield* toPhysicalId(
441 id,
442 news.restorePlanId,
443 output?.restorePlanId,
444 "restoreplan",
445 );
446 const location = normalizeLocation(
447 news.location ?? output?.location,
448 env.region,
449 );
450 const name = resourceName(env.project, location, restorePlanId);
451 const cluster = clusterName(news.cluster, env.project, location);
452 const backupPlan = expandParent(
453 news.backupPlan,
454 env.project,
455 location,
456 "backupPlans",
457 );
458 const desiredLabels = {
459 ...toLabels(news.labels),
460 ...(yield* createInternalLabels(id)),
461 };
462
463 let current = yield* getByName(output?.name ?? name);
464
465 if (current === undefined) {
466 const created = yield* gkebackup
467 .createProjectsLocationsRestorePlans({
468 parent: parentOf(env.project, location),
469 restorePlanId,
470 body: {
471 cluster,
472 backupPlan,
473 restoreConfig: news.restoreConfig,
474 description: news.description,
475 labels: desiredLabels,
476 },
477 })
478 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
479 if (created !== undefined) {
480 yield* waitForOperation(created);
481 }
482 current = yield* waitUntilExists(getByName(name), name);
483 }
484
485 if (current === undefined) {
486 return yield* new ResourceNotResolved({ name });
487 }
488
489 current = yield* waitUntilReady(
490 getByName(current.name ?? name),
491 current.name ?? name,
492 (item) => item.state,
493 (item) => item.stateReason,
494 );
495 if (current === undefined) {
496 return yield* new ResourceNotResolved({ name });
497 }
498
499 const observedLabels = tagRecord(current.labels);
500 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
501 const mask = fieldMask([
502 (upsert.length > 0 || removed.length > 0) && "labels",
503 !sameText(current.description, news.description) && "description",
504 fingerprint(toRestoreConfig(current.restoreConfig)) !==
505 fingerprint(news.restoreConfig) && "restoreConfig",
506 ]);
507
508 if (mask.length > 0) {
509 const operation = yield* gkebackup.patchProjectsLocationsRestorePlans({
510 name: current.name ?? name,
511 updateMask: mask,
512 body: {
513 etag: current.etag,
514 labels: desiredLabels,
515 description: news.description,
516 restoreConfig: news.restoreConfig,
517 },
518 });
519 yield* waitForOperation(operation);
520 current = yield* waitUntilReady(
521 getByName(current.name ?? name),
522 current.name ?? name,
523 (item) => item.state,
524 (item) => item.stateReason,
525 );
526 if (current === undefined) {
527 return yield* new ResourceNotResolved({ name });
528 }
529 }
530
531 return toAttrs(current, env.project, env.region);
532 }),
533
534 delete: Effect.fn(function* ({ output }) {
535 const operation = yield* gkebackup
536 .deleteProjectsLocationsRestorePlans({
537 name: output.name,
538 force: true,
539 })
540 .pipe(
541 Effect.retry({
542 while: (error) => error._tag === "Conflict",
543 times: 8,
544 schedule: Schedule.spaced("2 seconds"),
545 }),
546 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
547 );
548 if (operation !== undefined) {
549 yield* waitForDeleteOperation(operation);
550 }
551 yield* waitUntilGone(getByName(output.name), output.name);
552 }),
553 });