1import * as alloydb from "@distilled.cloud/gcp/alloydb_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForDeleteOperation, waitForOperation } from "./operations.ts";
22
23const DEFAULT_INSTANCE_TYPE = "PRIMARY";
24const DEFAULT_CPU_COUNT = 2;
25const DEFAULT_READ_POOL_NODES = 1;
26const MAX_NAME_LENGTH = 63;
27
28export type MachineConfig = {
29 /**
30 * vCPU count. Must match `machineType` when both are set. Minimum 2
31 * for PRIMARY instances.
32 * @default 2
33 */
34 cpuCount?: number;
35 /**
36 * Machine type (e.g. `n2-highmem-4`, `n2-highmem-8`).
37 */
38 machineType?: string;
39};
40
41export type ReadPoolConfig = {
42 /**
43 * Node count in the read pool. Required when `instanceType` is
44 * `READ_POOL`.
45 * @default 1
46 */
47 nodeCount?: number;
48};
49
50export type QueryInsightsConfig = alloydb.QueryInsightsInstanceConfig;
51export type ObservabilityConfig = alloydb.ObservabilityInstanceConfig;
52export type ClientConnectionConfig = alloydb.ClientConnectionConfig;
53export type InstanceNetworkConfig = alloydb.InstanceNetworkConfig;
54export type PscInstanceConfig = alloydb.PscInstanceConfig;
55export type ConnectionPoolConfig = alloydb.ConnectionPoolConfig;
56
57export type InstanceProps = {
58 /**
59 * Parent cluster id or full resource name
60 * (`projects/{project}/locations/{location}/clusters/{cluster}`).
61 * Immutable — changing it replaces the instance.
62 */
63 cluster: string;
64 /**
65 * Region of the parent cluster (`us-central1`, …). Ignored when
66 * `cluster` is a full resource name. Immutable — changing it replaces
67 * the instance. `US-CENTRAL1` is accepted and normalized to
68 * `us-central1`.
69 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
70 */
71 location?: string;
72 /**
73 * Instance id (the `{instance}` segment of
74 * `.../clusters/{cluster}/instances/{instance}`). If omitted, a unique
75 * RFC1035 name is generated. Must match
76 * `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable — changing it replaces
77 * the instance.
78 */
79 instanceId?: string;
80 /**
81 * Instance type. `SECONDARY` uses `instances.createsecondary`.
82 * Immutable — changing it replaces the instance.
83 * @default "PRIMARY"
84 */
85 instanceType?: alloydb.InstanceInstanceTypeEnum | (string & {});
86 /**
87 * User-facing display name.
88 */
89 displayName?: string;
90 /**
91 * User labels. Alchemy ownership labels are merged in automatically.
92 */
93 labels?: Record<string, string>;
94 /**
95 * Arbitrary client annotations (distinct from labels).
96 */
97 annotations?: Record<string, string>;
98 /**
99 * VM shape for the database engine. Defaults to 2 vCPU.
100 */
101 machineConfig?: MachineConfig;
102 /**
103 * Availability type. PRIMARY defaults to `REGIONAL`. `gceZone` is only
104 * valid with `ZONAL`.
105 */
106 availabilityType?: alloydb.InstanceAvailabilityTypeEnum | (string & {});
107 /**
108 * Compute Engine zone for `ZONAL` instances (e.g. `us-central1-a`).
109 */
110 gceZone?: string;
111 /**
112 * Whether the instance should run. `NEVER` stops it; `ALWAYS` starts
113 * it.
114 */
115 activationPolicy?: alloydb.InstanceActivationPolicyEnum | (string & {});
116 /**
117 * Database flags (`"key": "value"`). Booleans use `on` / `off`.
118 */
119 databaseFlags?: Record<string, string>;
120 /**
121 * Read-pool size. Required when `instanceType` is `READ_POOL`.
122 */
123 readPoolConfig?: ReadPoolConfig;
124 /**
125 * Query Insights configuration.
126 */
127 queryInsightsConfig?: QueryInsightsConfig;
128 /**
129 * Observability configuration.
130 */
131 observabilityConfig?: ObservabilityConfig;
132 /**
133 * Client connection and SSL settings.
134 */
135 clientConnectionConfig?: ClientConnectionConfig;
136 /**
137 * Instance-level network configuration (public IP, authorized CIDRs).
138 */
139 networkConfig?: InstanceNetworkConfig;
140 /**
141 * Private Service Connect configuration for this instance.
142 */
143 pscInstanceConfig?: PscInstanceConfig;
144 /**
145 * Managed Connection Pool (MCP) configuration.
146 */
147 connectionPoolConfig?: ConnectionPoolConfig;
148 /**
149 * Controls the Data API (`executeSql`).
150 */
151 dataApiAccess?: alloydb.InstanceDataApiAccessEnum | (string & {});
152};
153
154export type Instance = Resource<
155 "GCP.AlloyDB.Instance",
156 InstanceProps,
157 {
158 /** Full resource name `projects/{project}/locations/{location}/clusters/{cluster}/instances/{instance}`. */
159 name: string;
160 /** Instance id (last path segment). */
161 instanceId: string;
162 /** Parent cluster id. */
163 clusterId: string;
164 /** Parent cluster resource name. */
165 clusterName: string;
166 /** Project id. */
167 project: string;
168 /** Region id (`us-central1`, …). */
169 location: string;
170 /** Instance type (`PRIMARY`, `READ_POOL`, `SECONDARY`). */
171 instanceType: string;
172 /** User-facing display name. */
173 displayName: string | undefined;
174 /** User labels (Alchemy ownership labels stripped). */
175 labels: Record<string, string>;
176 /** Client annotations. */
177 annotations: Record<string, string>;
178 /** Serving state (`READY`, `CREATING`, `STOPPED`, …). */
179 state: string | undefined;
180 /** Private IP address. */
181 ipAddress: string | undefined;
182 /** Public IP address, if enabled. */
183 publicIpAddress: string | undefined;
184 /** Machine configuration currently applied. */
185 machineConfig: MachineConfig | undefined;
186 /** Availability type (`ZONAL`, `REGIONAL`). */
187 availabilityType: string | undefined;
188 /** Zone for ZONAL instances. */
189 gceZone: string | undefined;
190 /** Activation policy (`ALWAYS`, `NEVER`). */
191 activationPolicy: string | undefined;
192 /** Database flags currently applied. */
193 databaseFlags: Record<string, string>;
194 /** Read-pool configuration, if any. */
195 readPoolConfig: ReadPoolConfig | undefined;
196 /** Whether the service is reconciling intended vs actual state. */
197 reconciling: boolean;
198 /** System-generated UID. */
199 uid: string | undefined;
200 /** RFC3339 creation timestamp. */
201 createTime: string | undefined;
202 /** RFC3339 last-update timestamp. */
203 updateTime: string | undefined;
204 },
205 never,
206 Providers
207>;
208
209/**
210 * An AlloyDB instance (compute attached to a cluster).
211 *
212 * Changing `cluster`, `location`, `instanceId`, or `instanceType`
213 * replaces the instance. Machine size, flags, labels, activation policy,
214 * and read-pool size update in place.
215 *
216 * A cluster may exist without instances (`EMPTY`). Creating a PRIMARY
217 * instance typically takes several minutes.
218 *
219 * ### Creating an Instance
220 * **Example:** Primary instance on a cluster
221 * ```typescript
222 * const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
223 * pscConfig: { pscEnabled: true },
224 * });
225 * const primary = yield* GCP.AlloyDB.Instance("Primary", {
226 * cluster: cluster.name,
227 * instanceType: "PRIMARY",
228 * machineConfig: { cpuCount: 2 },
229 * });
230 * ```
231 *
232 * **Example:** Explicit id, display name, and labels
233 * ```typescript
234 * const primary = yield* GCP.AlloyDB.Instance("Primary", {
235 * cluster: cluster.name,
236 * instanceId: "app-db-primary",
237 * displayName: "app-db-primary",
238 * labels: { env: "prod" },
239 * machineConfig: { cpuCount: 2 },
240 * });
241 * ```
242 *
243 * ### Read Pool
244 * **Example:** Two-node read pool
245 * ```typescript
246 * const pool = yield* GCP.AlloyDB.Instance("Reads", {
247 * cluster: cluster.name,
248 * instanceType: "READ_POOL",
249 * readPoolConfig: { nodeCount: 2 },
250 * machineConfig: { cpuCount: 2 },
251 * });
252 * ```
253 *
254 * @resource
255 * @category AlloyDB
256 */
257export const Instance = Resource<Instance>("GCP.AlloyDB.Instance");
258
259export class InstanceNotResolved extends Data.TaggedError(
260 "GCP.AlloyDB.InstanceNotResolved",
261)<{
262 name: string;
263}> {}
264
265export class InstanceClusterMissing extends Data.TaggedError(
266 "GCP.AlloyDB.InstanceClusterMissing",
267)<{
268 message: string;
269}> {}
270
271export class InstanceNotReady extends Data.TaggedError(
272 "GCP.AlloyDB.InstanceNotReady",
273)<{
274 name: string;
275 state: string;
276}> {}
277
278export class InstanceFailed extends Data.TaggedError(
279 "GCP.AlloyDB.InstanceFailed",
280)<{
281 name: string;
282 state: string;
283}> {}
284
285export class InstanceStillExists extends Data.TaggedError(
286 "GCP.AlloyDB.InstanceStillExists",
287)<{
288 name: string;
289}> {}
290
291const lastSegment = (value: string | undefined) => {
292 if (value === undefined || value.length === 0) return "";
293 const trimmed = value.replace(/\/+$/, "");
294 const parts = trimmed.split("/");
295 return parts[parts.length - 1] || trimmed;
296};
297
298const normalizeLocation = (location: string) =>
299 lastSegment(location).toLowerCase();
300
301const normalizeInstanceType = (type: string | undefined) => {
302 const value = (type ?? DEFAULT_INSTANCE_TYPE).toUpperCase();
303 return value === "INSTANCE_TYPE_UNSPECIFIED" ? DEFAULT_INSTANCE_TYPE : value;
304};
305
306const rfc1035 = (name: string): string => {
307 let next = name
308 .toLowerCase()
309 .replace(/[^a-z0-9-]/g, "-")
310 .replace(/-+/g, "-")
311 .replace(/^-+|-+$/g, "");
312 if (!/^[a-z]/.test(next)) next = `i${next}`;
313 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
314 if (next.length === 0) return "instance";
315 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
316 return next.slice(0, MAX_NAME_LENGTH);
317};
318
319const resourceName = (
320 project: string,
321 location: string,
322 clusterId: string,
323 instanceId: string,
324) =>
325 `projects/${project}/locations/${location}/clusters/${clusterId}/instances/${instanceId}`;
326
327const clusterNameOf = (project: string, location: string, clusterId: string) =>
328 `projects/${project}/locations/${location}/clusters/${clusterId}`;
329
330const parseName = (name: string) => {
331 const parts = name.split("/").filter((part) => part.length > 0);
332 const instancesAt = parts.lastIndexOf("instances");
333 const clustersAt = parts.lastIndexOf("clusters");
334 const locationsAt = parts.lastIndexOf("locations");
335 const projectsAt = parts.lastIndexOf("projects");
336 return {
337 project:
338 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
339 location:
340 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
341 clusterId:
342 clustersAt >= 0 && parts[clustersAt + 1] ? parts[clustersAt + 1]! : "",
343 instanceId:
344 instancesAt >= 0 && parts[instancesAt + 1]
345 ? parts[instancesAt + 1]!
346 : lastSegment(name),
347 };
348};
349
350const parseClusterRef = (
351 cluster: string,
352 fallbackProject: string,
353 fallbackLocation: string,
354) => {
355 const trimmed = cluster.trim();
356 if (trimmed.length === 0) {
357 return {
358 project: fallbackProject,
359 location: normalizeLocation(fallbackLocation),
360 clusterId: "",
361 };
362 }
363 if (trimmed.includes("/clusters/") || trimmed.includes("projects/")) {
364 const parsed = parseName(
365 trimmed.includes("/instances/") ? trimmed : `${trimmed}/instances/_`,
366 );
367 return {
368 project: parsed.project || fallbackProject,
369 location: normalizeLocation(parsed.location || fallbackLocation),
370 clusterId: parsed.clusterId === "_" ? "" : parsed.clusterId,
371 };
372 }
373 return {
374 project: fallbackProject,
375 location: normalizeLocation(fallbackLocation),
376 clusterId: lastSegment(trimmed),
377 };
378};
379
380const userLabels = (
381 labels: Record<string, string | undefined> | null | undefined,
382): Record<string, string> => stripInternalLabels(tagRecord(labels));
383
384const stringMapOf = (
385 map: Record<string, string | undefined> | null | undefined,
386): Record<string, string> =>
387 Object.fromEntries(
388 Object.entries(map ?? {}).filter(
389 (entry): entry is [string, string] =>
390 entry[1] !== undefined && entry[1].length > 0,
391 ),
392 );
393
394const toId = (id: string, instanceId: string | undefined, existing?: string) =>
395 Effect.gen(function* () {
396 return (
397 instanceId ??
398 existing ??
399 rfc1035(
400 yield* createPhysicalName({
401 id,
402 maxLength: MAX_NAME_LENGTH,
403 lowercase: true,
404 }),
405 )
406 );
407 });
408
409const canonical = (value: unknown): unknown => {
410 if (value === undefined || value === null) return undefined;
411 if (typeof value === "boolean" || typeof value === "number") return value;
412 if (typeof value === "string") {
413 return value.length === 0 ? undefined : value;
414 }
415 if (Array.isArray(value)) {
416 const items = value.map(canonical).filter((item) => item !== undefined);
417 return items.length === 0 ? undefined : items;
418 }
419 if (typeof value === "object") {
420 const entries = Object.entries(value as Record<string, unknown>)
421 .map(([key, item]) => [key, canonical(item)] as const)
422 .filter(([, item]) => item !== undefined)
423 .sort(([left], [right]) => left.localeCompare(right));
424 if (entries.length === 0) return undefined;
425 return Object.fromEntries(entries);
426 }
427 return undefined;
428};
429
430const fingerprint = (value: unknown): string =>
431 JSON.stringify(canonical(value) ?? null);
432
433const specifiedChanged = (
434 desired: Record<string, unknown> | undefined,
435 observed: Record<string, unknown> | undefined,
436) => {
437 if (desired === undefined) return false;
438 const picked: Record<string, unknown> = {};
439 const current: Record<string, unknown> = {};
440 for (const [key, value] of Object.entries(desired)) {
441 if (value === undefined) continue;
442 picked[key] = value;
443 current[key] = observed?.[key];
444 }
445 return fingerprint(picked) !== fingerprint(current);
446};
447
448const toMachineConfig = (
449 config: alloydb.MachineConfig | MachineConfig | undefined,
450): MachineConfig | undefined => {
451 if (config === undefined) return undefined;
452 if (config.cpuCount === undefined && config.machineType === undefined) {
453 return undefined;
454 }
455 return {
456 cpuCount: config.cpuCount,
457 machineType: config.machineType,
458 };
459};
460
461const toReadPoolConfig = (
462 config: alloydb.ReadPoolConfig | ReadPoolConfig | undefined,
463): ReadPoolConfig | undefined => {
464 if (config === undefined || config.nodeCount === undefined) return undefined;
465 return { nodeCount: config.nodeCount };
466};
467
468const desiredMachineConfig = (news: InstanceProps): MachineConfig => ({
469 cpuCount: news.machineConfig?.cpuCount ?? DEFAULT_CPU_COUNT,
470 machineType: news.machineConfig?.machineType,
471});
472
473const desiredReadPoolConfig = (
474 news: InstanceProps,
475 instanceType: string,
476): ReadPoolConfig | undefined => {
477 if (instanceType !== "READ_POOL") return news.readPoolConfig;
478 return {
479 nodeCount: news.readPoolConfig?.nodeCount ?? DEFAULT_READ_POOL_NODES,
480 };
481};
482
483const isAvailable = (state: string | undefined) => {
484 const value = (state ?? "").toUpperCase();
485 return value === "READY" || value === "STOPPED";
486};
487
488const isFailed = (state: string | undefined) =>
489 (state ?? "").toUpperCase() === "FAILED";
490
491const toAttrs = (instance: alloydb.Instance, project: string) => {
492 const name = instance.name ?? "";
493 const parsed = parseName(name);
494 const clusterId = parsed.clusterId;
495 const location = parsed.location;
496 const resolvedProject = parsed.project || project;
497 return {
498 name,
499 instanceId: parsed.instanceId,
500 clusterId,
501 clusterName: clusterNameOf(resolvedProject, location, clusterId),
502 project: resolvedProject,
503 location,
504 instanceType: normalizeInstanceType(instance.instanceType),
505 displayName: instance.displayName,
506 labels: userLabels(instance.labels),
507 annotations: stringMapOf(instance.annotations),
508 state: instance.state,
509 ipAddress: instance.ipAddress,
510 publicIpAddress: instance.publicIpAddress,
511 machineConfig: toMachineConfig(instance.machineConfig),
512 availabilityType: instance.availabilityType,
513 gceZone: instance.gceZone,
514 activationPolicy: instance.activationPolicy,
515 databaseFlags: stringMapOf(instance.databaseFlags),
516 readPoolConfig: toReadPoolConfig(instance.readPoolConfig),
517 reconciling: instance.reconciling === true,
518 uid: instance.uid,
519 createTime: instance.createTime,
520 updateTime: instance.updateTime,
521 };
522};
523
524const isPlaceholder = (instance: alloydb.Instance) => {
525 const name = instance.name ?? "";
526 return name.endsWith("/instances/-") || name.endsWith("/instances/");
527};
528
529const getByName = (name: string) =>
530 alloydb
531 .getProjectsLocationsClustersInstances({ name })
532 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
533
534const waitUntilExists = (name: string) =>
535 getByName(name).pipe(
536 Effect.flatMap((instance) =>
537 instance
538 ? Effect.succeed(instance)
539 : Effect.fail(new InstanceNotResolved({ name })),
540 ),
541 Effect.retry({
542 while: (error) => error._tag === "GCP.AlloyDB.InstanceNotResolved",
543 times: 8,
544 schedule: Schedule.spaced("1 second"),
545 }),
546 );
547
548const waitUntilReady = (name: string) =>
549 Effect.gen(function* () {
550 const instance = yield* getByName(name);
551 if (instance === undefined) {
552 return yield* new InstanceNotReady({ name, state: "MISSING" });
553 }
554 if (isFailed(instance.state)) {
555 return yield* new InstanceFailed({
556 name,
557 state: instance.state ?? "FAILED",
558 });
559 }
560 if (!(isAvailable(instance.state) && instance.reconciling !== true)) {
561 return yield* new InstanceNotReady({
562 name,
563 state: instance.state ?? "STATE_UNSPECIFIED",
564 });
565 }
566 return instance;
567 }).pipe(
568 Effect.retry({
569 while: (error) => error._tag === "GCP.AlloyDB.InstanceNotReady",
570 times: 120,
571 schedule: Schedule.spaced("15 seconds"),
572 }),
573 );
574
575const waitUntilGone = (name: string) =>
576 getByName(name).pipe(
577 Effect.flatMap((instance) =>
578 instance === undefined
579 ? Effect.void
580 : Effect.fail(new InstanceStillExists({ name })),
581 ),
582 Effect.retry({
583 while: (error) => error._tag === "GCP.AlloyDB.InstanceStillExists",
584 times: 120,
585 schedule: Schedule.spaced("15 seconds"),
586 }),
587 );
588
589const toCreateBody = (
590 news: InstanceProps,
591 desiredLabels: Record<string, string>,
592 instanceType: string,
593 machineConfig: MachineConfig,
594 readPoolConfig: ReadPoolConfig | undefined,
595): alloydb.Instance => {
596 const body: alloydb.Instance = {
597 instanceType,
598 displayName: news.displayName,
599 labels: desiredLabels,
600 machineConfig,
601 };
602 if (news.annotations !== undefined) {
603 body.annotations = news.annotations;
604 }
605 if (news.availabilityType !== undefined) {
606 body.availabilityType = news.availabilityType;
607 }
608 if (news.gceZone !== undefined) {
609 body.gceZone = news.gceZone;
610 }
611 if (news.activationPolicy !== undefined) {
612 body.activationPolicy = news.activationPolicy;
613 }
614 if (news.databaseFlags !== undefined) {
615 body.databaseFlags = news.databaseFlags;
616 }
617 if (readPoolConfig !== undefined) {
618 body.readPoolConfig = readPoolConfig;
619 }
620 if (news.queryInsightsConfig !== undefined) {
621 body.queryInsightsConfig = news.queryInsightsConfig;
622 }
623 if (news.observabilityConfig !== undefined) {
624 body.observabilityConfig = news.observabilityConfig;
625 }
626 if (news.clientConnectionConfig !== undefined) {
627 body.clientConnectionConfig = news.clientConnectionConfig;
628 }
629 if (news.networkConfig !== undefined) {
630 body.networkConfig = news.networkConfig;
631 }
632 if (news.pscInstanceConfig !== undefined) {
633 body.pscInstanceConfig = news.pscInstanceConfig;
634 }
635 if (news.connectionPoolConfig !== undefined) {
636 body.connectionPoolConfig = news.connectionPoolConfig;
637 }
638 if (news.dataApiAccess !== undefined) {
639 body.dataApiAccess = news.dataApiAccess;
640 }
641 return body;
642};
643
644export const InstanceProvider = () =>
645 Provider.succeed(Instance, {
646 stables: [
647 "name",
648 "instanceId",
649 "clusterId",
650 "clusterName",
651 "project",
652 "location",
653 "uid",
654 "createTime",
655 ],
656
657 diff: Effect.fn(function* ({ news, olds, output }) {
658 if (!isResolved(news)) return undefined;
659 const env = yield* GcpEnvironment.current;
660
661 const previousId = olds?.instanceId ?? output?.instanceId;
662 const nextId = news.instanceId ?? previousId;
663 const previousCluster = lastSegment(olds?.cluster ?? output?.clusterId);
664 const nextCluster = lastSegment(news.cluster ?? previousCluster);
665 const previousLocation = normalizeLocation(
666 olds?.location ?? output?.location ?? env.region,
667 );
668 const nextLocation = normalizeLocation(
669 news.location ?? output?.location ?? env.region,
670 );
671 const previousType = normalizeInstanceType(
672 olds?.instanceType ?? output?.instanceType,
673 );
674 const nextType = normalizeInstanceType(
675 news.instanceType ?? output?.instanceType,
676 );
677
678 const replace =
679 (previousId !== undefined &&
680 nextId !== undefined &&
681 nextId !== previousId) ||
682 (previousCluster.length > 0 &&
683 nextCluster.length > 0 &&
684 previousCluster !== nextCluster) ||
685 previousLocation !== nextLocation ||
686 previousType !== nextType;
687
688 if (!replace) return undefined;
689 return {
690 action: "replace" as const,
691 deleteFirst:
692 previousLocation === nextLocation &&
693 previousCluster === nextCluster &&
694 previousId !== undefined &&
695 nextId === previousId,
696 };
697 }),
698
699 read: Effect.fn(function* ({ id, olds, output }) {
700 const env = yield* GcpEnvironment.current;
701 if (output?.name) {
702 const existing = yield* getByName(output.name);
703 if (existing === undefined) return undefined;
704 const attrs = toAttrs(existing, env.project);
705 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
706 ? attrs
707 : Unowned(attrs);
708 }
709 const instanceId = yield* toId(id, olds?.instanceId, output?.instanceId);
710 const ref = parseClusterRef(
711 olds?.cluster ?? output?.clusterName ?? output?.clusterId ?? "",
712 env.project,
713 olds?.location ?? output?.location ?? env.region,
714 );
715 if (ref.clusterId.length === 0) return undefined;
716 const name = resourceName(
717 ref.project,
718 ref.location,
719 ref.clusterId,
720 instanceId,
721 );
722 const existing = yield* getByName(name);
723 if (existing === undefined) return undefined;
724 const attrs = toAttrs(existing, env.project);
725 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
726 ? attrs
727 : Unowned(attrs);
728 }),
729
730 list: () =>
731 Effect.gen(function* () {
732 const env = yield* GcpEnvironment.current;
733 return yield* alloydb.listProjectsLocationsClustersInstances
734 .pages({
735 parent: `projects/${env.project}/locations/-/clusters/-`,
736 pageSize: 1000,
737 })
738 .pipe(
739 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
740 Stream.filter(
741 (instance) =>
742 !isPlaceholder(instance) &&
743 Object.keys(instance.labels ?? {}).some((key) =>
744 key.startsWith("alchemy-"),
745 ),
746 ),
747 Stream.map((instance) => toAttrs(instance, env.project)),
748 Stream.runCollect,
749 Effect.map((chunk) => Array.from(chunk)),
750 Effect.catchTag("NotFound", () => Effect.succeed([])),
751 );
752 }),
753
754 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
755 const env = yield* GcpEnvironment.current;
756 const instanceId = yield* toId(id, news.instanceId, output?.instanceId);
757 const ref = parseClusterRef(
758 news.cluster,
759 env.project,
760 news.location ?? output?.location ?? env.region,
761 );
762 if (ref.clusterId.length === 0) {
763 return yield* new InstanceClusterMissing({
764 message:
765 "GCP.AlloyDB.Instance requires `cluster` (cluster id or full resource name)",
766 });
767 }
768 const name = resourceName(
769 ref.project,
770 ref.location,
771 ref.clusterId,
772 instanceId,
773 );
774 const parent = clusterNameOf(ref.project, ref.location, ref.clusterId);
775 const instanceType = normalizeInstanceType(news.instanceType);
776 const machineConfig = desiredMachineConfig(news);
777 const readPoolConfig = desiredReadPoolConfig(news, instanceType);
778 const desiredLabels = {
779 ...toLabels(news.labels),
780 ...(yield* createInternalLabels(id)),
781 };
782
783 let current = yield* getByName(output?.name ?? name);
784
785 if (current === undefined) {
786 const body = toCreateBody(
787 news,
788 desiredLabels,
789 instanceType,
790 machineConfig,
791 readPoolConfig,
792 );
793 yield* (
794 instanceType === "SECONDARY"
795 ? alloydb.createsecondaryProjectsLocationsClustersInstances({
796 parent,
797 instanceId,
798 body,
799 })
800 : alloydb.createProjectsLocationsClustersInstances({
801 parent,
802 instanceId,
803 body,
804 })
805 ).pipe(
806 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
807 Effect.tap((created) =>
808 created === undefined ? Effect.void : waitForOperation(created),
809 ),
810 // AlloyDB intermittently fails the create operation with INTERNAL
811 // ("an internal error has occurred") after a long provisioning wait;
812 // the failed instance is removed, so the create is retried.
813 Effect.retry({
814 while: (error) =>
815 error._tag === "GCP.OperationFailed" && error.code === 13,
816 times: 2,
817 schedule: Schedule.spaced("30 seconds"),
818 }),
819 );
820 current = yield* waitUntilExists(name);
821 }
822
823 if (current === undefined) {
824 return yield* new InstanceNotResolved({ name });
825 }
826
827 if (!isAvailable(current.state) || current.reconciling === true) {
828 current = yield* waitUntilReady(name);
829 }
830
831 const observedLabels = tagRecord(current.labels);
832 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
833 const labelsChanged = upsert.length > 0 || removed.length > 0;
834 // AlloyDB omits `displayName` from instance reads (as for clusters and
835 // backups); compare against the last applied value instead.
836 const observedDisplayName =
837 current.displayName ?? output?.displayName ?? olds?.displayName;
838 const displayNameChanged =
839 (observedDisplayName ?? "") !== (news.displayName ?? "");
840 const annotationsChanged =
841 news.annotations !== undefined &&
842 fingerprint(stringMapOf(current.annotations)) !==
843 fingerprint(news.annotations);
844 const machineChanged = specifiedChanged(
845 news.machineConfig as Record<string, unknown> | undefined,
846 current.machineConfig as Record<string, unknown> | undefined,
847 );
848 const availabilityChanged =
849 news.availabilityType !== undefined &&
850 (current.availabilityType ?? "") !== news.availabilityType;
851 const zoneChanged =
852 news.gceZone !== undefined && (current.gceZone ?? "") !== news.gceZone;
853 const activationChanged =
854 news.activationPolicy !== undefined &&
855 (current.activationPolicy ?? "") !== news.activationPolicy;
856 const flagsChanged =
857 news.databaseFlags !== undefined &&
858 fingerprint(stringMapOf(current.databaseFlags)) !==
859 fingerprint(news.databaseFlags);
860 const readPoolChanged =
861 news.readPoolConfig !== undefined &&
862 (current.readPoolConfig?.nodeCount ?? 0) !==
863 (news.readPoolConfig.nodeCount ??
864 current.readPoolConfig?.nodeCount ??
865 0);
866 const insightsChanged = specifiedChanged(
867 news.queryInsightsConfig as Record<string, unknown> | undefined,
868 current.queryInsightsConfig as Record<string, unknown> | undefined,
869 );
870 const observabilityChanged = specifiedChanged(
871 news.observabilityConfig as Record<string, unknown> | undefined,
872 current.observabilityConfig as Record<string, unknown> | undefined,
873 );
874 const clientChanged = specifiedChanged(
875 news.clientConnectionConfig as Record<string, unknown> | undefined,
876 current.clientConnectionConfig as Record<string, unknown> | undefined,
877 );
878 const networkChanged = specifiedChanged(
879 news.networkConfig as Record<string, unknown> | undefined,
880 current.networkConfig as Record<string, unknown> | undefined,
881 );
882 const pscChanged = specifiedChanged(
883 news.pscInstanceConfig as Record<string, unknown> | undefined,
884 current.pscInstanceConfig as Record<string, unknown> | undefined,
885 );
886 const poolChanged = specifiedChanged(
887 news.connectionPoolConfig as Record<string, unknown> | undefined,
888 current.connectionPoolConfig as Record<string, unknown> | undefined,
889 );
890 const dataApiChanged =
891 news.dataApiAccess !== undefined &&
892 (current.dataApiAccess ?? "") !== news.dataApiAccess;
893
894 if (
895 labelsChanged ||
896 displayNameChanged ||
897 annotationsChanged ||
898 machineChanged ||
899 availabilityChanged ||
900 zoneChanged ||
901 activationChanged ||
902 flagsChanged ||
903 readPoolChanged ||
904 insightsChanged ||
905 observabilityChanged ||
906 clientChanged ||
907 networkChanged ||
908 pscChanged ||
909 poolChanged ||
910 dataApiChanged
911 ) {
912 const updateMask = [
913 labelsChanged ? "labels" : undefined,
914 displayNameChanged ? "displayName" : undefined,
915 annotationsChanged ? "annotations" : undefined,
916 machineChanged ? "machineConfig" : undefined,
917 availabilityChanged ? "availabilityType" : undefined,
918 zoneChanged ? "gceZone" : undefined,
919 activationChanged ? "activationPolicy" : undefined,
920 flagsChanged ? "databaseFlags" : undefined,
921 readPoolChanged ? "readPoolConfig" : undefined,
922 insightsChanged ? "queryInsightsConfig" : undefined,
923 observabilityChanged ? "observabilityConfig" : undefined,
924 clientChanged ? "clientConnectionConfig" : undefined,
925 networkChanged ? "networkConfig" : undefined,
926 pscChanged ? "pscInstanceConfig" : undefined,
927 poolChanged ? "connectionPoolConfig" : undefined,
928 dataApiChanged ? "dataApiAccess" : undefined,
929 ].filter((field): field is string => field !== undefined);
930
931 const patched = yield* alloydb
932 .patchProjectsLocationsClustersInstances({
933 name,
934 updateMask: updateMask.join(","),
935 body: {
936 name,
937 labels: desiredLabels,
938 displayName: news.displayName,
939 annotations: news.annotations,
940 machineConfig,
941 availabilityType: news.availabilityType,
942 gceZone: news.gceZone,
943 activationPolicy: news.activationPolicy,
944 databaseFlags: news.databaseFlags,
945 readPoolConfig,
946 queryInsightsConfig: news.queryInsightsConfig,
947 observabilityConfig: news.observabilityConfig,
948 clientConnectionConfig: news.clientConnectionConfig,
949 networkConfig: news.networkConfig,
950 pscInstanceConfig: news.pscInstanceConfig,
951 connectionPoolConfig: news.connectionPoolConfig,
952 dataApiAccess: news.dataApiAccess,
953 },
954 })
955 .pipe(
956 Effect.retry({
957 while: (error) => error._tag === "Conflict",
958 times: 40,
959 schedule: Schedule.spaced("15 seconds"),
960 }),
961 );
962 yield* waitForOperation(patched);
963 current = yield* waitUntilReady(name);
964 }
965
966 return toAttrs(
967 { ...current, displayName: current.displayName ?? news.displayName },
968 env.project,
969 );
970 }),
971
972 delete: Effect.fn(function* ({ output }) {
973 const operation = yield* alloydb
974 .deleteProjectsLocationsClustersInstances({ name: output.name })
975 .pipe(
976 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
977 Effect.retry({
978 while: (error) => error._tag === "Conflict",
979 times: 40,
980 schedule: Schedule.spaced("15 seconds"),
981 }),
982 );
983 if (operation !== undefined) {
984 yield* waitForDeleteOperation(operation);
985 }
986 yield* waitUntilGone(output.name);
987 }),
988 });