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 * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import ImageBuilderTestFunctionLive, {
13 ImageBuilderTestFunction,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "ImageBuilderBindings");
19
20// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
21// over 60s on a fresh deploy.
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28let functionArn: string;
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35// The shared Lambda fixture occasionally answers a transient 5xx under load
36// (cold re-init, IAM propagation on the freshly attached policy that the
37// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
38// 4xx/assertion failure surfaces immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 schedule: Schedule.max([
55 Schedule.exponential("500 millis"),
56 Schedule.recurs(6),
57 ]),
58 }),
59 );
60
61const getJson = (path: string) =>
62 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
63 Effect.flatMap((r) => r.json),
64 );
65
66const postJson = (path: string) =>
67 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
68 Effect.flatMap((r) => r.json),
69 );
70
71const deleteJson = (path: string) =>
72 send(HttpClientRequest.delete(`${baseUrl}${path}`)).pipe(
73 Effect.flatMap((r) => r.json),
74 );
75
76describe.sequential(
77 "ImageBuilder Bindings",
78 {
79 tags: [
80 "provider:aws",
81 "provider:aws:iam",
82 "provider:aws:imagebuilder",
83 "provider:aws:lambda",
84 "live",
85 ],
86 },
87 () => {
88 beforeAll(
89 Effect.gen(function* () {
90 yield* Effect.logInfo(
91 "ImageBuilder test setup: destroying previous resources",
92 );
93 yield* sharedStack.destroy();
94
95 yield* Effect.logInfo("ImageBuilder test setup: deploying fixture");
96 const attrs = yield* sharedStack.deploy(
97 Effect.gen(function* () {
98 return yield* ImageBuilderTestFunction;
99 }).pipe(Effect.provide(ImageBuilderTestFunctionLive)),
100 );
101
102 expect(attrs.functionUrl).toBeTruthy();
103 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
104 functionArn = attrs.functionArn;
105
106 const readinessUrl = `${baseUrl}/bindings`;
107 yield* Effect.logInfo(
108 `ImageBuilder test setup: probing readiness at ${readinessUrl}`,
109 );
110 yield* HttpClient.get(readinessUrl).pipe(
111 Effect.flatMap((response) =>
112 response.status === 200
113 ? Effect.succeed(response)
114 : Effect.fail(
115 new Error(`Function not ready: ${response.status}`),
116 ),
117 ),
118 Effect.tapError((error) =>
119 Effect.logWarning(
120 `ImageBuilder test setup: fixture not ready yet (${String(error)})`,
121 ),
122 ),
123 Effect.retry({ schedule: readinessPolicy }),
124 );
125 }),
126 { timeout: 300_000 },
127 );
128
129 afterAll(sharedStack.destroy(), { timeout: 180_000 });
130
131 describe("binding registration", () => {
132 test.provider("all eighteen capabilities initialize in the runtime", () =>
133 Effect.gen(function* () {
134 const response = (yield* getJson("/bindings")) as { bound: string[] };
135 expect(response.bound).toHaveLength(18);
136 // SendWorkflowStepAction needs a WAIT_FOR_ACTION step and RetryImage
137 // a FAILED build — registration/IAM wiring only.
138 expect(response.bound).toContain("sendWorkflowStepAction");
139 expect(response.bound).toContain("retryImage");
140 }),
141 );
142 });
143
144 describe("GetImagePipeline", () => {
145 test.provider(
146 "reads the bound pipeline's state (injected pipeline arn)",
147 () =>
148 Effect.gen(function* () {
149 const response = (yield* getJson("/pipeline")) as {
150 arn: string;
151 name: string;
152 status: string;
153 timeoutMinutes: number;
154 };
155 expect(response.arn).toContain(":image-pipeline/");
156 expect(response.status).toBe("ENABLED");
157 // The fixture's Duration timeout ("1 hour") landed as 60 wire
158 // minutes — proves the renamed duration prop converts correctly.
159 expect(response.timeoutMinutes).toBe(60);
160 }),
161 );
162 });
163
164 describe("ListImagePipelineImages", () => {
165 test.provider("enumerates the pipeline's builds", () =>
166 Effect.gen(function* () {
167 const response = (yield* getJson("/pipeline-images")) as {
168 arns: string[];
169 };
170 expect(Array.isArray(response.arns)).toBe(true);
171 }),
172 );
173 });
174
175 describe("ListImages", () => {
176 test.provider("enumerates the account's images", () =>
177 Effect.gen(function* () {
178 const response = (yield* getJson("/images")) as { count: number };
179 expect(response.count).toBeGreaterThanOrEqual(0);
180 }),
181 );
182 });
183
184 describe("account-level reads", () => {
185 test.provider(
186 "ListImageScanFindings + ListImageScanFindingAggregations + ListWaitingWorkflowSteps",
187 () =>
188 Effect.gen(function* () {
189 const findings = (yield* getJson("/scan-findings")) as {
190 count: number;
191 };
192 expect(findings.count).toBeGreaterThanOrEqual(0);
193
194 const aggregations = (yield* getJson("/scan-aggregations")) as {
195 count: number;
196 };
197 expect(aggregations.count).toBeGreaterThanOrEqual(0);
198
199 const waiting = (yield* getJson("/waiting-steps")) as {
200 count: number;
201 };
202 expect(waiting.count).toBeGreaterThanOrEqual(0);
203 }),
204 { timeout: 60_000 },
205 );
206 });
207
208 describe("build lifecycle", () => {
209 test.provider(
210 "Start → Cancel → GetImage → ListWorkflowExecutions → DeleteImage",
211 () =>
212 Effect.gen(function* () {
213 // Kick off a real build of the bound pipeline via the binding.
214 const started = (yield* postJson("/build/start")) as {
215 imageBuildVersionArn: string;
216 };
217 expect(started.imageBuildVersionArn).toContain(":image/");
218 const arn = encodeURIComponent(started.imageBuildVersionArn);
219
220 const run = Effect.gen(function* () {
221 // Cancel it — early build states can briefly reject the
222 // cancellation, so retry through the window (bounded).
223 const cancelled = yield* postJson(
224 `/build/cancel?arn=${arn}`,
225 ).pipe(
226 Effect.map(
227 (body) =>
228 body as { imageBuildVersionArn?: string; reason?: string },
229 ),
230 Effect.repeat({
231 schedule: Schedule.spaced("5 seconds"),
232 until: (body): boolean =>
233 body.imageBuildVersionArn !== undefined,
234 times: 12,
235 }),
236 );
237 expect(cancelled.imageBuildVersionArn).toBe(
238 started.imageBuildVersionArn,
239 );
240
241 // The build's workflow drill-down responds (may be empty this
242 // early in the build).
243 const workflows = (yield* getJson(
244 `/build/workflows?arn=${arn}`,
245 )) as { ids: string[] };
246 expect(Array.isArray(workflows.ids)).toBe(true);
247
248 // If the build already spawned a workflow execution, drill into
249 // it through the workflow-monitoring bindings.
250 const executionId = workflows.ids[0];
251 if (executionId !== undefined) {
252 const execution = (yield* getJson(
253 `/workflow-execution?id=${encodeURIComponent(executionId)}`,
254 )) as { id?: string; status?: string };
255 expect(execution.id).toBe(executionId);
256
257 const steps = (yield* getJson(
258 `/workflow-steps?id=${encodeURIComponent(executionId)}`,
259 )) as { ids: string[] };
260 expect(Array.isArray(steps.ids)).toBe(true);
261
262 const stepId = steps.ids[0];
263 if (stepId !== undefined) {
264 const step = (yield* getJson(
265 `/workflow-step?id=${encodeURIComponent(stepId)}`,
266 )) as { name?: string; status?: string };
267 expect(typeof step.status).toBe("string");
268 }
269 }
270
271 // The build version shows up under its image version ARN
272 // (…:image/{name}/{version} — strip the build-number suffix).
273 const imageVersionArn = started.imageBuildVersionArn.replace(
274 /\/\d+$/,
275 "",
276 );
277 const versions = (yield* getJson(
278 `/build-versions?arn=${encodeURIComponent(imageVersionArn)}`,
279 )) as { arns: string[] };
280 expect(versions.arns).toContain(started.imageBuildVersionArn);
281
282 // Packages exist only for AVAILABLE builds — this in-flight or
283 // cancelled build reports the typed rejection instead.
284 const packages = (yield* getJson(
285 `/build/packages?arn=${arn}`,
286 )) as {
287 count?: number;
288 reason?: string;
289 };
290 expect(
291 packages.count !== undefined || packages.reason !== undefined,
292 ).toBe(true);
293
294 // Observe the build settle into CANCELLED via the GetImage
295 // binding (bounded).
296 const settled = yield* getJson(`/build?arn=${arn}`).pipe(
297 Effect.map((body) => body as { status?: string }),
298 Effect.repeat({
299 schedule: Schedule.spaced("5 seconds"),
300 until: (body): boolean =>
301 body.status === "CANCELLED" || body.status === "FAILED",
302 times: 24,
303 }),
304 );
305 expect(settled.status).toBe("CANCELLED");
306
307 // Prune the cancelled build via the DeleteImage binding; poll
308 // through the not-yet-deletable window (bounded).
309 const deleted = yield* deleteJson(`/build?arn=${arn}`).pipe(
310 Effect.map(
311 (body) => body as { deleted: boolean; reason?: string },
312 ),
313 Effect.repeat({
314 schedule: Schedule.spaced("5 seconds"),
315 until: (body): boolean => body.deleted,
316 times: 12,
317 }),
318 );
319 expect(deleted.deleted).toBe(true);
320 });
321
322 // Zero-orphan guarantee: whatever happens above, the build
323 // version is removed out-of-band via distilled before the test
324 // ends (idempotent — tolerates the happy path's delete).
325 yield* run.pipe(
326 Effect.ensuring(
327 imagebuilder
328 .deleteImage({
329 imageBuildVersionArn: started.imageBuildVersionArn,
330 })
331 .pipe(
332 Effect.retry({
333 while: (e): boolean =>
334 e._tag === "InvalidRequestException" ||
335 e._tag === "ResourceDependencyException",
336 schedule: Schedule.max([
337 Schedule.fixed("5 seconds"),
338 Schedule.recurs(10),
339 ]),
340 }),
341 Effect.catchTag(
342 "ResourceNotFoundException",
343 () => Effect.void,
344 ),
345 Effect.catch(() => Effect.void),
346 ),
347 ),
348 );
349 }),
350 { timeout: 240_000 },
351 );
352 });
353
354 describe(
355 "consumeImageEvents",
356 { tags: ["provider:aws:eventbridge"] },
357 () => {
358 test.provider(
359 "the deploy created an EventBridge rule targeting the function",
360 () =>
361 Effect.gen(function* () {
362 // Out-of-band via distilled: the fixture's consumeImageEvents
363 // must have materialized as a rule on the default bus with the
364 // Lambda as target.
365 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
366 TargetArn: functionArn,
367 });
368 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
369 }),
370 );
371 },
372 );
373 },
374);