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
23export type RegionDiskProps = {
24 /**
25 * Disk name (RFC1035, 1-63 characters). If omitted, a unique name is
26 * generated from the stack, stage, and logical id. Changing the name
27 * replaces the disk.
28 */
29 diskName?: string;
30 /**
31 * Region the disk lives in (e.g. `us-central1`). Immutable — changing
32 * it replaces the disk. `US-CENTRAL1` is accepted and normalized to
33 * `us-central1`.
34 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
35 */
36 region?: string;
37 /**
38 * Zones the disk is replicated to. Two zones in `region` are required.
39 * Short names (`us-central1-a`) or URLs are accepted. Immutable —
40 * changing them replaces the disk.
41 * @default ["{region}-a", "{region}-b"]
42 */
43 replicaZones?: string[];
44 /**
45 * Persistent disk type (`pd-standard`, `pd-balanced`, `pd-ssd`,
46 * `pd-extreme`, `hyperdisk-balanced`, …). Immutable — changing it
47 * replaces the disk.
48 * @default "pd-standard"
49 */
50 type?: string;
51 /**
52 * Size in GB. Regional persistent disks require at least 200 GB. May
53 * only grow in place; shrinking replaces the disk.
54 * @default 200
55 */
56 sizeGb?: number;
57 /**
58 * Optional description.
59 */
60 description?: string;
61 /**
62 * User labels. Alchemy ownership labels are merged in automatically.
63 */
64 labels?: Record<string, string>;
65 /**
66 * Source image used to create the disk. Immutable — changing it replaces
67 * the disk.
68 */
69 sourceImage?: string;
70 /**
71 * Source snapshot used to create the disk. Immutable — changing it
72 * replaces the disk.
73 */
74 sourceSnapshot?: string;
75 /**
76 * Source disk used to create this disk. Immutable — changing it replaces
77 * the disk.
78 */
79 sourceDisk?: string;
80 /**
81 * CPU architecture (`ARM64` or `X86_64`). Immutable — changing it
82 * replaces the disk.
83 */
84 architecture?: string;
85 /**
86 * Physical block size in bytes (typically `4096`). Immutable — changing
87 * it replaces the disk.
88 */
89 physicalBlockSizeBytes?: number;
90 /**
91 * Provisioned IOPS (Extreme PD / Hyperdisk). Mutable in place.
92 */
93 provisionedIops?: number;
94 /**
95 * Provisioned throughput in MB/s (Hyperdisk). Mutable in place.
96 */
97 provisionedThroughput?: number;
98 /**
99 * Whether confidential compute is enabled. Immutable — changing it
100 * replaces the disk.
101 */
102 enableConfidentialCompute?: boolean;
103};
104
105export type RegionDisk = Resource<
106 "GCP.Compute.RegionDisk",
107 RegionDiskProps,
108 {
109 /** Disk name. */
110 diskName: string;
111 /** Server-assigned numeric id. */
112 diskId: string | undefined;
113 /** Project id. */
114 project: string;
115 /** Region (short name, e.g. `us-central1`). */
116 region: string;
117 /** Replica zone short names, sorted. */
118 replicaZones: string[];
119 /** Disk type (short name, e.g. `pd-standard`). */
120 type: string;
121 /** Size in GB. */
122 sizeGb: number;
123 /** Server-reported status (`READY`, `CREATING`, …). */
124 status: string | undefined;
125 /** User labels (Alchemy ownership labels stripped). */
126 labels: Record<string, string>;
127 /** Optional description. */
128 description: string | undefined;
129 /** Compute Engine self-link. */
130 selfLink: string | undefined;
131 /** RFC3339 creation timestamp. */
132 creationTimestamp: string | undefined;
133 /** Source image, if any. */
134 sourceImage: string | undefined;
135 /** Source snapshot, if any. */
136 sourceSnapshot: string | undefined;
137 /** Source disk, if any. */
138 sourceDisk: string | undefined;
139 /** Attached instance URLs. */
140 users: ReadonlyArray<string>;
141 },
142 never,
143 Providers
144>;
145
146/**
147 * A regional Compute Engine persistent disk.
148 *
149 * Regional disks replicate across two zones in a region and require a
150 * minimum size of 200 GB. Changing
151 * `region`, `replicaZones`, `type`, `diskName`, `sourceImage`,
152 * `sourceSnapshot`, `sourceDisk`, `architecture`,
153 * `physicalBlockSizeBytes`, or `enableConfidentialCompute` replaces the
154 * disk. Growing `sizeGb` is applied in place via `regionDisks.resize`;
155 * shrinking it replaces the disk.
156 *
157 * ### Creating a RegionDisk
158 * **Example:** Generated name
159 * ```typescript
160 * const disk = yield* GCP.Compute.RegionDisk("data", {});
161 * ```
162 *
163 * **Example:** Explicit name, replica zones, type, size, and labels
164 * ```typescript
165 * const disk = yield* GCP.Compute.RegionDisk("data", {
166 * diskName: "app-data",
167 * region: "us-central1",
168 * replicaZones: ["us-central1-a", "us-central1-b"],
169 * type: "pd-balanced",
170 * sizeGb: 200,
171 * labels: { env: "prod" },
172 * });
173 * ```
174 *
175 * ### Resizing a RegionDisk
176 * **Example:** Grow in place
177 * ```typescript
178 * const disk = yield* GCP.Compute.RegionDisk("data", {
179 * diskName: "app-data",
180 * region: "us-central1",
181 * sizeGb: 250,
182 * });
183 * ```
184 *
185 * @resource
186 * @category Compute
187 */
188export const RegionDisk = Resource<RegionDisk>("GCP.Compute.RegionDisk");
189
190export class RegionDiskNotResolved extends Data.TaggedError(
191 "GCP.Compute.RegionDiskNotResolved",
192)<{
193 diskName: string;
194 region: string;
195}> {}
196
197export class RegionDiskNotReady extends Data.TaggedError(
198 "GCP.Compute.RegionDiskNotReady",
199)<{
200 diskName: string;
201 status: string;
202}> {}
203
204export class RegionDiskFailed extends Data.TaggedError(
205 "GCP.Compute.RegionDiskFailed",
206)<{
207 diskName: string;
208 status: string;
209}> {}
210
211export class RegionDiskStillExists extends Data.TaggedError(
212 "GCP.Compute.RegionDiskStillExists",
213)<{
214 diskName: string;
215 status: string;
216}> {}
217
218const DEFAULT_TYPE = "pd-standard";
219const DEFAULT_SIZE_GB = 200;
220
221const lastSegment = (value: string | undefined): string | undefined => {
222 if (value === undefined || value.length === 0) return undefined;
223 const parts = value.split("/");
224 return parts[parts.length - 1] || value;
225};
226
227const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
228 (lastSegment(region ?? defaultRegion) ?? defaultRegion).toLowerCase();
229
230const userLabels = (
231 labels: Record<string, string | undefined> | null | undefined,
232): Record<string, string> => stripInternalLabels(tagRecord(labels));
233
234const parseSizeGb = (value: string | undefined): number => {
235 const parsed = Number(value);
236 return Number.isFinite(parsed) ? parsed : 0;
237};
238
239const normalizeReplicaZones = (
240 region: string,
241 zones: readonly string[] | undefined,
242): string[] => {
243 const raw =
244 zones && zones.length > 0 ? zones : [`${region}-a`, `${region}-b`];
245 return [
246 ...new Set(
247 raw
248 .map((zone) => lastSegment(zone))
249 .filter((zone): zone is string => zone !== undefined),
250 ),
251 ].sort();
252};
253
254const replicaZoneUrls = (project: string, zones: readonly string[]) =>
255 zones.map((zone) => `projects/${project}/zones/${zone}`);
256
257const replicaZonesEqual = (left: readonly string[], right: readonly string[]) =>
258 left.length === right.length &&
259 left.every((zone, index) => zone === right[index]);
260
261const toName = (id: string, name: string | undefined, existing?: string) =>
262 Effect.gen(function* () {
263 if (name !== undefined) return name;
264 if (existing !== undefined) return existing;
265 const generated = yield* createPhysicalName({
266 id,
267 maxLength: 63,
268 lowercase: true,
269 });
270 // RFC1035: disk names must start with a letter.
271 return /^[a-z]/.test(generated) ? generated : `d${generated}`.slice(0, 63);
272 });
273
274const toAttrs = (disk: compute.Disk, project: string) => {
275 const region = (lastSegment(disk.region) ?? "").toLowerCase();
276 return {
277 diskName: disk.name ?? disk.id ?? "",
278 diskId: disk.id,
279 project,
280 region,
281 replicaZones: normalizeReplicaZones(region, disk.replicaZones),
282 type: lastSegment(disk.type) ?? DEFAULT_TYPE,
283 sizeGb: parseSizeGb(disk.sizeGb),
284 status: disk.status,
285 labels: userLabels(disk.labels),
286 description: disk.description,
287 selfLink: disk.selfLink,
288 creationTimestamp: disk.creationTimestamp,
289 sourceImage: disk.sourceImage,
290 sourceSnapshot: disk.sourceSnapshot,
291 sourceDisk: disk.sourceDisk,
292 users: disk.users ?? [],
293 };
294};
295
296const getByName = (project: string, region: string, disk: string) =>
297 compute
298 .getRegionDisks({ project, region, disk })
299 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
300
301const waitDiskReady = (project: string, region: string, diskName: string) =>
302 getByName(project, region, diskName).pipe(
303 Effect.flatMap((disk) =>
304 disk?.status === "FAILED"
305 ? Effect.fail(new RegionDiskFailed({ diskName, status: "FAILED" }))
306 : Effect.succeed(disk),
307 ),
308 Effect.filterOrFail(
309 (disk): disk is compute.Disk =>
310 disk !== undefined && disk.status === "READY",
311 (disk) =>
312 new RegionDiskNotReady({
313 diskName,
314 status: disk?.status ?? "MISSING",
315 }),
316 ),
317 Effect.retry({
318 while: (error) => error._tag === "GCP.Compute.RegionDiskNotReady",
319 times: 10,
320 schedule: Schedule.spaced("2 seconds"),
321 }),
322 );
323
324const waitDiskGone = (project: string, region: string, diskName: string) =>
325 getByName(project, region, diskName).pipe(
326 Effect.flatMap((disk) =>
327 disk === undefined
328 ? Effect.void
329 : Effect.fail(
330 new RegionDiskStillExists({
331 diskName,
332 status: disk.status ?? "UNKNOWN",
333 }),
334 ),
335 ),
336 Effect.retry({
337 while: (error) => error._tag === "GCP.Compute.RegionDiskStillExists",
338 times: 10,
339 schedule: Schedule.spaced("3 seconds"),
340 }),
341 );
342
343const diskTypeUrl = (project: string, region: string, type: string) =>
344 `projects/${project}/regions/${region}/diskTypes/${type}`;
345
346export const RegionDiskProvider = () =>
347 Provider.succeed(RegionDisk, {
348 stables: [
349 "diskName",
350 "diskId",
351 "project",
352 "region",
353 "replicaZones",
354 "type",
355 "selfLink",
356 "creationTimestamp",
357 "sourceImage",
358 "sourceSnapshot",
359 "sourceDisk",
360 ],
361
362 diff: Effect.fn(function* ({ news, olds, output }) {
363 if (!isResolved(news)) return undefined;
364 const env = yield* GcpEnvironment.current;
365
366 const previousRegion = normalizeRegion(
367 olds?.region ?? output?.region,
368 env.region,
369 );
370 const nextRegion = normalizeRegion(
371 news.region ?? output?.region,
372 env.region,
373 );
374 const previousType =
375 lastSegment(olds?.type) ?? lastSegment(output?.type) ?? DEFAULT_TYPE;
376 const nextType = lastSegment(news.type) ?? DEFAULT_TYPE;
377 const previousName = olds?.diskName ?? output?.diskName;
378 const nextName = news.diskName ?? previousName;
379 const previousSize = olds?.sizeGb ?? output?.sizeGb;
380 const nextSize = news.sizeGb ?? DEFAULT_SIZE_GB;
381 const previousReplicas = normalizeReplicaZones(
382 previousRegion,
383 olds?.replicaZones ?? output?.replicaZones,
384 );
385 const nextReplicas = normalizeReplicaZones(nextRegion, news.replicaZones);
386
387 const replace =
388 previousRegion !== nextRegion ||
389 previousType !== nextType ||
390 !replicaZonesEqual(previousReplicas, nextReplicas) ||
391 (previousName !== undefined &&
392 nextName !== undefined &&
393 previousName !== nextName) ||
394 (olds?.sourceImage ?? undefined) !== (news.sourceImage ?? undefined) ||
395 (olds?.sourceSnapshot ?? undefined) !==
396 (news.sourceSnapshot ?? undefined) ||
397 (olds?.sourceDisk ?? undefined) !== (news.sourceDisk ?? undefined) ||
398 (olds?.architecture ?? undefined) !==
399 (news.architecture ?? undefined) ||
400 (olds?.physicalBlockSizeBytes ?? undefined) !==
401 (news.physicalBlockSizeBytes ?? undefined) ||
402 (olds?.enableConfidentialCompute ?? undefined) !==
403 (news.enableConfidentialCompute ?? undefined) ||
404 (previousSize !== undefined && nextSize < previousSize);
405
406 if (!replace) return undefined;
407 return {
408 action: "replace" as const,
409 deleteFirst:
410 previousName !== undefined &&
411 nextName === previousName &&
412 previousRegion === nextRegion,
413 };
414 }),
415
416 read: Effect.fn(function* ({ id, olds, output }) {
417 const env = yield* GcpEnvironment.current;
418 const diskName = yield* toName(id, olds?.diskName, output?.diskName);
419 const region = normalizeRegion(
420 olds?.region ?? output?.region,
421 env.region,
422 );
423 const existing = yield* getByName(env.project, region, diskName);
424 if (existing === undefined) return undefined;
425 const attrs = toAttrs(existing, env.project);
426 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
427 ? attrs
428 : Unowned(attrs);
429 }),
430
431 list: () =>
432 Effect.gen(function* () {
433 const env = yield* GcpEnvironment.current;
434 const pages = yield* compute.aggregatedListDisks
435 .pages({
436 project: env.project,
437 filter: "labels.alchemy-id:*",
438 returnPartialSuccess: true,
439 maxResults: 500,
440 })
441 .pipe(Stream.runCollect);
442 return Array.from(pages).flatMap((page) =>
443 Object.values(page.items ?? {}).flatMap((scoped) =>
444 (scoped?.disks ?? [])
445 .filter((disk) => disk.region !== undefined)
446 .filter((disk) =>
447 Object.keys(disk.labels ?? {}).some((key) =>
448 key.startsWith("alchemy-"),
449 ),
450 )
451 .map((disk) => toAttrs(disk, env.project)),
452 ),
453 );
454 }),
455
456 reconcile: Effect.fn(function* ({ id, news, output }) {
457 const env = yield* GcpEnvironment.current;
458 const diskName = yield* toName(id, news.diskName, output?.diskName);
459 const region = normalizeRegion(news.region ?? output?.region, env.region);
460 const diskType = lastSegment(news.type) ?? DEFAULT_TYPE;
461 const sizeGb = news.sizeGb ?? DEFAULT_SIZE_GB;
462 const replicaZones = normalizeReplicaZones(region, news.replicaZones);
463 const desiredLabels = {
464 ...toLabels(news.labels),
465 ...(yield* createInternalLabels(id)),
466 };
467
468 let current = yield* getByName(env.project, region, diskName);
469 if (current?.status === "DELETING") {
470 yield* waitDiskGone(env.project, region, diskName);
471 current = undefined;
472 }
473
474 if (current === undefined) {
475 const inserted = yield* compute
476 .insertRegionDisks({
477 project: env.project,
478 region,
479 body: {
480 name: diskName,
481 sizeGb: String(sizeGb),
482 type: diskTypeUrl(env.project, region, diskType),
483 replicaZones: replicaZoneUrls(env.project, replicaZones),
484 description: news.description,
485 labels: desiredLabels,
486 sourceImage: news.sourceImage,
487 sourceSnapshot: news.sourceSnapshot,
488 sourceDisk: news.sourceDisk,
489 architecture: news.architecture,
490 physicalBlockSizeBytes:
491 news.physicalBlockSizeBytes !== undefined
492 ? String(news.physicalBlockSizeBytes)
493 : undefined,
494 provisionedIops:
495 news.provisionedIops !== undefined
496 ? String(news.provisionedIops)
497 : undefined,
498 provisionedThroughput:
499 news.provisionedThroughput !== undefined
500 ? String(news.provisionedThroughput)
501 : undefined,
502 enableConfidentialCompute: news.enableConfidentialCompute,
503 },
504 })
505 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
506 if (inserted !== undefined) {
507 yield* waitRegionOperation(env.project, region, inserted, {
508 ignore: ["RESOURCE_ALREADY_EXISTS"],
509 });
510 }
511 current = yield* waitDiskReady(env.project, region, diskName);
512 }
513
514 if (current === undefined) {
515 return yield* new RegionDiskNotResolved({ diskName, region });
516 }
517
518 const observedSize = parseSizeGb(current.sizeGb);
519 if (sizeGb > observedSize) {
520 const resized = yield* compute.resizeRegionDisks({
521 project: env.project,
522 region,
523 disk: diskName,
524 body: { sizeGb: String(sizeGb) },
525 });
526 yield* waitRegionOperation(env.project, region, resized);
527 current = yield* waitDiskReady(env.project, region, diskName);
528 }
529
530 if (current === undefined) {
531 return yield* new RegionDiskNotResolved({ diskName, region });
532 }
533
534 const observedLabels = tagRecord(current.labels);
535 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
536 if (upsert.length > 0 || removed.length > 0) {
537 const labeled = yield* compute.setLabelsRegionDisks({
538 project: env.project,
539 region,
540 resource: diskName,
541 body: {
542 labels: desiredLabels,
543 labelFingerprint: current.labelFingerprint,
544 },
545 });
546 yield* waitRegionOperation(env.project, region, labeled);
547 current = yield* getByName(env.project, region, diskName);
548 }
549
550 if (current === undefined) {
551 return yield* new RegionDiskNotResolved({ diskName, region });
552 }
553
554 const descriptionChanged =
555 (current.description ?? "") !== (news.description ?? "");
556 const iopsChanged =
557 news.provisionedIops !== undefined &&
558 String(news.provisionedIops) !== (current.provisionedIops ?? "");
559 const throughputChanged =
560 news.provisionedThroughput !== undefined &&
561 String(news.provisionedThroughput) !==
562 (current.provisionedThroughput ?? "");
563
564 if (descriptionChanged || iopsChanged || throughputChanged) {
565 const updated = yield* compute.updateRegionDisks({
566 project: env.project,
567 region,
568 disk: diskName,
569 updateMask: [
570 descriptionChanged ? "description" : undefined,
571 iopsChanged ? "provisionedIops" : undefined,
572 throughputChanged ? "provisionedThroughput" : undefined,
573 ]
574 .filter((field): field is string => field !== undefined)
575 .join(","),
576 body: {
577 description: news.description,
578 provisionedIops:
579 news.provisionedIops !== undefined
580 ? String(news.provisionedIops)
581 : undefined,
582 provisionedThroughput:
583 news.provisionedThroughput !== undefined
584 ? String(news.provisionedThroughput)
585 : undefined,
586 },
587 });
588 yield* waitRegionOperation(env.project, region, updated);
589 current = yield* getByName(env.project, region, diskName);
590 }
591
592 if (current === undefined) {
593 return yield* new RegionDiskNotResolved({ diskName, region });
594 }
595
596 return toAttrs(current, env.project);
597 }),
598
599 delete: Effect.fn(function* ({ output }) {
600 const deleted = yield* compute
601 .deleteRegionDisks({
602 project: output.project,
603 region: output.region,
604 disk: output.diskName,
605 })
606 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
607 if (deleted !== undefined) {
608 yield* waitRegionOperation(output.project, output.region, deleted, {
609 ignore: ["RESOURCE_NOT_FOUND"],
610 });
611 }
612 yield* waitDiskGone(output.project, output.region, output.diskName);
613 }),
614 });