1import * as AWS from "@/AWS";
2import { ReplicationSubnetGroup } from "@/AWS/DMS";
3import { Subnet, Vpc } from "@/AWS/EC2";
4import * as Test from "@/Test/Alchemy";
5import * as dms from "@distilled.cloud/aws/database-migration-service";
6import * as ec2 from "@distilled.cloud/aws/ec2";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { reapDmsOrphans } from "./reap.ts";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const findGroup = (identifier: string) =>
15 dms
16 .describeReplicationSubnetGroups({
17 Filters: [{ Name: "replication-subnet-group-id", Values: [identifier] }],
18 })
19 .pipe(
20 Effect.map((r) => r.ReplicationSubnetGroups?.[0]),
21 Effect.catchTag("ResourceNotFoundFault", () => Effect.succeed(undefined)),
22 );
23
24const assertGone = (identifier: string) =>
25 findGroup(identifier).pipe(
26 Effect.flatMap((group) =>
27 group === undefined
28 ? Effect.void
29 : Effect.fail(new Error(`subnet group '${identifier}' still exists`)),
30 ),
31 Effect.retry({
32 schedule: Schedule.max([
33 Schedule.fixed("2 seconds"),
34 Schedule.recurs(15),
35 ]),
36 }),
37 );
38
39test.provider(
40 "create, update, delete a DMS replication subnet group",
41 (stack) =>
42 Effect.gen(function* () {
43 yield* stack.destroy();
44 // A previous hard-killed run (in-memory scratch state) may have
45 // orphaned the VPC fixture — reap it out-of-band before deploying.
46 yield* reapDmsOrphans;
47
48 // Resolve three AZs so the update can swap the subnet set.
49 const azResult = yield* ec2.describeAvailabilityZones({});
50 const azs = (azResult.AvailabilityZones ?? [])
51 .filter((az) => az.State === "available")
52 .map((az) => az.ZoneName!);
53 const [az1, az2, az3] = azs;
54
55 const { group, subnetCId } = yield* stack.deploy(
56 Effect.gen(function* () {
57 const vpc = yield* Vpc("DmsVpc", { cidrBlock: "10.91.0.0/16" });
58 const subnetA = yield* Subnet("DmsSubnetA", {
59 vpcId: vpc.vpcId,
60 cidrBlock: "10.91.1.0/24",
61 availabilityZone: az1,
62 });
63 const subnetB = yield* Subnet("DmsSubnetB", {
64 vpcId: vpc.vpcId,
65 cidrBlock: "10.91.2.0/24",
66 availabilityZone: az2,
67 });
68 const subnetC = yield* Subnet("DmsSubnetC", {
69 vpcId: vpc.vpcId,
70 cidrBlock: "10.91.3.0/24",
71 availabilityZone: az3,
72 });
73 const group = yield* ReplicationSubnetGroup("Group", {
74 description: "alchemy dms test subnets",
75 subnetIds: [subnetA.subnetId, subnetB.subnetId],
76 tags: { env: "test" },
77 });
78 return {
79 group,
80 subnetCId: subnetC.subnetId.as<string>(),
81 };
82 }),
83 );
84
85 expect(group.replicationSubnetGroupArn).toContain(":subgrp:");
86 expect(group.subnetIds.length).toBe(2);
87 expect(group.tags.env).toBe("test");
88
89 // Out-of-band verification via distilled.
90 const observed = yield* findGroup(group.replicationSubnetGroupIdentifier);
91 expect(observed?.SubnetGroupStatus).toBe("Complete");
92 expect(observed?.Subnets?.length).toBe(2);
93
94 // Update: swap one subnet + change the description.
95 const { group: updated } = yield* stack.deploy(
96 Effect.gen(function* () {
97 const vpc = yield* Vpc("DmsVpc", { cidrBlock: "10.91.0.0/16" });
98 const subnetA = yield* Subnet("DmsSubnetA", {
99 vpcId: vpc.vpcId,
100 cidrBlock: "10.91.1.0/24",
101 availabilityZone: az1,
102 });
103 yield* Subnet("DmsSubnetB", {
104 vpcId: vpc.vpcId,
105 cidrBlock: "10.91.2.0/24",
106 availabilityZone: az2,
107 });
108 const subnetC = yield* Subnet("DmsSubnetC", {
109 vpcId: vpc.vpcId,
110 cidrBlock: "10.91.3.0/24",
111 availabilityZone: az3,
112 });
113 const group = yield* ReplicationSubnetGroup("Group", {
114 description: "alchemy dms test subnets (updated)",
115 subnetIds: [subnetA.subnetId, subnetC.subnetId],
116 tags: { env: "test" },
117 });
118 return { group };
119 }),
120 );
121
122 // Same subnet group (in-place modify).
123 expect(updated.replicationSubnetGroupIdentifier).toBe(
124 group.replicationSubnetGroupIdentifier,
125 );
126 const observed2 = yield* findGroup(
127 group.replicationSubnetGroupIdentifier,
128 );
129 const observedSubnetIds = (observed2?.Subnets ?? [])
130 .map((s) => s.SubnetIdentifier)
131 .filter((s): s is string => typeof s === "string");
132 expect(observedSubnetIds).toContain(subnetCId);
133 expect(observed2?.ReplicationSubnetGroupDescription).toBe(
134 "alchemy dms test subnets (updated)",
135 );
136
137 yield* stack.destroy();
138 yield* assertGone(group.replicationSubnetGroupIdentifier);
139 }).pipe(
140 // Converge to zero leftovers even when the body (or the engine's own
141 // scratch destroy) fails: the reaper deletes the VPC fixture out-of-band
142 // with idempotent typed calls. `orDie` — a finalizer must not swallow
143 // its own failure silently.
144 Effect.ensuring(reapDmsOrphans.pipe(Effect.orDie)),
145 ),
146 {
147 tags: ["provider:aws", "provider:aws:dms", "provider:aws:ec2", "live"],
148 timeout: 300_000,
149 },
150);