1import * as dms from "@distilled.cloud/aws/database-migration-service";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * 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). */
36const VPC_CIDRS = ["10.91.0.0/16", "10.92.0.0/16"];
37
38class OrphanStillPresent extends Data.TaggedError("OrphanStillPresent")<{
39 readonly kind: string;
40}> {}
41
42/** ~10 minute ceiling for slow asynchronous releases (DMS instance deletes, ENIs). */
43const slowRelease = Schedule.max([
44 Schedule.spaced("15 seconds"),
45 Schedule.recurs(40),
46]);
47/** ~2 minute ceiling for ordinary dependency-violation races. */
48const dependencyRelease = Schedule.max([
49 Schedule.spaced("10 seconds"),
50 Schedule.recurs(12),
51]);
52
53const 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. */
62const 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 */
81const 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. */
133const 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 */
176const 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
219const 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 */
374export 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});