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";
34
35export type BackupPlansBackupProps = {
36 /**
37 * Parent BackupPlan. Full name
38 * `projects/{project}/locations/{location}/backupPlans/{backupPlan}`
39 * or the plan id (combined with `location`). Immutable — changing it
40 * replaces the backup.
41 */
42 backupPlan: string;
43 /**
44 * Region used when `backupPlan` is a bare id.
45 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
46 */
47 location?: string;
48 /**
49 * Backup id (the `{backup}` segment). If omitted, a unique RFC1035 name
50 * is generated from the stack, stage, and logical id. Immutable —
51 * changing it replaces the backup.
52 */
53 backupId?: string;
54 /**
55 * Minimum age in days before this Backup can be deleted (`0`–`90`).
56 * May only be increased after create. Defaults to the parent plan's
57 * `backupDeleteLockDays`.
58 */
59 deleteLockDays?: number;
60 /**
61 * Age in days after which this Backup is automatically deleted. `0`
62 * disables automatic deletion. May only be increased after create.
63 * Defaults to the parent plan's `backupRetainDays`.
64 */
65 retainDays?: number;
66 /**
67 * Human-readable description.
68 */
69 description?: string;
70 /**
71 * User labels. Alchemy ownership labels are merged in automatically.
72 */
73 labels?: Record<string, string>;
74};
75
76export type BackupPlansBackup = Resource<
77 "GCP.GKEBackup.BackupPlansBackup",
78 BackupPlansBackupProps,
79 {
80 /** Full resource name. */
81 name: string;
82 /** Backup id (last path segment). */
83 backupId: string;
84 /** Parent BackupPlan name. */
85 backupPlan: string;
86 /** Project id. */
87 project: string;
88 /** Location id. */
89 location: string;
90 /** Whether all namespaces were included. */
91 allNamespaces: boolean | undefined;
92 /** Delete-lock duration in days. */
93 deleteLockDays: number | undefined;
94 /** Automatic-deletion age in days. */
95 retainDays: number | undefined;
96 /** Whether this Backup was created manually. */
97 manual: boolean | undefined;
98 /** Whether the Backup contains volume data. */
99 containsVolumeData: boolean | undefined;
100 /** Whether the Backup contains Kubernetes Secrets. */
101 containsSecrets: boolean | undefined;
102 /** Size of the Backup in bytes. */
103 sizeBytes: string | undefined;
104 /** Human-readable description. */
105 description: string | undefined;
106 /** User labels (Alchemy ownership labels stripped). */
107 labels: Record<string, string>;
108 /** Server-reported state. */
109 state: string | undefined;
110 /** Human-readable state reason. */
111 stateReason: string | undefined;
112 /** Server-generated resource uid. */
113 uid: string | undefined;
114 /** RFC3339 creation timestamp. */
115 createTime: string | undefined;
116 /** RFC3339 completion timestamp. */
117 completeTime: string | undefined;
118 },
119 never,
120 Providers
121>;
122
123/**
124 * A Backup for GKE backup — a point-in-time capture of a cluster under
125 * a BackupPlan.
126 *
127 * Changing `backupId`, `backupPlan`, or `location` replaces the backup.
128 * Description, labels, `deleteLockDays`, and `retainDays` update in
129 * place (`deleteLockDays` and `retainDays` may only increase).
130 *
131 * ### Creating a Backup
132 * **Example:** Manual backup
133 * ```typescript
134 * const backup = yield* GCP.GKEBackup.BackupPlansBackup("Nightly", {
135 * backupPlan: plan.name,
136 * });
137 * ```
138 *
139 * **Example:** Retention overrides
140 * ```typescript
141 * const backup = yield* GCP.GKEBackup.BackupPlansBackup("Nightly", {
142 * backupPlan: plan.name,
143 * deleteLockDays: 7,
144 * retainDays: 30,
145 * description: "pre-release",
146 * labels: { env: "prod" },
147 * });
148 * ```
149 *
150 * ### Updating a Backup
151 * **Example:** Description and labels
152 * ```typescript
153 * const backup = yield* GCP.GKEBackup.BackupPlansBackup("Nightly", {
154 * backupPlan: plan.name,
155 * description: "pre-release v2",
156 * labels: { env: "prod", team: "platform" },
157 * });
158 * ```
159 *
160 * @resource
161 * @category GKEBackup
162 */
163export const BackupPlansBackup = Resource<BackupPlansBackup>(
164 "GCP.GKEBackup.BackupPlansBackup",
165);
166
167const resourceName = (plan: string, backupId: string) =>
168 `${plan}/backups/${backupId}`;
169
170const toAttrs = (item: gkebackup.Backup, project: string, region: string) => {
171 const name = item.name ?? "";
172 const parsed = parseName(name, "backups", region);
173 return {
174 name,
175 backupId: parsed.id,
176 backupPlan: parsed.parent,
177 project: parsed.project || project,
178 location: parsed.location,
179 allNamespaces: item.allNamespaces,
180 deleteLockDays: item.deleteLockDays,
181 retainDays: item.retainDays,
182 manual: item.manual,
183 containsVolumeData: item.containsVolumeData,
184 containsSecrets: item.containsSecrets,
185 sizeBytes: item.sizeBytes,
186 description: item.description,
187 labels: userLabels(item.labels),
188 state: item.state,
189 stateReason: item.stateReason,
190 uid: item.uid,
191 createTime: item.createTime,
192 completeTime: item.completeTime,
193 };
194};
195
196const getByName = (name: string) =>
197 gkebackup
198 .getProjectsLocationsBackupPlansBackups({ name })
199 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
200
201const listOwned = (project: string) =>
202 listAtNested(project, "backupPlans/-", (parent) =>
203 listLabeledPages(
204 gkebackup.listProjectsLocationsBackupPlansBackups.pages({
205 parent,
206 pageSize: 1000,
207 }),
208 (page) => page.backups,
209 (item) => item.labels,
210 ),
211 );
212
213export const BackupPlansBackupProvider = () =>
214 Provider.succeed(BackupPlansBackup, {
215 stables: [
216 "name",
217 "backupId",
218 "backupPlan",
219 "project",
220 "location",
221 "uid",
222 "createTime",
223 ],
224
225 diff: Effect.fn(function* ({ news, olds, output }) {
226 if (!isResolved(news)) return undefined;
227 const env = yield* GcpEnvironment.current;
228 return replaceOnIdentity({
229 previousId: olds?.backupId ?? output?.backupId,
230 nextId: news.backupId ?? olds?.backupId ?? output?.backupId,
231 previousLocation: normalizeLocation(
232 olds?.location ?? output?.location,
233 env.region,
234 ),
235 nextLocation: normalizeLocation(
236 news.location ?? olds?.location ?? output?.location,
237 env.region,
238 ),
239 previousParent: olds?.backupPlan ?? output?.backupPlan,
240 nextParent: news.backupPlan,
241 });
242 }),
243
244 read: Effect.fn(function* ({ id, olds, output }) {
245 const env = yield* GcpEnvironment.current;
246 const backupId = yield* toPhysicalId(
247 id,
248 olds?.backupId,
249 output?.backupId,
250 "backup",
251 );
252 const location = normalizeLocation(
253 olds?.location ?? output?.location,
254 env.region,
255 );
256 const plan = expandParent(
257 olds?.backupPlan ?? output?.backupPlan ?? "",
258 env.project,
259 location,
260 "backupPlans",
261 );
262 const name = output?.name ?? resourceName(plan, backupId);
263 const existing = yield* getByName(name);
264 if (existing === undefined) return undefined;
265 const attrs = toAttrs(existing, env.project, env.region);
266 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
267 ? attrs
268 : Unowned(attrs);
269 }),
270
271 list: () =>
272 Effect.gen(function* () {
273 const env = yield* GcpEnvironment.current;
274 const items = yield* listOwned(env.project);
275 return items.map((item) => toAttrs(item, env.project, env.region));
276 }),
277
278 reconcile: Effect.fn(function* ({ id, news, output }) {
279 const env = yield* GcpEnvironment.current;
280 const backupId = yield* toPhysicalId(
281 id,
282 news.backupId,
283 output?.backupId,
284 "backup",
285 );
286 const location = normalizeLocation(
287 news.location ?? output?.location,
288 env.region,
289 );
290 const plan = expandParent(
291 news.backupPlan,
292 env.project,
293 location,
294 "backupPlans",
295 );
296 const name = resourceName(plan, backupId);
297 const desiredLabels = {
298 ...toLabels(news.labels),
299 ...(yield* createInternalLabels(id)),
300 };
301
302 let current = yield* getByName(output?.name ?? name);
303
304 if (current === undefined) {
305 const created = yield* gkebackup
306 .createProjectsLocationsBackupPlansBackups({
307 parent: plan,
308 backupId,
309 body: {
310 deleteLockDays: news.deleteLockDays,
311 retainDays: news.retainDays,
312 description: news.description,
313 labels: desiredLabels,
314 },
315 })
316 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
317 if (created !== undefined) {
318 yield* waitForOperation(created);
319 }
320 current = yield* waitUntilExists(getByName(name), name);
321 }
322
323 if (current === undefined) {
324 return yield* new ResourceNotResolved({ name });
325 }
326
327 const observedLabels = tagRecord(current.labels);
328 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
329 const mask = fieldMask([
330 (upsert.length > 0 || removed.length > 0) && "labels",
331 !sameText(current.description, news.description) && "description",
332 news.deleteLockDays !== undefined &&
333 news.deleteLockDays !== current.deleteLockDays &&
334 "deleteLockDays",
335 news.retainDays !== undefined &&
336 news.retainDays !== current.retainDays &&
337 "retainDays",
338 ]);
339
340 if (mask.length > 0) {
341 const operation =
342 yield* gkebackup.patchProjectsLocationsBackupPlansBackups({
343 name: current.name ?? name,
344 updateMask: mask,
345 body: {
346 etag: current.etag,
347 labels: desiredLabels,
348 description: news.description,
349 deleteLockDays: news.deleteLockDays,
350 retainDays: news.retainDays,
351 },
352 });
353 yield* waitForOperation(operation);
354 current = yield* waitUntilExists(
355 getByName(current.name ?? name),
356 current.name ?? name,
357 );
358 }
359
360 return toAttrs(current, env.project, env.region);
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 const operation = yield* gkebackup
365 .deleteProjectsLocationsBackupPlansBackups({
366 name: output.name,
367 force: true,
368 })
369 .pipe(
370 Effect.retry({
371 while: (error) => error._tag === "Conflict",
372 times: 8,
373 schedule: Schedule.spaced("2 seconds"),
374 }),
375 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
376 );
377 if (operation !== undefined) {
378 yield* waitForDeleteOperation(operation);
379 }
380 yield* waitUntilGone(getByName(output.name), output.name);
381 }),
382 });