1import * as EC2 from "@/AWS/EC2";
2import * as ECS from "@/AWS/ECS";
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 { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13export class EcsBindingsTestFunction extends Lambda.Function<Lambda.Function>()(
14 "EcsBindingsTestFunction",
15) {}
16
17// `ECS.Task` is a Platform; its static type doesn't surface the
18// `ResourceClass.ref` that the runtime object carries (plain `Resource`
19// classes like `ECS.Cluster` type it). Narrow it here.
20const TaskRef = ECS.Task as unknown as {
21 ref: (id: string) => Effect.Effect<ECS.Task>;
22};
23
24/**
25 * Lambda fixture hosting the ECS task-control bindings:
26 * `RunTask`, `StopTask`, `DescribeTasks`, `ListTasks` — one HTTP route per
27 * operation.
28 *
29 * The cluster, one-shot task definition, and public subnet are deployed by
30 * the test's stack program (phase 1) and referenced here via
31 * `Resource.ref(...)`. Yielding them inline would re-execute their layers
32 * inside the Lambda runtime — and nesting a Platform resource (the ECS
33 * `Task`) under the Lambda bridge's intercepting ConfigProvider recurses
34 * config lookups until the sandbox OOMs. Refs resolve from stack state at
35 * deploy and from the injected environment at runtime.
36 */
37export default EcsBindingsTestFunction.make(
38 {
39 main,
40 functionUrl: true,
41 timeout: Duration.seconds(30),
42 memorySize: 512,
43 },
44 Effect.gen(function* () {
45 const cluster = yield* ECS.Cluster.ref("EcsBindingsCluster");
46 const task = yield* TaskRef.ref("EcsBindingsOneShotTask");
47 const subnet = yield* EC2.Subnet.ref("EcsBindingsSubnet");
48
49 const runTask = yield* ECS.RunTask(cluster, task);
50 const stopTask = yield* ECS.StopTask(cluster);
51 const describeTasks = yield* ECS.DescribeTasks(cluster);
52 const listTasks = yield* ECS.ListTasks(cluster);
53 const describeServices = yield* ECS.DescribeServices(cluster);
54 const listServices = yield* ECS.ListServices(cluster);
55 const listContainerInstances = yield* ECS.ListContainerInstances(cluster);
56 const getTaskProtection = yield* ECS.GetTaskProtection(cluster);
57 const updateTaskProtection = yield* ECS.UpdateTaskProtection(cluster);
58
59 const SubnetId = yield* subnet.subnetId;
60 const ContainerName = yield* task.containerName;
61
62 return {
63 fetch: Effect.gen(function* () {
64 const request = yield* HttpServerRequest;
65 const url = new URL(request.originalUrl);
66 const pathname = url.pathname;
67
68 if (request.method === "POST" && pathname === "/run") {
69 const body = (yield* request.json) as unknown as {
70 command?: string[];
71 startedBy?: string;
72 };
73 const subnetId = yield* SubnetId;
74 const containerName = yield* ContainerName;
75 const response = yield* runTask({
76 launchType: "FARGATE",
77 count: 1,
78 startedBy: body.startedBy ?? "alchemy-ecs-bindings-test",
79 networkConfiguration: {
80 awsvpcConfiguration: {
81 subnets: [subnetId],
82 // No securityGroups: the VPC default security group applies
83 // (all egress allowed — enough to pull the image from ECR).
84 assignPublicIp: "ENABLED",
85 },
86 },
87 ...(body.command
88 ? {
89 overrides: {
90 containerOverrides: [
91 { name: containerName, command: body.command },
92 ],
93 },
94 }
95 : {}),
96 });
97 return yield* HttpServerResponse.json({
98 taskArn: response.tasks?.[0]?.taskArn,
99 lastStatus: response.tasks?.[0]?.lastStatus,
100 failures: response.failures,
101 });
102 }
103
104 if (request.method === "POST" && pathname === "/stop") {
105 const body = (yield* request.json) as unknown as {
106 taskArn: string;
107 reason?: string;
108 };
109 const response = yield* stopTask({
110 task: body.taskArn,
111 reason: body.reason,
112 });
113 return yield* HttpServerResponse.json({
114 taskArn: response.task?.taskArn,
115 lastStatus: response.task?.lastStatus,
116 desiredStatus: response.task?.desiredStatus,
117 });
118 }
119
120 if (request.method === "GET" && pathname === "/describe") {
121 const taskArn = url.searchParams.get("task");
122 if (!taskArn) {
123 return HttpServerResponse.text("Missing task", { status: 400 });
124 }
125 const response = yield* describeTasks({ tasks: [taskArn] });
126 return yield* HttpServerResponse.json({
127 tasks: response.tasks?.map((t) => ({
128 taskArn: t.taskArn,
129 lastStatus: t.lastStatus,
130 stoppedReason: t.stoppedReason,
131 startedBy: t.startedBy,
132 containers: t.containers?.map((c) => ({
133 name: c.name,
134 exitCode: c.exitCode,
135 lastStatus: c.lastStatus,
136 })),
137 })),
138 failures: response.failures,
139 });
140 }
141
142 if (request.method === "GET" && pathname === "/list") {
143 const desiredStatus = url.searchParams.get("status");
144 const startedBy = url.searchParams.get("startedBy");
145 const response = yield* listTasks({
146 ...(desiredStatus
147 ? { desiredStatus: desiredStatus as "RUNNING" | "STOPPED" }
148 : {}),
149 ...(startedBy ? { startedBy } : {}),
150 });
151 return yield* HttpServerResponse.json({
152 taskArns: response.taskArns ?? [],
153 });
154 }
155
156 if (request.method === "GET" && pathname === "/services") {
157 const response = yield* listServices({});
158 return yield* HttpServerResponse.json({
159 serviceArns: response.serviceArns ?? [],
160 });
161 }
162
163 if (request.method === "GET" && pathname === "/describe-services") {
164 const service = url.searchParams.get("service");
165 if (!service) {
166 return HttpServerResponse.text("Missing service", { status: 400 });
167 }
168 const response = yield* describeServices({ services: [service] });
169 return yield* HttpServerResponse.json({
170 services: response.services?.map((s) => ({
171 serviceName: s.serviceName,
172 status: s.status,
173 })),
174 failures: response.failures,
175 });
176 }
177
178 if (request.method === "GET" && pathname === "/container-instances") {
179 const response = yield* listContainerInstances({});
180 return yield* HttpServerResponse.json({
181 containerInstanceArns: response.containerInstanceArns ?? [],
182 });
183 }
184
185 if (request.method === "GET" && pathname === "/protection") {
186 const taskArn = url.searchParams.get("task");
187 if (!taskArn) {
188 return HttpServerResponse.text("Missing task", { status: 400 });
189 }
190 const response = yield* getTaskProtection({ tasks: [taskArn] }).pipe(
191 Effect.map((r) => ({
192 protectedTasks: r.protectedTasks,
193 failures: r.failures,
194 })),
195 Effect.catchTag(
196 ["InvalidParameterException", "ResourceNotFoundException"],
197 (e) => Effect.succeed({ error: e._tag as string }),
198 ),
199 );
200 return yield* HttpServerResponse.json(response);
201 }
202
203 if (request.method === "POST" && pathname === "/protect") {
204 const body = (yield* request.json) as unknown as {
205 taskArn: string;
206 };
207 const response = yield* updateTaskProtection({
208 tasks: [body.taskArn],
209 protectionEnabled: true,
210 // Duration.Input → whole wire minutes via the central util.
211 expiresIn: "10 minutes",
212 }).pipe(
213 Effect.map((r) => ({
214 protectedTasks: r.protectedTasks,
215 failures: r.failures,
216 })),
217 Effect.catchTag(
218 ["InvalidParameterException", "ResourceNotFoundException"],
219 (e) => Effect.succeed({ error: e._tag as string }),
220 ),
221 );
222 return yield* HttpServerResponse.json(response);
223 }
224
225 return yield* HttpServerResponse.json(
226 { error: "Not found" },
227 { status: 404 },
228 );
229 }).pipe(Effect.orDie),
230 };
231 }).pipe(
232 Effect.provide(
233 Layer.mergeAll(
234 ECS.RunTaskHttp,
235 ECS.StopTaskHttp,
236 ECS.DescribeTasksHttp,
237 ECS.ListTasksHttp,
238 ECS.DescribeServicesHttp,
239 ECS.ListServicesHttp,
240 ECS.ListContainerInstancesHttp,
241 ECS.GetTaskProtectionHttp,
242 ECS.UpdateTaskProtectionHttp,
243 ),
244 ),
245 ),
246);