| 1 | import * as CodeDeploy from "@/AWS/CodeDeploy"; |
| 2 | import * as IAM from "@/AWS/IAM"; |
| 3 | import * as Lambda from "@/AWS/Lambda"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 13 | |
| 14 | export const FIXTURE_APPLICATION_NAME = "alchemy-test-codedeploy-bindings-app"; |
| 15 | export const FIXTURE_GROUP_NAME = "alchemy-test-codedeploy-bindings-dg"; |
| 16 | |
| 17 | /** A syntactically valid but nonexistent revision to address revision ops. */ |
| 18 | const 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 | |
| 27 | export 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 | */ |
| 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) => |
| 43 | Effect.logError(e).pipe(Effect.as({ errorTag: e._tag })), |
| 44 | ), |
| 45 | ); |
| 46 | |
| 47 | export 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 | ); |