1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import {
3 type WaitComputeOptions,
4 waitGlobalOperation,
5 waitRegionOperation,
6 waitZoneOperation,
7} from "./operations.ts";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import { Unowned } from "../../AdoptPolicy.ts";
13import { isResolved } from "../../Diff.ts";
14import { createPhysicalName } from "../../PhysicalName.ts";
15import * as Provider from "../../Provider.ts";
16import { Resource } from "../../Resource.ts";
17import { tagRecord } from "../../Tags.ts";
18import { GcpEnvironment } from "../Environment.ts";
19import {
20 createInternalLabels,
21 diffLabels,
22 hasAlchemyLabels,
23 stripInternalLabels,
24 toLabels,
25} from "../Labels.ts";
26import type { Providers } from "../Providers.ts";
27
28export type SnapshotProps = {
29 /**
30 * Snapshot name (RFC1035, 1-63 characters). If omitted, a unique name is
31 * generated from the stack, stage, and logical id. Changing the name
32 * replaces the snapshot.
33 */
34 snapshotName?: string;
35 /**
36 * Source disk URL or relative path used to create the snapshot
37 * (`projects/{project}/zones/{zone}/disks/{disk}`,
38 * `zones/{zone}/disks/{disk}`, or a full self-link). Immutable —
39 * changing it replaces the snapshot.
40 */
41 sourceDisk: string;
42 /**
43 * Source instant snapshot URL. Immutable — changing it replaces the
44 * snapshot. Mutually exclusive with creating from a different source
45 * disk.
46 */
47 sourceInstantSnapshot?: string;
48 /**
49 * Optional description. Immutable — changing it replaces the snapshot.
50 */
51 description?: string;
52 /**
53 * Snapshot type (`STANDARD` or `ARCHIVE`). Immutable — changing it
54 * replaces the snapshot.
55 * @default "STANDARD"
56 */
57 snapshotType?: compute.SnapshotSnapshotTypeEnum | (string & {});
58 /**
59 * Cloud Storage locations to store the snapshot (regional or
60 * multi-regional codes such as `us-central1` or `us`). Immutable —
61 * changing it replaces the snapshot.
62 */
63 storageLocations?: string[];
64 /**
65 * Snapshot chain name for advanced chargeback tracking. Immutable —
66 * changing it replaces the snapshot.
67 */
68 chainName?: string;
69 /**
70 * Attempt an application-consistent snapshot by flushing the guest OS.
71 * Input-only; not persisted on the resource.
72 */
73 guestFlush?: boolean;
74 /**
75 * User labels. Alchemy ownership labels are merged in automatically.
76 */
77 labels?: Record<string, string>;
78};
79
80export type Snapshot = Resource<
81 "GCP.Compute.Snapshot",
82 SnapshotProps,
83 {
84 /** Snapshot name. */
85 snapshotName: string;
86 /** Server-assigned numeric id. */
87 snapshotId: string | undefined;
88 /** Project id. */
89 project: string;
90 /** Source disk URL. */
91 sourceDisk: string | undefined;
92 /** Server-assigned source disk id. */
93 sourceDiskId: string | undefined;
94 /** Source instant snapshot URL, if any. */
95 sourceInstantSnapshot: string | undefined;
96 /** Snapshot type (`STANDARD` or `ARCHIVE`). */
97 snapshotType: string | undefined;
98 /** Cloud Storage locations. */
99 storageLocations: string[];
100 /** Snapshot chain name, if set. */
101 chainName: string | undefined;
102 /** Optional description. */
103 description: string | undefined;
104 /** Server-reported status (`READY`, `CREATING`, …). */
105 status: string | undefined;
106 /** Size of the source disk in GB. */
107 diskSizeGb: string | undefined;
108 /** Storage used by the snapshot, in bytes. */
109 storageBytes: string | undefined;
110 /** User labels (Alchemy ownership labels stripped). */
111 labels: Record<string, string>;
112 /** Compute Engine self-link. */
113 selfLink: string | undefined;
114 /** RFC3339 creation timestamp. */
115 creationTimestamp: string | undefined;
116 },
117 never,
118 Providers
119>;
120
121/**
122 * A global Compute Engine persistent-disk snapshot.
123 *
124 * Snapshots are created from a source disk (or instant snapshot). Name,
125 * source, type, storage locations, chain name, and description are
126 * immutable — changing them replaces the snapshot. Labels are updated
127 * in place via `snapshots.setLabels`.
128 *
129 * ### Creating a Snapshot
130 * **Example:** Snapshot of a disk
131 * ```typescript
132 * const disk = yield* GCP.Compute.Disk("data", {
133 * zone: "us-central1-a",
134 * sizeGb: 10,
135 * });
136 * const snapshot = yield* GCP.Compute.Snapshot("nightly", {
137 * sourceDisk: disk.selfLink,
138 * });
139 * ```
140 *
141 * **Example:** Explicit name, archive type, and labels
142 * ```typescript
143 * const snapshot = yield* GCP.Compute.Snapshot("nightly", {
144 * snapshotName: "app-data-nightly",
145 * sourceDisk: "zones/us-central1-a/disks/app-data",
146 * snapshotType: "ARCHIVE",
147 * storageLocations: ["us-central1"],
148 * labels: { env: "prod" },
149 * });
150 * ```
151 *
152 * @resource
153 * @category Compute
154 */
155export const Snapshot = Resource<Snapshot>("GCP.Compute.Snapshot");
156
157export class SnapshotNotResolved extends Data.TaggedError(
158 "GCP.Compute.SnapshotNotResolved",
159)<{
160 snapshotName: string;
161}> {}
162
163export class SnapshotNotReady extends Data.TaggedError(
164 "GCP.Compute.SnapshotNotReady",
165)<{
166 snapshotName: string;
167 status: string;
168}> {}
169
170export class SnapshotFailed extends Data.TaggedError(
171 "GCP.Compute.SnapshotFailed",
172)<{
173 snapshotName: string;
174 status: string;
175}> {}
176
177export class SnapshotStillExists extends Data.TaggedError(
178 "GCP.Compute.SnapshotStillExists",
179)<{
180 snapshotName: string;
181 status: string;
182}> {}
183
184const DEFAULT_SNAPSHOT_TYPE = "STANDARD";
185
186const lastSegment = (value: string | undefined): string | undefined => {
187 if (value === undefined || value.length === 0) return undefined;
188 const parts = value.split("/");
189 return parts[parts.length - 1] || value;
190};
191
192const canonicalizeSource = (source: string | undefined): string => {
193 if (source === undefined || source.length === 0) return "";
194 const cleaned = source.split("?")[0] ?? source;
195 const zonal = cleaned.match(/(zones\/[^/]+\/disks\/[^/]+)$/);
196 if (zonal?.[1] !== undefined) return zonal[1];
197 const regional = cleaned.match(/(regions\/[^/]+\/disks\/[^/]+)$/);
198 if (regional?.[1] !== undefined) return regional[1];
199 const instant = cleaned.match(/(zones\/[^/]+\/instantSnapshots\/[^/]+)$/);
200 if (instant?.[1] !== undefined) return instant[1];
201 return cleaned.replace(/^https?:\/\/[^/]+\//, "");
202};
203
204const sameLocations = (
205 left: readonly string[] | undefined,
206 right: readonly string[] | undefined,
207): boolean => {
208 const a = [...(left ?? [])].map((value) => value.toLowerCase()).sort();
209 const b = [...(right ?? [])].map((value) => value.toLowerCase()).sort();
210 return a.length === b.length && a.every((value, index) => value === b[index]);
211};
212
213const userLabels = (
214 labels: Record<string, string | undefined> | null | undefined,
215): Record<string, string> => stripInternalLabels(tagRecord(labels));
216
217const toName = (id: string, name: string | undefined, existing?: string) =>
218 Effect.gen(function* () {
219 if (name !== undefined) return name;
220 if (existing !== undefined) return existing;
221 const generated = yield* createPhysicalName({
222 id,
223 maxLength: 63,
224 lowercase: true,
225 });
226 return /^[a-z]/.test(generated) ? generated : `s${generated}`.slice(0, 63);
227 });
228
229const toAttrs = (snapshot: compute.Snapshot, project: string) => ({
230 snapshotName: snapshot.name ?? snapshot.id ?? "",
231 snapshotId: snapshot.id,
232 project,
233 sourceDisk: snapshot.sourceDisk,
234 sourceDiskId: snapshot.sourceDiskId,
235 sourceInstantSnapshot: snapshot.sourceInstantSnapshot,
236 snapshotType: snapshot.snapshotType,
237 storageLocations: snapshot.storageLocations ?? [],
238 chainName: snapshot.chainName,
239 description: snapshot.description,
240 status: snapshot.status,
241 diskSizeGb: snapshot.diskSizeGb,
242 storageBytes: snapshot.storageBytes,
243 labels: userLabels(snapshot.labels),
244 selfLink: snapshot.selfLink,
245 creationTimestamp: snapshot.creationTimestamp,
246});
247
248const getByName = (project: string, snapshot: string) =>
249 compute
250 .getSnapshots({ project, snapshot })
251 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
252
253const waitSnapshotReady = (project: string, snapshotName: string) =>
254 getByName(project, snapshotName).pipe(
255 Effect.flatMap((snapshot) =>
256 snapshot?.status === "FAILED"
257 ? Effect.fail(new SnapshotFailed({ snapshotName, status: "FAILED" }))
258 : Effect.succeed(snapshot),
259 ),
260 Effect.filterOrFail(
261 (snapshot): snapshot is compute.Snapshot =>
262 snapshot !== undefined && snapshot.status === "READY",
263 (snapshot) =>
264 new SnapshotNotReady({
265 snapshotName,
266 status: snapshot?.status ?? "MISSING",
267 }),
268 ),
269 Effect.retry({
270 while: (error) => error._tag === "GCP.Compute.SnapshotNotReady",
271 times: 72,
272 schedule: Schedule.spaced("5 seconds"),
273 }),
274 );
275
276const waitSnapshotGone = (project: string, snapshotName: string) =>
277 getByName(project, snapshotName).pipe(
278 Effect.flatMap((snapshot) =>
279 snapshot === undefined
280 ? Effect.void
281 : Effect.fail(
282 new SnapshotStillExists({
283 snapshotName,
284 status: snapshot.status ?? "UNKNOWN",
285 }),
286 ),
287 ),
288 Effect.retry({
289 while: (error) => error instanceof SnapshotStillExists,
290 times: 18,
291 schedule: Schedule.spaced("3 seconds"),
292 }),
293 );
294
295const waitSnapshotOperation = (
296 project: string,
297 operation: compute.Operation,
298 options?: WaitComputeOptions,
299) => {
300 const zone = lastSegment(operation.zone);
301 const region = lastSegment(operation.region);
302 return zone !== undefined
303 ? waitZoneOperation(project, zone, operation, options)
304 : region !== undefined
305 ? waitRegionOperation(project, region, operation, options)
306 : waitGlobalOperation(project, operation, options);
307};
308
309export const SnapshotProvider = () =>
310 Provider.succeed(Snapshot, {
311 stables: [
312 "snapshotName",
313 "snapshotId",
314 "project",
315 "sourceDisk",
316 "sourceDiskId",
317 "sourceInstantSnapshot",
318 "snapshotType",
319 "storageLocations",
320 "chainName",
321 "selfLink",
322 "creationTimestamp",
323 ],
324
325 diff: Effect.fn(function* ({ news, olds, output }) {
326 if (!isResolved(news)) return undefined;
327
328 const previousName = olds?.snapshotName ?? output?.snapshotName;
329 const nextName = news.snapshotName ?? previousName;
330 const previousSource = canonicalizeSource(
331 olds?.sourceDisk ?? output?.sourceDisk,
332 );
333 const nextSource = canonicalizeSource(news.sourceDisk);
334 const previousInstant = canonicalizeSource(
335 olds?.sourceInstantSnapshot ?? output?.sourceInstantSnapshot,
336 );
337 const nextInstant = canonicalizeSource(news.sourceInstantSnapshot);
338 const previousType =
339 olds?.snapshotType ?? output?.snapshotType ?? DEFAULT_SNAPSHOT_TYPE;
340 const nextType = news.snapshotType ?? DEFAULT_SNAPSHOT_TYPE;
341 const previousDescription =
342 olds?.description ?? output?.description ?? "";
343 const nextDescription = news.description ?? "";
344 const previousChain = olds?.chainName ?? output?.chainName ?? "";
345 const nextChain = news.chainName ?? "";
346 const locationsSpecified = news.storageLocations !== undefined;
347 const locationsChanged =
348 locationsSpecified &&
349 !sameLocations(
350 news.storageLocations,
351 olds?.storageLocations ?? output?.storageLocations,
352 );
353
354 const replace =
355 (previousName !== undefined &&
356 nextName !== undefined &&
357 previousName !== nextName) ||
358 (nextSource.length > 0 &&
359 previousSource.length > 0 &&
360 previousSource !== nextSource) ||
361 previousInstant !== nextInstant ||
362 previousType !== nextType ||
363 previousDescription !== nextDescription ||
364 previousChain !== nextChain ||
365 locationsChanged;
366
367 if (!replace) return undefined;
368 return {
369 action: "replace" as const,
370 deleteFirst:
371 previousName !== undefined &&
372 nextName !== undefined &&
373 previousName === nextName,
374 };
375 }),
376
377 read: Effect.fn(function* ({ id, olds, output }) {
378 const env = yield* GcpEnvironment.current;
379 const snapshotName = yield* toName(
380 id,
381 olds?.snapshotName,
382 output?.snapshotName,
383 );
384 const existing = yield* getByName(env.project, snapshotName);
385 if (existing === undefined) return undefined;
386 const attrs = toAttrs(existing, env.project);
387 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
388 ? attrs
389 : Unowned(attrs);
390 }),
391
392 list: () =>
393 Effect.gen(function* () {
394 const env = yield* GcpEnvironment.current;
395 return yield* compute.listSnapshots
396 .items({
397 project: env.project,
398 filter: "labels.alchemy-id:*",
399 maxResults: 500,
400 })
401 .pipe(
402 Stream.filter((snapshot) =>
403 Object.keys(snapshot.labels ?? {}).some((key) =>
404 key.startsWith("alchemy-"),
405 ),
406 ),
407 Stream.map((snapshot) => toAttrs(snapshot, env.project)),
408 Stream.runCollect,
409 Effect.map((chunk) => Array.from(chunk)),
410 );
411 }),
412
413 reconcile: Effect.fn(function* ({ id, news, output }) {
414 const env = yield* GcpEnvironment.current;
415 const snapshotName = yield* toName(
416 id,
417 news.snapshotName,
418 output?.snapshotName,
419 );
420 const desiredLabels = {
421 ...toLabels(news.labels),
422 ...(yield* createInternalLabels(id)),
423 };
424
425 let current = yield* getByName(env.project, snapshotName);
426 if (current?.status === "DELETING") {
427 yield* waitSnapshotGone(env.project, snapshotName);
428 current = undefined;
429 }
430
431 if (current === undefined) {
432 const inserted = yield* compute
433 .insertSnapshots({
434 project: env.project,
435 body: {
436 name: snapshotName,
437 sourceDisk: news.sourceDisk,
438 sourceInstantSnapshot: news.sourceInstantSnapshot,
439 description: news.description,
440 labels: desiredLabels,
441 snapshotType: news.snapshotType,
442 storageLocations: news.storageLocations,
443 chainName: news.chainName,
444 guestFlush: news.guestFlush,
445 },
446 })
447 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
448 if (inserted !== undefined) {
449 yield* waitSnapshotOperation(env.project, inserted, {
450 ignore: ["RESOURCE_ALREADY_EXISTS"],
451 });
452 }
453 current = yield* waitSnapshotReady(env.project, snapshotName);
454 }
455
456 if (current === undefined) {
457 return yield* new SnapshotNotResolved({ snapshotName });
458 }
459
460 if (current.status !== "READY") {
461 current = yield* waitSnapshotReady(env.project, snapshotName);
462 }
463
464 if (current === undefined) {
465 return yield* new SnapshotNotResolved({ snapshotName });
466 }
467
468 const observedLabels = tagRecord(current.labels);
469 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
470 if (upsert.length > 0 || removed.length > 0) {
471 const labeled = yield* compute.setLabelsSnapshots({
472 project: env.project,
473 resource: snapshotName,
474 body: {
475 labels: desiredLabels,
476 labelFingerprint: current.labelFingerprint,
477 },
478 });
479 yield* waitSnapshotOperation(env.project, labeled);
480 current = yield* getByName(env.project, snapshotName);
481 }
482
483 if (current === undefined) {
484 return yield* new SnapshotNotResolved({ snapshotName });
485 }
486
487 return toAttrs(current, env.project);
488 }),
489
490 delete: Effect.fn(function* ({ output }) {
491 const deleted = yield* compute
492 .deleteSnapshots({
493 project: output.project,
494 snapshot: output.snapshotName,
495 })
496 .pipe(
497 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
498 Effect.retry({
499 while: (error) => error._tag === "Conflict",
500 times: 8,
501 schedule: Schedule.spaced("2 seconds"),
502 }),
503 );
504 if (deleted !== undefined) {
505 yield* waitSnapshotOperation(output.project, deleted, {
506 ignore: ["RESOURCE_NOT_FOUND"],
507 });
508 }
509 yield* waitSnapshotGone(output.project, output.snapshotName);
510 }),
511 });