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