1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } 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 { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_ZONE = "us-central1-a";
21const LIST_ZONES = [
22 "us-central1-a",
23 "us-central1-b",
24 "us-central1-c",
25 "us-central1-f",
26];
27
28export type InstantSnapshotGroupProps = {
29 /**
30 * Instant snapshot group name (RFC1035, 1-63 chars). If omitted, a
31 * unique name is generated from the stack, stage, and logical id.
32 * Changing it replaces the group.
33 */
34 instantSnapshotGroupName?: string;
35 /**
36 * Zone of the group (e.g. `us-central1-a`). Immutable — changing it
37 * replaces the group. Inferred from `sourceConsistencyGroup` when
38 * omitted.
39 * @default "us-central1-a"
40 */
41 zone?: string;
42 /**
43 * Disk consistency-group resource policy URL or name. Immutable —
44 * changing it replaces the group.
45 */
46 sourceConsistencyGroup: string;
47 /**
48 * Optional description. Instant snapshot groups have no labels field —
49 * Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
50 * nuke.
51 */
52 description?: string;
53};
54
55export type InstantSnapshotGroup = Resource<
56 "GCP.Compute.InstantSnapshotGroup",
57 InstantSnapshotGroupProps,
58 {
59 /** Instant snapshot group name. */
60 instantSnapshotGroupName: string;
61 /** Project id. */
62 project: string;
63 /** Zone short name. */
64 zone: string;
65 /** Source consistency-group policy URL. */
66 sourceConsistencyGroup: string | undefined;
67 /** User description with the Alchemy ownership prefix stripped. */
68 description: string | undefined;
69 /** Server-reported status. */
70 status: string | undefined;
71 /** Server-assigned numeric id. */
72 instantSnapshotGroupId: string | undefined;
73 /** Server-defined URL. */
74 selfLink: string | undefined;
75 /** RFC3339 creation timestamp. */
76 creationTimestamp: string | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * A zonal Compute Engine instant snapshot group.
84 *
85 * Captures a point-in-time state of every disk in a consistency group.
86 * Name, zone, and source policy replace the group. Compute has no labels
87 * field, so Alchemy stamps ownership into the description.
88 *
89 * ### Creating an Instant Snapshot Group
90 * **Example:** Snapshot a consistency group
91 * ```typescript
92 * const policy = yield* GCP.Compute.ResourcePolicy("consistent", {
93 * diskConsistencyGroupPolicy: {},
94 * });
95 * const group = yield* GCP.Compute.InstantSnapshotGroup("checkpoint", {
96 * zone: "us-central1-a",
97 * sourceConsistencyGroup: policy.selfLink,
98 * });
99 * ```
100 *
101 * @resource
102 * @category Compute
103 */
104export const InstantSnapshotGroup = Resource<InstantSnapshotGroup>(
105 "GCP.Compute.InstantSnapshotGroup",
106);
107
108export class InstantSnapshotGroupNotResolved extends Data.TaggedError(
109 "GCP.Compute.InstantSnapshotGroupNotResolved",
110)<{
111 instantSnapshotGroupName: string;
112 zone: string;
113}> {}
114
115export class InstantSnapshotGroupNotReady extends Data.TaggedError(
116 "GCP.Compute.InstantSnapshotGroupNotReady",
117)<{
118 instantSnapshotGroupName: string;
119 status: string;
120}> {}
121
122export class InstantSnapshotGroupFailed extends Data.TaggedError(
123 "GCP.Compute.InstantSnapshotGroupFailed",
124)<{
125 instantSnapshotGroupName: string;
126 status: string;
127}> {}
128
129export class InstantSnapshotGroupStillExists extends Data.TaggedError(
130 "GCP.Compute.InstantSnapshotGroupStillExists",
131)<{
132 instantSnapshotGroupName: string;
133 status: string;
134}> {}
135
136const lastSegment = (value: string | undefined): string | undefined => {
137 if (value === undefined || value.length === 0) return undefined;
138 const parts = value.replace(/\/+$/, "").split("/");
139 return parts[parts.length - 1] || value;
140};
141
142const zoneOf = (value: string | undefined): string =>
143 lastSegment(value) ?? DEFAULT_ZONE;
144
145const rfc1035 = (name: string): string => {
146 let next = name
147 .toLowerCase()
148 .replace(/[^a-z0-9-]/g, "-")
149 .replace(/-+/g, "-")
150 .replace(/^-+/, "")
151 .replace(/-+$/, "");
152 if (!/^[a-z]/.test(next)) {
153 next = `g${next}`;
154 }
155 next = next.slice(0, 63).replace(/-+$/, "");
156 return next.length > 0 ? next : "instantsnapshotgroup";
157};
158
159const toName = (id: string, name: string | undefined, existing?: string) =>
160 Effect.gen(function* () {
161 if (name !== undefined) return name;
162 if (existing !== undefined) return existing;
163 return rfc1035(
164 yield* createPhysicalName({
165 id,
166 maxLength: 63,
167 lowercase: true,
168 }),
169 );
170 });
171
172const encodeDescription = (
173 labels: Record<string, string>,
174 description: string | undefined,
175): string => {
176 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
177 return description ? `${marker}\n${description}` : marker;
178};
179
180const parseDescription = (
181 description: string | undefined,
182): {
183 labels: Record<string, string>;
184 description: string | undefined;
185} => {
186 if (!description?.startsWith("[alchemy ")) {
187 return { labels: {}, description };
188 }
189 const end = description.indexOf("]");
190 if (end < 0) return { labels: {}, description };
191 const labels: Record<string, string> = {};
192 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
193 const eq = part.indexOf("=");
194 if (eq > 0) {
195 labels[part.slice(0, eq)] = part.slice(eq + 1);
196 }
197 }
198 const rest = description.slice(end + 1).replace(/^\n/, "");
199 return { labels, description: rest.length > 0 ? rest : undefined };
200};
201
202const toAttrs = (
203 group: compute.InstantSnapshotGroup,
204 project: string,
205): InstantSnapshotGroup["Attributes"] => {
206 const parsed = parseDescription(group.description);
207 return {
208 instantSnapshotGroupName: group.name ?? group.id ?? "",
209 project,
210 zone: zoneOf(group.zone),
211 sourceConsistencyGroup: group.sourceConsistencyGroup,
212 description: parsed.description,
213 status: group.status,
214 instantSnapshotGroupId: group.id,
215 selfLink: group.selfLink,
216 creationTimestamp: group.creationTimestamp,
217 };
218};
219
220const getByName = (
221 project: string,
222 zone: string,
223 instantSnapshotGroup: string,
224) =>
225 compute
226 .getInstantSnapshotGroups({ project, zone, instantSnapshotGroup })
227 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
228
229const waitReady = (
230 project: string,
231 zone: string,
232 instantSnapshotGroupName: string,
233) =>
234 getByName(project, zone, instantSnapshotGroupName).pipe(
235 Effect.flatMap((group) =>
236 group?.status === "FAILED" || group?.status === "INVALID"
237 ? Effect.fail(
238 new InstantSnapshotGroupFailed({
239 instantSnapshotGroupName,
240 status: group.status ?? "FAILED",
241 }),
242 )
243 : Effect.succeed(group),
244 ),
245 Effect.filterOrFail(
246 (group): group is compute.InstantSnapshotGroup =>
247 group !== undefined && group.status === "READY",
248 (group) =>
249 new InstantSnapshotGroupNotReady({
250 instantSnapshotGroupName,
251 status: group?.status ?? "MISSING",
252 }),
253 ),
254 Effect.retry({
255 while: (error) =>
256 error._tag === "GCP.Compute.InstantSnapshotGroupNotReady",
257 times: 10,
258 schedule: Schedule.spaced("1 second"),
259 }),
260 );
261
262const waitGone = (
263 project: string,
264 zone: string,
265 instantSnapshotGroupName: string,
266) =>
267 getByName(project, zone, instantSnapshotGroupName).pipe(
268 Effect.flatMap((group) =>
269 group === undefined
270 ? Effect.void
271 : Effect.fail(
272 new InstantSnapshotGroupStillExists({
273 instantSnapshotGroupName,
274 status: group.status ?? "EXISTS",
275 }),
276 ),
277 ),
278 Effect.retry({
279 while: (error) =>
280 error._tag === "GCP.Compute.InstantSnapshotGroupStillExists",
281 times: 10,
282 schedule: Schedule.spaced("2 seconds"),
283 }),
284 );
285
286export const InstantSnapshotGroupProvider = () =>
287 Provider.succeed(InstantSnapshotGroup, {
288 stables: [
289 "instantSnapshotGroupName",
290 "project",
291 "zone",
292 "sourceConsistencyGroup",
293 "instantSnapshotGroupId",
294 "selfLink",
295 "creationTimestamp",
296 ],
297
298 diff: Effect.fn(function* ({ news, olds, output }) {
299 if (!isResolved(news)) return undefined;
300 const previousName =
301 olds?.instantSnapshotGroupName ?? output?.instantSnapshotGroupName;
302 const nextName = news.instantSnapshotGroupName;
303 const previousZone = zoneOf(olds?.zone ?? output?.zone);
304 const nextZone = zoneOf(news.zone ?? output?.zone);
305 const previousSource = lastSegment(
306 olds?.sourceConsistencyGroup ?? output?.sourceConsistencyGroup,
307 );
308 const nextSource = lastSegment(news.sourceConsistencyGroup);
309 if (
310 (previousName !== undefined &&
311 nextName !== undefined &&
312 previousName !== nextName) ||
313 previousZone !== nextZone ||
314 (nextSource && previousSource && previousSource !== nextSource)
315 ) {
316 return {
317 action: "replace" as const,
318 deleteFirst:
319 previousName !== undefined &&
320 nextName !== undefined &&
321 previousName === nextName &&
322 previousZone === nextZone,
323 };
324 }
325 return undefined;
326 }),
327
328 read: Effect.fn(function* ({ id, olds, output }) {
329 const env = yield* GcpEnvironment.current;
330 const instantSnapshotGroupName = yield* toName(
331 id,
332 olds?.instantSnapshotGroupName,
333 output?.instantSnapshotGroupName,
334 );
335 const zone = zoneOf(olds?.zone ?? output?.zone);
336 const existing = yield* getByName(
337 env.project,
338 zone,
339 instantSnapshotGroupName,
340 );
341 if (existing === undefined) return undefined;
342 const attrs = toAttrs(existing, env.project);
343 const { labels } = parseDescription(existing.description);
344 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
345 }),
346
347 list: () =>
348 Effect.gen(function* () {
349 const env = yield* GcpEnvironment.current;
350 const perZone = yield* Effect.forEach(
351 LIST_ZONES,
352 (zone) =>
353 compute.listInstantSnapshotGroups
354 .items({
355 project: env.project,
356 zone,
357 maxResults: 500,
358 returnPartialSuccess: true,
359 })
360 .pipe(
361 Stream.filter((group) => {
362 const { labels } = parseDescription(group.description);
363 return Object.keys(labels).some((key) =>
364 key.startsWith("alchemy-"),
365 );
366 }),
367 Stream.map((group) => toAttrs(group, env.project)),
368 Stream.runCollect,
369 Effect.map((chunk) => Array.from(chunk)),
370 Effect.catchTag("NotFound", () => Effect.succeed([])),
371 ),
372 { concurrency: 4 },
373 );
374 return perZone.flat();
375 }),
376
377 reconcile: Effect.fn(function* ({ id, news, output }) {
378 const env = yield* GcpEnvironment.current;
379 const instantSnapshotGroupName = yield* toName(
380 id,
381 news.instantSnapshotGroupName,
382 output?.instantSnapshotGroupName,
383 );
384 const zone = zoneOf(news.zone ?? output?.zone);
385 const ownership = yield* createInternalLabels(id);
386 const description = encodeDescription(ownership, news.description);
387
388 let current = yield* getByName(
389 env.project,
390 zone,
391 instantSnapshotGroupName,
392 );
393 if (current?.status === "DELETING") {
394 yield* waitGone(env.project, zone, instantSnapshotGroupName);
395 current = undefined;
396 }
397
398 if (current === undefined) {
399 yield* compute
400 .insertInstantSnapshotGroups({
401 project: env.project,
402 zone,
403 sourceConsistencyGroup: news.sourceConsistencyGroup,
404 body: {
405 name: instantSnapshotGroupName,
406 description,
407 sourceConsistencyGroup: news.sourceConsistencyGroup,
408 },
409 })
410 .pipe(
411 Effect.flatMap((operation) =>
412 waitZoneOperation(env.project, zone, operation, {
413 ignore: ["RESOURCE_ALREADY_EXISTS"],
414 }),
415 ),
416 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
417 );
418 current = yield* waitReady(env.project, zone, instantSnapshotGroupName);
419 }
420
421 if (current === undefined) {
422 return yield* new InstantSnapshotGroupNotResolved({
423 instantSnapshotGroupName,
424 zone,
425 });
426 }
427
428 if (current.status !== "READY") {
429 current = yield* waitReady(env.project, zone, instantSnapshotGroupName);
430 }
431
432 return toAttrs(current, env.project);
433 }),
434
435 delete: Effect.fn(function* ({ output }) {
436 const env = yield* GcpEnvironment.current;
437 const zone = zoneOf(output.zone);
438 const operation = yield* compute
439 .deleteInstantSnapshotGroups({
440 project: env.project,
441 zone,
442 instantSnapshotGroup: output.instantSnapshotGroupName,
443 })
444 .pipe(
445 Effect.retry({
446 while: (error) => error._tag === "Conflict",
447 schedule: Schedule.spaced("2 seconds"),
448 times: 8,
449 }),
450 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
451 );
452 if (operation !== undefined) {
453 yield* waitZoneOperation(env.project, zone, operation).pipe(
454 Effect.catchTag("NotFound", () => Effect.void),
455 );
456 }
457 yield* waitGone(env.project, zone, output.instantSnapshotGroupName);
458 }),
459 });