1import * as rds from "@distilled.cloud/aws/rds";
2import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { isResolved } from "../../Diff.ts";
9import { toWireDays, toWireSeconds } from "../../Util/Duration.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import type { Providers } from "../Providers.ts";
14import { createInternalTags, diffTags } from "../../Tags.ts";
15
16export interface DBClusterProps {
17 /**
18 * Cluster identifier. If omitted, Alchemy generates one.
19 */
20 dbClusterIdentifier?: string;
21 /**
22 * Aurora engine, such as `aurora-postgresql`.
23 */
24 engine: string;
25 /**
26 * Optional engine version.
27 */
28 engineVersion?: string;
29 /**
30 * Optional database name created with the cluster.
31 */
32 databaseName?: string;
33 /**
34 * Subnet group used by the cluster.
35 */
36 dbSubnetGroupName?: string;
37 /**
38 * Cluster parameter group name.
39 */
40 dbClusterParameterGroupName?: string;
41 /**
42 * Security groups attached to the cluster.
43 */
44 vpcSecurityGroupIds?: string[];
45 /**
46 * Optional listener port.
47 */
48 port?: number;
49 /**
50 * Enable IAM database authentication.
51 */
52 enableIAMDatabaseAuthentication?: boolean;
53 /**
54 * Enable Aurora Data API / HTTP endpoint support.
55 */
56 enableHttpEndpoint?: boolean;
57 /**
58 * Engine mode, for example `provisioned` or `serverless`.
59 * Changing it forces replacement unless `AllowEngineModeChange` applies.
60 */
61 engineMode?: string;
62 /**
63 * Serverless v2 scaling configuration.
64 */
65 serverlessV2ScalingConfiguration?: rds.ServerlessV2ScalingConfiguration;
66 /**
67 * Serverless v1 scaling configuration. In-place modify.
68 */
69 scalingConfiguration?: rds.ScalingConfiguration;
70 /**
71 * Availability zones for cluster placement. Immutable — forces replacement.
72 */
73 availabilityZones?: string[];
74 /**
75 * Backup retention period (e.g. `"7 days"` or `Duration.days(7)`).
76 * Sent to the API in whole days. In-place modify.
77 */
78 backupRetentionPeriod?: Duration.Input;
79 /**
80 * Daily backup window, e.g. `07:00-09:00`. In-place modify.
81 */
82 preferredBackupWindow?: string;
83 /**
84 * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify.
85 */
86 preferredMaintenanceWindow?: string;
87 /**
88 * Backtrack window (Aurora MySQL only), e.g. `"1 hour"`. Sent to the API
89 * in whole seconds. In-place modify.
90 */
91 backtrackWindow?: Duration.Input;
92 /**
93 * Option group name. In-place modify.
94 */
95 optionGroupName?: string;
96 /**
97 * Log types to export to CloudWatch Logs. Diffed against observed state and
98 * applied via the delta-shaped `CloudwatchLogsExportConfiguration` on modify.
99 */
100 enableCloudwatchLogsExports?: string[];
101 /**
102 * Auto minor version upgrade. In-place modify.
103 */
104 autoMinorVersionUpgrade?: boolean;
105 /**
106 * Allow a major engine-version upgrade during a modify. Modify-only flag.
107 */
108 allowMajorVersionUpgrade?: boolean;
109 /**
110 * Enhanced-monitoring granularity (e.g. `"60 seconds"`). Sent to the API
111 * in whole seconds (valid: 0, 1, 5, 10, 15, 30, 60). In-place modify.
112 */
113 monitoringInterval?: Duration.Input;
114 /**
115 * IAM role ARN for enhanced monitoring. In-place modify.
116 */
117 monitoringRoleArn?: string;
118 /**
119 * Enable Performance Insights. In-place modify.
120 */
121 enablePerformanceInsights?: boolean;
122 /**
123 * KMS key for Performance Insights. In-place modify.
124 */
125 performanceInsightsKMSKeyId?: string;
126 /**
127 * Performance Insights retention (e.g. `"7 days"`). Sent to the API in
128 * whole days (valid: 7, 731, or month multiples). In-place modify.
129 */
130 performanceInsightsRetentionPeriod?: Duration.Input;
131 /**
132 * Network type: `IPV4` | `DUAL`. In-place modify.
133 */
134 networkType?: string;
135 /**
136 * CA certificate identifier. In-place modify.
137 */
138 caCertificateIdentifier?: string;
139 /**
140 * KMS key used to encrypt the managed master user secret. In-place modify.
141 */
142 masterUserSecretKmsKeyId?: string;
143 /**
144 * Rotate the managed master user password on the next reconcile.
145 */
146 rotateMasterUserPassword?: boolean;
147 /**
148 * Enable global write forwarding (secondary regions of a global cluster).
149 */
150 enableGlobalWriteForwarding?: boolean;
151 /**
152 * Enable local write forwarding (Aurora reader endpoints). In-place modify.
153 */
154 enableLocalWriteForwarding?: boolean;
155 /**
156 * Join this cluster to an Aurora global cluster. Immutable on create.
157 */
158 globalClusterIdentifier?: string;
159 /**
160 * Instance class for a provisioned multi-AZ cluster. In-place modify.
161 */
162 dbClusterInstanceClass?: string;
163 /**
164 * Allocated storage (GiB) for a provisioned multi-AZ cluster. In-place.
165 */
166 allocatedStorage?: number;
167 /**
168 * Storage type (provisioned multi-AZ cluster). In-place modify.
169 */
170 storageType?: string;
171 /**
172 * Provisioned IOPS (provisioned multi-AZ cluster). In-place modify.
173 */
174 iops?: number;
175 /**
176 * Whether a provisioned cluster is publicly reachable. In-place modify.
177 */
178 publiclyAccessible?: boolean;
179 /**
180 * Engine lifecycle support setting. Immutable — forces replacement.
181 */
182 engineLifecycleSupport?: string;
183 /**
184 * Whether to copy tags to snapshots.
185 */
186 copyTagsToSnapshot?: boolean;
187 /**
188 * Whether to block accidental deletion.
189 */
190 deletionProtection?: boolean;
191 /**
192 * Whether the storage is encrypted. Immutable — forces replacement.
193 */
194 storageEncrypted?: boolean;
195 /**
196 * Optional KMS key used for storage encryption. Immutable — forces replace.
197 */
198 kmsKeyId?: string;
199 /**
200 * Let RDS manage the master user password in Secrets Manager.
201 */
202 manageMasterUserPassword?: boolean;
203 /**
204 * Explicit master username when not deriving credentials from a secret.
205 */
206 masterUsername?: string;
207 /**
208 * Explicit master password when not deriving credentials from a secret.
209 */
210 masterUserPassword?: Redacted.Redacted<string>;
211 /**
212 * Existing Secrets Manager secret ARN whose JSON payload contains
213 * `username` and `password`.
214 */
215 masterUserSecretArn?: string;
216 /**
217 * User-defined tags.
218 */
219 tags?: Record<string, string>;
220}
221
222export interface DBCluster extends Resource<
223 "AWS.RDS.DBCluster",
224 DBClusterProps,
225 {
226 /**
227 * Identifier of the cluster.
228 */
229 dbClusterIdentifier: string;
230 /**
231 * ARN of the cluster.
232 */
233 dbClusterArn: string;
234 /**
235 * Subnet group the cluster is placed in.
236 */
237 dbSubnetGroupName: string | undefined;
238 /**
239 * Writer endpoint DNS address.
240 */
241 endpoint: string | undefined;
242 /**
243 * Load-balanced reader endpoint DNS address.
244 */
245 readerEndpoint: string | undefined;
246 /**
247 * Port the database listens on.
248 */
249 port: number | undefined;
250 /**
251 * Database engine (e.g. `aurora-postgresql`).
252 */
253 engine: string;
254 /**
255 * Engine version in use.
256 */
257 engineVersion: string | undefined;
258 /**
259 * Status of the cluster (e.g. `available`).
260 */
261 status: string | undefined;
262 /**
263 * Name of the initial database.
264 */
265 databaseName: string | undefined;
266 /**
267 * Master username.
268 */
269 masterUsername: string | undefined;
270 /**
271 * ARN of the Secrets Manager secret holding master credentials.
272 */
273 masterUserSecretArn: string | undefined;
274 /**
275 * Security groups attached to the cluster.
276 */
277 vpcSecurityGroupIds: string[];
278 /**
279 * Whether the Data API HTTP endpoint is enabled.
280 */
281 httpEndpointEnabled: boolean | undefined;
282 /**
283 * Allocated storage in GiB (provisioned engines).
284 */
285 allocatedStorage: number | undefined;
286 /**
287 * Backup retention period in days.
288 */
289 backupRetentionPeriod: number | undefined;
290 /**
291 * Daily backup window (`hh:mm-hh:mm` UTC).
292 */
293 preferredBackupWindow: string | undefined;
294 /**
295 * Weekly maintenance window.
296 */
297 preferredMaintenanceWindow: string | undefined;
298 /**
299 * Whether storage is encrypted.
300 */
301 storageEncrypted: boolean | undefined;
302 /**
303 * KMS key used for storage encryption.
304 */
305 kmsKeyId: string | undefined;
306 /**
307 * Whether deletion protection is enabled.
308 */
309 deletionProtection: boolean | undefined;
310 /**
311 * Whether IAM database authentication is enabled.
312 */
313 iamDatabaseAuthenticationEnabled: boolean | undefined;
314 /**
315 * Engine mode (e.g. `provisioned`).
316 */
317 engineMode: string | undefined;
318 /**
319 * Member instances with their writer flag and promotion tier.
320 */
321 dbClusterMembers: Array<{
322 dbInstanceIdentifier: string | undefined;
323 isClusterWriter: boolean | undefined;
324 promotionTier: number | undefined;
325 }>;
326 /**
327 * Immutable region-unique cluster resource ID (used in IAM auth ARNs).
328 */
329 dbClusterResourceId: string | undefined;
330 /**
331 * Route 53 hosted zone ID of the cluster endpoints.
332 */
333 hostedZoneId: string | undefined;
334 /**
335 * Whether the cluster has instances in multiple AZs.
336 */
337 multiAZ: boolean | undefined;
338 /**
339 * Log types exported to CloudWatch Logs.
340 */
341 enabledCloudwatchLogsExports: string[];
342 /**
343 * Whether cluster tags are copied to snapshots.
344 */
345 copyTagsToSnapshot: boolean | undefined;
346 /**
347 * Time the cluster was created (ISO 8601).
348 */
349 clusterCreateTime: string | undefined;
350 /**
351 * Serverless v2 platform version.
352 */
353 serverlessV2PlatformVersion: string | undefined;
354 /**
355 * Enhanced-monitoring granularity in seconds.
356 */
357 monitoringInterval: number | undefined;
358 /**
359 * Whether Performance Insights is enabled.
360 */
361 performanceInsightsEnabled: boolean | undefined;
362 /**
363 * Instance class for Multi-AZ DB clusters.
364 */
365 dbClusterInstanceClass: string | undefined;
366 /**
367 * Storage type (e.g. `aurora`, `aurora-iopt1`).
368 */
369 storageType: string | undefined;
370 /**
371 * Provisioned IOPS (Multi-AZ DB clusters).
372 */
373 iops: number | undefined;
374 /**
375 * Network type (`IPV4` or `DUAL`).
376 */
377 networkType: string | undefined;
378 /**
379 * Custom endpoint ARNs associated with the cluster.
380 */
381 customEndpoints: string[];
382 /**
383 * Tags on the cluster.
384 */
385 tags: Record<string, string>;
386 },
387 never,
388 Providers
389> {}
390
391/**
392 * An Aurora DB cluster.
393 *
394 * `DBCluster` owns the writer and reader endpoints, cluster-wide networking,
395 * and Data API enablement. It can bootstrap master credentials directly or by
396 * reading a Secrets Manager secret that contains `username` and `password`.
397 *
398 * It exposes the full backup, maintenance, monitoring, performance-insights,
399 * encryption, scaling, and log-export surface of `createDBCluster` /
400 * `modifyDBCluster`. Mutable fields are reconciled in place against the
401 * observed cloud state; immutable fields (`engine`, `databaseName`,
402 * `dbSubnetGroupName`, `storageEncrypted`, `kmsKeyId`, `engineMode`,
403 * `globalClusterIdentifier`, `availabilityZones`, `engineLifecycleSupport`)
404 * force a replacement.
405 * ### Serverless v2 Cluster
406 * **Example:** Aurora Postgres serverless-v2
407 * ```typescript
408 * const cluster = yield* DBCluster("Cluster", {
409 * engine: "aurora-postgresql",
410 * engineMode: "provisioned",
411 * serverlessV2ScalingConfiguration: { MinCapacity: 0.5, MaxCapacity: 4 },
412 * manageMasterUserPassword: true,
413 * masterUsername: "alchemy",
414 * backupRetentionPeriod: "7 days",
415 * deletionProtection: false,
416 * });
417 * ```
418 *
419 * ### Logs & Monitoring
420 * **Example:** Export logs and enable Performance Insights
421 * ```typescript
422 * const cluster = yield* DBCluster("Cluster", {
423 * engine: "aurora-postgresql",
424 * enableCloudwatchLogsExports: ["postgresql"],
425 * enablePerformanceInsights: true,
426 * monitoringInterval: "60 seconds",
427 * monitoringRoleArn: monitoringRole.roleArn,
428 * });
429 * ```
430 *
431 * @resource
432 */
433export const DBCluster = Resource<DBCluster>("AWS.RDS.DBCluster");
434
435const toTagRecord = (
436 tags: Array<{ Key?: string; Value?: string }> | undefined,
437): Record<string, string> =>
438 Object.fromEntries(
439 (tags ?? [])
440 .filter(
441 (tag): tag is { Key: string; Value: string } =>
442 typeof tag.Key === "string" && typeof tag.Value === "string",
443 )
444 .map((tag) => [tag.Key, tag.Value]),
445 );
446
447const resolveMasterCredentials = (props: DBClusterProps) =>
448 Effect.gen(function* () {
449 if (props.masterUserSecretArn) {
450 const value = yield* secretsmanager.getSecretValue({
451 SecretId: props.masterUserSecretArn,
452 });
453 const secretString = value.SecretString
454 ? typeof value.SecretString === "string"
455 ? value.SecretString
456 : Redacted.value(value.SecretString)
457 : undefined;
458 const secret = secretString
459 ? (JSON.parse(secretString) as {
460 username?: string;
461 password?: string;
462 })
463 : {};
464 return {
465 MasterUsername: props.masterUsername ?? secret.username,
466 MasterUserPassword: props.masterUserPassword ?? secret.password,
467 };
468 }
469
470 return {
471 MasterUsername: props.masterUsername,
472 MasterUserPassword: props.masterUserPassword,
473 };
474 });
475
476const toAttrs = ({
477 cluster,
478 tags,
479}: {
480 cluster: rds.DBCluster;
481 tags: Record<string, string>;
482}): DBCluster["Attributes"] => ({
483 dbClusterIdentifier: cluster.DBClusterIdentifier ?? "",
484 dbClusterArn: cluster.DBClusterArn ?? "",
485 dbSubnetGroupName: cluster.DBSubnetGroup,
486 endpoint: cluster.Endpoint,
487 readerEndpoint: cluster.ReaderEndpoint,
488 port: cluster.Port,
489 engine: cluster.Engine ?? "",
490 engineVersion: cluster.EngineVersion,
491 status: cluster.Status,
492 databaseName: cluster.DatabaseName,
493 masterUsername: cluster.MasterUsername,
494 masterUserSecretArn: cluster.MasterUserSecret?.SecretArn,
495 vpcSecurityGroupIds: (cluster.VpcSecurityGroups ?? []).flatMap((group) =>
496 group.VpcSecurityGroupId ? [group.VpcSecurityGroupId] : [],
497 ),
498 httpEndpointEnabled: cluster.HttpEndpointEnabled,
499 allocatedStorage: cluster.AllocatedStorage,
500 backupRetentionPeriod: cluster.BackupRetentionPeriod,
501 preferredBackupWindow: cluster.PreferredBackupWindow,
502 preferredMaintenanceWindow: cluster.PreferredMaintenanceWindow,
503 storageEncrypted: cluster.StorageEncrypted,
504 kmsKeyId: cluster.KmsKeyId,
505 deletionProtection: cluster.DeletionProtection,
506 iamDatabaseAuthenticationEnabled: cluster.IAMDatabaseAuthenticationEnabled,
507 engineMode: cluster.EngineMode,
508 dbClusterMembers: (cluster.DBClusterMembers ?? []).map((member) => ({
509 dbInstanceIdentifier: member.DBInstanceIdentifier,
510 isClusterWriter: member.IsClusterWriter,
511 promotionTier: member.PromotionTier,
512 })),
513 dbClusterResourceId: cluster.DbClusterResourceId,
514 hostedZoneId: cluster.HostedZoneId,
515 multiAZ: cluster.MultiAZ,
516 enabledCloudwatchLogsExports: cluster.EnabledCloudwatchLogsExports ?? [],
517 copyTagsToSnapshot: cluster.CopyTagsToSnapshot,
518 clusterCreateTime: cluster.ClusterCreateTime?.toISOString(),
519 serverlessV2PlatformVersion: cluster.ServerlessV2PlatformVersion,
520 monitoringInterval: cluster.MonitoringInterval,
521 performanceInsightsEnabled: cluster.PerformanceInsightsEnabled,
522 dbClusterInstanceClass: cluster.DBClusterInstanceClass,
523 storageType: cluster.StorageType,
524 iops: cluster.Iops,
525 networkType: cluster.NetworkType,
526 customEndpoints: cluster.CustomEndpoints ?? [],
527 tags,
528});
529
530/**
531 * Compute the CloudWatch Logs export delta. The modify API is delta-shaped
532 * (`EnableLogTypes`/`DisableLogTypes`), so it must NOT carry the full set.
533 * Returns `undefined` when there is no change.
534 */
535const logExportDelta = (
536 observed: string[] | undefined,
537 desired: string[] | undefined,
538): rds.CloudwatchLogsExportConfiguration | undefined => {
539 if (desired === undefined) return undefined;
540 const have = new Set(observed ?? []);
541 const want = new Set(desired);
542 const EnableLogTypes = [...want].filter((t) => !have.has(t));
543 const DisableLogTypes = [...have].filter((t) => !want.has(t));
544 if (EnableLogTypes.length === 0 && DisableLogTypes.length === 0) {
545 return undefined;
546 }
547 return {
548 ...(EnableLogTypes.length > 0 ? { EnableLogTypes } : {}),
549 ...(DisableLogTypes.length > 0 ? { DisableLogTypes } : {}),
550 };
551};
552
553export const DBClusterProvider = () =>
554 Provider.effect(
555 DBCluster,
556 Effect.gen(function* () {
557 const toIdentifier = (id: string, props: DBClusterProps) =>
558 props.dbClusterIdentifier
559 ? Effect.succeed(props.dbClusterIdentifier)
560 : createPhysicalName({ id, maxLength: 63 });
561
562 const readCluster = Effect.fn(function* (clusterId: string) {
563 const response = yield* rds
564 .describeDBClusters({
565 DBClusterIdentifier: clusterId,
566 })
567 .pipe(
568 Effect.catchTag("DBClusterNotFoundFault", () =>
569 Effect.succeed(undefined),
570 ),
571 );
572 return response?.DBClusters?.[0];
573 });
574
575 // Bounded readiness wait. Gate on cluster `Status === "available"` so a
576 // follow-on `modifyDBCluster` doesn't hit `InvalidDBClusterStateFault`.
577 // Budgets ~10 min (60 * 10s) for slow provisioning. `requireAvailable:
578 // false` only waits for the ARN to appear.
579 const waitForCluster = Effect.fn(function* (
580 clusterId: string,
581 { requireAvailable = true }: { requireAvailable?: boolean } = {},
582 ) {
583 const readinessPolicy = Schedule.max([
584 Schedule.fixed("10 seconds"),
585 Schedule.recurs(60),
586 ]);
587 return yield* readCluster(clusterId).pipe(
588 Effect.flatMap((cluster) => {
589 if (!cluster?.DBClusterArn) {
590 return Effect.fail(
591 new Error(`DB cluster '${clusterId}' not found`),
592 );
593 }
594 if (requireAvailable && cluster.Status !== "available") {
595 return Effect.fail(
596 new Error(
597 `DB cluster '${clusterId}' not available (status: ${cluster.Status})`,
598 ),
599 );
600 }
601 return Effect.succeed(cluster);
602 }),
603 Effect.retry({ schedule: readinessPolicy }),
604 );
605 });
606
607 return {
608 stables: ["dbClusterArn", "dbClusterIdentifier"],
609 // AWS account/region collection (pattern a): exhaustively paginate
610 // `describeDBClusters` and map each cluster to the exact `read`
611 // Attributes shape. Tags come inline on `DBCluster.TagList`, so no
612 // per-item `listTagsForResource` hydration is needed (matching `read`).
613 list: () =>
614 Effect.gen(function* () {
615 return yield* rds.describeDBClusters.pages({}).pipe(
616 Stream.runCollect,
617 Effect.map((chunk) =>
618 Array.from(chunk).flatMap((page) =>
619 (page.DBClusters ?? []).map((cluster) =>
620 toAttrs({
621 cluster,
622 tags: toTagRecord(cluster.TagList),
623 }),
624 ),
625 ),
626 ),
627 );
628 }),
629 diff: Effect.fn(function* ({ id, olds, news }) {
630 if (!isResolved(news)) return;
631 if (
632 (yield* toIdentifier(id, olds ?? ({} as DBClusterProps))) !==
633 (yield* toIdentifier(id, news))
634 ) {
635 return { action: "replace" } as const;
636 }
637 // Immutable props — any change forces a fresh cluster.
638 if (
639 olds !== undefined &&
640 (olds.engine !== news.engine ||
641 olds.databaseName !== news.databaseName ||
642 olds.dbSubnetGroupName !== news.dbSubnetGroupName ||
643 olds.storageEncrypted !== news.storageEncrypted ||
644 olds.kmsKeyId !== news.kmsKeyId ||
645 olds.engineMode !== news.engineMode ||
646 olds.globalClusterIdentifier !== news.globalClusterIdentifier ||
647 olds.engineLifecycleSupport !== news.engineLifecycleSupport ||
648 JSON.stringify(olds.availabilityZones ?? []) !==
649 JSON.stringify(news.availabilityZones ?? []))
650 ) {
651 return { action: "replace" } as const;
652 }
653 }),
654 read: Effect.fn(function* ({ id, olds, output }) {
655 const identifier =
656 output?.dbClusterIdentifier ??
657 (yield* toIdentifier(
658 id,
659 olds ?? ({ engine: "" } as DBClusterProps),
660 ));
661 const cluster = yield* readCluster(identifier);
662 if (!cluster?.DBClusterArn) {
663 return undefined;
664 }
665 return toAttrs({
666 cluster,
667 tags: toTagRecord(cluster.TagList),
668 });
669 }),
670 reconcile: Effect.fn(function* ({ id, news, output, session }) {
671 const identifier =
672 output?.dbClusterIdentifier ?? (yield* toIdentifier(id, news));
673 const internalTags = yield* createInternalTags(id);
674 const desiredTags = { ...internalTags, ...news.tags };
675 const credentials = yield* resolveMasterCredentials(news);
676 // Duration props → the exact wire units the RDS API expects.
677 const backupRetentionDays = toWireDays(news.backupRetentionPeriod);
678 const backtrackWindowSeconds = toWireSeconds(news.backtrackWindow);
679 const monitoringIntervalSeconds = toWireSeconds(
680 news.monitoringInterval,
681 );
682 const performanceInsightsRetentionDays = toWireDays(
683 news.performanceInsightsRetentionPeriod,
684 );
685
686 // Observe — fetch live cluster state. We never trust `output`
687 // blindly: the cluster may have been deleted out-of-band, or this
688 // may be a first-time reconcile after adoption.
689 let observed = yield* readCluster(identifier);
690
691 // Ensure — create the cluster if it's missing. Tolerate
692 // `DBClusterAlreadyExistsFault` as a race with a peer reconciler
693 // (e.g. retry after state-persistence failure).
694 if (!observed?.DBClusterArn) {
695 yield* rds
696 .createDBCluster({
697 DBClusterIdentifier: identifier,
698 Engine: news.engine,
699 EngineVersion: news.engineVersion,
700 DatabaseName: news.databaseName,
701 DBSubnetGroupName: news.dbSubnetGroupName,
702 DBClusterParameterGroupName: news.dbClusterParameterGroupName,
703 VpcSecurityGroupIds: news.vpcSecurityGroupIds,
704 Port: news.port,
705 AvailabilityZones: news.availabilityZones,
706 BackupRetentionPeriod: backupRetentionDays,
707 PreferredBackupWindow: news.preferredBackupWindow,
708 PreferredMaintenanceWindow: news.preferredMaintenanceWindow,
709 BacktrackWindow: backtrackWindowSeconds,
710 OptionGroupName: news.optionGroupName,
711 EnableCloudwatchLogsExports: news.enableCloudwatchLogsExports,
712 EnableIAMDatabaseAuthentication:
713 news.enableIAMDatabaseAuthentication,
714 EnableHttpEndpoint: news.enableHttpEndpoint,
715 EngineMode: news.engineMode,
716 ScalingConfiguration: news.scalingConfiguration,
717 ServerlessV2ScalingConfiguration:
718 news.serverlessV2ScalingConfiguration,
719 AutoMinorVersionUpgrade: news.autoMinorVersionUpgrade,
720 MonitoringInterval: monitoringIntervalSeconds,
721 MonitoringRoleArn: news.monitoringRoleArn,
722 EnablePerformanceInsights: news.enablePerformanceInsights,
723 PerformanceInsightsKMSKeyId: news.performanceInsightsKMSKeyId,
724 PerformanceInsightsRetentionPeriod:
725 performanceInsightsRetentionDays,
726 NetworkType: news.networkType,
727 CACertificateIdentifier: news.caCertificateIdentifier,
728 MasterUserSecretKmsKeyId: news.masterUserSecretKmsKeyId,
729 EnableGlobalWriteForwarding: news.enableGlobalWriteForwarding,
730 EnableLocalWriteForwarding: news.enableLocalWriteForwarding,
731 GlobalClusterIdentifier: news.globalClusterIdentifier,
732 DBClusterInstanceClass: news.dbClusterInstanceClass,
733 AllocatedStorage: news.allocatedStorage,
734 StorageType: news.storageType,
735 Iops: news.iops,
736 PubliclyAccessible: news.publiclyAccessible,
737 EngineLifecycleSupport: news.engineLifecycleSupport,
738 CopyTagsToSnapshot: news.copyTagsToSnapshot,
739 DeletionProtection: news.deletionProtection,
740 StorageEncrypted: news.storageEncrypted,
741 KmsKeyId: news.kmsKeyId,
742 ManageMasterUserPassword: news.manageMasterUserPassword,
743 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
744 Key,
745 Value,
746 })),
747 ...credentials,
748 })
749 .pipe(
750 Effect.catchTag(
751 "DBClusterAlreadyExistsFault",
752 () => Effect.void,
753 ),
754 );
755
756 observed = yield* waitForCluster(identifier);
757 } else {
758 // Wait for the cluster to settle before any modify so the call
759 // doesn't hit `InvalidDBClusterStateFault`.
760 observed = yield* waitForCluster(identifier);
761
762 // syncCoreSettings — single `modifyDBCluster` carrying scalar
763 // in-place fields, only emitting a field when the desired value
764 // differs from the observed cloud state.
765 const core: rds.ModifyDBClusterMessage = {
766 DBClusterIdentifier: identifier,
767 ApplyImmediately: true,
768 };
769 let coreDirty = false;
770 const setIf = <K extends keyof rds.ModifyDBClusterMessage>(
771 key: K,
772 desired: rds.ModifyDBClusterMessage[K] | undefined,
773 observedValue: unknown,
774 ) => {
775 if (desired !== undefined && desired !== observedValue) {
776 core[key] = desired;
777 coreDirty = true;
778 }
779 };
780 setIf("EngineVersion", news.engineVersion, observed.EngineVersion);
781 setIf("Port", news.port, observed.Port);
782 setIf("BackupRetentionPeriod", backupRetentionDays, observed.BackupRetentionPeriod); // prettier-ignore
783 setIf("PreferredBackupWindow", news.preferredBackupWindow, observed.PreferredBackupWindow); // prettier-ignore
784 setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore
785 setIf("BacktrackWindow", backtrackWindowSeconds, observed.BacktrackWindow); // prettier-ignore
786 setIf("DeletionProtection", news.deletionProtection, observed.DeletionProtection); // prettier-ignore
787 setIf("CopyTagsToSnapshot", news.copyTagsToSnapshot, observed.CopyTagsToSnapshot); // prettier-ignore
788 setIf("EnableIAMDatabaseAuthentication", news.enableIAMDatabaseAuthentication, observed.IAMDatabaseAuthenticationEnabled); // prettier-ignore
789 setIf("EnableHttpEndpoint", news.enableHttpEndpoint, observed.HttpEndpointEnabled); // prettier-ignore
790 setIf("AutoMinorVersionUpgrade", news.autoMinorVersionUpgrade, observed.AutoMinorVersionUpgrade); // prettier-ignore
791 setIf("MonitoringInterval", monitoringIntervalSeconds, observed.MonitoringInterval); // prettier-ignore
792 setIf("MonitoringRoleArn", news.monitoringRoleArn, observed.MonitoringRoleArn); // prettier-ignore
793 setIf("EnablePerformanceInsights", news.enablePerformanceInsights, observed.PerformanceInsightsEnabled); // prettier-ignore
794 setIf("PerformanceInsightsKMSKeyId", news.performanceInsightsKMSKeyId, observed.PerformanceInsightsKMSKeyId); // prettier-ignore
795 setIf("PerformanceInsightsRetentionPeriod", performanceInsightsRetentionDays, observed.PerformanceInsightsRetentionPeriod); // prettier-ignore
796 setIf("NetworkType", news.networkType, observed.NetworkType);
797 setIf("DBClusterInstanceClass", news.dbClusterInstanceClass, observed.DBClusterInstanceClass); // prettier-ignore
798 setIf("AllocatedStorage", news.allocatedStorage, observed.AllocatedStorage); // prettier-ignore
799 setIf("StorageType", news.storageType, observed.StorageType);
800 setIf("Iops", news.iops, observed.Iops);
801 setIf("OptionGroupName", news.optionGroupName, undefined);
802 setIf("DBClusterParameterGroupName", news.dbClusterParameterGroupName, observed.DBClusterParameterGroup); // prettier-ignore
803 setIf("EnableGlobalWriteForwarding", news.enableGlobalWriteForwarding, undefined); // prettier-ignore
804 setIf("EnableLocalWriteForwarding", news.enableLocalWriteForwarding, undefined); // prettier-ignore
805 setIf("CACertificateIdentifier", news.caCertificateIdentifier, undefined); // prettier-ignore
806 if (news.scalingConfiguration !== undefined) {
807 core.ScalingConfiguration = news.scalingConfiguration;
808 coreDirty = true;
809 }
810 if (news.serverlessV2ScalingConfiguration !== undefined) {
811 core.ServerlessV2ScalingConfiguration =
812 news.serverlessV2ScalingConfiguration;
813 coreDirty = true;
814 }
815 if (news.vpcSecurityGroupIds !== undefined) {
816 core.VpcSecurityGroupIds = news.vpcSecurityGroupIds;
817 coreDirty = true;
818 }
819 if (news.allowMajorVersionUpgrade) {
820 core.AllowMajorVersionUpgrade = true;
821 }
822 // syncMasterPassword — rotation or explicit password update.
823 if (
824 news.manageMasterUserPassword &&
825 news.rotateMasterUserPassword
826 ) {
827 core.RotateMasterUserPassword = true;
828 coreDirty = true;
829 } else if (credentials.MasterUserPassword !== undefined) {
830 core.MasterUserPassword = credentials.MasterUserPassword;
831 coreDirty = true;
832 }
833 if (coreDirty) {
834 yield* rds.modifyDBCluster(core);
835 observed = yield* waitForCluster(identifier);
836 }
837
838 // syncCloudwatchLogsExports — delta-shaped; separate call.
839 const logDelta = logExportDelta(
840 observed.EnabledCloudwatchLogsExports,
841 news.enableCloudwatchLogsExports,
842 );
843 if (logDelta) {
844 yield* rds.modifyDBCluster({
845 DBClusterIdentifier: identifier,
846 CloudwatchLogsExportConfiguration: logDelta,
847 ApplyImmediately: true,
848 });
849 observed = yield* waitForCluster(identifier);
850 }
851 }
852
853 const dbClusterArn = observed.DBClusterArn ?? "";
854
855 // Sync tags — diff observed cloud tags against desired so the
856 // reconciler converges regardless of what was on the resource
857 // before (initial create, adoption, or drift).
858 const observedTags = toTagRecord(observed.TagList);
859 const { removed, upsert } = diffTags(observedTags, desiredTags);
860 if (upsert.length > 0 && dbClusterArn) {
861 yield* rds.addTagsToResource({
862 ResourceName: dbClusterArn,
863 Tags: upsert,
864 });
865 }
866 if (removed.length > 0 && dbClusterArn) {
867 yield* rds.removeTagsFromResource({
868 ResourceName: dbClusterArn,
869 TagKeys: removed,
870 });
871 }
872
873 yield* session.note(dbClusterArn || identifier);
874 return toAttrs({ cluster: observed, tags: desiredTags });
875 }),
876 delete: Effect.fn(function* ({ output }) {
877 yield* rds
878 .deleteDBCluster({
879 DBClusterIdentifier: output.dbClusterIdentifier,
880 SkipFinalSnapshot: true,
881 })
882 .pipe(Effect.catchTag("DBClusterNotFoundFault", () => Effect.void));
883 // Block until the cluster is fully gone. RDS deletion is async; if we
884 // return while it is still `deleting`, a dependent (e.g. a
885 // DBSubnetGroup or VPC) is torn down next and AWS rejects it with
886 // `InvalidDBSubnetGroupStateFault: ... still using it`.
887 yield* Effect.repeat(
888 rds
889 .describeDBClusters({
890 DBClusterIdentifier: output.dbClusterIdentifier,
891 })
892 .pipe(
893 Effect.as(true),
894 Effect.catchTag("DBClusterNotFoundFault", () =>
895 Effect.succeed(false),
896 ),
897 ),
898 {
899 schedule: Schedule.max([
900 Schedule.fixed("15 seconds"),
901 Schedule.recurs(40),
902 ]),
903 until: (exists) => exists === false,
904 },
905 ).pipe(Effect.catch(() => Effect.void));
906 }),
907 };
908 }),
909 );