1import * as EC2 from "@/AWS/EC2";
2import * as GlobalAccelerator from "@/AWS/GlobalAccelerator";
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 Result from "effect/Result";
8import * as Schedule from "effect/Schedule";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15export 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).
23const 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
33export 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);