1import * as netapp from "@distilled.cloud/gcp/netapp_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 fingerprint,
21 listAtNested,
22 listLabeledPages,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 toPhysicalId,
28 userLabels,
29 waitForDeleteOperation,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33 waitUntilReady,
34} from "./internal.ts";
35
36export type OntapSource = {
37 /** Storage pool that hosts the ONTAP volume. */
38 storagePool?: string;
39 /** ONTAP source volume UUID. */
40 volumeUuid?: string;
41 /** ONTAP source snapshot UUID. */
42 snapshotUuid?: string;
43};
44
45export type BackupVaultsBackupProps = {
46 /**
47 * Parent backup vault. Full name
48 * `projects/{project}/locations/{location}/backupVaults/{backupVault}`
49 * or the vault id (combined with `location`). Immutable — changing it
50 * replaces the backup.
51 */
52 backupVault: string;
53 /**
54 * Region used when `backupVault` is a bare id.
55 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
56 */
57 location?: string;
58 /**
59 * Backup id (the `{backup}` segment). If omitted, a unique RFC1035 name
60 * is generated from the stack, stage, and logical id. Immutable —
61 * changing it replaces the backup.
62 */
63 backupId?: string;
64 /**
65 * Source volume name or id. Immutable — changing it replaces the
66 * backup. Either `sourceVolume` or `ontapSource` is required.
67 */
68 sourceVolume?: string;
69 /**
70 * Snapshot to back up. If omitted, a new snapshot is taken. Immutable.
71 */
72 sourceSnapshot?: string;
73 /**
74 * ONTAP source details. Immutable — changing it replaces the backup.
75 */
76 ontapSource?: OntapSource;
77 /**
78 * Human-readable description. Max 2048 characters.
79 */
80 description?: string;
81 /**
82 * User labels. Alchemy ownership labels are merged in automatically.
83 */
84 labels?: Record<string, string>;
85};
86
87export type BackupVaultsBackup = Resource<
88 "GCP.NetApp.BackupVaultsBackup",
89 BackupVaultsBackupProps,
90 {
91 /** Full resource name. */
92 name: string;
93 /** Backup id (last path segment). */
94 backupId: string;
95 /** Parent backup vault name. */
96 backupVault: string;
97 /** Project id. */
98 project: string;
99 /** Location id. */
100 location: string;
101 /** Source volume name. */
102 sourceVolume: string | undefined;
103 /** Source snapshot name. */
104 sourceSnapshot: string | undefined;
105 /** ONTAP source details. */
106 ontapSource: OntapSource | undefined;
107 /** Backup type (`MANUAL` or `SCHEDULED`). */
108 backupType: string | undefined;
109 /** Region that stores the backup. */
110 backupRegion: string | undefined;
111 /** Region of the source volume. */
112 volumeRegion: string | undefined;
113 /** File-system size at backup time, in bytes. */
114 volumeUsageBytes: string | undefined;
115 /** Chain storage in bytes. */
116 chainStorageBytes: string | undefined;
117 /** Time until which the backup cannot be deleted. */
118 enforcedRetentionEndTime: string | undefined;
119 /** Human-readable description. */
120 description: string | undefined;
121 /** User labels (Alchemy ownership labels stripped). */
122 labels: Record<string, string>;
123 /** Server-reported state. */
124 state: string | undefined;
125 /** RFC3339 creation timestamp. */
126 createTime: string | undefined;
127 },
128 never,
129 Providers
130>;
131
132/**
133 * A Cloud NetApp Volumes backup stored in a backup vault.
134 *
135 * Changing `backupId`, `backupVault`, `location`, `sourceVolume`,
136 * `sourceSnapshot`, or `ontapSource` replaces the backup. Description and
137 * labels update in place.
138 *
139 * ### Creating a Backup
140 * **Example:** From a volume
141 * ```typescript
142 * const backup = yield* GCP.NetApp.BackupVaultsBackup("Nightly", {
143 * backupVault: vault.name,
144 * sourceVolume: volume.name,
145 * });
146 * ```
147 *
148 * **Example:** From a snapshot
149 * ```typescript
150 * const backup = yield* GCP.NetApp.BackupVaultsBackup("Nightly", {
151 * backupVault: vault.name,
152 * sourceVolume: volume.name,
153 * sourceSnapshot: snapshot.name,
154 * description: "pre-release",
155 * labels: { env: "prod" },
156 * });
157 * ```
158 *
159 * ### Updating a Backup
160 * **Example:** Description and labels
161 * ```typescript
162 * const backup = yield* GCP.NetApp.BackupVaultsBackup("Nightly", {
163 * backupVault: vault.name,
164 * sourceVolume: volume.name,
165 * description: "pre-release v2",
166 * labels: { env: "prod", team: "storage" },
167 * });
168 * ```
169 *
170 * @resource
171 * @category NetApp
172 */
173export const BackupVaultsBackup = Resource<BackupVaultsBackup>(
174 "GCP.NetApp.BackupVaultsBackup",
175);
176
177const resourceName = (vault: string, backupId: string) =>
178 `${vault}/backups/${backupId}`;
179
180const toOntap = (
181 source: netapp.OntapSource | undefined,
182): OntapSource | undefined =>
183 source === undefined
184 ? undefined
185 : {
186 storagePool: source.storagePool,
187 volumeUuid: source.volumeUuid,
188 snapshotUuid: source.snapshotUuid,
189 };
190
191const toAttrs = (item: netapp.Backup, project: string, region: string) => {
192 const name = item.name ?? "";
193 const parsed = parseName(name, "backups", region);
194 return {
195 name,
196 backupId: parsed.id,
197 backupVault: parsed.parent,
198 project: parsed.project || project,
199 location: parsed.location,
200 sourceVolume: item.sourceVolume,
201 sourceSnapshot: item.sourceSnapshot,
202 ontapSource: toOntap(item.ontapSource),
203 backupType: item.backupType,
204 backupRegion: item.backupRegion,
205 volumeRegion: item.volumeRegion,
206 volumeUsageBytes: item.volumeUsageBytes,
207 chainStorageBytes: item.chainStorageBytes,
208 enforcedRetentionEndTime: item.enforcedRetentionEndTime,
209 description: item.description,
210 labels: userLabels(item.labels),
211 state: item.state,
212 createTime: item.createTime,
213 };
214};
215
216const getByName = (name: string) =>
217 netapp
218 .getProjectsLocationsBackupVaultsBackups({ name })
219 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
220
221const listOwned = (project: string) =>
222 listAtNested(project, "backupVaults/-", (parent) =>
223 listLabeledPages(
224 netapp.listProjectsLocationsBackupVaultsBackups.pages({
225 parent,
226 pageSize: 1000,
227 }),
228 (page) => page.backups,
229 (item) => item.labels,
230 ),
231 );
232
233export const BackupVaultsBackupProvider = () =>
234 Provider.succeed(BackupVaultsBackup, {
235 stables: [
236 "name",
237 "backupId",
238 "backupVault",
239 "project",
240 "location",
241 "createTime",
242 ],
243
244 diff: Effect.fn(function* ({ news, olds, output }) {
245 if (!isResolved(news)) return undefined;
246 const env = yield* GcpEnvironment.current;
247 const previousVolume = olds?.sourceVolume ?? output?.sourceVolume;
248 const previousSnapshot = olds?.sourceSnapshot ?? output?.sourceSnapshot;
249 const previousOntap = olds?.ontapSource ?? output?.ontapSource;
250 return replaceOnIdentity({
251 previousId: olds?.backupId ?? output?.backupId,
252 nextId: news.backupId ?? olds?.backupId ?? output?.backupId,
253 previousLocation: normalizeLocation(
254 olds?.location ?? output?.location,
255 env.region,
256 ),
257 nextLocation: normalizeLocation(
258 news.location ?? olds?.location ?? output?.location,
259 env.region,
260 ),
261 previousParent: olds?.backupVault ?? output?.backupVault,
262 nextParent: news.backupVault,
263 extra:
264 (previousVolume !== undefined &&
265 news.sourceVolume !== undefined &&
266 news.sourceVolume !== previousVolume &&
267 !previousVolume.endsWith(`/${news.sourceVolume}`)) ||
268 (previousSnapshot !== undefined &&
269 news.sourceSnapshot !== undefined &&
270 news.sourceSnapshot !== previousSnapshot) ||
271 (previousOntap !== undefined &&
272 news.ontapSource !== undefined &&
273 fingerprint(previousOntap) !== fingerprint(news.ontapSource)),
274 });
275 }),
276
277 read: Effect.fn(function* ({ id, olds, output }) {
278 const env = yield* GcpEnvironment.current;
279 const backupId = yield* toPhysicalId(
280 id,
281 olds?.backupId,
282 output?.backupId,
283 "backup",
284 );
285 const location = normalizeLocation(
286 olds?.location ?? output?.location,
287 env.region,
288 );
289 const parent = olds?.backupVault ?? output?.backupVault;
290 // A row whose parent never resolved (failed create) has nothing to read.
291 if (output?.name === undefined && !parent) return undefined;
292 const vault = expandParent(
293 parent ?? "",
294 env.project,
295 location,
296 "backupVaults",
297 );
298 const name = output?.name ?? resourceName(vault, backupId);
299 const existing = yield* getByName(name);
300 if (existing === undefined) return undefined;
301 const attrs = toAttrs(existing, env.project, env.region);
302 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
303 ? attrs
304 : Unowned(attrs);
305 }),
306
307 list: () =>
308 Effect.gen(function* () {
309 const env = yield* GcpEnvironment.current;
310 const items = yield* listOwned(env.project);
311 return items.map((item) => toAttrs(item, env.project, env.region));
312 }),
313
314 reconcile: Effect.fn(function* ({ id, news, output }) {
315 const env = yield* GcpEnvironment.current;
316 const backupId = yield* toPhysicalId(
317 id,
318 news.backupId,
319 output?.backupId,
320 "backup",
321 );
322 const location = normalizeLocation(
323 news.location ?? output?.location,
324 env.region,
325 );
326 const vault = expandParent(
327 news.backupVault,
328 env.project,
329 location,
330 "backupVaults",
331 );
332 const name = resourceName(vault, backupId);
333 const sourceVolume =
334 news.sourceVolume === undefined
335 ? undefined
336 : expandParent(news.sourceVolume, env.project, location, "volumes");
337 const desiredLabels = {
338 ...toLabels(news.labels),
339 ...(yield* createInternalLabels(id)),
340 };
341
342 let current = yield* getByName(output?.name ?? name);
343
344 if (current === undefined) {
345 const created = yield* netapp
346 .createProjectsLocationsBackupVaultsBackups({
347 parent: vault,
348 backupId,
349 body: {
350 sourceVolume,
351 sourceSnapshot: news.sourceSnapshot,
352 ontapSource: news.ontapSource,
353 description: news.description,
354 labels: desiredLabels,
355 },
356 })
357 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
358 if (created !== undefined) {
359 yield* waitForOperation(created);
360 }
361 current = yield* waitUntilExists(getByName(name), name);
362 }
363
364 if (current === undefined) {
365 return yield* new ResourceNotResolved({ name });
366 }
367
368 current = yield* waitUntilReady(
369 getByName(current.name ?? name),
370 current.name ?? name,
371 (item) => item.state,
372 );
373
374 const observedLabels = tagRecord(current.labels);
375 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
376 const mask = fieldMask([
377 (upsert.length > 0 || removed.length > 0) && "labels",
378 (current.description ?? "") !== (news.description ?? "") &&
379 "description",
380 ]);
381
382 if (mask.length > 0) {
383 const operation =
384 yield* netapp.patchProjectsLocationsBackupVaultsBackups({
385 name: current.name ?? name,
386 updateMask: mask,
387 body: {
388 name: current.name ?? name,
389 labels: desiredLabels,
390 description: news.description,
391 },
392 });
393 yield* waitForOperation(operation);
394 current = yield* waitUntilReady(
395 getByName(current.name ?? name),
396 current.name ?? name,
397 (item) => item.state,
398 );
399 }
400
401 return toAttrs(current, env.project, env.region);
402 }),
403
404 delete: Effect.fn(function* ({ output }) {
405 const operation = yield* netapp
406 .deleteProjectsLocationsBackupVaultsBackups({ name: output.name })
407 .pipe(
408 Effect.retry({
409 while: (error) => error._tag === "Conflict",
410 times: 8,
411 schedule: Schedule.spaced("2 seconds"),
412 }),
413 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
414 );
415 if (operation !== undefined) {
416 yield* waitForDeleteOperation(operation);
417 }
418 yield* waitUntilGone(getByName(output.name), output.name);
419 }),
420 });