1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import EmrcTestFunctionLive, { EmrcTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "EMRContainersBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25let functionArn: 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
63describe.sequential(
64 "EMRContainers Bindings",
65 {
66 tags: [
67 "provider:aws",
68 "provider:aws:emrcontainers",
69 "provider:aws:iam",
70 "provider:aws:lambda",
71 "live",
72 ],
73 },
74 () => {
75 beforeAll(
76 Effect.gen(function* () {
77 yield* Effect.logInfo(
78 "EMRContainers test setup: destroying previous resources",
79 );
80 yield* sharedStack.destroy();
81
82 yield* Effect.logInfo("EMRContainers test setup: deploying fixture");
83 const attrs = yield* sharedStack.deploy(
84 Effect.gen(function* () {
85 return yield* EmrcTestFunction;
86 }).pipe(Effect.provide(EmrcTestFunctionLive)),
87 );
88
89 expect(attrs.functionUrl).toBeTruthy();
90 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
91 functionArn = attrs.functionArn;
92
93 const readinessUrl = `${baseUrl}/bindings`;
94 yield* Effect.logInfo(
95 `EMRContainers test setup: probing readiness at ${readinessUrl}`,
96 );
97 yield* HttpClient.get(readinessUrl).pipe(
98 Effect.flatMap((response) =>
99 response.status === 200
100 ? Effect.succeed(response)
101 : Effect.fail(
102 new Error(`Function not ready: ${response.status}`),
103 ),
104 ),
105 Effect.tapError((error) =>
106 Effect.logWarning(
107 `EMRContainers test setup: fixture not ready yet (${String(error)})`,
108 ),
109 ),
110 Effect.retry({ schedule: readinessPolicy }),
111 );
112 }),
113 { timeout: 240_000 },
114 );
115
116 afterAll(sharedStack.destroy(), { timeout: 120_000 });
117
118 describe("binding registration", () => {
119 test.provider("all capabilities initialize in the runtime", (_stack) =>
120 Effect.gen(function* () {
121 const response = (yield* getJson("/bindings")) as { bound: string[] };
122 expect(response.bound).toHaveLength(3);
123 }),
124 );
125 });
126
127 describe("DescribeJobTemplate", () => {
128 test.provider(
129 "reads the bound template's data (injected template id)",
130 (_stack) =>
131 Effect.gen(function* () {
132 const response = (yield* getJson("/template")) as {
133 id: string;
134 name: string;
135 releaseLabel: string;
136 };
137 expect(response.id).toBeTruthy();
138 expect(response.releaseLabel).toBe("emr-7.5.0-latest");
139 }),
140 );
141 });
142
143 describe("ListJobTemplates", () => {
144 test.provider(
145 "enumerates the account's templates including the fixture's",
146 (_stack) =>
147 Effect.gen(function* () {
148 const template = (yield* getJson("/template")) as { id: string };
149 const response = (yield* getJson("/templates")) as {
150 ids: string[];
151 };
152 expect(response.ids).toContain(template.id);
153 }),
154 );
155 });
156
157 describe("ListVirtualClusters", () => {
158 test.provider(
159 "enumerates the account's virtual clusters (IAM grant + call path)",
160 (_stack) =>
161 Effect.gen(function* () {
162 // The account may have zero RUNNING virtual clusters (they require
163 // an EKS-backed cluster); the assertion is the authorized round
164 // trip, not the count.
165 const response = (yield* getJson("/virtual-clusters")) as {
166 ids: string[];
167 };
168 expect(Array.isArray(response.ids)).toBe(true);
169 }),
170 );
171 });
172
173 describe(
174 "consumeJobRunEvents",
175 { tags: ["provider:aws:eventbridge"] },
176 () => {
177 test.provider(
178 "the deploy created an EventBridge rule targeting the function",
179 (_stack) =>
180 Effect.gen(function* () {
181 // Out-of-band via distilled: the fixture's consumeJobRunEvents
182 // must have materialized as a rule on the default bus with the
183 // Lambda as target.
184 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
185 TargetArn: functionArn,
186 });
187 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
188 }),
189 );
190 },
191 );
192 },
193);