1import * as EMR from "@/AWS/EMR";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13/**
14 * Deterministic release label the catalog routes pin to — old enough to be
15 * available in every region the tests run in.
16 */
17export const PROBE_RELEASE_LABEL = "emr-7.5.0";
18
19export class EmrTestFunction extends Lambda.Function<Lambda.Function>()(
20 "EmrTestFunction",
21) {}
22
23/**
24 * Account-scoped binding fixture: no EMR cluster is ever created. The four
25 * discovery/catalog bindings and the EventBridge event source deploy and are
26 * driven at zero cost.
27 */
28export default EmrTestFunction.make(
29 {
30 main,
31 functionUrl: true,
32 timeout: Duration.seconds(30),
33 },
34 Effect.gen(function* () {
35 // Event source: subscribe the host to EMR cluster + step state changes.
36 // The deploy proves the EventBridge rule + invoke permission wiring.
37 yield* EMR.consumeClusterEvents({ kinds: ["cluster", "step"] }, (events) =>
38 Stream.runForEach(events, (event) =>
39 Effect.log(
40 `emr event: ${event.detail.clusterId} -> ${event.detail.state}`,
41 ),
42 ),
43 );
44
45 const listClusters = yield* EMR.ListClusters();
46 const listReleaseLabels = yield* EMR.ListReleaseLabels();
47 const describeReleaseLabel = yield* EMR.DescribeReleaseLabel();
48 const listSupportedInstanceTypes = yield* EMR.ListSupportedInstanceTypes();
49
50 const bound = {
51 listClusters,
52 listReleaseLabels,
53 describeReleaseLabel,
54 listSupportedInstanceTypes,
55 };
56
57 return {
58 fetch: Effect.gen(function* () {
59 const request = yield* HttpServerRequest;
60 const url = new URL(request.originalUrl);
61 const pathname = url.pathname;
62
63 if (request.method === "GET" && pathname === "/bindings") {
64 return yield* HttpServerResponse.json({
65 bound: Object.keys(bound),
66 });
67 }
68
69 // Account inventory (possibly empty) — proves the `*` grant and the
70 // response schema decode.
71 if (request.method === "GET" && pathname === "/clusters") {
72 const { Clusters } = yield* listClusters({
73 ClusterStates: ["STARTING", "RUNNING", "WAITING"],
74 });
75 return yield* HttpServerResponse.json({
76 count: (Clusters ?? []).length,
77 });
78 }
79
80 if (request.method === "GET" && pathname === "/releases") {
81 const { ReleaseLabels } = yield* listReleaseLabels();
82 return yield* HttpServerResponse.json({
83 count: (ReleaseLabels ?? []).length,
84 latest: ReleaseLabels?.[0],
85 });
86 }
87
88 if (request.method === "GET" && pathname === "/release") {
89 const { Applications } = yield* describeReleaseLabel({
90 ReleaseLabel: PROBE_RELEASE_LABEL,
91 });
92 return yield* HttpServerResponse.json({
93 applications: (Applications ?? []).map((app) => app.Name),
94 });
95 }
96
97 if (request.method === "GET" && pathname === "/instance-types") {
98 const { SupportedInstanceTypes } = yield* listSupportedInstanceTypes({
99 ReleaseLabel: PROBE_RELEASE_LABEL,
100 });
101 return yield* HttpServerResponse.json({
102 types: (SupportedInstanceTypes ?? []).map((t) => t.Type),
103 });
104 }
105
106 return yield* HttpServerResponse.json(
107 { error: "Not found", method: request.method, pathname },
108 { status: 404 },
109 );
110 }).pipe(Effect.orDie),
111 };
112 }).pipe(
113 Effect.provide(
114 Layer.mergeAll(
115 Lambda.EventSource,
116 EMR.ListClustersHttp,
117 EMR.ListReleaseLabelsHttp,
118 EMR.DescribeReleaseLabelHttp,
119 EMR.ListSupportedInstanceTypesHttp,
120 ),
121 ),
122 ),
123);