| 1 | import * as AWS from "@/AWS"; |
| 2 | import { AWSEnvironment } from "@/AWS/Environment.ts"; |
| 3 | import { |
| 4 | normalizePolicyDocument, |
| 5 | type PolicyDocument, |
| 6 | } from "@/AWS/IAM/Policy.ts"; |
| 7 | import * as secretsmanager from "@distilled.cloud/aws/secrets-manager"; |
| 8 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 9 | import { SecurityGroup } from "@/AWS/EC2/SecurityGroup"; |
| 10 | import { DBParameterGroup } from "@/AWS/RDS/DBParameterGroup"; |
| 11 | import { Network } from "@/AWS/EC2/Network"; |
| 12 | import { DBCluster, DBInstance, type DBInstanceProps } from "@/AWS/RDS"; |
| 13 | import * as Drift from "@/Drift"; |
| 14 | import { State } from "@/State"; |
| 15 | import * as HttpClient from "effect/http/HttpClient"; |
| 16 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 17 | import { DBSubnetGroup } from "@/AWS/RDS/DBSubnetGroup.ts"; |
| 18 | import * as Provider from "@/Provider"; |
| 19 | import * as Test from "@/Test/Alchemy"; |
| 20 | import * as rds from "@distilled.cloud/aws/rds"; |
| 21 | import { expect } from "alchemy-test"; |
| 22 | import * as Effect from "effect/Effect"; |
| 23 | import * as Duration from "effect/Duration"; |
| 24 | import * as Redacted from "effect/Redacted"; |
| 25 | import * as Result from "effect/Result"; |
| 26 | import * as Schedule from "effect/Schedule"; |
| 27 | |
| 28 | const { test } = Test.make({ providers: AWS.providers() }); |
| 29 | |
| 30 | // Render a deploy failure (whatever engine wrapper it arrives in) to a string |
| 31 | // we can assert AWS's parameter-validation message against. |
| 32 | const renderFailure = (attempt: Result.Result<unknown, unknown>): string => { |
| 33 | if (!Result.isFailure(attempt)) { |
| 34 | return ""; |
| 35 | } |
| 36 | const failure = attempt.failure; |
| 37 | const json = (() => { |
| 38 | try { |
| 39 | return JSON.stringify(failure); |
| 40 | } catch { |
| 41 | return ""; |
| 42 | } |
| 43 | })(); |
| 44 | return `${String(failure)} ${json}`; |
| 45 | }; |
| 46 | |
| 47 | // Live wire probes for this PR's Redacted/Duration prop conversions on |
| 48 | // DBInstance (the instance reconcile has its own conversion code, separate |
| 49 | // from DBCluster's). Both drive the full engine + provider `reconcile` path |
| 50 | // into a real `createDBInstance` call that AWS rejects at |
| 51 | // parameter-validation time — nothing is provisioned and the probe completes |
| 52 | // in seconds. Probe 1 proves `masterUserPassword: Redacted.Redacted<string>` |
| 53 | // serializes to the actual secret characters on the wire; probe 2 proves |
| 54 | // `backupRetentionPeriod: Duration.Input` ("60 days") arrives as integer days |
| 55 | // (rejected as > the 35-day maximum). These rejected requests do not verify |
| 56 | // the RDS_TEST_LIFECYCLE-gated storage lifecycle below. |
| 57 | test.provider( |
| 58 | "wire probe: Redacted password + Duration retention reach createDBInstance", |
| 59 | (stack) => |
| 60 | Effect.gen(function* () { |
| 61 | yield* stack.destroy(); |
| 62 | |
| 63 | const network = Effect.gen(function* () { |
| 64 | const net = yield* Network("ProbeNet", { cidrBlock: "10.47.0.0/16" }); |
| 65 | return yield* DBSubnetGroup("ProbeSubnetGroup", { |
| 66 | description: "RDS serialization probe network", |
| 67 | subnetIds: net.privateSubnetIds, |
| 68 | }); |
| 69 | }); |
| 70 | const badPassword = yield* Effect.result( |
| 71 | stack.deploy( |
| 72 | Effect.gen(function* () { |
| 73 | const subnetGroup = yield* network; |
| 74 | return yield* DBInstance("AuditProbeInstance", { |
| 75 | dbInstanceIdentifier: "alchemy-audit-probe-instance", |
| 76 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 77 | engine: "postgres", |
| 78 | dbInstanceClass: "db.t3.micro", |
| 79 | allocatedStorage: 20, |
| 80 | masterUsername: "alchemy", |
| 81 | // '@' and ' ' are forbidden password characters — AWS rejects |
| 82 | // the create before provisioning anything. |
| 83 | masterUserPassword: Redacted.make("bad@pass word1"), |
| 84 | backupRetentionPeriod: "3 days", |
| 85 | }); |
| 86 | }), |
| 87 | ), |
| 88 | ); |
| 89 | expect(Result.isFailure(badPassword)).toBe(true); |
| 90 | expect(renderFailure(badPassword)).toContain("InvalidParameterValue"); |
| 91 | expect(renderFailure(badPassword)).toContain("MasterUserPassword"); |
| 92 | |
| 93 | const badRetention = yield* Effect.result( |
| 94 | stack.deploy( |
| 95 | Effect.gen(function* () { |
| 96 | const subnetGroup = yield* network; |
| 97 | return yield* DBInstance("AuditProbeInstance", { |
| 98 | dbInstanceIdentifier: "alchemy-audit-probe-instance", |
| 99 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 100 | engine: "postgres", |
| 101 | dbInstanceClass: "db.t3.micro", |
| 102 | allocatedStorage: 20, |
| 103 | masterUsername: "alchemy", |
| 104 | masterUserPassword: Redacted.make("ValidPassw0rd"), |
| 105 | // 60 days is above the 1-35 day API maximum — AWS can only |
| 106 | // reject it if the converted integer arrived on the wire. |
| 107 | backupRetentionPeriod: "60 days", |
| 108 | }); |
| 109 | }), |
| 110 | ), |
| 111 | ); |
| 112 | expect(Result.isFailure(badRetention)).toBe(true); |
| 113 | expect(renderFailure(badRetention)).toContain("InvalidParameterValue"); |
| 114 | expect(renderFailure(badRetention)).toMatch(/retention/i); |
| 115 | |
| 116 | const invalidFlags = yield* rds |
| 117 | .describeDBEngineVersions({ |
| 118 | Engine: "postgres", |
| 119 | IncludeAll: true, |
| 120 | DefaultOnly: true, |
| 121 | }) |
| 122 | .pipe(Effect.result); |
| 123 | expect(Result.isFailure(invalidFlags)).toBe(true); |
| 124 | if (Result.isFailure(invalidFlags)) { |
| 125 | expect(invalidFlags.failure._tag).toBe("InvalidParameterCombination"); |
| 126 | } |
| 127 | const emptyGroups = yield* stack |
| 128 | .deploy( |
| 129 | Effect.gen(function* () { |
| 130 | const subnetGroup = yield* network; |
| 131 | return yield* DBInstance("AuditProbeInstance", { |
| 132 | dbInstanceIdentifier: "alchemy-audit-probe-instance", |
| 133 | engine: "postgres", |
| 134 | dbInstanceClass: "db.t3.micro", |
| 135 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 136 | vpcSecurityGroupIds: [], |
| 137 | masterUsername: "alchemy", |
| 138 | masterUserPassword: Redacted.make("bad@pass word1"), |
| 139 | }); |
| 140 | }), |
| 141 | ) |
| 142 | .pipe(Effect.result); |
| 143 | expect(Result.isFailure(emptyGroups)).toBe(true); |
| 144 | expect(renderFailure(emptyGroups)).toContain( |
| 145 | "InvalidDBInstanceAssociations", |
| 146 | ); |
| 147 | |
| 148 | yield* stack.destroy(); |
| 149 | }), |
| 150 | { |
| 151 | tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"], |
| 152 | timeout: 120_000, |
| 153 | }, |
| 154 | ); |
| 155 | |
| 156 | // Default (read-only) path: an RDS instance takes many minutes to create and |
| 157 | // delete — far beyond the 240s test budget — so the canonical `list()` test |
| 158 | // here does NOT deploy. It resolves the provider via the typed |
| 159 | // `Provider.findProvider(DBInstance)` helper and calls `list()` directly, |
| 160 | // asserting it returns a well-typed `DBInstance["Attributes"][]`. On a fresh |
| 161 | // account this is typically empty; either way every element must conform to |
| 162 | // the exact `read` shape. |
| 163 | test.provider( |
| 164 | "list returns well-typed DB instance attributes", |
| 165 | () => |
| 166 | Effect.gen(function* () { |
| 167 | const provider = yield* Provider.findProvider(DBInstance); |
| 168 | const all = yield* provider.list(); |
| 169 | |
| 170 | expect(Array.isArray(all)).toBe(true); |
| 171 | |
| 172 | // Every element must match the exact `Attributes` shape `read` produces. |
| 173 | for (const instance of all) { |
| 174 | expect(typeof instance.dbInstanceIdentifier).toBe("string"); |
| 175 | expect(typeof instance.dbInstanceArn).toBe("string"); |
| 176 | expect(Array.isArray(instance.dbParameterGroupNames)).toBe(true); |
| 177 | expect(typeof instance.tags).toBe("object"); |
| 178 | } |
| 179 | }), |
| 180 | { tags: ["provider:aws", "provider:aws:rds", "live"] }, |
| 181 | ); |
| 182 | |
| 183 | // Full lifecycle is gated: provisioning an Aurora cluster + instance and then |
| 184 | // tearing it down takes many minutes, exceeding the 240s budget. Set |
| 185 | // AWS_TEST_RDS_DBINSTANCE=1 on an account that can afford the wait to run it. |
| 186 | // It deploys a serverless-v2 Aurora cluster + instance and asserts the |
| 187 | // instance appears in the exhaustively-paginated `list()` result. |
| 188 | test.provider.skipIf(!process.env.AWS_TEST_RDS_DBINSTANCE)( |
| 189 | "list enumerates the deployed DB instance", |
| 190 | (stack) => |
| 191 | Effect.gen(function* () { |
| 192 | yield* stack.destroy(); |
| 193 | |
| 194 | const { client, requests } = yield* observeInstanceRequests; |
| 195 | const program = (round: string) => |
| 196 | Effect.gen(function* () { |
| 197 | const network = yield* Network("ListNet", { |
| 198 | cidrBlock: "10.43.0.0/16", |
| 199 | }); |
| 200 | const subnetGroup = yield* DBSubnetGroup("ListSubnetGroup", { |
| 201 | description: "alchemy instance list lifecycle", |
| 202 | subnetIds: network.privateSubnetIds, |
| 203 | }); |
| 204 | const clusterGroup = yield* SecurityGroup("ListClusterGroup", { |
| 205 | vpcId: network.vpcId, |
| 206 | description: "Aurora cluster group", |
| 207 | }); |
| 208 | const ignoredInstanceGroup = yield* SecurityGroup( |
| 209 | "ListIgnoredInstanceGroup", |
| 210 | { vpcId: network.vpcId, description: "Ignored instance group" }, |
| 211 | ); |
| 212 | const cluster = yield* DBCluster("ListCluster", { |
| 213 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 214 | vpcSecurityGroupIds: [clusterGroup.groupId], |
| 215 | engine: "aurora-postgresql", |
| 216 | engineMode: "provisioned", |
| 217 | port: 5434, |
| 218 | serverlessV2ScalingConfiguration: { |
| 219 | MinCapacity: 0.5, |
| 220 | MaxCapacity: 1, |
| 221 | }, |
| 222 | manageMasterUserPassword: true, |
| 223 | masterUsername: "alchemy", |
| 224 | }); |
| 225 | |
| 226 | return yield* DBInstance("ListInstance", { |
| 227 | dbClusterIdentifier: cluster.dbClusterIdentifier, |
| 228 | dbInstanceClass: "db.serverless", |
| 229 | engine: "aurora-postgresql", |
| 230 | port: 5435, |
| 231 | vpcSecurityGroupIds: [ignoredInstanceGroup.groupId], |
| 232 | tags: { round }, |
| 233 | }); |
| 234 | }); |
| 235 | const instance = yield* stack |
| 236 | .deploy(program("created")) |
| 237 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 238 | expect(instance.endpointPort).toBe(5434); |
| 239 | const updated = yield* stack |
| 240 | .deploy(program("updated")) |
| 241 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 242 | expect(updated.endpointPort).toBe(5434); |
| 243 | expect(requests.filter((request) => request.port !== null)).toEqual([]); |
| 244 | expect( |
| 245 | requests.filter((request) => request.securityGroups.length > 0), |
| 246 | ).toEqual([]); |
| 247 | const cluster = (yield* rds.describeDBClusters({ |
| 248 | DBClusterIdentifier: instance.dbClusterIdentifier!, |
| 249 | })).DBClusters![0]!; |
| 250 | expect([...updated.vpcSecurityGroupIds].sort()).toEqual( |
| 251 | cluster |
| 252 | .VpcSecurityGroups!.map((group) => group.VpcSecurityGroupId!) |
| 253 | .sort(), |
| 254 | ); |
| 255 | const family = (yield* rds.describeDBEngineVersions({ |
| 256 | Engine: "aurora-postgresql", |
| 257 | EngineVersion: cluster.EngineVersion, |
| 258 | })).DBEngineVersions![0]!.DBParameterGroupFamily!; |
| 259 | expect(updated.dbParameterGroupNames).toEqual([`default.${family}`]); |
| 260 | expect( |
| 261 | requests.filter( |
| 262 | (request) => |
| 263 | request.action === "ModifyDBInstance" && |
| 264 | request.parameterGroup !== null, |
| 265 | ), |
| 266 | ).toEqual([]); |
| 267 | expect( |
| 268 | requests.some((request) => request.action === "CreateDBInstance"), |
| 269 | ).toBe(true); |
| 270 | yield* assertPort(instance.dbInstanceIdentifier, 5434); |
| 271 | expect( |
| 272 | (yield* stack.plan(program("updated"))).resources.ListInstance, |
| 273 | ).toMatchObject({ action: "noop" }); |
| 274 | |
| 275 | const provider = yield* Provider.findProvider(DBInstance); |
| 276 | const all = yield* provider.list(); |
| 277 | |
| 278 | expect( |
| 279 | all.some( |
| 280 | (i) => i.dbInstanceIdentifier === instance.dbInstanceIdentifier, |
| 281 | ), |
| 282 | ).toBe(true); |
| 283 | |
| 284 | yield* stack.destroy(); |
| 285 | }), |
| 286 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 287 | ); |
| 288 | |
| 289 | // RDS provisioning and storage optimization exceed the default test budget. |
| 290 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 291 | "standalone instance: autoscaling defaults, drift, and allocation floor", |
| 292 | (stack) => |
| 293 | Effect.gen(function* () { |
| 294 | yield* stack.destroy(); |
| 295 | |
| 296 | // The testing account has no default VPC/subnets, so provision a |
| 297 | // production-shaped network (VPC + subnets across 2 AZs) and a DB subnet |
| 298 | // group for the instance to live in. |
| 299 | const network = Effect.gen(function* () { |
| 300 | const net = yield* Network("RdsNet", { cidrBlock: "10.41.0.0/16" }); |
| 301 | // No fixed name — let the engine generate a unique physical name so a |
| 302 | // leftover group from an interrupted run can't force a cross-VPC |
| 303 | // ModifyDBSubnetGroup ("new Subnets are not in the same Vpc"). |
| 304 | const subnetGroup = yield* DBSubnetGroup("RdsSubnetGroup", { |
| 305 | description: "alchemy standalone instance lifecycle", |
| 306 | subnetIds: net.privateSubnetIds, |
| 307 | }); |
| 308 | return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }; |
| 309 | }); |
| 310 | |
| 311 | const program = ( |
| 312 | allocatedStorage: number, |
| 313 | maxAllocatedStorage?: number, |
| 314 | backupRetentionPeriod: "1 day" | "3 days" = "1 day", |
| 315 | enablePerformanceInsights = false, |
| 316 | ) => |
| 317 | Effect.gen(function* () { |
| 318 | const { dbSubnetGroupName } = yield* network; |
| 319 | return yield* DBInstance("StandaloneInstance", { |
| 320 | engine: "postgres", |
| 321 | dbInstanceClass: "db.t3.micro", |
| 322 | allocatedStorage, |
| 323 | ...(maxAllocatedStorage === undefined |
| 324 | ? {} |
| 325 | : { maxAllocatedStorage }), |
| 326 | storageType: "gp2", |
| 327 | masterUsername: "alchemy", |
| 328 | manageMasterUserPassword: true, |
| 329 | backupRetentionPeriod, |
| 330 | enablePerformanceInsights, |
| 331 | deletionProtection: false, |
| 332 | dbSubnetGroupName, |
| 333 | publiclyAccessible: false, |
| 334 | }); |
| 335 | }); |
| 336 | const created = yield* stack.deploy(program(20)); |
| 337 | expect(created.allocatedStorage).toBe(20); |
| 338 | expect(created.storageType).toBe("gp2"); |
| 339 | expect(created.backupRetentionPeriod).toBe(1); |
| 340 | expect([0, 20]).toContain(created.maxAllocatedStorage ?? 0); |
| 341 | const describe = rds.describeDBInstances({ |
| 342 | DBInstanceIdentifier: created.dbInstanceIdentifier, |
| 343 | }); |
| 344 | expect([0, 20]).toContain( |
| 345 | (yield* describe).DBInstances?.[0]?.MaxAllocatedStorage ?? 0, |
| 346 | ); |
| 347 | |
| 348 | const enabled = yield* stack.deploy(program(20, 40)); |
| 349 | expect(enabled.maxAllocatedStorage).toBe(40); |
| 350 | expect((yield* describe).DBInstances?.[0]?.MaxAllocatedStorage).toBe(40); |
| 351 | // Grow beyond the old ceiling while disabling autoscaling in the same request. |
| 352 | const updated = yield* stack.deploy( |
| 353 | program(50, undefined, "3 days", true), |
| 354 | ); |
| 355 | expect(updated.dbInstanceArn).toBe(created.dbInstanceArn); |
| 356 | expect(updated.backupRetentionPeriod).toBe(3); |
| 357 | expect(updated.allocatedStorage).toBe(50); |
| 358 | expect([0, 50]).toContain(updated.maxAllocatedStorage ?? 0); |
| 359 | const grown = (yield* describe).DBInstances?.[0]; |
| 360 | expect(grown?.AllocatedStorage).toBe(50); |
| 361 | expect([0, 50]).toContain(grown?.MaxAllocatedStorage ?? 0); |
| 362 | expect(grown?.PendingModifiedValues?.AllocatedStorage).toBeUndefined(); |
| 363 | |
| 364 | const explicit = program(20, 100, "3 days", true); |
| 365 | const reenabled = yield* stack.deploy(explicit); |
| 366 | expect(reenabled.allocatedStorage).toBe(50); |
| 367 | expect(reenabled.maxAllocatedStorage).toBe(100); |
| 368 | expect((yield* describe).DBInstances?.[0]?.MaxAllocatedStorage).toBe(100); |
| 369 | |
| 370 | const injectCeiling = Effect.fn(function* (ceiling: number) { |
| 371 | yield* rds.modifyDBInstance({ |
| 372 | DBInstanceIdentifier: created.dbInstanceIdentifier, |
| 373 | MaxAllocatedStorage: ceiling, |
| 374 | ApplyImmediately: true, |
| 375 | }); |
| 376 | const injected = yield* describe.pipe( |
| 377 | Effect.repeat({ |
| 378 | schedule: Schedule.spaced("5 seconds"), |
| 379 | times: 8, |
| 380 | until: (response) => |
| 381 | response.DBInstances?.[0]?.MaxAllocatedStorage === ceiling, |
| 382 | }), |
| 383 | ); |
| 384 | expect(injected.DBInstances?.[0]?.MaxAllocatedStorage).toBe(ceiling); |
| 385 | }); |
| 386 | yield* injectCeiling(120); |
| 387 | expect( |
| 388 | (yield* stack.plan(explicit)).resources.StandaloneInstance, |
| 389 | ).toMatchObject({ |
| 390 | action: "update", |
| 391 | }); |
| 392 | const repairedExplicit = yield* stack.deploy(explicit); |
| 393 | expect(repairedExplicit.maxAllocatedStorage).toBe(100); |
| 394 | expect((yield* describe).DBInstances?.[0]?.MaxAllocatedStorage).toBe(100); |
| 395 | |
| 396 | // Lowering the minimum cannot shrink capacity, but omission disables autoscaling. |
| 397 | const lowerMinimum = program(20); |
| 398 | expect( |
| 399 | (yield* stack.plan(lowerMinimum)).resources.StandaloneInstance, |
| 400 | ).toMatchObject({ |
| 401 | action: "update", |
| 402 | }); |
| 403 | const preserved = yield* stack.deploy(lowerMinimum); |
| 404 | expect(preserved.dbInstanceArn).toBe(created.dbInstanceArn); |
| 405 | expect(preserved.allocatedStorage).toBe(50); |
| 406 | expect([0, 50]).toContain(preserved.maxAllocatedStorage ?? 0); |
| 407 | const observed = (yield* describe).DBInstances?.[0]; |
| 408 | expect(observed?.AllocatedStorage).toBe(50); |
| 409 | expect([0, 50]).toContain(observed?.MaxAllocatedStorage ?? 0); |
| 410 | expect(observed?.PendingModifiedValues?.AllocatedStorage).toBeUndefined(); |
| 411 | |
| 412 | yield* injectCeiling(80); |
| 413 | const drift = yield* Drift.detect({ |
| 414 | name: stack.name, |
| 415 | stage: stack.stage, |
| 416 | }); |
| 417 | expect(drift.resources.StandaloneInstance?.action).toBe("drifted"); |
| 418 | expect( |
| 419 | (yield* stack.plan(lowerMinimum)).resources.StandaloneInstance, |
| 420 | ).toMatchObject({ |
| 421 | action: "update", |
| 422 | }); |
| 423 | const repaired = yield* stack.deploy(lowerMinimum); |
| 424 | expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn); |
| 425 | expect(repaired.allocatedStorage).toBe(50); |
| 426 | expect([0, 50]).toContain(repaired.maxAllocatedStorage ?? 0); |
| 427 | const settled = (yield* describe).DBInstances?.[0]; |
| 428 | expect([0, 50]).toContain(settled?.MaxAllocatedStorage ?? 0); |
| 429 | expect(settled?.AllocatedStorage).toBe(50); |
| 430 | expect( |
| 431 | (yield* stack.plan(lowerMinimum)).resources.StandaloneInstance, |
| 432 | ).toMatchObject({ action: "noop" }); |
| 433 | |
| 434 | const disabled = program(20, 0); |
| 435 | const explicitZero = yield* stack.deploy(disabled); |
| 436 | expect([0, 50]).toContain(explicitZero.maxAllocatedStorage ?? 0); |
| 437 | expect(explicitZero.allocatedStorage).toBe(50); |
| 438 | expect( |
| 439 | (yield* stack.plan(disabled)).resources.StandaloneInstance, |
| 440 | ).toMatchObject({ action: "noop" }); |
| 441 | |
| 442 | yield* stack.destroy(); |
| 443 | const gone = yield* describe.pipe( |
| 444 | Effect.as(false), |
| 445 | Effect.catchTag("DBInstanceNotFoundFault", () => Effect.succeed(true)), |
| 446 | Effect.repeat({ |
| 447 | schedule: Schedule.spaced("5 seconds"), |
| 448 | times: 8, |
| 449 | until: (absent) => absent, |
| 450 | }), |
| 451 | ); |
| 452 | expect(gone).toBe(true); |
| 453 | }), |
| 454 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 455 | ); |
| 456 | |
| 457 | type StorageProps = Pick< |
| 458 | DBInstanceProps, |
| 459 | | "allocatedStorage" |
| 460 | | "storageType" |
| 461 | | "iops" |
| 462 | | "storageThroughput" |
| 463 | | "maxAllocatedStorage" |
| 464 | >; |
| 465 | |
| 466 | type StorageState = readonly [ |
| 467 | allocatedStorage: number, |
| 468 | storageType: string, |
| 469 | iops: number, |
| 470 | storageThroughput: number, |
| 471 | ]; |
| 472 | |
| 473 | const storageProgram = (props: StorageProps) => |
| 474 | Effect.gen(function* () { |
| 475 | const network = yield* Network("StorageNet", { cidrBlock: "10.44.0.0/16" }); |
| 476 | const subnetGroup = yield* DBSubnetGroup("StorageSubnetGroup", { |
| 477 | description: "alchemy coupled storage lifecycle", |
| 478 | subnetIds: network.privateSubnetIds, |
| 479 | }); |
| 480 | return yield* DBInstance("StorageInstance", { |
| 481 | engine: "postgres", |
| 482 | dbInstanceClass: "db.t3.micro", |
| 483 | masterUsername: "alchemy", |
| 484 | masterUserPassword: Redacted.make("StorageCouplingPass123"), |
| 485 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 486 | backupRetentionPeriod: "0 days", |
| 487 | deletionProtection: false, |
| 488 | skipFinalSnapshot: true, |
| 489 | publiclyAccessible: false, |
| 490 | ...props, |
| 491 | }); |
| 492 | }); |
| 493 | |
| 494 | const assertStorageState = Effect.fn(function* ( |
| 495 | identifier: string, |
| 496 | expected: StorageState, |
| 497 | maximum = 0, |
| 498 | ) { |
| 499 | const observed = (yield* rds.describeDBInstances({ |
| 500 | DBInstanceIdentifier: identifier, |
| 501 | })).DBInstances?.[0]; |
| 502 | expect([ |
| 503 | observed?.AllocatedStorage, |
| 504 | observed?.StorageType, |
| 505 | observed?.Iops ?? 0, |
| 506 | observed?.StorageThroughput ?? 0, |
| 507 | ]).toEqual(expected); |
| 508 | if (maximum === 0) { |
| 509 | expect([0, expected[0]]).toContain(observed?.MaxAllocatedStorage ?? 0); |
| 510 | } else { |
| 511 | expect(observed?.MaxAllocatedStorage).toBe(maximum); |
| 512 | } |
| 513 | expect(observed?.PendingModifiedValues?.AllocatedStorage).toBeUndefined(); |
| 514 | expect(observed?.PendingModifiedValues?.StorageType).toBeUndefined(); |
| 515 | expect(observed?.PendingModifiedValues?.Iops).toBeUndefined(); |
| 516 | expect(observed?.PendingModifiedValues?.StorageThroughput).toBeUndefined(); |
| 517 | }); |
| 518 | |
| 519 | const assertInstanceGone = Effect.fn(function* (identifier: string) { |
| 520 | const gone = yield* rds |
| 521 | .describeDBInstances({ DBInstanceIdentifier: identifier }) |
| 522 | .pipe( |
| 523 | Effect.as(false), |
| 524 | Effect.catchTag("DBInstanceNotFoundFault", () => Effect.succeed(true)), |
| 525 | Effect.repeat({ |
| 526 | schedule: Schedule.spaced("5 seconds"), |
| 527 | times: 8, |
| 528 | until: (gone) => gone, |
| 529 | }), |
| 530 | ); |
| 531 | expect(gone).toBe(true); |
| 532 | }); |
| 533 | |
| 534 | interface StorageCase { |
| 535 | name: string; |
| 536 | initial: StorageProps; |
| 537 | desired: StorageProps; |
| 538 | before: StorageState; |
| 539 | after: StorageState; |
| 540 | equivalent?: StorageProps; |
| 541 | } |
| 542 | |
| 543 | // Each database receives at most one storage modification; optimization can take hours. |
| 544 | const storageCases: StorageCase[] = [ |
| 545 | { |
| 546 | name: "small gp3 defaults and resize", |
| 547 | initial: {}, |
| 548 | desired: { |
| 549 | allocatedStorage: 25, |
| 550 | storageType: "gp3", |
| 551 | iops: 3000, |
| 552 | storageThroughput: 125, |
| 553 | }, |
| 554 | before: [20, "gp3", 3000, 125], |
| 555 | after: [25, "gp3", 3000, 125], |
| 556 | equivalent: {}, |
| 557 | }, |
| 558 | { |
| 559 | name: "gp3 resize retains provisioned performance", |
| 560 | initial: { allocatedStorage: 400 }, |
| 561 | desired: { allocatedStorage: 500 }, |
| 562 | before: [400, "gp3", 12000, 500], |
| 563 | after: [500, "gp3", 12000, 500], |
| 564 | }, |
| 565 | { |
| 566 | name: "gp3 IOPS update includes allocation", |
| 567 | initial: { allocatedStorage: 400 }, |
| 568 | desired: { allocatedStorage: 400, iops: 16000 }, |
| 569 | before: [400, "gp3", 12000, 500], |
| 570 | after: [400, "gp3", 16000, 500], |
| 571 | }, |
| 572 | { |
| 573 | name: "gp3 throughput update includes IOPS", |
| 574 | initial: { allocatedStorage: 400 }, |
| 575 | desired: { allocatedStorage: 400, storageThroughput: 750 }, |
| 576 | before: [400, "gp3", 12000, 500], |
| 577 | after: [400, "gp3", 12000, 750], |
| 578 | }, |
| 579 | { |
| 580 | name: "gp3 performance removal restores defaults", |
| 581 | initial: { allocatedStorage: 400, iops: 16000, storageThroughput: 750 }, |
| 582 | desired: { allocatedStorage: 400 }, |
| 583 | before: [400, "gp3", 16000, 750], |
| 584 | after: [400, "gp3", 12000, 500], |
| 585 | }, |
| 586 | { |
| 587 | name: "storage type removal restores gp3", |
| 588 | initial: { allocatedStorage: 25, storageType: "gp2" }, |
| 589 | desired: {}, |
| 590 | before: [25, "gp2", 0, 0], |
| 591 | after: [25, "gp3", 3000, 125], |
| 592 | }, |
| 593 | ...(["io1", "io2"] as const).flatMap((storageType): StorageCase[] => [ |
| 594 | { |
| 595 | name: `${storageType} resize includes unchanged IOPS`, |
| 596 | initial: { storageType }, |
| 597 | desired: { storageType, allocatedStorage: 120 }, |
| 598 | before: [100, storageType, 1000, 0], |
| 599 | after: [120, storageType, 1000, 0], |
| 600 | }, |
| 601 | { |
| 602 | name: `${storageType} performance and autoscaling removal are atomic`, |
| 603 | initial: { storageType, iops: 3000, maxAllocatedStorage: 6000 }, |
| 604 | desired: { storageType }, |
| 605 | before: [100, storageType, 3000, 0], |
| 606 | after: [100, storageType, 1000, 0], |
| 607 | equivalent: { storageType, maxAllocatedStorage: 0 }, |
| 608 | }, |
| 609 | ]), |
| 610 | ]; |
| 611 | |
| 612 | for (const scenario of storageCases) { |
| 613 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 614 | `storage coupling: ${scenario.name}`, |
| 615 | (stack) => |
| 616 | Effect.gen(function* () { |
| 617 | yield* stack.destroy(); |
| 618 | const created = yield* stack.deploy(storageProgram(scenario.initial)); |
| 619 | yield* assertStorageState( |
| 620 | created.dbInstanceIdentifier, |
| 621 | scenario.before, |
| 622 | scenario.initial.maxAllocatedStorage, |
| 623 | ); |
| 624 | const updated = yield* stack.deploy(storageProgram(scenario.desired)); |
| 625 | expect(updated.dbInstanceArn).toBe(created.dbInstanceArn); |
| 626 | yield* assertStorageState( |
| 627 | updated.dbInstanceIdentifier, |
| 628 | scenario.after, |
| 629 | scenario.desired.maxAllocatedStorage, |
| 630 | ); |
| 631 | const finalProps = scenario.equivalent ?? scenario.desired; |
| 632 | if (scenario.equivalent) { |
| 633 | const equivalent = yield* stack.deploy(storageProgram(finalProps)); |
| 634 | expect(equivalent.dbInstanceArn).toBe(created.dbInstanceArn); |
| 635 | yield* assertStorageState( |
| 636 | equivalent.dbInstanceIdentifier, |
| 637 | scenario.after, |
| 638 | finalProps.maxAllocatedStorage, |
| 639 | ); |
| 640 | } |
| 641 | expect( |
| 642 | (yield* stack.plan(storageProgram(finalProps))).resources |
| 643 | .StorageInstance, |
| 644 | ).toMatchObject({ action: "noop" }); |
| 645 | yield* stack.destroy(); |
| 646 | yield* assertInstanceGone(created.dbInstanceIdentifier); |
| 647 | }), |
| 648 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 649 | ); |
| 650 | } |
| 651 | |
| 652 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 653 | "storage coupling: plans correction for external storage drift", |
| 654 | (stack) => |
| 655 | Effect.gen(function* () { |
| 656 | yield* stack.destroy(); |
| 657 | const desired = storageProgram({}); |
| 658 | const created = yield* stack.deploy(desired); |
| 659 | yield* assertStorageState(created.dbInstanceIdentifier, [ |
| 660 | 20, |
| 661 | "gp3", |
| 662 | 3000, |
| 663 | 125, |
| 664 | ]); |
| 665 | yield* rds.modifyDBInstance({ |
| 666 | DBInstanceIdentifier: created.dbInstanceIdentifier, |
| 667 | StorageType: "gp2", |
| 668 | ApplyImmediately: true, |
| 669 | }); |
| 670 | yield* rds |
| 671 | .describeDBInstances({ |
| 672 | DBInstanceIdentifier: created.dbInstanceIdentifier, |
| 673 | }) |
| 674 | .pipe( |
| 675 | Effect.repeat({ |
| 676 | schedule: Schedule.min([ |
| 677 | Schedule.exponential("5 seconds"), |
| 678 | Schedule.spaced("1 minute"), |
| 679 | ]), |
| 680 | times: 10, |
| 681 | until: (response) => { |
| 682 | const instance = response.DBInstances?.[0]; |
| 683 | return ( |
| 684 | instance?.StorageType === "gp2" && |
| 685 | (instance.Iops ?? 0) === 0 && |
| 686 | (instance.StorageThroughput ?? 0) === 0 && |
| 687 | instance.PendingModifiedValues?.StorageType === undefined |
| 688 | ); |
| 689 | }, |
| 690 | }), |
| 691 | ); |
| 692 | yield* assertStorageState(created.dbInstanceIdentifier, [ |
| 693 | 20, |
| 694 | "gp2", |
| 695 | 0, |
| 696 | 0, |
| 697 | ]); |
| 698 | const drift = yield* Drift.detect({ |
| 699 | name: stack.name, |
| 700 | stage: stack.stage, |
| 701 | }); |
| 702 | expect(drift.resources.StorageInstance?.action).toBe("drifted"); |
| 703 | expect( |
| 704 | (yield* stack.plan(desired)).resources.StorageInstance, |
| 705 | ).toMatchObject({ action: "update" }); |
| 706 | // A second storage modification must wait for AWS's optimization cooldown. |
| 707 | yield* stack.destroy(); |
| 708 | yield* assertInstanceGone(created.dbInstanceIdentifier); |
| 709 | }), |
| 710 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 711 | ); |
| 712 | |
| 713 | const observeInstanceRequests = Effect.gen(function* () { |
| 714 | const client = yield* HttpClient.HttpClient; |
| 715 | const requests: Array<{ |
| 716 | action: string; |
| 717 | port: string | null; |
| 718 | parameterGroup: string | null; |
| 719 | securityGroups: string[]; |
| 720 | iamAuthentication: string | null; |
| 721 | publiclyAccessible: string | null; |
| 722 | deletionProtection: string | null; |
| 723 | networkType: string | null; |
| 724 | applyImmediately: string | null; |
| 725 | enabledLogs: string[]; |
| 726 | disabledLogs: string[]; |
| 727 | }> = []; |
| 728 | const observedClient = client.pipe( |
| 729 | HttpClient.tapRequest((request) => |
| 730 | Effect.sync(() => { |
| 731 | if (request.body._tag !== "Uint8Array") return; |
| 732 | const parameters = new URLSearchParams( |
| 733 | new TextDecoder().decode(request.body.body), |
| 734 | ); |
| 735 | const action = parameters.get("Action"); |
| 736 | if ( |
| 737 | action === "CreateDBInstance" || |
| 738 | action === "ModifyDBInstance" || |
| 739 | action === "DescribeDBInstances" |
| 740 | ) { |
| 741 | requests.push({ |
| 742 | action, |
| 743 | iamAuthentication: parameters.get( |
| 744 | "EnableIAMDatabaseAuthentication", |
| 745 | ), |
| 746 | publiclyAccessible: parameters.get("PubliclyAccessible"), |
| 747 | deletionProtection: parameters.get("DeletionProtection"), |
| 748 | networkType: parameters.get("NetworkType"), |
| 749 | applyImmediately: parameters.get("ApplyImmediately"), |
| 750 | enabledLogs: [...parameters.entries()] |
| 751 | .filter( |
| 752 | ([key]) => |
| 753 | key.startsWith("EnableCloudwatchLogsExports.") || |
| 754 | key.startsWith( |
| 755 | "CloudwatchLogsExportConfiguration.EnableLogTypes.", |
| 756 | ), |
| 757 | ) |
| 758 | .map(([, value]) => value) |
| 759 | .sort(), |
| 760 | disabledLogs: [...parameters.entries()] |
| 761 | .filter(([key]) => |
| 762 | key.startsWith( |
| 763 | "CloudwatchLogsExportConfiguration.DisableLogTypes.", |
| 764 | ), |
| 765 | ) |
| 766 | .map(([, value]) => value) |
| 767 | .sort(), |
| 768 | parameterGroup: parameters.get("DBParameterGroupName"), |
| 769 | securityGroups: [...parameters.entries()] |
| 770 | .filter(([key]) => key.startsWith("VpcSecurityGroupIds.")) |
| 771 | .map(([, value]) => value) |
| 772 | .sort(), |
| 773 | port: parameters.get( |
| 774 | action === "CreateDBInstance" ? "Port" : "DBPortNumber", |
| 775 | ), |
| 776 | }); |
| 777 | } |
| 778 | }), |
| 779 | ), |
| 780 | ); |
| 781 | return { client: observedClient, requests }; |
| 782 | }); |
| 783 | |
| 784 | const portProgram = (port?: number, round = "ports", identifier?: string) => |
| 785 | Effect.gen(function* () { |
| 786 | const network = yield* Network("PortNet", { cidrBlock: "10.45.0.0/16" }); |
| 787 | const subnetGroup = yield* DBSubnetGroup("PortSubnetGroup", { |
| 788 | description: "alchemy listener port lifecycle", |
| 789 | subnetIds: network.privateSubnetIds, |
| 790 | }); |
| 791 | return yield* DBInstance("PortInstance", { |
| 792 | dbInstanceIdentifier: identifier, |
| 793 | engine: "postgres", |
| 794 | dbInstanceClass: "db.t3.micro", |
| 795 | masterUsername: "alchemy", |
| 796 | manageMasterUserPassword: true, |
| 797 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 798 | backupRetentionPeriod: "0 days", |
| 799 | deletionProtection: false, |
| 800 | skipFinalSnapshot: true, |
| 801 | publiclyAccessible: false, |
| 802 | ...(port === undefined ? {} : { port }), |
| 803 | tags: { round }, |
| 804 | }); |
| 805 | }); |
| 806 | |
| 807 | const assertPort = Effect.fn(function* (identifier: string, port: number) { |
| 808 | const instance = (yield* rds.describeDBInstances({ |
| 809 | DBInstanceIdentifier: identifier, |
| 810 | })).DBInstances?.[0]; |
| 811 | expect(instance?.Endpoint?.Port).toBe(port); |
| 812 | expect(instance?.PendingModifiedValues?.Port).toBeUndefined(); |
| 813 | expect(["available", "storage-optimization"]).toContain( |
| 814 | instance?.DBInstanceStatus, |
| 815 | ); |
| 816 | return instance; |
| 817 | }); |
| 818 | |
| 819 | const injectPort = Effect.fn(function* (identifier: string, port: number) { |
| 820 | yield* rds.modifyDBInstance({ |
| 821 | DBInstanceIdentifier: identifier, |
| 822 | DBPortNumber: port, |
| 823 | ApplyImmediately: true, |
| 824 | }); |
| 825 | yield* rds.describeDBInstances({ DBInstanceIdentifier: identifier }).pipe( |
| 826 | Effect.repeat({ |
| 827 | schedule: Schedule.min([ |
| 828 | Schedule.exponential("5 seconds"), |
| 829 | Schedule.spaced("1 minute"), |
| 830 | ]), |
| 831 | times: 10, |
| 832 | until: (response) => { |
| 833 | const instance = response.DBInstances?.[0]; |
| 834 | return ( |
| 835 | instance?.DBInstanceStatus === "available" && |
| 836 | instance.Endpoint?.Port === port && |
| 837 | instance.PendingModifiedValues?.Port === undefined |
| 838 | ); |
| 839 | }, |
| 840 | }), |
| 841 | ); |
| 842 | yield* assertPort(identifier, port); |
| 843 | }); |
| 844 | |
| 845 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 846 | "listener port: defaults, updates, removal, and no redundant writes", |
| 847 | (stack) => |
| 848 | Effect.gen(function* () { |
| 849 | yield* stack.destroy(); |
| 850 | const { client, requests } = yield* observeInstanceRequests; |
| 851 | const deploy = (port?: number, round?: string) => |
| 852 | stack |
| 853 | .deploy(portProgram(port, round)) |
| 854 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 855 | const writes = () => |
| 856 | requests |
| 857 | .filter( |
| 858 | (request) => |
| 859 | request.action === "ModifyDBInstance" && request.port !== null, |
| 860 | ) |
| 861 | .map((request) => request.port); |
| 862 | const created = yield* deploy(); |
| 863 | expect(created.endpointPort).toBe(5432); |
| 864 | expect( |
| 865 | requests |
| 866 | .filter((request) => request.action === "CreateDBInstance") |
| 867 | .map((request) => request.port), |
| 868 | ).toEqual(["5432"]); |
| 869 | expect( |
| 870 | (yield* assertPort(created.dbInstanceIdentifier, 5432))?.DbInstancePort, |
| 871 | ).toBe(0); |
| 872 | |
| 873 | requests.length = 0; |
| 874 | yield* deploy(5432, "same-port"); |
| 875 | expect( |
| 876 | requests.some((request) => request.action === "DescribeDBInstances"), |
| 877 | ).toBe(true); |
| 878 | expect(writes()).toEqual([]); |
| 879 | |
| 880 | requests.length = 0; |
| 881 | const changed = yield* deploy(5433); |
| 882 | expect(changed.dbInstanceArn).toBe(created.dbInstanceArn); |
| 883 | expect(changed.endpointPort).toBe(5433); |
| 884 | expect(writes()).toEqual(["5433"]); |
| 885 | yield* assertPort(created.dbInstanceIdentifier, 5433); |
| 886 | |
| 887 | requests.length = 0; |
| 888 | yield* deploy(5433, "same-custom-port"); |
| 889 | expect(writes()).toEqual([]); |
| 890 | yield* assertPort(created.dbInstanceIdentifier, 5433); |
| 891 | |
| 892 | requests.length = 0; |
| 893 | const restored = yield* deploy(); |
| 894 | expect(restored.dbInstanceArn).toBe(created.dbInstanceArn); |
| 895 | expect(restored.endpointPort).toBe(5432); |
| 896 | expect(writes()).toEqual(["5432"]); |
| 897 | yield* assertPort(created.dbInstanceIdentifier, 5432); |
| 898 | expect( |
| 899 | (yield* stack.plan(portProgram())).resources.PortInstance, |
| 900 | ).toMatchObject({ action: "noop" }); |
| 901 | yield* stack.destroy(); |
| 902 | yield* assertInstanceGone(created.dbInstanceIdentifier); |
| 903 | }), |
| 904 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 905 | ); |
| 906 | |
| 907 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 908 | "listener port: unchanged-input drift repair and adoption defaults", |
| 909 | (stack) => |
| 910 | Effect.gen(function* () { |
| 911 | yield* stack.destroy(); |
| 912 | const identifier = "alchemy-rds-port-adoption"; |
| 913 | const program = (port?: number) => |
| 914 | portProgram(port, "adoption", identifier); |
| 915 | const created = yield* stack.deploy(program(5433)); |
| 916 | yield* assertPort(identifier, 5433); |
| 917 | yield* injectPort(identifier, 5434); |
| 918 | expect( |
| 919 | (yield* stack.plan(program(5433))).resources.PortInstance, |
| 920 | ).toMatchObject({ action: "update" }); |
| 921 | const drift = yield* Drift.detect({ |
| 922 | name: stack.name, |
| 923 | stage: stack.stage, |
| 924 | }); |
| 925 | expect(drift.resources.PortInstance?.action).toBe("drifted"); |
| 926 | const repaired = yield* stack.deploy(program(5433)); |
| 927 | expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn); |
| 928 | expect(repaired.endpointPort).toBe(5433); |
| 929 | yield* assertPort(identifier, 5433); |
| 930 | expect( |
| 931 | (yield* stack.plan(program(5433))).resources.PortInstance, |
| 932 | ).toMatchObject({ action: "noop" }); |
| 933 | |
| 934 | // Removing the saved row exercises discovery with no previous props or attributes. |
| 935 | yield* Effect.gen(function* () { |
| 936 | const state = yield* yield* State; |
| 937 | yield* state.delete({ |
| 938 | stack: stack.name, |
| 939 | stage: stack.stage, |
| 940 | fqn: "PortInstance", |
| 941 | }); |
| 942 | }).pipe(Effect.provide(stack.state)); |
| 943 | const adopted = yield* stack.deploy(program()); |
| 944 | expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn); |
| 945 | expect(adopted.endpointPort).toBe(5432); |
| 946 | yield* assertPort(identifier, 5432); |
| 947 | expect( |
| 948 | (yield* stack.plan(program())).resources.PortInstance, |
| 949 | ).toMatchObject({ action: "noop" }); |
| 950 | |
| 951 | yield* injectPort(identifier, 5434); |
| 952 | expect( |
| 953 | (yield* stack.plan(program())).resources.PortInstance, |
| 954 | ).toMatchObject({ action: "update" }); |
| 955 | const defaultRepaired = yield* stack.deploy(program()); |
| 956 | expect(defaultRepaired.endpointPort).toBe(5432); |
| 957 | yield* assertPort(identifier, 5432); |
| 958 | expect( |
| 959 | (yield* stack.plan(program())).resources.PortInstance, |
| 960 | ).toMatchObject({ action: "noop" }); |
| 961 | yield* stack.destroy(); |
| 962 | yield* assertInstanceGone(identifier); |
| 963 | }), |
| 964 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 965 | ); |
| 966 | |
| 967 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 968 | "listener port: waits for an accepted change without resubmitting", |
| 969 | (stack) => |
| 970 | Effect.gen(function* () { |
| 971 | yield* stack.destroy(); |
| 972 | const created = yield* stack.deploy(portProgram()); |
| 973 | yield* assertPort(created.dbInstanceIdentifier, 5432); |
| 974 | yield* rds.modifyDBInstance({ |
| 975 | DBInstanceIdentifier: created.dbInstanceIdentifier, |
| 976 | DBPortNumber: 5433, |
| 977 | ApplyImmediately: false, |
| 978 | }); |
| 979 | const { client, requests } = yield* observeInstanceRequests; |
| 980 | const settled = yield* stack |
| 981 | .deploy(portProgram(5433)) |
| 982 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 983 | expect(settled.dbInstanceArn).toBe(created.dbInstanceArn); |
| 984 | expect(settled.endpointPort).toBe(5433); |
| 985 | expect( |
| 986 | requests.some((request) => request.action === "DescribeDBInstances"), |
| 987 | ).toBe(true); |
| 988 | expect( |
| 989 | requests.filter( |
| 990 | (request) => |
| 991 | request.action === "ModifyDBInstance" && request.port !== null, |
| 992 | ), |
| 993 | ).toEqual([]); |
| 994 | yield* assertPort(created.dbInstanceIdentifier, 5433); |
| 995 | expect( |
| 996 | (yield* stack.plan(portProgram(5433))).resources.PortInstance, |
| 997 | ).toMatchObject({ action: "noop" }); |
| 998 | yield* stack.destroy(); |
| 999 | yield* assertInstanceGone(created.dbInstanceIdentifier); |
| 1000 | }), |
| 1001 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 1002 | ); |
| 1003 | |
| 1004 | type SecurityProps = Pick< |
| 1005 | DBInstanceProps, |
| 1006 | | "enableIAMDatabaseAuthentication" |
| 1007 | | "publiclyAccessible" |
| 1008 | | "deletionProtection" |
| 1009 | | "networkType" |
| 1010 | | "enableCloudwatchLogsExports" |
| 1011 | >; |
| 1012 | |
| 1013 | const securityProgram = ( |
| 1014 | security: SecurityProps = {}, |
| 1015 | round = "security", |
| 1016 | identifier?: string, |
| 1017 | preferredMaintenanceWindow?: string, |
| 1018 | ) => |
| 1019 | Effect.gen(function* () { |
| 1020 | const network = yield* Network("SecurityNet", { |
| 1021 | cidrBlock: "10.51.0.0/16", |
| 1022 | }); |
| 1023 | const subnetGroup = yield* DBSubnetGroup("SecuritySubnetGroup", { |
| 1024 | description: "RDS security desired-state lifecycle", |
| 1025 | subnetIds: network.publicSubnetIds, |
| 1026 | }); |
| 1027 | return yield* DBInstance("SecurityInstance", { |
| 1028 | dbInstanceIdentifier: identifier, |
| 1029 | engine: "postgres", |
| 1030 | dbInstanceClass: "db.t3.micro", |
| 1031 | masterUsername: "alchemy", |
| 1032 | manageMasterUserPassword: true, |
| 1033 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 1034 | backupRetentionPeriod: "0 days", |
| 1035 | preferredMaintenanceWindow, |
| 1036 | skipFinalSnapshot: true, |
| 1037 | ...security, |
| 1038 | tags: { round }, |
| 1039 | }); |
| 1040 | }); |
| 1041 | |
| 1042 | const assertSecurity = Effect.fn(function* ( |
| 1043 | identifier: string, |
| 1044 | security: SecurityProps = {}, |
| 1045 | ) { |
| 1046 | const instance = (yield* rds.describeDBInstances({ |
| 1047 | DBInstanceIdentifier: identifier, |
| 1048 | })).DBInstances?.[0]; |
| 1049 | expect(["available", "storage-optimization"]).toContain( |
| 1050 | instance?.DBInstanceStatus, |
| 1051 | ); |
| 1052 | expect(instance?.IAMDatabaseAuthenticationEnabled).toBe( |
| 1053 | security.enableIAMDatabaseAuthentication ?? false, |
| 1054 | ); |
| 1055 | expect( |
| 1056 | instance?.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled, |
| 1057 | ).toBeUndefined(); |
| 1058 | expect(instance?.PubliclyAccessible).toBe( |
| 1059 | security.publiclyAccessible ?? false, |
| 1060 | ); |
| 1061 | expect(instance?.DeletionProtection).toBe( |
| 1062 | security.deletionProtection ?? false, |
| 1063 | ); |
| 1064 | expect(instance?.NetworkType).toBe(security.networkType ?? "IPV4"); |
| 1065 | expect([...(instance?.EnabledCloudwatchLogsExports ?? [])].sort()).toEqual( |
| 1066 | [...new Set(security.enableCloudwatchLogsExports ?? [])].sort(), |
| 1067 | ); |
| 1068 | expect( |
| 1069 | instance?.PendingModifiedValues?.PendingCloudwatchLogsExports |
| 1070 | ?.LogTypesToEnable ?? [], |
| 1071 | ).toEqual([]); |
| 1072 | expect( |
| 1073 | instance?.PendingModifiedValues?.PendingCloudwatchLogsExports |
| 1074 | ?.LogTypesToDisable ?? [], |
| 1075 | ).toEqual([]); |
| 1076 | return instance; |
| 1077 | }); |
| 1078 | |
| 1079 | const waitForSecurityObservation = ( |
| 1080 | identifier: string, |
| 1081 | until: (instance: rds.DBInstance) => boolean, |
| 1082 | ) => |
| 1083 | rds.describeDBInstances({ DBInstanceIdentifier: identifier }).pipe( |
| 1084 | Effect.map((response) => response.DBInstances?.[0]), |
| 1085 | Effect.repeat({ |
| 1086 | schedule: Schedule.min([ |
| 1087 | Schedule.exponential("5 seconds"), |
| 1088 | Schedule.spaced("1 minute"), |
| 1089 | ]), |
| 1090 | times: 10, |
| 1091 | until: (instance) => |
| 1092 | instance !== undefined && |
| 1093 | ["available", "storage-optimization"].includes( |
| 1094 | instance.DBInstanceStatus ?? "", |
| 1095 | ) && |
| 1096 | until(instance), |
| 1097 | }), |
| 1098 | Effect.tap((instance) => |
| 1099 | Effect.sync(() => { |
| 1100 | expect(instance).toBeDefined(); |
| 1101 | expect(["available", "storage-optimization"]).toContain( |
| 1102 | instance?.DBInstanceStatus, |
| 1103 | ); |
| 1104 | expect(instance !== undefined && until(instance)).toBe(true); |
| 1105 | }), |
| 1106 | ), |
| 1107 | ); |
| 1108 | |
| 1109 | const enabledSecurity: SecurityProps = { |
| 1110 | enableIAMDatabaseAuthentication: true, |
| 1111 | publiclyAccessible: true, |
| 1112 | deletionProtection: true, |
| 1113 | networkType: "IPV4", |
| 1114 | enableCloudwatchLogsExports: ["postgresql", "upgrade"], |
| 1115 | }; |
| 1116 | |
| 1117 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 1118 | "security: defaults, removal, drift, adoption, and no redundant writes", |
| 1119 | (stack) => |
| 1120 | Effect.gen(function* () { |
| 1121 | yield* stack.destroy(); |
| 1122 | const identifier = "alchemy-rds-security-defaults"; |
| 1123 | const { client, requests } = yield* observeInstanceRequests; |
| 1124 | const program = (security: SecurityProps = {}, round = "security") => |
| 1125 | securityProgram(security, round, identifier); |
| 1126 | const deploy = (security: SecurityProps = {}, round = "security") => |
| 1127 | stack |
| 1128 | .deploy(program(security, round)) |
| 1129 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 1130 | const created = yield* deploy(); |
| 1131 | yield* assertSecurity(identifier); |
| 1132 | expect(created.iamDatabaseAuthenticationEnabled).toBe(false); |
| 1133 | expect(created.pendingIamDatabaseAuthenticationEnabled).toBeUndefined(); |
| 1134 | expect(created.publiclyAccessible).toBe(false); |
| 1135 | expect(created.deletionProtection).toBe(false); |
| 1136 | expect(created.networkType).toBe("IPV4"); |
| 1137 | expect(created.enabledCloudwatchLogsExports).toEqual([]); |
| 1138 | expect(Object.keys(created.vpcSecurityGroupStatuses).sort()).toEqual( |
| 1139 | [...created.vpcSecurityGroupIds].sort(), |
| 1140 | ); |
| 1141 | expect(Object.values(created.vpcSecurityGroupStatuses)).toEqual([ |
| 1142 | "active", |
| 1143 | ]); |
| 1144 | expect( |
| 1145 | requests.filter((request) => request.action === "CreateDBInstance"), |
| 1146 | ).toMatchObject([ |
| 1147 | { |
| 1148 | iamAuthentication: "false", |
| 1149 | publiclyAccessible: "false", |
| 1150 | deletionProtection: "false", |
| 1151 | networkType: "IPV4", |
| 1152 | enabledLogs: [], |
| 1153 | }, |
| 1154 | ]); |
| 1155 | |
| 1156 | const enabled = yield* deploy(enabledSecurity); |
| 1157 | expect(enabled.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1158 | expect(enabled.iamDatabaseAuthenticationEnabled).toBe(true); |
| 1159 | expect(enabled.pendingIamDatabaseAuthenticationEnabled).toBeUndefined(); |
| 1160 | yield* assertSecurity(identifier, enabledSecurity); |
| 1161 | requests.length = 0; |
| 1162 | yield* deploy( |
| 1163 | { |
| 1164 | ...enabledSecurity, |
| 1165 | enableCloudwatchLogsExports: ["upgrade", "postgresql", "postgresql"], |
| 1166 | }, |
| 1167 | "set-equivalent", |
| 1168 | ); |
| 1169 | expect( |
| 1170 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1171 | ).toEqual([]); |
| 1172 | |
| 1173 | requests.length = 0; |
| 1174 | const removed = yield* deploy(); |
| 1175 | expect(removed.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1176 | yield* assertSecurity(identifier); |
| 1177 | expect( |
| 1178 | requests.filter((request) => request.disabledLogs.length > 0), |
| 1179 | ).toMatchObject([ |
| 1180 | { |
| 1181 | disabledLogs: ["postgresql", "upgrade"], |
| 1182 | applyImmediately: "false", |
| 1183 | }, |
| 1184 | ]); |
| 1185 | expect( |
| 1186 | (yield* stack.plan(program())).resources.SecurityInstance, |
| 1187 | ).toMatchObject({ action: "noop" }); |
| 1188 | |
| 1189 | const inject = Effect.gen(function* () { |
| 1190 | yield* rds.modifyDBInstance({ |
| 1191 | DBInstanceIdentifier: identifier, |
| 1192 | EnableIAMDatabaseAuthentication: true, |
| 1193 | PubliclyAccessible: true, |
| 1194 | DeletionProtection: true, |
| 1195 | CloudwatchLogsExportConfiguration: { |
| 1196 | EnableLogTypes: ["postgresql", "upgrade"], |
| 1197 | }, |
| 1198 | ApplyImmediately: true, |
| 1199 | }); |
| 1200 | yield* waitForSecurityObservation( |
| 1201 | identifier, |
| 1202 | (instance) => |
| 1203 | instance.IAMDatabaseAuthenticationEnabled === true && |
| 1204 | instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled === |
| 1205 | undefined && |
| 1206 | instance.PubliclyAccessible === true && |
| 1207 | instance.DeletionProtection === true && |
| 1208 | (instance.EnabledCloudwatchLogsExports ?? []).length === 2 && |
| 1209 | (instance.PendingModifiedValues?.PendingCloudwatchLogsExports |
| 1210 | ?.LogTypesToEnable?.length ?? 0) === 0 && |
| 1211 | (instance.PendingModifiedValues?.PendingCloudwatchLogsExports |
| 1212 | ?.LogTypesToDisable?.length ?? 0) === 0, |
| 1213 | ); |
| 1214 | yield* assertSecurity(identifier, enabledSecurity); |
| 1215 | }); |
| 1216 | yield* inject; |
| 1217 | const drift = yield* Drift.detect({ |
| 1218 | name: stack.name, |
| 1219 | stage: stack.stage, |
| 1220 | }); |
| 1221 | expect(drift.resources.SecurityInstance?.action).toBe("drifted"); |
| 1222 | expect( |
| 1223 | (yield* stack.plan(program())).resources.SecurityInstance, |
| 1224 | ).toMatchObject({ action: "update" }); |
| 1225 | const repaired = yield* deploy(); |
| 1226 | expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1227 | yield* assertSecurity(identifier); |
| 1228 | |
| 1229 | yield* inject; |
| 1230 | yield* Effect.gen(function* () { |
| 1231 | const state = yield* yield* State; |
| 1232 | yield* state.delete({ |
| 1233 | stack: stack.name, |
| 1234 | stage: stack.stage, |
| 1235 | fqn: "SecurityInstance", |
| 1236 | }); |
| 1237 | }).pipe(Effect.provide(stack.state)); |
| 1238 | const adopted = yield* deploy(); |
| 1239 | expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1240 | yield* assertSecurity(identifier); |
| 1241 | requests.length = 0; |
| 1242 | yield* deploy({}, "tag-only"); |
| 1243 | expect( |
| 1244 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1245 | ).toEqual([]); |
| 1246 | expect( |
| 1247 | (yield* stack.plan(program({}, "tag-only"))).resources.SecurityInstance, |
| 1248 | ).toMatchObject({ action: "noop" }); |
| 1249 | yield* stack.destroy(); |
| 1250 | yield* assertInstanceGone(identifier); |
| 1251 | }), |
| 1252 | { |
| 1253 | tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"], |
| 1254 | timeout: 1_800_000, |
| 1255 | }, |
| 1256 | ); |
| 1257 | |
| 1258 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 1259 | "security: maintenance-queued IAM convergence and immediate-only queue isolation", |
| 1260 | (stack) => |
| 1261 | Effect.gen(function* () { |
| 1262 | yield* stack.destroy(); |
| 1263 | const maintenance = yield* Effect.sync(() => { |
| 1264 | const day = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"][ |
| 1265 | (new Date().getUTCDay() + 2) % 7 |
| 1266 | ]; |
| 1267 | return `${day}:03:00-${day}:04:00`; |
| 1268 | }); |
| 1269 | const { client, requests } = yield* observeInstanceRequests; |
| 1270 | const program = (security: SecurityProps = {}, round = "security") => |
| 1271 | securityProgram(security, round, undefined, maintenance); |
| 1272 | const deploy = (security: SecurityProps = {}, round = "security") => |
| 1273 | stack |
| 1274 | .deploy(program(security, round)) |
| 1275 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 1276 | const created = yield* deploy(); |
| 1277 | const identifier = created.dbInstanceIdentifier; |
| 1278 | yield* rds.modifyDBInstance({ |
| 1279 | DBInstanceIdentifier: identifier, |
| 1280 | BackupRetentionPeriod: 1, |
| 1281 | ApplyImmediately: false, |
| 1282 | }); |
| 1283 | yield* waitForSecurityObservation( |
| 1284 | identifier, |
| 1285 | (instance) => |
| 1286 | instance.PendingModifiedValues?.BackupRetentionPeriod === 1, |
| 1287 | ); |
| 1288 | requests.length = 0; |
| 1289 | const immediateOnly: SecurityProps = { |
| 1290 | publiclyAccessible: true, |
| 1291 | deletionProtection: true, |
| 1292 | enableCloudwatchLogsExports: ["postgresql"], |
| 1293 | }; |
| 1294 | const changed = yield* deploy(immediateOnly); |
| 1295 | expect(changed.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1296 | expect( |
| 1297 | (yield* assertSecurity(identifier, immediateOnly)) |
| 1298 | ?.PendingModifiedValues?.BackupRetentionPeriod, |
| 1299 | ).toBe(1); |
| 1300 | expect( |
| 1301 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1302 | ).toMatchObject([{ applyImmediately: "false", iamAuthentication: null }]); |
| 1303 | expect( |
| 1304 | (yield* stack.plan(program(immediateOnly))).resources.SecurityInstance, |
| 1305 | ).toMatchObject({ action: "noop" }); |
| 1306 | yield* deploy(); |
| 1307 | expect( |
| 1308 | (yield* assertSecurity(identifier))?.PendingModifiedValues |
| 1309 | ?.BackupRetentionPeriod, |
| 1310 | ).toBe(1); |
| 1311 | // Cancel the test's unrelated queued change before exercising queue promotion. |
| 1312 | yield* rds.modifyDBInstance({ |
| 1313 | DBInstanceIdentifier: identifier, |
| 1314 | BackupRetentionPeriod: 0, |
| 1315 | ApplyImmediately: true, |
| 1316 | }); |
| 1317 | yield* waitForSecurityObservation( |
| 1318 | identifier, |
| 1319 | (instance) => |
| 1320 | instance.BackupRetentionPeriod === 0 && |
| 1321 | instance.PendingModifiedValues?.BackupRetentionPeriod === undefined, |
| 1322 | ); |
| 1323 | |
| 1324 | yield* rds.modifyDBInstance({ |
| 1325 | DBInstanceIdentifier: identifier, |
| 1326 | EnableIAMDatabaseAuthentication: true, |
| 1327 | ApplyImmediately: false, |
| 1328 | }); |
| 1329 | yield* waitForSecurityObservation( |
| 1330 | identifier, |
| 1331 | (instance) => |
| 1332 | instance.IAMDatabaseAuthenticationEnabled === false && |
| 1333 | instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled === |
| 1334 | true, |
| 1335 | ); |
| 1336 | const pendingDrift = yield* Drift.detect({ |
| 1337 | name: stack.name, |
| 1338 | stage: stack.stage, |
| 1339 | }); |
| 1340 | expect(pendingDrift.resources.SecurityInstance).toMatchObject({ |
| 1341 | action: "drifted", |
| 1342 | attr: { |
| 1343 | iamDatabaseAuthenticationEnabled: false, |
| 1344 | pendingIamDatabaseAuthenticationEnabled: true, |
| 1345 | }, |
| 1346 | }); |
| 1347 | requests.length = 0; |
| 1348 | const desired = { enableIAMDatabaseAuthentication: true }; |
| 1349 | const promoted = yield* deploy(desired); |
| 1350 | expect(promoted.iamDatabaseAuthenticationEnabled).toBe(true); |
| 1351 | expect(promoted.pendingIamDatabaseAuthenticationEnabled).toBeUndefined(); |
| 1352 | yield* assertSecurity(identifier, desired); |
| 1353 | // Matching pending IAM needs queue promotion, not another IAM assignment. |
| 1354 | expect( |
| 1355 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1356 | ).toMatchObject([{ applyImmediately: "true", iamAuthentication: null }]); |
| 1357 | |
| 1358 | yield* rds.modifyDBInstance({ |
| 1359 | DBInstanceIdentifier: identifier, |
| 1360 | EnableIAMDatabaseAuthentication: false, |
| 1361 | ApplyImmediately: false, |
| 1362 | }); |
| 1363 | yield* waitForSecurityObservation( |
| 1364 | identifier, |
| 1365 | (instance) => |
| 1366 | instance.IAMDatabaseAuthenticationEnabled === true && |
| 1367 | instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled === |
| 1368 | false, |
| 1369 | ); |
| 1370 | expect( |
| 1371 | (yield* stack.plan(program(desired))).resources.SecurityInstance, |
| 1372 | ).toMatchObject({ action: "update" }); |
| 1373 | requests.length = 0; |
| 1374 | const repaired = yield* deploy(desired); |
| 1375 | expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1376 | yield* assertSecurity(identifier, desired); |
| 1377 | expect( |
| 1378 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1379 | ).toMatchObject([ |
| 1380 | { applyImmediately: "true", iamAuthentication: "true" }, |
| 1381 | ]); |
| 1382 | requests.length = 0; |
| 1383 | yield* deploy(desired, "tag-only"); |
| 1384 | expect( |
| 1385 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1386 | ).toEqual([]); |
| 1387 | yield* rds.modifyDBInstance({ |
| 1388 | DBInstanceIdentifier: identifier, |
| 1389 | EnableIAMDatabaseAuthentication: false, |
| 1390 | ApplyImmediately: false, |
| 1391 | }); |
| 1392 | yield* waitForSecurityObservation( |
| 1393 | identifier, |
| 1394 | (instance) => |
| 1395 | instance.IAMDatabaseAuthenticationEnabled === true && |
| 1396 | instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled === |
| 1397 | false, |
| 1398 | ); |
| 1399 | requests.length = 0; |
| 1400 | yield* deploy(); |
| 1401 | yield* assertSecurity(identifier); |
| 1402 | expect( |
| 1403 | requests.filter((request) => request.action === "ModifyDBInstance"), |
| 1404 | ).toMatchObject([{ applyImmediately: "true", iamAuthentication: null }]); |
| 1405 | expect( |
| 1406 | (yield* stack.plan(program())).resources.SecurityInstance, |
| 1407 | ).toMatchObject({ action: "noop" }); |
| 1408 | yield* stack.destroy(); |
| 1409 | yield* assertInstanceGone(identifier); |
| 1410 | }), |
| 1411 | { |
| 1412 | tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"], |
| 1413 | timeout: 1_800_000, |
| 1414 | }, |
| 1415 | ); |
| 1416 | |
| 1417 | test.provider.skipIf(!process.env.AWS_TEST_RDS_DBINSTANCE)( |
| 1418 | "security: Aurora cluster ownership survives omitted membership and adoption", |
| 1419 | (stack) => |
| 1420 | Effect.gen(function* () { |
| 1421 | yield* stack.destroy(); |
| 1422 | const identifier = "alchemy-rds-security-aurora"; |
| 1423 | const { client, requests } = yield* observeInstanceRequests; |
| 1424 | const program = (membership = true, explicit = true, round = "created") => |
| 1425 | Effect.gen(function* () { |
| 1426 | const network = yield* Network("AuroraSecurityNet", { |
| 1427 | cidrBlock: "10.52.0.0/16", |
| 1428 | }); |
| 1429 | const subnetGroup = yield* DBSubnetGroup( |
| 1430 | "AuroraSecuritySubnetGroup", |
| 1431 | { |
| 1432 | description: "Aurora instance security ownership", |
| 1433 | subnetIds: network.privateSubnetIds, |
| 1434 | }, |
| 1435 | ); |
| 1436 | const cluster = yield* DBCluster("AuroraSecurityCluster", { |
| 1437 | engine: "aurora-postgresql", |
| 1438 | engineMode: "provisioned", |
| 1439 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 1440 | masterUsername: "alchemy", |
| 1441 | manageMasterUserPassword: true, |
| 1442 | enableIAMDatabaseAuthentication: true, |
| 1443 | enableCloudwatchLogsExports: ["postgresql"], |
| 1444 | networkType: "IPV4", |
| 1445 | deletionProtection: false, |
| 1446 | serverlessV2ScalingConfiguration: { |
| 1447 | MinCapacity: 0.5, |
| 1448 | MaxCapacity: 1, |
| 1449 | }, |
| 1450 | }); |
| 1451 | return yield* DBInstance("AuroraSecurityInstance", { |
| 1452 | dbInstanceIdentifier: identifier, |
| 1453 | dbClusterIdentifier: membership |
| 1454 | ? cluster.dbClusterIdentifier |
| 1455 | : undefined, |
| 1456 | engine: "aurora-postgresql", |
| 1457 | dbInstanceClass: "db.serverless", |
| 1458 | ...(explicit |
| 1459 | ? { |
| 1460 | enableIAMDatabaseAuthentication: false, |
| 1461 | deletionProtection: true, |
| 1462 | networkType: "DUAL", |
| 1463 | enableCloudwatchLogsExports: ["not-an-instance-log"], |
| 1464 | } |
| 1465 | : {}), |
| 1466 | tags: { round, cluster: cluster.dbClusterIdentifier }, |
| 1467 | }); |
| 1468 | }); |
| 1469 | const deploy = (membership = true, explicit = true, round = "created") => |
| 1470 | stack |
| 1471 | .deploy(program(membership, explicit, round)) |
| 1472 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 1473 | const created = yield* deploy(); |
| 1474 | expect(created.publiclyAccessible).toBe(false); |
| 1475 | const check = Effect.gen(function* () { |
| 1476 | const instance = (yield* rds.describeDBInstances({ |
| 1477 | DBInstanceIdentifier: identifier, |
| 1478 | })).DBInstances![0]!; |
| 1479 | const cluster = (yield* rds.describeDBClusters({ |
| 1480 | DBClusterIdentifier: instance.DBClusterIdentifier!, |
| 1481 | })).DBClusters![0]!; |
| 1482 | expect(cluster.IAMDatabaseAuthenticationEnabled).toBe(true); |
| 1483 | expect(cluster.EnabledCloudwatchLogsExports).toEqual(["postgresql"]); |
| 1484 | expect(cluster.DeletionProtection).toBe(false); |
| 1485 | expect(cluster.NetworkType).toBe("IPV4"); |
| 1486 | expect(instance.PubliclyAccessible).toBe(false); |
| 1487 | expect( |
| 1488 | requests |
| 1489 | .filter( |
| 1490 | (request) => |
| 1491 | request.action === "CreateDBInstance" || |
| 1492 | request.action === "ModifyDBInstance", |
| 1493 | ) |
| 1494 | .every( |
| 1495 | (request) => |
| 1496 | request.iamAuthentication === null && |
| 1497 | request.deletionProtection === null && |
| 1498 | request.networkType === null && |
| 1499 | request.enabledLogs.length === 0 && |
| 1500 | request.disabledLogs.length === 0, |
| 1501 | ), |
| 1502 | ).toBe(true); |
| 1503 | return instance; |
| 1504 | }); |
| 1505 | yield* check; |
| 1506 | const omitted = yield* deploy(true, false, "omitted"); |
| 1507 | expect(omitted.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1508 | yield* check; |
| 1509 | yield* Effect.gen(function* () { |
| 1510 | const state = yield* yield* State; |
| 1511 | yield* state.delete({ |
| 1512 | stack: stack.name, |
| 1513 | stage: stack.stage, |
| 1514 | fqn: "AuroraSecurityInstance", |
| 1515 | }); |
| 1516 | }).pipe(Effect.provide(stack.state)); |
| 1517 | const adopted = yield* deploy(false, true, "adopted"); |
| 1518 | expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1519 | const observed = yield* check; |
| 1520 | expect(adopted.iamDatabaseAuthenticationEnabled).toBe( |
| 1521 | observed.IAMDatabaseAuthenticationEnabled, |
| 1522 | ); |
| 1523 | expect(adopted.enabledCloudwatchLogsExports).toEqual( |
| 1524 | observed.EnabledCloudwatchLogsExports ?? [], |
| 1525 | ); |
| 1526 | expect( |
| 1527 | (yield* stack.plan(program(false, true, "adopted"))).resources |
| 1528 | .AuroraSecurityInstance, |
| 1529 | ).toMatchObject({ action: "noop" }); |
| 1530 | yield* stack.destroy(); |
| 1531 | yield* assertInstanceGone(identifier); |
| 1532 | }), |
| 1533 | { |
| 1534 | tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"], |
| 1535 | timeout: 1_800_000, |
| 1536 | }, |
| 1537 | ); |
| 1538 | |
| 1539 | const associationProgram = ( |
| 1540 | options: { |
| 1541 | explicit?: boolean; |
| 1542 | reverse?: boolean; |
| 1543 | version?: string; |
| 1544 | identifier?: string; |
| 1545 | round?: string; |
| 1546 | omitSubnet?: boolean; |
| 1547 | } = {}, |
| 1548 | ) => |
| 1549 | Effect.gen(function* () { |
| 1550 | const network = yield* Network("AssociationNet", { |
| 1551 | cidrBlock: "10.46.0.0/16", |
| 1552 | }); |
| 1553 | const subnetGroup = yield* DBSubnetGroup("AssociationSubnetGroup", { |
| 1554 | description: "RDS association lifecycle", |
| 1555 | subnetIds: network.privateSubnetIds, |
| 1556 | }); |
| 1557 | const groupA = yield* SecurityGroup("AssociationGroupA", { |
| 1558 | vpcId: network.vpcId, |
| 1559 | description: "RDS association A", |
| 1560 | }); |
| 1561 | const groupB = yield* SecurityGroup("AssociationGroupB", { |
| 1562 | vpcId: network.vpcId, |
| 1563 | description: "RDS association B", |
| 1564 | }); |
| 1565 | const parameterGroup = yield* DBParameterGroup("AssociationParameters", { |
| 1566 | family: "postgres16", |
| 1567 | parameters: { max_connections: "120" }, |
| 1568 | }); |
| 1569 | const groups = options.reverse |
| 1570 | ? [groupB.groupId, groupA.groupId, groupA.groupId] |
| 1571 | : [groupA.groupId, groupB.groupId]; |
| 1572 | const instance = yield* DBInstance("AssociationInstance", { |
| 1573 | dbInstanceIdentifier: options.identifier, |
| 1574 | engine: "postgres", |
| 1575 | engineVersion: options.version, |
| 1576 | dbInstanceClass: "db.t3.micro", |
| 1577 | masterUsername: "alchemy", |
| 1578 | manageMasterUserPassword: true, |
| 1579 | ...(options.omitSubnet |
| 1580 | ? {} |
| 1581 | : { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }), |
| 1582 | ...(options.explicit |
| 1583 | ? { |
| 1584 | dbParameterGroupName: parameterGroup.dbParameterGroupName, |
| 1585 | vpcSecurityGroupIds: groups, |
| 1586 | } |
| 1587 | : {}), |
| 1588 | backupRetentionPeriod: "0 days", |
| 1589 | deletionProtection: false, |
| 1590 | skipFinalSnapshot: true, |
| 1591 | publiclyAccessible: false, |
| 1592 | // Keep teardown ordered when adoption omits the subnet input. |
| 1593 | tags: { |
| 1594 | round: options.round ?? "associations", |
| 1595 | subnetGroup: subnetGroup.dbSubnetGroupName, |
| 1596 | }, |
| 1597 | }); |
| 1598 | return { |
| 1599 | instance, |
| 1600 | parameterGroupName: parameterGroup.dbParameterGroupName, |
| 1601 | groupIds: [groupA.groupId, groupB.groupId], |
| 1602 | vpcId: network.vpcId, |
| 1603 | }; |
| 1604 | }); |
| 1605 | |
| 1606 | const defaultGroupForVpc = Effect.fn(function* (vpcId: string) { |
| 1607 | const groups = yield* ec2.describeSecurityGroups({ |
| 1608 | Filters: [ |
| 1609 | { Name: "vpc-id", Values: [vpcId] }, |
| 1610 | { Name: "group-name", Values: ["default"] }, |
| 1611 | ], |
| 1612 | }); |
| 1613 | expect(groups.SecurityGroups).toHaveLength(1); |
| 1614 | return groups.SecurityGroups![0]!.GroupId!; |
| 1615 | }); |
| 1616 | |
| 1617 | const assertAssociations = Effect.fn(function* ( |
| 1618 | identifier: string, |
| 1619 | parameterGroup: string, |
| 1620 | securityGroups: string[], |
| 1621 | ) { |
| 1622 | const instance = (yield* rds.describeDBInstances({ |
| 1623 | DBInstanceIdentifier: identifier, |
| 1624 | })).DBInstances?.[0]; |
| 1625 | expect(["available", "storage-optimization"]).toContain( |
| 1626 | instance?.DBInstanceStatus, |
| 1627 | ); |
| 1628 | expect( |
| 1629 | instance?.DBParameterGroups?.map((group) => group.DBParameterGroupName), |
| 1630 | ).toEqual([parameterGroup]); |
| 1631 | expect(["in-sync", "pending-reboot"]).toContain( |
| 1632 | instance?.DBParameterGroups?.[0]?.ParameterApplyStatus, |
| 1633 | ); |
| 1634 | expect( |
| 1635 | instance?.VpcSecurityGroups?.map( |
| 1636 | (group) => group.VpcSecurityGroupId, |
| 1637 | ).sort(), |
| 1638 | ).toEqual([...securityGroups].sort()); |
| 1639 | expect( |
| 1640 | instance?.VpcSecurityGroups?.every((group) => group.Status === "active"), |
| 1641 | ).toBe(true); |
| 1642 | return instance; |
| 1643 | }); |
| 1644 | |
| 1645 | const injectAssociations = Effect.fn(function* ( |
| 1646 | identifier: string, |
| 1647 | parameterGroup: string, |
| 1648 | securityGroups: string[], |
| 1649 | ) { |
| 1650 | yield* rds.modifyDBInstance({ |
| 1651 | DBInstanceIdentifier: identifier, |
| 1652 | DBParameterGroupName: parameterGroup, |
| 1653 | VpcSecurityGroupIds: securityGroups, |
| 1654 | ApplyImmediately: true, |
| 1655 | }); |
| 1656 | yield* rds.describeDBInstances({ DBInstanceIdentifier: identifier }).pipe( |
| 1657 | Effect.repeat({ |
| 1658 | schedule: Schedule.min([ |
| 1659 | Schedule.exponential("5 seconds"), |
| 1660 | Schedule.spaced("1 minute"), |
| 1661 | ]), |
| 1662 | times: 10, |
| 1663 | until: (response) => { |
| 1664 | const db = response.DBInstances?.[0]; |
| 1665 | return ( |
| 1666 | db?.DBInstanceStatus === "available" && |
| 1667 | db.DBParameterGroups?.[0]?.DBParameterGroupName === parameterGroup && |
| 1668 | ["in-sync", "pending-reboot"].includes( |
| 1669 | db.DBParameterGroups?.[0]?.ParameterApplyStatus ?? "", |
| 1670 | ) && |
| 1671 | db.VpcSecurityGroups?.every((group) => group.Status === "active") === |
| 1672 | true && |
| 1673 | JSON.stringify( |
| 1674 | db.VpcSecurityGroups.map( |
| 1675 | (group) => group.VpcSecurityGroupId, |
| 1676 | ).sort(), |
| 1677 | ) === JSON.stringify([...securityGroups].sort()) |
| 1678 | ); |
| 1679 | }, |
| 1680 | }), |
| 1681 | ); |
| 1682 | yield* assertAssociations(identifier, parameterGroup, securityGroups); |
| 1683 | }); |
| 1684 | |
| 1685 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 1686 | "associations: defaults, explicit attachments, and order-independent no-op writes", |
| 1687 | (stack) => |
| 1688 | Effect.gen(function* () { |
| 1689 | yield* stack.destroy(); |
| 1690 | const { client, requests } = yield* observeInstanceRequests; |
| 1691 | const options = { version: "16.13" }; |
| 1692 | const deploy = (extra: Parameters<typeof associationProgram>[0] = {}) => |
| 1693 | stack |
| 1694 | .deploy(associationProgram({ ...options, ...extra })) |
| 1695 | .pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| 1696 | const created = yield* deploy(); |
| 1697 | const defaultGroup = yield* defaultGroupForVpc(created.vpcId); |
| 1698 | yield* assertAssociations( |
| 1699 | created.instance.dbInstanceIdentifier, |
| 1700 | "default.postgres16", |
| 1701 | [defaultGroup], |
| 1702 | ); |
| 1703 | expect(created.instance.vpcSecurityGroupIds).toEqual([defaultGroup]); |
| 1704 | expect( |
| 1705 | requests.filter((request) => request.action === "CreateDBInstance"), |
| 1706 | ).toMatchObject([ |
| 1707 | { |
| 1708 | parameterGroup: "default.postgres16", |
| 1709 | securityGroups: [defaultGroup], |
| 1710 | }, |
| 1711 | ]); |
| 1712 | |
| 1713 | requests.length = 0; |
| 1714 | const attached = yield* deploy({ explicit: true }); |
| 1715 | expect(attached.instance.dbInstanceArn).toBe( |
| 1716 | created.instance.dbInstanceArn, |
| 1717 | ); |
| 1718 | yield* assertAssociations( |
| 1719 | created.instance.dbInstanceIdentifier, |
| 1720 | created.parameterGroupName, |
| 1721 | created.groupIds, |
| 1722 | ); |
| 1723 | expect( |
| 1724 | requests.filter( |
| 1725 | (request) => |
| 1726 | request.action === "ModifyDBInstance" && |
| 1727 | (request.parameterGroup !== null || |
| 1728 | request.securityGroups.length > 0), |
| 1729 | ), |
| 1730 | ).toHaveLength(1); |
| 1731 | expect(["in-sync", "pending-reboot"]).toContain( |
| 1732 | attached.instance.dbParameterGroupApplyStatuses[ |
| 1733 | created.parameterGroupName |
| 1734 | ], |
| 1735 | ); |
| 1736 | |
| 1737 | requests.length = 0; |
| 1738 | yield* deploy({ explicit: true, reverse: true, round: "reordered" }); |
| 1739 | expect( |
| 1740 | requests.some((request) => request.action === "DescribeDBInstances"), |
| 1741 | ).toBe(true); |
| 1742 | expect( |
| 1743 | requests.filter( |
| 1744 | (request) => |
| 1745 | request.action === "ModifyDBInstance" && |
| 1746 | (request.parameterGroup !== null || |
| 1747 | request.securityGroups.length > 0), |
| 1748 | ), |
| 1749 | ).toEqual([]); |
| 1750 | yield* assertAssociations( |
| 1751 | created.instance.dbInstanceIdentifier, |
| 1752 | created.parameterGroupName, |
| 1753 | created.groupIds, |
| 1754 | ); |
| 1755 | expect( |
| 1756 | (yield* stack.plan( |
| 1757 | associationProgram({ |
| 1758 | ...options, |
| 1759 | explicit: true, |
| 1760 | reverse: true, |
| 1761 | round: "reordered", |
| 1762 | }), |
| 1763 | )).resources.AssociationInstance, |
| 1764 | ).toMatchObject({ action: "noop" }); |
| 1765 | yield* stack.destroy(); |
| 1766 | yield* assertInstanceGone(created.instance.dbInstanceIdentifier); |
| 1767 | }), |
| 1768 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 1769 | ); |
| 1770 | |
| 1771 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 1772 | "associations: removal, older-engine defaults, drift, and nondefault-VPC adoption", |
| 1773 | (stack) => |
| 1774 | Effect.gen(function* () { |
| 1775 | yield* stack.destroy(); |
| 1776 | const identifier = "alchemy-rds-association-defaults"; |
| 1777 | const program = (extra: Parameters<typeof associationProgram>[0] = {}) => |
| 1778 | associationProgram({ identifier, ...extra }); |
| 1779 | const created = yield* stack.deploy( |
| 1780 | program({ version: "16.13", explicit: true }), |
| 1781 | ); |
| 1782 | const defaultGroup = yield* defaultGroupForVpc(created.vpcId); |
| 1783 | yield* assertAssociations( |
| 1784 | identifier, |
| 1785 | created.parameterGroupName, |
| 1786 | created.groupIds, |
| 1787 | ); |
| 1788 | |
| 1789 | const removed = yield* stack.deploy(program()); |
| 1790 | expect(removed.instance.dbInstanceArn).toBe( |
| 1791 | created.instance.dbInstanceArn, |
| 1792 | ); |
| 1793 | expect(removed.instance.engineVersion).toBe("16.13"); |
| 1794 | yield* assertAssociations(identifier, "default.postgres16", [ |
| 1795 | defaultGroup, |
| 1796 | ]); |
| 1797 | expect( |
| 1798 | (yield* ec2.describeSecurityGroups({ GroupIds: created.groupIds })) |
| 1799 | .SecurityGroups, |
| 1800 | ).toHaveLength(2); |
| 1801 | expect( |
| 1802 | (yield* rds.describeDBParameterGroups({ |
| 1803 | DBParameterGroupName: created.parameterGroupName, |
| 1804 | })).DBParameterGroups, |
| 1805 | ).toHaveLength(1); |
| 1806 | |
| 1807 | yield* injectAssociations( |
| 1808 | identifier, |
| 1809 | created.parameterGroupName, |
| 1810 | created.groupIds, |
| 1811 | ); |
| 1812 | const drift = yield* Drift.detect({ |
| 1813 | name: stack.name, |
| 1814 | stage: stack.stage, |
| 1815 | }); |
| 1816 | expect(drift.resources.AssociationInstance?.action).toBe("drifted"); |
| 1817 | expect( |
| 1818 | (yield* stack.plan(program())).resources.AssociationInstance, |
| 1819 | ).toMatchObject({ action: "update" }); |
| 1820 | yield* stack.deploy(program()); |
| 1821 | yield* assertAssociations(identifier, "default.postgres16", [ |
| 1822 | defaultGroup, |
| 1823 | ]); |
| 1824 | expect( |
| 1825 | (yield* stack.plan(program())).resources.AssociationInstance, |
| 1826 | ).toMatchObject({ action: "noop" }); |
| 1827 | |
| 1828 | yield* injectAssociations( |
| 1829 | identifier, |
| 1830 | created.parameterGroupName, |
| 1831 | created.groupIds, |
| 1832 | ); |
| 1833 | yield* Effect.gen(function* () { |
| 1834 | const state = yield* yield* State; |
| 1835 | yield* state.delete({ |
| 1836 | stack: stack.name, |
| 1837 | stage: stack.stage, |
| 1838 | fqn: "AssociationInstance", |
| 1839 | }); |
| 1840 | }).pipe(Effect.provide(stack.state)); |
| 1841 | const adopted = yield* stack.deploy(program({ omitSubnet: true })); |
| 1842 | expect(adopted.instance.dbInstanceArn).toBe( |
| 1843 | created.instance.dbInstanceArn, |
| 1844 | ); |
| 1845 | expect(adopted.instance.engineVersion).toBe("16.13"); |
| 1846 | yield* assertAssociations(identifier, "default.postgres16", [ |
| 1847 | defaultGroup, |
| 1848 | ]); |
| 1849 | expect( |
| 1850 | (yield* stack.plan(program({ omitSubnet: true }))).resources |
| 1851 | .AssociationInstance, |
| 1852 | ).toMatchObject({ action: "noop" }); |
| 1853 | yield* stack.destroy(); |
| 1854 | yield* assertInstanceGone(identifier); |
| 1855 | }), |
| 1856 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 1857 | ); |
| 1858 | |
| 1859 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 1860 | "managed secret policy: create, update, removal, drift, adoption, and public rejection", |
| 1861 | (stack) => |
| 1862 | Effect.gen(function* () { |
| 1863 | yield* stack.destroy(); |
| 1864 | const { accountId } = yield* AWSEnvironment.current; |
| 1865 | const policy = (actions: string[]): PolicyDocument => ({ |
| 1866 | Version: "2012-10-17", |
| 1867 | Statement: [ |
| 1868 | { |
| 1869 | Effect: "Allow", |
| 1870 | Principal: { AWS: `arn:aws:iam::${accountId}:root` }, |
| 1871 | Action: actions, |
| 1872 | Resource: "*", |
| 1873 | }, |
| 1874 | ], |
| 1875 | }); |
| 1876 | const initialPolicy = policy(["secretsmanager:DescribeSecret"]); |
| 1877 | const updatedPolicy = policy([ |
| 1878 | "secretsmanager:DescribeSecret", |
| 1879 | "secretsmanager:GetResourcePolicy", |
| 1880 | ]); |
| 1881 | const client = yield* HttpClient.HttpClient; |
| 1882 | const requests: Array<{ |
| 1883 | action: string; |
| 1884 | blockPublicPolicy: boolean | undefined; |
| 1885 | }> = []; |
| 1886 | const observedClient = client.pipe( |
| 1887 | HttpClient.tapRequest((request) => |
| 1888 | Effect.sync(() => { |
| 1889 | const target = request.headers["x-amz-target"]; |
| 1890 | if (!target?.startsWith("secretsmanager.")) return; |
| 1891 | const body = |
| 1892 | request.body._tag === "Uint8Array" |
| 1893 | ? JSON.parse(new TextDecoder().decode(request.body.body)) |
| 1894 | : {}; |
| 1895 | requests.push({ |
| 1896 | action: target.split(".").at(-1)!, |
| 1897 | blockPublicPolicy: body.BlockPublicPolicy, |
| 1898 | }); |
| 1899 | }), |
| 1900 | ), |
| 1901 | ); |
| 1902 | const identifier = "alchemy-rds-secret-policy"; |
| 1903 | const program = ( |
| 1904 | masterUserSecretResourcePolicy: PolicyDocument | undefined, |
| 1905 | round = "default", |
| 1906 | ) => |
| 1907 | Effect.gen(function* () { |
| 1908 | const net = yield* Network("SecretPolicyNet", { |
| 1909 | cidrBlock: "10.49.0.0/16", |
| 1910 | }); |
| 1911 | const subnetGroup = yield* DBSubnetGroup("SecretPolicySubnetGroup", { |
| 1912 | description: "RDS managed secret policy lifecycle", |
| 1913 | subnetIds: net.privateSubnetIds, |
| 1914 | }); |
| 1915 | return yield* DBInstance("SecretPolicyInstance", { |
| 1916 | dbInstanceIdentifier: identifier, |
| 1917 | engine: "postgres", |
| 1918 | dbInstanceClass: "db.t3.micro", |
| 1919 | masterUsername: "alchemy", |
| 1920 | manageMasterUserPassword: true, |
| 1921 | masterUserSecretResourcePolicy, |
| 1922 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 1923 | backupRetentionPeriod: "0 days", |
| 1924 | deletionProtection: false, |
| 1925 | skipFinalSnapshot: true, |
| 1926 | publiclyAccessible: false, |
| 1927 | tags: { round }, |
| 1928 | }); |
| 1929 | }); |
| 1930 | const deploy = (desired: PolicyDocument | undefined, round = "default") => |
| 1931 | stack |
| 1932 | .deploy(program(desired, round)) |
| 1933 | .pipe(Effect.provideService(HttpClient.HttpClient, observedClient)); |
| 1934 | const created = yield* deploy(initialPolicy); |
| 1935 | const secretArn = created.masterUserSecretArn; |
| 1936 | if (!secretArn) |
| 1937 | return yield* Effect.fail( |
| 1938 | new Error("RDS did not return its managed secret ARN"), |
| 1939 | ); |
| 1940 | const readPolicy = secretsmanager |
| 1941 | .getResourcePolicy({ SecretId: secretArn }) |
| 1942 | .pipe( |
| 1943 | Effect.map((response) => |
| 1944 | response.ResourcePolicy === undefined |
| 1945 | ? undefined |
| 1946 | : normalizePolicyDocument(response.ResourcePolicy), |
| 1947 | ), |
| 1948 | ); |
| 1949 | const waitPolicy = (desired: PolicyDocument | undefined) => |
| 1950 | readPolicy.pipe( |
| 1951 | Effect.repeat({ |
| 1952 | schedule: Schedule.spaced("5 seconds"), |
| 1953 | times: 8, |
| 1954 | until: (actual) => |
| 1955 | actual === |
| 1956 | (desired === undefined |
| 1957 | ? undefined |
| 1958 | : normalizePolicyDocument(desired)), |
| 1959 | }), |
| 1960 | ); |
| 1961 | const injectPolicy = (desired: PolicyDocument) => |
| 1962 | Effect.gen(function* () { |
| 1963 | yield* secretsmanager.putResourcePolicy({ |
| 1964 | SecretId: secretArn, |
| 1965 | ResourcePolicy: JSON.stringify(desired), |
| 1966 | BlockPublicPolicy: true, |
| 1967 | }); |
| 1968 | expect(yield* waitPolicy(desired)).toBe( |
| 1969 | normalizePolicyDocument(desired), |
| 1970 | ); |
| 1971 | }); |
| 1972 | const metadata = yield* secretsmanager.describeSecret({ |
| 1973 | SecretId: secretArn, |
| 1974 | }); |
| 1975 | expect(metadata.OwningService).toBe("rds"); |
| 1976 | expect(created.masterUserSecretResourcePolicy).toBe( |
| 1977 | normalizePolicyDocument(initialPolicy), |
| 1978 | ); |
| 1979 | expect(yield* readPolicy).toBe(normalizePolicyDocument(initialPolicy)); |
| 1980 | expect( |
| 1981 | requests.filter((request) => request.action === "PutResourcePolicy"), |
| 1982 | ).toEqual([{ action: "PutResourcePolicy", blockPublicPolicy: true }]); |
| 1983 | const beforePlan = requests.length; |
| 1984 | expect( |
| 1985 | (yield* stack |
| 1986 | .plan(program(initialPolicy)) |
| 1987 | .pipe(Effect.provideService(HttpClient.HttpClient, observedClient))) |
| 1988 | .resources.SecretPolicyInstance, |
| 1989 | ).toMatchObject({ action: "noop" }); |
| 1990 | expect( |
| 1991 | requests |
| 1992 | .slice(beforePlan) |
| 1993 | .some((request) => request.action === "ValidateResourcePolicy"), |
| 1994 | ).toBe(true); |
| 1995 | |
| 1996 | const beforeNoop = requests.length; |
| 1997 | const unchanged = yield* deploy(initialPolicy, "force-reconcile"); |
| 1998 | expect(unchanged.dbInstanceArn).toBe(created.dbInstanceArn); |
| 1999 | expect( |
| 2000 | requests |
| 2001 | .slice(beforeNoop) |
| 2002 | .some((request) => request.action === "ValidateResourcePolicy"), |
| 2003 | ).toBe(true); |
| 2004 | expect( |
| 2005 | requests |
| 2006 | .slice(beforeNoop) |
| 2007 | .filter((request) => |
| 2008 | ["PutResourcePolicy", "DeleteResourcePolicy"].includes( |
| 2009 | request.action, |
| 2010 | ), |
| 2011 | ), |
| 2012 | ).toEqual([]); |
| 2013 | |
| 2014 | const updated = yield* deploy(updatedPolicy); |
| 2015 | expect(updated.masterUserSecretArn).toBe(secretArn); |
| 2016 | expect(updated.masterUserSecretResourcePolicy).toBe( |
| 2017 | normalizePolicyDocument(updatedPolicy), |
| 2018 | ); |
| 2019 | expect(yield* readPolicy).toBe(normalizePolicyDocument(updatedPolicy)); |
| 2020 | |
| 2021 | yield* secretsmanager.deleteResourcePolicy({ SecretId: secretArn }); |
| 2022 | expect(yield* waitPolicy(undefined)).toBeUndefined(); |
| 2023 | expect( |
| 2024 | (yield* stack.plan(program(updatedPolicy))).resources |
| 2025 | .SecretPolicyInstance, |
| 2026 | ).toMatchObject({ action: "update" }); |
| 2027 | yield* deploy(updatedPolicy); |
| 2028 | expect(yield* readPolicy).toBe(normalizePolicyDocument(updatedPolicy)); |
| 2029 | |
| 2030 | const removed = yield* deploy(undefined); |
| 2031 | expect(removed.masterUserSecretArn).toBe(secretArn); |
| 2032 | expect(removed.masterUserSecretResourcePolicy).toBeUndefined(); |
| 2033 | expect(yield* readPolicy).toBeUndefined(); |
| 2034 | expect( |
| 2035 | (yield* stack.plan(program(undefined))).resources.SecretPolicyInstance, |
| 2036 | ).toMatchObject({ action: "noop" }); |
| 2037 | |
| 2038 | yield* injectPolicy(initialPolicy); |
| 2039 | const drift = yield* Drift.detect({ |
| 2040 | name: stack.name, |
| 2041 | stage: stack.stage, |
| 2042 | }); |
| 2043 | expect(drift.resources.SecretPolicyInstance?.action).toBe("drifted"); |
| 2044 | expect( |
| 2045 | (yield* stack.plan(program(undefined))).resources.SecretPolicyInstance, |
| 2046 | ).toMatchObject({ action: "update" }); |
| 2047 | yield* deploy(undefined); |
| 2048 | expect(yield* readPolicy).toBeUndefined(); |
| 2049 | |
| 2050 | yield* injectPolicy(updatedPolicy); |
| 2051 | yield* Effect.gen(function* () { |
| 2052 | const state = yield* yield* State; |
| 2053 | yield* state.delete({ |
| 2054 | stack: stack.name, |
| 2055 | stage: stack.stage, |
| 2056 | fqn: "SecretPolicyInstance", |
| 2057 | }); |
| 2058 | }).pipe(Effect.provide(stack.state)); |
| 2059 | const adopted = yield* deploy(undefined); |
| 2060 | expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn); |
| 2061 | expect(adopted.masterUserSecretArn).toBe(secretArn); |
| 2062 | expect(yield* readPolicy).toBeUndefined(); |
| 2063 | |
| 2064 | const publicPolicy: PolicyDocument = { |
| 2065 | Version: "2012-10-17", |
| 2066 | Statement: [ |
| 2067 | { |
| 2068 | Effect: "Allow", |
| 2069 | Principal: { AWS: "*" }, |
| 2070 | Action: ["secretsmanager:GetSecretValue"], |
| 2071 | Resource: "*", |
| 2072 | }, |
| 2073 | ], |
| 2074 | }; |
| 2075 | const rejected = yield* deploy(publicPolicy).pipe(Effect.result); |
| 2076 | expect(Result.isFailure(rejected)).toBe(true); |
| 2077 | expect(renderFailure(rejected)).toContain( |
| 2078 | "InvalidDBInstanceSecretPolicy", |
| 2079 | ); |
| 2080 | expect(yield* readPolicy).toBeUndefined(); |
| 2081 | yield* deploy(undefined); |
| 2082 | const finalMetadata = yield* secretsmanager.describeSecret({ |
| 2083 | SecretId: secretArn, |
| 2084 | }); |
| 2085 | expect(finalMetadata.ARN).toBe(metadata.ARN); |
| 2086 | expect(finalMetadata.RotationEnabled).toBe(metadata.RotationEnabled); |
| 2087 | expect(finalMetadata.VersionIdsToStages).toEqual( |
| 2088 | metadata.VersionIdsToStages, |
| 2089 | ); |
| 2090 | expect( |
| 2091 | requests.some((request) => |
| 2092 | [ |
| 2093 | "GetSecretValue", |
| 2094 | "BatchGetSecretValue", |
| 2095 | "PutSecretValue", |
| 2096 | "UpdateSecret", |
| 2097 | "RotateSecret", |
| 2098 | "DeleteSecret", |
| 2099 | ].includes(request.action), |
| 2100 | ), |
| 2101 | ).toBe(false); |
| 2102 | expect( |
| 2103 | requests |
| 2104 | .filter((request) => request.action === "PutResourcePolicy") |
| 2105 | .every((request) => request.blockPublicPolicy), |
| 2106 | ).toBe(true); |
| 2107 | expect( |
| 2108 | (yield* stack.plan(program(undefined))).resources.SecretPolicyInstance, |
| 2109 | ).toMatchObject({ action: "noop" }); |
| 2110 | yield* stack.destroy(); |
| 2111 | yield* assertInstanceGone(identifier); |
| 2112 | expect( |
| 2113 | yield* secretsmanager.describeSecret({ SecretId: secretArn }).pipe( |
| 2114 | Effect.as(false), |
| 2115 | Effect.catchTag("ResourceNotFoundException", () => |
| 2116 | Effect.succeed(true), |
| 2117 | ), |
| 2118 | Effect.repeat({ |
| 2119 | schedule: Schedule.spaced("5 seconds"), |
| 2120 | times: 8, |
| 2121 | until: (gone) => gone, |
| 2122 | }), |
| 2123 | ), |
| 2124 | ).toBe(true); |
| 2125 | }), |
| 2126 | { |
| 2127 | tags: [ |
| 2128 | "provider:aws", |
| 2129 | "provider:aws:ec2", |
| 2130 | "provider:aws:iam", |
| 2131 | "provider:aws:rds", |
| 2132 | "provider:aws:secretsmanager", |
| 2133 | "live", |
| 2134 | ], |
| 2135 | }, |
| 2136 | ); |
| 2137 | |
| 2138 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 2139 | "managed secret policy: enable management on an existing instance", |
| 2140 | (stack) => |
| 2141 | Effect.gen(function* () { |
| 2142 | yield* stack.destroy(); |
| 2143 | const { accountId } = yield* AWSEnvironment.current; |
| 2144 | const policy: PolicyDocument = { |
| 2145 | Version: "2012-10-17", |
| 2146 | Statement: [ |
| 2147 | { |
| 2148 | Effect: "Allow", |
| 2149 | Principal: { AWS: `arn:aws:iam::${accountId}:root` }, |
| 2150 | Action: ["secretsmanager:DescribeSecret"], |
| 2151 | Resource: "*", |
| 2152 | }, |
| 2153 | ], |
| 2154 | }; |
| 2155 | const program = (managed: boolean, resourcePolicy?: PolicyDocument) => |
| 2156 | Effect.gen(function* () { |
| 2157 | const net = yield* Network("EnableSecretNet", { |
| 2158 | cidrBlock: "10.50.0.0/16", |
| 2159 | }); |
| 2160 | const subnetGroup = yield* DBSubnetGroup("EnableSecretSubnetGroup", { |
| 2161 | description: "Enable RDS managed credentials", |
| 2162 | subnetIds: net.privateSubnetIds, |
| 2163 | }); |
| 2164 | return yield* DBInstance("EnableSecretInstance", { |
| 2165 | engine: "postgres", |
| 2166 | dbInstanceClass: "db.t3.micro", |
| 2167 | masterUsername: "alchemy", |
| 2168 | masterUserPassword: managed |
| 2169 | ? undefined |
| 2170 | : Redacted.make("Alchemy-secret-policy-42!"), |
| 2171 | manageMasterUserPassword: managed, |
| 2172 | masterUserSecretResourcePolicy: resourcePolicy, |
| 2173 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 2174 | backupRetentionPeriod: "0 days", |
| 2175 | deletionProtection: false, |
| 2176 | skipFinalSnapshot: true, |
| 2177 | publiclyAccessible: false, |
| 2178 | }); |
| 2179 | }); |
| 2180 | const invalid = yield* stack |
| 2181 | .deploy(program(false, policy)) |
| 2182 | .pipe(Effect.result); |
| 2183 | expect(renderFailure(invalid)).toContain("InvalidDBInstanceSecretPolicy"); |
| 2184 | const created = yield* stack.deploy(program(false)); |
| 2185 | expect(created.masterUserSecretArn).toBeUndefined(); |
| 2186 | expect(created.masterUserSecretResourcePolicy).toBeUndefined(); |
| 2187 | const enabled = yield* stack.deploy(program(true, policy)); |
| 2188 | expect(enabled.dbInstanceArn).toBe(created.dbInstanceArn); |
| 2189 | const secretArn = enabled.masterUserSecretArn; |
| 2190 | if (!secretArn) |
| 2191 | return yield* Effect.fail( |
| 2192 | new Error("RDS did not enable its managed secret"), |
| 2193 | ); |
| 2194 | expect(enabled.masterUserSecretResourcePolicy).toBe( |
| 2195 | normalizePolicyDocument(policy), |
| 2196 | ); |
| 2197 | expect( |
| 2198 | normalizePolicyDocument( |
| 2199 | (yield* secretsmanager.getResourcePolicy({ SecretId: secretArn })) |
| 2200 | .ResourcePolicy!, |
| 2201 | ), |
| 2202 | ).toBe(normalizePolicyDocument(policy)); |
| 2203 | const removed = yield* stack.deploy(program(true)); |
| 2204 | expect(removed.masterUserSecretArn).toBe(secretArn); |
| 2205 | expect( |
| 2206 | (yield* secretsmanager.getResourcePolicy({ SecretId: secretArn })) |
| 2207 | .ResourcePolicy, |
| 2208 | ).toBeUndefined(); |
| 2209 | expect( |
| 2210 | (yield* stack.plan(program(true))).resources.EnableSecretInstance, |
| 2211 | ).toMatchObject({ action: "noop" }); |
| 2212 | yield* stack.destroy(); |
| 2213 | yield* assertInstanceGone(created.dbInstanceIdentifier); |
| 2214 | }), |
| 2215 | { |
| 2216 | tags: [ |
| 2217 | "provider:aws", |
| 2218 | "provider:aws:ec2", |
| 2219 | "provider:aws:iam", |
| 2220 | "provider:aws:rds", |
| 2221 | "provider:aws:secretsmanager", |
| 2222 | "live", |
| 2223 | ], |
| 2224 | }, |
| 2225 | ); |
| 2226 | |
| 2227 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 2228 | "readiness: pending creation, blocked stop, and restart recovery", |
| 2229 | (stack) => |
| 2230 | Effect.gen(function* () { |
| 2231 | yield* stack.destroy(); |
| 2232 | const client = yield* HttpClient.HttpClient; |
| 2233 | const observations: Array<{ action: string; statuses: string[] }> = []; |
| 2234 | const observedClient = client.pipe( |
| 2235 | HttpClient.transformResponse( |
| 2236 | Effect.flatMap((response) => |
| 2237 | Effect.gen(function* () { |
| 2238 | const body = response.request.body; |
| 2239 | if (body._tag !== "Uint8Array") return response; |
| 2240 | const action = yield* Effect.sync(() => |
| 2241 | new URLSearchParams(new TextDecoder().decode(body.body)).get( |
| 2242 | "Action", |
| 2243 | ), |
| 2244 | ); |
| 2245 | if (action !== "DescribeDBInstances") return response; |
| 2246 | const bytes = yield* response.arrayBuffer; |
| 2247 | return yield* Effect.sync(() => { |
| 2248 | const text = new TextDecoder().decode(bytes); |
| 2249 | observations.push({ |
| 2250 | action, |
| 2251 | statuses: [ |
| 2252 | ...text.matchAll( |
| 2253 | /<DBInstanceStatus>([^<]+)<\/DBInstanceStatus>/g, |
| 2254 | ), |
| 2255 | ].map((match) => match[1]!), |
| 2256 | }); |
| 2257 | // Forward the real response bytes unchanged to the SDK parser. |
| 2258 | return HttpClientResponse.fromWeb( |
| 2259 | response.request, |
| 2260 | new Response(bytes, { |
| 2261 | status: response.status, |
| 2262 | headers: response.headers, |
| 2263 | }), |
| 2264 | ); |
| 2265 | }); |
| 2266 | }), |
| 2267 | ), |
| 2268 | ), |
| 2269 | ); |
| 2270 | const program = Effect.gen(function* () { |
| 2271 | const network = yield* Network("ReadinessNet", { |
| 2272 | cidrBlock: "10.48.0.0/16", |
| 2273 | }); |
| 2274 | const subnetGroup = yield* DBSubnetGroup("ReadinessSubnetGroup", { |
| 2275 | description: "RDS readiness lifecycle", |
| 2276 | subnetIds: network.privateSubnetIds, |
| 2277 | }); |
| 2278 | return yield* DBInstance("ReadinessInstance", { |
| 2279 | engine: "postgres", |
| 2280 | dbInstanceClass: "db.t3.micro", |
| 2281 | dbSubnetGroupName: subnetGroup.dbSubnetGroupName, |
| 2282 | masterUsername: "alchemy", |
| 2283 | manageMasterUserPassword: true, |
| 2284 | backupRetentionPeriod: "0 days", |
| 2285 | deletionProtection: false, |
| 2286 | skipFinalSnapshot: true, |
| 2287 | publiclyAccessible: false, |
| 2288 | }); |
| 2289 | }); |
| 2290 | const deploy = stack |
| 2291 | .deploy(program) |
| 2292 | .pipe(Effect.provideService(HttpClient.HttpClient, observedClient)); |
| 2293 | const created = yield* deploy; |
| 2294 | const statuses = () => |
| 2295 | observations.flatMap((observation) => observation.statuses); |
| 2296 | expect(statuses()).toContain("creating"); |
| 2297 | expect(statuses().at(-1)).toBe("available"); |
| 2298 | expect(created.status).toBe("available"); |
| 2299 | const identifier = created.dbInstanceIdentifier; |
| 2300 | const describe = rds.describeDBInstances({ |
| 2301 | DBInstanceIdentifier: identifier, |
| 2302 | }); |
| 2303 | const waitForStatus = (status: string) => |
| 2304 | describe.pipe( |
| 2305 | Effect.repeat({ |
| 2306 | schedule: Schedule.min([ |
| 2307 | Schedule.exponential("5 seconds"), |
| 2308 | Schedule.spaced("1 minute"), |
| 2309 | ]), |
| 2310 | times: 10, |
| 2311 | until: (response) => |
| 2312 | response.DBInstances?.[0]?.DBInstanceStatus === status, |
| 2313 | }), |
| 2314 | ); |
| 2315 | expect((yield* describe).DBInstances?.[0]?.DBInstanceStatus).toBe( |
| 2316 | "available", |
| 2317 | ); |
| 2318 | |
| 2319 | yield* rds.stopDBInstance({ DBInstanceIdentifier: identifier }); |
| 2320 | const stopped = yield* waitForStatus("stopped"); |
| 2321 | expect(stopped.DBInstances?.[0]?.DBInstanceStatus).toBe("stopped"); |
| 2322 | expect( |
| 2323 | (yield* stack.plan(program)).resources.ReadinessInstance, |
| 2324 | ).toMatchObject({ action: "update" }); |
| 2325 | const [elapsed, blocked] = yield* deploy.pipe( |
| 2326 | Effect.result, |
| 2327 | Effect.timed, |
| 2328 | ); |
| 2329 | expect(Result.isFailure(blocked)).toBe(true); |
| 2330 | expect(renderFailure(blocked)).toContain("DBInstanceReadinessBlocked"); |
| 2331 | expect(renderFailure(blocked)).toContain("stopped"); |
| 2332 | expect(Duration.toMillis(elapsed)).toBeLessThan(60_000); |
| 2333 | |
| 2334 | yield* rds.startDBInstance({ DBInstanceIdentifier: identifier }); |
| 2335 | const starting = yield* describe.pipe( |
| 2336 | Effect.repeat({ |
| 2337 | schedule: Schedule.spaced("5 seconds"), |
| 2338 | times: 8, |
| 2339 | until: (response) => |
| 2340 | ["starting", "available"].includes( |
| 2341 | response.DBInstances?.[0]?.DBInstanceStatus ?? "", |
| 2342 | ), |
| 2343 | }), |
| 2344 | ); |
| 2345 | expect(["starting", "available"]).toContain( |
| 2346 | starting.DBInstances?.[0]?.DBInstanceStatus, |
| 2347 | ); |
| 2348 | observations.length = 0; |
| 2349 | const started = yield* deploy; |
| 2350 | expect(statuses().at(-1)).toBe("available"); |
| 2351 | expect(started.dbInstanceArn).toBe(created.dbInstanceArn); |
| 2352 | expect(started.status).toBe("available"); |
| 2353 | expect((yield* describe).DBInstances?.[0]?.DBInstanceStatus).toBe( |
| 2354 | "available", |
| 2355 | ); |
| 2356 | expect( |
| 2357 | (yield* stack.plan(program)).resources.ReadinessInstance, |
| 2358 | ).toMatchObject({ action: "noop" }); |
| 2359 | yield* stack.destroy(); |
| 2360 | yield* assertInstanceGone(identifier); |
| 2361 | }), |
| 2362 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 2363 | ); |
| 2364 | |
| 2365 | // Fingerprint-guarded master password lifecycle (#876), gated behind |
| 2366 | // RDS_TEST_LIFECYCLE=1 (real db.t3.micro, ~15-25 min). |
| 2367 | // |
| 2368 | // AWS never returns the master password, so the provider fingerprints the |
| 2369 | // configured value (identifier-salted sha256, persisted `Redacted`) and only |
| 2370 | // sends `MasterUserPassword` on modify when the fingerprint changed. RDS |
| 2371 | // durably records a "Reset master credentials" event whenever a password |
| 2372 | // modify actually applies, which makes the guard observable out-of-band: |
| 2373 | // |
| 2374 | // 1. create with password P1 — set at create time, no reset event |
| 2375 | // 2. redeploy P1 with a tag change (forces reconcile) — fingerprint stable |
| 2376 | // 3. redeploy P2 — fingerprint changes; RDS records the credentials reset |
| 2377 | // |
| 2378 | // After step 3's event is observed, the reset-event count over the whole run |
| 2379 | // must be exactly 1 — the anchored positive event proves step 2's reconcile |
| 2380 | // did not re-send the unchanged password (pre-#876 every reconcile did, |
| 2381 | // putting the instance through a live `resetting-master-credentials` cycle). |
| 2382 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 2383 | "master password: fingerprint guard skips unchanged, applies rotation", |
| 2384 | (stack) => |
| 2385 | Effect.gen(function* () { |
| 2386 | yield* stack.destroy(); |
| 2387 | |
| 2388 | const identifier = "alchemy-rds-fingerprint"; |
| 2389 | const startedAt = yield* Effect.sync(() => new Date()); |
| 2390 | |
| 2391 | // The testing account has no default VPC/subnets — provision a network |
| 2392 | // and DB subnet group like the standalone lifecycle test above. |
| 2393 | const network = Effect.gen(function* () { |
| 2394 | const net = yield* Network("FingerprintNet", { |
| 2395 | cidrBlock: "10.42.0.0/16", |
| 2396 | }); |
| 2397 | const subnetGroup = yield* DBSubnetGroup("FingerprintSubnetGroup", { |
| 2398 | description: "alchemy master-password fingerprint lifecycle", |
| 2399 | subnetIds: net.privateSubnetIds, |
| 2400 | }); |
| 2401 | return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }; |
| 2402 | }); |
| 2403 | |
| 2404 | const deployInstance = (password: string, round: string) => |
| 2405 | stack.deploy( |
| 2406 | Effect.gen(function* () { |
| 2407 | const { dbSubnetGroupName } = yield* network; |
| 2408 | return yield* DBInstance("FingerprintInstance", { |
| 2409 | dbInstanceIdentifier: identifier, |
| 2410 | engine: "postgres", |
| 2411 | dbInstanceClass: "db.t3.micro", |
| 2412 | allocatedStorage: 20, |
| 2413 | masterUsername: "alchemy", |
| 2414 | masterUserPassword: Redacted.make(password), |
| 2415 | deletionProtection: false, |
| 2416 | dbSubnetGroupName, |
| 2417 | publiclyAccessible: false, |
| 2418 | // A changed tag guarantees the engine sees a props diff and |
| 2419 | // runs `reconcile` — the exact path that used to re-send the |
| 2420 | // unchanged password. |
| 2421 | tags: { round }, |
| 2422 | }); |
| 2423 | }), |
| 2424 | ); |
| 2425 | |
| 2426 | const resetEvents = rds |
| 2427 | .describeEvents({ |
| 2428 | SourceIdentifier: identifier, |
| 2429 | SourceType: "db-instance", |
| 2430 | StartTime: startedAt, |
| 2431 | }) |
| 2432 | .pipe( |
| 2433 | Effect.map((response) => |
| 2434 | (response.Events ?? []).filter((event) => |
| 2435 | /reset master credentials/i.test(event.Message ?? ""), |
| 2436 | ), |
| 2437 | ), |
| 2438 | ); |
| 2439 | |
| 2440 | const created = yield* deployInstance("FingerprintPass1", "one"); |
| 2441 | const createdFingerprint = created.masterUserPasswordFingerprint; |
| 2442 | expect(createdFingerprint).toBeDefined(); |
| 2443 | // sha256 hex digest — never the password itself. |
| 2444 | expect(Redacted.value(createdFingerprint!)).toMatch(/^[0-9a-f]{64}$/); |
| 2445 | |
| 2446 | // Same password, tag-only change → reconcile runs but must skip the |
| 2447 | // `MasterUserPassword` modify (same fingerprint). |
| 2448 | const unchanged = yield* deployInstance("FingerprintPass1", "two"); |
| 2449 | expect(unchanged.dbInstanceArn).toBe(created.dbInstanceArn); |
| 2450 | expect(Redacted.value(unchanged.masterUserPasswordFingerprint!)).toBe( |
| 2451 | Redacted.value(createdFingerprint!), |
| 2452 | ); |
| 2453 | |
| 2454 | // Rotation: new password → new fingerprint, and RDS applies a real |
| 2455 | // master-credentials reset. |
| 2456 | const rotated = yield* deployInstance("FingerprintPass2", "three"); |
| 2457 | expect(rotated.dbInstanceArn).toBe(created.dbInstanceArn); |
| 2458 | expect(Redacted.value(rotated.masterUserPasswordFingerprint!)).not.toBe( |
| 2459 | Redacted.value(createdFingerprint!), |
| 2460 | ); |
| 2461 | |
| 2462 | // The reset event lands when the modify applies; poll bounded for it, |
| 2463 | // then assert the count over the whole run is exactly 1 — proving |
| 2464 | // round "two" (unchanged password) never triggered a reset. |
| 2465 | const events = yield* resetEvents.pipe( |
| 2466 | Effect.repeat({ |
| 2467 | schedule: Schedule.min([ |
| 2468 | Schedule.exponential("5 seconds"), |
| 2469 | Schedule.spaced("1 minute"), |
| 2470 | ]), |
| 2471 | until: (found) => found.length > 0, |
| 2472 | times: 10, |
| 2473 | }), |
| 2474 | ); |
| 2475 | expect(events).toHaveLength(1); |
| 2476 | |
| 2477 | yield* stack.destroy(); |
| 2478 | }), |
| 2479 | { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] }, |
| 2480 | ); |