1import * as AWS from "@/AWS";
2import { ReplicationInstance, ReplicationSubnetGroup } from "@/AWS/DMS";
3import { Subnet, Vpc } from "@/AWS/EC2";
4import { AWSEnvironment } from "@/AWS/Environment";
5import * as Test from "@/Test/Alchemy";
6import * as dms from "@distilled.cloud/aws/database-migration-service";
7import * as ec2 from "@distilled.cloud/aws/ec2";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import { reapDmsOrphans } from "./reap.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Ungated typed-error probe: proves the distilled error union carries the
16// not-found tag this provider's read/delete paths depend on.
17test.provider(
18 "deleteReplicationInstance on a nonexistent instance fails with ResourceNotFoundFault",
19 () =>
20 Effect.gen(function* () {
21 const { accountId, region } = yield* AWSEnvironment.current;
22 const error = yield* Effect.flip(
23 dms.deleteReplicationInstance({
24 ReplicationInstanceArn: `arn:aws:dms:${region}:${accountId}:rep:AAAAAAAAAAAAAAAAAAAAAAAAAA`,
25 }),
26 );
27 expect(error._tag).toBe("ResourceNotFoundFault");
28 }),
29 { tags: ["provider:aws", "provider:aws:dms", "live"] },
30);
31
32const findInstance = (identifier: string) =>
33 dms
34 .describeReplicationInstances({
35 Filters: [{ Name: "replication-instance-id", Values: [identifier] }],
36 })
37 .pipe(
38 Effect.map((r) => r.ReplicationInstances?.[0]),
39 Effect.catchTag("ResourceNotFoundFault", () => Effect.succeed(undefined)),
40 );
41
42// A replication instance takes ~6-10 minutes to provision, a similar time to
43// delete, and is billed hourly while it exists. The full lifecycle is gated
44// behind AWS_TEST_SLOW=1 and always destroys what it created.
45test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
46 "create replication instance in a subnet group, destroy, verify gone",
47 (stack) =>
48 Effect.gen(function* () {
49 yield* stack.destroy();
50 // A previous hard-killed run (in-memory scratch state) may have
51 // orphaned the VPC fixture — reap it out-of-band before deploying.
52 yield* reapDmsOrphans;
53
54 const azResult = yield* ec2.describeAvailabilityZones({});
55 const azs = (azResult.AvailabilityZones ?? [])
56 .filter((az) => az.State === "available")
57 .map((az) => az.ZoneName!);
58 const [az1, az2] = azs;
59
60 const { instance } = yield* stack.deploy(
61 Effect.gen(function* () {
62 const vpc = yield* Vpc("DmsInstVpc", { cidrBlock: "10.92.0.0/16" });
63 const subnetA = yield* Subnet("DmsInstSubnetA", {
64 vpcId: vpc.vpcId,
65 cidrBlock: "10.92.1.0/24",
66 availabilityZone: az1,
67 });
68 const subnetB = yield* Subnet("DmsInstSubnetB", {
69 vpcId: vpc.vpcId,
70 cidrBlock: "10.92.2.0/24",
71 availabilityZone: az2,
72 });
73 const subnetGroup = yield* ReplicationSubnetGroup("InstGroup", {
74 description: "alchemy dms instance test",
75 subnetIds: [subnetA.subnetId, subnetB.subnetId],
76 });
77 const instance = yield* ReplicationInstance("Instance", {
78 replicationInstanceClass: "dms.t3.micro",
79 allocatedStorage: 20,
80 replicationSubnetGroupIdentifier:
81 subnetGroup.replicationSubnetGroupIdentifier,
82 publiclyAccessible: false,
83 multiAZ: false,
84 });
85 return { instance };
86 }),
87 );
88
89 expect(instance.replicationInstanceArn).toContain(":rep:");
90 expect(instance.replicationInstanceClass).toBe("dms.t3.micro");
91 expect(instance.status).toBe("available");
92
93 const observed = yield* findInstance(
94 instance.replicationInstanceIdentifier,
95 );
96 expect(observed?.ReplicationInstanceArn).toBe(
97 instance.replicationInstanceArn,
98 );
99
100 // Destroy immediately — the instance bills hourly — and verify deletion
101 // has at least initiated (full disappearance takes several more minutes).
102 yield* stack.destroy();
103 const status = yield* findInstance(
104 instance.replicationInstanceIdentifier,
105 ).pipe(
106 Effect.map((i) => i?.ReplicationInstanceStatus ?? "gone"),
107 Effect.retry({
108 schedule: Schedule.max([
109 Schedule.fixed("10 seconds"),
110 Schedule.recurs(12),
111 ]),
112 }),
113 );
114 expect(["gone", "deleting"]).toContain(status);
115 }).pipe(
116 // Converge to zero leftovers even when the body (or the engine's own
117 // scratch destroy) fails: the reaper waits out the instance deletion
118 // and tears the VPC fixture down out-of-band with idempotent typed
119 // calls. `orDie` — a finalizer must not swallow its own failure.
120 Effect.ensuring(reapDmsOrphans.pipe(Effect.orDie)),
121 ),
122 {
123 tags: ["provider:aws", "provider:aws:dms", "provider:aws:ec2", "live"],
124 timeout: 1_500_000,
125 },
126);