1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } from "./operations.ts";
3import { OperationFailed } from "../Operation.ts";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { tagRecord } from "../../Tags.ts";
14import { GcpEnvironment } from "../Environment.ts";
15import {
16 createInternalLabels,
17 diffLabels,
18 hasAlchemyLabels,
19 stripInternalLabels,
20 toLabels,
21} from "../Labels.ts";
22import type { Providers } from "../Providers.ts";
23
24const DEFAULT_ZONE = "us-central1-a";
25
26export type InstantSnapshotProps = {
27 /**
28 * Instant snapshot name (RFC1035, 1-63 characters). If omitted, a unique
29 * name is generated from the stack, stage, and logical id. Changing the
30 * name replaces the snapshot.
31 */
32 instantSnapshotName?: string;
33 /**
34 * Zone of the snapshot and its source disk (e.g. `us-central1-a`).
35 * Immutable — changing it replaces the snapshot. Inferred from
36 * `sourceDisk` when omitted.
37 * @default "us-central1-a"
38 */
39 zone?: string;
40 /**
41 * Source disk URL or relative path
42 * (`projects/{project}/zones/{zone}/disks/{disk}`). Immutable —
43 * changing it replaces the snapshot.
44 */
45 sourceDisk: string;
46 /**
47 * Optional description. Immutable — changing it replaces the snapshot.
48 */
49 description?: string;
50 /**
51 * User labels. Alchemy ownership labels are merged in automatically
52 * and synced via `instantSnapshots.setLabels`.
53 */
54 labels?: Record<string, string>;
55};
56
57export type InstantSnapshot = Resource<
58 "GCP.Compute.InstantSnapshot",
59 InstantSnapshotProps,
60 {
61 /** Instant snapshot name. */
62 instantSnapshotName: string;
63 /** Server-assigned numeric id. */
64 instantSnapshotId: string | undefined;
65 /** Project id. */
66 project: string;
67 /** Zone short name (`us-central1-a`). */
68 zone: string;
69 /** Source disk URL. */
70 sourceDisk: string | undefined;
71 /** Server-assigned source disk id. */
72 sourceDiskId: string | undefined;
73 /** Optional description. */
74 description: string | undefined;
75 /** Server-reported status (`READY`, `CREATING`, …). */
76 status: string | undefined;
77 /** Size of the source disk in GB. */
78 diskSizeGb: string | undefined;
79 /** User labels (Alchemy ownership labels stripped). */
80 labels: Record<string, string>;
81 /** Compute Engine self-link. */
82 selfLink: string | undefined;
83 /** RFC3339 creation timestamp. */
84 creationTimestamp: string | undefined;
85 /** Architecture (`X86_64` or `ARM64`). */
86 architecture: string | undefined;
87 },
88 never,
89 Providers
90>;
91
92/**
93 * A zonal Compute Engine instant snapshot.
94 *
95 * Instant snapshots capture a disk's point-in-time state in the same zone
96 * (Hyperdisk and some persistent-disk types). Name, zone, source disk, and
97 * description are immutable — changing them replaces the snapshot. Labels
98 * update in place via `instantSnapshots.setLabels`.
99 *
100 * ### Creating an Instant Snapshot
101 * **Example:** Snapshot of a disk
102 * ```typescript
103 * const disk = yield* GCP.Compute.Disk("data", {
104 * zone: "us-central1-a",
105 * type: "pd-balanced",
106 * sizeGb: 10,
107 * });
108 * const snap = yield* GCP.Compute.InstantSnapshot("checkpoint", {
109 * sourceDisk: disk.selfLink,
110 * zone: disk.zone,
111 * });
112 * ```
113 *
114 * **Example:** Named snapshot with labels
115 * ```typescript
116 * const snap = yield* GCP.Compute.InstantSnapshot("checkpoint", {
117 * instantSnapshotName: "app-data-now",
118 * zone: "us-central1-a",
119 * sourceDisk: "zones/us-central1-a/disks/app-data",
120 * labels: { env: "prod" },
121 * });
122 * ```
123 *
124 * @resource
125 * @category Compute
126 */
127export const InstantSnapshot = Resource<InstantSnapshot>(
128 "GCP.Compute.InstantSnapshot",
129);
130
131export class InstantSnapshotNotResolved extends Data.TaggedError(
132 "GCP.Compute.InstantSnapshotNotResolved",
133)<{
134 instantSnapshotName: string;
135 zone: string;
136}> {}
137
138export class InstantSnapshotNotReady extends Data.TaggedError(
139 "GCP.Compute.InstantSnapshotNotReady",
140)<{
141 instantSnapshotName: string;
142 status: string;
143}> {}
144
145export class InstantSnapshotFailed extends Data.TaggedError(
146 "GCP.Compute.InstantSnapshotFailed",
147)<{
148 instantSnapshotName: string;
149 status: string;
150}> {}
151
152export class InstantSnapshotStillExists extends Data.TaggedError(
153 "GCP.Compute.InstantSnapshotStillExists",
154)<{
155 instantSnapshotName: string;
156 status: string;
157}> {}
158
159const lastSegment = (value: string | undefined): string | undefined => {
160 if (value === undefined || value.length === 0) return undefined;
161 const parts = value.replace(/\/+$/, "").split("/");
162 return parts[parts.length - 1] || value;
163};
164
165const zoneFromDisk = (sourceDisk: string | undefined): string | undefined => {
166 if (!sourceDisk) return undefined;
167 const match = sourceDisk.match(/\/zones\/([^/]+)\//);
168 return match?.[1];
169};
170
171const canonicalizeSource = (source: string | undefined): string => {
172 if (source === undefined || source.length === 0) return "";
173 return lastSegment(source) ?? source;
174};
175
176const userLabels = (
177 labels: Record<string, string | undefined> | null | undefined,
178): Record<string, string> => stripInternalLabels(tagRecord(labels));
179
180const rfc1035 = (name: string): string => {
181 let next = name
182 .toLowerCase()
183 .replace(/[^a-z0-9-]/g, "-")
184 .replace(/-+/g, "-")
185 .replace(/^-+/, "")
186 .replace(/-+$/, "");
187 if (!/^[a-z]/.test(next)) {
188 next = `i${next}`;
189 }
190 next = next.slice(0, 63).replace(/-+$/, "");
191 return next.length > 0 ? next : "instantsnapshot";
192};
193
194const toName = (id: string, name: string | undefined, existing?: string) =>
195 Effect.gen(function* () {
196 if (name !== undefined) return name;
197 if (existing !== undefined) return existing;
198 return rfc1035(
199 yield* createPhysicalName({
200 id,
201 maxLength: 63,
202 lowercase: true,
203 }),
204 );
205 });
206
207const toAttrs = (
208 snapshot: compute.InstantSnapshot,
209 project: string,
210): InstantSnapshot["Attributes"] => ({
211 instantSnapshotName: snapshot.name ?? snapshot.id ?? "",
212 instantSnapshotId: snapshot.id,
213 project,
214 zone: lastSegment(snapshot.zone) ?? DEFAULT_ZONE,
215 sourceDisk: snapshot.sourceDisk,
216 sourceDiskId: snapshot.sourceDiskId,
217 description: snapshot.description,
218 status: snapshot.status,
219 diskSizeGb: snapshot.diskSizeGb,
220 labels: userLabels(snapshot.labels),
221 selfLink: snapshot.selfLink,
222 creationTimestamp: snapshot.creationTimestamp,
223 architecture: snapshot.architecture,
224});
225
226const getByName = (project: string, zone: string, instantSnapshot: string) =>
227 compute
228 .getInstantSnapshots({ project, zone, instantSnapshot })
229 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
230
231const waitReady = (project: string, zone: string, name: string) =>
232 getByName(project, zone, name).pipe(
233 Effect.flatMap((snapshot) =>
234 snapshot?.status === "FAILED"
235 ? Effect.fail(
236 new InstantSnapshotFailed({
237 instantSnapshotName: name,
238 status: "FAILED",
239 }),
240 )
241 : Effect.succeed(snapshot),
242 ),
243 Effect.filterOrFail(
244 (snapshot): snapshot is compute.InstantSnapshot =>
245 snapshot !== undefined && snapshot.status === "READY",
246 (snapshot) =>
247 new InstantSnapshotNotReady({
248 instantSnapshotName: name,
249 status: snapshot?.status ?? "MISSING",
250 }),
251 ),
252 Effect.retry({
253 while: (error) => error._tag === "GCP.Compute.InstantSnapshotNotReady",
254 times: 10,
255 schedule: Schedule.spaced("1 second"),
256 }),
257 );
258
259const waitGone = (project: string, zone: string, name: string) =>
260 getByName(project, zone, name).pipe(
261 Effect.flatMap((snapshot) =>
262 snapshot === undefined
263 ? Effect.void
264 : Effect.fail(
265 new InstantSnapshotStillExists({
266 instantSnapshotName: name,
267 status: snapshot.status ?? "EXISTS",
268 }),
269 ),
270 ),
271 Effect.retry({
272 while: (error) => error._tag === "GCP.Compute.InstantSnapshotStillExists",
273 times: 10,
274 schedule: Schedule.spaced("2 seconds"),
275 }),
276 );
277
278export const InstantSnapshotProvider = () =>
279 Provider.succeed(InstantSnapshot, {
280 stables: [
281 "instantSnapshotName",
282 "instantSnapshotId",
283 "project",
284 "zone",
285 "sourceDisk",
286 "sourceDiskId",
287 "selfLink",
288 "creationTimestamp",
289 ],
290
291 diff: Effect.fn(function* ({ news, olds, output }) {
292 if (!isResolved(news)) return undefined;
293 const previousName =
294 olds?.instantSnapshotName ?? output?.instantSnapshotName;
295 const nextName = news.instantSnapshotName ?? previousName;
296 const previousZone =
297 lastSegment(olds?.zone) ?? lastSegment(output?.zone) ?? DEFAULT_ZONE;
298 const nextZone =
299 lastSegment(news.zone) ?? zoneFromDisk(news.sourceDisk) ?? previousZone;
300 const previousDisk = canonicalizeSource(
301 olds?.sourceDisk ?? output?.sourceDisk,
302 );
303 const nextDisk = canonicalizeSource(news.sourceDisk);
304 const previousDescription =
305 olds?.description ?? output?.description ?? "";
306 const nextDescription = news.description ?? "";
307
308 const replace =
309 (previousName !== undefined &&
310 nextName !== undefined &&
311 previousName !== nextName) ||
312 previousZone !== nextZone ||
313 (nextDisk.length > 0 &&
314 previousDisk.length > 0 &&
315 previousDisk !== nextDisk) ||
316 previousDescription !== nextDescription;
317
318 if (!replace) return undefined;
319 return {
320 action: "replace" as const,
321 deleteFirst:
322 previousName !== undefined &&
323 nextName !== undefined &&
324 previousName === nextName &&
325 previousZone === nextZone,
326 };
327 }),
328
329 read: Effect.fn(function* ({ id, olds, output }) {
330 const env = yield* GcpEnvironment.current;
331 const instantSnapshotName = yield* toName(
332 id,
333 olds?.instantSnapshotName,
334 output?.instantSnapshotName,
335 );
336 const zone =
337 lastSegment(olds?.zone) ??
338 lastSegment(output?.zone) ??
339 zoneFromDisk(olds?.sourceDisk ?? output?.sourceDisk) ??
340 DEFAULT_ZONE;
341 const existing = yield* getByName(env.project, zone, instantSnapshotName);
342 if (existing === undefined) return undefined;
343 const attrs = toAttrs(existing, env.project);
344 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
345 ? attrs
346 : Unowned(attrs);
347 }),
348
349 list: () =>
350 Effect.gen(function* () {
351 const env = yield* GcpEnvironment.current;
352 const pages = yield* compute.aggregatedListInstantSnapshots
353 .pages({
354 project: env.project,
355 filter: "labels.alchemy-id:*",
356 returnPartialSuccess: true,
357 maxResults: 500,
358 })
359 .pipe(Stream.runCollect);
360 return Array.from(pages).flatMap((page) =>
361 Object.values(page.items ?? {}).flatMap((scoped) =>
362 (scoped?.instantSnapshots ?? [])
363 .filter((item) =>
364 Object.keys(item.labels ?? {}).some((key) =>
365 key.startsWith("alchemy-"),
366 ),
367 )
368 .map((item) => toAttrs(item, env.project)),
369 ),
370 );
371 }),
372
373 reconcile: Effect.fn(function* ({ id, news, output }) {
374 const env = yield* GcpEnvironment.current;
375 const instantSnapshotName = yield* toName(
376 id,
377 news.instantSnapshotName,
378 output?.instantSnapshotName,
379 );
380 const zone =
381 lastSegment(news.zone) ??
382 lastSegment(output?.zone) ??
383 zoneFromDisk(news.sourceDisk) ??
384 DEFAULT_ZONE;
385 const desiredLabels = {
386 ...toLabels(news.labels),
387 ...(yield* createInternalLabels(id)),
388 };
389
390 let current = yield* getByName(env.project, zone, instantSnapshotName);
391 if (current?.status === "DELETING") {
392 yield* waitGone(env.project, zone, instantSnapshotName);
393 current = undefined;
394 }
395
396 if (current === undefined) {
397 // "The previous instant snapshot is too recent" — one instant snapshot
398 // per disk every 30 seconds.
399 const tooRecent = (error: { readonly _tag: string }) =>
400 error instanceof OperationFailed &&
401 error.reason === "UNSUPPORTED_OPERATION";
402 yield* compute
403 .insertInstantSnapshots({
404 project: env.project,
405 zone,
406 body: {
407 name: instantSnapshotName,
408 sourceDisk: news.sourceDisk,
409 description: news.description,
410 labels: desiredLabels,
411 },
412 })
413 .pipe(
414 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
415 Effect.flatMap((inserted) =>
416 inserted === undefined
417 ? Effect.void
418 : waitZoneOperation(env.project, zone, inserted, {
419 ignore: ["RESOURCE_ALREADY_EXISTS"],
420 }),
421 ),
422 Effect.retry({
423 while: tooRecent,
424 times: 3,
425 schedule: Schedule.spaced("30 seconds"),
426 }),
427 );
428 current = yield* waitReady(env.project, zone, instantSnapshotName);
429 }
430
431 if (current === undefined) {
432 return yield* new InstantSnapshotNotResolved({
433 instantSnapshotName,
434 zone,
435 });
436 }
437
438 if (current.status !== "READY") {
439 current = yield* waitReady(env.project, zone, instantSnapshotName);
440 }
441
442 const observedLabels = tagRecord(current.labels);
443 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
444 if (upsert.length > 0 || removed.length > 0) {
445 const labeled = yield* compute.setLabelsInstantSnapshots({
446 project: env.project,
447 zone,
448 resource: instantSnapshotName,
449 body: {
450 labels: desiredLabels,
451 labelFingerprint: current.labelFingerprint,
452 },
453 });
454 yield* waitZoneOperation(env.project, zone, labeled);
455 current = yield* getByName(env.project, zone, instantSnapshotName);
456 }
457
458 if (current === undefined) {
459 return yield* new InstantSnapshotNotResolved({
460 instantSnapshotName,
461 zone,
462 });
463 }
464
465 return toAttrs(current, env.project);
466 }),
467
468 delete: Effect.fn(function* ({ output }) {
469 const env = yield* GcpEnvironment.current;
470 const project = output.project || env.project;
471 const zone = lastSegment(output.zone) ?? DEFAULT_ZONE;
472 const operation = yield* compute
473 .deleteInstantSnapshots({
474 project,
475 zone,
476 instantSnapshot: output.instantSnapshotName,
477 })
478 .pipe(
479 Effect.retry({
480 while: (error) => error._tag === "Conflict",
481 times: 8,
482 schedule: Schedule.spaced("2 seconds"),
483 }),
484 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
485 );
486 if (operation !== undefined) {
487 yield* waitZoneOperation(project, zone, operation).pipe(
488 Effect.catchTag("NotFound", () => Effect.void),
489 );
490 }
491 yield* waitGone(project, zone, output.instantSnapshotName);
492 }),
493 });