1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as dax from "@distilled.cloud/aws/dax";
5import * as EC2 from "@distilled.cloud/aws/ec2";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import { getDefaultVpc } from "../DefaultVpc.ts";
11
12import DAXTestFunctionLive, { DAXTestFunction } from "./handler";
13import DAXSlowTestFunctionLive, {
14 DAXSlowTestFunction,
15 SLOW_SUBNET_GROUP_NAME,
16} from "./slow-handler";
17
18const testOptions = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(testOptions);
20const sharedStack = Core.scratchStack(testOptions, "DAXBindings");
21
22// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
23// over 60s on a fresh deploy.
24const readinessPolicy = Schedule.max([
25 Schedule.fixed("2 seconds"),
26 Schedule.recurs(75),
27]);
28
29let baseUrl: string;
30
31const getJson = (path: string) =>
32 HttpClient.get(`${baseUrl}${path}`).pipe(
33 Effect.flatMap((response) =>
34 response.status >= 500
35 ? Effect.fail(new Error(`transient upstream ${response.status}`))
36 : Effect.succeed(response),
37 ),
38 Effect.retry({
39 schedule: Schedule.max([
40 Schedule.exponential("500 millis"),
41 Schedule.recurs(6),
42 ]),
43 }),
44 Effect.flatMap((r) => r.json),
45 );
46
47describe.sequential(
48 "DAX Bindings",
49 { tags: ["provider:aws", "provider:aws:dax", "provider:aws:lambda", "live"] },
50 () => {
51 beforeAll(
52 Effect.gen(function* () {
53 yield* Effect.logInfo("DAX test setup: destroying previous resources");
54 yield* sharedStack.destroy();
55
56 yield* Effect.logInfo("DAX test setup: deploying fixture");
57 const { functionUrl } = yield* sharedStack.deploy(
58 Effect.gen(function* () {
59 return yield* DAXTestFunction;
60 }).pipe(Effect.provide(DAXTestFunctionLive)),
61 );
62
63 expect(functionUrl).toBeTruthy();
64 baseUrl = functionUrl!.replace(/\/+$/, "");
65
66 const readinessUrl = `${baseUrl}/bindings`;
67 yield* HttpClient.get(readinessUrl).pipe(
68 Effect.flatMap((response) =>
69 response.status === 200
70 ? Effect.succeed(response)
71 : Effect.fail(
72 new Error(`Function not ready: ${response.status}`),
73 ),
74 ),
75 Effect.retry({ schedule: readinessPolicy }),
76 );
77 }),
78 { timeout: 240_000 },
79 );
80
81 afterAll(sharedStack.destroy(), { timeout: 120_000 });
82
83 describe("binding registration", () => {
84 test.provider("both capabilities initialize in the runtime", (_stack) =>
85 Effect.gen(function* () {
86 const response = yield* getJson("/bindings");
87 expect((response as any).bound).toHaveLength(2);
88 }),
89 );
90 });
91
92 describe("DescribeClusters", () => {
93 test.provider(
94 "surfaces the typed not-found tag for a nonexistent cluster",
95 (_stack) =>
96 Effect.gen(function* () {
97 const response = yield* getJson("/clusters");
98 expect((response as any).tag).toBe("ClusterNotFoundFault");
99 }),
100 );
101 });
102
103 describe("DescribeEvents", () => {
104 test.provider("lists the account's recent DAX events", (_stack) =>
105 Effect.gen(function* () {
106 const response = yield* getJson("/events");
107 expect((response as any).count).toBeGreaterThanOrEqual(0);
108 }),
109 );
110 });
111 },
112);
113
114// ---------------------------------------------------------------------------
115// Cluster-scoped bindings (ConnectReadWrite, RebootNode) need a real DAX
116// cluster (~10 min to provision, billed per node-hour) — gated behind
117// AWS_TEST_SLOW=1 like the Cluster lifecycle test.
118// ---------------------------------------------------------------------------
119
120const slowStack = Core.scratchStack(testOptions, "DAXSlowBindings");
121
122// Resolve two default-for-AZ subnets from the region's first three AZs (DAX
123// is not offered in every AZ, e.g. us-west-2d).
124const defaultSubnetIds = Effect.gen(function* () {
125 const vpc = yield* getDefaultVpc;
126 const subnets = yield* EC2.describeSubnets({
127 Filters: [
128 { Name: "vpc-id", Values: [vpc.vpcId] },
129 { Name: "default-for-az", Values: ["true"] },
130 ],
131 });
132 const subnetIds = (subnets.Subnets ?? [])
133 .filter((s) => /[abc]$/.test(s.AvailabilityZone ?? ""))
134 .sort((l, r) =>
135 (l.AvailabilityZone ?? "").localeCompare(r.AvailabilityZone ?? ""),
136 )
137 .map((s) => s.SubnetId)
138 .filter((id): id is `subnet-${string}` => id !== undefined)
139 .slice(0, 2);
140 if (subnetIds.length < 2) {
141 return yield* Effect.die(new Error("default VPC is missing subnets"));
142 }
143 return subnetIds;
144});
145
146// The fixture module cannot look up subnet ids, so the subnet group is
147// managed out-of-band under a deterministic name.
148const ensureSubnetGroup = (subnetIds: string[]) =>
149 dax
150 .createSubnetGroup({
151 SubnetGroupName: SLOW_SUBNET_GROUP_NAME,
152 Description: "alchemy dax bindings test subnets",
153 SubnetIds: subnetIds,
154 })
155 .pipe(Effect.catchTag("SubnetGroupAlreadyExistsFault", () => Effect.void));
156
157// The subnet group is only deletable once the cluster is fully gone —
158// deletion takes several minutes after destroy initiates it, so retry
159// through SubnetGroupInUseFault (gated test: generous bounded budget).
160const deleteSubnetGroup = dax
161 .deleteSubnetGroup({ SubnetGroupName: SLOW_SUBNET_GROUP_NAME })
162 .pipe(
163 Effect.catchTag("SubnetGroupNotFoundFault", () => Effect.void),
164 Effect.retry({
165 while: (e) => e._tag === "SubnetGroupInUseFault",
166 schedule: Schedule.max([
167 Schedule.fixed("15 seconds"),
168 Schedule.recurs(60),
169 ]),
170 }),
171 );
172
173test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
174 "connect + scaling + reboot-node bindings against a live cluster",
175 () =>
176 Effect.gen(function* () {
177 yield* slowStack.destroy();
178 const subnetIds = yield* defaultSubnetIds;
179 yield* ensureSubnetGroup(subnetIds);
180
181 yield* Effect.gen(function* () {
182 const { functionUrl } = yield* slowStack.deploy(
183 Effect.gen(function* () {
184 return yield* DAXSlowTestFunction;
185 }).pipe(Effect.provide(DAXSlowTestFunctionLive)),
186 );
187 expect(functionUrl).toBeTruthy();
188 const slowBaseUrl = functionUrl!.replace(/\/+$/, "");
189
190 const get = (path: string) =>
191 HttpClient.get(`${slowBaseUrl}${path}`).pipe(
192 Effect.retry({
193 schedule: Schedule.max([
194 Schedule.exponential("500 millis"),
195 Schedule.recurs(10),
196 ]),
197 }),
198 Effect.flatMap((r) => r.json),
199 );
200
201 // Connection info resolves from both the runtime attributes and the
202 // published environment variables.
203 const connect = (yield* get("/connect")) as {
204 info: { host: string; port: number; url: string; tls: boolean };
205 env: { host?: string; port?: string; url?: string; tls?: string };
206 };
207 expect(connect.info.host).toBeTruthy();
208 expect(connect.info.port).toBe(8111);
209 expect(connect.info.url).toContain("dax://");
210 expect(connect.info.tls).toBe(false);
211 expect(connect.env.host).toBe(connect.info.host);
212 expect(connect.env.port).toBe(String(connect.info.port));
213 expect(connect.env.url).toBe(connect.info.url);
214 expect(connect.env.tls).toBe("false");
215
216 // Scaling bindings: invalid target factors reach service-side
217 // validation and surface typed tags — proves the per-cluster IAM
218 // grant and the ClusterName injection for both bindings.
219 const scale = (yield* get("/scale-probe")) as {
220 increaseTag: string;
221 decreaseTag: string;
222 };
223 expect([
224 "InvalidParameterValueException",
225 "InvalidClusterStateFault",
226 ]).toContain(scale.increaseTag);
227 expect([
228 "InvalidParameterValueException",
229 "InvalidClusterStateFault",
230 ]).toContain(scale.decreaseTag);
231
232 // Reboot the cluster's only node and observe it transition.
233 const nodes = (yield* get("/nodes")) as { nodeIds: string[] };
234 expect(nodes.nodeIds.length).toBe(1);
235 const reboot = (yield* get(`/reboot?nodeId=${nodes.nodeIds[0]}`)) as {
236 nodeStatus?: string;
237 };
238 expect(reboot.nodeStatus).toBeDefined();
239 }).pipe(
240 Effect.ensuring(
241 slowStack
242 .destroy()
243 .pipe(Effect.andThen(deleteSubnetGroup), Effect.orDie),
244 ),
245 );
246 }),
247 // cluster create (~10 min) + probes + delete (~10 min) in one test.
248 {
249 tags: [
250 "provider:aws",
251 "provider:aws:dax",
252 "provider:aws:ec2",
253 "provider:aws:iam",
254 "provider:aws:lambda",
255 "live",
256 ],
257 timeout: 2_400_000,
258 },
259);