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
23const DEFAULT_ZONE = "us-central1-a";
24const DEFAULT_TYPE = "hyperdisk-balanced";
25const MAX_NAME_LENGTH = 63;
26
27export type StoragePoolCapacityProvisioningType =
28 | compute.StoragePoolCapacityProvisioningTypeEnum
29 | (string & {});
30export type StoragePoolPerformanceProvisioningType =
31 | compute.StoragePoolPerformanceProvisioningTypeEnum
32 | (string & {});
33
34export type StoragePoolProps = {
35 /**
36 * Storage pool name (RFC1035, 1-63 characters). If omitted, a unique
37 * name is generated from the stack, stage, and logical id. Changing the
38 * name replaces the pool.
39 */
40 storagePoolName?: string;
41 /**
42 * Zone the pool lives in (e.g. `us-central1-a`). Immutable — changing it
43 * replaces the pool.
44 * @default "us-central1-a"
45 */
46 zone?: string;
47 /**
48 * Storage pool type (`hyperdisk-balanced`, `hyperdisk-throughput`,
49 * `hyperdisk-extreme`, …). Immutable — changing it replaces the pool.
50 * @default "hyperdisk-balanced"
51 */
52 storagePoolType?: string;
53 /**
54 * Optional description.
55 */
56 description?: string;
57 /**
58 * User labels. Alchemy ownership labels are merged in automatically.
59 */
60 labels?: Record<string, string>;
61 /**
62 * Provisioned pool capacity in GiB. Mutable in place. Hyperdisk Balanced
63 * pools typically require at least 10240 GiB.
64 */
65 poolProvisionedCapacityGb?: number | string;
66 /**
67 * Provisioned IOPS (hyperdisk-balanced). Mutable in place.
68 */
69 poolProvisionedIops?: number | string;
70 /**
71 * Provisioned throughput in MiB/s (hyperdisk-balanced /
72 * hyperdisk-throughput). Mutable in place.
73 */
74 poolProvisionedThroughput?: number | string;
75 /**
76 * Capacity provisioning type. Immutable — changing it replaces the pool.
77 */
78 capacityProvisioningType?: StoragePoolCapacityProvisioningType;
79 /**
80 * Performance provisioning type. Immutable — changing it replaces the
81 * pool.
82 */
83 performanceProvisioningType?: StoragePoolPerformanceProvisioningType;
84};
85
86export type StoragePool = Resource<
87 "GCP.Compute.StoragePool",
88 StoragePoolProps,
89 {
90 /** Storage pool name. */
91 storagePoolName: string;
92 /** Server-assigned numeric id. */
93 storagePoolId: string | undefined;
94 /** Project id. */
95 project: string;
96 /** Zone short name (`us-central1-a`). */
97 zone: string;
98 /** Storage pool type short name. */
99 storagePoolType: string | undefined;
100 /** Optional description. */
101 description: string | undefined;
102 /** User labels (Alchemy ownership labels stripped). */
103 labels: Record<string, string>;
104 /** Provisioned capacity in GiB. */
105 poolProvisionedCapacityGb: string | undefined;
106 /** Provisioned IOPS. */
107 poolProvisionedIops: string | undefined;
108 /** Provisioned throughput in MiB/s. */
109 poolProvisionedThroughput: string | undefined;
110 /** Capacity provisioning type. */
111 capacityProvisioningType: string | undefined;
112 /** Performance provisioning type. */
113 performanceProvisioningType: string | undefined;
114 /** Server-reported state (`READY`, `CREATING`, …). */
115 state: string | undefined;
116 /** Compute Engine self-link. */
117 selfLink: string | undefined;
118 /** RFC3339 creation timestamp. */
119 creationTimestamp: string | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * A zonal Compute Engine Hyperdisk storage pool.
127 *
128 * Storage pools pre-purchase Hyperdisk capacity, IOPS, and throughput.
129 * Changing `zone`, `storagePoolType`, `capacityProvisioningType`, or
130 * `performanceProvisioningType` replaces the pool. Capacity, IOPS,
131 * throughput, labels, and description update in place.
132 *
133 * ### Creating a Storage Pool
134 * **Example:** Hyperdisk Balanced pool
135 * ```typescript
136 * const pool = yield* GCP.Compute.StoragePool("disks", {
137 * storagePoolType: "hyperdisk-balanced",
138 * poolProvisionedCapacityGb: 10240,
139 * poolProvisionedIops: 10000,
140 * poolProvisionedThroughput: 1024,
141 * labels: { env: "prod" },
142 * });
143 * ```
144 *
145 * **Example:** Named pool in a specific zone
146 * ```typescript
147 * const pool = yield* GCP.Compute.StoragePool("disks", {
148 * storagePoolName: "app-hyperdisk",
149 * zone: "us-central1-a",
150 * description: "shared hyperdisk capacity",
151 * poolProvisionedCapacityGb: 10240,
152 * });
153 * ```
154 *
155 * @resource
156 * @category Compute
157 */
158export const StoragePool = Resource<StoragePool>("GCP.Compute.StoragePool");
159
160export class StoragePoolNotResolved extends Data.TaggedError(
161 "GCP.Compute.StoragePoolNotResolved",
162)<{
163 storagePoolName: string;
164 zone: string;
165}> {}
166
167export class StoragePoolNotReady extends Data.TaggedError(
168 "GCP.Compute.StoragePoolNotReady",
169)<{
170 storagePoolName: string;
171 status: string;
172}> {}
173
174export class StoragePoolFailed extends Data.TaggedError(
175 "GCP.Compute.StoragePoolFailed",
176)<{
177 storagePoolName: string;
178 status: string;
179}> {}
180
181export class StoragePoolStillExists extends Data.TaggedError(
182 "GCP.Compute.StoragePoolStillExists",
183)<{
184 storagePoolName: string;
185 status: string;
186}> {}
187
188const lastSegment = (value: string | undefined): string => {
189 if (value === undefined || value.length === 0) return "";
190 const trimmed = value.replace(/\/+$/, "");
191 const parts = trimmed.split("/");
192 return parts[parts.length - 1] || trimmed;
193};
194
195const normalizeZone = (zone: string | undefined) =>
196 lastSegment(zone ?? DEFAULT_ZONE).toLowerCase();
197
198const asString = (value: number | string | undefined): string | undefined => {
199 if (value === undefined) return undefined;
200 return String(value);
201};
202
203const userLabels = (
204 labels: Record<string, string | undefined> | null | undefined,
205): Record<string, string> => stripInternalLabels(tagRecord(labels));
206
207const toName = (id: string, name: string | undefined, existing?: string) =>
208 Effect.gen(function* () {
209 if (name !== undefined) return name;
210 if (existing !== undefined) return existing;
211 const generated = yield* createPhysicalName({
212 id,
213 maxLength: MAX_NAME_LENGTH,
214 lowercase: true,
215 });
216 return /^[a-z]/.test(generated)
217 ? generated
218 : `s${generated}`.slice(0, MAX_NAME_LENGTH);
219 });
220
221const poolTypeUrl = (project: string, zone: string, type: string) =>
222 type.includes("/")
223 ? type
224 : `projects/${project}/zones/${zone}/storagePoolTypes/${type}`;
225
226const toAttrs = (
227 pool: compute.StoragePool,
228 project: string,
229): StoragePool["Attributes"] => ({
230 storagePoolName: pool.name ?? pool.id ?? "",
231 storagePoolId: pool.id,
232 project,
233 zone: normalizeZone(pool.zone),
234 storagePoolType: lastSegment(pool.storagePoolType) || undefined,
235 description: pool.description,
236 labels: userLabels(pool.labels),
237 poolProvisionedCapacityGb: pool.poolProvisionedCapacityGb,
238 poolProvisionedIops: pool.poolProvisionedIops,
239 poolProvisionedThroughput: pool.poolProvisionedThroughput,
240 capacityProvisioningType: pool.capacityProvisioningType,
241 performanceProvisioningType: pool.performanceProvisioningType,
242 state: pool.state,
243 selfLink: pool.selfLink,
244 creationTimestamp: pool.creationTimestamp,
245});
246
247const getByName = (project: string, zone: string, storagePool: string) =>
248 compute
249 .getStoragePools({ project, zone, storagePool })
250 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
251
252const waitPoolReady = (
253 project: string,
254 zone: string,
255 storagePoolName: string,
256) =>
257 getByName(project, zone, storagePoolName).pipe(
258 Effect.flatMap((pool) =>
259 pool?.state === "FAILED"
260 ? Effect.fail(
261 new StoragePoolFailed({
262 storagePoolName,
263 status: "FAILED",
264 }),
265 )
266 : Effect.succeed(pool),
267 ),
268 Effect.filterOrFail(
269 (pool): pool is compute.StoragePool =>
270 pool !== undefined && pool.state === "READY",
271 (pool) =>
272 new StoragePoolNotReady({
273 storagePoolName,
274 status: pool?.state ?? "MISSING",
275 }),
276 ),
277 Effect.retry({
278 while: (error) => error._tag === "GCP.Compute.StoragePoolNotReady",
279 times: 10,
280 schedule: Schedule.spaced("2 seconds"),
281 }),
282 );
283
284const waitPoolGone = (project: string, zone: string, storagePoolName: string) =>
285 getByName(project, zone, storagePoolName).pipe(
286 Effect.flatMap((pool) =>
287 pool === undefined
288 ? Effect.void
289 : Effect.fail(
290 new StoragePoolStillExists({
291 storagePoolName,
292 status: pool.state ?? "UNKNOWN",
293 }),
294 ),
295 ),
296 Effect.retry({
297 while: (error) => error instanceof StoragePoolStillExists,
298 times: 10,
299 schedule: Schedule.spaced("2 seconds"),
300 }),
301 );
302
303export const StoragePoolProvider = () =>
304 Provider.succeed(StoragePool, {
305 stables: [
306 "storagePoolName",
307 "storagePoolId",
308 "project",
309 "zone",
310 "storagePoolType",
311 "selfLink",
312 "creationTimestamp",
313 ],
314
315 diff: Effect.fn(function* ({ news, olds, output }) {
316 if (!isResolved(news)) return undefined;
317
318 const previousName = olds?.storagePoolName ?? output?.storagePoolName;
319 const nextName = news.storagePoolName ?? previousName;
320 const nameChanged =
321 previousName !== undefined &&
322 nextName !== undefined &&
323 previousName !== nextName;
324
325 const previousZone = normalizeZone(olds?.zone ?? output?.zone);
326 const nextZone = normalizeZone(news.zone ?? output?.zone);
327 const previousType =
328 lastSegment(olds?.storagePoolType) ||
329 lastSegment(output?.storagePoolType) ||
330 DEFAULT_TYPE;
331 const nextType = lastSegment(news.storagePoolType) || DEFAULT_TYPE;
332 const previousCapacityType =
333 olds?.capacityProvisioningType ?? output?.capacityProvisioningType;
334 const nextCapacityType = news.capacityProvisioningType;
335 const previousPerfType =
336 olds?.performanceProvisioningType ??
337 output?.performanceProvisioningType;
338 const nextPerfType = news.performanceProvisioningType;
339
340 const typeChanged = previousType !== nextType;
341 const capacityTypeChanged =
342 previousCapacityType !== undefined &&
343 nextCapacityType !== undefined &&
344 previousCapacityType !== nextCapacityType;
345 const perfTypeChanged =
346 previousPerfType !== undefined &&
347 nextPerfType !== undefined &&
348 previousPerfType !== nextPerfType;
349
350 if (
351 nameChanged ||
352 previousZone !== nextZone ||
353 typeChanged ||
354 capacityTypeChanged ||
355 perfTypeChanged
356 ) {
357 return {
358 action: "replace" as const,
359 deleteFirst:
360 previousName !== undefined &&
361 nextName === previousName &&
362 previousZone === nextZone,
363 };
364 }
365 return undefined;
366 }),
367
368 read: Effect.fn(function* ({ id, olds, output }) {
369 const env = yield* GcpEnvironment.current;
370 const storagePoolName = yield* toName(
371 id,
372 olds?.storagePoolName,
373 output?.storagePoolName,
374 );
375 const zone = normalizeZone(olds?.zone ?? output?.zone);
376 const existing = yield* getByName(env.project, zone, storagePoolName);
377 if (existing === undefined) return undefined;
378 const attrs = toAttrs(existing, env.project);
379 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
380 ? attrs
381 : Unowned(attrs);
382 }),
383
384 list: () =>
385 Effect.gen(function* () {
386 const env = yield* GcpEnvironment.current;
387 const pages = yield* compute.aggregatedListStoragePools
388 .pages({
389 project: env.project,
390 filter: "labels.alchemy-id:*",
391 returnPartialSuccess: true,
392 maxResults: 500,
393 })
394 .pipe(Stream.runCollect);
395 return Array.from(pages).flatMap((page) =>
396 Object.values(page.items ?? {}).flatMap((scoped) =>
397 (scoped?.storagePools ?? [])
398 .filter((pool) =>
399 Object.keys(pool.labels ?? {}).some((key) =>
400 key.startsWith("alchemy-"),
401 ),
402 )
403 .map((pool) => toAttrs(pool, env.project)),
404 ),
405 );
406 }),
407
408 reconcile: Effect.fn(function* ({ id, news, output }) {
409 const env = yield* GcpEnvironment.current;
410 const storagePoolName = yield* toName(
411 id,
412 news.storagePoolName,
413 output?.storagePoolName,
414 );
415 const zone = normalizeZone(news.zone ?? output?.zone);
416 const storagePoolType = lastSegment(news.storagePoolType) || DEFAULT_TYPE;
417 const desiredLabels = {
418 ...toLabels(news.labels),
419 ...(yield* createInternalLabels(id)),
420 };
421 const capacity = asString(news.poolProvisionedCapacityGb);
422 const iops = asString(news.poolProvisionedIops);
423 const throughput = asString(news.poolProvisionedThroughput);
424
425 let current = yield* getByName(env.project, zone, storagePoolName);
426 if (current?.state === "DELETING") {
427 yield* waitPoolGone(env.project, zone, storagePoolName);
428 current = undefined;
429 }
430
431 if (current === undefined) {
432 const inserted = yield* compute
433 .insertStoragePools({
434 project: env.project,
435 zone,
436 body: {
437 name: storagePoolName,
438 description: news.description,
439 labels: desiredLabels,
440 storagePoolType: poolTypeUrl(env.project, zone, storagePoolType),
441 poolProvisionedCapacityGb: capacity,
442 poolProvisionedIops: iops,
443 poolProvisionedThroughput: throughput,
444 capacityProvisioningType: news.capacityProvisioningType,
445 performanceProvisioningType: news.performanceProvisioningType,
446 },
447 })
448 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
449 if (inserted !== undefined) {
450 yield* waitZoneOperation(env.project, zone, inserted, {
451 ignore: ["RESOURCE_ALREADY_EXISTS"],
452 });
453 }
454 current = yield* waitPoolReady(env.project, zone, storagePoolName);
455 }
456
457 if (current === undefined) {
458 return yield* new StoragePoolNotResolved({
459 storagePoolName,
460 zone,
461 });
462 }
463
464 if (current.state === "CREATING") {
465 current = yield* waitPoolReady(env.project, zone, storagePoolName);
466 }
467
468 if (current === undefined) {
469 return yield* new StoragePoolNotResolved({
470 storagePoolName,
471 zone,
472 });
473 }
474
475 const observedLabels = tagRecord(current.labels);
476 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
477 const descriptionChanged =
478 (current.description ?? "") !== (news.description ?? "");
479 const capacityChanged =
480 capacity !== undefined &&
481 capacity !== (current.poolProvisionedCapacityGb ?? "");
482 const iopsChanged =
483 iops !== undefined && iops !== (current.poolProvisionedIops ?? "");
484 const throughputChanged =
485 throughput !== undefined &&
486 throughput !== (current.poolProvisionedThroughput ?? "");
487 const labelsChanged = upsert.length > 0 || removed.length > 0;
488
489 if (
490 descriptionChanged ||
491 capacityChanged ||
492 iopsChanged ||
493 throughputChanged ||
494 labelsChanged
495 ) {
496 const patched = yield* compute.updateStoragePools({
497 project: env.project,
498 zone,
499 storagePool: storagePoolName,
500 updateMask: [
501 descriptionChanged ? "description" : undefined,
502 capacityChanged ? "poolProvisionedCapacityGb" : undefined,
503 iopsChanged ? "poolProvisionedIops" : undefined,
504 throughputChanged ? "poolProvisionedThroughput" : undefined,
505 labelsChanged ? "labels" : undefined,
506 ]
507 .filter((field): field is string => field !== undefined)
508 .join(","),
509 body: {
510 description: news.description,
511 poolProvisionedCapacityGb: capacity,
512 poolProvisionedIops: iops,
513 poolProvisionedThroughput: throughput,
514 labels: desiredLabels,
515 labelFingerprint: current.labelFingerprint,
516 },
517 });
518 yield* waitZoneOperation(env.project, zone, patched);
519 current =
520 (yield* getByName(env.project, zone, storagePoolName)) ??
521 (yield* waitPoolReady(env.project, zone, storagePoolName));
522 }
523
524 if (current === undefined) {
525 return yield* new StoragePoolNotResolved({
526 storagePoolName,
527 zone,
528 });
529 }
530
531 return toAttrs(current, env.project);
532 }),
533
534 delete: Effect.fn(function* ({ output }) {
535 const env = yield* GcpEnvironment.current;
536 const project = output.project || env.project;
537 const zone = normalizeZone(output.zone);
538 const deleted = yield* compute
539 .deleteStoragePools({
540 project,
541 zone,
542 storagePool: output.storagePoolName,
543 })
544 .pipe(
545 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
546 Effect.retry({
547 while: (error) => error._tag === "Conflict",
548 times: 8,
549 schedule: Schedule.spaced("2 seconds"),
550 }),
551 );
552 if (deleted !== undefined) {
553 yield* waitZoneOperation(project, zone, deleted, {
554 ignore: ["RESOURCE_NOT_FOUND"],
555 });
556 }
557 yield* waitPoolGone(project, zone, output.storagePoolName);
558 }),
559 });