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