1import * as Lambda from "@/AWS/Lambda";
2import * as OpenSearch from "@/AWS/OpenSearch";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12// A well-formed-but-nonexistent domain name — the probe routes drive the
13// bindings against it so the fixture exercises the IAM grants and the typed
14// error decode at zero cost (no domain is ever created; domains take 15-25
15// minutes to provision and bill per instance-hour).
16const NONEXISTENT_DOMAIN = "alchemy-nonexistent-os-probe";
17
18export class OpenSearchBindingsTestFunction extends Lambda.Function<Lambda.Function>()(
19 "OpenSearchBindingsTestFunction",
20) {}
21
22/**
23 * Account-level binding fixture: no OpenSearch domain is ever created. List
24 * routes prove each grant and response decode against real (possibly empty)
25 * account/catalog data; probe routes drive domain-addressed operations
26 * against a nonexistent domain and must surface the service's typed
27 * `ResourceNotFoundException` — an IAM gap would surface AccessDeniedException
28 * and fail the route with an opaque 500.
29 */
30export default OpenSearchBindingsTestFunction.make(
31 {
32 main,
33 functionUrl: true,
34 },
35 Effect.gen(function* () {
36 // Event source: subscribe the host to OpenSearch Service notifications
37 // flowing through the account's default bus. The deploy proves the
38 // EventBridge rule + invoke permission wiring.
39 yield* OpenSearch.consumeDomainEvents(
40 { kinds: ["cluster-status", "software-update"] },
41 (events) =>
42 Stream.runForEach(events, (event) =>
43 Effect.log(
44 `opensearch event: ${event["detail-type"]} -> ${event.resources.join(", ")}`,
45 ),
46 ),
47 );
48
49 const describeDomain = yield* OpenSearch.DescribeDomain();
50 const describeDomains = yield* OpenSearch.DescribeDomains();
51 const describeDomainConfig = yield* OpenSearch.DescribeDomainConfig();
52 const describeDomainHealth = yield* OpenSearch.DescribeDomainHealth();
53 const describeDomainNodes = yield* OpenSearch.DescribeDomainNodes();
54 const describeDomainChangeProgress =
55 yield* OpenSearch.DescribeDomainChangeProgress();
56 const listDomainNames = yield* OpenSearch.ListDomainNames();
57 const describeDomainAutoTunes = yield* OpenSearch.DescribeDomainAutoTunes();
58 const listScheduledActions = yield* OpenSearch.ListScheduledActions();
59 const startDomainMaintenance = yield* OpenSearch.StartDomainMaintenance();
60 const getDomainMaintenanceStatus =
61 yield* OpenSearch.GetDomainMaintenanceStatus();
62 const listDomainMaintenances = yield* OpenSearch.ListDomainMaintenances();
63 const startServiceSoftwareUpdate =
64 yield* OpenSearch.StartServiceSoftwareUpdate();
65 const cancelServiceSoftwareUpdate =
66 yield* OpenSearch.CancelServiceSoftwareUpdate();
67 const getUpgradeStatus = yield* OpenSearch.GetUpgradeStatus();
68 const getUpgradeHistory = yield* OpenSearch.GetUpgradeHistory();
69 const getCompatibleVersions = yield* OpenSearch.GetCompatibleVersions();
70 const listVersions = yield* OpenSearch.ListVersions();
71 const listInstanceTypeDetails = yield* OpenSearch.ListInstanceTypeDetails();
72
73 const bound = {
74 describeDomain,
75 describeDomains,
76 describeDomainConfig,
77 describeDomainHealth,
78 describeDomainNodes,
79 describeDomainChangeProgress,
80 listDomainNames,
81 describeDomainAutoTunes,
82 listScheduledActions,
83 startDomainMaintenance,
84 getDomainMaintenanceStatus,
85 listDomainMaintenances,
86 startServiceSoftwareUpdate,
87 cancelServiceSoftwareUpdate,
88 getUpgradeStatus,
89 getUpgradeHistory,
90 getCompatibleVersions,
91 listVersions,
92 listInstanceTypeDetails,
93 };
94
95 return {
96 fetch: Effect.gen(function* () {
97 const request = yield* HttpServerRequest;
98 const url = new URL(request.originalUrl);
99 const pathname = url.pathname;
100
101 if (request.method === "GET" && pathname === "/bindings") {
102 return yield* HttpServerResponse.json({
103 bound: Object.keys(bound),
104 });
105 }
106
107 if (request.method === "GET" && pathname === "/domain") {
108 // A nonexistent domain must surface the typed not-found tag.
109 const tag = yield* describeDomain({
110 DomainName: NONEXISTENT_DOMAIN,
111 }).pipe(
112 Effect.map(() => "Found"),
113 Effect.catchTag("ResourceNotFoundException", (e) =>
114 Effect.succeed(e._tag),
115 ),
116 );
117 return yield* HttpServerResponse.json({ tag });
118 }
119
120 if (request.method === "GET" && pathname === "/domains-batch") {
121 // The batch describe returns an empty status list (no not-found
122 // error) for unknown names — proves the grant and the decode.
123 const result = yield* describeDomains({
124 DomainNames: [NONEXISTENT_DOMAIN],
125 });
126 return yield* HttpServerResponse.json({
127 count: (result.DomainStatusList ?? []).length,
128 });
129 }
130
131 if (request.method === "GET" && pathname === "/domain-config") {
132 const tag = yield* describeDomainConfig({
133 DomainName: NONEXISTENT_DOMAIN,
134 }).pipe(
135 Effect.map(() => "Found"),
136 Effect.catchTag("ResourceNotFoundException", (e) =>
137 Effect.succeed(e._tag),
138 ),
139 );
140 return yield* HttpServerResponse.json({ tag });
141 }
142
143 if (request.method === "GET" && pathname === "/domain-health") {
144 // OpenSearch reports a missing domain on this operation as the
145 // typed `BaseException` ("Domain not found: …"), not
146 // ResourceNotFoundException — both are typed tags; catch both and
147 // surface whichever fired.
148 const tag = yield* describeDomainHealth({
149 DomainName: NONEXISTENT_DOMAIN,
150 }).pipe(
151 Effect.map(() => "Found"),
152 Effect.catchTag(
153 ["ResourceNotFoundException", "BaseException"],
154 (e) => Effect.succeed(e._tag),
155 ),
156 );
157 return yield* HttpServerResponse.json({ tag });
158 }
159
160 if (request.method === "GET" && pathname === "/domain-nodes") {
161 // Missing domain surfaces as the typed `BaseException` here (see
162 // /domain-health).
163 const tag = yield* describeDomainNodes({
164 DomainName: NONEXISTENT_DOMAIN,
165 }).pipe(
166 Effect.map(() => "Found"),
167 Effect.catchTag(
168 ["ResourceNotFoundException", "BaseException"],
169 (e) => Effect.succeed(e._tag),
170 ),
171 );
172 return yield* HttpServerResponse.json({ tag });
173 }
174
175 if (request.method === "GET" && pathname === "/change-progress") {
176 // A domain with no in-flight change (or no domain at all) surfaces
177 // as the typed `BaseException` ("No progress information found").
178 const tag = yield* describeDomainChangeProgress({
179 DomainName: NONEXISTENT_DOMAIN,
180 }).pipe(
181 Effect.map(() => "Found"),
182 Effect.catchTag(
183 ["ResourceNotFoundException", "BaseException"],
184 (e) => Effect.succeed(e._tag),
185 ),
186 );
187 return yield* HttpServerResponse.json({ tag });
188 }
189
190 if (request.method === "GET" && pathname === "/domain-names") {
191 // Listing the account's domains succeeds (possibly empty) —
192 // proves the grant and the schema decode.
193 const result = yield* listDomainNames();
194 return yield* HttpServerResponse.json({
195 count: (result.DomainNames ?? []).length,
196 });
197 }
198
199 if (request.method === "GET" && pathname === "/auto-tunes") {
200 const tag = yield* describeDomainAutoTunes({
201 DomainName: NONEXISTENT_DOMAIN,
202 }).pipe(
203 Effect.map(() => "Found"),
204 Effect.catchTag("ResourceNotFoundException", (e) =>
205 Effect.succeed(e._tag),
206 ),
207 );
208 return yield* HttpServerResponse.json({ tag });
209 }
210
211 if (request.method === "GET" && pathname === "/scheduled-actions") {
212 const tag = yield* listScheduledActions({
213 DomainName: NONEXISTENT_DOMAIN,
214 }).pipe(
215 Effect.map(() => "Found"),
216 Effect.catchTag("ResourceNotFoundException", (e) =>
217 Effect.succeed(e._tag),
218 ),
219 );
220 return yield* HttpServerResponse.json({ tag });
221 }
222
223 if (
224 request.method === "GET" &&
225 pathname === "/start-maintenance-probe"
226 ) {
227 // Starting maintenance on a nonexistent domain must surface the
228 // typed not-found tag — proves the write-side grant without ever
229 // rebooting anything.
230 const tag = yield* startDomainMaintenance({
231 DomainName: NONEXISTENT_DOMAIN,
232 Action: "REBOOT_NODE",
233 }).pipe(
234 Effect.map(() => "Started"),
235 Effect.catchTag("ResourceNotFoundException", (e) =>
236 Effect.succeed(e._tag),
237 ),
238 );
239 return yield* HttpServerResponse.json({ tag });
240 }
241
242 if (
243 request.method === "GET" &&
244 pathname === "/maintenance-status-probe"
245 ) {
246 // Missing domain surfaces as the typed `BaseException` here (see
247 // /domain-health).
248 const tag = yield* getDomainMaintenanceStatus({
249 DomainName: NONEXISTENT_DOMAIN,
250 MaintenanceId: "nonexistent-maintenance-id",
251 }).pipe(
252 Effect.map(() => "Found"),
253 Effect.catchTag(
254 ["ResourceNotFoundException", "BaseException"],
255 (e) => Effect.succeed(e._tag),
256 ),
257 );
258 return yield* HttpServerResponse.json({ tag });
259 }
260
261 if (request.method === "GET" && pathname === "/maintenances") {
262 // Missing domain surfaces as the typed `BaseException` here (see
263 // /domain-health).
264 const tag = yield* listDomainMaintenances({
265 DomainName: NONEXISTENT_DOMAIN,
266 }).pipe(
267 Effect.map(() => "Found"),
268 Effect.catchTag(
269 ["ResourceNotFoundException", "BaseException"],
270 (e) => Effect.succeed(e._tag),
271 ),
272 );
273 return yield* HttpServerResponse.json({ tag });
274 }
275
276 if (request.method === "GET" && pathname === "/software-update-probe") {
277 const tag = yield* startServiceSoftwareUpdate({
278 DomainName: NONEXISTENT_DOMAIN,
279 }).pipe(
280 Effect.map(() => "Started"),
281 Effect.catchTag("ResourceNotFoundException", (e) =>
282 Effect.succeed(e._tag),
283 ),
284 );
285 return yield* HttpServerResponse.json({ tag });
286 }
287
288 if (
289 request.method === "GET" &&
290 pathname === "/cancel-software-update-probe"
291 ) {
292 const tag = yield* cancelServiceSoftwareUpdate({
293 DomainName: NONEXISTENT_DOMAIN,
294 }).pipe(
295 Effect.map(() => "Cancelled"),
296 Effect.catchTag("ResourceNotFoundException", (e) =>
297 Effect.succeed(e._tag),
298 ),
299 );
300 return yield* HttpServerResponse.json({ tag });
301 }
302
303 if (request.method === "GET" && pathname === "/upgrade-status") {
304 const tag = yield* getUpgradeStatus({
305 DomainName: NONEXISTENT_DOMAIN,
306 }).pipe(
307 Effect.map(() => "Found"),
308 Effect.catchTag("ResourceNotFoundException", (e) =>
309 Effect.succeed(e._tag),
310 ),
311 );
312 return yield* HttpServerResponse.json({ tag });
313 }
314
315 if (request.method === "GET" && pathname === "/upgrade-history") {
316 const tag = yield* getUpgradeHistory({
317 DomainName: NONEXISTENT_DOMAIN,
318 }).pipe(
319 Effect.map(() => "Found"),
320 Effect.catchTag("ResourceNotFoundException", (e) =>
321 Effect.succeed(e._tag),
322 ),
323 );
324 return yield* HttpServerResponse.json({ tag });
325 }
326
327 if (request.method === "GET" && pathname === "/compatible-versions") {
328 // Without a DomainName the call maps every supported version to
329 // its upgrade targets — proves the grant and the decode.
330 const result = yield* getCompatibleVersions();
331 return yield* HttpServerResponse.json({
332 count: (result.CompatibleVersions ?? []).length,
333 });
334 }
335
336 if (request.method === "GET" && pathname === "/versions") {
337 // The engine-version catalog is never empty.
338 const result = yield* listVersions();
339 return yield* HttpServerResponse.json({
340 count: (result.Versions ?? []).length,
341 });
342 }
343
344 if (request.method === "GET" && pathname === "/instance-types") {
345 // The instance-type catalog for a current engine version is never
346 // empty.
347 const result = yield* listInstanceTypeDetails({
348 EngineVersion: "OpenSearch_2.19",
349 });
350 return yield* HttpServerResponse.json({
351 count: (result.InstanceTypeDetails ?? []).length,
352 });
353 }
354
355 return yield* HttpServerResponse.json(
356 { error: "Not found", method: request.method, pathname },
357 { status: 404 },
358 );
359 }).pipe(Effect.orDie),
360 };
361 }).pipe(
362 Effect.provide(
363 Layer.mergeAll(
364 Lambda.EventSource,
365 OpenSearch.DescribeDomainHttp,
366 OpenSearch.DescribeDomainsHttp,
367 OpenSearch.DescribeDomainConfigHttp,
368 OpenSearch.DescribeDomainHealthHttp,
369 OpenSearch.DescribeDomainNodesHttp,
370 OpenSearch.DescribeDomainChangeProgressHttp,
371 OpenSearch.ListDomainNamesHttp,
372 OpenSearch.DescribeDomainAutoTunesHttp,
373 OpenSearch.ListScheduledActionsHttp,
374 OpenSearch.StartDomainMaintenanceHttp,
375 OpenSearch.GetDomainMaintenanceStatusHttp,
376 OpenSearch.ListDomainMaintenancesHttp,
377 OpenSearch.StartServiceSoftwareUpdateHttp,
378 OpenSearch.CancelServiceSoftwareUpdateHttp,
379 OpenSearch.GetUpgradeStatusHttp,
380 OpenSearch.GetUpgradeHistoryHttp,
381 OpenSearch.GetCompatibleVersionsHttp,
382 OpenSearch.ListVersionsHttp,
383 OpenSearch.ListInstanceTypeDetailsHttp,
384 ),
385 ),
386 ),
387);