| 1 | import * as dms from "@distilled.cloud/aws/database-migration-service"; |
| 2 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | |
| 7 | /** |
| 8 | * Out-of-band orphan reaper for the DMS test suites. |
| 9 | * |
| 10 | * `test.provider` scratch stacks keep their state IN MEMORY, so a run that is |
| 11 | * hard-killed (vitest SIGKILL, outer `timeout`, OOM) between deploy and the |
| 12 | * `Effect.ensuring(scratch.destroy())` finalizer orphans the whole fixture — |
| 13 | * VPC, subnets, DMS subnet group, and (for the SLOW-gated test) a replication |
| 14 | * instance — with no state left to destroy from. A post-wave account nuke |
| 15 | * found exactly this: a leaked DMS test VPC stack. |
| 16 | * |
| 17 | * The reaper finds the fixtures by their DETERMINISTIC VPC CIDRs |
| 18 | * (`10.91.0.0/16` in ReplicationSubnetGroup.test.ts, `10.92.0.0/16` in |
| 19 | * ReplicationInstance.test.ts — unique across the test suite) and deletes |
| 20 | * everything inside dependency-first with typed, idempotent calls: |
| 21 | * replication instances → subnet groups → ENIs → IGWs → route tables → |
| 22 | * security groups → subnets → VPC. |
| 23 | * |
| 24 | * It runs as a delete-if-exists pre-clean at the top of each test body and as |
| 25 | * an `Effect.ensuring` finalizer around it, so any run converges the account |
| 26 | * to zero leftovers regardless of how the previous one ended. A clean account |
| 27 | * costs one `describeVpcs` call. |
| 28 | * |
| 29 | * NOTE: the account-wide `dms-vpc-role` IAM role is owned and cleaned by the |
| 30 | * ReplicationSubnetGroup provider when Alchemy created it and the last subnet |
| 31 | * group is gone. The network reaper does not delete foreign/pre-existing IAM |
| 32 | * roles with that AWS-mandated name. |
| 33 | */ |
| 34 | |
| 35 | /** Fixed CIDRs of the DMS test VPCs (see the two test files). */ |
| 36 | const VPC_CIDRS = ["10.91.0.0/16", "10.92.0.0/16"]; |
| 37 | |
| 38 | class OrphanStillPresent extends Data.TaggedError("OrphanStillPresent")<{ |
| 39 | readonly kind: string; |
| 40 | }> {} |
| 41 | |
| 42 | /** ~10 minute ceiling for slow asynchronous releases (DMS instance deletes, ENIs). */ |
| 43 | const slowRelease = Schedule.max([ |
| 44 | Schedule.spaced("15 seconds"), |
| 45 | Schedule.recurs(40), |
| 46 | ]); |
| 47 | /** ~2 minute ceiling for ordinary dependency-violation races. */ |
| 48 | const dependencyRelease = Schedule.max([ |
| 49 | Schedule.spaced("10 seconds"), |
| 50 | Schedule.recurs(12), |
| 51 | ]); |
| 52 | |
| 53 | const findTestVpcIds = ec2 |
| 54 | .describeVpcs({ Filters: [{ Name: "cidr", Values: VPC_CIDRS }] }) |
| 55 | .pipe( |
| 56 | Effect.map((response) => |
| 57 | (response.Vpcs ?? []).flatMap((vpc) => (vpc.VpcId ? [vpc.VpcId] : [])), |
| 58 | ), |
| 59 | ); |
| 60 | |
| 61 | /** Count replication instances still placed in the given VPCs. */ |
| 62 | const countInstancesIn = (vpcIds: ReadonlySet<string>) => |
| 63 | dms.describeReplicationInstances({}).pipe( |
| 64 | Effect.catchTag("ResourceNotFoundFault", () => |
| 65 | Effect.succeed({ ReplicationInstances: [] }), |
| 66 | ), |
| 67 | Effect.map( |
| 68 | (response) => |
| 69 | (response.ReplicationInstances ?? []).filter( |
| 70 | (instance): boolean => |
| 71 | !!instance.ReplicationSubnetGroup?.VpcId && |
| 72 | vpcIds.has(instance.ReplicationSubnetGroup.VpcId), |
| 73 | ).length, |
| 74 | ), |
| 75 | ); |
| 76 | |
| 77 | /** |
| 78 | * Delete any replication instance placed in a test VPC and wait until it is |
| 79 | * fully gone — its ENIs block subnet/VPC deletion until then. |
| 80 | */ |
| 81 | const reapReplicationInstances = Effect.fn(function* ( |
| 82 | vpcIds: ReadonlySet<string>, |
| 83 | ) { |
| 84 | const response = yield* dms |
| 85 | .describeReplicationInstances({}) |
| 86 | .pipe( |
| 87 | Effect.catchTag("ResourceNotFoundFault", () => |
| 88 | Effect.succeed({ ReplicationInstances: [] }), |
| 89 | ), |
| 90 | ); |
| 91 | const arns = (response.ReplicationInstances ?? []) |
| 92 | .filter( |
| 93 | (instance): boolean => |
| 94 | !!instance.ReplicationSubnetGroup?.VpcId && |
| 95 | vpcIds.has(instance.ReplicationSubnetGroup.VpcId), |
| 96 | ) |
| 97 | .flatMap((instance) => |
| 98 | instance.ReplicationInstanceArn ? [instance.ReplicationInstanceArn] : [], |
| 99 | ); |
| 100 | yield* Effect.forEach( |
| 101 | arns, |
| 102 | (ReplicationInstanceArn) => |
| 103 | Effect.logInfo( |
| 104 | `DMS reap: deleting replication instance ${ReplicationInstanceArn}`, |
| 105 | ).pipe( |
| 106 | Effect.andThen( |
| 107 | dms.deleteReplicationInstance({ ReplicationInstanceArn }), |
| 108 | ), |
| 109 | // InvalidResourceStateFault: already deleting — the wait below |
| 110 | // confirms it actually goes away. |
| 111 | Effect.catchTag( |
| 112 | ["ResourceNotFoundFault", "InvalidResourceStateFault"], |
| 113 | () => Effect.void, |
| 114 | ), |
| 115 | ), |
| 116 | { discard: true }, |
| 117 | ); |
| 118 | if (arns.length > 0) { |
| 119 | yield* countInstancesIn(vpcIds).pipe( |
| 120 | Effect.repeat({ schedule: slowRelease, until: (n) => n === 0 }), |
| 121 | Effect.flatMap((n) => |
| 122 | n === 0 |
| 123 | ? Effect.void |
| 124 | : Effect.fail( |
| 125 | new OrphanStillPresent({ kind: "DMS replication instances" }), |
| 126 | ), |
| 127 | ), |
| 128 | ); |
| 129 | } |
| 130 | }); |
| 131 | |
| 132 | /** Delete any replication subnet group whose subnets live in a test VPC. */ |
| 133 | const reapSubnetGroups = Effect.fn(function* (vpcIds: ReadonlySet<string>) { |
| 134 | const response = yield* dms |
| 135 | .describeReplicationSubnetGroups({}) |
| 136 | .pipe( |
| 137 | Effect.catchTag("ResourceNotFoundFault", () => |
| 138 | Effect.succeed({ ReplicationSubnetGroups: [] }), |
| 139 | ), |
| 140 | ); |
| 141 | const identifiers = (response.ReplicationSubnetGroups ?? []) |
| 142 | .filter((group): boolean => !!group.VpcId && vpcIds.has(group.VpcId)) |
| 143 | .flatMap((group) => |
| 144 | group.ReplicationSubnetGroupIdentifier |
| 145 | ? [group.ReplicationSubnetGroupIdentifier] |
| 146 | : [], |
| 147 | ); |
| 148 | yield* Effect.forEach( |
| 149 | identifiers, |
| 150 | (ReplicationSubnetGroupIdentifier) => |
| 151 | Effect.logInfo( |
| 152 | `DMS reap: deleting subnet group ${ReplicationSubnetGroupIdentifier}`, |
| 153 | ).pipe( |
| 154 | Effect.andThen( |
| 155 | dms.deleteReplicationSubnetGroup({ |
| 156 | ReplicationSubnetGroupIdentifier, |
| 157 | }), |
| 158 | ), |
| 159 | // InvalidResourceStateFault: an instance is still detaching from the |
| 160 | // group (instance reap above waits, but deletion lag can linger). |
| 161 | Effect.retry({ |
| 162 | while: (e): boolean => e._tag === "InvalidResourceStateFault", |
| 163 | schedule: dependencyRelease, |
| 164 | }), |
| 165 | Effect.catchTag("ResourceNotFoundFault", () => Effect.void), |
| 166 | ), |
| 167 | { discard: true }, |
| 168 | ); |
| 169 | }); |
| 170 | |
| 171 | /** |
| 172 | * ENIs (e.g. from a deleted replication instance) release asynchronously and |
| 173 | * block subnet/VPC deletion. Each pass deletes the ENIs that have reached |
| 174 | * `available`, then re-counts. |
| 175 | */ |
| 176 | const sweepNetworkInterfaces = Effect.fn(function* (vpcId: string) { |
| 177 | const describe = ec2 |
| 178 | .describeNetworkInterfaces({ |
| 179 | Filters: [{ Name: "vpc-id", Values: [vpcId] }], |
| 180 | }) |
| 181 | .pipe(Effect.map((response) => response.NetworkInterfaces ?? [])); |
| 182 | yield* Effect.gen(function* () { |
| 183 | const enis = yield* describe; |
| 184 | yield* Effect.forEach( |
| 185 | enis.filter( |
| 186 | (eni): boolean => |
| 187 | eni.Status === "available" && !!eni.NetworkInterfaceId, |
| 188 | ), |
| 189 | (eni) => |
| 190 | Effect.logInfo(`DMS reap: deleting ENI ${eni.NetworkInterfaceId}`).pipe( |
| 191 | Effect.andThen( |
| 192 | ec2.deleteNetworkInterface({ |
| 193 | NetworkInterfaceId: eni.NetworkInterfaceId, |
| 194 | }), |
| 195 | ), |
| 196 | Effect.catchTag( |
| 197 | [ |
| 198 | "InvalidNetworkInterfaceID.NotFound", |
| 199 | "InvalidNetworkInterface.InUse", |
| 200 | ], |
| 201 | () => Effect.void, |
| 202 | ), |
| 203 | ), |
| 204 | { discard: true }, |
| 205 | ); |
| 206 | return (yield* describe).length; |
| 207 | }).pipe( |
| 208 | Effect.repeat({ schedule: slowRelease, until: (n) => n === 0 }), |
| 209 | Effect.flatMap((n) => |
| 210 | n === 0 |
| 211 | ? Effect.void |
| 212 | : Effect.fail( |
| 213 | new OrphanStillPresent({ kind: `network interfaces in ${vpcId}` }), |
| 214 | ), |
| 215 | ), |
| 216 | ); |
| 217 | }); |
| 218 | |
| 219 | const reapVpc = Effect.fn(function* (vpcId: string) { |
| 220 | yield* Effect.logInfo(`DMS reap: tearing down VPC ${vpcId}`); |
| 221 | yield* sweepNetworkInterfaces(vpcId); |
| 222 | |
| 223 | // Internet gateways: detach, then delete. |
| 224 | const igws = yield* ec2 |
| 225 | .describeInternetGateways({ |
| 226 | Filters: [{ Name: "attachment.vpc-id", Values: [vpcId] }], |
| 227 | }) |
| 228 | .pipe( |
| 229 | Effect.map((response) => |
| 230 | (response.InternetGateways ?? []).flatMap((igw) => |
| 231 | igw.InternetGatewayId ? [igw.InternetGatewayId] : [], |
| 232 | ), |
| 233 | ), |
| 234 | ); |
| 235 | yield* Effect.forEach( |
| 236 | igws, |
| 237 | (InternetGatewayId) => |
| 238 | Effect.logInfo( |
| 239 | `DMS reap: detaching + deleting IGW ${InternetGatewayId}`, |
| 240 | ).pipe( |
| 241 | Effect.andThen( |
| 242 | ec2 |
| 243 | .detachInternetGateway({ InternetGatewayId, VpcId: vpcId }) |
| 244 | .pipe( |
| 245 | Effect.catchTag( |
| 246 | ["Gateway.NotAttached", "InvalidInternetGatewayID.NotFound"], |
| 247 | () => Effect.void, |
| 248 | ), |
| 249 | ), |
| 250 | ), |
| 251 | Effect.andThen(ec2.deleteInternetGateway({ InternetGatewayId })), |
| 252 | Effect.retry({ |
| 253 | while: (e): boolean => e._tag === "DependencyViolation", |
| 254 | schedule: dependencyRelease, |
| 255 | }), |
| 256 | Effect.catchTag("InvalidInternetGatewayID.NotFound", () => Effect.void), |
| 257 | ), |
| 258 | { discard: true }, |
| 259 | ); |
| 260 | |
| 261 | // Non-main route tables: disassociate subnet associations, then delete. |
| 262 | const routeTables = yield* ec2 |
| 263 | .describeRouteTables({ |
| 264 | Filters: [{ Name: "vpc-id", Values: [vpcId] }], |
| 265 | }) |
| 266 | .pipe(Effect.map((response) => response.RouteTables ?? [])); |
| 267 | yield* Effect.forEach( |
| 268 | routeTables, |
| 269 | (table) => |
| 270 | Effect.gen(function* () { |
| 271 | const associations = table.Associations ?? []; |
| 272 | if (associations.some((a): boolean => a.Main === true)) return; |
| 273 | yield* Effect.forEach( |
| 274 | associations.flatMap((a) => |
| 275 | a.RouteTableAssociationId ? [a.RouteTableAssociationId] : [], |
| 276 | ), |
| 277 | (AssociationId) => |
| 278 | ec2 |
| 279 | .disassociateRouteTable({ AssociationId }) |
| 280 | .pipe( |
| 281 | Effect.catchTag( |
| 282 | "InvalidAssociationID.NotFound", |
| 283 | () => Effect.void, |
| 284 | ), |
| 285 | ), |
| 286 | { discard: true }, |
| 287 | ); |
| 288 | if (table.RouteTableId) { |
| 289 | yield* Effect.logInfo( |
| 290 | `DMS reap: deleting route table ${table.RouteTableId}`, |
| 291 | ).pipe( |
| 292 | Effect.andThen( |
| 293 | ec2.deleteRouteTable({ RouteTableId: table.RouteTableId }), |
| 294 | ), |
| 295 | Effect.retry({ |
| 296 | while: (e): boolean => e._tag === "DependencyViolation", |
| 297 | schedule: dependencyRelease, |
| 298 | }), |
| 299 | Effect.catchTag("InvalidRouteTableID.NotFound", () => Effect.void), |
| 300 | ); |
| 301 | } |
| 302 | }), |
| 303 | { discard: true }, |
| 304 | ); |
| 305 | |
| 306 | // Non-default security groups. |
| 307 | const groups = yield* ec2 |
| 308 | .describeSecurityGroups({ |
| 309 | Filters: [{ Name: "vpc-id", Values: [vpcId] }], |
| 310 | }) |
| 311 | .pipe( |
| 312 | Effect.map((response) => |
| 313 | (response.SecurityGroups ?? []).filter( |
| 314 | (group): boolean => group.GroupName !== "default" && !!group.GroupId, |
| 315 | ), |
| 316 | ), |
| 317 | ); |
| 318 | yield* Effect.forEach( |
| 319 | groups, |
| 320 | (group) => |
| 321 | Effect.logInfo( |
| 322 | `DMS reap: deleting security group ${group.GroupId} (${group.GroupName})`, |
| 323 | ).pipe( |
| 324 | Effect.andThen(ec2.deleteSecurityGroup({ GroupId: group.GroupId })), |
| 325 | Effect.retry({ |
| 326 | while: (e): boolean => e._tag === "DependencyViolation", |
| 327 | schedule: dependencyRelease, |
| 328 | }), |
| 329 | Effect.catchTag("InvalidGroup.NotFound", () => Effect.void), |
| 330 | ), |
| 331 | { discard: true }, |
| 332 | ); |
| 333 | |
| 334 | // Subnets. |
| 335 | const subnets = yield* ec2 |
| 336 | .describeSubnets({ Filters: [{ Name: "vpc-id", Values: [vpcId] }] }) |
| 337 | .pipe( |
| 338 | Effect.map((response) => |
| 339 | (response.Subnets ?? []).flatMap((subnet) => |
| 340 | subnet.SubnetId ? [subnet.SubnetId] : [], |
| 341 | ), |
| 342 | ), |
| 343 | ); |
| 344 | yield* Effect.forEach( |
| 345 | subnets, |
| 346 | (SubnetId) => |
| 347 | Effect.logInfo(`DMS reap: deleting subnet ${SubnetId}`).pipe( |
| 348 | Effect.andThen(ec2.deleteSubnet({ SubnetId })), |
| 349 | Effect.retry({ |
| 350 | while: (e): boolean => e._tag === "DependencyViolation", |
| 351 | schedule: dependencyRelease, |
| 352 | }), |
| 353 | Effect.catchTag("InvalidSubnetID.NotFound", () => Effect.void), |
| 354 | ), |
| 355 | { discard: true }, |
| 356 | ); |
| 357 | |
| 358 | yield* ec2.deleteVpc({ VpcId: vpcId }).pipe( |
| 359 | Effect.retry({ |
| 360 | while: (e): boolean => e._tag === "DependencyViolation", |
| 361 | schedule: dependencyRelease, |
| 362 | }), |
| 363 | Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void), |
| 364 | ); |
| 365 | }); |
| 366 | |
| 367 | /** |
| 368 | * Delete every leftover DMS fixture resource, in dependency order. |
| 369 | * Idempotent: a run against a clean account is a single `describeVpcs`. |
| 370 | * |
| 371 | * Requires the AWS provider environment (available inside `test.provider` |
| 372 | * bodies; wrap with `Core.withProviders` elsewhere). |
| 373 | */ |
| 374 | export const reapDmsOrphans = Effect.gen(function* () { |
| 375 | const vpcIdList = yield* findTestVpcIds; |
| 376 | if (vpcIdList.length === 0) return; |
| 377 | const vpcIds: ReadonlySet<string> = new Set(vpcIdList); |
| 378 | yield* reapReplicationInstances(vpcIds); |
| 379 | yield* reapSubnetGroups(vpcIds); |
| 380 | yield* Effect.forEach(vpcIdList, reapVpc, { discard: true }); |
| 381 | }); |