1import * as AWS from "@/AWS";
2import { Cluster, SubnetGroup } from "@/AWS/DAX";
3import { Role } from "@/AWS/IAM/Role.ts";
4import * as Test from "@/Test/Alchemy";
5import * as dax from "@distilled.cloud/aws/dax";
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 { getDefaultVpc } from "../DefaultVpc.ts";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Ungated typed-error probe: prove the distilled error union carries the
15// not-found tag this provider's read/delete paths depend on. Runs in every
16// CI pass at near-zero cost, unlike the gated lifecycle below.
17test.provider(
18 "describeClusters on a nonexistent cluster fails with ClusterNotFoundFault",
19 () =>
20 Effect.gen(function* () {
21 const error = yield* Effect.flip(
22 dax.describeClusters({
23 ClusterNames: ["alchemy-nonexistent-dax-cluster-probe"],
24 }),
25 );
26 expect(error._tag).toBe("ClusterNotFoundFault");
27 }),
28 { tags: ["provider:aws", "provider:aws:dax", "live"] },
29);
30
31// Resolve two default-for-AZ subnets and the default security group.
32const defaultNetwork = Effect.gen(function* () {
33 const vpc = yield* getDefaultVpc;
34 const subnets = yield* EC2.describeSubnets({
35 Filters: [
36 { Name: "vpc-id", Values: [vpc.vpcId] },
37 { Name: "default-for-az", Values: ["true"] },
38 ],
39 });
40 // DAX is not offered in every AZ (e.g. us-west-2d) — pick subnets from the
41 // region's first three AZs (suffix a/b/c) only.
42 const subnetIds = (subnets.Subnets ?? [])
43 .filter((s) => /[abc]$/.test(s.AvailabilityZone ?? ""))
44 .sort((l, r) =>
45 (l.AvailabilityZone ?? "").localeCompare(r.AvailabilityZone ?? ""),
46 )
47 .map((s) => s.SubnetId)
48 .filter((id): id is `subnet-${string}` => id !== undefined)
49 .slice(0, 2);
50 const groups = yield* EC2.describeSecurityGroups({
51 Filters: [
52 { Name: "vpc-id", Values: [vpc.vpcId] },
53 { Name: "group-name", Values: ["default"] },
54 ],
55 });
56 const securityGroupId = groups.SecurityGroups?.[0]?.GroupId;
57 if (subnetIds.length < 2 || securityGroupId === undefined) {
58 return yield* Effect.die(
59 new Error("default VPC is missing subnets or its default security group"),
60 );
61 }
62 return { subnetIds, securityGroupIds: [securityGroupId] };
63});
64
65// Deletion is verified as INITIATED (status `deleting`, irreversible) or
66// fully gone. Full disappearance takes several more minutes server-side.
67const assertClusterDeleting = (name: string) =>
68 Effect.gen(function* () {
69 const status = yield* dax.describeClusters({ ClusterNames: [name] }).pipe(
70 Effect.map((r) => r.Clusters?.[0]?.Status ?? "gone"),
71 Effect.catchTag("ClusterNotFoundFault", () =>
72 Effect.succeed("gone" as const),
73 ),
74 );
75 if (status !== "gone" && status !== "deleting") {
76 return yield* Effect.fail(
77 new Error(`cluster '${name}' still exists (status: ${status})`),
78 );
79 }
80 }).pipe(
81 Effect.retry({
82 schedule: Schedule.max([
83 Schedule.fixed("10 seconds"),
84 Schedule.recurs(18),
85 ]),
86 }),
87 );
88
89// A DAX cluster takes ~5-10 minutes to provision and bills per node-hour
90// while it exists. The full lifecycle is gated behind AWS_TEST_SLOW=1 and
91// always destroys what it created.
92test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
93 "create DAX cluster (dax.t3.small), verify, destroy",
94 (stack) =>
95 Effect.gen(function* () {
96 yield* stack.destroy();
97
98 const network = yield* defaultNetwork;
99
100 const { cluster } = yield* stack.deploy(
101 Effect.gen(function* () {
102 const role = yield* Role("DaxRole", {
103 assumeRolePolicyDocument: {
104 Version: "2012-10-17",
105 Statement: [
106 {
107 Effect: "Allow",
108 Principal: { Service: "dax.amazonaws.com" },
109 Action: ["sts:AssumeRole"],
110 },
111 ],
112 },
113 managedPolicyArns: [
114 "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess",
115 ],
116 });
117 const subnetGroup = yield* SubnetGroup("Subnets", {
118 description: "alchemy dax cluster subnets",
119 subnetIds: network.subnetIds,
120 });
121 const cluster = yield* Cluster("Cache", {
122 nodeType: "dax.t3.small",
123 replicationFactor: 1,
124 iamRoleArn: role.roleArn,
125 subnetGroupName: subnetGroup.subnetGroupName,
126 securityGroupIds: network.securityGroupIds,
127 description: "alchemy dax test cluster",
128 tags: { fixture: "dax-cluster" },
129 });
130 return { cluster };
131 }),
132 );
133
134 expect(cluster.clusterName).toBeDefined();
135 expect(cluster.clusterArn).toContain(":cache/");
136 expect(cluster.status).toBe("available");
137 expect(cluster.nodeType).toBe("dax.t3.small");
138 expect(cluster.totalNodes).toBe(1);
139 expect(cluster.discoveryEndpointAddress).toBeDefined();
140 expect(cluster.discoveryEndpointPort).toBeDefined();
141
142 // Out-of-band verification via distilled.
143 const described = yield* dax.describeClusters({
144 ClusterNames: [cluster.clusterName],
145 });
146 const observed = described.Clusters?.[0];
147 expect(observed?.Status).toBe("available");
148 expect(observed?.NodeType).toBe("dax.t3.small");
149 expect(observed?.TotalNodes).toBe(1);
150
151 // Destroy immediately — clusters bill while they exist — and verify
152 // deletion was initiated out-of-band.
153 yield* stack.destroy();
154 yield* assertClusterDeleting(cluster.clusterName);
155 }),
156 // cluster create (~10 min) + delete initiation, one test.
157 {
158 tags: [
159 "provider:aws",
160 "provider:aws:dax",
161 "provider:aws:ec2",
162 "provider:aws:iam",
163 "live",
164 ],
165 timeout: 1_500_000,
166 },
167);