1import * as CodePipeline from "@/AWS/CodePipeline";
2import * as IAM from "@/AWS/IAM";
3import * as Lambda from "@/AWS/Lambda";
4import * as S3 from "@/AWS/S3";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Output from "@/Output";
9import * as Stream from "effect/Stream";
10import { HttpServerRequest } from "effect/http/HttpServerRequest";
11import * as HttpServerResponse from "effect/http/HttpServerResponse";
12import path from "pathe";
13
14const main = path.resolve(import.meta.dirname, "handler.ts");
15
16export const FIXTURE_PIPELINE_NAME = process.env.ALCHEMY_TEST_STAGE
17 ? `alchemy-test-codepipeline-bindings-${process.env.ALCHEMY_TEST_STAGE}`
18 : "alchemy-test-codepipeline-bindings";
19export const SOURCE_OBJECT_KEY = "source.zip";
20export const SOURCE_STAGE = "Source";
21export const SOURCE_ACTION = "S3Source";
22export const APPROVAL_STAGE = "Approve";
23export const APPROVAL_ACTION = "ManualApproval";
24
25/** A minimal valid (empty) zip archive: the end-of-central-directory record. */
26const EMPTY_ZIP = new Uint8Array([
27 0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
28]);
29
30export class CodePipelineTestFunction extends Lambda.Function<Lambda.Function>()(
31 "CodePipelineTestFunction",
32) {}
33
34/**
35 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
36 * operation fails with a TYPED error — the test asserts the tag is a typed,
37 * non-authorization tag, which proves both the binding wiring and the IAM
38 * grant. An untyped error crashes into a 500 instead.
39 */
40const errorTagged = <A, E extends { _tag: string; message?: string }, R>(
41 effect: Effect.Effect<A, E, R>,
42): Effect.Effect<A | { errorTag: string; errorMessage?: string }, never, R> =>
43 effect.pipe(
44 Effect.map((a): A | { errorTag: string; errorMessage?: string } => a),
45 Effect.catch((e) =>
46 Effect.succeed({ errorTag: e._tag, errorMessage: e.message }),
47 ),
48 );
49
50export default CodePipelineTestFunction.make(
51 {
52 main,
53 functionUrl: true,
54 // Pipeline state reads fan out SDK calls — AWS's 3s default
55 // intermittently times out under cold starts.
56 timeout: Duration.seconds(30),
57 },
58 Effect.gen(function* () {
59 const source = yield* S3.Bucket("PipelineBindingsSource", {
60 versioning: "Enabled",
61 forceDestroy: true,
62 });
63 const artifacts = yield* S3.Bucket("PipelineBindingsArtifacts", {
64 forceDestroy: true,
65 });
66
67 const role = yield* IAM.Role("PipelineBindingsRole", {
68 assumeRolePolicyDocument: {
69 Version: "2012-10-17",
70 Statement: [
71 {
72 Effect: "Allow",
73 Principal: { Service: "codepipeline.amazonaws.com" },
74 Action: ["sts:AssumeRole"],
75 },
76 ],
77 },
78 inlinePolicies: {
79 Artifacts: {
80 Version: "2012-10-17",
81 Statement: [
82 {
83 Effect: "Allow",
84 Action: [
85 "s3:GetObject",
86 "s3:GetObjectVersion",
87 "s3:PutObject",
88 "s3:GetBucketVersioning",
89 "s3:ListBucket",
90 ],
91 Resource: [
92 source.bucketArn,
93 Output.interpolate`${source.bucketArn}/*` as any,
94 artifacts.bucketArn,
95 Output.interpolate`${artifacts.bucketArn}/*` as any,
96 ],
97 },
98 ],
99 },
100 },
101 });
102
103 const pipeline = yield* CodePipeline.Pipeline("BindingsPipeline", {
104 pipelineName: FIXTURE_PIPELINE_NAME,
105 roleArn: role.roleArn,
106 artifactStore: { type: "S3", location: artifacts.bucketName },
107 stages: [
108 {
109 name: SOURCE_STAGE,
110 actions: [
111 {
112 name: SOURCE_ACTION,
113 category: "Source",
114 owner: "AWS",
115 provider: "S3",
116 outputArtifacts: ["SourceOutput"],
117 configuration: {
118 S3Bucket: source.bucketName,
119 S3ObjectKey: SOURCE_OBJECT_KEY,
120 PollForSourceChanges: "false",
121 },
122 },
123 ],
124 },
125 {
126 name: APPROVAL_STAGE,
127 actions: [
128 {
129 name: APPROVAL_ACTION,
130 category: "Approval",
131 owner: "AWS",
132 provider: "Manual",
133 },
134 ],
135 },
136 ],
137 });
138
139 // Source plane (cross-service: seed the source object from the Lambda).
140 const putObject = yield* S3.PutObject(source);
141 // Execution plane
142 const startExecution = yield* CodePipeline.StartPipelineExecution(pipeline);
143 const stopExecution = yield* CodePipeline.StopPipelineExecution(pipeline);
144 const getState = yield* CodePipeline.GetPipelineState(pipeline);
145 const getExecution = yield* CodePipeline.GetPipelineExecution(pipeline);
146 const listExecutions = yield* CodePipeline.ListPipelineExecutions(pipeline);
147 const listActions = yield* CodePipeline.ListActionExecutions(pipeline);
148 const listRules = yield* CodePipeline.ListRuleExecutions(pipeline);
149 const listDeployTargets =
150 yield* CodePipeline.ListDeployActionExecutionTargets(pipeline);
151 // Stage plane
152 const retryStage = yield* CodePipeline.RetryStageExecution(pipeline);
153 const rollbackStage = yield* CodePipeline.RollbackStage(pipeline);
154 const enableTransition =
155 yield* CodePipeline.EnableStageTransition(pipeline);
156 const disableTransition =
157 yield* CodePipeline.DisableStageTransition(pipeline);
158 const overrideCondition =
159 yield* CodePipeline.OverrideStageCondition(pipeline);
160 // Approval + source-revision plane
161 const putApproval = yield* CodePipeline.PutApprovalResult(pipeline);
162 const putActionRevision = yield* CodePipeline.PutActionRevision(pipeline);
163 // Job-worker plane (account-scoped — no resource anchor)
164 const getJobDetails = yield* CodePipeline.GetJobDetails();
165 const putJobSuccess = yield* CodePipeline.PutJobSuccessResult();
166 const putJobFailure = yield* CodePipeline.PutJobFailureResult();
167 const pollForJobs = yield* CodePipeline.PollForJobs();
168 const acknowledgeJob = yield* CodePipeline.AcknowledgeJob();
169
170 // Deploy-time: creates the EventBridge rule (default bus, source
171 // aws.codepipeline) targeting this Function. Runtime firing rides on the
172 // real executions the suite starts; the test verifies the rule deploys.
173 yield* CodePipeline.consumePipelineEvents(
174 { kinds: ["execution"], pipelineNames: [FIXTURE_PIPELINE_NAME] },
175 (events) =>
176 Stream.runForEach(events, (event) =>
177 Effect.log(
178 `codepipeline event: ${event.detail.pipeline} -> ${event.detail.state}`,
179 ),
180 ),
181 );
182
183 return {
184 fetch: Effect.gen(function* () {
185 const request = yield* HttpServerRequest;
186 const url = new URL(request.originalUrl);
187 const pathname = url.pathname;
188 const route = `${request.method} ${pathname}`;
189 const param = (name: string) => url.searchParams.get(name)!;
190
191 switch (route) {
192 // ---- source plane ----
193 case "POST /source/upload": {
194 const result = yield* errorTagged(
195 putObject({ Key: SOURCE_OBJECT_KEY, Body: EMPTY_ZIP }),
196 );
197 return yield* HttpServerResponse.json(
198 "errorTag" in result ? result : { versionId: result.VersionId },
199 );
200 }
201
202 // ---- execution plane ----
203 case "POST /execution/start": {
204 const result = yield* errorTagged(startExecution());
205 return yield* HttpServerResponse.json(
206 "errorTag" in result
207 ? result
208 : { executionId: result.pipelineExecutionId },
209 );
210 }
211 case "POST /execution/stop": {
212 const body = (yield* request.json) as unknown as {
213 id: string;
214 abandon?: boolean;
215 };
216 const result = yield* errorTagged(
217 stopExecution({
218 pipelineExecutionId: body.id,
219 abandon: body.abandon,
220 reason: "stopped by bindings test",
221 }),
222 );
223 return yield* HttpServerResponse.json(
224 "errorTag" in result
225 ? result
226 : { executionId: result.pipelineExecutionId },
227 );
228 }
229 case "GET /state": {
230 const result = yield* errorTagged(getState());
231 if ("errorTag" in result) {
232 return yield* HttpServerResponse.json(result);
233 }
234 const approvalState = result.stageStates
235 ?.find((s) => s.stageName === APPROVAL_STAGE)
236 ?.actionStates?.find((a) => a.actionName === APPROVAL_ACTION);
237 const approveStage = result.stageStates?.find(
238 (s) => s.stageName === APPROVAL_STAGE,
239 );
240 return yield* HttpServerResponse.json({
241 pipelineName: result.pipelineName,
242 stageNames: (result.stageStates ?? []).map((s) => s.stageName),
243 approvalToken: approvalState?.latestExecution?.token,
244 approvalStatus: approvalState?.latestExecution?.status,
245 inboundTransitionEnabled:
246 approveStage?.inboundTransitionState?.enabled,
247 });
248 }
249 case "GET /execution/get": {
250 const result = yield* errorTagged(
251 getExecution({ pipelineExecutionId: param("id") }),
252 );
253 return yield* HttpServerResponse.json(
254 "errorTag" in result
255 ? result
256 : { status: result.pipelineExecution?.status },
257 );
258 }
259 case "GET /executions": {
260 const result = yield* errorTagged(listExecutions());
261 return yield* HttpServerResponse.json(
262 "errorTag" in result
263 ? result
264 : {
265 ids: (result.pipelineExecutionSummaries ?? []).map(
266 (e) => e.pipelineExecutionId,
267 ),
268 },
269 );
270 }
271 case "GET /actions": {
272 const executionId = url.searchParams.get("executionId");
273 const result = yield* errorTagged(
274 listActions(
275 executionId !== null
276 ? { filter: { pipelineExecutionId: executionId } }
277 : undefined,
278 ),
279 );
280 return yield* HttpServerResponse.json(
281 "errorTag" in result
282 ? result
283 : {
284 actions: (result.actionExecutionDetails ?? []).map(
285 (a) => a.actionName,
286 ),
287 },
288 );
289 }
290 case "GET /rules": {
291 const result = yield* errorTagged(listRules());
292 return yield* HttpServerResponse.json(
293 "errorTag" in result
294 ? result
295 : { count: (result.ruleExecutionDetails ?? []).length },
296 );
297 }
298 case "GET /deploy-targets": {
299 const result = yield* errorTagged(
300 listDeployTargets({
301 actionExecutionId: param("actionExecutionId"),
302 }),
303 );
304 return yield* HttpServerResponse.json(
305 "errorTag" in result
306 ? result
307 : { count: (result.targets ?? []).length },
308 );
309 }
310
311 // ---- stage plane ----
312 case "POST /stage/retry": {
313 const body = (yield* request.json) as unknown as {
314 stageName: string;
315 executionId: string;
316 };
317 const result = yield* errorTagged(
318 retryStage({
319 stageName: body.stageName,
320 pipelineExecutionId: body.executionId,
321 retryMode: "FAILED_ACTIONS",
322 }),
323 );
324 return yield* HttpServerResponse.json(
325 "errorTag" in result
326 ? result
327 : { executionId: result.pipelineExecutionId },
328 );
329 }
330 case "POST /stage/rollback": {
331 const body = (yield* request.json) as unknown as {
332 stageName: string;
333 targetExecutionId: string;
334 };
335 const result = yield* errorTagged(
336 rollbackStage({
337 stageName: body.stageName,
338 targetPipelineExecutionId: body.targetExecutionId,
339 }),
340 );
341 return yield* HttpServerResponse.json(
342 "errorTag" in result
343 ? result
344 : { executionId: result.pipelineExecutionId },
345 );
346 }
347 case "POST /transition/disable": {
348 const body = (yield* request.json) as unknown as {
349 stageName: string;
350 };
351 const result = yield* errorTagged(
352 disableTransition({
353 stageName: body.stageName,
354 transitionType: "Inbound",
355 reason: "bindings test freeze",
356 }),
357 );
358 return yield* HttpServerResponse.json(
359 "errorTag" in result ? result : { ok: true },
360 );
361 }
362 case "POST /transition/enable": {
363 const body = (yield* request.json) as unknown as {
364 stageName: string;
365 };
366 const result = yield* errorTagged(
367 enableTransition({
368 stageName: body.stageName,
369 transitionType: "Inbound",
370 }),
371 );
372 return yield* HttpServerResponse.json(
373 "errorTag" in result ? result : { ok: true },
374 );
375 }
376 case "POST /condition/override": {
377 const body = (yield* request.json) as unknown as {
378 stageName: string;
379 executionId: string;
380 };
381 const result = yield* errorTagged(
382 overrideCondition({
383 stageName: body.stageName,
384 pipelineExecutionId: body.executionId,
385 conditionType: "BEFORE_ENTRY",
386 }),
387 );
388 return yield* HttpServerResponse.json(
389 "errorTag" in result ? result : { ok: true },
390 );
391 }
392
393 // ---- approval + source-revision plane ----
394 case "POST /approval": {
395 const body = (yield* request.json) as unknown as {
396 token: string;
397 status: "Approved" | "Rejected";
398 };
399 const result = yield* errorTagged(
400 putApproval({
401 stageName: APPROVAL_STAGE,
402 actionName: APPROVAL_ACTION,
403 token: body.token,
404 result: {
405 status: body.status,
406 summary: "answered by bindings test",
407 },
408 }),
409 );
410 return yield* HttpServerResponse.json(
411 "errorTag" in result ? result : { approvedAt: result.approvedAt },
412 );
413 }
414 case "POST /action/revision": {
415 const body = (yield* request.json) as unknown as {
416 actionName: string;
417 revisionId: string;
418 };
419 const result = yield* errorTagged(
420 putActionRevision({
421 stageName: SOURCE_STAGE,
422 actionName: body.actionName,
423 actionRevision: {
424 revisionId: body.revisionId,
425 revisionChangeId: body.revisionId,
426 created: new Date(),
427 },
428 }),
429 );
430 return yield* HttpServerResponse.json(
431 "errorTag" in result
432 ? result
433 : {
434 newRevision: result.newRevision,
435 executionId: result.pipelineExecutionId,
436 },
437 );
438 }
439
440 // ---- job-worker plane ----
441 case "GET /job/get": {
442 const result = yield* errorTagged(
443 getJobDetails({ jobId: param("id") }),
444 );
445 return yield* HttpServerResponse.json(
446 "errorTag" in result ? result : { jobId: result.jobDetails?.id },
447 );
448 }
449 case "POST /job/success": {
450 const body = (yield* request.json) as unknown as {
451 jobId: string;
452 };
453 const result = yield* errorTagged(
454 putJobSuccess({ jobId: body.jobId }),
455 );
456 return yield* HttpServerResponse.json(
457 "errorTag" in result ? result : { ok: true },
458 );
459 }
460 case "POST /job/failure": {
461 const body = (yield* request.json) as unknown as {
462 jobId: string;
463 };
464 const result = yield* errorTagged(
465 putJobFailure({
466 jobId: body.jobId,
467 failureDetails: {
468 type: "JobFailed",
469 message: "failed by bindings test",
470 },
471 }),
472 );
473 return yield* HttpServerResponse.json(
474 "errorTag" in result ? result : { ok: true },
475 );
476 }
477
478 case "POST /job/poll": {
479 // The fixture declares no custom action type — a typed
480 // ActionTypeNotFoundException proves wiring + grant.
481 const result = yield* errorTagged(
482 pollForJobs({
483 actionTypeId: {
484 category: "Build",
485 owner: "Custom",
486 provider: "AlchemyBindingsTest",
487 version: "1",
488 },
489 maxBatchSize: 1,
490 }),
491 );
492 return yield* HttpServerResponse.json(
493 "errorTag" in result
494 ? result
495 : { count: (result.jobs ?? []).length },
496 );
497 }
498 case "POST /job/ack": {
499 const body = (yield* request.json) as unknown as {
500 jobId: string;
501 nonce: string;
502 };
503 const result = yield* errorTagged(
504 acknowledgeJob({ jobId: body.jobId, nonce: body.nonce }),
505 );
506 return yield* HttpServerResponse.json(
507 "errorTag" in result ? result : { status: result.status },
508 );
509 }
510
511 default:
512 return yield* HttpServerResponse.json(
513 { error: "Not found", route },
514 { status: 404 },
515 );
516 }
517 }).pipe(Effect.orDie),
518 };
519 }).pipe(
520 Effect.provide(
521 Layer.mergeAll(
522 Lambda.EventSource,
523 S3.PutObjectHttp,
524 CodePipeline.StartPipelineExecutionHttp,
525 CodePipeline.StopPipelineExecutionHttp,
526 CodePipeline.GetPipelineStateHttp,
527 CodePipeline.GetPipelineExecutionHttp,
528 CodePipeline.ListPipelineExecutionsHttp,
529 CodePipeline.ListActionExecutionsHttp,
530 CodePipeline.ListRuleExecutionsHttp,
531 CodePipeline.ListDeployActionExecutionTargetsHttp,
532 CodePipeline.RetryStageExecutionHttp,
533 CodePipeline.RollbackStageHttp,
534 CodePipeline.EnableStageTransitionHttp,
535 CodePipeline.DisableStageTransitionHttp,
536 CodePipeline.OverrideStageConditionHttp,
537 CodePipeline.PutApprovalResultHttp,
538 CodePipeline.PutActionRevisionHttp,
539 CodePipeline.GetJobDetailsHttp,
540 CodePipeline.PutJobSuccessResultHttp,
541 CodePipeline.PutJobFailureResultHttp,
542 CodePipeline.PollForJobsHttp,
543 CodePipeline.AcknowledgeJobHttp,
544 ),
545 ),
546 ),
547);