1import * as file from "@distilled.cloud/gcp/file_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 DEFAULT_ZONE,
20 expandParent,
21 fieldMask,
22 hasAlchemyLabelMap,
23 isDeletingState,
24 lastSegment,
25 listLabeledPages,
26 normalizeLocation,
27 parseName,
28 replaceOnIdentity,
29 ResourceNotResolved,
30 toPhysicalId,
31 userLabels,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35 waitUntilReady,
36} from "./internal.ts";
37
38export type InstancesSnapshotProps = {
39 /**
40 * Parent Filestore instance. Full name
41 * `projects/{project}/locations/{location}/instances/{instance}` or
42 * the instance id (combined with `location`). Immutable — changing it
43 * replaces the snapshot.
44 *
45 * Snapshots require a Zonal, Regional, Enterprise, or High Scale SSD
46 * instance. Basic HDD and Basic SSD do not support snapshots.
47 */
48 instance: string;
49 /**
50 * Location of the parent instance when `instance` is a bare id.
51 * Ignored when `instance` is a full resource name. Immutable —
52 * changing it replaces the snapshot. `US-CENTRAL1-A` is accepted and
53 * normalized to `us-central1-a`.
54 * @default "us-central1-a"
55 */
56 location?: string;
57 /**
58 * Snapshot id (the `{snapshot}` segment of
59 * `.../instances/{instance}/snapshots/{snapshot}`). If omitted, a
60 * unique RFC1035 name is generated from the stack, stage, and logical
61 * id. Must start with a letter, be 1-63 characters, and end with a
62 * letter or digit. Immutable — changing it replaces the snapshot.
63 */
64 snapshotId?: string;
65 /**
66 * Human-readable description (2048 characters or less).
67 */
68 description?: string;
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73 /**
74 * Resource Manager tags (namespaced key to short value). Input-only
75 * and immutable.
76 */
77 tags?: Record<string, string>;
78};
79
80export type InstancesSnapshot = Resource<
81 "GCP.Filestore.InstancesSnapshot",
82 InstancesSnapshotProps,
83 {
84 /** Full resource name `projects/{project}/locations/{location}/instances/{instance}/snapshots/{snapshot}`. */
85 name: string;
86 /** Snapshot id (last path segment). */
87 snapshotId: string;
88 /** Parent instance resource name. */
89 instance: string;
90 /** Project id. */
91 project: string;
92 /** Instance location (zone or region). */
93 location: string;
94 /** Human-readable description. */
95 description: string | undefined;
96 /** User labels (Alchemy ownership labels stripped). */
97 labels: Record<string, string>;
98 /** Bytes needed to allocate a full copy of the snapshot. */
99 filesystemUsedBytes: string | undefined;
100 /** Server-reported state (`READY`, `CREATING`, `DELETING`). */
101 state: string | undefined;
102 /** RFC3339 creation timestamp. */
103 createTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A Cloud Filestore snapshot of an instance.
111 *
112 * Changing `snapshotId`, `instance`, or `location` replaces the
113 * snapshot. Description and labels update in place.
114 *
115 * Snapshots are supported on Zonal, Regional, Enterprise, and High Scale
116 * SSD instances. Basic HDD and Basic SSD reject snapshot create.
117 *
118 * ### Creating a Snapshot
119 * **Example:** Generated name
120 * ```typescript
121 * const snap = yield* GCP.Filestore.InstancesSnapshot("Nightly", {
122 * instance: nfs.name,
123 * });
124 * ```
125 *
126 * **Example:** Explicit id, description, and labels
127 * ```typescript
128 * const snap = yield* GCP.Filestore.InstancesSnapshot("Nightly", {
129 * instance: nfs.name,
130 * snapshotId: "app-nightly",
131 * description: "nightly snapshot",
132 * labels: { env: "prod" },
133 * });
134 * ```
135 *
136 * ### Updating a Snapshot
137 * Re-declare the same logical id with changed props; the engine keeps the
138 * physical resource and updates it in place.
139 *
140 * **Example:** Description and labels
141 * ```typescript
142 * const snap = yield* GCP.Filestore.InstancesSnapshot("Nightly", {
143 * instance: nfs.name,
144 * description: "nightly snapshot v2",
145 * labels: { env: "prod", team: "storage" },
146 * });
147 * ```
148 *
149 * @resource
150 * @category Filestore
151 */
152export const InstancesSnapshot = Resource<InstancesSnapshot>(
153 "GCP.Filestore.InstancesSnapshot",
154);
155
156const resourceName = (instance: string, snapshotId: string) =>
157 `${instance}/snapshots/${snapshotId}`;
158
159const parseInstanceRef = (
160 value: string,
161 fallbackProject: string,
162 fallbackLocation: string,
163) => {
164 const trimmed = value.trim();
165 if (trimmed.includes("/")) {
166 const parsed = parseName(trimmed, "instances", DEFAULT_ZONE);
167 const location = normalizeLocation(parsed.location, DEFAULT_ZONE);
168 return {
169 project: parsed.project || fallbackProject,
170 location,
171 instanceId: parsed.id,
172 instanceName: expandParent(
173 parsed.id,
174 parsed.project || fallbackProject,
175 location,
176 "instances",
177 ),
178 };
179 }
180 const location = normalizeLocation(fallbackLocation, DEFAULT_ZONE);
181 const instanceId = lastSegment(trimmed);
182 return {
183 project: fallbackProject,
184 location,
185 instanceId,
186 instanceName: expandParent(
187 instanceId,
188 fallbackProject,
189 location,
190 "instances",
191 ),
192 };
193};
194
195const toAttrs = (snapshot: file.Snapshot, project: string) => {
196 const name = snapshot.name ?? "";
197 const parsed = parseName(name, "snapshots", DEFAULT_ZONE);
198 return {
199 name,
200 snapshotId: parsed.id,
201 instance: parsed.parent,
202 project: parsed.project || project,
203 location: parsed.location,
204 description: snapshot.description,
205 labels: userLabels(snapshot.labels),
206 filesystemUsedBytes: snapshot.filesystemUsedBytes,
207 state: snapshot.state,
208 createTime: snapshot.createTime,
209 };
210};
211
212const isPlaceholder = (snapshot: file.Snapshot) => {
213 const name = snapshot.name ?? "";
214 return (
215 name.length === 0 ||
216 name.endsWith("/snapshots/-") ||
217 name.endsWith("/snapshots/")
218 );
219};
220
221const getByName = (name: string) =>
222 Effect.gen(function* () {
223 if (name.length === 0 || name.includes("//")) return undefined;
224 return yield* file
225 .getProjectsLocationsInstancesSnapshots({ name })
226 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
227 });
228
229const listViaInstances = (project: string) =>
230 file.listProjectsLocationsInstances
231 .pages({
232 parent: `projects/${project}/locations/-`,
233 pageSize: 1000,
234 })
235 .pipe(
236 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
237 Stream.filter((instance) => (instance.name ?? "").length > 0),
238 Stream.runCollect,
239 Effect.map((chunk) => Array.from(chunk)),
240 Effect.flatMap((instances) =>
241 Effect.forEach(
242 instances,
243 (instance) =>
244 listLabeledPages(
245 file.listProjectsLocationsInstancesSnapshots.pages({
246 parent: instance.name!,
247 pageSize: 1000,
248 }),
249 (page) => page.snapshots,
250 (item) => item.labels,
251 ),
252 { concurrency: 4 },
253 ).pipe(Effect.map((groups) => groups.flat())),
254 ),
255 );
256
257const listOwned = (project: string) =>
258 file.listProjectsLocationsInstancesSnapshots
259 .pages({
260 parent: `projects/${project}/locations/-/instances/-`,
261 pageSize: 1000,
262 returnPartialSuccess: true,
263 })
264 .pipe(
265 Stream.flatMap((page) => Stream.fromIterable(page.snapshots ?? [])),
266 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
267 Stream.runCollect,
268 Effect.map((chunk) => Array.from(chunk)),
269 Effect.catchIf(
270 () => true,
271 () => listViaInstances(project),
272 ),
273 );
274
275export const InstancesSnapshotProvider = () =>
276 Provider.succeed(InstancesSnapshot, {
277 stables: [
278 "name",
279 "snapshotId",
280 "instance",
281 "project",
282 "location",
283 "createTime",
284 ],
285
286 diff: Effect.fn(function* ({ news, olds, output }) {
287 if (!isResolved(news)) return undefined;
288 const env = yield* GcpEnvironment.current;
289 const previous = parseInstanceRef(
290 olds?.instance ?? output?.instance ?? "",
291 env.project,
292 olds?.location ?? output?.location ?? DEFAULT_ZONE,
293 );
294 const next = parseInstanceRef(
295 news.instance,
296 env.project,
297 news.location ??
298 olds?.location ??
299 output?.location ??
300 previous.location,
301 );
302 return replaceOnIdentity({
303 previousId: olds?.snapshotId ?? output?.snapshotId,
304 nextId: news.snapshotId ?? olds?.snapshotId ?? output?.snapshotId,
305 previousLocation: previous.location,
306 nextLocation: next.location,
307 previousParent: previous.instanceName,
308 nextParent: next.instanceName,
309 });
310 }),
311
312 read: Effect.fn(function* ({ id, olds, output }) {
313 const env = yield* GcpEnvironment.current;
314 const instance = parseInstanceRef(
315 olds?.instance ?? output?.instance ?? "",
316 env.project,
317 olds?.location ?? output?.location ?? DEFAULT_ZONE,
318 );
319 const snapshotId = yield* toPhysicalId(
320 id,
321 olds?.snapshotId,
322 output?.snapshotId,
323 "snapshot",
324 );
325 const name =
326 output?.name ?? resourceName(instance.instanceName, snapshotId);
327 const existing = yield* getByName(name);
328 if (existing === undefined || isPlaceholder(existing)) return undefined;
329 const attrs = toAttrs(existing, env.project);
330 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
331 ? attrs
332 : Unowned(attrs);
333 }),
334
335 list: () =>
336 Effect.gen(function* () {
337 const env = yield* GcpEnvironment.current;
338 const items = yield* listOwned(env.project);
339 return items
340 .filter(
341 (item) => !isPlaceholder(item) && hasAlchemyLabelMap(item.labels),
342 )
343 .map((item) => toAttrs(item, env.project));
344 }),
345
346 reconcile: Effect.fn(function* ({ id, news, output }) {
347 const env = yield* GcpEnvironment.current;
348 const instance = parseInstanceRef(
349 news.instance,
350 env.project,
351 news.location ?? output?.location ?? DEFAULT_ZONE,
352 );
353 const snapshotId = yield* toPhysicalId(
354 id,
355 news.snapshotId,
356 output?.snapshotId,
357 "snapshot",
358 );
359 const name = resourceName(instance.instanceName, snapshotId);
360 const desiredLabels = {
361 ...toLabels(news.labels),
362 ...(yield* createInternalLabels(id)),
363 };
364
365 let current = yield* getByName(output?.name ?? name);
366
367 if (current !== undefined && isDeletingState(current.state)) {
368 yield* waitUntilGone(
369 getByName(current.name ?? name),
370 current.name ?? name,
371 );
372 current = undefined;
373 }
374
375 if (current === undefined) {
376 const created = yield* file
377 .createProjectsLocationsInstancesSnapshots({
378 parent: instance.instanceName,
379 snapshotId,
380 body: {
381 description: news.description,
382 labels: desiredLabels,
383 tags: news.tags,
384 },
385 })
386 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
387 if (created !== undefined) {
388 yield* waitForOperation(created);
389 }
390 current = yield* waitUntilExists(getByName(name), name);
391 }
392
393 if (current === undefined) {
394 return yield* new ResourceNotResolved({ name });
395 }
396
397 current = yield* waitUntilReady(
398 getByName(current.name ?? name),
399 current.name ?? name,
400 (item) => item.state,
401 );
402 if (current === undefined) {
403 return yield* new ResourceNotResolved({ name });
404 }
405
406 const observedLabels = tagRecord(current.labels);
407 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
408 const mask = fieldMask([
409 (upsert.length > 0 || removed.length > 0) && "labels",
410 (current.description ?? "") !== (news.description ?? "") &&
411 "description",
412 ]);
413
414 if (mask.length > 0) {
415 const operation = yield* file.patchProjectsLocationsInstancesSnapshots({
416 name: current.name ?? name,
417 updateMask: mask,
418 body: {
419 name: current.name ?? name,
420 labels: desiredLabels,
421 description: news.description,
422 },
423 });
424 yield* waitForOperation(operation);
425 current = yield* waitUntilReady(
426 getByName(current.name ?? name),
427 current.name ?? name,
428 (item) => item.state,
429 );
430 if (current === undefined) {
431 return yield* new ResourceNotResolved({ name });
432 }
433 }
434
435 return toAttrs(current, env.project);
436 }),
437
438 delete: Effect.fn(function* ({ output }) {
439 if (!output.name || output.name.includes("//")) return;
440 const operation = yield* file
441 .deleteProjectsLocationsInstancesSnapshots({ name: output.name })
442 .pipe(
443 Effect.retry({
444 while: (error) => error._tag === "Conflict",
445 times: 8,
446 schedule: Schedule.spaced("5 seconds"),
447 }),
448 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
449 );
450 if (operation !== undefined) {
451 yield* waitForOperation(operation, { notFoundOk: true });
452 }
453 yield* waitUntilGone(getByName(output.name), output.name);
454 }),
455 });