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 fieldMask,
19 listAtNested,
20 listLabeledPages,
21 normalizeLocation,
22 parseName,
23 replaceOnIdentity,
24 ResourceNotResolved,
25 toPhysicalId,
26 userLabels,
27 volumeOf,
28 waitForDeleteOperation,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32 waitUntilReady,
33} from "./internal.ts";
34
35export type VolumesSnapshotProps = {
36 /**
37 * Parent volume. Full name
38 * `projects/{project}/locations/{location}/volumes/{volume}` or the
39 * volume id (combined with `location`). Immutable — changing it
40 * replaces the snapshot.
41 */
42 volume: string;
43 /**
44 * Region used when `volume` is a bare id. Immutable — changing it
45 * replaces the snapshot. `US-CENTRAL1` is accepted and normalized to
46 * `us-central1`.
47 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
48 */
49 location?: string;
50 /**
51 * Snapshot id (the `{snapshot}` segment of
52 * `.../volumes/{volume}/snapshots/{snapshot}`). If omitted, a unique
53 * RFC1035 name is generated from the stack, stage, and logical id.
54 * Immutable — changing it replaces the snapshot.
55 */
56 snapshotId?: string;
57 /**
58 * Human-readable description (2048 characters or less).
59 */
60 description?: string;
61 /**
62 * User labels. Alchemy ownership labels are merged in automatically.
63 */
64 labels?: Record<string, string>;
65};
66
67export type VolumesSnapshot = Resource<
68 "GCP.NetApp.VolumesSnapshot",
69 VolumesSnapshotProps,
70 {
71 /** Full resource name. */
72 name: string;
73 /** Snapshot id (last path segment). */
74 snapshotId: string;
75 /** Parent volume resource name. */
76 volume: string;
77 /** Project id. */
78 project: string;
79 /** Location id. */
80 location: string;
81 /** Human-readable description. */
82 description: string | undefined;
83 /** User labels (Alchemy ownership labels stripped). */
84 labels: Record<string, string>;
85 /** Storage used by unique snapshot blocks, in bytes. */
86 usedBytes: number | undefined;
87 /** Server-reported state. */
88 state: string | undefined;
89 /** Server-reported state details. */
90 stateDetails: string | undefined;
91 /** RFC3339 creation timestamp. */
92 createTime: string | undefined;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * A Cloud NetApp Volumes snapshot — a point-in-time copy of a volume.
100 *
101 * Changing `snapshotId`, `volume`, or `location` replaces the snapshot.
102 * Description and labels update in place.
103 *
104 * ### Creating a Snapshot
105 * **Example:** Generated name
106 * ```typescript
107 * const snap = yield* GCP.NetApp.VolumesSnapshot("Nightly", {
108 * volume: volume.name,
109 * });
110 * ```
111 *
112 * **Example:** Explicit id, description, and labels
113 * ```typescript
114 * const snap = yield* GCP.NetApp.VolumesSnapshot("Nightly", {
115 * volume: volume.name,
116 * snapshotId: "app-nightly",
117 * description: "nightly snapshot",
118 * labels: { env: "prod" },
119 * });
120 * ```
121 *
122 * ### Updating a Snapshot
123 * **Example:** Description and labels
124 * ```typescript
125 * const snap = yield* GCP.NetApp.VolumesSnapshot("Nightly", {
126 * volume: volume.name,
127 * description: "nightly snapshot v2",
128 * labels: { env: "prod", team: "storage" },
129 * });
130 * ```
131 *
132 * @resource
133 * @category NetApp
134 */
135export const VolumesSnapshot = Resource<VolumesSnapshot>(
136 "GCP.NetApp.VolumesSnapshot",
137);
138
139const resourceName = (volume: string, snapshotId: string) =>
140 `${volume}/snapshots/${snapshotId}`;
141
142const toAttrs = (
143 snapshot: netapp.Snapshot,
144 project: string,
145 region: string,
146) => {
147 const name = snapshot.name ?? "";
148 const parsed = parseName(name, "snapshots", region);
149 return {
150 name,
151 snapshotId: parsed.id,
152 volume: parsed.parent,
153 project: parsed.project || project,
154 location: parsed.location,
155 description: snapshot.description,
156 labels: userLabels(snapshot.labels),
157 usedBytes: snapshot.usedBytes,
158 state: snapshot.state,
159 stateDetails: snapshot.stateDetails,
160 createTime: snapshot.createTime,
161 };
162};
163
164const getByName = (name: string) =>
165 name.length === 0 || name.includes("//")
166 ? Effect.succeed(undefined)
167 : netapp
168 .getProjectsLocationsVolumesSnapshots({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listOwned = (project: string) =>
172 listAtNested(project, "volumes/-", (parent) =>
173 listLabeledPages(
174 netapp.listProjectsLocationsVolumesSnapshots.pages({
175 parent,
176 pageSize: 1000,
177 }),
178 (page) => page.snapshots,
179 (item) => item.labels,
180 ),
181 );
182
183export const VolumesSnapshotProvider = () =>
184 Provider.succeed(VolumesSnapshot, {
185 stables: [
186 "name",
187 "snapshotId",
188 "volume",
189 "project",
190 "location",
191 "createTime",
192 ],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const env = yield* GcpEnvironment.current;
197 const previousLocation = normalizeLocation(
198 olds?.location ?? output?.location,
199 env.region,
200 );
201 const nextLocation = normalizeLocation(
202 news.location ?? olds?.location ?? output?.location,
203 env.region,
204 );
205 const previousVolume = volumeOf(
206 olds?.volume ?? output?.volume ?? "",
207 env.project,
208 previousLocation,
209 );
210 const nextVolume = volumeOf(
211 news.volume ?? olds?.volume ?? output?.volume ?? "",
212 env.project,
213 nextLocation,
214 );
215 return replaceOnIdentity({
216 previousId: olds?.snapshotId ?? output?.snapshotId,
217 nextId: news.snapshotId ?? olds?.snapshotId ?? output?.snapshotId,
218 previousLocation,
219 nextLocation,
220 previousParent: previousVolume,
221 nextParent: nextVolume,
222 });
223 }),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const env = yield* GcpEnvironment.current;
227 const location = normalizeLocation(
228 olds?.location ?? output?.location,
229 env.region,
230 );
231 const volume = volumeOf(
232 olds?.volume ?? output?.volume ?? "",
233 env.project,
234 location,
235 );
236 const snapshotId = yield* toPhysicalId(
237 id,
238 olds?.snapshotId,
239 output?.snapshotId,
240 "snapshot",
241 );
242 const name = output?.name ?? resourceName(volume, snapshotId);
243 const existing = yield* getByName(name);
244 if (existing === undefined) return undefined;
245 const attrs = toAttrs(existing, env.project, env.region);
246 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
247 ? attrs
248 : Unowned(attrs);
249 }),
250
251 list: () =>
252 Effect.gen(function* () {
253 const env = yield* GcpEnvironment.current;
254 const items = yield* listOwned(env.project);
255 return items.map((item) => toAttrs(item, env.project, env.region));
256 }),
257
258 reconcile: Effect.fn(function* ({ id, news, output }) {
259 const env = yield* GcpEnvironment.current;
260 const locationHint = normalizeLocation(
261 news.location ?? output?.location,
262 env.region,
263 );
264 const volume = volumeOf(news.volume, env.project, locationHint);
265 const snapshotId = yield* toPhysicalId(
266 id,
267 news.snapshotId,
268 output?.snapshotId,
269 "snapshot",
270 );
271 const name = output?.name ?? resourceName(volume, snapshotId);
272 const desiredLabels = {
273 ...toLabels(news.labels),
274 ...(yield* createInternalLabels(id)),
275 };
276
277 let current = yield* getByName(output?.name ?? name);
278
279 if (current === undefined) {
280 const created = yield* netapp
281 .createProjectsLocationsVolumesSnapshots({
282 parent: volume,
283 snapshotId,
284 body: {
285 description: news.description,
286 labels: desiredLabels,
287 },
288 })
289 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
290 if (created !== undefined) {
291 yield* waitForOperation(created);
292 }
293 current = yield* waitUntilExists(getByName(name), name);
294 }
295
296 if (current === undefined) {
297 return yield* new ResourceNotResolved({ name });
298 }
299
300 current = yield* waitUntilReady(
301 getByName(current.name ?? name),
302 current.name ?? name,
303 (item) => item.state,
304 );
305
306 const observedLabels = tagRecord(current.labels);
307 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
308 const labelsChanged = upsert.length > 0 || removed.length > 0;
309 const descriptionChanged =
310 (current.description ?? "") !== (news.description ?? "");
311 const mask = fieldMask([
312 labelsChanged && "labels",
313 descriptionChanged && "description",
314 ]);
315
316 if (mask.length > 0) {
317 const operation = yield* netapp.patchProjectsLocationsVolumesSnapshots({
318 name: current.name ?? name,
319 updateMask: mask,
320 body: {
321 name: current.name ?? name,
322 labels: desiredLabels,
323 description: news.description,
324 },
325 });
326 yield* waitForOperation(operation);
327 current = yield* waitUntilReady(
328 getByName(current.name ?? name),
329 current.name ?? name,
330 (item) => item.state,
331 );
332 }
333
334 return toAttrs(current, env.project, env.region);
335 }),
336
337 delete: Effect.fn(function* ({ output }) {
338 if (!output.name || output.name.includes("//")) return;
339 const operation = yield* netapp
340 .deleteProjectsLocationsVolumesSnapshots({ name: output.name })
341 .pipe(
342 Effect.retry({
343 while: (error) => error._tag === "Conflict",
344 times: 8,
345 schedule: Schedule.spaced("2 seconds"),
346 }),
347 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
348 );
349 if (operation !== undefined) {
350 yield* waitForDeleteOperation(operation);
351 }
352 yield* waitUntilGone(getByName(output.name), output.name);
353 }),
354 });