1import * as CodeBuild from "@/AWS/CodeBuild";
2import * as IAM from "@/AWS/IAM";
3import * as Lambda from "@/AWS/Lambda";
4import * as Logs from "@/AWS/Logs";
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 const FIXTURE_PROJECT_NAME = "alchemy-test-codebuild-bindings";
16export const FIXTURE_REPORT_GROUP_NAME =
17 "alchemy-test-codebuild-bindings-reports";
18
19const buildspec = [
20 "version: 0.2",
21 "phases:",
22 " build:",
23 " commands:",
24 " - echo Hello from CodeBuild bindings fixture",
25].join("\n");
26
27export class CodeBuildTestFunction extends Lambda.Function<Lambda.Function>()(
28 "CodeBuildTestFunction",
29) {}
30
31/**
32 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
33 * operation fails with a TYPED error — the test asserts the tag is a typed,
34 * non-authorization tag, which proves both the binding wiring and the IAM
35 * grant. An untyped error crashes into a 500 instead.
36 */
37const errorTagged = <A, E extends { _tag: string }, R>(
38 effect: Effect.Effect<A, E, R>,
39): Effect.Effect<A | { errorTag: string }, never, R> =>
40 effect.pipe(
41 Effect.map((a): A | { errorTag: string } => a),
42 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
43 );
44
45export default CodeBuildTestFunction.make(
46 {
47 main,
48 functionUrl: true,
49 // Build start/stop fan out SDK calls — AWS's 3s default intermittently
50 // times out under cold starts.
51 timeout: Duration.seconds(30),
52 },
53 Effect.gen(function* () {
54 const role = yield* IAM.Role("CodeBuildBindingsRole", {
55 assumeRolePolicyDocument: {
56 Version: "2012-10-17",
57 Statement: [
58 {
59 Effect: "Allow",
60 Principal: { Service: "codebuild.amazonaws.com" },
61 Action: ["sts:AssumeRole"],
62 },
63 ],
64 },
65 inlinePolicies: {
66 Logs: {
67 Version: "2012-10-17",
68 Statement: [
69 {
70 Effect: "Allow",
71 Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
72 Resource: ["*"],
73 },
74 ],
75 },
76 },
77 });
78
79 // Own CodeBuild's deterministic log destination in the stack instead of
80 // allowing the service to create its implicit `/aws/codebuild/...` group.
81 // Project depends on this output, so destroy deletes the Project before
82 // the LogGroup; removing CreateLogGroup above prevents a late-finishing
83 // build from recreating it after the observed LogGroup deletion.
84 const buildLogs = yield* Logs.LogGroup("BindingsBuildLogs", {
85 logGroupName: `/aws/codebuild/${FIXTURE_PROJECT_NAME}`,
86 retention: "1 day",
87 });
88
89 const project = yield* CodeBuild.Project("BindingsProject", {
90 projectName: FIXTURE_PROJECT_NAME,
91 serviceRole: role.roleArn,
92 source: { type: "NO_SOURCE", buildspec },
93 environment: {
94 image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
95 computeType: "BUILD_GENERAL1_SMALL",
96 },
97 logsConfig: {
98 cloudWatchLogs: {
99 status: "ENABLED",
100 groupName: buildLogs.logGroupName,
101 },
102 s3Logs: { status: "DISABLED" },
103 },
104 });
105
106 const reportGroup = yield* CodeBuild.ReportGroup("BindingsReports", {
107 reportGroupName: FIXTURE_REPORT_GROUP_NAME,
108 type: "TEST",
109 });
110
111 // Build plane
112 const startBuild = yield* CodeBuild.StartBuild(project);
113 const batchGetBuilds = yield* CodeBuild.BatchGetBuilds(project);
114 const stopBuild = yield* CodeBuild.StopBuild(project);
115 const retryBuild = yield* CodeBuild.RetryBuild(project);
116 const listBuilds = yield* CodeBuild.ListBuildsForProject(project);
117 const batchDeleteBuilds = yield* CodeBuild.BatchDeleteBuilds(project);
118 const invalidateProjectCache =
119 yield* CodeBuild.InvalidateProjectCache(project);
120 // Batch-build plane
121 const startBuildBatch = yield* CodeBuild.StartBuildBatch(project);
122 const stopBuildBatch = yield* CodeBuild.StopBuildBatch(project);
123 const retryBuildBatch = yield* CodeBuild.RetryBuildBatch(project);
124 const batchGetBuildBatches = yield* CodeBuild.BatchGetBuildBatches(project);
125 const listBuildBatches =
126 yield* CodeBuild.ListBuildBatchesForProject(project);
127 const deleteBuildBatch = yield* CodeBuild.DeleteBuildBatch(project);
128 // Sandbox plane
129 const startSandbox = yield* CodeBuild.StartSandbox(project);
130 const stopSandbox = yield* CodeBuild.StopSandbox(project);
131 const batchGetSandboxes = yield* CodeBuild.BatchGetSandboxes(project);
132 const listSandboxes = yield* CodeBuild.ListSandboxesForProject(project);
133 const startCommandExecution =
134 yield* CodeBuild.StartCommandExecution(project);
135 const batchGetCommandExecutions =
136 yield* CodeBuild.BatchGetCommandExecutions(project);
137 const listCommandExecutions =
138 yield* CodeBuild.ListCommandExecutionsForSandbox(project);
139 // Report plane
140 const listReports = yield* CodeBuild.ListReportsForReportGroup(reportGroup);
141 const batchGetReports = yield* CodeBuild.BatchGetReports(reportGroup);
142 const describeTestCases = yield* CodeBuild.DescribeTestCases(reportGroup);
143 const describeCodeCoverages =
144 yield* CodeBuild.DescribeCodeCoverages(reportGroup);
145 const getReportGroupTrend =
146 yield* CodeBuild.GetReportGroupTrend(reportGroup);
147 const deleteReport = yield* CodeBuild.DeleteReport(reportGroup);
148
149 // Deploy-time: creates the EventBridge rule (default bus, source
150 // aws.codebuild) targeting this Function. Runtime firing rides on the
151 // real builds the suite starts; the test verifies the rule deploys.
152 yield* CodeBuild.consumeBuildEvents({ kinds: ["state"] }, (events) =>
153 Stream.runForEach(events, (event) =>
154 Effect.log(
155 `codebuild event: ${event.detail["build-id"]} -> ${event.detail["build-status"]}`,
156 ),
157 ),
158 );
159
160 return {
161 fetch: Effect.gen(function* () {
162 const request = yield* HttpServerRequest;
163 const url = new URL(request.originalUrl);
164 const pathname = url.pathname;
165 const route = `${request.method} ${pathname}`;
166 const param = (name: string) => url.searchParams.get(name)!;
167
168 switch (route) {
169 // ---- build plane ----
170 case "POST /build/start": {
171 const result = yield* errorTagged(startBuild());
172 return yield* HttpServerResponse.json(
173 "errorTag" in result ? result : { buildId: result.build?.id },
174 );
175 }
176 case "GET /build/get": {
177 const result = yield* errorTagged(
178 batchGetBuilds({ ids: [param("id")] }),
179 );
180 return yield* HttpServerResponse.json(
181 "errorTag" in result
182 ? result
183 : { status: result.builds?.[0]?.buildStatus },
184 );
185 }
186 case "GET /build/list": {
187 const result = yield* errorTagged(listBuilds());
188 return yield* HttpServerResponse.json(
189 "errorTag" in result ? result : { ids: result.ids ?? [] },
190 );
191 }
192 case "POST /build/stop": {
193 const body = (yield* request.json) as unknown as { id: string };
194 const result = yield* errorTagged(stopBuild({ id: body.id }));
195 return yield* HttpServerResponse.json(
196 "errorTag" in result
197 ? result
198 : { status: result.build?.buildStatus },
199 );
200 }
201 case "POST /build/retry": {
202 const body = (yield* request.json) as unknown as { id: string };
203 const result = yield* errorTagged(retryBuild({ id: body.id }));
204 return yield* HttpServerResponse.json(
205 "errorTag" in result ? result : { buildId: result.build?.id },
206 );
207 }
208 case "POST /build/delete": {
209 const body = (yield* request.json) as unknown as {
210 ids: string[];
211 };
212 const result = yield* errorTagged(
213 batchDeleteBuilds({ ids: body.ids }),
214 );
215 return yield* HttpServerResponse.json(
216 "errorTag" in result
217 ? result
218 : {
219 deleted: result.buildsDeleted ?? [],
220 notDeleted: (result.buildsNotDeleted ?? []).map(
221 (b) => b.id,
222 ),
223 },
224 );
225 }
226 case "POST /cache/invalidate": {
227 const result = yield* errorTagged(invalidateProjectCache());
228 return yield* HttpServerResponse.json(
229 "errorTag" in result ? result : { ok: true },
230 );
231 }
232
233 // ---- batch-build plane ----
234 case "POST /batch/start": {
235 const result = yield* errorTagged(startBuildBatch());
236 return yield* HttpServerResponse.json(
237 "errorTag" in result
238 ? result
239 : { batchId: result.buildBatch?.id },
240 );
241 }
242 case "POST /batch/stop": {
243 const body = (yield* request.json) as unknown as { id: string };
244 const result = yield* errorTagged(stopBuildBatch({ id: body.id }));
245 return yield* HttpServerResponse.json(
246 "errorTag" in result ? result : { ok: true },
247 );
248 }
249 case "POST /batch/retry": {
250 const body = (yield* request.json) as unknown as { id: string };
251 const result = yield* errorTagged(retryBuildBatch({ id: body.id }));
252 return yield* HttpServerResponse.json(
253 "errorTag" in result ? result : { ok: true },
254 );
255 }
256 case "GET /batch/get": {
257 const result = yield* errorTagged(
258 batchGetBuildBatches({ ids: [param("id")] }),
259 );
260 return yield* HttpServerResponse.json(
261 "errorTag" in result
262 ? result
263 : {
264 found: (result.buildBatches ?? []).map((b) => b.id),
265 notFound: result.buildBatchesNotFound ?? [],
266 },
267 );
268 }
269 case "GET /batch/list": {
270 const result = yield* errorTagged(listBuildBatches());
271 return yield* HttpServerResponse.json(
272 "errorTag" in result ? result : { ids: result.ids ?? [] },
273 );
274 }
275 case "POST /batch/delete": {
276 const body = (yield* request.json) as unknown as { id: string };
277 const result = yield* errorTagged(
278 deleteBuildBatch({ id: body.id }),
279 );
280 return yield* HttpServerResponse.json(
281 "errorTag" in result ? result : { ok: true },
282 );
283 }
284
285 // ---- sandbox plane ----
286 case "POST /sandbox/start": {
287 const result = yield* errorTagged(startSandbox());
288 return yield* HttpServerResponse.json(
289 "errorTag" in result ? result : { sandboxId: result.sandbox?.id },
290 );
291 }
292 case "POST /sandbox/stop": {
293 const body = (yield* request.json) as unknown as { id: string };
294 const result = yield* errorTagged(stopSandbox({ id: body.id }));
295 return yield* HttpServerResponse.json(
296 "errorTag" in result ? result : { ok: true },
297 );
298 }
299 case "GET /sandbox/get": {
300 const result = yield* errorTagged(
301 batchGetSandboxes({ ids: [param("id")] }),
302 );
303 return yield* HttpServerResponse.json(
304 "errorTag" in result
305 ? result
306 : {
307 statuses: (result.sandboxes ?? []).map((s) => s.status),
308 },
309 );
310 }
311 case "GET /sandbox/list": {
312 const result = yield* errorTagged(listSandboxes());
313 return yield* HttpServerResponse.json(
314 "errorTag" in result ? result : { ids: result.ids ?? [] },
315 );
316 }
317 case "POST /sandbox/command": {
318 const body = (yield* request.json) as unknown as {
319 sandboxId: string;
320 command: string;
321 };
322 const result = yield* errorTagged(
323 startCommandExecution({
324 sandboxId: body.sandboxId,
325 command: body.command,
326 }),
327 );
328 return yield* HttpServerResponse.json(
329 "errorTag" in result
330 ? result
331 : { commandId: result.commandExecution?.id },
332 );
333 }
334 case "GET /sandbox/command-get": {
335 const result = yield* errorTagged(
336 batchGetCommandExecutions({
337 sandboxId: param("sandboxId"),
338 commandExecutionIds: [param("commandId")],
339 }),
340 );
341 return yield* HttpServerResponse.json(
342 "errorTag" in result
343 ? result
344 : {
345 statuses: (result.commandExecutions ?? []).map(
346 (c) => c.status,
347 ),
348 },
349 );
350 }
351 case "GET /sandbox/commands": {
352 const result = yield* errorTagged(
353 listCommandExecutions({ sandboxId: param("sandboxId") }),
354 );
355 return yield* HttpServerResponse.json(
356 "errorTag" in result
357 ? result
358 : {
359 count: (result.commandExecutions ?? []).length,
360 },
361 );
362 }
363
364 // ---- report plane ----
365 case "GET /reports/list": {
366 const result = yield* errorTagged(listReports());
367 return yield* HttpServerResponse.json(
368 "errorTag" in result ? result : { reports: result.reports ?? [] },
369 );
370 }
371 case "GET /reports/get": {
372 const result = yield* errorTagged(
373 batchGetReports({ reportArns: [param("arn")] }),
374 );
375 return yield* HttpServerResponse.json(
376 "errorTag" in result
377 ? result
378 : {
379 found: (result.reports ?? []).map((r) => r.arn),
380 notFound: result.reportsNotFound ?? [],
381 },
382 );
383 }
384 case "GET /reports/test-cases": {
385 const result = yield* errorTagged(
386 describeTestCases({ reportArn: param("arn") }),
387 );
388 return yield* HttpServerResponse.json(
389 "errorTag" in result
390 ? result
391 : { count: (result.testCases ?? []).length },
392 );
393 }
394 case "GET /reports/coverage": {
395 const result = yield* errorTagged(
396 describeCodeCoverages({ reportArn: param("arn") }),
397 );
398 return yield* HttpServerResponse.json(
399 "errorTag" in result
400 ? result
401 : { count: (result.codeCoverages ?? []).length },
402 );
403 }
404 case "GET /reports/trend": {
405 const result = yield* errorTagged(
406 getReportGroupTrend({ trendField: "DURATION" }),
407 );
408 return yield* HttpServerResponse.json(
409 "errorTag" in result
410 ? result
411 : { average: result.stats?.average },
412 );
413 }
414 case "POST /reports/delete": {
415 const body = (yield* request.json) as unknown as { arn: string };
416 const result = yield* errorTagged(deleteReport({ arn: body.arn }));
417 return yield* HttpServerResponse.json(
418 "errorTag" in result ? result : { ok: true },
419 );
420 }
421
422 default:
423 return yield* HttpServerResponse.json(
424 { error: "Not found", route },
425 { status: 404 },
426 );
427 }
428 }).pipe(Effect.orDie),
429 };
430 }).pipe(
431 Effect.provide(
432 Layer.mergeAll(
433 Lambda.EventSource,
434 CodeBuild.StartBuildHttp,
435 CodeBuild.BatchGetBuildsHttp,
436 CodeBuild.StopBuildHttp,
437 CodeBuild.RetryBuildHttp,
438 CodeBuild.ListBuildsForProjectHttp,
439 CodeBuild.BatchDeleteBuildsHttp,
440 CodeBuild.InvalidateProjectCacheHttp,
441 CodeBuild.StartBuildBatchHttp,
442 CodeBuild.StopBuildBatchHttp,
443 CodeBuild.RetryBuildBatchHttp,
444 CodeBuild.BatchGetBuildBatchesHttp,
445 CodeBuild.ListBuildBatchesForProjectHttp,
446 CodeBuild.DeleteBuildBatchHttp,
447 CodeBuild.StartSandboxHttp,
448 CodeBuild.StopSandboxHttp,
449 CodeBuild.BatchGetSandboxesHttp,
450 CodeBuild.ListSandboxesForProjectHttp,
451 CodeBuild.StartCommandExecutionHttp,
452 CodeBuild.BatchGetCommandExecutionsHttp,
453 CodeBuild.ListCommandExecutionsForSandboxHttp,
454 CodeBuild.ListReportsForReportGroupHttp,
455 CodeBuild.BatchGetReportsHttp,
456 CodeBuild.DescribeTestCasesHttp,
457 CodeBuild.DescribeCodeCoveragesHttp,
458 CodeBuild.GetReportGroupTrendHttp,
459 CodeBuild.DeleteReportHttp,
460 ),
461 ),
462 ),
463);