| 1 | import * as EC2 from "@/AWS/EC2"; |
| 2 | import * as GlobalAccelerator from "@/AWS/GlobalAccelerator"; |
| 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 Result from "effect/Result"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 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 class GaTestFunction extends Lambda.Function<Lambda.Function>()( |
| 16 | "GaTestFunction", |
| 17 | ) {} |
| 18 | |
| 19 | // Global Accelerator serializes config changes per accelerator; a mutation |
| 20 | // racing an in-flight transaction is rejected with |
| 21 | // TransactionInProgressException. Retry briefly inside the Lambda (bounded |
| 22 | // well under the function timeout). |
| 23 | const retryGaTransaction = <A, E extends { _tag: string }, R>( |
| 24 | self: Effect.Effect<A, E, R>, |
| 25 | ): Effect.Effect<A, E, R> => |
| 26 | Effect.retry(self, { |
| 27 | while: (e): boolean => |
| 28 | e._tag === "TransactionInProgressException" || |
| 29 | e._tag === "ConflictException", |
| 30 | schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]), |
| 31 | }); |
| 32 | |
| 33 | export default GaTestFunction.make( |
| 34 | { |
| 35 | main, |
| 36 | functionUrl: true, |
| 37 | timeout: Duration.seconds(60), |
| 38 | }, |
| 39 | Effect.gen(function* () { |
| 40 | // The accelerator/listener/endpoint group the bindings are bound to. The |
| 41 | // endpoint group starts empty; the Add/RemoveEndpoints bindings register |
| 42 | // and deregister the Elastic IP below at runtime. |
| 43 | const accelerator = yield* GlobalAccelerator.Accelerator( |
| 44 | "BindingAccelerator", |
| 45 | {}, |
| 46 | ); |
| 47 | const listener = yield* GlobalAccelerator.Listener("BindingListener", { |
| 48 | acceleratorArn: accelerator.acceleratorArn, |
| 49 | portRanges: [{ fromPort: 80, toPort: 80 }], |
| 50 | protocol: "TCP", |
| 51 | }); |
| 52 | const group = yield* GlobalAccelerator.EndpointGroup("BindingGroup", { |
| 53 | listenerArn: listener.listenerArn, |
| 54 | endpointGroupRegion: "us-west-2", |
| 55 | }); |
| 56 | // An Elastic IP is the cheapest endpoint type Global Accelerator accepts |
| 57 | // (no instance/load balancer required). |
| 58 | const eip = yield* EC2.EIP("BindingEip", {}); |
| 59 | const AllocationId = yield* eip.allocationId; |
| 60 | |
| 61 | const describeAccelerator = |
| 62 | yield* GlobalAccelerator.DescribeAccelerator(accelerator); |
| 63 | const describeEndpointGroup = |
| 64 | yield* GlobalAccelerator.DescribeEndpointGroup(group); |
| 65 | const addEndpoints = yield* GlobalAccelerator.AddEndpoints(group); |
| 66 | const removeEndpoints = yield* GlobalAccelerator.RemoveEndpoints(group); |
| 67 | |
| 68 | const bound = { |
| 69 | describeAccelerator, |
| 70 | describeEndpointGroup, |
| 71 | addEndpoints, |
| 72 | removeEndpoints, |
| 73 | }; |
| 74 | |
| 75 | return { |
| 76 | fetch: Effect.gen(function* () { |
| 77 | const request = yield* HttpServerRequest; |
| 78 | const url = new URL(request.originalUrl); |
| 79 | const pathname = url.pathname; |
| 80 | |
| 81 | if (request.method === "GET" && pathname === "/bindings") { |
| 82 | return yield* HttpServerResponse.json({ |
| 83 | bound: Object.keys(bound), |
| 84 | }); |
| 85 | } |
| 86 | |
| 87 | // The Elastic IP the test uses to drive Add/RemoveEndpoints. |
| 88 | if (request.method === "GET" && pathname === "/context") { |
| 89 | const allocationId = yield* AllocationId; |
| 90 | return yield* HttpServerResponse.json({ allocationId }); |
| 91 | } |
| 92 | |
| 93 | // Accelerator-scoped read: the ARN is injected from the binding. |
| 94 | if (request.method === "GET" && pathname === "/accelerator") { |
| 95 | const { Accelerator } = yield* describeAccelerator(); |
| 96 | return yield* HttpServerResponse.json({ |
| 97 | name: Accelerator?.Name, |
| 98 | status: Accelerator?.Status, |
| 99 | dnsName: Accelerator?.DnsName, |
| 100 | enabled: Accelerator?.Enabled, |
| 101 | }); |
| 102 | } |
| 103 | |
| 104 | // Endpoint-group-scoped read: per-endpoint health. |
| 105 | if (request.method === "GET" && pathname === "/group") { |
| 106 | const { EndpointGroup } = yield* describeEndpointGroup({}); |
| 107 | return yield* HttpServerResponse.json({ |
| 108 | region: EndpointGroup?.EndpointGroupRegion, |
| 109 | endpoints: (EndpointGroup?.EndpointDescriptions ?? []).map( |
| 110 | (endpoint) => ({ |
| 111 | endpointId: endpoint.EndpointId, |
| 112 | healthState: endpoint.HealthState, |
| 113 | }), |
| 114 | ), |
| 115 | }); |
| 116 | } |
| 117 | |
| 118 | // Register the Elastic IP as an endpoint at runtime. Failures are |
| 119 | // surfaced as a 400 JSON body (with the typed tag) so the test fails |
| 120 | // fast and diagnostically instead of retrying an opaque 5xx. |
| 121 | if (request.method === "POST" && pathname === "/endpoints/add") { |
| 122 | const allocationId = yield* AllocationId; |
| 123 | const added = yield* Effect.result( |
| 124 | retryGaTransaction( |
| 125 | addEndpoints({ |
| 126 | EndpointConfigurations: [ |
| 127 | { EndpointId: allocationId, Weight: 64 }, |
| 128 | ], |
| 129 | }), |
| 130 | ), |
| 131 | ); |
| 132 | if (Result.isFailure(added)) { |
| 133 | return yield* HttpServerResponse.json( |
| 134 | { error: "addEndpoints", failure: JSON.stringify(added.failure) }, |
| 135 | { status: 400 }, |
| 136 | ); |
| 137 | } |
| 138 | return yield* HttpServerResponse.json({ |
| 139 | added: (added.success.EndpointDescriptions ?? []).map( |
| 140 | (endpoint) => endpoint.EndpointId, |
| 141 | ), |
| 142 | }); |
| 143 | } |
| 144 | |
| 145 | // Deregister the Elastic IP endpoint. |
| 146 | if (request.method === "POST" && pathname === "/endpoints/remove") { |
| 147 | const allocationId = yield* AllocationId; |
| 148 | const removed = yield* Effect.result( |
| 149 | retryGaTransaction( |
| 150 | removeEndpoints({ |
| 151 | EndpointIdentifiers: [{ EndpointId: allocationId }], |
| 152 | }), |
| 153 | ), |
| 154 | ); |
| 155 | if (Result.isFailure(removed)) { |
| 156 | return yield* HttpServerResponse.json( |
| 157 | { |
| 158 | error: "removeEndpoints", |
| 159 | failure: JSON.stringify(removed.failure), |
| 160 | }, |
| 161 | { status: 400 }, |
| 162 | ); |
| 163 | } |
| 164 | return yield* HttpServerResponse.json({ removed: allocationId }); |
| 165 | } |
| 166 | |
| 167 | return yield* HttpServerResponse.json( |
| 168 | { error: "Not found", method: request.method, pathname }, |
| 169 | { status: 404 }, |
| 170 | ); |
| 171 | }).pipe(Effect.orDie), |
| 172 | }; |
| 173 | }).pipe( |
| 174 | Effect.provide( |
| 175 | Layer.mergeAll( |
| 176 | GlobalAccelerator.DescribeAcceleratorHttp, |
| 177 | GlobalAccelerator.DescribeEndpointGroupHttp, |
| 178 | GlobalAccelerator.AddEndpointsHttp, |
| 179 | GlobalAccelerator.RemoveEndpointsHttp, |
| 180 | ), |
| 181 | ), |
| 182 | ), |
| 183 | ); |