| 1 | import * as CodeBuild from "@/AWS/CodeBuild"; |
| 2 | import * as IAM from "@/AWS/IAM"; |
| 3 | import * as Lambda from "@/AWS/Lambda"; |
| 4 | import * as Logs from "@/AWS/Logs"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import path from "pathe"; |
| 12 | |
| 13 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 14 | |
| 15 | export const FIXTURE_PROJECT_NAME = "alchemy-test-codebuild-bindings"; |
| 16 | export const FIXTURE_REPORT_GROUP_NAME = |
| 17 | "alchemy-test-codebuild-bindings-reports"; |
| 18 | |
| 19 | const buildspec = [ |
| 20 | "version: 0.2", |
| 21 | "phases:", |
| 22 | " build:", |
| 23 | " commands:", |
| 24 | " - echo Hello from CodeBuild bindings fixture", |
| 25 | ].join("\n"); |
| 26 | |
| 27 | export 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 | */ |
| 37 | const 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 | |
| 45 | export 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 | ); |