| 1 | import * as docdb from "@distilled.cloud/aws/docdb"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { createInternalTags, diffTags } from "../../Tags.ts"; |
| 11 | |
| 12 | export interface DBInstanceProps { |
| 13 | /** |
| 14 | * Instance identifier. If omitted, Alchemy generates one. |
| 15 | */ |
| 16 | dbInstanceIdentifier?: string; |
| 17 | /** |
| 18 | * DocumentDB cluster the instance belongs to. Required — every DocumentDB |
| 19 | * instance is a cluster member. Immutable — forces replacement. |
| 20 | */ |
| 21 | dbClusterIdentifier: string; |
| 22 | /** |
| 23 | * Instance class such as `db.r6g.large` or `db.t3.medium`. In-place modify. |
| 24 | */ |
| 25 | dbInstanceClass: string; |
| 26 | /** |
| 27 | * Database engine. DocumentDB only supports `docdb`. |
| 28 | * Changing the engine forces replacement. |
| 29 | * @default "docdb" |
| 30 | */ |
| 31 | engine?: string; |
| 32 | /** |
| 33 | * Availability zone. Immutable — forces replacement. |
| 34 | */ |
| 35 | availabilityZone?: string; |
| 36 | /** |
| 37 | * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify. |
| 38 | */ |
| 39 | preferredMaintenanceWindow?: string; |
| 40 | /** |
| 41 | * Auto minor version upgrades. In-place modify. |
| 42 | */ |
| 43 | autoMinorVersionUpgrade?: boolean; |
| 44 | /** |
| 45 | * Promotion tier inside the cluster (0-15). In-place modify. |
| 46 | */ |
| 47 | promotionTier?: number; |
| 48 | /** |
| 49 | * Enable Performance Insights. In-place modify. |
| 50 | */ |
| 51 | enablePerformanceInsights?: boolean; |
| 52 | /** |
| 53 | * KMS key for Performance Insights. In-place modify. |
| 54 | */ |
| 55 | performanceInsightsKMSKeyId?: string; |
| 56 | /** |
| 57 | * CA certificate identifier. In-place modify. |
| 58 | */ |
| 59 | caCertificateIdentifier?: string; |
| 60 | /** |
| 61 | * Copy tags to snapshots. In-place modify. |
| 62 | */ |
| 63 | copyTagsToSnapshot?: boolean; |
| 64 | /** |
| 65 | * User-defined tags. |
| 66 | */ |
| 67 | tags?: Record<string, string>; |
| 68 | } |
| 69 | |
| 70 | export interface DBInstance extends Resource< |
| 71 | "AWS.DocDB.DBInstance", |
| 72 | DBInstanceProps, |
| 73 | { |
| 74 | /** The instance identifier (unique per account/region). */ |
| 75 | dbInstanceIdentifier: string; |
| 76 | /** The ARN of the instance. */ |
| 77 | dbInstanceArn: string; |
| 78 | /** The cluster this instance belongs to. */ |
| 79 | dbClusterIdentifier: string | undefined; |
| 80 | /** The instance endpoint hostname. */ |
| 81 | endpointAddress: string | undefined; |
| 82 | /** The port the instance accepts connections on. */ |
| 83 | endpointPort: number | undefined; |
| 84 | /** The compute class of the instance, e.g. `db.t3.medium`. */ |
| 85 | dbInstanceClass: string | undefined; |
| 86 | /** The database engine (`docdb`). */ |
| 87 | engine: string | undefined; |
| 88 | /** The engine version running on the instance. */ |
| 89 | engineVersion: string | undefined; |
| 90 | /** The current status of the instance, e.g. `available`. */ |
| 91 | status: string | undefined; |
| 92 | /** The failover promotion tier of the instance. */ |
| 93 | promotionTier: number | undefined; |
| 94 | /** Whether the instance is publicly accessible. */ |
| 95 | publiclyAccessible: boolean | undefined; |
| 96 | /** The DB subnet group the instance is deployed into. */ |
| 97 | dbSubnetGroupName: string | undefined; |
| 98 | /** The Availability Zone the instance runs in. */ |
| 99 | availabilityZone: string | undefined; |
| 100 | /** The weekly window during which maintenance can occur. */ |
| 101 | preferredMaintenanceWindow: string | undefined; |
| 102 | /** Number of days automated backups are retained (cluster-managed). */ |
| 103 | backupRetentionPeriod: number | undefined; |
| 104 | /** The KMS key used for storage encryption. */ |
| 105 | kmsKeyId: string | undefined; |
| 106 | /** Whether storage is encrypted at rest. */ |
| 107 | storageEncrypted: boolean | undefined; |
| 108 | /** The CA certificate identifier used by the instance. */ |
| 109 | caCertificateIdentifier: string | undefined; |
| 110 | /** Whether Performance Insights is enabled. */ |
| 111 | performanceInsightsEnabled: boolean | undefined; |
| 112 | /** The log types exported to CloudWatch Logs. */ |
| 113 | enabledCloudwatchLogsExports: string[]; |
| 114 | /** The immutable region-unique resource ID of the instance. */ |
| 115 | dbiResourceId: string | undefined; |
| 116 | /** Whether minor engine upgrades apply automatically. */ |
| 117 | autoMinorVersionUpgrade: boolean | undefined; |
| 118 | /** Whether instance tags are copied to snapshots. */ |
| 119 | copyTagsToSnapshot: boolean | undefined; |
| 120 | /** The tags attached to the instance. */ |
| 121 | tags: Record<string, string>; |
| 122 | }, |
| 123 | never, |
| 124 | Providers |
| 125 | > {} |
| 126 | |
| 127 | /** |
| 128 | * An Amazon DocumentDB instance — a compute member of a DocumentDB |
| 129 | * {@link DBCluster}. Storage, backup, and endpoints are managed at the cluster |
| 130 | * level; the instance contributes CPU/RAM and can serve as a writer or reader. |
| 131 | * Provisioning takes several minutes. |
| 132 | * |
| 133 | * Mutable fields (`dbInstanceClass`, `promotionTier`, maintenance window, |
| 134 | * monitoring) are reconciled in place; immutable fields (`engine`, |
| 135 | * `dbClusterIdentifier`, `availabilityZone`) force a replacement. |
| 136 | * ### Adding an Instance |
| 137 | * **Example:** A DocumentDB writer instance |
| 138 | * ```typescript |
| 139 | * const writer = yield* DBInstance("Writer", { |
| 140 | * dbClusterIdentifier: cluster.dbClusterIdentifier, |
| 141 | * dbInstanceClass: "db.t3.medium", |
| 142 | * }); |
| 143 | * ``` |
| 144 | * |
| 145 | * @resource |
| 146 | */ |
| 147 | export const DBInstance = Resource<DBInstance>("AWS.DocDB.DBInstance"); |
| 148 | |
| 149 | const toTagRecord = ( |
| 150 | tags: Array<{ Key?: string; Value?: string }> | undefined, |
| 151 | ): Record<string, string> => |
| 152 | Object.fromEntries( |
| 153 | (tags ?? []) |
| 154 | .filter( |
| 155 | (tag): tag is { Key: string; Value: string } => |
| 156 | typeof tag.Key === "string" && typeof tag.Value === "string", |
| 157 | ) |
| 158 | .map((tag) => [tag.Key, tag.Value]), |
| 159 | ); |
| 160 | |
| 161 | const toAttrs = ({ |
| 162 | instance, |
| 163 | tags, |
| 164 | }: { |
| 165 | instance: docdb.DBInstance; |
| 166 | tags: Record<string, string>; |
| 167 | }): DBInstance["Attributes"] => ({ |
| 168 | dbInstanceIdentifier: instance.DBInstanceIdentifier ?? "", |
| 169 | dbInstanceArn: instance.DBInstanceArn ?? "", |
| 170 | dbClusterIdentifier: instance.DBClusterIdentifier, |
| 171 | endpointAddress: instance.Endpoint?.Address, |
| 172 | endpointPort: instance.Endpoint?.Port, |
| 173 | dbInstanceClass: instance.DBInstanceClass, |
| 174 | engine: instance.Engine, |
| 175 | engineVersion: instance.EngineVersion, |
| 176 | status: instance.DBInstanceStatus, |
| 177 | promotionTier: instance.PromotionTier, |
| 178 | publiclyAccessible: instance.PubliclyAccessible, |
| 179 | dbSubnetGroupName: instance.DBSubnetGroup?.DBSubnetGroupName, |
| 180 | availabilityZone: instance.AvailabilityZone, |
| 181 | preferredMaintenanceWindow: instance.PreferredMaintenanceWindow, |
| 182 | backupRetentionPeriod: instance.BackupRetentionPeriod, |
| 183 | kmsKeyId: instance.KmsKeyId, |
| 184 | storageEncrypted: instance.StorageEncrypted, |
| 185 | caCertificateIdentifier: instance.CACertificateIdentifier, |
| 186 | performanceInsightsEnabled: instance.PerformanceInsightsEnabled, |
| 187 | enabledCloudwatchLogsExports: instance.EnabledCloudwatchLogsExports ?? [], |
| 188 | dbiResourceId: instance.DbiResourceId, |
| 189 | autoMinorVersionUpgrade: instance.AutoMinorVersionUpgrade, |
| 190 | copyTagsToSnapshot: instance.CopyTagsToSnapshot, |
| 191 | tags, |
| 192 | }); |
| 193 | |
| 194 | export const DBInstanceProvider = () => |
| 195 | Provider.effect( |
| 196 | DBInstance, |
| 197 | Effect.gen(function* () { |
| 198 | const toIdentifier = (id: string, props: DBInstanceProps) => |
| 199 | props.dbInstanceIdentifier |
| 200 | ? Effect.succeed(props.dbInstanceIdentifier) |
| 201 | : createPhysicalName({ id, maxLength: 63 }); |
| 202 | |
| 203 | const readInstance = Effect.fn(function* (instanceId: string) { |
| 204 | const response = yield* docdb |
| 205 | .describeDBInstances({ |
| 206 | DBInstanceIdentifier: instanceId, |
| 207 | }) |
| 208 | .pipe( |
| 209 | Effect.catchTag("DBInstanceNotFoundFault", () => |
| 210 | Effect.succeed(undefined), |
| 211 | ), |
| 212 | ); |
| 213 | return response?.DBInstances?.[0]; |
| 214 | }); |
| 215 | |
| 216 | const readTags = Effect.fn(function* (arn: string | undefined) { |
| 217 | if (!arn) return {} as Record<string, string>; |
| 218 | const response = yield* docdb |
| 219 | .listTagsForResource({ ResourceName: arn }) |
| 220 | .pipe( |
| 221 | Effect.catchTag("DBInstanceNotFoundFault", () => |
| 222 | Effect.succeed(undefined), |
| 223 | ), |
| 224 | ); |
| 225 | return toTagRecord(response?.TagList); |
| 226 | }); |
| 227 | |
| 228 | // Bounded readiness wait. Gate on `DBInstanceStatus === "available"` so a |
| 229 | // follow-on `modifyDBInstance` doesn't hit `InvalidDBInstanceStateFault`. |
| 230 | // Budgets ~10 min (60 * 10s) for slow provisioning. |
| 231 | const waitForInstance = Effect.fn(function* (instanceId: string) { |
| 232 | const readinessPolicy = Schedule.max([ |
| 233 | Schedule.fixed("10 seconds"), |
| 234 | Schedule.recurs(60), |
| 235 | ]); |
| 236 | return yield* readInstance(instanceId).pipe( |
| 237 | Effect.flatMap((instance) => { |
| 238 | if (!instance?.DBInstanceArn) { |
| 239 | return Effect.fail( |
| 240 | new Error(`DB instance '${instanceId}' not found`), |
| 241 | ); |
| 242 | } |
| 243 | const status = instance.DBInstanceStatus; |
| 244 | if ( |
| 245 | status !== "available" && |
| 246 | status !== "incompatible-parameters" && |
| 247 | status !== "incompatible-restore" |
| 248 | ) { |
| 249 | return Effect.fail( |
| 250 | new Error( |
| 251 | `DB instance '${instanceId}' not available (status: ${status})`, |
| 252 | ), |
| 253 | ); |
| 254 | } |
| 255 | return Effect.succeed(instance); |
| 256 | }), |
| 257 | Effect.retry({ schedule: readinessPolicy }), |
| 258 | ); |
| 259 | }); |
| 260 | |
| 261 | return { |
| 262 | stables: ["dbInstanceArn", "dbInstanceIdentifier"], |
| 263 | // AWS account/region collection: `describeDBInstances` is paginated |
| 264 | // (items: "DBInstances"). DocumentDB does not surface tags inline, so |
| 265 | // we emit an empty tag map rather than a per-item `listTagsForResource` |
| 266 | // fan-out. `DBInstanceNotFoundFault` is in the op's typed error union; |
| 267 | // treat a stray one as "nothing to list". |
| 268 | list: () => |
| 269 | docdb.describeDBInstances.pages({}).pipe( |
| 270 | Stream.runCollect, |
| 271 | Effect.map((chunk) => |
| 272 | Array.from(chunk).flatMap((page) => |
| 273 | (page.DBInstances ?? []) |
| 274 | .filter( |
| 275 | ( |
| 276 | instance, |
| 277 | ): instance is typeof instance & { |
| 278 | DBInstanceArn: string; |
| 279 | } => instance.DBInstanceArn != null, |
| 280 | ) |
| 281 | .map((instance) => toAttrs({ instance, tags: {} })), |
| 282 | ), |
| 283 | ), |
| 284 | Effect.catchTag("DBInstanceNotFoundFault", () => |
| 285 | Effect.succeed([] as DBInstance["Attributes"][]), |
| 286 | ), |
| 287 | ), |
| 288 | diff: Effect.fn(function* ({ id, olds, news }) { |
| 289 | if (!isResolved(news)) return undefined; |
| 290 | if ( |
| 291 | (yield* toIdentifier(id, olds ?? ({} as DBInstanceProps))) !== |
| 292 | (yield* toIdentifier(id, news)) |
| 293 | ) { |
| 294 | return { action: "replace" } as const; |
| 295 | } |
| 296 | // Immutable props — any change forces a fresh instance. |
| 297 | if ( |
| 298 | olds !== undefined && |
| 299 | ((olds.engine ?? "docdb") !== (news.engine ?? "docdb") || |
| 300 | olds.dbClusterIdentifier !== news.dbClusterIdentifier || |
| 301 | olds.availabilityZone !== news.availabilityZone) |
| 302 | ) { |
| 303 | return { action: "replace" } as const; |
| 304 | } |
| 305 | }), |
| 306 | read: Effect.fn(function* ({ id, olds, output }) { |
| 307 | const identifier = |
| 308 | output?.dbInstanceIdentifier ?? |
| 309 | (yield* toIdentifier( |
| 310 | id, |
| 311 | olds ?? |
| 312 | ({ |
| 313 | dbClusterIdentifier: "", |
| 314 | dbInstanceClass: "", |
| 315 | } as DBInstanceProps), |
| 316 | )); |
| 317 | const instance = yield* readInstance(identifier); |
| 318 | if (!instance?.DBInstanceArn) { |
| 319 | return undefined; |
| 320 | } |
| 321 | const tags = yield* readTags(instance.DBInstanceArn); |
| 322 | return toAttrs({ instance, tags }); |
| 323 | }), |
| 324 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 325 | const identifier = |
| 326 | output?.dbInstanceIdentifier ?? (yield* toIdentifier(id, news)); |
| 327 | const internalTags = yield* createInternalTags(id); |
| 328 | const desiredTags = { ...internalTags, ...news.tags }; |
| 329 | |
| 330 | // Observe — fetch live instance state. |
| 331 | let observed = yield* readInstance(identifier); |
| 332 | |
| 333 | // Ensure — create if missing. Tolerate |
| 334 | // `DBInstanceAlreadyExistsFault` as a race with a peer reconciler. |
| 335 | if (!observed?.DBInstanceArn) { |
| 336 | yield* docdb |
| 337 | .createDBInstance({ |
| 338 | DBInstanceIdentifier: identifier, |
| 339 | DBClusterIdentifier: news.dbClusterIdentifier, |
| 340 | DBInstanceClass: news.dbInstanceClass, |
| 341 | Engine: news.engine ?? "docdb", |
| 342 | AvailabilityZone: news.availabilityZone, |
| 343 | PreferredMaintenanceWindow: news.preferredMaintenanceWindow, |
| 344 | AutoMinorVersionUpgrade: news.autoMinorVersionUpgrade, |
| 345 | PromotionTier: news.promotionTier, |
| 346 | EnablePerformanceInsights: news.enablePerformanceInsights, |
| 347 | PerformanceInsightsKMSKeyId: news.performanceInsightsKMSKeyId, |
| 348 | CACertificateIdentifier: news.caCertificateIdentifier, |
| 349 | CopyTagsToSnapshot: news.copyTagsToSnapshot, |
| 350 | Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ |
| 351 | Key, |
| 352 | Value, |
| 353 | })), |
| 354 | }) |
| 355 | .pipe( |
| 356 | Effect.catchTag( |
| 357 | "DBInstanceAlreadyExistsFault", |
| 358 | () => Effect.void, |
| 359 | ), |
| 360 | ); |
| 361 | |
| 362 | observed = yield* waitForInstance(identifier); |
| 363 | } else { |
| 364 | // Wait for the instance to settle before any modify so the call |
| 365 | // doesn't hit `InvalidDBInstanceStateFault`. |
| 366 | observed = yield* waitForInstance(identifier); |
| 367 | |
| 368 | // syncCoreSettings — single `modifyDBInstance` carrying scalar |
| 369 | // in-place fields. Only emit a field when the desired value differs |
| 370 | // from the observed cloud state. |
| 371 | const core: docdb.ModifyDBInstanceMessage = { |
| 372 | DBInstanceIdentifier: identifier, |
| 373 | ApplyImmediately: true, |
| 374 | }; |
| 375 | let coreDirty = false; |
| 376 | const setIf = <K extends keyof docdb.ModifyDBInstanceMessage>( |
| 377 | key: K, |
| 378 | desired: docdb.ModifyDBInstanceMessage[K] | undefined, |
| 379 | observedValue: unknown, |
| 380 | ) => { |
| 381 | if (desired !== undefined && desired !== observedValue) { |
| 382 | core[key] = desired; |
| 383 | coreDirty = true; |
| 384 | } |
| 385 | }; |
| 386 | setIf("DBInstanceClass", news.dbInstanceClass, observed.DBInstanceClass); // prettier-ignore |
| 387 | setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore |
| 388 | setIf("AutoMinorVersionUpgrade", news.autoMinorVersionUpgrade, observed.AutoMinorVersionUpgrade); // prettier-ignore |
| 389 | setIf("PromotionTier", news.promotionTier, observed.PromotionTier); |
| 390 | setIf("EnablePerformanceInsights", news.enablePerformanceInsights, observed.PerformanceInsightsEnabled); // prettier-ignore |
| 391 | setIf("PerformanceInsightsKMSKeyId", news.performanceInsightsKMSKeyId, observed.PerformanceInsightsKMSKeyId); // prettier-ignore |
| 392 | setIf("CACertificateIdentifier", news.caCertificateIdentifier, observed.CACertificateIdentifier); // prettier-ignore |
| 393 | setIf("CopyTagsToSnapshot", news.copyTagsToSnapshot, observed.CopyTagsToSnapshot); // prettier-ignore |
| 394 | if (coreDirty) { |
| 395 | yield* docdb.modifyDBInstance(core); |
| 396 | observed = yield* waitForInstance(identifier); |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | const dbInstanceArn = observed.DBInstanceArn ?? ""; |
| 401 | |
| 402 | // Sync tags — diff observed cloud tags against desired. |
| 403 | const observedTags = yield* readTags(dbInstanceArn); |
| 404 | const { removed, upsert } = diffTags(observedTags, desiredTags); |
| 405 | if (upsert.length > 0 && dbInstanceArn) { |
| 406 | yield* docdb.addTagsToResource({ |
| 407 | ResourceName: dbInstanceArn, |
| 408 | Tags: upsert, |
| 409 | }); |
| 410 | } |
| 411 | if (removed.length > 0 && dbInstanceArn) { |
| 412 | yield* docdb.removeTagsFromResource({ |
| 413 | ResourceName: dbInstanceArn, |
| 414 | TagKeys: removed, |
| 415 | }); |
| 416 | } |
| 417 | |
| 418 | yield* session.note(dbInstanceArn || identifier); |
| 419 | return toAttrs({ instance: observed, tags: desiredTags }); |
| 420 | }), |
| 421 | delete: Effect.fn(function* ({ output }) { |
| 422 | yield* docdb |
| 423 | .deleteDBInstance({ |
| 424 | DBInstanceIdentifier: output.dbInstanceIdentifier, |
| 425 | }) |
| 426 | .pipe( |
| 427 | Effect.catchTag("DBInstanceNotFoundFault", () => Effect.void), |
| 428 | ); |
| 429 | // Block until the instance is fully gone so a dependent cluster or |
| 430 | // subnet group is not torn down while DocumentDB still references it. |
| 431 | yield* Effect.repeat( |
| 432 | docdb |
| 433 | .describeDBInstances({ |
| 434 | DBInstanceIdentifier: output.dbInstanceIdentifier, |
| 435 | }) |
| 436 | .pipe( |
| 437 | Effect.as(true), |
| 438 | Effect.catchTag("DBInstanceNotFoundFault", () => |
| 439 | Effect.succeed(false), |
| 440 | ), |
| 441 | ), |
| 442 | { |
| 443 | schedule: Schedule.max([ |
| 444 | Schedule.fixed("15 seconds"), |
| 445 | Schedule.recurs(40), |
| 446 | ]), |
| 447 | until: (exists) => exists === false, |
| 448 | }, |
| 449 | ).pipe(Effect.catch(() => Effect.void)); |
| 450 | }), |
| 451 | }; |
| 452 | }), |
| 453 | ); |