1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as rds from "@distilled.cloud/aws/rds";
3import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
4import {
5 normalizePolicyDocument,
6 stringifyPolicyDocument,
7 type PolicyDocument,
8} from "../IAM/Policy.ts";
9import * as Data from "effect/Data";
10import type * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
14import * as Stream from "effect/Stream";
15import { isResolved } from "../../Diff.ts";
16import { toWireDays, toWireSeconds } from "../../Util/Duration.ts";
17import { createPhysicalName } from "../../PhysicalName.ts";
18import * as Provider from "../../Provider.ts";
19import { Resource } from "../../Resource.ts";
20import type { Providers } from "../Providers.ts";
21import { createInternalTags, diffTags } from "../../Tags.ts";
22import { sha256 } from "../../Util/sha256.ts";
23
24export interface DBInstanceProps {
25 /**
26 * Instance identifier. If omitted, Alchemy generates one.
27 */
28 dbInstanceIdentifier?: string;
29 /**
30 * Aurora cluster the instance belongs to. When set, the instance is a
31 * cluster member and most storage/backup props are managed by the cluster.
32 * Replacing this forces a new instance.
33 */
34 dbClusterIdentifier?: string;
35 /**
36 * Instance class such as `db.serverless` or `db.t3.micro`.
37 */
38 dbInstanceClass: string;
39 /**
40 * Database engine, e.g. `mysql`, `postgres`, `aurora-postgresql`.
41 * Changing the engine forces replacement.
42 */
43 engine: string;
44 /**
45 * Optional engine version. Changed in place via `modifyDBInstance`.
46 */
47 engineVersion?: string;
48 /**
49 * Standalone (non-Aurora) database name created with the instance.
50 * Immutable — forces replacement.
51 */
52 dbName?: string;
53 /**
54 * Minimum allocated storage in GiB (standalone instances). RDS cannot
55 * shrink storage; omission/removal retains any larger existing allocation.
56 * Defaults to 100 GiB for io1/io2, 40 GiB for other RDS Custom storage,
57 * and 20 GiB otherwise. In-place increases request at least 10% growth.
58 * Cluster members inherit storage from their cluster.
59 */
60 allocatedStorage?: number;
61 /**
62 * Upper limit (GiB) for storage autoscaling on standalone instances.
63 * Omission or `0` disables autoscaling, including after adoption or drift.
64 * Disabling never shrinks allocated storage. RDS requires the current
65 * allocation as the modify request's reset value, not a zero ceiling.
66 * Not supported for cluster members or RDS Custom.
67 * @default 0
68 */
69 maxAllocatedStorage?: number;
70 /**
71 * Storage type: `gp2` | `gp3` | `io1` | `io2` | `standard`. Omission/removal
72 * selects gp3, including for existing instances. Magnetic (`standard`)
73 * storage is accepted only for existing instances; Db2 does not support gp2.
74 * AWS engine, instance-class, and regional restrictions still apply.
75 * Cluster members inherit storage from their cluster.
76 * @default "gp3"
77 */
78 storageType?: string;
79 /**
80 * Provisioned IOPS (io1/io2/gp3). Omission/removal selects a deterministic
81 * baseline, not the observed setting: gp3 uses 3000 IOPS, or 12000 for
82 * striped storage; io1/io2 use at least 1000. Allocation, autoscaling,
83 * engine, and declared throughput can raise that baseline.
84 * Small non-SQL Server gp3 volumes have fixed performance. AWS rate-limits
85 * changes and may require a storage-optimization cooldown.
86 */
87 iops?: number;
88 /**
89 * Storage throughput in MiBps (gp3 only). Omission/removal selects 125,
90 * or 500 for striped storage. MySQL/MariaDB IOPS can raise the baseline.
91 * gp3 is striped at 200 GiB for Oracle and 400 GiB for other engines,
92 * except SQL Server, which does not use the striped baseline.
93 */
94 storageThroughput?: number;
95 /**
96 * Master username (standalone instances). Immutable — forces replacement.
97 */
98 masterUsername?: string;
99 /**
100 * Master password (standalone instances). In-place modify.
101 */
102 masterUserPassword?: Redacted.Redacted<string>;
103 /**
104 * Let RDS manage the master user password in Secrets Manager.
105 */
106 manageMasterUserPassword?: boolean;
107 /**
108 * Rotate the managed master user password on the next reconcile.
109 */
110 rotateMasterUserPassword?: boolean;
111 /**
112 * KMS key used to encrypt the managed master user secret.
113 */
114 masterUserSecretKmsKeyId?: string;
115 /**
116 * Resource policy for this instance's RDS-managed master secret. Omission or
117 * removal deletes an existing policy, including after adoption or drift.
118 * RDS retains ownership of the secret value, rotation, and deletion.
119 * Requires Secrets Manager GetResourcePolicy, ValidateResourcePolicy,
120 * PutResourcePolicy, and DeleteResourcePolicy permissions. Declared policies
121 * are validated even on no-op plans; writes also use BlockPublicPolicy.
122 * Cluster-owned secrets and RDS Custom are not supported by this property.
123 */
124 masterUserSecretResourcePolicy?: PolicyDocument;
125 /**
126 * Standalone listener port. Omission/removal restores the engine default:
127 * PostgreSQL 5432, MySQL/MariaDB 3306, Oracle 1521, SQL Server 1433, Db2 50000.
128 * Unknown engines require an explicit port. RDS Custom supports creation
129 * with a port, but changing an existing Custom listener is not supported here.
130 * Ignored for Aurora and cluster members: configure the DBCluster port.
131 * Changes restart the database and are sent as `DBPortNumber` on modify.
132 */
133 port?: number;
134 /**
135 * Multi-AZ deployment (standalone instances). In-place modify.
136 */
137 multiAZ?: boolean;
138 /**
139 * Availability zone (standalone single-AZ). Immutable — forces replacement.
140 */
141 availabilityZone?: string;
142 /**
143 * Backup retention period (e.g. `"7 days"` or `Duration.days(7)`).
144 * Sent to the API in whole days. In-place modify.
145 */
146 backupRetentionPeriod?: Duration.Input;
147 /**
148 * Daily backup window, e.g. `07:00-09:00`. In-place modify.
149 */
150 preferredBackupWindow?: string;
151 /**
152 * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify.
153 */
154 preferredMaintenanceWindow?: string;
155 /**
156 * Optional DB subnet group. Effectively immutable for an in-VPC instance.
157 */
158 dbSubnetGroupName?: string;
159 /**
160 * DB parameter group. Omission/removal restores `default.<engine-family>`.
161 * The family follows the requested version, otherwise the existing engine
162 * version, otherwise the regional default for a new instance. Aurora uses
163 * its cluster's version. Pending-reboot associations are returned without
164 * automatically rebooting. Unsupported on RDS Custom; Db2 BYOL requires
165 * an explicit group with IBM licensing IDs.
166 */
167 dbParameterGroupName?: string;
168 /**
169 * VPC security groups attached to the instance, compared as a set.
170 * Omission/removal restores the default group in the effective instance VPC:
171 * the declared subnet group's VPC, the existing instance VPC, or the regional
172 * default VPC for a new instance. An empty array is invalid.
173 * Resetting changes network access according to the default group's rules;
174 * it does not delete detached group resources. Ignored for cluster members,
175 * whose security groups are managed on DBCluster. Existing RDS Custom
176 * attachments cannot be changed through this provider.
177 */
178 vpcSecurityGroupIds?: string[];
179 /**
180 * Option group (MySQL/Oracle/SQL Server). In-place modify.
181 */
182 optionGroupName?: string;
183 /**
184 * License model, e.g. `license-included` | `bring-your-own-license`.
185 */
186 licenseModel?: string;
187 /**
188 * Whether storage is encrypted. Immutable — forces replacement.
189 */
190 storageEncrypted?: boolean;
191 /**
192 * KMS key for storage encryption. Immutable — forces replacement.
193 */
194 kmsKeyId?: string;
195 /**
196 * CA certificate identifier. In-place modify.
197 */
198 caCertificateIdentifier?: string;
199 /**
200 * Enable IAM database authentication. Omission/removal disables it, including
201 * after adoption or drift. Cluster members inherit their cluster's setting.
202 * Applying a change or a queued IAM change uses ApplyImmediately, which also
203 * applies unrelated pending RDS modifications. Supported on standalone
204 * PostgreSQL, MySQL, and MariaDB; unsupported on RDS Custom.
205 * @default false
206 */
207 enableIAMDatabaseAuthentication?: boolean;
208 /**
209 * Enable Performance Insights. In-place modify.
210 */
211 enablePerformanceInsights?: boolean;
212 /**
213 * KMS key for Performance Insights. In-place modify.
214 */
215 performanceInsightsKMSKeyId?: string;
216 /**
217 * Performance Insights retention (e.g. `"7 days"`). Sent to the API in
218 * whole days (valid: 7, 731, or month multiples).
219 */
220 performanceInsightsRetentionPeriod?: Duration.Input;
221 /**
222 * Enhanced-monitoring granularity (e.g. `"60 seconds"`). Sent to the API
223 * in whole seconds (valid: 0, 1, 5, 10, 15, 30, 60).
224 */
225 monitoringInterval?: Duration.Input;
226 /**
227 * IAM role ARN for enhanced monitoring. In-place modify.
228 */
229 monitoringRoleArn?: string;
230 /**
231 * Log types to export to CloudWatch Logs, compared as a set. Omission/removal
232 * disables all exports. Changes apply immediately without flushing unrelated
233 * pending modifications. Cluster members inherit their cluster's exports.
234 * Unsupported on RDS Custom.
235 * @default []
236 */
237 enableCloudwatchLogsExports?: string[];
238 /**
239 * Block accidental deletion. Omission/removal disables protection, including
240 * after adoption or drift. Ignored for cluster members: configure DBCluster.
241 * Aurora cluster protection does not prevent deleting individual instances.
242 * @default false
243 */
244 deletionProtection?: boolean;
245 /**
246 * Network type: `IPV4` | `DUAL`. Omission/removal restores IPv4. DUAL requires
247 * IPv6-enabled subnets. Ignored for cluster members: configure DBCluster.
248 * Changes apply immediately and can also apply unrelated pending RDS changes.
249 * @default "IPV4"
250 */
251 networkType?: string;
252 /**
253 * Allow a major engine-version upgrade during a modify. Modify-only flag.
254 */
255 allowMajorVersionUpgrade?: boolean;
256 /**
257 * Whether the instance has a public address. Omission/removal restores private
258 * access, including after adoption or drift. Also managed on Aurora instances;
259 * non-Aurora Multi-AZ cluster members inherit their cluster's configuration.
260 * Changes apply immediately without flushing unrelated pending modifications.
261 * @default false
262 */
263 publiclyAccessible?: boolean;
264 /**
265 * Promotion tier inside the cluster.
266 */
267 promotionTier?: number;
268 /**
269 * Auto minor version upgrades.
270 */
271 autoMinorVersionUpgrade?: boolean;
272 /**
273 * Copy tags to snapshots.
274 */
275 copyTagsToSnapshot?: boolean;
276 /**
277 * User-defined tags.
278 */
279 tags?: Record<string, string>;
280 /**
281 * Skip the final snapshot when the instance is deleted. Set `false` to
282 * have RDS take a final snapshot on teardown — belt-and-suspenders beyond
283 * `deletionProtection` for databases whose data must survive a deliberate
284 * destroy. Persisted into state so `delete` honors it without props.
285 * @default true
286 */
287 skipFinalSnapshot?: boolean;
288 /**
289 * Identifier for the final snapshot taken when `skipFinalSnapshot` is
290 * `false`. Defaults to `<instance-identifier>-final-<timestamp>` so
291 * repeated destroy/create cycles never collide on snapshot names.
292 */
293 finalDBSnapshotIdentifier?: string;
294}
295
296export interface DBInstance extends Resource<
297 "AWS.RDS.DBInstance",
298 DBInstanceProps,
299 {
300 /**
301 * Identifier of the instance.
302 */
303 dbInstanceIdentifier: string;
304 /**
305 * ARN of the instance.
306 */
307 dbInstanceArn: string;
308 /**
309 * Aurora cluster the instance belongs to, if any.
310 */
311 dbClusterIdentifier: string | undefined;
312 /**
313 * DNS address of the instance endpoint.
314 */
315 endpointAddress: string | undefined;
316 /**
317 * Port of the instance endpoint.
318 */
319 endpointPort: number | undefined;
320 /**
321 * Instance class (e.g. `db.serverless`, `db.t3.micro`).
322 */
323 dbInstanceClass: string | undefined;
324 /**
325 * Database engine.
326 */
327 engine: string | undefined;
328 /**
329 * Engine version in use.
330 */
331 engineVersion: string | undefined;
332 /**
333 * Status of the instance (e.g. `available`).
334 */
335 status: string | undefined;
336 /**
337 * Failover promotion tier inside the cluster.
338 */
339 promotionTier: number | undefined;
340 /**
341 * Whether the instance has a public address.
342 */
343 publiclyAccessible: boolean | undefined;
344 /**
345 * Subnet group the instance is placed in.
346 */
347 dbSubnetGroupName: string | undefined;
348 /**
349 * Parameter groups applied to the instance.
350 */
351 dbParameterGroupNames: string[];
352 /**
353 * Observed parameter-group apply statuses, including pending-reboot.
354 */
355 dbParameterGroupApplyStatuses: Record<string, string | undefined>;
356 /**
357 * Observed VPC security-group attachments, including cluster-owned groups.
358 */
359 vpcSecurityGroupIds: string[];
360 /**
361 * Observed VPC security-group application states, such as `active`.
362 */
363 vpcSecurityGroupStatuses: Record<string, string | undefined>;
364 /**
365 * Allocated storage in GiB.
366 */
367 allocatedStorage: number | undefined;
368 /**
369 * Storage autoscaling ceiling in GiB.
370 */
371 maxAllocatedStorage: number | undefined;
372 /**
373 * Storage type (e.g. `gp3`, `io1`, `aurora`).
374 */
375 storageType: string | undefined;
376 /**
377 * Provisioned IOPS.
378 */
379 iops: number | undefined;
380 /**
381 * Storage throughput in MiBps (gp3).
382 */
383 storageThroughput: number | undefined;
384 /**
385 * Whether the instance is Multi-AZ.
386 */
387 multiAZ: boolean | undefined;
388 /**
389 * Availability Zone of the instance.
390 */
391 availabilityZone: string | undefined;
392 /**
393 * Standby AZ for Multi-AZ deployments.
394 */
395 secondaryAvailabilityZone: string | undefined;
396 /**
397 * Backup retention period in days.
398 */
399 backupRetentionPeriod: number | undefined;
400 /**
401 * Daily backup window (`hh:mm-hh:mm` UTC).
402 */
403 preferredBackupWindow: string | undefined;
404 /**
405 * Weekly maintenance window.
406 */
407 preferredMaintenanceWindow: string | undefined;
408 /**
409 * KMS key used for storage encryption.
410 */
411 kmsKeyId: string | undefined;
412 /**
413 * Whether storage is encrypted.
414 */
415 storageEncrypted: boolean | undefined;
416 /**
417 * CA certificate identifier.
418 */
419 caCertificateIdentifier: string | undefined;
420 /**
421 * Whether IAM database authentication is enabled.
422 */
423 iamDatabaseAuthenticationEnabled: boolean | undefined;
424 /**
425 * Observed IAM authentication change awaiting application. A pending value
426 * is not evidence that authentication has changed on the database.
427 */
428 pendingIamDatabaseAuthenticationEnabled: boolean | undefined;
429 /**
430 * Whether Performance Insights is enabled.
431 */
432 performanceInsightsEnabled: boolean | undefined;
433 /**
434 * Enhanced-monitoring granularity in seconds.
435 */
436 monitoringInterval: number | undefined;
437 /**
438 * ARN of the enhanced-monitoring CloudWatch Logs stream.
439 */
440 enhancedMonitoringResourceArn: string | undefined;
441 /**
442 * Log types exported to CloudWatch Logs.
443 */
444 enabledCloudwatchLogsExports: string[];
445 /**
446 * Whether deletion protection is enabled.
447 */
448 deletionProtection: boolean | undefined;
449 /**
450 * Immutable region-unique instance resource ID (used in IAM auth ARNs).
451 */
452 dbiResourceId: string | undefined;
453 /**
454 * Master username.
455 */
456 masterUsername: string | undefined;
457 /**
458 * Whether the final snapshot is skipped on delete, persisted from the
459 * prop of the same name. `delete` receives only the stored attributes,
460 * never live props, so the snapshot decision must ride in state;
461 * `undefined` (older state without this attr) is treated as skip.
462 */
463 skipFinalSnapshot: boolean | undefined;
464 /**
465 * Identifier used for the final snapshot when `skipFinalSnapshot` is
466 * `false`, persisted from props so `delete` can name the snapshot without
467 * live props. `undefined` means `delete` falls back to the default
468 * `<instance-identifier>-final-<timestamp>` naming scheme.
469 */
470 finalDBSnapshotIdentifier: string | undefined;
471 /**
472 * Salted SHA-256 fingerprint of the last `masterUserPassword` this
473 * provider sent to RDS — `sha256(`${dbInstanceIdentifier}:${password}`)`,
474 * never the secret itself. Lets reconcile skip the `MasterUserPassword`
475 * modify (and the `resetting-master-credentials` cycle it triggers) when
476 * the configured password has not changed.
477 *
478 * Persisted `Redacted` because a password hash is still sensitive: an
479 * unsalted digest is vulnerable to rainbow-table lookup, so the state
480 * store must not surface it in plaintext (logs, `stringify`, dumps). The
481 * per-resource identifier salt additionally defeats precomputed tables;
482 * it is stable across a resource's life, so the fingerprint stays
483 * comparable across reconciles.
484 */
485 masterUserPasswordFingerprint: Redacted.Redacted<string> | undefined;
486 /**
487 * ARN of the Secrets Manager secret holding master credentials.
488 */
489 masterUserSecretArn: string | undefined;
490 /**
491 * Canonical policy observed during read or reconciliation. Undefined when
492 * there is no instance-managed secret or no resource policy. Inventory
493 * listing does not load policy metadata and leaves this attribute undefined.
494 */
495 masterUserSecretResourcePolicy: string | undefined;
496 /**
497 * Option group memberships.
498 */
499 optionGroupMemberships: string[];
500 /**
501 * License model.
502 */
503 licenseModel: string | undefined;
504 /**
505 * Configured database port.
506 */
507 dbInstancePort: number | undefined;
508 /**
509 * Network type (`IPV4` or `DUAL`).
510 */
511 networkType: string | undefined;
512 /**
513 * Tags on the instance.
514 */
515 tags: Record<string, string>;
516 },
517 never,
518 Providers
519> {}
520
521/**
522 * An RDS database instance — either a standalone (non-Aurora) database or a
523 * member of an Aurora `DBCluster`.
524 *
525 * Exposes the full storage, backup, monitoring, performance-insights,
526 * encryption, networking, and log-export surface of `createDBInstance` /
527 * `modifyDBInstance`. Mutable fields are reconciled in place against the
528 * observed cloud state; immutable fields (`engine`, `dbName`,
529 * `masterUsername`, `availabilityZone`, `storageEncrypted`, `kmsKeyId`,
530 * `dbSubnetGroupName`) force a replacement.
531 * ### Standalone Instance
532 * **Example:** A gp3 MySQL instance
533 * ```typescript
534 * const db = yield* DBInstance("Db", {
535 * engine: "mysql",
536 * dbInstanceClass: "db.t3.micro",
537 * allocatedStorage: 20,
538 * storageType: "gp3",
539 * masterUsername: "admin",
540 * masterUserPassword: Redacted.make("supersecret"),
541 * backupRetentionPeriod: "7 days",
542 * deletionProtection: false,
543 * });
544 * ```
545 *
546 * ### Storage Defaults
547 * Omitted storage settings describe desired defaults, including on existing
548 * instances: gp3 storage, a minimum allocation of 20 GiB, and the engine's
549 * baseline performance. RDS Custom defaults to 40 GiB; io1/io2 default to
550 * 100 GiB and at least 1000 IOPS. Only allocated capacity is a floor because
551 * RDS cannot shrink a database. Storage type and performance are reconciled
552 * even when the program is unchanged and the cloud settings have drifted.
553 *
554 * **Example:** PostgreSQL with default storage and autoscaling disabled
555 * ```typescript
556 * const db = yield* DBInstance("Db", {
557 * engine: "postgres",
558 * dbInstanceClass: "db.t3.micro",
559 * masterUsername: "admin",
560 * manageMasterUserPassword: true,
561 * });
562 * ```
563 *
564 * This starts with 20 GiB of gp3 storage, 3000 IOPS, and 125 MiBps. For small
565 * non-SQL Server gp3 volumes, these performance values are fixed and Alchemy
566 * omits the unsupported performance fields from AWS requests.
567 *
568 * ### Resetting Storage Performance
569 * Removing performance settings restores the baseline for the desired
570 * storage type, effective allocation, engine, and autoscaling limit. AWS
571 * requires coupled storage fields together on modifications; Alchemy sends
572 * the resolved allocation, type, supported performance fields, and ceiling
573 * in the same request. Existing database contents are retained.
574 *
575 * **Example:** Restore the PostgreSQL gp3 baseline at 400 GiB
576 * ```diff lang="typescript"
577 * const db = yield* DBInstance("Db", {
578 * engine: "postgres",
579 * dbInstanceClass: "db.t3.micro",
580 * masterUsername: "admin",
581 * manageMasterUserPassword: true,
582 * allocatedStorage: 400,
583 * - iops: 16000,
584 * - storageThroughput: 750,
585 * });
586 * ```
587 *
588 * At this allocation PostgreSQL uses striped gp3 storage, so removal requests
589 * 12000 IOPS and 500 MiBps. Removing `storageType: "gp2"` instead selects gp3
590 * without shrinking the allocation. Changes remain subject to AWS's storage
591 * optimization cooldown; redeploying cannot bypass that restriction.
592 *
593 * ### Storage Autoscaling
594 * `allocatedStorage` is a minimum, not a shrink target. RDS can increase the
595 * allocation but cannot reduce it in place. `maxAllocatedStorage` controls
596 * future automatic growth; omitting it or setting it to `0` disables autoscaling.
597 *
598 * **Example:** Start with 20 GiB and allow automatic growth to 100 GiB
599 * ```typescript
600 * const db = yield* DBInstance("Db", {
601 * engine: "postgres",
602 * dbInstanceClass: "db.t3.micro",
603 * masterUsername: "admin",
604 * manageMasterUserPassword: true,
605 * storageType: "gp2",
606 * allocatedStorage: 20,
607 * maxAllocatedStorage: 100,
608 * });
609 * ```
610 *
611 * If RDS grows this database to 30 GiB, redeploying keeps that capacity and the
612 * 100 GiB autoscaling limit. It does not attempt to shrink the database to 20 GiB.
613 * An externally changed autoscaling limit is reset to the declared value.
614 *
615 * ### Disabling Storage Autoscaling
616 * Removing `maxAllocatedStorage` resets autoscaling to its disabled default.
617 * Existing allocated storage and database contents remain intact.
618 *
619 * **Example:** Remove the autoscaling limit to disable future automatic growth
620 * ```diff lang="typescript"
621 * const db = yield* DBInstance("Db", {
622 * engine: "postgres",
623 * dbInstanceClass: "db.t3.micro",
624 * masterUsername: "admin",
625 * manageMasterUserPassword: true,
626 * storageType: "gp2",
627 * allocatedStorage: 20,
628 * - maxAllocatedStorage: 100,
629 * });
630 * ```
631 *
632 * `maxAllocatedStorage: 0` has the same behavior. Redeploying with either form
633 * also disables autoscaling if it was enabled outside Alchemy.
634 *
635 * ### Cluster Member
636 * **Example:** An Aurora writer instance
637 * ```typescript
638 * const writer = yield* DBInstance("Writer", {
639 * dbClusterIdentifier: cluster.dbClusterIdentifier,
640 * dbInstanceClass: "db.serverless",
641 * engine: "aurora-postgresql",
642 * });
643 * ```
644 *
645 * ### Listener Port Defaults
646 * Omitted listener ports select the database engine's default: PostgreSQL
647 * 5432, MySQL/MariaDB 3306, Oracle 1521, SQL Server 1433, and Db2 50000.
648 * Alchemy compares the actual endpoint port, including pending changes,
649 * rather than the separate `DbInstancePort` field returned by AWS.
650 *
651 * **Example:** PostgreSQL listening on its default port
652 * ```typescript
653 * const db = yield* DBInstance("Db", {
654 * engine: "postgres",
655 * dbInstanceClass: "db.t3.micro",
656 * masterUsername: "admin",
657 * manageMasterUserPassword: true,
658 * });
659 * ```
660 *
661 * ### Resetting the Listener Port
662 * Removing a custom port restores the engine default. Unchanged programs
663 * also detect and repair external listener changes. Port changes restart
664 * the database, including when restoring defaults after adoption.
665 *
666 * **Example:** Restore PostgreSQL's port 5432
667 * ```diff lang="typescript"
668 * const db = yield* DBInstance("Db", {
669 * engine: "postgres",
670 * dbInstanceClass: "db.t3.micro",
671 * masterUsername: "admin",
672 * manageMasterUserPassword: true,
673 * - port: 5433,
674 * });
675 * ```
676 *
677 * Aurora and other cluster members inherit their listener from `DBCluster`;
678 * instance `port` declarations are ignored. RDS Custom creation accepts a
679 * port, but an existing Custom instance that needs a listener change fails
680 * explicitly instead of silently retaining the wrong port or replacing data.
681 *
682 * ### Association Defaults
683 * Parameter and security-group associations are desired configuration.
684 * Omission selects a compatible engine default parameter group and the
685 * effective VPC's default security group, including during adoption.
686 * Existing database versions and VPC placement constrain those defaults;
687 * the currently attached groups are never used as fallback desired values.
688 *
689 * **Example:** Use default associations in a declared network
690 * ```typescript
691 * const db = yield* DBInstance("Db", {
692 * engine: "postgres",
693 * dbInstanceClass: "db.t3.micro",
694 * masterUsername: "admin",
695 * manageMasterUserPassword: true,
696 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
697 * });
698 * ```
699 *
700 * ### Resetting Associations
701 * Removing declarations restores defaults. Unchanged programs also repair
702 * external attachment drift. Reordering or duplicating security-group IDs
703 * does not resend the association request.
704 *
705 * **Example:** Restore the engine and VPC default groups
706 * ```diff lang="typescript"
707 * const db = yield* DBInstance("Db", {
708 * engine: "postgres",
709 * dbInstanceClass: "db.t3.micro",
710 * masterUsername: "admin",
711 * manageMasterUserPassword: true,
712 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
713 * - dbParameterGroupName: customParameters.dbParameterGroupName,
714 * - vpcSecurityGroupIds: [applicationGroup.groupId],
715 * });
716 * ```
717 *
718 * The default security group's rules determine network access after reset.
719 * Detached group resources remain intact. Parameter changes can require a
720 * reboot; `dbParameterGroupApplyStatuses` reports `pending-reboot` without
721 * automatically restarting the database. Aurora instance parameter groups
722 * remain instance-managed, while security groups belong to its cluster.
723 * Multi-AZ DB cluster associations are cluster-managed. RDS Custom cannot
724 * manage parameter groups or modify existing security-group attachments;
725 * Db2 BYOL requires an explicit parameter group with IBM licensing IDs.
726 *
727 * ### Security Defaults
728 * Omission is desired configuration, not permission to retain external state:
729 * IAM authentication, public access, and deletion protection default to false,
730 * network type to IPV4, and log exports to an empty set. Removal and unchanged
731 * declarations repair these settings after drift or adoption.
732 *
733 * **Example:** Enable IAM authentication and PostgreSQL log exports
734 * ```typescript
735 * const db = yield* DBInstance("Db", {
736 * engine: "postgres",
737 * dbInstanceClass: "db.t3.micro",
738 * masterUsername: "admin",
739 * manageMasterUserPassword: true,
740 * dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
741 * enableIAMDatabaseAuthentication: true,
742 * enableCloudwatchLogsExports: ["postgresql"],
743 * });
744 * ```
745 *
746 * Removing the last two properties disables IAM authentication and log exports.
747 * Public access remains instance-managed for Aurora, while IAM authentication,
748 * deletion protection, network type, and exports belong to DBCluster. Requested
749 * or observed cluster membership suppresses instance writes to those settings.
750 * Non-Aurora Multi-AZ cluster members also inherit public accessibility.
751 *
752 * ### Pending Security Changes
753 * **Example:** Inspect applied and pending IAM authentication separately
754 * ```typescript
755 * return {
756 * enabled: db.iamDatabaseAuthenticationEnabled,
757 * pending: db.pendingIamDatabaseAuthenticationEnabled,
758 * securityGroupStatuses: db.vpcSecurityGroupStatuses,
759 * };
760 * ```
761 *
762 * Deployment waits for applied security settings and an operational instance.
763 * A matching queued IAM value is promoted with ApplyImmediately without
764 * resending the IAM field; an incompatible queued value is overwritten even
765 * when the applied value already matches. AWS has no selective queue flush:
766 * applying IAM or network-type changes immediately can apply unrelated pending
767 * modifications and cause downtime. Alchemy does not wait until maintenance.
768 * Network type has no field in AWS PendingModifiedValues, so an externally
769 * queued network change cannot be detected until it starts applying.
770 * Public access, deletion protection, and log exports use ApplyImmediately:false
771 * because AWS applies these immediately regardless of that flag. They do not
772 * flush the maintenance queue. Other declared updates retain their existing
773 * immediate-application behavior. Static parameter changes remain pending-reboot;
774 * Alchemy never calls RebootDBInstance automatically.
775 *
776 * ### Managed Master Secret Policy
777 * **Example:** Allow a role to read the RDS-managed master secret
778 * ```typescript
779 * const db = yield* DBInstance("Db", {
780 * engine: "postgres",
781 * dbInstanceClass: "db.t3.micro",
782 * masterUsername: "admin",
783 * manageMasterUserPassword: true,
784 * masterUserSecretResourcePolicy: {
785 * Version: "2012-10-17",
786 * Statement: [{
787 * Effect: "Allow",
788 * Principal: { AWS: reader.roleArn },
789 * Action: ["secretsmanager:GetSecretValue"],
790 * Resource: "*",
791 * }],
792 * },
793 * });
794 * ```
795 *
796 * Declared policies are checked with `ValidateResourcePolicy`, including
797 * unchanged plans, and writes additionally use `BlockPublicPolicy`. Deployment
798 * requires `secretsmanager:ValidateResourcePolicy` permission. Alchemy compares
799 * live policy metadata without reading secret values. RDS owns credential
800 * generation, rotation, and secret deletion. Only instance-managed secrets are
801 * supported; an Aurora or Multi-AZ cluster owns its own secret, and RDS Custom
802 * does not support managed credentials. Explicit denies can still obstruct the
803 * deployer's access or RDS rotation; policy validation is not a guarantee that
804 * every declared permission is operationally safe.
805 *
806 * ### Removing a Managed Secret Policy
807 * **Example:** Restore the default of no resource policy
808 * ```diff lang="typescript"
809 * const db = yield* DBInstance("Db", {
810 * engine: "postgres",
811 * dbInstanceClass: "db.t3.micro",
812 * masterUsername: "admin",
813 * manageMasterUserPassword: true,
814 * - masterUserSecretResourcePolicy: readerPolicy,
815 * });
816 * ```
817 *
818 * Omission or removal deletes the resource policy, including external policies
819 * discovered during adoption or unchanged-input drift detection. Identity-based
820 * IAM permissions still apply. The secret, its credentials, and RDS rotation
821 * remain intact. Deployment waits for policy readback before returning; managing
822 * an existing secret also requires `secretsmanager:GetResourcePolicy` and, when
823 * resetting a policy, `secretsmanager:DeleteResourcePolicy`.
824 *
825 * ### Readiness
826 * Deployment waits for an instance ARN and an operational status: `available`
827 * or `storage-optimization`. Storage optimization remains online and can
828 * continue for hours. Missing or pending observations retain the ten-minute
829 * provisioning budget; SDK failures keep their original typed errors rather
830 * than being retried by the readiness loop. Unknown statuses and blue/green
831 * `storage-initialization` remain bounded pending observations, never ready.
832 *
833 * **Example:** Return the ready database's status from a stack
834 * ```typescript
835 * const db = yield* DBInstance("Db", {
836 * engine: "postgres",
837 * dbInstanceClass: "db.t3.micro",
838 * masterUsername: "admin",
839 * manageMasterUserPassword: true,
840 * });
841 * return { status: db.status };
842 * ```
843 *
844 * States requiring intervention, including `stopped`, `storage-full`, and
845 * incompatible or failed configurations, fail with `DBInstanceReadinessBlocked`.
846 * Alchemy does not automatically start a stopped instance. Restore its
847 * operational state and deploy again. Unchanged declarations also check
848 * readiness, while storage, port, and association updates wait for both their
849 * requested values and an operational instance before returning.
850 *
851 * ### Monitoring & Logs
852 * **Example:** Enhanced monitoring + log export
853 * ```typescript
854 * const db = yield* DBInstance("Db", {
855 * engine: "postgres",
856 * dbInstanceClass: "db.t3.micro",
857 * allocatedStorage: 20,
858 * monitoringInterval: "60 seconds",
859 * monitoringRoleArn: monitoringRole.roleArn,
860 * enablePerformanceInsights: true,
861 * enableCloudwatchLogsExports: ["postgresql", "upgrade"],
862 * });
863 * ```
864 *
865 * @resource
866 */
867export const DBInstance = Resource<DBInstance>("AWS.RDS.DBInstance");
868
869const toTagRecord = (
870 tags: Array<{ Key?: string; Value?: string }> | undefined,
871): Record<string, string> =>
872 Object.fromEntries(
873 (tags ?? [])
874 .filter(
875 (tag): tag is { Key: string; Value: string } =>
876 typeof tag.Key === "string" && typeof tag.Value === "string",
877 )
878 .map((tag) => [tag.Key, tag.Value]),
879 );
880
881/**
882 * Whether two optional master-password fingerprints match, compared by their
883 * underlying (`Redacted`-unwrapped) digest. A missing stored fingerprint
884 * counts as "does not match" so a pre-existing instance sends its password
885 * once, then records the fingerprint for subsequent reconciles.
886 */
887const sameFingerprint = (
888 a: Redacted.Redacted<string> | undefined,
889 b: Redacted.Redacted<string> | undefined,
890): boolean =>
891 a !== undefined && b !== undefined && Redacted.value(a) === Redacted.value(b);
892
893const toAttrs = ({
894 instance,
895 tags,
896 skipFinalSnapshot,
897 finalDBSnapshotIdentifier,
898 masterUserPasswordFingerprint,
899 masterUserSecretResourcePolicy,
900}: {
901 instance: rds.DBInstance;
902 tags: Record<string, string>;
903 skipFinalSnapshot?: boolean | undefined;
904 finalDBSnapshotIdentifier?: string | undefined;
905 masterUserPasswordFingerprint?: Redacted.Redacted<string> | undefined;
906 masterUserSecretResourcePolicy?: string | undefined;
907}): DBInstance["Attributes"] => ({
908 skipFinalSnapshot,
909 finalDBSnapshotIdentifier,
910 masterUserPasswordFingerprint,
911 dbInstanceIdentifier: instance.DBInstanceIdentifier ?? "",
912 dbInstanceArn: instance.DBInstanceArn ?? "",
913 dbClusterIdentifier: instance.DBClusterIdentifier,
914 endpointAddress: instance.Endpoint?.Address,
915 endpointPort: instance.Endpoint?.Port,
916 dbInstanceClass: instance.DBInstanceClass,
917 engine: instance.Engine,
918 engineVersion: instance.EngineVersion,
919 status: instance.DBInstanceStatus,
920 promotionTier: instance.PromotionTier,
921 publiclyAccessible: instance.PubliclyAccessible,
922 dbSubnetGroupName: instance.DBSubnetGroup?.DBSubnetGroupName,
923 dbParameterGroupNames: (instance.DBParameterGroups ?? []).flatMap((group) =>
924 group.DBParameterGroupName ? [group.DBParameterGroupName] : [],
925 ),
926 dbParameterGroupApplyStatuses: Object.fromEntries(
927 (instance.DBParameterGroups ?? []).flatMap((group) =>
928 group.DBParameterGroupName
929 ? [[group.DBParameterGroupName, group.ParameterApplyStatus]]
930 : [],
931 ),
932 ),
933 vpcSecurityGroupIds: (instance.VpcSecurityGroups ?? []).flatMap((group) =>
934 group.VpcSecurityGroupId ? [group.VpcSecurityGroupId] : [],
935 ),
936 vpcSecurityGroupStatuses: Object.fromEntries(
937 (instance.VpcSecurityGroups ?? []).flatMap((group) =>
938 group.VpcSecurityGroupId
939 ? [[group.VpcSecurityGroupId, group.Status]]
940 : [],
941 ),
942 ),
943 allocatedStorage: instance.AllocatedStorage,
944 maxAllocatedStorage: instance.MaxAllocatedStorage,
945 storageType: instance.StorageType,
946 iops: instance.Iops,
947 storageThroughput: instance.StorageThroughput,
948 multiAZ: instance.MultiAZ,
949 availabilityZone: instance.AvailabilityZone,
950 secondaryAvailabilityZone: instance.SecondaryAvailabilityZone,
951 backupRetentionPeriod: instance.BackupRetentionPeriod,
952 preferredBackupWindow: instance.PreferredBackupWindow,
953 preferredMaintenanceWindow: instance.PreferredMaintenanceWindow,
954 kmsKeyId: instance.KmsKeyId,
955 storageEncrypted: instance.StorageEncrypted,
956 caCertificateIdentifier: instance.CACertificateIdentifier,
957 iamDatabaseAuthenticationEnabled: instance.IAMDatabaseAuthenticationEnabled,
958 pendingIamDatabaseAuthenticationEnabled:
959 instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled,
960 performanceInsightsEnabled: instance.PerformanceInsightsEnabled,
961 monitoringInterval: instance.MonitoringInterval,
962 enhancedMonitoringResourceArn: instance.EnhancedMonitoringResourceArn,
963 enabledCloudwatchLogsExports: instance.EnabledCloudwatchLogsExports ?? [],
964 deletionProtection: instance.DeletionProtection,
965 dbiResourceId: instance.DbiResourceId,
966 masterUsername: instance.MasterUsername,
967 masterUserSecretArn: instance.MasterUserSecret?.SecretArn,
968 masterUserSecretResourcePolicy,
969 optionGroupMemberships: (instance.OptionGroupMemberships ?? []).flatMap(
970 (membership) =>
971 membership.OptionGroupName ? [membership.OptionGroupName] : [],
972 ),
973 licenseModel: instance.LicenseModel,
974 dbInstancePort: instance.DbInstancePort,
975 networkType: instance.NetworkType,
976 tags,
977});
978
979/**
980 * Compute the CloudWatch Logs export delta. The modify API is delta-shaped
981 * (`EnableLogTypes`/`DisableLogTypes`), so it must NOT carry the full set.
982 * Returns `undefined` when there is no change.
983 */
984const logExportDelta = (
985 observed: string[] | undefined,
986 desired: string[] | undefined,
987): rds.CloudwatchLogsExportConfiguration | undefined => {
988 if (desired === undefined) return undefined;
989 const have = new Set(observed ?? []);
990 const want = new Set(desired);
991 const EnableLogTypes = [...want].filter((t) => !have.has(t));
992 const DisableLogTypes = [...have].filter((t) => !want.has(t));
993 if (EnableLogTypes.length === 0 && DisableLogTypes.length === 0) {
994 return undefined;
995 }
996 return {
997 ...(EnableLogTypes.length > 0 ? { EnableLogTypes } : {}),
998 ...(DisableLogTypes.length > 0 ? { DisableLogTypes } : {}),
999 };
1000};
1001
1002const sameMembers = (
1003 desired: readonly string[],
1004 observed: readonly (string | undefined)[],
1005) => {
1006 const want = new Set(desired);
1007 const have = new Set(observed);
1008 return want.size === have.size && [...want].every((value) => have.has(value));
1009};
1010
1011const clusterOwnsConfiguration = (
1012 props: DBInstanceProps,
1013 observed?: rds.DBInstance,
1014) =>
1015 props.dbClusterIdentifier !== undefined ||
1016 observed?.DBClusterIdentifier !== undefined ||
1017 props.engine.startsWith("aurora") ||
1018 observed?.Engine?.startsWith("aurora") === true;
1019
1020const securityConfiguration = (
1021 props: DBInstanceProps,
1022 observed?: rds.DBInstance,
1023) => {
1024 const clusterOwned = clusterOwnsConfiguration(props, observed);
1025 const custom =
1026 props.engine.startsWith("custom-") ||
1027 observed?.Engine?.startsWith("custom-") === true;
1028 const supportsIam = ["postgres", "mysql", "mariadb"].includes(
1029 observed?.Engine ?? props.engine,
1030 );
1031 return {
1032 publiclyAccessible:
1033 clusterOwned &&
1034 !props.engine.startsWith("aurora") &&
1035 !observed?.Engine?.startsWith("aurora")
1036 ? undefined
1037 : (props.publiclyAccessible ?? false),
1038 iamAuthentication: clusterOwned
1039 ? undefined
1040 : (props.enableIAMDatabaseAuthentication ??
1041 (supportsIam ? false : undefined)),
1042 deletionProtection: clusterOwned
1043 ? undefined
1044 : (props.deletionProtection ?? false),
1045 networkType: clusterOwned ? undefined : (props.networkType ?? "IPV4"),
1046 logExports: clusterOwned
1047 ? undefined
1048 : (props.enableCloudwatchLogsExports ?? (custom ? undefined : [])),
1049 };
1050};
1051
1052type SecurityConfiguration = ReturnType<typeof securityConfiguration>;
1053
1054const pendingLogExports = (instance: rds.DBInstance) =>
1055 instance.PendingModifiedValues?.PendingCloudwatchLogsExports;
1056
1057const effectiveLogExports = (instance: rds.DBInstance) => {
1058 const exports = new Set(instance.EnabledCloudwatchLogsExports ?? []);
1059 for (const log of pendingLogExports(instance)?.LogTypesToDisable ?? []) {
1060 exports.delete(log);
1061 }
1062 for (const log of pendingLogExports(instance)?.LogTypesToEnable ?? []) {
1063 exports.add(log);
1064 }
1065 return [...exports];
1066};
1067
1068const iamConverged = (
1069 desired: SecurityConfiguration,
1070 instance: rds.DBInstance,
1071) =>
1072 desired.iamAuthentication === undefined ||
1073 (desired.iamAuthentication === instance.IAMDatabaseAuthenticationEnabled &&
1074 instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled ===
1075 undefined);
1076
1077const securityConverged = (
1078 desired: SecurityConfiguration,
1079 instance: rds.DBInstance,
1080) =>
1081 iamConverged(desired, instance) &&
1082 (desired.publiclyAccessible === undefined ||
1083 desired.publiclyAccessible === instance.PubliclyAccessible) &&
1084 (desired.deletionProtection === undefined ||
1085 desired.deletionProtection === instance.DeletionProtection) &&
1086 (desired.networkType === undefined ||
1087 desired.networkType === instance.NetworkType) &&
1088 (desired.logExports === undefined ||
1089 (sameMembers(
1090 desired.logExports,
1091 instance.EnabledCloudwatchLogsExports ?? [],
1092 ) &&
1093 (pendingLogExports(instance)?.LogTypesToEnable?.length ?? 0) === 0 &&
1094 (pendingLogExports(instance)?.LogTypesToDisable?.length ?? 0) === 0));
1095
1096class DBInstanceSecurityPending extends Data.TaggedError(
1097 "DBInstanceSecurityPending",
1098)<{ instanceId: string }> {
1099 override get message() {
1100 return `DB instance '${this.instanceId}' security configuration did not converge`;
1101 }
1102}
1103
1104class InvalidDBInstanceAssociations extends Data.TaggedError(
1105 "InvalidDBInstanceAssociations",
1106)<{
1107 message: string;
1108}> {}
1109
1110const resolveAssociations = Effect.fn(function* (
1111 props: DBInstanceProps,
1112 observed?: rds.DBInstance,
1113) {
1114 const clusterIdentifier =
1115 props.dbClusterIdentifier ?? observed?.DBClusterIdentifier;
1116 const clusterOwned = clusterOwnsConfiguration(props, observed);
1117 const aurora =
1118 props.engine.startsWith("aurora") ||
1119 observed?.Engine?.startsWith("aurora") === true;
1120 const custom = props.engine.startsWith("custom-");
1121 if (clusterOwned && !aurora) {
1122 if (props.dbParameterGroupName !== undefined) {
1123 return yield* new InvalidDBInstanceAssociations({
1124 message: "Multi-AZ DB cluster associations are managed on the cluster",
1125 });
1126 }
1127 return { dbParameterGroupName: undefined, vpcSecurityGroupIds: undefined };
1128 }
1129 if (
1130 props.engine.startsWith("db2-") &&
1131 props.dbParameterGroupName === undefined &&
1132 (props.licenseModel ??
1133 observed?.LicenseModel ??
1134 "bring-your-own-license") === "bring-your-own-license"
1135 ) {
1136 return yield* new InvalidDBInstanceAssociations({
1137 message:
1138 "Db2 BYOL requires an explicit parameter group with IBM licensing IDs",
1139 });
1140 }
1141 let dbParameterGroupName = props.dbParameterGroupName;
1142 if (custom && dbParameterGroupName !== undefined) {
1143 return yield* new InvalidDBInstanceAssociations({
1144 message: "RDS Custom does not support DB parameter-group associations",
1145 });
1146 }
1147 if (!custom && dbParameterGroupName === undefined) {
1148 let engineVersion = props.engineVersion ?? observed?.EngineVersion;
1149 if (clusterIdentifier !== undefined) {
1150 engineVersion = (yield* rds.describeDBClusters({
1151 DBClusterIdentifier: clusterIdentifier,
1152 })).DBClusters?.[0]?.EngineVersion;
1153 if (!engineVersion) {
1154 return yield* new InvalidDBInstanceAssociations({
1155 message: "The DB cluster did not return an engine version",
1156 });
1157 }
1158 }
1159 const pages = yield* rds.describeDBEngineVersions
1160 .pages({
1161 Engine: props.engine,
1162 EngineVersion: engineVersion,
1163 DefaultOnly: engineVersion === undefined ? true : undefined,
1164 IncludeAll: engineVersion !== undefined ? true : undefined,
1165 })
1166 .pipe(Stream.runCollect);
1167 const families = new Set(
1168 pages.flatMap((page) =>
1169 (page.DBEngineVersions ?? []).map(
1170 (version) => version.DBParameterGroupFamily,
1171 ),
1172 ),
1173 );
1174 const family = [...families][0];
1175 if (families.size !== 1 || !family) {
1176 return yield* new InvalidDBInstanceAssociations({
1177 message:
1178 "No unambiguous DB parameter group family; declare engineVersion or dbParameterGroupName",
1179 });
1180 }
1181 dbParameterGroupName = `default.${family}`;
1182 }
1183 if (clusterOwned)
1184 return { dbParameterGroupName, vpcSecurityGroupIds: undefined };
1185 let vpcSecurityGroupIds = props.vpcSecurityGroupIds;
1186 if (vpcSecurityGroupIds === undefined) {
1187 const vpcId =
1188 props.dbSubnetGroupName !== undefined
1189 ? (yield* rds.describeDBSubnetGroups({
1190 DBSubnetGroupName: props.dbSubnetGroupName,
1191 })).DBSubnetGroups?.[0]?.VpcId
1192 : (observed?.DBSubnetGroup?.VpcId ??
1193 (yield* ec2.describeVpcs({
1194 Filters: [{ Name: "is-default", Values: ["true"] }],
1195 })).Vpcs?.[0]?.VpcId);
1196 if (!vpcId) {
1197 return yield* new InvalidDBInstanceAssociations({
1198 message:
1199 "No VPC found for the DB instance; declare dbSubnetGroupName or provide a default VPC",
1200 });
1201 }
1202 const groups = yield* ec2.describeSecurityGroups({
1203 Filters: [
1204 { Name: "vpc-id", Values: [vpcId] },
1205 { Name: "group-name", Values: ["default"] },
1206 ],
1207 });
1208 vpcSecurityGroupIds = (groups.SecurityGroups ?? []).flatMap((group) =>
1209 group.GroupId ? [group.GroupId] : [],
1210 );
1211 if (vpcSecurityGroupIds.length !== 1) {
1212 return yield* new InvalidDBInstanceAssociations({
1213 message: `Expected one default security group in VPC '${vpcId}'`,
1214 });
1215 }
1216 }
1217 if (vpcSecurityGroupIds.length === 0) {
1218 return yield* new InvalidDBInstanceAssociations({
1219 message:
1220 "At least one VPC security group is required; omit vpcSecurityGroupIds to select the VPC default group",
1221 });
1222 }
1223 return {
1224 dbParameterGroupName,
1225 vpcSecurityGroupIds: [...new Set(vpcSecurityGroupIds)],
1226 };
1227});
1228
1229type Associations = Effect.Success<ReturnType<typeof resolveAssociations>>;
1230
1231const parameterGroupMatches = (
1232 desired: Associations,
1233 instance: rds.DBInstance,
1234) =>
1235 desired.dbParameterGroupName === undefined ||
1236 sameMembers(
1237 [desired.dbParameterGroupName],
1238 (instance.DBParameterGroups ?? []).map(
1239 (group) => group.DBParameterGroupName,
1240 ),
1241 );
1242
1243const securityGroupsMatch = (desired: Associations, instance: rds.DBInstance) =>
1244 desired.vpcSecurityGroupIds === undefined ||
1245 sameMembers(
1246 desired.vpcSecurityGroupIds,
1247 (instance.VpcSecurityGroups ?? []).map((group) => group.VpcSecurityGroupId),
1248 );
1249
1250const associationsConverged = (
1251 desired: Associations,
1252 instance: rds.DBInstance,
1253) =>
1254 parameterGroupMatches(desired, instance) &&
1255 (desired.dbParameterGroupName === undefined ||
1256 (instance.DBParameterGroups ?? []).every(
1257 (group) =>
1258 group.ParameterApplyStatus === "in-sync" ||
1259 group.ParameterApplyStatus === "pending-reboot",
1260 )) &&
1261 securityGroupsMatch(desired, instance) &&
1262 (desired.vpcSecurityGroupIds === undefined ||
1263 (instance.VpcSecurityGroups ?? []).every(
1264 (group) => group.Status === "active",
1265 ));
1266
1267class InvalidDBInstancePort extends Data.TaggedError("InvalidDBInstancePort")<{
1268 message: string;
1269}> {}
1270
1271const desiredInstancePort = Effect.fn(function* (
1272 props: DBInstanceProps,
1273 observed?: rds.DBInstance,
1274) {
1275 if (clusterOwnsConfiguration(props, observed)) {
1276 return undefined;
1277 }
1278 if (props.port !== undefined) {
1279 if (
1280 !Number.isInteger(props.port) ||
1281 props.port < 1150 ||
1282 props.port > 65535
1283 ) {
1284 return yield* new InvalidDBInstancePort({
1285 message: "port must be an integer between 1150 and 65535",
1286 });
1287 }
1288 return props.port;
1289 }
1290 if (props.engine === "postgres") return 5432;
1291 if (props.engine === "mysql" || props.engine === "mariadb") return 3306;
1292 if (
1293 props.engine.startsWith("oracle-") ||
1294 props.engine.startsWith("custom-oracle-")
1295 )
1296 return 1521;
1297 if (
1298 props.engine.startsWith("sqlserver-") ||
1299 props.engine.startsWith("custom-sqlserver-")
1300 )
1301 return 1433;
1302 if (props.engine.startsWith("db2-")) return 50000;
1303 return yield* new InvalidDBInstancePort({
1304 message: `Declare port explicitly for engine '${props.engine}'`,
1305 });
1306});
1307
1308const portConverged = (instance: rds.DBInstance, port: number | undefined) =>
1309 port === undefined ||
1310 (instance.Endpoint?.Port === port &&
1311 instance.PendingModifiedValues?.Port === undefined);
1312
1313class InvalidDBInstanceStorage extends Data.TaggedError(
1314 "InvalidDBInstanceStorage",
1315)<{ message: string }> {}
1316
1317type StorageRequest = Required<
1318 Pick<rds.CreateDBInstanceMessage, "AllocatedStorage" | "StorageType">
1319> &
1320 Pick<rds.CreateDBInstanceMessage, "Iops" | "StorageThroughput">;
1321
1322interface DesiredStorage {
1323 request: StorageRequest;
1324 iops: number;
1325 throughput: number;
1326}
1327
1328const resolveStorage = Effect.fn(function* (
1329 props: DBInstanceProps,
1330 allocatedFloor = 0,
1331) {
1332 if (props.dbClusterIdentifier || props.engine.startsWith("aurora")) {
1333 if (
1334 props.allocatedStorage !== undefined ||
1335 props.storageType !== undefined ||
1336 props.iops !== undefined ||
1337 props.storageThroughput !== undefined
1338 ) {
1339 return yield* new InvalidDBInstanceStorage({
1340 message: "Configure cluster-owned storage on DBCluster, not DBInstance",
1341 });
1342 }
1343 return undefined;
1344 }
1345 const sqlServer = props.engine.includes("sqlserver-");
1346 const oracle = props.engine.includes("oracle-");
1347 const storageType = props.storageType ?? "gp3";
1348 const provisioned = storageType === "io1" || storageType === "io2";
1349 if (
1350 !["standard", "gp2", "gp3", "io1", "io2"].includes(storageType) ||
1351 (storageType === "standard" && allocatedFloor === 0) ||
1352 (storageType === "gp2" && props.engine.startsWith("db2-"))
1353 ) {
1354 return yield* new InvalidDBInstanceStorage({
1355 message: `Storage type '${storageType}' is not supported for '${props.engine}'`,
1356 });
1357 }
1358 const custom = props.engine.startsWith("custom-");
1359 const minimum = custom
1360 ? 40
1361 : sqlServer
1362 ? 20
1363 : provisioned
1364 ? 100
1365 : storageType === "standard"
1366 ? 5
1367 : 20;
1368 const requested =
1369 props.allocatedStorage ?? (provisioned ? 100 : custom ? 40 : 20);
1370 if (!Number.isInteger(requested) || requested <= 0) {
1371 return yield* new InvalidDBInstanceStorage({
1372 message: "allocatedStorage must be a positive integer",
1373 });
1374 }
1375 // An in-place increase must be at least ten percent of the current allocation.
1376 const allocation =
1377 requested > allocatedFloor && allocatedFloor > 0
1378 ? Math.max(requested, Math.ceil((allocatedFloor * 11) / 10))
1379 : Math.max(requested, allocatedFloor);
1380 if (allocation < minimum) {
1381 return yield* new InvalidDBInstanceStorage({
1382 message: `Effective allocated storage must be at least ${minimum} GiB for ${storageType}`,
1383 });
1384 }
1385 const ratioCapacity = Math.max(allocation, props.maxAllocatedStorage ?? 0);
1386 if (storageType !== "gp3") {
1387 if (
1388 props.storageThroughput !== undefined ||
1389 (!provisioned && props.iops !== undefined)
1390 ) {
1391 return yield* new InvalidDBInstanceStorage({
1392 message: `${storageType} does not support the declared IOPS/throughput settings`,
1393 });
1394 }
1395 const minimumIops = provisioned
1396 ? Math.max(
1397 1000,
1398 Math.ceil(
1399 ratioCapacity * (sqlServer && storageType === "io1" ? 1 : 0.5),
1400 ),
1401 )
1402 : 0;
1403 const iops = props.iops ?? minimumIops;
1404 const maximumIops = allocation * (storageType === "io1" ? 50 : 1000);
1405 if (
1406 provisioned &&
1407 (!Number.isInteger(iops) || iops < minimumIops || iops > maximumIops)
1408 ) {
1409 return yield* new InvalidDBInstanceStorage({
1410 message: `The allocation and autoscaling limit require ${storageType} IOPS between ${minimumIops} and ${maximumIops}`,
1411 });
1412 }
1413 return {
1414 request: {
1415 AllocatedStorage: allocation,
1416 StorageType: storageType,
1417 Iops: provisioned ? iops : undefined,
1418 },
1419 iops,
1420 throughput: 0,
1421 } satisfies DesiredStorage;
1422 }
1423 const striped = !sqlServer && allocation >= (oracle ? 200 : 400);
1424 const configurable = sqlServer || striped;
1425 const baselineIops = striped ? 12000 : 3000;
1426 const baselineThroughput = striped ? 500 : 125;
1427 const minimumIops = Math.max(
1428 baselineIops,
1429 sqlServer ? Math.ceil(ratioCapacity * 0.5) : 0,
1430 (props.storageThroughput ?? baselineThroughput) * 4,
1431 );
1432 const iops = props.iops ?? minimumIops;
1433 const minimumThroughput = Math.max(
1434 baselineThroughput,
1435 ["mysql", "mariadb"].includes(props.engine) ? Math.ceil(iops / 64) : 0,
1436 );
1437 const throughput = props.storageThroughput ?? minimumThroughput;
1438 if (
1439 !Number.isInteger(iops) ||
1440 iops < minimumIops ||
1441 !Number.isInteger(throughput) ||
1442 throughput < minimumThroughput ||
1443 throughput > iops * 0.25 ||
1444 (!configurable &&
1445 (iops !== baselineIops || throughput !== baselineThroughput))
1446 ) {
1447 return yield* new InvalidDBInstanceStorage({
1448 message: configurable
1449 ? `gp3 requires at least ${minimumIops} IOPS and ${minimumThroughput} MiBps, with throughput <= IOPS / 4`
1450 : `Small ${props.engine} gp3 storage has fixed 3000 IOPS and 125 MiBps`,
1451 });
1452 }
1453 return {
1454 request: {
1455 AllocatedStorage: allocation,
1456 StorageType: storageType,
1457 Iops: configurable ? iops : undefined,
1458 StorageThroughput: configurable ? throughput : undefined,
1459 },
1460 iops,
1461 throughput,
1462 } satisfies DesiredStorage;
1463});
1464
1465const hasPendingStorage = (instance: rds.DBInstance) =>
1466 instance.PendingModifiedValues?.AllocatedStorage !== undefined ||
1467 instance.PendingModifiedValues?.StorageType !== undefined ||
1468 instance.PendingModifiedValues?.Iops !== undefined ||
1469 instance.PendingModifiedValues?.StorageThroughput !== undefined;
1470
1471const storageConverged = (
1472 observed: rds.DBInstance,
1473 desired: DesiredStorage | undefined,
1474): boolean =>
1475 desired === undefined ||
1476 ((observed.AllocatedStorage ?? 0) >= desired.request.AllocatedStorage &&
1477 observed.StorageType === desired.request.StorageType &&
1478 (observed.Iops ?? 0) === desired.iops &&
1479 (observed.StorageThroughput ?? 0) === desired.throughput &&
1480 !hasPendingStorage(observed));
1481
1482const toStorageConfiguration = Effect.fn(function* (props: DBInstanceProps) {
1483 const supportsAutoscaling =
1484 props.dbClusterIdentifier === undefined &&
1485 !props.engine.startsWith("aurora") &&
1486 !props.engine.startsWith("custom-");
1487 if (!supportsAutoscaling && props.maxAllocatedStorage !== undefined) {
1488 return yield* new InvalidDBInstanceStorage({
1489 message:
1490 "maxAllocatedStorage is not supported for cluster members or RDS Custom",
1491 });
1492 }
1493 const maxAllocatedStorage = supportsAutoscaling
1494 ? (props.maxAllocatedStorage ?? 0)
1495 : undefined;
1496 if (
1497 maxAllocatedStorage !== undefined &&
1498 (!Number.isInteger(maxAllocatedStorage) || maxAllocatedStorage < 0)
1499 ) {
1500 return yield* new InvalidDBInstanceStorage({
1501 message: "maxAllocatedStorage must be a nonnegative integer in GiB",
1502 });
1503 }
1504 return {
1505 allocatedStorage:
1506 props.dbClusterIdentifier === undefined
1507 ? props.allocatedStorage
1508 : undefined,
1509 maxAllocatedStorage,
1510 };
1511});
1512
1513type StorageConfiguration = Effect.Success<
1514 ReturnType<typeof toStorageConfiguration>
1515>;
1516
1517const allocationConverged = (
1518 desired: StorageConfiguration,
1519 observed: rds.DBInstance,
1520) =>
1521 desired.allocatedStorage === undefined ||
1522 (observed.AllocatedStorage !== undefined &&
1523 observed.AllocatedStorage >= desired.allocatedStorage);
1524
1525const autoscalingConverged = (
1526 desired: StorageConfiguration,
1527 observed: rds.DBInstance,
1528) => {
1529 const ceiling = desired.maxAllocatedStorage;
1530 if (ceiling === undefined) return true;
1531 // RDS reports disabled autoscaling as zero; equality also prevents growth.
1532 if (ceiling === 0 || ceiling === observed.AllocatedStorage) {
1533 return (
1534 (observed.MaxAllocatedStorage ?? 0) === 0 ||
1535 observed.MaxAllocatedStorage === observed.AllocatedStorage
1536 );
1537 }
1538 return ceiling === observed.MaxAllocatedStorage;
1539};
1540
1541class InvalidDBInstanceSecretPolicy extends Data.TaggedError(
1542 "InvalidDBInstanceSecretPolicy",
1543)<{ message: string }> {}
1544
1545class DBInstanceManagedSecretMissing extends Data.TaggedError(
1546 "DBInstanceManagedSecretMissing",
1547)<{ instanceId: string }> {
1548 override get message() {
1549 return `DB instance '${this.instanceId}' has no RDS-managed master secret for its resource policy`;
1550 }
1551}
1552
1553class DBInstanceSecretPolicyPending extends Data.TaggedError(
1554 "DBInstanceSecretPolicyPending",
1555)<{ instanceId: string }> {
1556 override get message() {
1557 return `DB instance '${this.instanceId}' managed-secret resource policy has not propagated`;
1558 }
1559}
1560
1561class DBInstancePending extends Data.TaggedError("DBInstancePending")<{
1562 instanceId: string;
1563 status: string | undefined;
1564}> {
1565 override get message() {
1566 return `DB instance '${this.instanceId}' is not ready (status: ${this.status ?? "not yet visible"})`;
1567 }
1568}
1569
1570class DBInstanceReadinessBlocked extends Data.TaggedError(
1571 "DBInstanceReadinessBlocked",
1572)<{
1573 instanceId: string;
1574 status: string;
1575}> {
1576 override get message() {
1577 return `DB instance '${this.instanceId}' requires intervention (status: ${this.status})`;
1578 }
1579}
1580
1581const blockedStatuses = new Set([
1582 "automation-paused",
1583 "delete-precheck",
1584 "deleted",
1585 "deleting",
1586 "failed",
1587 "inaccessible-encryption-credentials",
1588 "inaccessible-encryption-credentials-recoverable",
1589 "incompatible-create",
1590 "incompatible-network",
1591 "incompatible-option-group",
1592 "incompatible-parameters",
1593 "incompatible-restore",
1594 "insufficient-capacity",
1595 "restore-error",
1596 "stopped",
1597 "stopping",
1598 "storage-full",
1599 "unsupported-configuration",
1600 "upgrade-failed",
1601]);
1602
1603const instanceReady = (
1604 instance: rds.DBInstance | undefined,
1605): instance is rds.DBInstance & { DBInstanceArn: string } =>
1606 !!instance?.DBInstanceArn &&
1607 (instance.DBInstanceStatus === "available" ||
1608 instance.DBInstanceStatus === "storage-optimization");
1609
1610export const DBInstanceProvider = () =>
1611 Provider.effect(
1612 DBInstance,
1613 Effect.gen(function* () {
1614 const toIdentifier = (id: string, props: DBInstanceProps) =>
1615 props.dbInstanceIdentifier
1616 ? Effect.succeed(props.dbInstanceIdentifier)
1617 : createPhysicalName({ id, maxLength: 63 });
1618
1619 const readInstance = Effect.fn(function* (instanceId: string) {
1620 const response = yield* rds
1621 .describeDBInstances({
1622 DBInstanceIdentifier: instanceId,
1623 })
1624 .pipe(
1625 Effect.catchTag("DBInstanceNotFoundFault", () =>
1626 Effect.succeed(undefined),
1627 ),
1628 );
1629 return response?.DBInstances?.[0];
1630 });
1631
1632 const observeReadiness = Effect.fn(function* (instanceId: string) {
1633 const instance = yield* readInstance(instanceId);
1634 const status = instance?.DBInstanceStatus;
1635 if (status !== undefined && blockedStatuses.has(status)) {
1636 return yield* new DBInstanceReadinessBlocked({ instanceId, status });
1637 }
1638 return instance;
1639 });
1640
1641 // Storage optimization is online; provisioning retains its ten-minute budget.
1642 const waitForInstance = Effect.fn(function* (instanceId: string) {
1643 return yield* observeReadiness(instanceId).pipe(
1644 Effect.flatMap((instance) =>
1645 instanceReady(instance)
1646 ? Effect.succeed(instance)
1647 : Effect.fail(
1648 new DBInstancePending({
1649 instanceId,
1650 status: instance?.DBInstanceStatus,
1651 }),
1652 ),
1653 ),
1654 Effect.retry({
1655 while: (error) => error._tag === "DBInstancePending",
1656 schedule: Schedule.max([
1657 Schedule.fixed("10 seconds"),
1658 Schedule.recurs(60),
1659 ]),
1660 }),
1661 );
1662 });
1663
1664 const waitForSecurity = Effect.fn(function* (
1665 instanceId: string,
1666 desired: SecurityConfiguration,
1667 ) {
1668 const instance = yield* observeReadiness(instanceId).pipe(
1669 Effect.repeat({
1670 schedule: Schedule.min([
1671 Schedule.exponential("5 seconds"),
1672 Schedule.spaced("1 minute"),
1673 ]),
1674 times: 10,
1675 until: (instance) =>
1676 instanceReady(instance) && securityConverged(desired, instance),
1677 }),
1678 );
1679 if (!instanceReady(instance) || !securityConverged(desired, instance)) {
1680 return yield* new DBInstanceSecurityPending({ instanceId });
1681 }
1682 return instance;
1683 });
1684
1685 const waitForAssociations = Effect.fn(function* (
1686 instanceId: string,
1687 desired: Associations,
1688 ) {
1689 const instance = yield* observeReadiness(instanceId).pipe(
1690 Effect.flatMap((instance) =>
1691 instance?.DBParameterGroups?.some(
1692 (group) => group.ParameterApplyStatus === "failed-to-apply",
1693 )
1694 ? Effect.fail(
1695 new InvalidDBInstanceAssociations({
1696 message: `DB instance '${instanceId}' parameter group failed to apply`,
1697 }),
1698 )
1699 : Effect.succeed(instance),
1700 ),
1701 Effect.repeat({
1702 schedule: Schedule.min([
1703 Schedule.exponential("5 seconds"),
1704 Schedule.spaced("1 minute"),
1705 ]),
1706 times: 10,
1707 until: (instance) =>
1708 instanceReady(instance) &&
1709 associationsConverged(desired, instance),
1710 }),
1711 );
1712 if (
1713 !instanceReady(instance) ||
1714 !associationsConverged(desired, instance)
1715 ) {
1716 return yield* new InvalidDBInstanceAssociations({
1717 message: `DB instance '${instanceId}' associations did not converge`,
1718 });
1719 }
1720 return instance;
1721 });
1722
1723 const waitForPort = Effect.fn(function* (
1724 instanceId: string,
1725 port: number,
1726 ) {
1727 const instance = yield* observeReadiness(instanceId).pipe(
1728 Effect.repeat({
1729 schedule: Schedule.min([
1730 Schedule.exponential("5 seconds"),
1731 Schedule.spaced("1 minute"),
1732 ]),
1733 times: 10,
1734 until: (instance) =>
1735 instanceReady(instance) && portConverged(instance, port),
1736 }),
1737 );
1738 if (!instanceReady(instance) || !portConverged(instance, port)) {
1739 return yield* new InvalidDBInstancePort({
1740 message: `DB instance '${instanceId}' listener did not converge (status: ${instance?.DBInstanceStatus}, desired: ${port}, observed: ${instance?.Endpoint?.Port}, pending: ${instance?.PendingModifiedValues?.Port})`,
1741 });
1742 }
1743 return instance;
1744 });
1745
1746 const waitForStorage = Effect.fn(function* (
1747 instanceId: string,
1748 converged: (instance: rds.DBInstance) => boolean,
1749 ) {
1750 // RDS can remain available while an accepted storage resize is pending.
1751 const instance = yield* observeReadiness(instanceId).pipe(
1752 Effect.repeat({
1753 schedule: Schedule.min([
1754 Schedule.exponential("5 seconds"),
1755 Schedule.spaced("1 minute"),
1756 ]),
1757 times: 10,
1758 until: (instance) => instanceReady(instance) && converged(instance),
1759 }),
1760 );
1761 if (!instanceReady(instance) || !converged(instance)) {
1762 return yield* new InvalidDBInstanceStorage({
1763 message: `DB instance '${instanceId}' storage did not converge (status: ${instance?.DBInstanceStatus}, allocated: ${instance?.AllocatedStorage}, maximum: ${instance?.MaxAllocatedStorage}, pending allocation: ${instance?.PendingModifiedValues?.AllocatedStorage})`,
1764 });
1765 }
1766 return instance;
1767 });
1768
1769 const validateMasterSecretPolicy = Effect.fn(function* (
1770 policy: PolicyDocument | undefined,
1771 ) {
1772 if (policy === undefined) return;
1773 const validation = yield* secretsmanager.validateResourcePolicy({
1774 ResourcePolicy: stringifyPolicyDocument(policy),
1775 });
1776 if (validation.PolicyValidationPassed !== true) {
1777 return yield* new InvalidDBInstanceSecretPolicy({
1778 message: `Managed-secret resource policy validation failed: ${(validation.ValidationErrors ?? []).map((error) => `${error.CheckName}: ${error.ErrorMessage}`).join("; ")}`,
1779 });
1780 }
1781 });
1782
1783 const readMasterSecretPolicy = Effect.fn(function* (secretArn: string) {
1784 const observed = yield* secretsmanager.getResourcePolicy({
1785 SecretId: secretArn,
1786 });
1787 return observed.ResourcePolicy === undefined
1788 ? undefined
1789 : normalizePolicyDocument(observed.ResourcePolicy);
1790 });
1791
1792 const readObservedSecretPolicy = (instance: rds.DBInstance) =>
1793 instance.MasterUserSecret?.SecretArn === undefined ||
1794 instance.DBClusterIdentifier !== undefined
1795 ? Effect.succeed(undefined)
1796 : readMasterSecretPolicy(instance.MasterUserSecret.SecretArn).pipe(
1797 Effect.retry({
1798 while: (error) => error._tag === "ResourceNotFoundException",
1799 schedule: Schedule.fixed("5 seconds"),
1800 times: 10,
1801 }),
1802 );
1803
1804 const reconcileMasterSecretPolicy = Effect.fn(function* (
1805 instanceId: string,
1806 instance: rds.DBInstance,
1807 policy: PolicyDocument | undefined,
1808 ) {
1809 if (instance.DBClusterIdentifier !== undefined) {
1810 return { instance, policy: undefined };
1811 }
1812 if (
1813 instance.MasterUserSecret?.SecretArn === undefined &&
1814 policy !== undefined
1815 ) {
1816 instance = yield* readInstance(instanceId).pipe(
1817 Effect.flatMap((current) =>
1818 current?.MasterUserSecret?.SecretArn === undefined
1819 ? Effect.fail(
1820 new DBInstanceManagedSecretMissing({ instanceId }),
1821 )
1822 : Effect.succeed(current),
1823 ),
1824 Effect.retry({
1825 while: (error) => error._tag === "DBInstanceManagedSecretMissing",
1826 schedule: Schedule.fixed("5 seconds"),
1827 times: 10,
1828 }),
1829 );
1830 }
1831 const secretArn = instance.MasterUserSecret?.SecretArn;
1832 if (secretArn === undefined) return { instance, policy: undefined };
1833 const desired =
1834 policy === undefined ? undefined : normalizePolicyDocument(policy);
1835 const observed = yield* readObservedSecretPolicy(instance);
1836 if (observed === desired) return { instance, policy: observed };
1837 const writePolicy =
1838 policy === undefined
1839 ? secretsmanager.deleteResourcePolicy({ SecretId: secretArn })
1840 : secretsmanager.putResourcePolicy({
1841 SecretId: secretArn,
1842 ResourcePolicy: stringifyPolicyDocument(policy),
1843 BlockPublicPolicy: true,
1844 });
1845 yield* writePolicy.pipe(
1846 Effect.retry({
1847 while: (error) => error._tag === "ResourceNotFoundException",
1848 schedule: Schedule.fixed("5 seconds"),
1849 times: 10,
1850 }),
1851 );
1852 // A missing secret is not evidence that its policy has been removed.
1853 const applied = yield* readMasterSecretPolicy(secretArn).pipe(
1854 Effect.flatMap((actual) =>
1855 actual === desired
1856 ? Effect.succeed(actual)
1857 : Effect.fail(new DBInstanceSecretPolicyPending({ instanceId })),
1858 ),
1859 Effect.retry({
1860 while: (error) =>
1861 error._tag === "DBInstanceSecretPolicyPending" ||
1862 error._tag === "ResourceNotFoundException",
1863 schedule: Schedule.fixed("5 seconds"),
1864 times: 10,
1865 }),
1866 );
1867 return { instance, policy: applied };
1868 });
1869
1870 return {
1871 stables: ["dbInstanceArn", "dbInstanceIdentifier"],
1872 // Pattern (a) AWS account/region collection: `describeDBInstances` is
1873 // paginated (items: "DBInstances") and returns each instance's
1874 // `TagList` inline, so we hydrate directly into the same shape `read`
1875 // produces — no per-item tag fetch needed. An empty/no-instances
1876 // account simply yields no pages. `DBInstanceNotFoundFault` is in the
1877 // op's typed error union; treat a stray one as "nothing to list".
1878 list: () =>
1879 rds.describeDBInstances.pages({}).pipe(
1880 Stream.runCollect,
1881 Effect.map((chunk) =>
1882 Array.from(chunk).flatMap((page) =>
1883 (page.DBInstances ?? [])
1884 .filter(
1885 (
1886 instance,
1887 ): instance is typeof instance & {
1888 DBInstanceArn: string;
1889 } => instance.DBInstanceArn != null,
1890 )
1891 .map((instance) =>
1892 toAttrs({
1893 instance,
1894 tags: toTagRecord(instance.TagList),
1895 }),
1896 ),
1897 ),
1898 ),
1899 Effect.catchTag("DBInstanceNotFoundFault", () =>
1900 Effect.succeed([] as DBInstance["Attributes"][]),
1901 ),
1902 ),
1903 diff: Effect.fn(function* ({ id, olds, news, output }) {
1904 if (!isResolved(news)) return undefined;
1905 yield* validateMasterSecretPolicy(
1906 news.masterUserSecretResourcePolicy,
1907 );
1908 if (
1909 (yield* toIdentifier(id, olds ?? ({} as DBInstanceProps))) !==
1910 (yield* toIdentifier(id, news))
1911 ) {
1912 return { action: "replace" } as const;
1913 }
1914 // Immutable props — any change forces a fresh instance.
1915 if (
1916 olds !== undefined &&
1917 (olds.engine !== news.engine ||
1918 olds.dbName !== news.dbName ||
1919 olds.masterUsername !== news.masterUsername ||
1920 olds.availabilityZone !== news.availabilityZone ||
1921 olds.storageEncrypted !== news.storageEncrypted ||
1922 olds.kmsKeyId !== news.kmsKeyId ||
1923 olds.dbSubnetGroupName !== news.dbSubnetGroupName)
1924 ) {
1925 return { action: "replace" } as const;
1926 }
1927 const storage = yield* toStorageConfiguration(news);
1928 if (output !== undefined) {
1929 const instance = yield* readInstance(output.dbInstanceIdentifier);
1930 if (!instance?.DBInstanceArn) {
1931 return { action: "update", stables: [] } as const;
1932 }
1933 if (!instanceReady(instance)) {
1934 return { action: "update" } as const;
1935 }
1936 const port = yield* desiredInstancePort(news, instance);
1937 const associations = yield* resolveAssociations(news, instance);
1938 const desiredStorage = yield* resolveStorage(
1939 news,
1940 instance.AllocatedStorage,
1941 );
1942 if (
1943 !storageConverged(instance, desiredStorage) ||
1944 !autoscalingConverged(storage, instance) ||
1945 !portConverged(instance, port) ||
1946 !associationsConverged(associations, instance) ||
1947 !securityConverged(
1948 securityConfiguration(news, instance),
1949 instance,
1950 ) ||
1951 !Object.hasOwn(output, "vpcSecurityGroupStatuses") ||
1952 (news.masterUserSecretResourcePolicy !== undefined &&
1953 instance.MasterUserSecret?.SecretArn === undefined) ||
1954 (news.masterUserSecretResourcePolicy === undefined
1955 ? undefined
1956 : normalizePolicyDocument(
1957 news.masterUserSecretResourcePolicy,
1958 )) !== (yield* readObservedSecretPolicy(instance))
1959 ) {
1960 return { action: "update" } as const;
1961 }
1962 }
1963 }),
1964 read: Effect.fn(function* ({ id, olds, output }) {
1965 const identifier =
1966 output?.dbInstanceIdentifier ??
1967 (yield* toIdentifier(
1968 id,
1969 olds ?? { dbInstanceClass: "", engine: "" },
1970 ));
1971 const instance = yield* readInstance(identifier);
1972 if (!instance?.DBInstanceArn) {
1973 return undefined;
1974 }
1975 return toAttrs({
1976 instance,
1977 tags: toTagRecord(instance.TagList),
1978 // Not observable from AWS — carry the stored deletion behavior
1979 // and last-sent fingerprint forward so a refresh drops neither.
1980 skipFinalSnapshot: output?.skipFinalSnapshot,
1981 finalDBSnapshotIdentifier: output?.finalDBSnapshotIdentifier,
1982 masterUserPasswordFingerprint:
1983 output?.masterUserPasswordFingerprint,
1984 masterUserSecretResourcePolicy:
1985 yield* readObservedSecretPolicy(instance),
1986 });
1987 }),
1988 reconcile: Effect.fn(function* ({ id, news, output, session }) {
1989 yield* validateMasterSecretPolicy(
1990 news.masterUserSecretResourcePolicy,
1991 );
1992 const identifier =
1993 output?.dbInstanceIdentifier ?? (yield* toIdentifier(id, news));
1994 // AWS never returns the master password, so there is nothing to
1995 // observe-and-diff — fingerprint the configured value instead and
1996 // only send `MasterUserPassword` when the fingerprint changed.
1997 // Without this, every reconcile of an instance whose props carry a
1998 // password triggers a live `resetting-master-credentials` modify.
1999 // The hash is salted with the (stable) instance identifier so the
2000 // persisted digest is not rainbow-table-lookupable, and kept
2001 // `Redacted` so it never leaks in plaintext through state/logs.
2002 const passwordFingerprint =
2003 news.masterUserPassword !== undefined
2004 ? Redacted.make(
2005 yield* sha256(
2006 `${identifier}:${Redacted.value(news.masterUserPassword)}`,
2007 ),
2008 )
2009 : undefined;
2010 const internalTags = yield* createInternalTags(id);
2011 const desiredTags = { ...internalTags, ...news.tags };
2012 // Duration props → the exact wire units the RDS API expects.
2013 const backupRetentionDays = toWireDays(news.backupRetentionPeriod);
2014 const performanceInsightsRetentionDays = toWireDays(
2015 news.performanceInsightsRetentionPeriod,
2016 );
2017 const monitoringIntervalSeconds = toWireSeconds(
2018 news.monitoringInterval,
2019 );
2020
2021 const storage = yield* toStorageConfiguration(news);
2022 // Observe — fetch live instance state.
2023 let observed = yield* observeReadiness(identifier);
2024 if (observed?.DBInstanceArn) {
2025 observed = yield* waitForInstance(identifier);
2026 }
2027 if (
2028 news.masterUserSecretResourcePolicy !== undefined &&
2029 (news.dbClusterIdentifier !== undefined ||
2030 news.engine.startsWith("custom-") ||
2031 observed?.DBClusterIdentifier !== undefined ||
2032 news.manageMasterUserPassword === false ||
2033 (!observed?.MasterUserSecret?.SecretArn &&
2034 !news.manageMasterUserPassword))
2035 ) {
2036 return yield* new InvalidDBInstanceSecretPolicy({
2037 message:
2038 "A master secret resource policy requires an instance-managed secret; enable manageMasterUserPassword on a standalone DBInstance",
2039 });
2040 }
2041 let security = securityConfiguration(news, observed);
2042 let port = yield* desiredInstancePort(news, observed);
2043 let associations = yield* resolveAssociations(news, observed);
2044 let desiredStorage = yield* resolveStorage(
2045 news,
2046 observed?.AllocatedStorage,
2047 );
2048
2049 // Ensure — create if missing. Tolerate
2050 // `DBInstanceAlreadyExistsFault` as a race with a peer reconciler.
2051 let created = false;
2052 if (!observed?.DBInstanceArn) {
2053 created = yield* rds
2054 .createDBInstance({
2055 DBInstanceIdentifier: identifier,
2056 DBClusterIdentifier: news.dbClusterIdentifier,
2057 DBInstanceClass: news.dbInstanceClass,
2058 Engine: news.engine,
2059 EngineVersion: news.engineVersion,
2060 DBName: news.dbName,
2061 ...desiredStorage?.request,
2062 // Omission at creation leaves autoscaling disabled.
2063 MaxAllocatedStorage: storage.maxAllocatedStorage || undefined,
2064 MasterUsername: news.masterUsername,
2065 MasterUserPassword: news.masterUserPassword,
2066 ManageMasterUserPassword: news.manageMasterUserPassword,
2067 MasterUserSecretKmsKeyId: news.masterUserSecretKmsKeyId,
2068 Port: port,
2069 MultiAZ: news.multiAZ,
2070 AvailabilityZone: news.availabilityZone,
2071 BackupRetentionPeriod: backupRetentionDays,
2072 PreferredBackupWindow: news.preferredBackupWindow,
2073 PreferredMaintenanceWindow: news.preferredMaintenanceWindow,
2074 DBSubnetGroupName: news.dbSubnetGroupName,
2075 DBParameterGroupName: associations.dbParameterGroupName,
2076 OptionGroupName: news.optionGroupName,
2077 LicenseModel: news.licenseModel,
2078 StorageEncrypted: news.storageEncrypted,
2079 KmsKeyId: news.kmsKeyId,
2080 CACertificateIdentifier: news.caCertificateIdentifier,
2081 EnableIAMDatabaseAuthentication: security.iamAuthentication,
2082 EnablePerformanceInsights: news.enablePerformanceInsights,
2083 PerformanceInsightsKMSKeyId: news.performanceInsightsKMSKeyId,
2084 PerformanceInsightsRetentionPeriod:
2085 performanceInsightsRetentionDays,
2086 MonitoringInterval: monitoringIntervalSeconds,
2087 MonitoringRoleArn: news.monitoringRoleArn,
2088 EnableCloudwatchLogsExports: security.logExports,
2089 DeletionProtection: security.deletionProtection,
2090 NetworkType: security.networkType,
2091 VpcSecurityGroupIds: associations.vpcSecurityGroupIds,
2092 PubliclyAccessible: security.publiclyAccessible,
2093 PromotionTier: news.promotionTier,
2094 AutoMinorVersionUpgrade: news.autoMinorVersionUpgrade,
2095 CopyTagsToSnapshot: news.copyTagsToSnapshot,
2096 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
2097 Key,
2098 Value,
2099 })),
2100 })
2101 .pipe(
2102 Effect.as(true),
2103 Effect.catchTag("DBInstanceAlreadyExistsFault", () =>
2104 Effect.succeed(false),
2105 ),
2106 );
2107
2108 observed = yield* waitForInstance(identifier);
2109 }
2110 if (!created) {
2111 if (hasPendingStorage(observed)) {
2112 observed = yield* waitForStorage(
2113 identifier,
2114 (instance) => !hasPendingStorage(instance),
2115 );
2116 }
2117 desiredStorage = yield* resolveStorage(
2118 news,
2119 observed.AllocatedStorage,
2120 );
2121
2122 associations = yield* resolveAssociations(news, observed);
2123
2124 // syncCoreSettings — single `modifyDBInstance` carrying scalar
2125 // in-place fields. Only emit a field when the desired value differs
2126 // from the observed cloud state, to avoid spurious
2127 // `PendingModifiedValues`. The listener port is synchronized below.
2128 const core: rds.ModifyDBInstanceMessage = {
2129 DBInstanceIdentifier: identifier,
2130 ApplyImmediately: true,
2131 };
2132 let coreDirty = false;
2133 const setIf = <K extends keyof rds.ModifyDBInstanceMessage>(
2134 key: K,
2135 desired: rds.ModifyDBInstanceMessage[K] | undefined,
2136 observedValue: unknown,
2137 ) => {
2138 if (desired !== undefined && desired !== observedValue) {
2139 core[key] = desired;
2140 coreDirty = true;
2141 }
2142 };
2143 setIf("DBInstanceClass", news.dbInstanceClass, observed.DBInstanceClass); // prettier-ignore
2144 setIf("EngineVersion", news.engineVersion, observed.EngineVersion);
2145 if (desiredStorage && !storageConverged(observed, desiredStorage)) {
2146 Object.assign(core, desiredStorage.request);
2147 core.MaxAllocatedStorage =
2148 storage.maxAllocatedStorage === 0
2149 ? desiredStorage.request.AllocatedStorage
2150 : storage.maxAllocatedStorage;
2151 coreDirty = true;
2152 }
2153 setIf("MultiAZ", news.multiAZ, observed.MultiAZ);
2154 setIf("BackupRetentionPeriod", backupRetentionDays, observed.BackupRetentionPeriod); // prettier-ignore
2155 setIf("PreferredBackupWindow", news.preferredBackupWindow, observed.PreferredBackupWindow); // prettier-ignore
2156 setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore
2157 setIf("OptionGroupName", news.optionGroupName, undefined);
2158 setIf("LicenseModel", news.licenseModel, observed.LicenseModel);
2159 setIf("CACertificateIdentifier", news.caCertificateIdentifier, observed.CACertificateIdentifier); // prettier-ignore
2160 setIf("EnablePerformanceInsights", news.enablePerformanceInsights, observed.PerformanceInsightsEnabled); // prettier-ignore
2161 setIf("PerformanceInsightsKMSKeyId", news.performanceInsightsKMSKeyId, observed.PerformanceInsightsKMSKeyId); // prettier-ignore
2162 setIf("PerformanceInsightsRetentionPeriod", performanceInsightsRetentionDays, observed.PerformanceInsightsRetentionPeriod); // prettier-ignore
2163 setIf("MonitoringInterval", monitoringIntervalSeconds, observed.MonitoringInterval); // prettier-ignore
2164 setIf("MonitoringRoleArn", news.monitoringRoleArn, observed.MonitoringRoleArn); // prettier-ignore
2165 if (!parameterGroupMatches(associations, observed)) {
2166 core.DBParameterGroupName = associations.dbParameterGroupName;
2167 coreDirty = true;
2168 }
2169 setIf("PromotionTier", news.promotionTier, observed.PromotionTier);
2170 setIf("AutoMinorVersionUpgrade", news.autoMinorVersionUpgrade, observed.AutoMinorVersionUpgrade); // prettier-ignore
2171 setIf("CopyTagsToSnapshot", news.copyTagsToSnapshot, observed.CopyTagsToSnapshot); // prettier-ignore
2172 if (!securityGroupsMatch(associations, observed)) {
2173 if (news.engine.startsWith("custom-")) {
2174 return yield* new InvalidDBInstanceAssociations({
2175 message:
2176 "Changing existing RDS Custom security-group associations through ModifyDBInstance is not supported",
2177 });
2178 }
2179 core.VpcSecurityGroupIds = associations.vpcSecurityGroupIds;
2180 coreDirty = true;
2181 }
2182 if (news.allowMajorVersionUpgrade) {
2183 core.AllowMajorVersionUpgrade = true;
2184 }
2185 // syncMasterPassword — rotation or explicit password update. The
2186 // explicit branch is fingerprint-guarded: send only when the
2187 // configured password actually changed (or was never
2188 // fingerprinted — pre-existing state sends once, then records).
2189 if (
2190 news.manageMasterUserPassword &&
2191 !observed.MasterUserSecret?.SecretArn &&
2192 !observed.DBClusterIdentifier
2193 ) {
2194 core.ManageMasterUserPassword = true;
2195 core.MasterUserSecretKmsKeyId = news.masterUserSecretKmsKeyId;
2196 coreDirty = true;
2197 }
2198 if (
2199 news.manageMasterUserPassword &&
2200 news.rotateMasterUserPassword
2201 ) {
2202 core.RotateMasterUserPassword = true;
2203 coreDirty = true;
2204 } else if (
2205 news.masterUserPassword !== undefined &&
2206 !sameFingerprint(
2207 passwordFingerprint,
2208 output?.masterUserPasswordFingerprint,
2209 )
2210 ) {
2211 core.MasterUserPassword = news.masterUserPassword;
2212 coreDirty = true;
2213 }
2214 if (coreDirty) {
2215 yield* rds.modifyDBInstance(core);
2216 observed = yield* waitForInstance(identifier);
2217 if (
2218 core.DBParameterGroupName !== undefined ||
2219 core.VpcSecurityGroupIds !== undefined
2220 ) {
2221 observed = yield* waitForAssociations(identifier, associations);
2222 }
2223 if (core.AllocatedStorage !== undefined) {
2224 observed = yield* waitForStorage(identifier, (instance) =>
2225 storageConverged(instance, desiredStorage),
2226 );
2227 }
2228 }
2229 }
2230
2231 if (hasPendingStorage(observed)) {
2232 observed = yield* waitForStorage(
2233 identifier,
2234 (instance) => !hasPendingStorage(instance),
2235 );
2236 }
2237 desiredStorage = yield* resolveStorage(
2238 news,
2239 observed.AllocatedStorage,
2240 );
2241 if (desiredStorage && !storageConverged(observed, desiredStorage)) {
2242 yield* rds.modifyDBInstance({
2243 DBInstanceIdentifier: identifier,
2244 ...desiredStorage.request,
2245 MaxAllocatedStorage:
2246 storage.maxAllocatedStorage === 0
2247 ? desiredStorage.request.AllocatedStorage
2248 : storage.maxAllocatedStorage,
2249 ApplyImmediately: true,
2250 });
2251 observed = yield* waitForInstance(identifier);
2252 observed = yield* waitForStorage(identifier, (instance) =>
2253 storageConverged(instance, desiredStorage),
2254 );
2255 }
2256 if (!autoscalingConverged(storage, observed)) {
2257 const ceiling =
2258 storage.maxAllocatedStorage === 0
2259 ? observed.AllocatedStorage
2260 : storage.maxAllocatedStorage;
2261 if (ceiling === undefined) {
2262 return yield* new InvalidDBInstanceStorage({
2263 message:
2264 "RDS did not return the allocated storage needed to disable autoscaling",
2265 });
2266 }
2267 // AWS disables autoscaling when the ceiling equals live allocation.
2268 yield* rds.modifyDBInstance({
2269 DBInstanceIdentifier: identifier,
2270 MaxAllocatedStorage: ceiling,
2271 ApplyImmediately: true,
2272 });
2273 observed = yield* waitForStorage(
2274 identifier,
2275 (instance) =>
2276 allocationConverged(storage, instance) &&
2277 autoscalingConverged(storage, instance),
2278 );
2279 }
2280
2281 port = yield* desiredInstancePort(news, observed);
2282 if (port !== undefined && !portConverged(observed, port)) {
2283 if (observed.PendingModifiedValues?.Port !== port) {
2284 if (news.engine.startsWith("custom-")) {
2285 return yield* new InvalidDBInstancePort({
2286 message:
2287 "Changing an existing RDS Custom listener through ModifyDBInstance is not supported",
2288 });
2289 }
2290 // Port changes restart immediately without applying unrelated pending settings.
2291 yield* rds.modifyDBInstance({
2292 DBInstanceIdentifier: identifier,
2293 DBPortNumber: port,
2294 ApplyImmediately: false,
2295 });
2296 }
2297 observed = yield* waitForPort(identifier, port);
2298 }
2299
2300 associations = yield* resolveAssociations(news, observed);
2301 if (!associationsConverged(associations, observed)) {
2302 const parameterChanged = !parameterGroupMatches(
2303 associations,
2304 observed,
2305 );
2306 const securityChanged = !securityGroupsMatch(
2307 associations,
2308 observed,
2309 );
2310 if (securityChanged && news.engine.startsWith("custom-")) {
2311 return yield* new InvalidDBInstanceAssociations({
2312 message:
2313 "Changing existing RDS Custom security-group associations through ModifyDBInstance is not supported",
2314 });
2315 }
2316 if (parameterChanged || securityChanged) {
2317 yield* rds.modifyDBInstance({
2318 DBInstanceIdentifier: identifier,
2319 DBParameterGroupName: parameterChanged
2320 ? associations.dbParameterGroupName
2321 : undefined,
2322 VpcSecurityGroupIds: securityChanged
2323 ? associations.vpcSecurityGroupIds
2324 : undefined,
2325 ApplyImmediately: true,
2326 });
2327 }
2328 observed = yield* waitForAssociations(identifier, associations);
2329 }
2330
2331 security = securityConfiguration(news, observed);
2332 const iamPending =
2333 observed.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled;
2334 const iamChanged = !iamConverged(security, observed);
2335 const networkChanged =
2336 security.networkType !== undefined &&
2337 security.networkType !== observed.NetworkType;
2338 if (iamChanged || networkChanged) {
2339 // IAM and network changes honor maintenance scheduling. AWS cannot
2340 // selectively apply one queued change without applying the rest.
2341 yield* rds.modifyDBInstance({
2342 DBInstanceIdentifier: identifier,
2343 EnableIAMDatabaseAuthentication:
2344 iamChanged && iamPending !== security.iamAuthentication
2345 ? security.iamAuthentication
2346 : undefined,
2347 NetworkType: networkChanged ? security.networkType : undefined,
2348 ApplyImmediately: true,
2349 });
2350 observed = yield* waitForInstance(identifier);
2351 }
2352
2353 const publicChanged =
2354 security.publiclyAccessible !== undefined &&
2355 security.publiclyAccessible !== observed.PubliclyAccessible;
2356 const protectionChanged =
2357 security.deletionProtection !== undefined &&
2358 security.deletionProtection !== observed.DeletionProtection;
2359 const logDelta = logExportDelta(
2360 effectiveLogExports(observed),
2361 security.logExports,
2362 );
2363 if (publicChanged || protectionChanged || logDelta) {
2364 // These fields apply immediately even with false; leave unrelated
2365 // maintenance-window changes queued rather than flushing them.
2366 yield* rds.modifyDBInstance({
2367 DBInstanceIdentifier: identifier,
2368 PubliclyAccessible: publicChanged
2369 ? security.publiclyAccessible
2370 : undefined,
2371 DeletionProtection: protectionChanged
2372 ? security.deletionProtection
2373 : undefined,
2374 CloudwatchLogsExportConfiguration: logDelta,
2375 ApplyImmediately: false,
2376 });
2377 }
2378 observed = yield* waitForSecurity(identifier, security);
2379
2380 const dbInstanceArn = observed.DBInstanceArn ?? "";
2381
2382 // Sync tags — diff observed cloud tags against desired.
2383 const observedTags = toTagRecord(observed.TagList);
2384 const { removed, upsert } = diffTags(observedTags, desiredTags);
2385 if (upsert.length > 0 && dbInstanceArn) {
2386 yield* rds.addTagsToResource({
2387 ResourceName: dbInstanceArn,
2388 Tags: upsert,
2389 });
2390 }
2391 if (removed.length > 0 && dbInstanceArn) {
2392 yield* rds.removeTagsFromResource({
2393 ResourceName: dbInstanceArn,
2394 TagKeys: removed,
2395 });
2396 }
2397
2398 const managedSecret = yield* reconcileMasterSecretPolicy(
2399 identifier,
2400 observed,
2401 news.masterUserSecretResourcePolicy,
2402 );
2403 yield* session.note(dbInstanceArn || identifier);
2404 return toAttrs({
2405 instance: managedSecret.instance,
2406 tags: desiredTags,
2407 skipFinalSnapshot: news.skipFinalSnapshot,
2408 finalDBSnapshotIdentifier: news.finalDBSnapshotIdentifier,
2409 masterUserPasswordFingerprint: passwordFingerprint,
2410 masterUserSecretResourcePolicy: managedSecret.policy,
2411 });
2412 }),
2413 delete: Effect.fn(function* ({ output }) {
2414 // Default preserves the existing behavior (no final snapshot). When
2415 // the resource was declared with `skipFinalSnapshot: false`, take
2416 // one — timestamped by default so repeated destroy/create cycles
2417 // never collide on snapshot names.
2418 const skipFinalSnapshot = output.skipFinalSnapshot ?? true;
2419 const finalDBSnapshotIdentifier = skipFinalSnapshot
2420 ? undefined
2421 : (output.finalDBSnapshotIdentifier ??
2422 (yield* Effect.sync(
2423 () =>
2424 `${output.dbInstanceIdentifier}-final-${new Date()
2425 .toISOString()
2426 .replaceAll(/[:.]/g, "-")
2427 .toLowerCase()}`,
2428 )));
2429 yield* rds
2430 .deleteDBInstance({
2431 DBInstanceIdentifier: output.dbInstanceIdentifier,
2432 SkipFinalSnapshot: skipFinalSnapshot,
2433 FinalDBSnapshotIdentifier: finalDBSnapshotIdentifier,
2434 })
2435 .pipe(
2436 Effect.catchTag("DBInstanceNotFoundFault", () => Effect.void),
2437 );
2438 // Block until the instance is fully gone. RDS deletion is async; if we
2439 // return while it is still `deleting`, a dependent (e.g. a
2440 // DBSubnetGroup or VPC) is torn down next and AWS rejects it with
2441 // `InvalidDBSubnetGroupStateFault: ... still using it`.
2442 yield* Effect.repeat(
2443 rds
2444 .describeDBInstances({
2445 DBInstanceIdentifier: output.dbInstanceIdentifier,
2446 })
2447 .pipe(
2448 Effect.as(true),
2449 Effect.catchTag("DBInstanceNotFoundFault", () =>
2450 Effect.succeed(false),
2451 ),
2452 ),
2453 {
2454 schedule: Schedule.max([
2455 Schedule.fixed("15 seconds"),
2456 // A final snapshot serializes before the delete, so give
2457 // that path a larger budget than the plain-delete wait.
2458 Schedule.recurs(skipFinalSnapshot ? 40 : 80),
2459 ]),
2460 until: (exists) => exists === false,
2461 },
2462 ).pipe(Effect.catch(() => Effect.void));
2463 }),
2464 };
2465 }),
2466 );