| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Network } from "@/AWS/EC2/Network"; |
| 3 | import { DBCluster } from "@/AWS/RDS/DBCluster.ts"; |
| 4 | import type { DBClusterProps } from "@/AWS/RDS/DBCluster.ts"; |
| 5 | import { DBSubnetGroup } from "@/AWS/RDS/DBSubnetGroup.ts"; |
| 6 | import * as Provider from "@/Provider"; |
| 7 | import * as Test from "@/Test/Alchemy"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Result from "effect/Result"; |
| 12 | |
| 13 | const { test } = Test.make({ providers: AWS.providers() }); |
| 14 | |
| 15 | // Fast, unconditional `diff` checks for the replacement-set logic. No deploy. |
| 16 | const callDiff = (olds: DBClusterProps, news: DBClusterProps) => |
| 17 | Effect.gen(function* () { |
| 18 | const provider = yield* Provider.findProvider(DBCluster); |
| 19 | return yield* provider.diff!({ |
| 20 | id: "TestCluster", |
| 21 | fqn: "TestCluster", |
| 22 | instanceId: "test-cluster", |
| 23 | olds, |
| 24 | news, |
| 25 | oldBindings: undefined as never, |
| 26 | newBindings: undefined as never, |
| 27 | output: undefined, |
| 28 | }); |
| 29 | }); |
| 30 | |
| 31 | const base: DBClusterProps = { |
| 32 | dbClusterIdentifier: "alchemy-rds-cluster-diff", |
| 33 | engine: "aurora-postgresql", |
| 34 | }; |
| 35 | |
| 36 | test.provider( |
| 37 | "diff: backup retention is an in-place update", |
| 38 | () => |
| 39 | Effect.gen(function* () { |
| 40 | const result = yield* callDiff( |
| 41 | { ...base, backupRetentionPeriod: "1 day" }, |
| 42 | { ...base, backupRetentionPeriod: "7 days" }, |
| 43 | ); |
| 44 | expect(result).toBeUndefined(); |
| 45 | }), |
| 46 | { tags: ["provider:aws", "provider:aws:rds", "live"] }, |
| 47 | ); |
| 48 | |
| 49 | test.provider( |
| 50 | "diff: changing databaseName forces replacement", |
| 51 | () => |
| 52 | Effect.gen(function* () { |
| 53 | const result = yield* callDiff( |
| 54 | { ...base, databaseName: "app" }, |
| 55 | { ...base, databaseName: "other" }, |
| 56 | ); |
| 57 | expect(result).toEqual({ action: "replace" }); |
| 58 | }), |
| 59 | { tags: ["provider:aws", "provider:aws:rds", "live"] }, |
| 60 | ); |
| 61 | |
| 62 | test.provider( |
| 63 | "diff: changing kmsKeyId forces replacement", |
| 64 | () => |
| 65 | Effect.gen(function* () { |
| 66 | const result = yield* callDiff( |
| 67 | { ...base, kmsKeyId: "key-a" }, |
| 68 | { ...base, kmsKeyId: "key-b" }, |
| 69 | ); |
| 70 | expect(result).toEqual({ action: "replace" }); |
| 71 | }), |
| 72 | { tags: ["provider:aws", "provider:aws:rds", "live"] }, |
| 73 | ); |
| 74 | |
| 75 | test.provider( |
| 76 | "diff: changing engineMode forces replacement", |
| 77 | () => |
| 78 | Effect.gen(function* () { |
| 79 | const result = yield* callDiff( |
| 80 | { ...base, engineMode: "provisioned" }, |
| 81 | { ...base, engineMode: "serverless" }, |
| 82 | ); |
| 83 | expect(result).toEqual({ action: "replace" }); |
| 84 | }), |
| 85 | { tags: ["provider:aws", "provider:aws:rds", "live"] }, |
| 86 | ); |
| 87 | |
| 88 | // Render a deploy failure (whatever engine wrapper it arrives in) to a string |
| 89 | // we can assert AWS's parameter-validation message against. |
| 90 | const renderFailure = (attempt: Result.Result<unknown, unknown>): string => { |
| 91 | if (!Result.isFailure(attempt)) { |
| 92 | return ""; |
| 93 | } |
| 94 | const failure = attempt.failure; |
| 95 | const json = (() => { |
| 96 | try { |
| 97 | return JSON.stringify(failure); |
| 98 | } catch { |
| 99 | return ""; |
| 100 | } |
| 101 | })(); |
| 102 | return `${String(failure)} ${json}`; |
| 103 | }; |
| 104 | |
| 105 | // Live wire probes for this PR's Redacted/Duration prop conversions. Both |
| 106 | // drive the full engine + provider `reconcile` path into a real |
| 107 | // `createDBCluster` call that AWS rejects at parameter-validation time, so |
| 108 | // nothing is ever provisioned and the probe completes in seconds. |
| 109 | // |
| 110 | // Probe 1 proves `masterUserPassword: Redacted.Redacted<string>` is |
| 111 | // serialized to the actual secret characters on the wire — AWS's validator |
| 112 | // can only reject the password if it saw the real '@'/' ' characters, not a |
| 113 | // "<redacted>" placeholder. |
| 114 | // |
| 115 | // Probe 2 proves `backupRetentionPeriod: Duration.Input` ("60 days") reaches |
| 116 | // the wire as integer days — AWS can only reject 60 > 35 if the converted |
| 117 | // number arrived. |
| 118 | // |
| 119 | // (The in-range live round-trip — create with "1 day", read back 1, modify to |
| 120 | // "3 days", read back 3 — is covered by the RDS_TEST_LIFECYCLE-gated test |
| 121 | // below, which needs ~15+ minutes of Aurora provisioning.) |
| 122 | test.provider( |
| 123 | "wire probe: Redacted password + Duration retention reach createDBCluster", |
| 124 | (stack) => |
| 125 | Effect.gen(function* () { |
| 126 | yield* stack.destroy(); |
| 127 | |
| 128 | const badPassword = yield* Effect.result( |
| 129 | stack.deploy( |
| 130 | Effect.gen(function* () { |
| 131 | return yield* DBCluster("AuditProbeCluster", { |
| 132 | dbClusterIdentifier: "alchemy-audit-probe", |
| 133 | engine: "aurora-postgresql", |
| 134 | masterUsername: "alchemy", |
| 135 | // '@' and ' ' are forbidden password characters — AWS rejects |
| 136 | // the create before provisioning anything. |
| 137 | masterUserPassword: Redacted.make("bad@pass word1"), |
| 138 | backupRetentionPeriod: "3 days", |
| 139 | }); |
| 140 | }), |
| 141 | ), |
| 142 | ); |
| 143 | expect(Result.isFailure(badPassword)).toBe(true); |
| 144 | // Typed tag (patched into distilled rds createDBCluster) + AWS's own |
| 145 | // validation text naming the password parameter. |
| 146 | expect(renderFailure(badPassword)).toContain("InvalidParameterValue"); |
| 147 | expect(renderFailure(badPassword)).toContain("MasterUserPassword"); |
| 148 | |
| 149 | const badRetention = yield* Effect.result( |
| 150 | stack.deploy( |
| 151 | Effect.gen(function* () { |
| 152 | return yield* DBCluster("AuditProbeCluster", { |
| 153 | dbClusterIdentifier: "alchemy-audit-probe", |
| 154 | engine: "aurora-postgresql", |
| 155 | masterUsername: "alchemy", |
| 156 | masterUserPassword: Redacted.make("ValidPassw0rd"), |
| 157 | // 60 days is above the 1-35 day API maximum — AWS echoes the |
| 158 | // converted integer back in the validation error. |
| 159 | backupRetentionPeriod: "60 days", |
| 160 | }); |
| 161 | }), |
| 162 | ), |
| 163 | ); |
| 164 | expect(Result.isFailure(badRetention)).toBe(true); |
| 165 | expect(renderFailure(badRetention)).toContain("InvalidParameterValue"); |
| 166 | expect(renderFailure(badRetention)).toMatch(/retention/i); |
| 167 | |
| 168 | yield* stack.destroy(); |
| 169 | }), |
| 170 | { tags: ["provider:aws", "provider:aws:rds", "live"], timeout: 120_000 }, |
| 171 | ); |
| 172 | |
| 173 | // Read-only `list()` test (no deploy). An Aurora DB cluster takes MANY minutes |
| 174 | // to create *and* delete — far beyond the 240s test budget — so we exercise the |
| 175 | // enumeration path without provisioning. We resolve the provider via the typed |
| 176 | // `Provider.findProvider(DBCluster)` so `list()`'s element type is the exact |
| 177 | // `DBCluster["Attributes"]` shape, call it, and assert it returns a well-typed |
| 178 | // array (likely empty in a clean test account). This proves the paginated |
| 179 | // `describeDBClusters` -> Attributes mapping compiles and runs. |
| 180 | test.provider( |
| 181 | "list returns a typed DBCluster Attributes array", |
| 182 | () => |
| 183 | Effect.gen(function* () { |
| 184 | const provider = yield* Provider.findProvider(DBCluster); |
| 185 | const all = yield* provider.list(); |
| 186 | |
| 187 | expect(Array.isArray(all)).toBe(true); |
| 188 | for (const cluster of all) { |
| 189 | expect(typeof cluster.dbClusterIdentifier).toBe("string"); |
| 190 | expect(typeof cluster.dbClusterArn).toBe("string"); |
| 191 | expect(typeof cluster.engine).toBe("string"); |
| 192 | expect(typeof cluster.tags).toBe("object"); |
| 193 | expect(Array.isArray(cluster.vpcSecurityGroupIds)).toBe(true); |
| 194 | } |
| 195 | }), |
| 196 | { tags: ["provider:aws", "provider:aws:rds", "live"] }, |
| 197 | ); |
| 198 | |
| 199 | // Full deploy-based `list()` test, gated behind AWS_TEST_RDS_DBCLUSTER=1. |
| 200 | // Reason: an Aurora cluster create + delete spans many minutes, blowing past |
| 201 | // the 240s budget, so it is opt-in only. When enabled, it deploys a real |
| 202 | // cluster and asserts the deployed identifier appears in the exhaustively |
| 203 | // paginated `list()` result. |
| 204 | test.provider.skipIf(!process.env.AWS_TEST_RDS_DBCLUSTER)( |
| 205 | "list enumerates the deployed DB cluster", |
| 206 | (stack) => |
| 207 | Effect.gen(function* () { |
| 208 | yield* stack.destroy(); |
| 209 | |
| 210 | const cluster = yield* stack.deploy( |
| 211 | Effect.gen(function* () { |
| 212 | return yield* DBCluster("ListCluster", { |
| 213 | engine: "aurora-postgresql", |
| 214 | engineMode: "provisioned", |
| 215 | serverlessV2ScalingConfiguration: { |
| 216 | MinCapacity: 0.5, |
| 217 | MaxCapacity: 1, |
| 218 | }, |
| 219 | manageMasterUserPassword: true, |
| 220 | masterUsername: "alchemy", |
| 221 | }); |
| 222 | }), |
| 223 | ); |
| 224 | |
| 225 | const provider = yield* Provider.findProvider(DBCluster); |
| 226 | const all = yield* provider.list(); |
| 227 | |
| 228 | expect( |
| 229 | all.some((c) => c.dbClusterIdentifier === cluster.dbClusterIdentifier), |
| 230 | ).toBe(true); |
| 231 | |
| 232 | yield* stack.destroy(); |
| 233 | }), |
| 234 | { tags: ["provider:aws", "provider:aws:rds", "live"], timeout: 1_800_000 }, |
| 235 | ); |
| 236 | |
| 237 | // Full cluster lifecycle gated behind RDS_TEST_LIFECYCLE=1. Creates a |
| 238 | // serverless-v2 Aurora cluster with backup/log/deletion-protection knobs, then |
| 239 | // does an in-place modify (backup retention, toggle deletionProtection — a |
| 240 | // regression for the previously-missing `modifyDBCluster` deletion-protection |
| 241 | // sync — and scaling min/max), asserting no replacement (same ARN). |
| 242 | test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( |
| 243 | "cluster: create with knobs, then in-place modify (deletionProtection toggle)", |
| 244 | (stack) => |
| 245 | Effect.gen(function* () { |
| 246 | yield* stack.destroy(); |
| 247 | |
| 248 | // No default VPC/subnets in the testing account — provision a |
| 249 | // production-shaped network (VPC + subnets across 2 AZs) + a DB subnet |
| 250 | // group for the cluster. |
| 251 | const network = Effect.gen(function* () { |
| 252 | const net = yield* Network("ClusterNet", { cidrBlock: "10.42.0.0/16" }); |
| 253 | // No fixed name — let the engine generate a unique physical name so a |
| 254 | // leftover group from an interrupted run can't force a cross-VPC |
| 255 | // ModifyDBSubnetGroup ("new Subnets are not in the same Vpc"). |
| 256 | const subnetGroup = yield* DBSubnetGroup("ClusterSubnetGroup", { |
| 257 | description: "alchemy cluster lifecycle", |
| 258 | subnetIds: net.privateSubnetIds, |
| 259 | }); |
| 260 | return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }; |
| 261 | }); |
| 262 | |
| 263 | const created = yield* stack.deploy( |
| 264 | Effect.gen(function* () { |
| 265 | const { dbSubnetGroupName } = yield* network; |
| 266 | return yield* DBCluster("LifecycleCluster", { |
| 267 | dbClusterIdentifier: "alchemy-rds-lifecycle", |
| 268 | engine: "aurora-postgresql", |
| 269 | engineMode: "provisioned", |
| 270 | dbSubnetGroupName, |
| 271 | serverlessV2ScalingConfiguration: { |
| 272 | MinCapacity: 0.5, |
| 273 | MaxCapacity: 1, |
| 274 | }, |
| 275 | manageMasterUserPassword: true, |
| 276 | masterUsername: "alchemy", |
| 277 | backupRetentionPeriod: "1 day", |
| 278 | enableCloudwatchLogsExports: ["postgresql"], |
| 279 | deletionProtection: false, |
| 280 | }); |
| 281 | }), |
| 282 | ); |
| 283 | |
| 284 | expect(created.backupRetentionPeriod).toBe(1); |
| 285 | expect(created.enabledCloudwatchLogsExports).toContain("postgresql"); |
| 286 | expect(created.deletionProtection).toBe(false); |
| 287 | |
| 288 | const updated = yield* stack.deploy( |
| 289 | Effect.gen(function* () { |
| 290 | const { dbSubnetGroupName } = yield* network; |
| 291 | return yield* DBCluster("LifecycleCluster", { |
| 292 | dbClusterIdentifier: "alchemy-rds-lifecycle", |
| 293 | engine: "aurora-postgresql", |
| 294 | engineMode: "provisioned", |
| 295 | dbSubnetGroupName, |
| 296 | serverlessV2ScalingConfiguration: { |
| 297 | MinCapacity: 1, |
| 298 | MaxCapacity: 2, |
| 299 | }, |
| 300 | manageMasterUserPassword: true, |
| 301 | masterUsername: "alchemy", |
| 302 | backupRetentionPeriod: "3 days", |
| 303 | enableCloudwatchLogsExports: ["postgresql"], |
| 304 | deletionProtection: true, |
| 305 | }); |
| 306 | }), |
| 307 | ); |
| 308 | |
| 309 | expect(updated.dbClusterArn).toBe(created.dbClusterArn); |
| 310 | expect(updated.backupRetentionPeriod).toBe(3); |
| 311 | expect(updated.deletionProtection).toBe(true); |
| 312 | |
| 313 | // Re-disable protection so the trailing destroy can delete the cluster. |
| 314 | yield* stack.deploy( |
| 315 | Effect.gen(function* () { |
| 316 | const { dbSubnetGroupName } = yield* network; |
| 317 | return yield* DBCluster("LifecycleCluster", { |
| 318 | dbClusterIdentifier: "alchemy-rds-lifecycle", |
| 319 | engine: "aurora-postgresql", |
| 320 | engineMode: "provisioned", |
| 321 | dbSubnetGroupName, |
| 322 | serverlessV2ScalingConfiguration: { |
| 323 | MinCapacity: 1, |
| 324 | MaxCapacity: 2, |
| 325 | }, |
| 326 | manageMasterUserPassword: true, |
| 327 | masterUsername: "alchemy", |
| 328 | backupRetentionPeriod: "3 days", |
| 329 | enableCloudwatchLogsExports: ["postgresql"], |
| 330 | deletionProtection: false, |
| 331 | }); |
| 332 | }), |
| 333 | ); |
| 334 | |
| 335 | yield* stack.destroy(); |
| 336 | }), |
| 337 | { |
| 338 | tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"], |
| 339 | timeout: 2_400_000, |
| 340 | }, |
| 341 | ); |