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