1import * as FSx from "@/AWS/FSx";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import path from "pathe";
8
9const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
10
11// Syntactically valid but nonexistent ids: each probe must round-trip to FSx
12// and come back as the typed *NotFound — an IAM gap would surface
13// AccessDeniedException (a 500 via Effect.orDie) instead.
14const MISSING_BACKUP_ID = "backup-00000000000000000";
15const MISSING_SNAPSHOT_ID = "fsvolsnap-00000000000000000";
16const MISSING_VOLUME_ID = "fsvol-00000000000000000";
17const MISSING_TASK_ID = "task-00000000000000000";
18// Well-formed FSx snapshot ARN pointing at a nonexistent snapshot. FSx
19// reports the missing source snapshot as "SourceSnapshotARN provided is not
20// a valid ARN" — the distilled patch carves the typed SourceSnapshotNotFound
21// out of that BadRequest by message predicate.
22const MISSING_SNAPSHOT_ARN =
23 "arn:aws:fsx:us-west-2:000000000000:snapshot/fsvolsnap-00000000000000000";
24
25export class FSxBindingsFunction extends Lambda.Function<Lambda.Function>()(
26 "FSxBindingsFunction",
27) {}
28
29export default FSxBindingsFunction.make(
30 {
31 main,
32 functionUrl: true,
33 },
34 Effect.gen(function* () {
35 // Account-level bindings only — the file-system-scoped bindings
36 // (DescribeFileSystem, CreateBackup, CreateDataRepositoryTask,
37 // ReleaseFileSystemNfsV3Locks) require a live FSx file system, which
38 // takes ~8 minutes to provision and bills hourly; their runtime path is
39 // identical scaffolding and is exercised by the account-level routes.
40 const describeBackups = yield* FSx.DescribeBackups();
41 const deleteBackup = yield* FSx.DeleteBackup();
42 const copyBackup = yield* FSx.CopyBackup();
43 const updateSnapshot = yield* FSx.UpdateSnapshot();
44 const describeSnapshots = yield* FSx.DescribeSnapshots();
45 const createSnapshot = yield* FSx.CreateSnapshot();
46 const deleteSnapshot = yield* FSx.DeleteSnapshot();
47 const restoreVolumeFromSnapshot = yield* FSx.RestoreVolumeFromSnapshot();
48 const copySnapshotAndUpdateVolume =
49 yield* FSx.CopySnapshotAndUpdateVolume();
50 const describeVolumes = yield* FSx.DescribeVolumes();
51 const describeStorageVirtualMachines =
52 yield* FSx.DescribeStorageVirtualMachines();
53 const describeDataRepositoryTasks =
54 yield* FSx.DescribeDataRepositoryTasks();
55 const describeDataRepositoryAssociations =
56 yield* FSx.DescribeDataRepositoryAssociations();
57 const cancelDataRepositoryTask = yield* FSx.CancelDataRepositoryTask();
58
59 const bound = {
60 describeBackups,
61 deleteBackup,
62 copyBackup,
63 updateSnapshot,
64 describeSnapshots,
65 createSnapshot,
66 deleteSnapshot,
67 restoreVolumeFromSnapshot,
68 copySnapshotAndUpdateVolume,
69 describeVolumes,
70 describeStorageVirtualMachines,
71 describeDataRepositoryTasks,
72 describeDataRepositoryAssociations,
73 cancelDataRepositoryTask,
74 };
75
76 // Run a probe expected to fail with a typed tag; return the tag so the
77 // test can assert the exact typed error (proving both the IAM grant and
78 // the typed error union end-to-end).
79 const probeTag = <A, E extends { _tag: string }, R>(
80 effect: Effect.Effect<A, E, R>,
81 ) =>
82 effect.pipe(
83 Effect.result,
84 Effect.map((result) =>
85 result._tag === "Failure" ? result.failure._tag : "Success",
86 ),
87 );
88
89 return {
90 fetch: Effect.gen(function* () {
91 const request = yield* HttpServerRequest;
92 const url = new URL(request.originalUrl);
93 const pathname = url.pathname;
94
95 if (request.method === "GET" && pathname === "/bindings") {
96 return yield* HttpServerResponse.json({
97 bound: Object.keys(bound),
98 });
99 }
100
101 if (request.method === "GET" && pathname === "/backups") {
102 const response = yield* describeBackups();
103 return yield* HttpServerResponse.json({
104 count: (response.Backups ?? []).length,
105 });
106 }
107
108 if (request.method === "GET" && pathname === "/snapshots") {
109 const response = yield* describeSnapshots();
110 return yield* HttpServerResponse.json({
111 count: (response.Snapshots ?? []).length,
112 });
113 }
114
115 if (request.method === "GET" && pathname === "/volumes") {
116 const response = yield* describeVolumes();
117 return yield* HttpServerResponse.json({
118 count: (response.Volumes ?? []).length,
119 });
120 }
121
122 if (request.method === "GET" && pathname === "/svms") {
123 const response = yield* describeStorageVirtualMachines();
124 return yield* HttpServerResponse.json({
125 count: (response.StorageVirtualMachines ?? []).length,
126 });
127 }
128
129 if (request.method === "GET" && pathname === "/dr-tasks") {
130 const response = yield* describeDataRepositoryTasks();
131 return yield* HttpServerResponse.json({
132 count: (response.DataRepositoryTasks ?? []).length,
133 });
134 }
135
136 if (request.method === "GET" && pathname === "/dr-associations") {
137 const response = yield* describeDataRepositoryAssociations();
138 return yield* HttpServerResponse.json({
139 count: (response.Associations ?? []).length,
140 });
141 }
142
143 if (
144 request.method === "POST" &&
145 pathname === "/backup/delete-missing"
146 ) {
147 const tag = yield* probeTag(
148 deleteBackup({ BackupId: MISSING_BACKUP_ID }),
149 );
150 return yield* HttpServerResponse.json({ tag });
151 }
152
153 if (request.method === "POST" && pathname === "/backup/copy-missing") {
154 const tag = yield* probeTag(
155 copyBackup({ SourceBackupId: MISSING_BACKUP_ID }),
156 );
157 return yield* HttpServerResponse.json({ tag });
158 }
159
160 if (
161 request.method === "POST" &&
162 pathname === "/snapshot/update-missing"
163 ) {
164 const tag = yield* probeTag(
165 updateSnapshot({
166 SnapshotId: MISSING_SNAPSHOT_ID,
167 Name: "alchemy-fsx-bindings-probe",
168 }),
169 );
170 return yield* HttpServerResponse.json({ tag });
171 }
172
173 if (
174 request.method === "POST" &&
175 pathname === "/snapshot/delete-missing"
176 ) {
177 const tag = yield* probeTag(
178 deleteSnapshot({ SnapshotId: MISSING_SNAPSHOT_ID }),
179 );
180 return yield* HttpServerResponse.json({ tag });
181 }
182
183 if (
184 request.method === "POST" &&
185 pathname === "/snapshot/create-missing-volume"
186 ) {
187 const tag = yield* probeTag(
188 createSnapshot({
189 Name: "alchemy-fsx-bindings-probe",
190 VolumeId: MISSING_VOLUME_ID,
191 }),
192 );
193 return yield* HttpServerResponse.json({ tag });
194 }
195
196 if (
197 request.method === "POST" &&
198 pathname === "/volume/restore-missing"
199 ) {
200 const tag = yield* probeTag(
201 restoreVolumeFromSnapshot({
202 VolumeId: MISSING_VOLUME_ID,
203 SnapshotId: MISSING_SNAPSHOT_ID,
204 }),
205 );
206 return yield* HttpServerResponse.json({ tag });
207 }
208
209 if (
210 request.method === "POST" &&
211 pathname === "/volume/copy-snapshot-missing"
212 ) {
213 const tag = yield* probeTag(
214 copySnapshotAndUpdateVolume({
215 VolumeId: MISSING_VOLUME_ID,
216 SourceSnapshotARN: MISSING_SNAPSHOT_ARN,
217 }),
218 );
219 return yield* HttpServerResponse.json({ tag });
220 }
221
222 if (
223 request.method === "POST" &&
224 pathname === "/dr-task/cancel-missing"
225 ) {
226 const tag = yield* probeTag(
227 cancelDataRepositoryTask({ TaskId: MISSING_TASK_ID }),
228 );
229 return yield* HttpServerResponse.json({ tag });
230 }
231
232 return yield* HttpServerResponse.json(
233 { error: "Not found", method: request.method, pathname },
234 { status: 404 },
235 );
236 }).pipe(Effect.orDie),
237 };
238 }).pipe(
239 Effect.provide(
240 Layer.mergeAll(
241 FSx.DescribeBackupsHttp,
242 FSx.DeleteBackupHttp,
243 FSx.CopyBackupHttp,
244 FSx.UpdateSnapshotHttp,
245 FSx.DescribeSnapshotsHttp,
246 FSx.CreateSnapshotHttp,
247 FSx.DeleteSnapshotHttp,
248 FSx.RestoreVolumeFromSnapshotHttp,
249 FSx.CopySnapshotAndUpdateVolumeHttp,
250 FSx.DescribeVolumesHttp,
251 FSx.DescribeStorageVirtualMachinesHttp,
252 FSx.DescribeDataRepositoryTasksHttp,
253 FSx.DescribeDataRepositoryAssociationsHttp,
254 FSx.CancelDataRepositoryTaskHttp,
255 ),
256 ),
257 ),
258);