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 expandParent,
19 fieldMask,
20 listAtNested,
21 listLabeledPages,
22 normalizeLocation,
23 parseName,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 sameText,
27 toPhysicalId,
28 userLabels,
29 waitForDeleteOperation,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33} from "./internal.ts";
34import type { GroupKind, RestoreConfig } from "./RestorePlan.ts";
35
36export type ResourceSelector = {
37 /** Select resources of this group kind. */
38 groupKind?: GroupKind;
39 /** Select resources in this namespace. */
40 namespace?: string;
41 /** Select resources with all of these labels. */
42 labels?: Record<string, string>;
43 /** Select resources with this name. */
44 name?: string;
45};
46
47export type RestoreFilter = {
48 /** Restore only resources matching any of these selectors. */
49 inclusionFilters?: ResourceSelector[];
50 /** Exclude resources matching any of these selectors. */
51 exclusionFilters?: ResourceSelector[];
52};
53
54export type VolumeDataRestorePolicyOverride = {
55 /** Policy applied to the selected PVCs. */
56 policy?: gkebackup.VolumeDataRestorePolicyOverridePolicyEnum | (string & {});
57 /** PVCs this override applies to. */
58 selectedPvcs?: {
59 namespacedNames?: Array<{
60 /** Kubernetes namespace. */
61 namespace?: string;
62 /** Kubernetes resource name. */
63 name?: string;
64 }>;
65 };
66};
67
68export type RestorePlansRestoreProps = {
69 /**
70 * Parent RestorePlan. Full name
71 * `projects/{project}/locations/{location}/restorePlans/{restorePlan}`
72 * or the plan id (combined with `location`). Immutable — changing it
73 * replaces the restore.
74 */
75 restorePlan: string;
76 /**
77 * Region used when `restorePlan` is a bare id.
78 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
79 */
80 location?: string;
81 /**
82 * Restore id (the `{restore}` segment). If omitted, a unique RFC1035
83 * name is generated from the stack, stage, and logical id. Immutable —
84 * changing it replaces the restore.
85 */
86 restoreId?: string;
87 /**
88 * Source Backup. Full name
89 * `projects/{project}/locations/{location}/backupPlans/{backupPlan}/backups/{backup}`
90 * or a backup id under the RestorePlan's BackupPlan. Immutable —
91 * changing it replaces the restore.
92 */
93 backup: string;
94 /**
95 * Filter that further refines resource selection. Immutable.
96 */
97 filter?: RestoreFilter;
98 /**
99 * Per-PVC volume restore policy overrides. Immutable.
100 */
101 volumeDataRestorePolicyOverrides?: VolumeDataRestorePolicyOverride[];
102 /**
103 * Human-readable description.
104 */
105 description?: string;
106 /**
107 * User labels. Alchemy ownership labels are merged in automatically.
108 */
109 labels?: Record<string, string>;
110};
111
112export type RestorePlansRestore = Resource<
113 "GCP.GKEBackup.RestorePlansRestore",
114 RestorePlansRestoreProps,
115 {
116 /** Full resource name. */
117 name: string;
118 /** Restore id (last path segment). */
119 restoreId: string;
120 /** Parent RestorePlan name. */
121 restorePlan: string;
122 /** Project id. */
123 project: string;
124 /** Location id. */
125 location: string;
126 /** Source Backup name. */
127 backup: string | undefined;
128 /** Target cluster name. */
129 cluster: string | undefined;
130 /** Restore configuration inherited from the plan. */
131 restoreConfig: RestoreConfig | undefined;
132 /** Resource filter applied to this Restore. */
133 filter: RestoreFilter | undefined;
134 /** Volume policy overrides. */
135 volumeDataRestorePolicyOverrides:
136 | VolumeDataRestorePolicyOverride[]
137 | undefined;
138 /** Human-readable description. */
139 description: string | undefined;
140 /** User labels (Alchemy ownership labels stripped). */
141 labels: Record<string, string>;
142 /** Server-reported state. */
143 state: string | undefined;
144 /** Human-readable state reason. */
145 stateReason: string | undefined;
146 /** Resources restored. */
147 resourcesRestoredCount: number | undefined;
148 /** Resources that failed to restore. */
149 resourcesFailedCount: number | undefined;
150 /** Resources excluded. */
151 resourcesExcludedCount: number | undefined;
152 /** Volumes restored. */
153 volumesRestoredCount: number | undefined;
154 /** Server-generated resource uid. */
155 uid: string | undefined;
156 /** RFC3339 creation timestamp. */
157 createTime: string | undefined;
158 /** RFC3339 completion timestamp. */
159 completeTime: string | undefined;
160 },
161 never,
162 Providers
163>;
164
165/**
166 * A Backup for GKE restore — a request to restore a Backup into the
167 * target cluster of a RestorePlan.
168 *
169 * Changing `restoreId`, `restorePlan`, `location`, `backup`, `filter`,
170 * or `volumeDataRestorePolicyOverrides` replaces the restore.
171 * Description and labels update in place.
172 *
173 * ### Creating a Restore
174 * **Example:** Restore a backup
175 * ```typescript
176 * const restore = yield* GCP.GKEBackup.RestorePlansRestore("Rollback", {
177 * restorePlan: plan.name,
178 * backup: backup.name,
179 * });
180 * ```
181 *
182 * **Example:** Filtered restore
183 * ```typescript
184 * const restore = yield* GCP.GKEBackup.RestorePlansRestore("Rollback", {
185 * restorePlan: plan.name,
186 * backup: backup.name,
187 * filter: {
188 * inclusionFilters: [{ namespace: "app" }],
189 * },
190 * description: "app-only rollback",
191 * labels: { env: "prod" },
192 * });
193 * ```
194 *
195 * ### Updating a Restore
196 * **Example:** Description and labels
197 * ```typescript
198 * const restore = yield* GCP.GKEBackup.RestorePlansRestore("Rollback", {
199 * restorePlan: plan.name,
200 * backup: backup.name,
201 * description: "app-only rollback v2",
202 * labels: { env: "prod", team: "platform" },
203 * });
204 * ```
205 *
206 * @resource
207 * @category GKEBackup
208 */
209export const RestorePlansRestore = Resource<RestorePlansRestore>(
210 "GCP.GKEBackup.RestorePlansRestore",
211);
212
213const resourceName = (plan: string, restoreId: string) =>
214 `${plan}/restores/${restoreId}`;
215
216const toSelector = (item: gkebackup.ResourceSelector): ResourceSelector => ({
217 groupKind: item.groupKind,
218 namespace: item.namespace,
219 labels:
220 item.labels === undefined
221 ? undefined
222 : Object.fromEntries(
223 Object.entries(item.labels).filter(
224 (entry): entry is [string, string] => typeof entry[1] === "string",
225 ),
226 ),
227 name: item.name,
228});
229
230const toFilter = (
231 filter: gkebackup.Filter | undefined,
232): RestoreFilter | undefined =>
233 filter === undefined
234 ? undefined
235 : {
236 inclusionFilters: filter.inclusionFilters?.map(toSelector),
237 exclusionFilters: filter.exclusionFilters?.map(toSelector),
238 };
239
240const toOverrides = (
241 overrides: gkebackup.VolumeDataRestorePolicyOverrideList | undefined,
242): VolumeDataRestorePolicyOverride[] | undefined =>
243 overrides === undefined
244 ? undefined
245 : overrides.map((item) => ({
246 policy: item.policy,
247 selectedPvcs: item.selectedPvcs,
248 }));
249
250const toRestoreConfig = (
251 config: gkebackup.RestoreConfig | undefined,
252): RestoreConfig | undefined =>
253 config === undefined
254 ? undefined
255 : {
256 namespacedResourceRestoreMode: config.namespacedResourceRestoreMode,
257 substitutionRules: config.substitutionRules,
258 excludedNamespaces: config.excludedNamespaces,
259 volumeDataRestorePolicyBindings: config.volumeDataRestorePolicyBindings,
260 allNamespaces: config.allNamespaces,
261 transformationRules: config.transformationRules,
262 restoreOrder: config.restoreOrder,
263 volumeDataRestorePolicy: config.volumeDataRestorePolicy,
264 clusterResourceConflictPolicy: config.clusterResourceConflictPolicy,
265 clusterResourceRestoreScope: config.clusterResourceRestoreScope,
266 selectedNamespaces: config.selectedNamespaces,
267 selectedApplications: config.selectedApplications,
268 noNamespaces: config.noNamespaces,
269 };
270
271const toAttrs = (item: gkebackup.Restore, project: string, region: string) => {
272 const name = item.name ?? "";
273 const parsed = parseName(name, "restores", region);
274 return {
275 name,
276 restoreId: parsed.id,
277 restorePlan: parsed.parent,
278 project: parsed.project || project,
279 location: parsed.location,
280 backup: item.backup,
281 cluster: item.cluster,
282 restoreConfig: toRestoreConfig(item.restoreConfig),
283 filter: toFilter(item.filter),
284 volumeDataRestorePolicyOverrides: toOverrides(
285 item.volumeDataRestorePolicyOverrides,
286 ),
287 description: item.description,
288 labels: userLabels(item.labels),
289 state: item.state,
290 stateReason: item.stateReason,
291 resourcesRestoredCount: item.resourcesRestoredCount,
292 resourcesFailedCount: item.resourcesFailedCount,
293 resourcesExcludedCount: item.resourcesExcludedCount,
294 volumesRestoredCount: item.volumesRestoredCount,
295 uid: item.uid,
296 createTime: item.createTime,
297 completeTime: item.completeTime,
298 };
299};
300
301const getByName = (name: string) =>
302 gkebackup
303 .getProjectsLocationsRestorePlansRestores({ name })
304 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
305
306const listOwned = (project: string) =>
307 listAtNested(project, "restorePlans/-", (parent) =>
308 listLabeledPages(
309 gkebackup.listProjectsLocationsRestorePlansRestores.pages({
310 parent,
311 pageSize: 1000,
312 }),
313 (page) => page.restores,
314 (item) => item.labels,
315 ),
316 );
317
318const expandBackup = (value: string, restorePlan: string) => {
319 if (value.includes("/")) return value.replace(/\/+$/, "");
320 const parts = restorePlan.split("/").filter((part) => part.length > 0);
321 const restorePlansAt = parts.lastIndexOf("restorePlans");
322 const locationParent =
323 restorePlansAt > 0 ? parts.slice(0, restorePlansAt).join("/") : restorePlan;
324 // Callers that pass a bare backup id must also pass a full backup
325 // plan path via restorePlan's backupPlan attribute at create time;
326 // without it we cannot guess the BackupPlan id, so treat the value as
327 // already relative to the restore plan's location.
328 return `${locationParent}/backupPlans/-/backups/${value}`;
329};
330
331export const RestorePlansRestoreProvider = () =>
332 Provider.succeed(RestorePlansRestore, {
333 stables: [
334 "name",
335 "restoreId",
336 "restorePlan",
337 "project",
338 "location",
339 "uid",
340 "createTime",
341 ],
342
343 diff: Effect.fn(function* ({ news, olds, output }) {
344 if (!isResolved(news)) return undefined;
345 const env = yield* GcpEnvironment.current;
346 const previousBackup = olds?.backup ?? output?.backup;
347 return replaceOnIdentity({
348 previousId: olds?.restoreId ?? output?.restoreId,
349 nextId: news.restoreId ?? olds?.restoreId ?? output?.restoreId,
350 previousLocation: normalizeLocation(
351 olds?.location ?? output?.location,
352 env.region,
353 ),
354 nextLocation: normalizeLocation(
355 news.location ?? olds?.location ?? output?.location,
356 env.region,
357 ),
358 previousParent: olds?.restorePlan ?? output?.restorePlan,
359 nextParent: news.restorePlan,
360 extra:
361 previousBackup !== undefined &&
362 news.backup !== undefined &&
363 previousBackup !== news.backup &&
364 !previousBackup.endsWith(`/${news.backup}`),
365 });
366 }),
367
368 read: Effect.fn(function* ({ id, olds, output }) {
369 const env = yield* GcpEnvironment.current;
370 const restoreId = yield* toPhysicalId(
371 id,
372 olds?.restoreId,
373 output?.restoreId,
374 "restore",
375 );
376 const location = normalizeLocation(
377 olds?.location ?? output?.location,
378 env.region,
379 );
380 const plan = expandParent(
381 olds?.restorePlan ?? output?.restorePlan ?? "",
382 env.project,
383 location,
384 "restorePlans",
385 );
386 const name = output?.name ?? resourceName(plan, restoreId);
387 const existing = yield* getByName(name);
388 if (existing === undefined) return undefined;
389 const attrs = toAttrs(existing, env.project, env.region);
390 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
391 ? attrs
392 : Unowned(attrs);
393 }),
394
395 list: () =>
396 Effect.gen(function* () {
397 const env = yield* GcpEnvironment.current;
398 const items = yield* listOwned(env.project);
399 return items.map((item) => toAttrs(item, env.project, env.region));
400 }),
401
402 reconcile: Effect.fn(function* ({ id, news, output }) {
403 const env = yield* GcpEnvironment.current;
404 const restoreId = yield* toPhysicalId(
405 id,
406 news.restoreId,
407 output?.restoreId,
408 "restore",
409 );
410 const location = normalizeLocation(
411 news.location ?? output?.location,
412 env.region,
413 );
414 const plan = expandParent(
415 news.restorePlan,
416 env.project,
417 location,
418 "restorePlans",
419 );
420 const name = resourceName(plan, restoreId);
421 const backup = news.backup.includes("/")
422 ? news.backup.replace(/\/+$/, "")
423 : expandBackup(news.backup, plan);
424 const desiredLabels = {
425 ...toLabels(news.labels),
426 ...(yield* createInternalLabels(id)),
427 };
428
429 let current = yield* getByName(output?.name ?? name);
430
431 if (current === undefined) {
432 const created = yield* gkebackup
433 .createProjectsLocationsRestorePlansRestores({
434 parent: plan,
435 restoreId,
436 body: {
437 backup,
438 filter: news.filter,
439 volumeDataRestorePolicyOverrides:
440 news.volumeDataRestorePolicyOverrides,
441 description: news.description,
442 labels: desiredLabels,
443 },
444 })
445 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
446 if (created !== undefined) {
447 yield* waitForOperation(created);
448 }
449 current = yield* waitUntilExists(getByName(name), name);
450 }
451
452 if (current === undefined) {
453 return yield* new ResourceNotResolved({ name });
454 }
455
456 const observedLabels = tagRecord(current.labels);
457 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
458 const mask = fieldMask([
459 (upsert.length > 0 || removed.length > 0) && "labels",
460 !sameText(current.description, news.description) && "description",
461 ]);
462
463 if (mask.length > 0) {
464 const operation =
465 yield* gkebackup.patchProjectsLocationsRestorePlansRestores({
466 name: current.name ?? name,
467 updateMask: mask,
468 body: {
469 etag: current.etag,
470 labels: desiredLabels,
471 description: news.description,
472 },
473 });
474 yield* waitForOperation(operation);
475 current = yield* waitUntilExists(
476 getByName(current.name ?? name),
477 current.name ?? name,
478 );
479 }
480
481 return toAttrs(current, env.project, env.region);
482 }),
483
484 delete: Effect.fn(function* ({ output }) {
485 const operation = yield* gkebackup
486 .deleteProjectsLocationsRestorePlansRestores({
487 name: output.name,
488 force: true,
489 })
490 .pipe(
491 Effect.retry({
492 while: (error) => error._tag === "Conflict",
493 times: 8,
494 schedule: Schedule.spaced("2 seconds"),
495 }),
496 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
497 );
498 if (operation !== undefined) {
499 yield* waitForDeleteOperation(operation);
500 }
501 yield* waitUntilGone(getByName(output.name), output.name);
502 }),
503 });