1import * as CodeDeploy from "@/AWS/CodeDeploy";
2import * as IAM from "@/AWS/IAM";
3import * as Lambda from "@/AWS/Lambda";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export const FIXTURE_APPLICATION_NAME = "alchemy-test-codedeploy-bindings-app";
15export const FIXTURE_GROUP_NAME = "alchemy-test-codedeploy-bindings-dg";
16
17/** A syntactically valid but nonexistent revision to address revision ops. */
18const FIXTURE_REVISION = {
19 revisionType: "S3",
20 s3Location: {
21 bucket: "alchemy-test-codedeploy-bindings-nonexistent",
22 key: "app.zip",
23 bundleType: "zip",
24 },
25} as const;
26
27export class CodeDeployTestFunction extends Lambda.Function<Lambda.Function>()(
28 "CodeDeployTestFunction",
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) =>
43 Effect.logError(e).pipe(Effect.as({ errorTag: e._tag })),
44 ),
45 );
46
47export default CodeDeployTestFunction.make(
48 {
49 main,
50 functionUrl: true,
51 // Deployment ops fan out SDK calls — AWS's 3s default intermittently
52 // times out under cold starts.
53 timeout: Duration.seconds(30),
54 },
55 Effect.gen(function* () {
56 const role = yield* IAM.Role("CodeDeployBindingsRole", {
57 assumeRolePolicyDocument: {
58 Version: "2012-10-17",
59 Statement: [
60 {
61 Effect: "Allow",
62 Principal: { Service: "codedeploy.amazonaws.com" },
63 Action: ["sts:AssumeRole"],
64 },
65 ],
66 },
67 managedPolicyArns: [
68 "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambda",
69 ],
70 });
71
72 const app = yield* CodeDeploy.Application("BindingsApp", {
73 applicationName: FIXTURE_APPLICATION_NAME,
74 computePlatform: "Lambda",
75 });
76
77 const group = yield* CodeDeploy.DeploymentGroup("BindingsGroup", {
78 applicationName: app.applicationName,
79 deploymentGroupName: FIXTURE_GROUP_NAME,
80 serviceRoleArn: role.roleArn,
81 deploymentConfigName: "CodeDeployDefault.LambdaAllAtOnce",
82 deploymentStyle: {
83 deploymentType: "BLUE_GREEN",
84 deploymentOption: "WITH_TRAFFIC_CONTROL",
85 },
86 });
87
88 // Deployment plane
89 const createDeployment = yield* CodeDeploy.CreateDeployment(group);
90 const getDeployment = yield* CodeDeploy.GetDeployment(group);
91 const batchGetDeployments = yield* CodeDeploy.BatchGetDeployments(group);
92 const listDeployments = yield* CodeDeploy.ListDeployments(group);
93 const stopDeployment = yield* CodeDeploy.StopDeployment(group);
94 const continueDeployment = yield* CodeDeploy.ContinueDeployment(group);
95 const putHookStatus =
96 yield* CodeDeploy.PutLifecycleEventHookExecutionStatus(group);
97 // Target plane
98 const getDeploymentTarget = yield* CodeDeploy.GetDeploymentTarget(group);
99 const listDeploymentTargets =
100 yield* CodeDeploy.ListDeploymentTargets(group);
101 const batchGetDeploymentTargets =
102 yield* CodeDeploy.BatchGetDeploymentTargets(group);
103 // Revision plane
104 const registerApplicationRevision =
105 yield* CodeDeploy.RegisterApplicationRevision(app);
106 const getApplicationRevision =
107 yield* CodeDeploy.GetApplicationRevision(app);
108 const listApplicationRevisions =
109 yield* CodeDeploy.ListApplicationRevisions(app);
110 const batchGetApplicationRevisions =
111 yield* CodeDeploy.BatchGetApplicationRevisions(app);
112
113 // Deploy-time: creates the EventBridge rule (default bus, source
114 // aws.codedeploy) targeting this Function. Runtime firing rides on real
115 // deployments; the test verifies the rule deploys.
116 yield* CodeDeploy.consumeDeploymentEvents(
117 { kinds: ["deployment"], applications: [FIXTURE_APPLICATION_NAME] },
118 (events) =>
119 Stream.runForEach(events, (event) =>
120 Effect.log(
121 `codedeploy event: ${event.detail.deploymentId} -> ${event.detail.state}`,
122 ),
123 ),
124 );
125
126 return {
127 fetch: Effect.gen(function* () {
128 const request = yield* HttpServerRequest;
129 const url = new URL(request.originalUrl);
130 const pathname = url.pathname;
131 const route = `${request.method} ${pathname}`;
132 const param = (name: string) => url.searchParams.get(name)!;
133
134 switch (route) {
135 // ---- deployment plane ----
136 case "POST /deployment/create": {
137 // No revision on purpose — CodeDeploy must answer with the
138 // TYPED RevisionRequiredException, which proves the binding
139 // wiring, the name injection, and the IAM grant.
140 const result = yield* errorTagged(createDeployment());
141 return yield* HttpServerResponse.json(
142 "errorTag" in result
143 ? result
144 : { deploymentId: result.deploymentId },
145 );
146 }
147 case "GET /deployment/get": {
148 const result = yield* errorTagged(
149 getDeployment({ deploymentId: param("id") }),
150 );
151 return yield* HttpServerResponse.json(
152 "errorTag" in result
153 ? result
154 : { status: result.deploymentInfo?.status },
155 );
156 }
157 case "GET /deployment/batch-get": {
158 const result = yield* errorTagged(
159 batchGetDeployments({ deploymentIds: [param("id")] }),
160 );
161 return yield* HttpServerResponse.json(
162 "errorTag" in result
163 ? result
164 : { count: (result.deploymentsInfo ?? []).length },
165 );
166 }
167 case "GET /deployment/list": {
168 const result = yield* errorTagged(listDeployments());
169 return yield* HttpServerResponse.json(
170 "errorTag" in result
171 ? result
172 : { deployments: result.deployments ?? [] },
173 );
174 }
175 case "POST /deployment/stop": {
176 const body = (yield* request.json) as unknown as { id: string };
177 const result = yield* errorTagged(
178 stopDeployment({ deploymentId: body.id }),
179 );
180 return yield* HttpServerResponse.json(
181 "errorTag" in result ? result : { status: result.status },
182 );
183 }
184 case "POST /deployment/continue": {
185 const body = (yield* request.json) as unknown as { id: string };
186 const result = yield* errorTagged(
187 continueDeployment({ deploymentId: body.id }),
188 );
189 return yield* HttpServerResponse.json(
190 "errorTag" in result ? result : { ok: true },
191 );
192 }
193 case "POST /hook/status": {
194 const body = (yield* request.json) as unknown as {
195 deploymentId: string;
196 executionId: string;
197 };
198 const result = yield* errorTagged(
199 putHookStatus({
200 deploymentId: body.deploymentId,
201 lifecycleEventHookExecutionId: body.executionId,
202 status: "Succeeded",
203 }),
204 );
205 return yield* HttpServerResponse.json(
206 "errorTag" in result ? result : { ok: true },
207 );
208 }
209
210 // ---- target plane ----
211 case "GET /target/get": {
212 const result = yield* errorTagged(
213 getDeploymentTarget({
214 deploymentId: param("deploymentId"),
215 targetId: param("targetId"),
216 }),
217 );
218 return yield* HttpServerResponse.json(
219 "errorTag" in result
220 ? result
221 : { status: result.deploymentTarget?.lambdaTarget?.status },
222 );
223 }
224 case "GET /target/list": {
225 const result = yield* errorTagged(
226 listDeploymentTargets({ deploymentId: param("deploymentId") }),
227 );
228 return yield* HttpServerResponse.json(
229 "errorTag" in result
230 ? result
231 : { targetIds: result.targetIds ?? [] },
232 );
233 }
234 case "GET /target/batch-get": {
235 const result = yield* errorTagged(
236 batchGetDeploymentTargets({
237 deploymentId: param("deploymentId"),
238 targetIds: [param("targetId")],
239 }),
240 );
241 return yield* HttpServerResponse.json(
242 "errorTag" in result
243 ? result
244 : { count: (result.deploymentTargets ?? []).length },
245 );
246 }
247
248 // ---- revision plane ----
249 case "POST /revision/register": {
250 const result = yield* errorTagged(
251 registerApplicationRevision({
252 revision: FIXTURE_REVISION,
253 description: "alchemy CodeDeploy bindings fixture revision",
254 }),
255 );
256 return yield* HttpServerResponse.json(
257 "errorTag" in result ? result : { ok: true },
258 );
259 }
260 case "GET /revision/get": {
261 const result = yield* errorTagged(
262 getApplicationRevision({ revision: FIXTURE_REVISION }),
263 );
264 return yield* HttpServerResponse.json(
265 "errorTag" in result
266 ? result
267 : {
268 description: result.revisionInfo?.description,
269 },
270 );
271 }
272 case "GET /revision/list": {
273 const result = yield* errorTagged(listApplicationRevisions());
274 return yield* HttpServerResponse.json(
275 "errorTag" in result
276 ? result
277 : { count: (result.revisions ?? []).length },
278 );
279 }
280 case "GET /revision/batch-get": {
281 const result = yield* errorTagged(
282 batchGetApplicationRevisions({
283 revisions: [FIXTURE_REVISION],
284 }),
285 );
286 return yield* HttpServerResponse.json(
287 "errorTag" in result
288 ? result
289 : { count: (result.revisions ?? []).length },
290 );
291 }
292
293 default:
294 return yield* HttpServerResponse.json(
295 { error: "Not found", route },
296 { status: 404 },
297 );
298 }
299 }).pipe(Effect.orDie),
300 };
301 }).pipe(
302 Effect.provide(
303 Layer.mergeAll(
304 Lambda.EventSource,
305 CodeDeploy.CreateDeploymentHttp,
306 CodeDeploy.GetDeploymentHttp,
307 CodeDeploy.BatchGetDeploymentsHttp,
308 CodeDeploy.ListDeploymentsHttp,
309 CodeDeploy.StopDeploymentHttp,
310 CodeDeploy.ContinueDeploymentHttp,
311 CodeDeploy.PutLifecycleEventHookExecutionStatusHttp,
312 CodeDeploy.GetDeploymentTargetHttp,
313 CodeDeploy.ListDeploymentTargetsHttp,
314 CodeDeploy.BatchGetDeploymentTargetsHttp,
315 CodeDeploy.RegisterApplicationRevisionHttp,
316 CodeDeploy.GetApplicationRevisionHttp,
317 CodeDeploy.ListApplicationRevisionsHttp,
318 CodeDeploy.BatchGetApplicationRevisionsHttp,
319 ),
320 ),
321 ),
322);