1import * as bigtable from "@distilled.cloud/gcp/bigtableadmin_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 clusterLocation,
13 clusterName,
14 DEFAULT_SERVE_NODES,
15 DEFAULT_ZONE,
16 lastSegment,
17 listAlchemyInstances,
18 MAX_CLUSTER_ID_LENGTH,
19 MIN_CLUSTER_ID_LENGTH,
20 parentOwned,
21 parseResourceName,
22 toPhysicalId,
23 waitForOperation,
24 zoneOf,
25 collectPages,
26} from "./operations.ts";
27
28const DEFAULT_STORAGE_TYPE = "SSD";
29
30export type AutoscalingLimits = {
31 /** Minimum nodes the autoscaler can scale down to. */
32 minServeNodes?: number;
33 /** Maximum nodes the autoscaler can scale up to. */
34 maxServeNodes?: number;
35};
36
37export type AutoscalingTargets = {
38 /**
39 * Target CPU utilization percent (`10`–`80`).
40 */
41 cpuUtilizationPercent?: number;
42 /**
43 * Target storage utilization in GiB per node. SSD default `2560`;
44 * HDD default `8192`.
45 */
46 storageUtilizationGibPerNode?: number;
47};
48
49export type ClusterAutoscalingConfig = {
50 /** Minimum and maximum node counts. */
51 autoscalingLimits?: AutoscalingLimits;
52 /** CPU and storage utilization targets. */
53 autoscalingTargets?: AutoscalingTargets;
54};
55
56export type ClusterConfig = {
57 /** Autoscaling configuration. Mutually exclusive with `serveNodes`. */
58 clusterAutoscalingConfig?: ClusterAutoscalingConfig;
59};
60
61export type EncryptionConfig = {
62 /**
63 * Cloud KMS key
64 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`.
65 * Must be in the same region as the cluster. Immutable — changing it
66 * replaces the cluster.
67 */
68 kmsKeyName?: string;
69};
70
71export type ClusterResourceProps = {
72 /**
73 * Parent instance id (the `{instance}` segment of
74 * `projects/{project}/instances/{instance}`). Full resource names are
75 * accepted and reduced to the last path segment. Immutable — changing
76 * it replaces the cluster.
77 */
78 instance: string;
79 /**
80 * Cluster id (the `{cluster}` segment of
81 * `projects/{project}/instances/{instance}/clusters/{cluster}`). If
82 * omitted, a unique name is generated from the stack, stage, and
83 * logical id. Must be 6-30 characters and match
84 * `[a-z][-a-z0-9]*`. Immutable — changing it replaces the cluster.
85 */
86 clusterId?: string;
87 /**
88 * Zone where nodes and storage reside (e.g. `us-central1-b`). Full
89 * location names `projects/{project}/locations/{zone}` are accepted.
90 * Immutable — changing it replaces the cluster.
91 * @default "us-central1-b"
92 */
93 location?: string;
94 /**
95 * Storage type used by this cluster (`SSD` or `HDD`). Immutable —
96 * changing it replaces the cluster.
97 * @default "SSD"
98 */
99 defaultStorageType?: bigtable.ClusterDefaultStorageTypeEnum | (string & {});
100 /**
101 * Manual node count. Mutually exclusive with
102 * `clusterConfig.clusterAutoscalingConfig`. Ignored while
103 * autoscaling is enabled.
104 * @default 1
105 */
106 serveNodes?: number;
107 /**
108 * Node scaling factor. Immutable — changing it replaces the cluster.
109 */
110 nodeScalingFactor?: bigtable.ClusterNodeScalingFactorEnum | (string & {});
111 /**
112 * Customer-managed encryption. Immutable — changing it replaces the
113 * cluster.
114 */
115 encryptionConfig?: EncryptionConfig;
116 /**
117 * Cluster configuration. Set `clusterAutoscalingConfig` to enable
118 * autoscaling (and omit `serveNodes`).
119 */
120 clusterConfig?: ClusterConfig;
121};
122
123export type Cluster = Resource<
124 "GCP.Bigtable.Cluster",
125 ClusterResourceProps,
126 {
127 /** Full resource name `projects/{project}/instances/{instance}/clusters/{cluster}`. */
128 name: string;
129 /** Cluster id (last path segment). */
130 clusterId: string;
131 /** Parent instance resource name. */
132 instance: string;
133 /** Parent instance id. */
134 instanceId: string;
135 /** Project id. */
136 project: string;
137 /** Zone id (`us-central1-b`, …). */
138 location: string;
139 /** Storage type (`SSD`, `HDD`). */
140 defaultStorageType: string | undefined;
141 /** Current node count. */
142 serveNodes: number | undefined;
143 /** Node scaling factor. */
144 nodeScalingFactor: string | undefined;
145 /** CMEK configuration, if any. */
146 encryptionConfig: EncryptionConfig | undefined;
147 /** Cluster configuration currently applied. */
148 clusterConfig: ClusterConfig | undefined;
149 /** Server-reported state (`READY`, `CREATING`, `RESIZING`, …). */
150 state: string | undefined;
151 },
152 never,
153 Providers
154>;
155
156/**
157 * A Cloud Bigtable cluster — a resizable group of nodes in one zone
158 * that serves every table in the parent instance.
159 *
160 * An instance always has at least one cluster (created with the
161 * instance). Use this resource to add additional clusters or to manage
162 * an existing cluster's node count and autoscaling. You cannot delete
163 * the last cluster in an instance.
164 *
165 * Changing `clusterId`, `instance`, `location`, `defaultStorageType`,
166 * `nodeScalingFactor`, or `encryptionConfig.kmsKeyName` replaces the
167 * cluster. `serveNodes` and `clusterConfig.clusterAutoscalingConfig`
168 * update in place.
169 *
170 * Clusters have no labels field. `list` enumerates clusters on
171 * alchemy-labeled instances so `pnpm nuke:gcp` can find leaked rows.
172 *
173 * Provisioning typically takes several minutes.
174 *
175 * ### Creating a Cluster
176 * **Example:** Additional cluster on an existing instance
177 * ```typescript
178 * const replica = yield* GCP.Bigtable.Cluster("Replica", {
179 * instance: instance.instanceId,
180 * location: "us-central1-b",
181 * serveNodes: 1,
182 * });
183 * ```
184 *
185 * **Example:** Explicit id and HDD storage
186 * ```typescript
187 * const replica = yield* GCP.Bigtable.Cluster("Replica", {
188 * instance: "app-instance",
189 * clusterId: "app-replica",
190 * location: "us-central1-c",
191 * defaultStorageType: "HDD",
192 * serveNodes: 1,
193 * });
194 * ```
195 *
196 * ### Autoscaling
197 * **Example:** Autoscaling between 1 and 3 nodes
198 * ```typescript
199 * const replica = yield* GCP.Bigtable.Cluster("Replica", {
200 * instance: "app-instance",
201 * location: "us-central1-f",
202 * clusterConfig: {
203 * clusterAutoscalingConfig: {
204 * autoscalingLimits: { minServeNodes: 1, maxServeNodes: 3 },
205 * autoscalingTargets: { cpuUtilizationPercent: 50 },
206 * },
207 * },
208 * });
209 * ```
210 *
211 * @resource
212 * @category Bigtable
213 */
214export const Cluster = Resource<Cluster>("GCP.Bigtable.Cluster");
215
216export class ClusterNotResolved extends Data.TaggedError(
217 "GCP.Bigtable.ClusterNotResolved",
218)<{
219 name: string;
220}> {}
221
222export class ClusterNotReady extends Data.TaggedError(
223 "GCP.Bigtable.ClusterNotReady",
224)<{
225 name: string;
226 state: string;
227}> {}
228
229export class ClusterStillExists extends Data.TaggedError(
230 "GCP.Bigtable.ClusterStillExists",
231)<{
232 name: string;
233}> {}
234
235const instanceIdOf = (value: string) => lastSegment(value);
236
237const normalizeStorage = (storage: string | undefined) => {
238 const value = (storage ?? DEFAULT_STORAGE_TYPE).toUpperCase();
239 return value === "STORAGE_TYPE_UNSPECIFIED" ? DEFAULT_STORAGE_TYPE : value;
240};
241
242const normalizeScaling = (factor: string | undefined) =>
243 (factor ?? "").toUpperCase();
244
245const encryptionOf = (
246 config: bigtable.EncryptionConfig | EncryptionConfig | undefined,
247): EncryptionConfig | undefined => {
248 const kmsKeyName = config?.kmsKeyName;
249 if (kmsKeyName === undefined || kmsKeyName.length === 0) return undefined;
250 return { kmsKeyName };
251};
252
253const encryptionKey = (
254 config: bigtable.EncryptionConfig | EncryptionConfig | undefined,
255) => encryptionOf(config)?.kmsKeyName ?? "";
256
257const autoscalingOf = (
258 config:
259 | bigtable.ClusterAutoscalingConfig
260 | ClusterAutoscalingConfig
261 | undefined,
262): ClusterAutoscalingConfig | undefined => {
263 if (config === undefined) return undefined;
264 const limits = config.autoscalingLimits;
265 const targets = config.autoscalingTargets;
266 if (limits === undefined && targets === undefined) return undefined;
267 return {
268 autoscalingLimits: limits
269 ? {
270 minServeNodes: limits.minServeNodes,
271 maxServeNodes: limits.maxServeNodes,
272 }
273 : undefined,
274 autoscalingTargets: targets
275 ? {
276 cpuUtilizationPercent: targets.cpuUtilizationPercent,
277 storageUtilizationGibPerNode: targets.storageUtilizationGibPerNode,
278 }
279 : undefined,
280 };
281};
282
283const clusterConfigOf = (
284 config: bigtable.ClusterConfig | ClusterConfig | undefined,
285): ClusterConfig | undefined => {
286 const autoscaling = autoscalingOf(config?.clusterAutoscalingConfig);
287 if (autoscaling === undefined) return undefined;
288 return { clusterAutoscalingConfig: autoscaling };
289};
290
291const autoscalingKey = (
292 config: bigtable.ClusterConfig | ClusterConfig | undefined,
293) => {
294 const autoscaling = clusterConfigOf(config)?.clusterAutoscalingConfig;
295 return JSON.stringify({
296 minServeNodes: autoscaling?.autoscalingLimits?.minServeNodes ?? null,
297 maxServeNodes: autoscaling?.autoscalingLimits?.maxServeNodes ?? null,
298 cpuUtilizationPercent:
299 autoscaling?.autoscalingTargets?.cpuUtilizationPercent ?? null,
300 storageUtilizationGibPerNode:
301 autoscaling?.autoscalingTargets?.storageUtilizationGibPerNode ?? null,
302 });
303};
304
305const toAttrs = (cluster: bigtable.Cluster, project: string) => {
306 const name = cluster.name ?? "";
307 const parsed = parseResourceName(name);
308 return {
309 name,
310 clusterId: parsed.clusterId,
311 instance: parsed.instance,
312 instanceId: parsed.instanceId,
313 project: parsed.project || project,
314 location: zoneOf(cluster.location || DEFAULT_ZONE),
315 defaultStorageType: cluster.defaultStorageType,
316 serveNodes: cluster.serveNodes,
317 nodeScalingFactor: cluster.nodeScalingFactor,
318 encryptionConfig: encryptionOf(cluster.encryptionConfig),
319 clusterConfig: clusterConfigOf(cluster.clusterConfig),
320 state: cluster.state,
321 };
322};
323
324const getByName = (name: string) =>
325 bigtable
326 .getProjectsInstancesClusters({ name })
327 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
328
329const isBusy = (state: string | undefined) =>
330 state === "CREATING" ||
331 state === "RESIZING" ||
332 state === "STATE_NOT_KNOWN" ||
333 state === undefined;
334
335const waitUntilExists = (name: string) =>
336 getByName(name).pipe(
337 Effect.flatMap((cluster) =>
338 cluster
339 ? Effect.succeed(cluster)
340 : Effect.fail(new ClusterNotResolved({ name })),
341 ),
342 Effect.retry({
343 while: (error) => error._tag === "GCP.Bigtable.ClusterNotResolved",
344 times: 8,
345 schedule: Schedule.spaced("1 second"),
346 }),
347 );
348
349const waitUntilReady = (name: string) =>
350 getByName(name).pipe(
351 Effect.filterOrFail(
352 (cluster): cluster is bigtable.Cluster => cluster !== undefined,
353 () => new ClusterNotResolved({ name }),
354 ),
355 Effect.filterOrFail(
356 (cluster) => !isBusy(cluster.state),
357 (cluster) =>
358 new ClusterNotReady({
359 name,
360 state: cluster.state ?? "STATE_NOT_KNOWN",
361 }),
362 ),
363 Effect.retry({
364 while: (error) =>
365 error._tag === "GCP.Bigtable.ClusterNotReady" ||
366 error._tag === "GCP.Bigtable.ClusterNotResolved",
367 times: 10,
368 schedule: Schedule.spaced("8 seconds"),
369 }),
370 );
371
372const waitUntilGone = (name: string) =>
373 getByName(name).pipe(
374 Effect.flatMap((cluster) =>
375 cluster === undefined
376 ? Effect.void
377 : Effect.fail(new ClusterStillExists({ name })),
378 ),
379 Effect.retry({
380 while: (error) => error._tag === "GCP.Bigtable.ClusterStillExists",
381 times: 10,
382 schedule: Schedule.spaced("8 seconds"),
383 }),
384 );
385
386const toCreateBody = (
387 news: ClusterResourceProps,
388 project: string,
389 zone: string,
390 storage: string,
391): bigtable.Cluster => {
392 const autoscaling = clusterConfigOf(news.clusterConfig);
393 return {
394 location: clusterLocation(project, zone),
395 defaultStorageType: storage,
396 nodeScalingFactor: news.nodeScalingFactor,
397 encryptionConfig: news.encryptionConfig,
398 serveNodes:
399 autoscaling === undefined
400 ? (news.serveNodes ?? DEFAULT_SERVE_NODES)
401 : undefined,
402 clusterConfig: autoscaling,
403 };
404};
405
406export const ClusterProvider = () =>
407 Provider.succeed(Cluster, {
408 stables: [
409 "name",
410 "clusterId",
411 "instance",
412 "instanceId",
413 "project",
414 "location",
415 ],
416
417 diff: Effect.fn(function* ({ news, olds, output }) {
418 if (!isResolved(news)) return undefined;
419
420 const previousId = olds?.clusterId ?? output?.clusterId;
421 const nextId = news.clusterId ?? previousId;
422 const previousInstance = instanceIdOf(
423 olds?.instance ?? output?.instance ?? output?.instanceId ?? "",
424 );
425 const nextInstance = instanceIdOf(news.instance);
426 const previousZone = zoneOf(olds?.location ?? output?.location);
427 const nextZone = zoneOf(news.location ?? output?.location);
428 const previousStorage = normalizeStorage(
429 olds?.defaultStorageType ?? output?.defaultStorageType,
430 );
431 const nextStorage = normalizeStorage(
432 news.defaultStorageType ?? output?.defaultStorageType,
433 );
434 const previousScaling = normalizeScaling(
435 olds?.nodeScalingFactor ?? output?.nodeScalingFactor,
436 );
437 const nextScaling = normalizeScaling(
438 news.nodeScalingFactor ?? output?.nodeScalingFactor,
439 );
440 const previousCmek = encryptionKey(
441 olds?.encryptionConfig ?? output?.encryptionConfig,
442 );
443 const nextCmek = encryptionKey(
444 news.encryptionConfig ?? output?.encryptionConfig,
445 );
446
447 const replace =
448 (previousId !== undefined &&
449 nextId !== undefined &&
450 nextId !== previousId) ||
451 (previousInstance.length > 0 &&
452 nextInstance.length > 0 &&
453 previousInstance !== nextInstance) ||
454 previousZone !== nextZone ||
455 previousStorage !== nextStorage ||
456 (previousScaling.length > 0 &&
457 nextScaling.length > 0 &&
458 previousScaling !== nextScaling) ||
459 previousCmek !== nextCmek;
460
461 if (!replace) return undefined;
462 return {
463 action: "replace" as const,
464 deleteFirst:
465 previousInstance === nextInstance &&
466 previousId !== undefined &&
467 nextId === previousId,
468 };
469 }),
470
471 read: Effect.fn(function* ({ id, olds, output }) {
472 const env = yield* GcpEnvironment.current;
473 const instanceId = instanceIdOf(
474 olds?.instance ?? output?.instance ?? output?.instanceId ?? "",
475 );
476 if (instanceId.length === 0) return undefined;
477 const clusterId = yield* toPhysicalId(
478 id,
479 olds?.clusterId,
480 output?.clusterId,
481 MAX_CLUSTER_ID_LENGTH,
482 MIN_CLUSTER_ID_LENGTH,
483 );
484 const name =
485 output?.name ?? clusterName(env.project, instanceId, clusterId);
486 const existing = yield* getByName(name);
487 if (existing === undefined) return undefined;
488 const attrs = toAttrs(existing, env.project);
489 return (yield* parentOwned(attrs.instance)) ? attrs : Unowned(attrs);
490 }),
491
492 list: () =>
493 Effect.gen(function* () {
494 const env = yield* GcpEnvironment.current;
495 const instances = yield* listAlchemyInstances(env.project);
496 const labeled = new Set(
497 instances
498 .map((instance) => instance.name ?? "")
499 .filter((name) => name.length > 0),
500 );
501 if (labeled.size === 0) return [];
502 const clusters = yield* collectPages(
503 bigtable.listProjectsInstancesClusters.pages({
504 parent: `projects/${env.project}/instances/-`,
505 }),
506 (page) => page.clusters,
507 );
508 return clusters
509 .filter((cluster) =>
510 labeled.has(parseResourceName(cluster.name ?? "").instance),
511 )
512 .map((cluster) => toAttrs(cluster, env.project));
513 }),
514
515 reconcile: Effect.fn(function* ({ id, news, output }) {
516 const env = yield* GcpEnvironment.current;
517 const instanceId = instanceIdOf(news.instance);
518 const clusterId = yield* toPhysicalId(
519 id,
520 news.clusterId,
521 output?.clusterId,
522 MAX_CLUSTER_ID_LENGTH,
523 MIN_CLUSTER_ID_LENGTH,
524 );
525 const zone = zoneOf(news.location ?? output?.location);
526 const storage = normalizeStorage(news.defaultStorageType);
527 const name = clusterName(env.project, instanceId, clusterId);
528 const desiredAutoscaling = clusterConfigOf(news.clusterConfig);
529
530 let current = yield* getByName(output?.name ?? name);
531
532 if (current === undefined) {
533 const created = yield* bigtable
534 .createProjectsInstancesClusters({
535 parent: `projects/${env.project}/instances/${instanceId}`,
536 clusterId,
537 body: toCreateBody(news, env.project, zone, storage),
538 })
539 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
540 if (created !== undefined) {
541 yield* waitForOperation(created, { alreadyExistsOk: true });
542 }
543 current = yield* waitUntilExists(name);
544 }
545
546 if (isBusy(current.state)) {
547 current = yield* waitUntilReady(name);
548 }
549
550 const observedAutoscaling = clusterConfigOf(current.clusterConfig);
551 const autoscalingChanged =
552 desiredAutoscaling !== undefined &&
553 autoscalingKey(current.clusterConfig) !==
554 autoscalingKey(news.clusterConfig);
555 const disableAutoscaling =
556 desiredAutoscaling === undefined &&
557 news.serveNodes !== undefined &&
558 observedAutoscaling !== undefined;
559 const serveNodesChanged =
560 desiredAutoscaling === undefined &&
561 news.serveNodes !== undefined &&
562 (current.serveNodes ?? DEFAULT_SERVE_NODES) !== news.serveNodes;
563
564 if (autoscalingChanged) {
565 const patched =
566 yield* bigtable.partialUpdateClusterProjectsInstancesClusters({
567 name,
568 updateMask: "cluster_config.cluster_autoscaling_config",
569 body: {
570 name,
571 clusterConfig: desiredAutoscaling,
572 },
573 });
574 yield* waitForOperation(patched);
575 current = yield* waitUntilReady(name);
576 } else if (disableAutoscaling || serveNodesChanged) {
577 const serveNodes =
578 news.serveNodes ?? current.serveNodes ?? DEFAULT_SERVE_NODES;
579 if (disableAutoscaling) {
580 const patched =
581 yield* bigtable.partialUpdateClusterProjectsInstancesClusters({
582 name,
583 updateMask:
584 "serve_nodes,cluster_config.cluster_autoscaling_config",
585 body: {
586 name,
587 serveNodes,
588 clusterConfig: {},
589 },
590 });
591 yield* waitForOperation(patched);
592 } else {
593 const updated = yield* bigtable.updateProjectsInstancesClusters({
594 name,
595 body: {
596 name,
597 location: current.location ?? clusterLocation(env.project, zone),
598 serveNodes,
599 defaultStorageType: current.defaultStorageType ?? storage,
600 },
601 });
602 yield* waitForOperation(updated);
603 }
604 current = yield* waitUntilReady(name);
605 }
606
607 return toAttrs(current, env.project);
608 }),
609
610 delete: Effect.fn(function* ({ output }) {
611 const result = yield* bigtable
612 .deleteProjectsInstancesClusters({ name: output.name })
613 .pipe(
614 Effect.retry({
615 while: (error) => error._tag === "Conflict",
616 times: 8,
617 schedule: Schedule.spaced("5 seconds"),
618 }),
619 Effect.as("deleted" as const),
620 Effect.catchTag("NotFound", () => Effect.succeed("deleted" as const)),
621 // The instance's last cluster goes away with the instance.
622 Effect.catchTag("LastClusterDeletion", () =>
623 Effect.succeed("kept" as const),
624 ),
625 );
626 if (result === "deleted") {
627 yield* waitUntilGone(output.name);
628 }
629 }),
630 });