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 { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 stripInternalLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 clusterLocation,
21 DEFAULT_CLUSTER_ID,
22 DEFAULT_EDITION,
23 DEFAULT_INSTANCE_TYPE,
24 DEFAULT_SERVE_NODES,
25 DEFAULT_STORAGE,
26 DEFAULT_ZONE,
27 getInstanceByName,
28 instanceName,
29 lastSegment,
30 listAlchemyInstances,
31 MAX_INSTANCE_ID_LENGTH,
32 MIN_INSTANCE_ID_LENGTH,
33 parseResourceName,
34 toPhysicalId,
35 waitForOperation,
36} from "./operations.ts";
37
38export type ClusterAutoscalingLimits = {
39 /** Minimum nodes the autoscaler can scale down to. */
40 minServeNodes?: number;
41 /** Maximum nodes the autoscaler can scale up to. */
42 maxServeNodes?: number;
43};
44
45export type ClusterAutoscalingTargets = {
46 /** Target CPU utilization percent (`10`–`80`). */
47 cpuUtilizationPercent?: number;
48 /** Target storage utilization in GiB per node. */
49 storageUtilizationGibPerNode?: number;
50};
51
52export type InstanceClusterSpec = {
53 /**
54 * Zone (`us-central1-b`) or full location
55 * (`projects/{project}/locations/{zone}`). Immutable after create.
56 * @default "us-central1-b"
57 */
58 location?: string;
59 /**
60 * Manual node count. Mutually exclusive with autoscaling.
61 * @default 1
62 */
63 serveNodes?: number;
64 /**
65 * Storage type. Immutable after create.
66 * @default "HDD"
67 */
68 defaultStorageType?: bigtable.ClusterDefaultStorageTypeEnum | (string & {});
69 /**
70 * Customer-managed KMS key
71 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
72 * Immutable.
73 */
74 kmsKeyName?: string;
75 /**
76 * Node scaling factor. Immutable.
77 */
78 nodeScalingFactor?: bigtable.ClusterNodeScalingFactorEnum | (string & {});
79 /** Autoscaling limits. Mutually exclusive with `serveNodes`. */
80 autoscalingLimits?: ClusterAutoscalingLimits;
81 /** Autoscaling targets. Used with `autoscalingLimits`. */
82 autoscalingTargets?: ClusterAutoscalingTargets;
83};
84
85export type InstanceProps = {
86 /**
87 * Instance id (the `{instance}` segment of
88 * `projects/{project}/instances/{instance}`). If omitted, a unique name
89 * is generated from the stack, stage, and logical id. Must be 6-33
90 * characters, start with a letter, and contain only lowercase letters,
91 * numbers, and hyphens. Immutable — changing it replaces the instance.
92 */
93 instanceId?: string;
94 /**
95 * User-facing display name. Defaults to the instance id.
96 */
97 displayName?: string;
98 /**
99 * Instance type. `DEVELOPMENT` is deprecated; new instances should use
100 * `PRODUCTION`.
101 * @default "PRODUCTION"
102 */
103 type?: bigtable.InstanceTypeEnum | (string & {});
104 /**
105 * Instance edition.
106 * @default "ENTERPRISE"
107 */
108 edition?: bigtable.InstanceEditionEnum | (string & {});
109 /**
110 * User labels. Alchemy ownership labels are merged in automatically.
111 */
112 labels?: Record<string, string>;
113 /**
114 * Clusters created with the instance. At least one is required by the
115 * API. Subsequent cluster changes belong on {@link Cluster}. Keys are
116 * cluster ids.
117 * @default { cluster: { location: "us-central1-b", serveNodes: 1, defaultStorageType: "HDD" } }
118 */
119 clusters?: Record<string, InstanceClusterSpec>;
120};
121
122export type Instance = Resource<
123 "GCP.Bigtable.Instance",
124 InstanceProps,
125 {
126 /** Full resource name `projects/{project}/instances/{instance}`. */
127 name: string;
128 /** Instance id (last path segment). */
129 instanceId: string;
130 /** Project id. */
131 project: string;
132 /** User-facing display name. */
133 displayName: string | undefined;
134 /** Instance type (`PRODUCTION`, `DEVELOPMENT`). */
135 type: string;
136 /** Instance edition (`ENTERPRISE`, `ENTERPRISE_PLUS`). */
137 edition: string | undefined;
138 /** User labels (Alchemy ownership labels stripped). */
139 labels: Record<string, string>;
140 /** Server-reported state (`READY`, `CREATING`, …). */
141 state: string | undefined;
142 /** RFC3339 creation timestamp. */
143 createTime: string | undefined;
144 },
145 never,
146 Providers
147>;
148
149/**
150 * A Cloud Bigtable instance — a container for clusters, tables, and app
151 * profiles.
152 *
153 * Create requires at least one cluster. The default is a single 1-node
154 * HDD cluster in `us-central1-b`. Changing `instanceId` replaces the
155 * instance. `displayName`, `type`, `edition`, and `labels` update in
156 * place. Cluster create-time fields (`location`, storage type, CMEK) are
157 * immutable; manage clusters after create with GCP.Bigtable.Cluster.
158 *
159 * Provisioning typically takes one to two minutes.
160 *
161 * ### Creating an Instance
162 * **Example:** Generated name, 1-node HDD cluster
163 * ```typescript
164 * const instance = yield* GCP.Bigtable.Instance("Data", {});
165 * ```
166 *
167 * **Example:** Explicit id, labels, and cluster
168 * ```typescript
169 * const instance = yield* GCP.Bigtable.Instance("Data", {
170 * instanceId: "app-bt",
171 * displayName: "app bigtable",
172 * type: "PRODUCTION",
173 * labels: { env: "prod" },
174 * clusters: {
175 * cluster: {
176 * location: "us-central1-b",
177 * serveNodes: 1,
178 * defaultStorageType: "HDD",
179 * },
180 * },
181 * });
182 * ```
183 *
184 * ### Observing Instances
185 * **Example:** Read the bound instance
186 * ```typescript
187 * const getInstance = yield* GCP.Bigtable.GetInstance(instance);
188 * const live = yield* getInstance();
189 * ```
190 *
191 * @resource
192 * @category Bigtable
193 */
194export const Instance = Resource<Instance>("GCP.Bigtable.Instance");
195
196export class InstanceNotResolved extends Data.TaggedError(
197 "GCP.Bigtable.InstanceNotResolved",
198)<{
199 name: string;
200}> {}
201
202export class InstanceNotReady extends Data.TaggedError(
203 "GCP.Bigtable.InstanceNotReady",
204)<{
205 name: string;
206 state: string;
207}> {}
208
209export class InstanceStillExists extends Data.TaggedError(
210 "GCP.Bigtable.InstanceStillExists",
211)<{
212 name: string;
213}> {}
214
215const normalizeType = (type: string | undefined) => {
216 const value = (type ?? DEFAULT_INSTANCE_TYPE).toUpperCase();
217 return value === "TYPE_UNSPECIFIED" ? DEFAULT_INSTANCE_TYPE : value;
218};
219
220const normalizeEdition = (edition: string | undefined) => {
221 const value = (edition ?? DEFAULT_EDITION).toUpperCase();
222 return value === "EDITION_UNSPECIFIED" ? DEFAULT_EDITION : value;
223};
224
225const userLabels = (
226 labels: Record<string, string | undefined> | null | undefined,
227): Record<string, string> => stripInternalLabels(tagRecord(labels));
228
229const toId = (id: string, instanceId: string | undefined, existing?: string) =>
230 toPhysicalId(
231 id,
232 instanceId,
233 existing,
234 MAX_INSTANCE_ID_LENGTH,
235 MIN_INSTANCE_ID_LENGTH,
236 );
237
238const defaultClusters = (): Record<string, InstanceClusterSpec> => ({
239 [DEFAULT_CLUSTER_ID]: {
240 location: DEFAULT_ZONE,
241 serveNodes: DEFAULT_SERVE_NODES,
242 defaultStorageType: DEFAULT_STORAGE,
243 },
244});
245
246const toClusterBody = (
247 project: string,
248 spec: InstanceClusterSpec,
249): bigtable.Cluster => {
250 const location = clusterLocation(project, spec.location ?? DEFAULT_ZONE);
251 const storage = (spec.defaultStorageType ?? DEFAULT_STORAGE).toUpperCase();
252 const body: bigtable.Cluster = {
253 location,
254 defaultStorageType: storage,
255 nodeScalingFactor: spec.nodeScalingFactor,
256 encryptionConfig: spec.kmsKeyName
257 ? { kmsKeyName: spec.kmsKeyName }
258 : undefined,
259 };
260 if (spec.autoscalingLimits !== undefined) {
261 body.clusterConfig = {
262 clusterAutoscalingConfig: {
263 autoscalingLimits: spec.autoscalingLimits,
264 autoscalingTargets: spec.autoscalingTargets,
265 },
266 };
267 } else {
268 body.serveNodes = spec.serveNodes ?? DEFAULT_SERVE_NODES;
269 }
270 return body;
271};
272
273const toAttrs = (instance: bigtable.Instance, project: string) => {
274 const name = instance.name ?? "";
275 const parsed = parseResourceName(name);
276 return {
277 name,
278 instanceId: parsed.instanceId || lastSegment(name),
279 project: parsed.project || project,
280 displayName: instance.displayName,
281 type: normalizeType(instance.type),
282 edition: instance.edition,
283 labels: userLabels(instance.labels),
284 state: instance.state,
285 createTime: instance.createTime,
286 };
287};
288
289const waitUntilExists = (name: string) =>
290 getInstanceByName(name).pipe(
291 Effect.flatMap((instance) =>
292 instance
293 ? Effect.succeed(instance)
294 : Effect.fail(new InstanceNotResolved({ name })),
295 ),
296 Effect.retry({
297 while: (error) => error._tag === "GCP.Bigtable.InstanceNotResolved",
298 times: 8,
299 schedule: Schedule.spaced("1 second"),
300 }),
301 );
302
303const waitUntilReady = (name: string) =>
304 getInstanceByName(name).pipe(
305 Effect.filterOrFail(
306 (instance): instance is bigtable.Instance => instance !== undefined,
307 () => new InstanceNotResolved({ name }),
308 ),
309 Effect.filterOrFail(
310 (instance) => (instance.state ?? "STATE_NOT_KNOWN") === "READY",
311 (instance) =>
312 new InstanceNotReady({
313 name,
314 state: instance.state ?? "STATE_NOT_KNOWN",
315 }),
316 ),
317 Effect.retry({
318 while: (error) =>
319 error._tag === "GCP.Bigtable.InstanceNotReady" ||
320 error._tag === "GCP.Bigtable.InstanceNotResolved",
321 times: 10,
322 schedule: Schedule.spaced("8 seconds"),
323 }),
324 );
325
326const waitUntilGone = (name: string) =>
327 getInstanceByName(name).pipe(
328 Effect.flatMap((instance) =>
329 instance === undefined
330 ? Effect.void
331 : Effect.fail(new InstanceStillExists({ name })),
332 ),
333 Effect.retry({
334 while: (error) => error._tag === "GCP.Bigtable.InstanceStillExists",
335 times: 10,
336 schedule: Schedule.spaced("8 seconds"),
337 }),
338 );
339
340export const InstanceProvider = () =>
341 Provider.succeed(Instance, {
342 stables: ["name", "instanceId", "project", "createTime"],
343
344 diff: Effect.fn(function* ({ news, olds, output }) {
345 if (!isResolved(news)) return undefined;
346 const previousId = olds?.instanceId ?? output?.instanceId;
347 const nextId = news.instanceId ?? previousId;
348 if (
349 previousId !== undefined &&
350 nextId !== undefined &&
351 previousId !== nextId
352 ) {
353 return { action: "replace" as const };
354 }
355 return undefined;
356 }),
357
358 read: Effect.fn(function* ({ id, olds, output }) {
359 const env = yield* GcpEnvironment.current;
360 const instanceId = yield* toId(id, olds?.instanceId, output?.instanceId);
361 const name = output?.name ?? instanceName(env.project, instanceId);
362 const existing = yield* getInstanceByName(name);
363 if (existing === undefined) return undefined;
364 const attrs = toAttrs(existing, env.project);
365 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
366 ? attrs
367 : Unowned(attrs);
368 }),
369
370 list: () =>
371 Effect.gen(function* () {
372 const env = yield* GcpEnvironment.current;
373 const instances = yield* listAlchemyInstances(env.project);
374 return instances.map((instance) => toAttrs(instance, env.project));
375 }),
376
377 reconcile: Effect.fn(function* ({ id, news, output }) {
378 const env = yield* GcpEnvironment.current;
379 const instanceId = yield* toId(id, news.instanceId, output?.instanceId);
380 const name = instanceName(env.project, instanceId);
381 const type = normalizeType(news.type ?? output?.type);
382 const edition = normalizeEdition(news.edition ?? output?.edition);
383 // Bigtable display names must be 4-30 characters.
384 const displayName = news.displayName ?? instanceId.slice(0, 30);
385 const desiredLabels = {
386 ...toLabels(news.labels),
387 ...(yield* createInternalLabels(id)),
388 };
389 const clusters = news.clusters ?? defaultClusters();
390
391 let current = yield* getInstanceByName(name);
392
393 if (current === undefined) {
394 const clusterMap: bigtable.ClusterMap = {};
395 for (const [clusterId, spec] of Object.entries(clusters)) {
396 clusterMap[clusterId] = toClusterBody(env.project, spec);
397 }
398 const created = yield* bigtable
399 .createProjectsInstances({
400 parent: `projects/${env.project}`,
401 body: {
402 instanceId,
403 instance: {
404 displayName,
405 type,
406 edition,
407 labels: desiredLabels,
408 },
409 clusters: clusterMap,
410 },
411 })
412 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
413 if (created !== undefined) {
414 yield* waitForOperation(created, { alreadyExistsOk: true });
415 }
416 current = yield* waitUntilExists(name);
417 }
418
419 if (current === undefined) {
420 return yield* new InstanceNotResolved({ name });
421 }
422
423 if ((current.state ?? "STATE_NOT_KNOWN") !== "READY") {
424 current = yield* waitUntilReady(name);
425 }
426
427 const observedLabels = tagRecord(current.labels);
428 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
429 const labelsChanged = upsert.length > 0 || removed.length > 0;
430 const displayChanged = (current.displayName ?? "") !== displayName;
431 const typeChanged = normalizeType(current.type) !== type;
432 const editionChanged =
433 normalizeEdition(current.edition) !== edition &&
434 news.edition !== undefined;
435
436 if (labelsChanged || displayChanged || typeChanged || editionChanged) {
437 const mask = [
438 labelsChanged ? "labels" : undefined,
439 displayChanged ? "display_name" : undefined,
440 typeChanged ? "type" : undefined,
441 editionChanged ? "edition" : undefined,
442 ].filter((field): field is string => field !== undefined);
443 const patched = yield* bigtable.partialUpdateInstanceProjectsInstances({
444 name,
445 updateMask: mask.join(","),
446 body: {
447 displayName,
448 type,
449 edition,
450 labels: desiredLabels,
451 },
452 });
453 yield* waitForOperation(patched);
454 current = yield* waitUntilExists(name);
455 }
456
457 return toAttrs(current, env.project);
458 }),
459
460 delete: Effect.fn(function* ({ output }) {
461 yield* bigtable
462 .deleteProjectsInstances({ name: output.name })
463 .pipe(Effect.catchTag("NotFound", () => Effect.void));
464 yield* waitUntilGone(output.name);
465 }),
466 });