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_CLUSTER_TYPE = "PRIMARY";
24const DEFAULT_NETWORK = "default";
25/** Cluster ids must match `^[a-z]([a-z0-9-]{0,53}[a-z0-9])?$`. */
26const MAX_NAME_LENGTH = 55;
27
28export type ClusterTimeOfDay = {
29 /** Hours of day in 24-hour format (`0`–`23`). */
30 hours?: number;
31 /** Minutes of hour (`0`–`59`). */
32 minutes?: number;
33 /** Seconds of minute (`0`–`59`). */
34 seconds?: number;
35 /** Fractional seconds, in nanoseconds. */
36 nanos?: number;
37};
38
39export type MaintenanceWindow = {
40 /** Preferred day of week (`MONDAY` … `SUNDAY`). */
41 day?: alloydb.MaintenanceWindowDayEnum | (string & {});
42 /** Preferred UTC start time. Maintenance starts within 1 hour. */
43 startTime?: ClusterTimeOfDay;
44};
45
46export type DenyMaintenancePeriod = {
47 /** Inclusive start date. */
48 startDate?: { year?: number; month?: number; day?: number };
49 /** Inclusive end date. */
50 endDate?: { year?: number; month?: number; day?: number };
51 /** UTC time of day for the window bounds. */
52 time?: ClusterTimeOfDay;
53};
54
55export type MaintenanceUpdatePolicy = {
56 /** Preferred windows. Currently limited to 1. */
57 maintenanceWindows?: MaintenanceWindow[];
58 /** Periods to deny maintenance. Currently limited to 1. */
59 denyMaintenancePeriods?: DenyMaintenancePeriod[];
60};
61
62export type NetworkConfig = {
63 /**
64 * VPC network
65 * (`projects/{project}/global/networks/{network}`). Immutable.
66 * Required unless Private Service Connect is enabled.
67 */
68 network?: string;
69 /**
70 * Allocated IP range name for private IP (e.g.
71 * `google-managed-services-default`). Immutable.
72 */
73 allocatedIpRange?: string;
74};
75
76export type PscConfig = {
77 /**
78 * Allow Private Service Connect endpoints. When true, a VPC network is
79 * not required. Immutable.
80 */
81 pscEnabled?: boolean;
82};
83
84export type EncryptionConfig = {
85 /**
86 * Customer-managed KMS key
87 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
88 * Immutable — changing it replaces the cluster.
89 */
90 kmsKeyName?: string;
91};
92
93export type WeeklySchedule = {
94 /** UTC start times (typically exact hours). */
95 startTimes?: ClusterTimeOfDay[];
96 /** Days of week. Empty means every day. */
97 daysOfWeek?: Array<alloydb.WeeklyScheduleDaysOfWeekItemEnum | (string & {})>;
98};
99
100export type AutomatedBackupPolicy = {
101 /** Whether automated backups are enabled. Defaults to true when omitted. */
102 enabled?: boolean;
103 /** Backup window duration (e.g. `"1800s"`). */
104 backupWindow?: string;
105 /** Weekly schedule. */
106 weeklySchedule?: WeeklySchedule;
107 /** Keep the most recent N backups. */
108 quantityBasedRetention?: { count?: number };
109 /** Keep backups for a duration (e.g. `"1209600s"`). */
110 timeBasedRetention?: { retentionPeriod?: string };
111 /** Backup location. Defaults to the cluster region. */
112 location?: string;
113 /** Labels applied to created backups. */
114 labels?: Record<string, string>;
115 /** CMEK for backups. Defaults to the cluster encryption config. */
116 encryptionConfig?: EncryptionConfig;
117};
118
119export type ContinuousBackupConfig = {
120 /** Whether continuous backup (PITR) is enabled. */
121 enabled?: boolean;
122 /** Recovery window in days. Defaults to 14. */
123 recoveryWindowDays?: number;
124 /** CMEK for continuous backups. */
125 encryptionConfig?: EncryptionConfig;
126};
127
128export type InitialUser = {
129 /** Database username. Defaults to `postgres` when omitted. */
130 user?: string;
131 /** Initial password. Create-only. */
132 password?: string;
133};
134
135export type SslConfig = {
136 /** Client-server SSL mode. */
137 sslMode?: alloydb.SslConfigSslModeEnum | (string & {});
138 /** Certificate authority source. Only `CA_SOURCE_MANAGED` is supported. */
139 caSource?: alloydb.SslConfigCaSourceEnum | (string & {});
140};
141
142export type DataplexConfig = {
143 /** Integrate AlloyDB PG resources with Dataplex. */
144 enabled?: boolean;
145};
146
147export type SecondaryConfig = {
148 /**
149 * Primary cluster name
150 * `projects/{project}/locations/{location}/clusters/{cluster}`.
151 * Required when `clusterType` is `SECONDARY`. Immutable.
152 */
153 primaryClusterName?: string;
154};
155
156export type ClusterProps = {
157 /**
158 * Cluster id (the `{cluster}` segment of
159 * `projects/{project}/locations/{location}/clusters/{cluster}`).
160 * If omitted, a unique name is generated from the stack, stage, and
161 * logical id. Must match `[a-z0-9-]{1,63}`. Immutable — changing it
162 * replaces the cluster.
163 */
164 clusterId?: string;
165 /**
166 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
167 * replaces the cluster. `US-CENTRAL1` is accepted and normalized to
168 * `us-central1`.
169 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
170 */
171 location?: string;
172 /**
173 * User-facing display name.
174 */
175 displayName?: string;
176 /**
177 * User labels. Alchemy ownership labels are merged in automatically.
178 */
179 labels?: Record<string, string>;
180 /**
181 * Arbitrary client annotations (distinct from labels).
182 */
183 annotations?: Record<string, string>;
184 /**
185 * Convenience alias for `networkConfig.network`. A bare network id
186 * (`default`) is expanded to
187 * `projects/{project}/global/networks/{network}`. Immutable.
188 */
189 network?: string;
190 /**
191 * Private IP network configuration. Immutable. Defaults to the project
192 * `default` network when Private Service Connect is not enabled.
193 */
194 networkConfig?: NetworkConfig;
195 /**
196 * Private Service Connect configuration. `pscEnabled` is immutable.
197 */
198 pscConfig?: PscConfig;
199 /**
200 * Customer-managed encryption. Immutable — changing it replaces the
201 * cluster.
202 */
203 encryptionConfig?: EncryptionConfig;
204 /**
205 * Database engine major version (`POSTGRES_14`, `POSTGRES_15`, …).
206 * Upgrading patches in place; downgrading replaces the cluster. If
207 * omitted at create time, AlloyDB picks a default.
208 */
209 databaseVersion?: alloydb.ClusterDatabaseVersionEnum | (string & {});
210 /**
211 * Cluster type. `SECONDARY` uses `clusters.createsecondary` and
212 * requires `secondaryConfig`. Immutable — changing it replaces the
213 * cluster.
214 * @default "PRIMARY"
215 */
216 clusterType?: alloydb.ClusterClusterTypeEnum | (string & {});
217 /**
218 * Cross-region replica config. Required for `SECONDARY` clusters.
219 * Immutable.
220 */
221 secondaryConfig?: SecondaryConfig;
222 /**
223 * Initial database user. Create-only; ignored on update.
224 */
225 initialUser?: InitialUser;
226 /**
227 * Automated backup policy. If omitted, AlloyDB applies its default
228 * (daily backups, 14-day retention).
229 */
230 automatedBackupPolicy?: AutomatedBackupPolicy;
231 /**
232 * Continuous backup (PITR) configuration.
233 */
234 continuousBackupConfig?: ContinuousBackupConfig;
235 /**
236 * Preferred maintenance windows and deny periods.
237 */
238 maintenanceUpdatePolicy?: MaintenanceUpdatePolicy;
239 /**
240 * Client-server SSL configuration.
241 */
242 sslConfig?: SslConfig;
243 /**
244 * Dataplex integration.
245 */
246 dataplexConfig?: DataplexConfig;
247 /**
248 * Subscription type (`STANDARD`, `TRIAL`).
249 */
250 subscriptionType?: alloydb.ClusterSubscriptionTypeEnum | (string & {});
251};
252
253export type Cluster = Resource<
254 "GCP.AlloyDB.Cluster",
255 ClusterProps,
256 {
257 /** Full resource name `projects/{project}/locations/{location}/clusters/{cluster}`. */
258 name: string;
259 /** Cluster id (last path segment). */
260 clusterId: string;
261 /** Project id. */
262 project: string;
263 /** Region id (`us-central1`, …). */
264 location: string;
265 /** User-facing display name. */
266 displayName: string | undefined;
267 /** User labels (Alchemy ownership labels stripped). */
268 labels: Record<string, string>;
269 /** Client annotations. */
270 annotations: Record<string, string>;
271 /** Serving state (`READY`, `EMPTY`, `CREATING`, …). */
272 state: string | undefined;
273 /** Cluster type (`PRIMARY`, `SECONDARY`). */
274 clusterType: string;
275 /** Database engine major version. */
276 databaseVersion: string | undefined;
277 /** VPC network, if any. */
278 network: string | undefined;
279 /** Private IP network configuration. */
280 networkConfig: NetworkConfig | undefined;
281 /** Private Service Connect configuration. */
282 pscConfig: PscConfig | undefined;
283 /** Customer-managed encryption, if any. */
284 encryptionConfig: EncryptionConfig | undefined;
285 /** Automated backup policy currently applied. */
286 automatedBackupPolicy: AutomatedBackupPolicy | undefined;
287 /** Continuous backup configuration currently applied. */
288 continuousBackupConfig: ContinuousBackupConfig | undefined;
289 /** SSL configuration currently applied. */
290 sslConfig: SslConfig | undefined;
291 /** Subscription type. */
292 subscriptionType: string | undefined;
293 /** System-generated UID. */
294 uid: string | undefined;
295 /** Whether the service is reconciling intended vs actual state. */
296 reconciling: boolean;
297 /** Secondary-cluster config, if any. */
298 secondaryConfig: SecondaryConfig | undefined;
299 /** RFC3339 creation timestamp. */
300 createTime: string | undefined;
301 /** RFC3339 last-update timestamp. */
302 updateTime: string | undefined;
303 },
304 never,
305 Providers
306>;
307
308/**
309 * An AlloyDB for PostgreSQL cluster (regional storage layer).
310 *
311 * Changing `clusterId`, `location`, `network` / `networkConfig`,
312 * `encryptionConfig`, `clusterType`, `secondaryConfig`, or
313 * `pscConfig.pscEnabled` replaces the cluster. A database version
314 * upgrade patches in place; a downgrade replaces.
315 *
316 * Provisioning typically takes several minutes. Delete always sends
317 * `force=true` so child instances do not block teardown.
318 *
319 * ### Creating a Cluster
320 * **Example:** Generated name with Private Service Connect
321 * ```typescript
322 * const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
323 * location: "us-central1",
324 * pscConfig: { pscEnabled: true },
325 * initialUser: { user: "postgres", password: "change-me" },
326 * automatedBackupPolicy: { enabled: false },
327 * continuousBackupConfig: { enabled: false },
328 * });
329 * ```
330 *
331 * **Example:** Explicit id, default VPC, and labels
332 * ```typescript
333 * const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
334 * clusterId: "app-db",
335 * location: "us-central1",
336 * network: "default",
337 * displayName: "app-db",
338 * labels: { env: "prod" },
339 * initialUser: { user: "postgres", password: "change-me" },
340 * });
341 * ```
342 *
343 * ### Backups
344 * **Example:** Disable automated and continuous backups
345 * ```typescript
346 * const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
347 * pscConfig: { pscEnabled: true },
348 * automatedBackupPolicy: { enabled: false },
349 * continuousBackupConfig: { enabled: false },
350 * });
351 * ```
352 *
353 * @resource
354 * @category AlloyDB
355 */
356export const Cluster = Resource<Cluster>("GCP.AlloyDB.Cluster");
357
358export class ClusterNotResolved extends Data.TaggedError(
359 "GCP.AlloyDB.ClusterNotResolved",
360)<{
361 name: string;
362}> {}
363
364export class ClusterNotReady extends Data.TaggedError(
365 "GCP.AlloyDB.ClusterNotReady",
366)<{
367 name: string;
368 state: string;
369}> {}
370
371export class ClusterFailed extends Data.TaggedError(
372 "GCP.AlloyDB.ClusterFailed",
373)<{
374 name: string;
375 state: string;
376}> {}
377
378export class ClusterStillExists extends Data.TaggedError(
379 "GCP.AlloyDB.ClusterStillExists",
380)<{
381 name: string;
382}> {}
383
384const lastSegment = (value: string | undefined) => {
385 if (value === undefined || value.length === 0) return "";
386 const trimmed = value.replace(/\/+$/, "");
387 const parts = trimmed.split("/");
388 return parts[parts.length - 1] || trimmed;
389};
390
391const normalizeLocation = (location: string) =>
392 lastSegment(location).toLowerCase();
393
394const normalizeClusterType = (type: string | undefined) => {
395 const value = (type ?? DEFAULT_CLUSTER_TYPE).toUpperCase();
396 return value === "CLUSTER_TYPE_UNSPECIFIED" ? DEFAULT_CLUSTER_TYPE : value;
397};
398
399const normalizeVersion = (version: string | undefined) => {
400 if (version === undefined || version.length === 0) return undefined;
401 const value = version.toUpperCase();
402 return value === "DATABASE_VERSION_UNSPECIFIED" ? undefined : value;
403};
404
405const parsePostgresVersion = (version: string | undefined) => {
406 const normalized = normalizeVersion(version);
407 if (normalized === undefined) return undefined;
408 const match = normalized.match(/^POSTGRES_(\d+)$/);
409 return match ? Number(match[1]) : undefined;
410};
411
412const versionDecreasing = (previous: string | undefined, next: string) => {
413 const oldN = parsePostgresVersion(previous);
414 const newN = parsePostgresVersion(next);
415 if (oldN === undefined || newN === undefined) return previous !== next;
416 return newN < oldN;
417};
418
419const rfc1035 = (name: string): string => {
420 let next = name
421 .toLowerCase()
422 .replace(/[^a-z0-9-]/g, "-")
423 .replace(/-+/g, "-")
424 .replace(/^-+|-+$/g, "");
425 if (!/^[a-z]/.test(next)) next = `c${next}`;
426 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
427 if (next.length === 0) return "cluster";
428 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
429 return next.slice(0, MAX_NAME_LENGTH);
430};
431
432const resourceName = (project: string, location: string, clusterId: string) =>
433 `projects/${project}/locations/${location}/clusters/${clusterId}`;
434
435const parseName = (name: string) => {
436 const parts = name.split("/").filter((part) => part.length > 0);
437 const clustersAt = parts.lastIndexOf("clusters");
438 const locationsAt = parts.lastIndexOf("locations");
439 const projectsAt = parts.lastIndexOf("projects");
440 return {
441 project:
442 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
443 location:
444 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
445 clusterId:
446 clustersAt >= 0 && parts[clustersAt + 1]
447 ? parts[clustersAt + 1]!
448 : lastSegment(name),
449 };
450};
451
452const userLabels = (
453 labels: Record<string, string | undefined> | null | undefined,
454): Record<string, string> => stripInternalLabels(tagRecord(labels));
455
456const stringMapOf = (
457 map: Record<string, string | undefined> | null | undefined,
458): Record<string, string> =>
459 Object.fromEntries(
460 Object.entries(map ?? {}).filter(
461 (entry): entry is [string, string] =>
462 entry[1] !== undefined && entry[1].length > 0,
463 ),
464 );
465
466const expandNetwork = (project: string, network: string | undefined) => {
467 if (network === undefined || network.length === 0) return undefined;
468 if (network.includes("/")) return network;
469 return `projects/${project}/global/networks/${network}`;
470};
471
472const networkIdOf = (network: string | undefined) => lastSegment(network);
473
474const toId = (id: string, clusterId: string | undefined, existing?: string) =>
475 Effect.gen(function* () {
476 return (
477 clusterId ??
478 existing ??
479 rfc1035(
480 yield* createPhysicalName({
481 id,
482 maxLength: MAX_NAME_LENGTH,
483 lowercase: true,
484 }),
485 )
486 );
487 });
488
489const canonical = (value: unknown): unknown => {
490 if (value === undefined || value === null) return undefined;
491 if (typeof value === "boolean" || typeof value === "number") return value;
492 if (typeof value === "string") {
493 return value.length === 0 ? undefined : value;
494 }
495 if (Array.isArray(value)) {
496 const items = value.map(canonical).filter((item) => item !== undefined);
497 return items.length === 0 ? undefined : items;
498 }
499 if (typeof value === "object") {
500 const entries = Object.entries(value as Record<string, unknown>)
501 .map(([key, item]) => [key, canonical(item)] as const)
502 .filter(([, item]) => item !== undefined)
503 .sort(([left], [right]) => left.localeCompare(right));
504 if (entries.length === 0) return undefined;
505 return Object.fromEntries(entries);
506 }
507 return undefined;
508};
509
510const fingerprint = (value: unknown): string =>
511 JSON.stringify(canonical(value) ?? null);
512
513const specifiedEquals = (desired: unknown, observed: unknown): boolean => {
514 if (desired === undefined) return true;
515 if (
516 typeof desired === "boolean" ||
517 typeof desired === "number" ||
518 typeof desired === "string" ||
519 Array.isArray(desired)
520 ) {
521 return fingerprint(desired) === fingerprint(observed);
522 }
523 if (desired !== null && typeof desired === "object") {
524 const obs =
525 observed !== null && typeof observed === "object"
526 ? (observed as Record<string, unknown>)
527 : {};
528 return Object.entries(desired as Record<string, unknown>).every(
529 ([key, value]) => specifiedEquals(value, obs[key]),
530 );
531 }
532 return fingerprint(desired) === fingerprint(observed);
533};
534
535const toNetworkConfig = (
536 config: alloydb.NetworkConfig | NetworkConfig | undefined,
537 fallbackNetwork?: string,
538): NetworkConfig | undefined => {
539 const network = config?.network ?? fallbackNetwork;
540 const allocatedIpRange = config?.allocatedIpRange;
541 if (
542 (network === undefined || network.length === 0) &&
543 (allocatedIpRange === undefined || allocatedIpRange.length === 0)
544 ) {
545 return undefined;
546 }
547 return {
548 network: network || undefined,
549 allocatedIpRange: allocatedIpRange || undefined,
550 };
551};
552
553const toPscConfig = (
554 config: alloydb.PscConfig | PscConfig | undefined,
555): PscConfig | undefined => {
556 if (config === undefined) return undefined;
557 return { pscEnabled: config.pscEnabled === true };
558};
559
560const toEncryptionConfig = (
561 config: alloydb.EncryptionConfig | EncryptionConfig | undefined,
562): EncryptionConfig | undefined => {
563 const kmsKeyName = config?.kmsKeyName;
564 if (kmsKeyName === undefined || kmsKeyName.length === 0) return undefined;
565 return { kmsKeyName };
566};
567
568const toSslConfig = (
569 config: alloydb.SslConfig | SslConfig | undefined,
570): SslConfig | undefined => {
571 if (config === undefined) return undefined;
572 return {
573 sslMode: config.sslMode,
574 caSource: config.caSource,
575 };
576};
577
578const toSecondaryConfig = (
579 config: alloydb.SecondaryConfig | SecondaryConfig | undefined,
580): SecondaryConfig | undefined => {
581 const primaryClusterName = config?.primaryClusterName;
582 if (primaryClusterName === undefined || primaryClusterName.length === 0) {
583 return undefined;
584 }
585 return { primaryClusterName };
586};
587
588const toAutomatedBackupPolicy = (
589 policy: alloydb.AutomatedBackupPolicy | AutomatedBackupPolicy | undefined,
590): AutomatedBackupPolicy | undefined => {
591 if (policy === undefined) return undefined;
592 return canonical(policy) as AutomatedBackupPolicy | undefined;
593};
594
595const toContinuousBackupConfig = (
596 config: alloydb.ContinuousBackupConfig | ContinuousBackupConfig | undefined,
597): ContinuousBackupConfig | undefined => {
598 if (config === undefined) return undefined;
599 return canonical(config) as ContinuousBackupConfig | undefined;
600};
601
602const isAvailable = (state: string | undefined) => {
603 const value = (state ?? "").toUpperCase();
604 return value === "READY" || value === "EMPTY";
605};
606
607const isFailed = (state: string | undefined) =>
608 (state ?? "").toUpperCase() === "FAILED";
609
610const toAttrs = (cluster: alloydb.Cluster, project: string) => {
611 const name = cluster.name ?? "";
612 const parsed = parseName(name);
613 const networkConfig = toNetworkConfig(cluster.networkConfig, cluster.network);
614 return {
615 name,
616 clusterId: parsed.clusterId,
617 project: parsed.project || project,
618 location: parsed.location,
619 displayName: cluster.displayName,
620 labels: userLabels(cluster.labels),
621 annotations: stringMapOf(cluster.annotations),
622 state: cluster.state,
623 clusterType: normalizeClusterType(cluster.clusterType),
624 databaseVersion: cluster.databaseVersion,
625 network: networkConfig?.network ?? cluster.network,
626 networkConfig,
627 pscConfig: toPscConfig(cluster.pscConfig),
628 encryptionConfig: toEncryptionConfig(cluster.encryptionConfig),
629 automatedBackupPolicy: toAutomatedBackupPolicy(
630 cluster.automatedBackupPolicy,
631 ),
632 continuousBackupConfig: toContinuousBackupConfig(
633 cluster.continuousBackupConfig,
634 ),
635 sslConfig: toSslConfig(cluster.sslConfig),
636 subscriptionType: cluster.subscriptionType,
637 uid: cluster.uid,
638 reconciling: cluster.reconciling === true,
639 secondaryConfig: toSecondaryConfig(cluster.secondaryConfig),
640 createTime: cluster.createTime,
641 updateTime: cluster.updateTime,
642 };
643};
644
645const isPlaceholder = (cluster: alloydb.Cluster) => {
646 const name = cluster.name ?? "";
647 return name.endsWith("/clusters/-") || name.endsWith("/clusters/");
648};
649
650const getByName = (name: string) =>
651 alloydb
652 .getProjectsLocationsClusters({ name })
653 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
654
655const waitUntilExists = (name: string) =>
656 getByName(name).pipe(
657 Effect.flatMap((cluster) =>
658 cluster
659 ? Effect.succeed(cluster)
660 : Effect.fail(new ClusterNotResolved({ name })),
661 ),
662 Effect.retry({
663 while: (error) => error._tag === "GCP.AlloyDB.ClusterNotResolved",
664 times: 8,
665 schedule: Schedule.spaced("1 second"),
666 }),
667 );
668
669const waitUntilReady = (name: string) =>
670 Effect.gen(function* () {
671 const cluster = yield* getByName(name);
672 if (cluster === undefined) {
673 return yield* new ClusterNotReady({ name, state: "MISSING" });
674 }
675 if (isFailed(cluster.state)) {
676 return yield* new ClusterFailed({
677 name,
678 state: cluster.state ?? "FAILED",
679 });
680 }
681 if (!(isAvailable(cluster.state) && cluster.reconciling !== true)) {
682 return yield* new ClusterNotReady({
683 name,
684 state: cluster.state ?? "STATE_UNSPECIFIED",
685 });
686 }
687 return cluster;
688 }).pipe(
689 Effect.retry({
690 while: (error) => error._tag === "GCP.AlloyDB.ClusterNotReady",
691 times: 120,
692 schedule: Schedule.spaced("15 seconds"),
693 }),
694 );
695
696const waitUntilGone = (name: string) =>
697 getByName(name).pipe(
698 Effect.flatMap((cluster) =>
699 cluster === undefined
700 ? Effect.void
701 : Effect.fail(new ClusterStillExists({ name })),
702 ),
703 Effect.retry({
704 while: (error) => error._tag === "GCP.AlloyDB.ClusterStillExists",
705 times: 120,
706 schedule: Schedule.spaced("15 seconds"),
707 }),
708 );
709
710const desiredNetworkConfig = (
711 news: ClusterProps,
712 project: string,
713 pscEnabled: boolean,
714): NetworkConfig | undefined => {
715 const network = expandNetwork(
716 project,
717 news.networkConfig?.network ?? news.network,
718 );
719 const allocatedIpRange = news.networkConfig?.allocatedIpRange;
720 if (pscEnabled) {
721 if (
722 (network === undefined || network.length === 0) &&
723 (allocatedIpRange === undefined || allocatedIpRange.length === 0)
724 ) {
725 return undefined;
726 }
727 return { network, allocatedIpRange };
728 }
729 return {
730 network: network ?? expandNetwork(project, DEFAULT_NETWORK),
731 allocatedIpRange,
732 };
733};
734
735const toCreateBody = (
736 news: ClusterProps,
737 desiredLabels: Record<string, string>,
738 clusterType: string,
739 networkConfig: NetworkConfig | undefined,
740 pscEnabled: boolean,
741): alloydb.Cluster => {
742 const body: alloydb.Cluster = {
743 displayName: news.displayName,
744 labels: desiredLabels,
745 clusterType,
746 };
747 if (news.annotations !== undefined) {
748 body.annotations = news.annotations;
749 }
750 if (networkConfig !== undefined) {
751 body.networkConfig = networkConfig;
752 }
753 if (news.pscConfig !== undefined || pscEnabled) {
754 body.pscConfig = { pscEnabled };
755 }
756 const encryption = toEncryptionConfig(news.encryptionConfig);
757 if (encryption !== undefined) {
758 body.encryptionConfig = encryption;
759 }
760 const version = normalizeVersion(news.databaseVersion);
761 if (version !== undefined) {
762 body.databaseVersion = version;
763 }
764 if (clusterType === "SECONDARY" && news.secondaryConfig !== undefined) {
765 body.secondaryConfig = news.secondaryConfig;
766 }
767 if (news.initialUser !== undefined) {
768 body.initialUser = news.initialUser;
769 }
770 if (news.automatedBackupPolicy !== undefined) {
771 body.automatedBackupPolicy = news.automatedBackupPolicy;
772 }
773 if (news.continuousBackupConfig !== undefined) {
774 body.continuousBackupConfig = news.continuousBackupConfig;
775 }
776 if (news.maintenanceUpdatePolicy !== undefined) {
777 body.maintenanceUpdatePolicy = news.maintenanceUpdatePolicy;
778 }
779 if (news.sslConfig !== undefined) {
780 body.sslConfig = news.sslConfig;
781 }
782 if (news.dataplexConfig !== undefined) {
783 body.dataplexConfig = news.dataplexConfig;
784 }
785 if (news.subscriptionType !== undefined) {
786 body.subscriptionType = news.subscriptionType;
787 }
788 return body;
789};
790
791export const ClusterProvider = () =>
792 Provider.succeed(Cluster, {
793 stables: ["name", "clusterId", "project", "location", "uid", "createTime"],
794
795 diff: Effect.fn(function* ({ news, olds, output }) {
796 if (!isResolved(news)) return undefined;
797 const env = yield* GcpEnvironment.current;
798
799 const previousId = olds?.clusterId ?? output?.clusterId;
800 const nextId = news.clusterId ?? previousId;
801 const previousLocation = normalizeLocation(
802 olds?.location ?? output?.location ?? env.region,
803 );
804 const nextLocation = normalizeLocation(
805 news.location ?? output?.location ?? env.region,
806 );
807 const previousType = normalizeClusterType(
808 olds?.clusterType ?? output?.clusterType,
809 );
810 const nextType = normalizeClusterType(
811 news.clusterType ?? output?.clusterType,
812 );
813 const previousNetwork = networkIdOf(
814 olds?.networkConfig?.network ??
815 olds?.network ??
816 output?.networkConfig?.network ??
817 output?.network,
818 );
819 const nextNetwork = networkIdOf(
820 news.networkConfig?.network ?? news.network ?? previousNetwork,
821 );
822 const previousRange =
823 olds?.networkConfig?.allocatedIpRange ??
824 output?.networkConfig?.allocatedIpRange ??
825 "";
826 const nextRange = news.networkConfig?.allocatedIpRange ?? previousRange;
827 const previousPsc =
828 olds?.pscConfig?.pscEnabled === true ||
829 output?.pscConfig?.pscEnabled === true;
830 const nextPsc =
831 news.pscConfig?.pscEnabled !== undefined
832 ? news.pscConfig.pscEnabled === true
833 : previousPsc;
834 const previousKey =
835 olds?.encryptionConfig?.kmsKeyName ??
836 output?.encryptionConfig?.kmsKeyName ??
837 "";
838 const nextKey = news.encryptionConfig?.kmsKeyName ?? previousKey;
839 const previousPrimary =
840 olds?.secondaryConfig?.primaryClusterName ??
841 output?.secondaryConfig?.primaryClusterName ??
842 "";
843 const nextPrimary =
844 news.secondaryConfig?.primaryClusterName ?? previousPrimary;
845 const previousVersion = normalizeVersion(
846 olds?.databaseVersion ?? output?.databaseVersion,
847 );
848 const nextVersion = normalizeVersion(news.databaseVersion);
849 const downgrade =
850 nextVersion !== undefined &&
851 previousVersion !== undefined &&
852 versionDecreasing(previousVersion, nextVersion);
853
854 const replace =
855 (previousId !== undefined &&
856 nextId !== undefined &&
857 nextId !== previousId) ||
858 previousLocation !== nextLocation ||
859 previousType !== nextType ||
860 previousNetwork !== nextNetwork ||
861 previousRange !== nextRange ||
862 previousPsc !== nextPsc ||
863 previousKey !== nextKey ||
864 previousPrimary !== nextPrimary ||
865 downgrade;
866
867 if (!replace) return undefined;
868 return {
869 action: "replace" as const,
870 deleteFirst:
871 previousLocation === nextLocation &&
872 previousId !== undefined &&
873 nextId === previousId,
874 };
875 }),
876
877 read: Effect.fn(function* ({ id, olds, output }) {
878 const env = yield* GcpEnvironment.current;
879 const clusterId = yield* toId(id, olds?.clusterId, output?.clusterId);
880 const location = normalizeLocation(
881 olds?.location ?? output?.location ?? env.region,
882 );
883 const name =
884 output?.name ?? resourceName(env.project, location, clusterId);
885 const existing = yield* getByName(name);
886 if (existing === undefined) return undefined;
887 const attrs = toAttrs(existing, env.project);
888 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
889 ? attrs
890 : Unowned(attrs);
891 }),
892
893 list: () =>
894 Effect.gen(function* () {
895 const env = yield* GcpEnvironment.current;
896 return yield* alloydb.listProjectsLocationsClusters
897 .pages({
898 parent: `projects/${env.project}/locations/-`,
899 pageSize: 1000,
900 })
901 .pipe(
902 Stream.flatMap((page) => Stream.fromIterable(page.clusters ?? [])),
903 Stream.filter(
904 (cluster) =>
905 !isPlaceholder(cluster) &&
906 Object.keys(cluster.labels ?? {}).some((key) =>
907 key.startsWith("alchemy-"),
908 ),
909 ),
910 Stream.map((cluster) => toAttrs(cluster, env.project)),
911 Stream.runCollect,
912 Effect.map((chunk) => Array.from(chunk)),
913 Effect.catchTag("NotFound", () => Effect.succeed([])),
914 );
915 }),
916
917 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
918 const env = yield* GcpEnvironment.current;
919 const clusterId = yield* toId(id, news.clusterId, output?.clusterId);
920 const location = normalizeLocation(
921 news.location ?? output?.location ?? env.region,
922 );
923 const name = resourceName(env.project, location, clusterId);
924 const clusterType = normalizeClusterType(news.clusterType);
925 const pscEnabled = news.pscConfig?.pscEnabled === true;
926 const networkConfig = desiredNetworkConfig(news, env.project, pscEnabled);
927 const desiredLabels = {
928 ...toLabels(news.labels),
929 ...(yield* createInternalLabels(id)),
930 };
931
932 let current = yield* getByName(output?.name ?? name);
933
934 if (current === undefined) {
935 const parent = `projects/${env.project}/locations/${location}`;
936 const body = toCreateBody(
937 news,
938 desiredLabels,
939 clusterType,
940 networkConfig,
941 pscEnabled,
942 );
943 const created = yield* (
944 clusterType === "SECONDARY"
945 ? alloydb.createsecondaryProjectsLocationsClusters({
946 parent,
947 clusterId,
948 body,
949 })
950 : alloydb.createProjectsLocationsClusters({
951 parent,
952 clusterId,
953 body,
954 })
955 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
956 if (created !== undefined) {
957 yield* waitForOperation(created);
958 }
959 current = yield* waitUntilExists(name);
960 }
961
962 if (current === undefined) {
963 return yield* new ClusterNotResolved({ name });
964 }
965
966 if (!isAvailable(current.state) || current.reconciling === true) {
967 current = yield* waitUntilReady(name);
968 }
969
970 const desiredVersion = normalizeVersion(news.databaseVersion);
971 const currentVersion = normalizeVersion(current.databaseVersion);
972 const versionChanged =
973 desiredVersion !== undefined &&
974 currentVersion !== undefined &&
975 desiredVersion !== currentVersion &&
976 !versionDecreasing(currentVersion, desiredVersion);
977
978 const observedLabels = tagRecord(current.labels);
979 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
980 const labelsChanged = upsert.length > 0 || removed.length > 0;
981 // AlloyDB has been observed to omit `displayName` from reads; when it
982 // does, compare against what was last applied instead of re-patching
983 // on every deploy.
984 const observedDisplayName =
985 current.displayName ?? output?.displayName ?? olds?.displayName;
986 const displayNameChanged =
987 (observedDisplayName ?? "") !== (news.displayName ?? "");
988 const annotationsChanged =
989 news.annotations !== undefined &&
990 fingerprint(stringMapOf(current.annotations)) !==
991 fingerprint(news.annotations);
992 const backupChanged =
993 news.automatedBackupPolicy !== undefined &&
994 !specifiedEquals(
995 news.automatedBackupPolicy,
996 current.automatedBackupPolicy,
997 );
998 const continuousChanged =
999 news.continuousBackupConfig !== undefined &&
1000 !specifiedEquals(
1001 news.continuousBackupConfig,
1002 current.continuousBackupConfig,
1003 );
1004 const maintenanceChanged =
1005 news.maintenanceUpdatePolicy !== undefined &&
1006 !specifiedEquals(
1007 news.maintenanceUpdatePolicy,
1008 current.maintenanceUpdatePolicy,
1009 );
1010 const sslChanged =
1011 news.sslConfig !== undefined &&
1012 !specifiedEquals(news.sslConfig, toSslConfig(current.sslConfig));
1013 const dataplexChanged =
1014 news.dataplexConfig !== undefined &&
1015 !specifiedEquals(news.dataplexConfig, current.dataplexConfig);
1016 const subscriptionChanged =
1017 news.subscriptionType !== undefined &&
1018 (current.subscriptionType ?? "") !== news.subscriptionType;
1019
1020 if (
1021 labelsChanged ||
1022 displayNameChanged ||
1023 annotationsChanged ||
1024 backupChanged ||
1025 continuousChanged ||
1026 maintenanceChanged ||
1027 sslChanged ||
1028 dataplexChanged ||
1029 subscriptionChanged ||
1030 versionChanged
1031 ) {
1032 const updateMask = [
1033 labelsChanged ? "labels" : undefined,
1034 displayNameChanged ? "displayName" : undefined,
1035 annotationsChanged ? "annotations" : undefined,
1036 backupChanged ? "automatedBackupPolicy" : undefined,
1037 continuousChanged ? "continuousBackupConfig" : undefined,
1038 maintenanceChanged ? "maintenanceUpdatePolicy" : undefined,
1039 sslChanged ? "sslConfig" : undefined,
1040 dataplexChanged ? "dataplexConfig" : undefined,
1041 subscriptionChanged ? "subscriptionType" : undefined,
1042 versionChanged ? "databaseVersion" : undefined,
1043 ].filter((field): field is string => field !== undefined);
1044
1045 const patched = yield* alloydb
1046 .patchProjectsLocationsClusters({
1047 name,
1048 updateMask: updateMask.join(","),
1049 body: {
1050 name,
1051 labels: desiredLabels,
1052 displayName: news.displayName,
1053 annotations: news.annotations,
1054 automatedBackupPolicy: news.automatedBackupPolicy,
1055 continuousBackupConfig: news.continuousBackupConfig,
1056 maintenanceUpdatePolicy: news.maintenanceUpdatePolicy,
1057 sslConfig: news.sslConfig,
1058 dataplexConfig: news.dataplexConfig,
1059 subscriptionType: news.subscriptionType,
1060 databaseVersion: desiredVersion,
1061 },
1062 })
1063 .pipe(
1064 Effect.retry({
1065 while: (error) => error._tag === "Conflict",
1066 times: 40,
1067 schedule: Schedule.spaced("15 seconds"),
1068 }),
1069 );
1070 yield* waitForOperation(patched);
1071 current = yield* waitUntilReady(name);
1072 }
1073
1074 // When the read omits `displayName`, the desired value is what is
1075 // deployed: it was sent on create, patched above, or observed equal.
1076 return {
1077 ...toAttrs(current, env.project),
1078 displayName: current.displayName ?? news.displayName,
1079 };
1080 }),
1081
1082 delete: Effect.fn(function* ({ output }) {
1083 const operation = yield* alloydb
1084 .deleteProjectsLocationsClusters({
1085 name: output.name,
1086 force: true,
1087 })
1088 .pipe(
1089 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1090 Effect.retry({
1091 while: (error) => error._tag === "Conflict",
1092 times: 40,
1093 schedule: Schedule.spaced("15 seconds"),
1094 }),
1095 );
1096 if (operation !== undefined) {
1097 yield* waitForDeleteOperation(operation);
1098 }
1099 yield* waitUntilGone(output.name);
1100 }),
1101 });