1import { InstanceProfile } from "@/AWS/IAM/InstanceProfile.ts";
2import { Role } from "@/AWS/IAM/Role.ts";
3import * as ImageBuilder from "@/AWS/ImageBuilder";
4import * as Lambda from "@/AWS/Lambda";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Stream from "effect/Stream";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15export class ImageBuilderTestFunction extends Lambda.Function<Lambda.Function>()(
16 "ImageBuilderTestFunction",
17) {}
18
19const componentData = [
20 "name: alchemy-imagebuilder-bindings-component",
21 "description: no-op component used by alchemy binding tests",
22 "schemaVersion: 1.0",
23 "phases:",
24 " - name: build",
25 " steps:",
26 " - name: hello",
27 " action: ExecuteBash",
28 " inputs:",
29 " commands:",
30 " - echo hello-from-alchemy-bindings",
31].join("\n");
32
33export default ImageBuilderTestFunction.make(
34 {
35 main,
36 functionUrl: true,
37 timeout: Duration.seconds(30),
38 },
39 Effect.gen(function* () {
40 // The pipeline the pipeline-scoped bindings are bound to. ENABLED but
41 // with no schedule, so it only builds when the Start binding fires.
42 const role = yield* Role("BindingsBuilderRole", {
43 assumeRolePolicyDocument: {
44 Version: "2012-10-17",
45 Statement: [
46 {
47 Effect: "Allow",
48 Principal: { Service: "ec2.amazonaws.com" },
49 Action: ["sts:AssumeRole"],
50 },
51 ],
52 },
53 managedPolicyArns: [
54 "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder",
55 "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
56 ],
57 });
58 const profile = yield* InstanceProfile("BindingsBuilderProfile", {
59 roleName: role.roleName,
60 });
61 const component = yield* ImageBuilder.Component("BindingsComponent", {
62 platform: "Linux",
63 data: componentData,
64 });
65 const recipe = yield* ImageBuilder.ImageRecipe("BindingsRecipe", {
66 // The testing account deploys to us-west-2 (region is fixed here
67 // because AWSEnvironment is deploy-only and this effect re-runs at
68 // Lambda init).
69 parentImage:
70 "arn:aws:imagebuilder:us-west-2:aws:image/amazon-linux-2023-x86/x.x.x",
71 components: [{ componentArn: component.componentBuildVersionArn }],
72 });
73 const infra = yield* ImageBuilder.InfrastructureConfiguration(
74 "BindingsInfra",
75 {
76 instanceProfileName: profile.instanceProfileName,
77 instanceTypes: ["t3.micro"],
78 terminateInstanceOnFailure: true,
79 },
80 );
81 const pipeline = yield* ImageBuilder.ImagePipeline("BindingsPipeline", {
82 imageRecipeArn: recipe.imageRecipeArn,
83 infrastructureConfigurationArn: infra.infrastructureConfigurationArn,
84 // Disable tests so a cancelled build never proceeds to a test phase.
85 imageTestsConfiguration: { imageTestsEnabled: false, timeout: "1 hour" },
86 });
87
88 // Event source: subscribe the host to image state-change events. The
89 // deploy proves the EventBridge rule + invoke permission wiring.
90 yield* ImageBuilder.consumeImageEvents(
91 { kinds: ["image-state-change"] },
92 (events) =>
93 Stream.runForEach(events, (event) =>
94 Effect.log(
95 `image state change: ${event.resources[0]} -> ${event.detail.state?.status}`,
96 ),
97 ),
98 );
99
100 const getPipeline = yield* ImageBuilder.GetImagePipeline(pipeline);
101 const listPipelineImages =
102 yield* ImageBuilder.ListImagePipelineImages(pipeline);
103 const startBuild =
104 yield* ImageBuilder.StartImagePipelineExecution(pipeline);
105 const getImage = yield* ImageBuilder.GetImage();
106 const cancelBuild = yield* ImageBuilder.CancelImageCreation();
107 const deleteImage = yield* ImageBuilder.DeleteImage();
108 const listImages = yield* ImageBuilder.ListImages();
109 const listWorkflowExecutions = yield* ImageBuilder.ListWorkflowExecutions();
110 const listImageBuildVersions = yield* ImageBuilder.ListImageBuildVersions();
111 const listImagePackages = yield* ImageBuilder.ListImagePackages();
112 const listImageScanFindings = yield* ImageBuilder.ListImageScanFindings();
113 const listImageScanFindingAggregations =
114 yield* ImageBuilder.ListImageScanFindingAggregations();
115 const getWorkflowExecution = yield* ImageBuilder.GetWorkflowExecution();
116 const getWorkflowStepExecution =
117 yield* ImageBuilder.GetWorkflowStepExecution();
118 const listWorkflowStepExecutions =
119 yield* ImageBuilder.ListWorkflowStepExecutions();
120 const listWaitingWorkflowSteps =
121 yield* ImageBuilder.ListWaitingWorkflowSteps();
122 const sendWorkflowStepAction = yield* ImageBuilder.SendWorkflowStepAction();
123 const retryImage = yield* ImageBuilder.RetryImage();
124
125 const bound = {
126 getPipeline,
127 listPipelineImages,
128 startBuild,
129 getImage,
130 cancelBuild,
131 deleteImage,
132 listImages,
133 listWorkflowExecutions,
134 listImageBuildVersions,
135 listImagePackages,
136 listImageScanFindings,
137 listImageScanFindingAggregations,
138 getWorkflowExecution,
139 getWorkflowStepExecution,
140 listWorkflowStepExecutions,
141 listWaitingWorkflowSteps,
142 // Approving needs a build paused on WAIT_FOR_ACTION and retrying needs
143 // a FAILED build, so the suite only proves registration/IAM wiring for
144 // these two.
145 sendWorkflowStepAction,
146 retryImage,
147 };
148
149 return {
150 fetch: Effect.gen(function* () {
151 const request = yield* HttpServerRequest;
152 const url = new URL(request.originalUrl);
153 const pathname = url.pathname;
154 const arn = url.searchParams.get("arn") ?? "";
155 const id = url.searchParams.get("id") ?? "";
156
157 if (request.method === "GET" && pathname === "/bindings") {
158 return yield* HttpServerResponse.json({
159 bound: Object.keys(bound),
160 });
161 }
162
163 // Pipeline-scoped read: the pipeline ARN is injected.
164 if (request.method === "GET" && pathname === "/pipeline") {
165 const { imagePipeline } = yield* getPipeline();
166 return yield* HttpServerResponse.json({
167 arn: imagePipeline?.arn,
168 name: imagePipeline?.name,
169 status: imagePipeline?.status,
170 timeoutMinutes:
171 imagePipeline?.imageTestsConfiguration?.timeoutMinutes,
172 });
173 }
174
175 // Pipeline-scoped list of the builds this pipeline produced.
176 if (request.method === "GET" && pathname === "/pipeline-images") {
177 const { imageSummaryList } = yield* listPipelineImages();
178 return yield* HttpServerResponse.json({
179 arns: (imageSummaryList ?? []).map((image) => image.arn),
180 });
181 }
182
183 // Account-level image listing.
184 if (request.method === "GET" && pathname === "/images") {
185 const { imageVersionList } = yield* listImages({ owner: "Self" });
186 return yield* HttpServerResponse.json({
187 count: (imageVersionList ?? []).length,
188 });
189 }
190
191 // Kick off a build of the bound pipeline.
192 if (request.method === "POST" && pathname === "/build/start") {
193 const { imageBuildVersionArn } = yield* startBuild();
194 return yield* HttpServerResponse.json({ imageBuildVersionArn });
195 }
196
197 // Cancel an in-flight build. Early build states can briefly reject
198 // cancellation — report the typed tag so the test can poll.
199 if (request.method === "POST" && pathname === "/build/cancel") {
200 const result = yield* cancelBuild({
201 imageBuildVersionArn: arn,
202 }).pipe(
203 Effect.map(({ imageBuildVersionArn }) => ({
204 imageBuildVersionArn,
205 })),
206 Effect.catchTag(
207 [
208 "ResourceInUseException",
209 "InvalidRequestException",
210 // The build version is not visible immediately after start.
211 "ResourceNotFoundException",
212 ],
213 (error) => Effect.succeed({ reason: error._tag }),
214 ),
215 );
216 return yield* HttpServerResponse.json(result);
217 }
218
219 // Read a build version's state.
220 if (request.method === "GET" && pathname === "/build") {
221 const { image } = yield* getImage({ imageBuildVersionArn: arn });
222 return yield* HttpServerResponse.json({
223 status: image?.state?.status,
224 });
225 }
226
227 // Drill into the build's workflow runs.
228 if (request.method === "GET" && pathname === "/build/workflows") {
229 const { workflowExecutions } = yield* listWorkflowExecutions({
230 imageBuildVersionArn: arn,
231 });
232 return yield* HttpServerResponse.json({
233 ids: (workflowExecutions ?? []).flatMap((execution) =>
234 execution.workflowExecutionId !== undefined
235 ? [execution.workflowExecutionId]
236 : [],
237 ),
238 });
239 }
240
241 // List the build versions of an image version.
242 if (request.method === "GET" && pathname === "/build-versions") {
243 const { imageSummaryList } = yield* listImageBuildVersions({
244 imageVersionArn: arn,
245 });
246 return yield* HttpServerResponse.json({
247 arns: (imageSummaryList ?? []).flatMap((image) =>
248 image.arn !== undefined ? [image.arn] : [],
249 ),
250 });
251 }
252
253 // Packages only exist once a build is AVAILABLE; earlier states
254 // report the typed rejection so the test can assert it.
255 if (request.method === "GET" && pathname === "/build/packages") {
256 const result = yield* listImagePackages({
257 imageBuildVersionArn: arn,
258 }).pipe(
259 Effect.map(({ imagePackageList }) => ({
260 count: (imagePackageList ?? []).length,
261 })),
262 Effect.catchTag(
263 ["InvalidRequestException", "ResourceNotFoundException"],
264 (error) => Effect.succeed({ reason: error._tag }),
265 ),
266 );
267 return yield* HttpServerResponse.json(result);
268 }
269
270 // Account-level Inspector scan findings (empty unless scanning is
271 // enabled somewhere in the account).
272 if (request.method === "GET" && pathname === "/scan-findings") {
273 const { findings } = yield* listImageScanFindings();
274 return yield* HttpServerResponse.json({
275 count: (findings ?? []).length,
276 });
277 }
278
279 if (request.method === "GET" && pathname === "/scan-aggregations") {
280 const { responses } = yield* listImageScanFindingAggregations();
281 return yield* HttpServerResponse.json({
282 count: (responses ?? []).length,
283 });
284 }
285
286 // Steps across the account waiting on WAIT_FOR_ACTION.
287 if (request.method === "GET" && pathname === "/waiting-steps") {
288 const { steps } = yield* listWaitingWorkflowSteps();
289 return yield* HttpServerResponse.json({
290 count: (steps ?? []).length,
291 });
292 }
293
294 // Read one workflow execution by id.
295 if (request.method === "GET" && pathname === "/workflow-execution") {
296 const execution = yield* getWorkflowExecution({
297 workflowExecutionId: id,
298 });
299 return yield* HttpServerResponse.json({
300 id: execution.workflowExecutionId,
301 type: execution.type,
302 status: execution.status,
303 });
304 }
305
306 // List the steps of one workflow execution.
307 if (request.method === "GET" && pathname === "/workflow-steps") {
308 const { steps } = yield* listWorkflowStepExecutions({
309 workflowExecutionId: id,
310 });
311 return yield* HttpServerResponse.json({
312 ids: (steps ?? []).flatMap((step) =>
313 step.stepExecutionId !== undefined ? [step.stepExecutionId] : [],
314 ),
315 });
316 }
317
318 // Read one workflow step by id.
319 if (request.method === "GET" && pathname === "/workflow-step") {
320 const step = yield* getWorkflowStepExecution({
321 stepExecutionId: id,
322 });
323 return yield* HttpServerResponse.json({
324 name: step.name,
325 status: step.status,
326 });
327 }
328
329 // Prune a build version; not-yet-deletable states report why so the
330 // test can poll until the build settles.
331 if (request.method === "DELETE" && pathname === "/build") {
332 const result = yield* deleteImage({
333 imageBuildVersionArn: arn,
334 }).pipe(
335 Effect.map(() => ({ deleted: true as const })),
336 // Already gone — deletion is idempotent.
337 Effect.catchTag("ResourceNotFoundException", () =>
338 Effect.succeed({ deleted: true as const }),
339 ),
340 Effect.catchTag(
341 ["InvalidRequestException", "ResourceDependencyException"],
342 (error) =>
343 Effect.succeed({ deleted: false as const, reason: error._tag }),
344 ),
345 );
346 return yield* HttpServerResponse.json(result);
347 }
348
349 return yield* HttpServerResponse.json(
350 { error: "Not found", method: request.method, pathname },
351 { status: 404 },
352 );
353 }).pipe(Effect.orDie),
354 };
355 }).pipe(
356 Effect.provide(
357 Layer.mergeAll(
358 Lambda.EventSource,
359 ImageBuilder.GetImagePipelineHttp,
360 ImageBuilder.ListImagePipelineImagesHttp,
361 ImageBuilder.StartImagePipelineExecutionHttp,
362 ImageBuilder.GetImageHttp,
363 ImageBuilder.CancelImageCreationHttp,
364 ImageBuilder.DeleteImageHttp,
365 ImageBuilder.ListImagesHttp,
366 ImageBuilder.ListWorkflowExecutionsHttp,
367 ImageBuilder.ListImageBuildVersionsHttp,
368 ImageBuilder.ListImagePackagesHttp,
369 ImageBuilder.ListImageScanFindingsHttp,
370 ImageBuilder.ListImageScanFindingAggregationsHttp,
371 ImageBuilder.GetWorkflowExecutionHttp,
372 ImageBuilder.GetWorkflowStepExecutionHttp,
373 ImageBuilder.ListWorkflowStepExecutionsHttp,
374 ImageBuilder.ListWaitingWorkflowStepsHttp,
375 ImageBuilder.SendWorkflowStepActionHttp,
376 ImageBuilder.RetryImageHttp,
377 ),
378 ),
379 ),
380);