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