1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import FSxBindingsFunctionLive, {
11 FSxBindingsFunction,
12} from "./bindings-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "FSxBindings");
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy under parallel-suite load.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// The shared Lambda fixture occasionally answers a transient 5xx under load
33// (cold re-init, IAM propagation on the freshly attached policy that the
34// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
35// 4xx/assertion failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58const getJson = (path: string) =>
59 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
60 Effect.flatMap((r) => r.json),
61 );
62
63const postJson = (path: string) =>
64 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
65 Effect.flatMap((r) => r.json),
66 );
67
68describe.sequential(
69 "FSx Bindings",
70 { tags: ["provider:aws", "provider:aws:fsx", "provider:aws:lambda", "live"] },
71 () => {
72 beforeAll(
73 Effect.gen(function* () {
74 yield* Effect.logInfo("FSx test setup: destroying previous resources");
75 yield* sharedStack.destroy();
76
77 yield* Effect.logInfo("FSx test setup: deploying fixture");
78 const { functionUrl } = yield* sharedStack.deploy(
79 Effect.gen(function* () {
80 return yield* FSxBindingsFunction;
81 }).pipe(Effect.provide(FSxBindingsFunctionLive)),
82 );
83
84 expect(functionUrl).toBeTruthy();
85 baseUrl = functionUrl!.replace(/\/+$/, "");
86
87 const readinessUrl = `${baseUrl}/bindings`;
88 yield* Effect.logInfo(
89 `FSx test setup: probing readiness at ${readinessUrl}`,
90 );
91 yield* HttpClient.get(readinessUrl).pipe(
92 Effect.flatMap((response) =>
93 response.status === 200
94 ? Effect.succeed(response)
95 : Effect.fail(
96 new Error(`Function not ready: ${response.status}`),
97 ),
98 ),
99 Effect.tapError((error) =>
100 Effect.logWarning(
101 `FSx test setup: fixture not ready yet (${String(error)})`,
102 ),
103 ),
104 Effect.retry({ schedule: readinessPolicy }),
105 );
106 }),
107 { timeout: 240_000 },
108 );
109
110 afterAll(sharedStack.destroy(), { timeout: 240_000 });
111
112 describe("binding registration", () => {
113 test.provider("all 14 capabilities initialize in the runtime", (_stack) =>
114 Effect.gen(function* () {
115 const response = (yield* getJson("/bindings")) as any;
116 expect(response.bound).toHaveLength(14);
117 }),
118 );
119 });
120
121 describe("DescribeBackups", () => {
122 test.provider("lists the account's FSx backups", (_stack) =>
123 Effect.gen(function* () {
124 const response = (yield* getJson("/backups")) as any;
125 expect(typeof response.count).toBe("number");
126 }),
127 );
128 });
129
130 describe("DescribeSnapshots", () => {
131 test.provider("lists the account's OpenZFS snapshots", (_stack) =>
132 Effect.gen(function* () {
133 const response = (yield* getJson("/snapshots")) as any;
134 expect(typeof response.count).toBe("number");
135 }),
136 );
137 });
138
139 describe("DescribeVolumes", () => {
140 test.provider("lists the account's ONTAP/OpenZFS volumes", (_stack) =>
141 Effect.gen(function* () {
142 const response = (yield* getJson("/volumes")) as any;
143 expect(typeof response.count).toBe("number");
144 }),
145 );
146 });
147
148 describe("DescribeStorageVirtualMachines", () => {
149 test.provider("lists the account's ONTAP SVMs", (_stack) =>
150 Effect.gen(function* () {
151 const response = (yield* getJson("/svms")) as any;
152 expect(typeof response.count).toBe("number");
153 }),
154 );
155 });
156
157 describe("DescribeDataRepositoryTasks", () => {
158 test.provider("lists the account's data repository tasks", (_stack) =>
159 Effect.gen(function* () {
160 const response = (yield* getJson("/dr-tasks")) as any;
161 expect(typeof response.count).toBe("number");
162 }),
163 );
164 });
165
166 describe("DescribeDataRepositoryAssociations", () => {
167 test.provider(
168 "lists the account's data repository associations",
169 (_stack) =>
170 Effect.gen(function* () {
171 const response = (yield* getJson("/dr-associations")) as any;
172 expect(typeof response.count).toBe("number");
173 }),
174 );
175 });
176
177 describe("DeleteBackup", () => {
178 test.provider(
179 "returns the typed BackupNotFound for a missing backup",
180 (_stack) =>
181 Effect.gen(function* () {
182 const response = (yield* postJson("/backup/delete-missing")) as any;
183 expect(response.tag).toBe("BackupNotFound");
184 }),
185 );
186 });
187
188 describe("CopyBackup", () => {
189 test.provider(
190 "returns the typed BackupNotFound for a missing source backup",
191 (_stack) =>
192 Effect.gen(function* () {
193 const response = (yield* postJson("/backup/copy-missing")) as any;
194 expect(response.tag).toBe("BackupNotFound");
195 }),
196 );
197 });
198
199 describe("UpdateSnapshot", () => {
200 // FSx misclassifies this not-found as a wire `BadRequest`; the distilled
201 // patch (patches/fsx.json) carves the typed UpdateSnapshotNotFound out
202 // of it by message predicate.
203 test.provider(
204 "returns the typed UpdateSnapshotNotFound for a missing snapshot",
205 (_stack) =>
206 Effect.gen(function* () {
207 const response = (yield* postJson(
208 "/snapshot/update-missing",
209 )) as any;
210 expect(response.tag).toBe("UpdateSnapshotNotFound");
211 }),
212 );
213 });
214
215 describe("DeleteSnapshot", () => {
216 test.provider(
217 "returns the typed SnapshotNotFound for a missing snapshot",
218 (_stack) =>
219 Effect.gen(function* () {
220 const response = (yield* postJson(
221 "/snapshot/delete-missing",
222 )) as any;
223 expect(response.tag).toBe("SnapshotNotFound");
224 }),
225 );
226 });
227
228 describe("CreateSnapshot", () => {
229 // FSx misclassifies this not-found as a wire `BadRequest`; the distilled
230 // patch (patches/fsx.json) carves the typed SnapshotVolumeNotFound out
231 // of it by message predicate.
232 test.provider(
233 "returns the typed SnapshotVolumeNotFound for a missing volume",
234 (_stack) =>
235 Effect.gen(function* () {
236 const response = (yield* postJson(
237 "/snapshot/create-missing-volume",
238 )) as any;
239 expect(response.tag).toBe("SnapshotVolumeNotFound");
240 }),
241 );
242 });
243
244 describe("RestoreVolumeFromSnapshot", () => {
245 // FSx misclassifies this not-found as a wire `BadRequest`; the distilled
246 // patch (patches/fsx.json) carves the typed RestoreSnapshotNotFound out
247 // of it by message predicate.
248 test.provider(
249 "returns the typed RestoreSnapshotNotFound for a missing snapshot",
250 (_stack) =>
251 Effect.gen(function* () {
252 const response = (yield* postJson(
253 "/volume/restore-missing",
254 )) as any;
255 expect(response.tag).toBe("RestoreSnapshotNotFound");
256 }),
257 );
258 });
259
260 describe("CopySnapshotAndUpdateVolume", () => {
261 // FSx reports a nonexistent source snapshot as a wire `BadRequest` with
262 // "SourceSnapshotARN provided is not a valid ARN"; the distilled patch
263 // (patches/fsx.json) carves the typed SourceSnapshotNotFound out of it
264 // by message predicate.
265 test.provider(
266 "returns the typed SourceSnapshotNotFound for a missing source snapshot",
267 (_stack) =>
268 Effect.gen(function* () {
269 const response = (yield* postJson(
270 "/volume/copy-snapshot-missing",
271 )) as any;
272 expect(response.tag).toBe("SourceSnapshotNotFound");
273 }),
274 );
275 });
276
277 describe("CancelDataRepositoryTask", () => {
278 test.provider(
279 "returns the typed DataRepositoryTaskNotFound for a missing task",
280 (_stack) =>
281 Effect.gen(function* () {
282 const response = (yield* postJson(
283 "/dr-task/cancel-missing",
284 )) as any;
285 expect(response.tag).toBe("DataRepositoryTaskNotFound");
286 }),
287 );
288 });
289 },
290);