1/**
2 * Actions under `alchemy dev`: the common shapes a deploy-time binding client
3 * takes, each proven to land on the floci emulator rather than the real
4 * cloud.
5 *
6 * In a dev run every dualized resource resolves to its LOCAL provider, so the
7 * physical resource exists only on the emulator. An Action body runs at
8 * deploy time in the plan process — whose ambient AWS environment is the
9 * live `testing` profile — so without per-resource routing its binding
10 * clients would miss the emulated resource or mutate the real cloud. #1308
11 * routes each client to the bound resource's registered data plane; this
12 * suite pins that for the shapes users actually write:
13 *
14 * - one Action binding several services (S3 + SQS + DynamoDB) — each
15 * client routed independently;
16 * - an Action's output feeding a downstream resource's props;
17 * - a single-resource binding on a hand-written floci dual
18 * (`InvokeFunction(fn)`) — the case that used to be left UNROUTED and
19 * silently hit the real cloud;
20 * - a multi-resource binding spanning a `flociDual` and a hand-written
21 * dual (`RunTask(cluster, task)`) — the reported "mixed data planes"
22 * failure;
23 * - a binding that genuinely spans modes (`Alchemy.remote()` on one side)
24 * fails at bind time with a per-resource explanation.
25 *
26 * Out-of-band checks use the distilled SDK under `flociServices()` (the
27 * emulator) and, where it matters, the ambient live environment (to prove
28 * absence). Requires Docker (floci runs as a container).
29 */
30import { Action } from "@/Action";
31import * as AWS from "@/AWS";
32import { flociServices } from "@/AWS/Local/FlociServices.ts";
33import * as Alchemy from "@/index.ts";
34import * as Test from "@/Test/Alchemy";
35import { liveContext } from "./fixtures/live.ts";
36import * as DynamoDB from "@distilled.cloud/aws/dynamodb";
37import * as S3 from "@distilled.cloud/aws/s3";
38import * as SQS from "@distilled.cloud/aws/sqs";
39import * as SSM from "@distilled.cloud/aws/ssm";
40import { expect } from "alchemy-test";
41import * as Cause from "effect/Cause";
42import * as Effect from "effect/Effect";
43import * as Exit from "effect/Exit";
44import * as Layer from "effect/Layer";
45import * as Redacted from "effect/Redacted";
46import * as Stream from "effect/Stream";
47import { fileURLToPath } from "node:url";
48
49const { test } = Test.make({ providers: AWS.providers(), dev: true });
50
51const echoHandlerPath = fileURLToPath(
52 new URL("./fixtures/actions/echo.mjs", import.meta.url),
53);
54
55/** The emulator runs task containers on THIS machine. */
56const hostRuntimePlatform = {
57 cpuArchitecture:
58 process.arch === "arm64" ? ("ARM64" as const) : ("X86_64" as const),
59 operatingSystemFamily: "LINUX" as const,
60};
61
62/** Collect a streaming SDK body into its decoded text. */
63const readBody = (body: Stream.Stream<Uint8Array, Error>) =>
64 Stream.runCollect(body).pipe(
65 Effect.map((chunks) =>
66 new TextDecoder().decode(Buffer.concat([...chunks])),
67 ),
68 );
69
70test.provider(
71 "one Action binding S3, SQS and DynamoDB routes every client to the emulator",
72 (stack) =>
73 Effect.gen(function* () {
74 yield* stack.destroy();
75
76 const outputs = yield* stack.deploy(
77 Effect.gen(function* () {
78 const bucket = yield* AWS.S3.Bucket("ActionsBucket", {
79 forceDestroy: true,
80 });
81 const queue = yield* AWS.SQS.Queue("ActionsQueue");
82 const table = yield* AWS.DynamoDB.Table("ActionsTable", {
83 partitionKey: "id",
84 attributes: { id: "S" },
85 });
86
87 // A realistic "ingest" Action: write the object, record it, and
88 // enqueue its key — three services behind one body.
89 const Ingest = Action(
90 "Ingest",
91 Effect.gen(function* () {
92 const putObject = yield* AWS.S3.PutObject(bucket);
93 const getObject = yield* AWS.S3.GetObject(bucket);
94 const sendMessage = yield* AWS.SQS.SendMessage(queue);
95 const putItem = yield* AWS.DynamoDB.PutItem(table);
96 return Effect.fn(function* (input: { key: string }) {
97 yield* putObject({ Key: input.key, Body: "ingested" });
98 const object = yield* getObject({ Key: input.key });
99 const body = yield* readBody(object.Body!);
100 yield* putItem({
101 Item: { id: { S: input.key }, body: { S: body } },
102 });
103 const sent = yield* sendMessage({ MessageBody: input.key });
104 return { body, messageId: sent.MessageId };
105 });
106 }).pipe(
107 Effect.provide(
108 Layer.mergeAll(
109 AWS.S3.PutObjectHttp,
110 AWS.S3.GetObjectHttp,
111 AWS.SQS.SendMessageHttp,
112 AWS.DynamoDB.PutItemHttp,
113 ),
114 ),
115 ),
116 );
117 const ingested = yield* Ingest({ key: "uploads/hello.txt" });
118 return {
119 bucketName: bucket.bucketName,
120 queueUrl: queue.queueUrl,
121 tableName: table.tableName,
122 ingested,
123 };
124 }),
125 );
126
127 // The Action read back what it wrote — through the emulator.
128 expect(outputs.ingested.body).toBe("ingested");
129 expect(outputs.ingested.messageId).toBeTruthy();
130
131 // Every side effect landed on the emulator…
132 const head = yield* S3.headObject({
133 Bucket: outputs.bucketName,
134 Key: "uploads/hello.txt",
135 }).pipe(Effect.provide(flociServices()));
136 expect(head.ContentLength).toBe("ingested".length);
137
138 const item = yield* DynamoDB.getItem({
139 TableName: outputs.tableName,
140 Key: { id: { S: "uploads/hello.txt" } },
141 }).pipe(Effect.provide(flociServices()));
142 expect(item.Item?.body).toEqual({ S: "ingested" });
143
144 const received = yield* SQS.receiveMessage({
145 QueueUrl: outputs.queueUrl,
146 MaxNumberOfMessages: 1,
147 WaitTimeSeconds: 5,
148 }).pipe(Effect.provide(flociServices()));
149 expect(received.Messages?.[0]?.Body).toBe("uploads/hello.txt");
150
151 // …and the bucket never existed on the real cloud. The ambient
152 // environment in a dev run is the emulator, so the real cloud is
153 // reached EXPLICITLY via the live environment chain.
154 const live = yield* S3.headBucket({ Bucket: outputs.bucketName }).pipe(
155 Effect.map(() => "found" as const),
156 Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
157 Effect.provide(liveContext),
158 );
159 expect(live).toBe("not-found");
160
161 yield* stack.destroy();
162 }),
163 {
164 tags: [
165 "provider:aws",
166 "provider:aws:dynamodb",
167 "provider:aws:s3",
168 "provider:aws:sqs",
169 "live",
170 ],
171 timeout: 300_000,
172 },
173);
174
175test.provider(
176 "an Action's output feeds a downstream resource",
177 (stack) =>
178 Effect.gen(function* () {
179 yield* stack.destroy();
180
181 const outputs = yield* stack.deploy(
182 Effect.gen(function* () {
183 const source = yield* AWS.SSM.Parameter("ActionsSource", {
184 value: "region-a",
185 });
186 // Read one parameter through its binding, derive a value, and
187 // hand the Action's Output to a second resource's props.
188 const Derive = Action(
189 "Derive",
190 Effect.gen(function* () {
191 const getParameter = yield* AWS.SSM.GetParameter(source);
192 return Effect.fn(function* () {
193 const current = yield* getParameter({ WithDecryption: true });
194 const raw = current.Parameter?.Value;
195 const value =
196 typeof raw === "string" ? raw : Redacted.value(raw!);
197 return { derived: `${value}:derived` };
198 });
199 }).pipe(Effect.provide(AWS.SSM.GetParameterHttp)),
200 );
201 const derived = yield* Derive({});
202 const target = yield* AWS.SSM.Parameter("ActionsTarget", {
203 value: derived.derived,
204 });
205 return { sourceArn: source.parameterArn, target };
206 }),
207 );
208
209 expect(outputs.sourceArn).toContain(":000000000000:");
210 expect(outputs.target.parameterArn).toContain(":000000000000:");
211
212 // The downstream parameter carries the Action-derived value — on the
213 // emulator, where the Action's GetParameter read from.
214 const stored = yield* SSM.getParameter({
215 Name: outputs.target.parameterName,
216 }).pipe(Effect.provide(flociServices()));
217 const value = stored.Parameter?.Value;
218 expect(typeof value === "string" ? value : Redacted.value(value!)).toBe(
219 "region-a:derived",
220 );
221
222 yield* stack.destroy();
223 }),
224 { tags: ["provider:aws", "provider:aws:ssm", "local"], timeout: 300_000 },
225);
226
227test.provider(
228 "InvokeFunction(fn) Action invokes the dev Lambda on the emulator",
229 (stack) =>
230 Effect.gen(function* () {
231 yield* stack.destroy();
232
233 const outputs = yield* stack.deploy(
234 Effect.gen(function* () {
235 const fn = yield* AWS.Lambda.Function("ActionsEcho", {
236 main: echoHandlerPath,
237 handler: "handler",
238 bundle: false,
239 functionUrl: false,
240 });
241 // A single-resource binding on a hand-written floci dual. Before
242 // Lambda.Function declared its data plane this client was left
243 // unrouted and the invoke went to the real cloud.
244 const Ping = Action(
245 "Ping",
246 Effect.gen(function* () {
247 const invoke = yield* AWS.Lambda.InvokeFunction(fn);
248 return Effect.fn(function* (input: { message: string }) {
249 const response = yield* invoke({
250 Payload: new TextEncoder().encode(JSON.stringify(input)),
251 });
252 const result = JSON.parse(
253 yield* readBody(response.Payload!),
254 ) as { echoed: { message: string }; from: string };
255 return { statusCode: response.StatusCode, result };
256 });
257 }).pipe(Effect.provide(AWS.Lambda.InvokeFunctionHttp)),
258 );
259 return {
260 functionArn: fn.functionArn,
261 ping: yield* Ping({ message: "hello" }),
262 };
263 }),
264 );
265
266 expect(outputs.functionArn).toContain(":000000000000:");
267 expect(outputs.ping.statusCode).toBe(200);
268 expect(outputs.ping.result.from).toBe("floci-echo");
269 expect(outputs.ping.result.echoed).toEqual({ message: "hello" });
270
271 yield* stack.destroy();
272 }),
273 // First invoke pulls the Lambda runtime image on a cold machine.
274 { tags: ["provider:aws", "provider:aws:lambda", "local"], timeout: 600_000 },
275);
276
277test.provider(
278 "RunTask(cluster, task) Action launches the task on the emulator",
279 (stack) =>
280 Effect.gen(function* () {
281 yield* stack.destroy();
282
283 const outputs = yield* stack.deploy(
284 Effect.gen(function* () {
285 const cluster = yield* AWS.ECS.Cluster("ActionsCluster");
286 const task = yield* AWS.ECS.Task("ActionsTask", {
287 image: "busybox:stable",
288 command: ["true"],
289 cpu: 256,
290 memory: 512,
291 requiresCompatibilities: ["EC2"],
292 runtimePlatform: hostRuntimePlatform,
293 });
294 // A multi-resource binding spanning a `flociDual` (Cluster) and a
295 // hand-written dual (Task). The reported bug: with Task
296 // undeclared this bind died with "mixed data planes".
297 const Launch = Action(
298 "Launch",
299 Effect.gen(function* () {
300 const runTask = yield* AWS.ECS.RunTask(cluster, task);
301 return () =>
302 runTask({ launchType: "EC2", count: 1 }).pipe(
303 Effect.map((response) => ({
304 taskArn: response.tasks?.[0]?.taskArn,
305 failures: response.failures ?? [],
306 })),
307 );
308 }).pipe(Effect.provide(AWS.ECS.RunTaskHttp)),
309 );
310 return {
311 clusterArn: cluster.clusterArn,
312 launched: yield* Launch({}),
313 };
314 }),
315 );
316
317 expect(outputs.clusterArn).toContain(":000000000000:");
318 expect(outputs.launched.failures).toEqual([]);
319 expect(outputs.launched.taskArn).toContain(":000000000000:");
320
321 yield* stack.destroy();
322 }),
323 { tags: ["provider:aws", "provider:aws:ecs", "local"], timeout: 300_000 },
324);
325
326test.provider(
327 "a binding spanning a remote() resource and a local one fails at bind with a per-resource explanation",
328 (stack) =>
329 Effect.gen(function* () {
330 yield* stack.destroy();
331
332 const exit = yield* Effect.exit(
333 stack.deploy(
334 Effect.gen(function* () {
335 // Pinned live: its clients belong to the real cloud…
336 const cluster = yield* AWS.ECS.Cluster("MixedCluster").pipe(
337 Alchemy.remote(),
338 );
339 // …while the task is emulated. One API call cannot span both.
340 const task = yield* AWS.ECS.Task("MixedTask", {
341 image: "busybox:stable",
342 command: ["true"],
343 cpu: 256,
344 memory: 512,
345 requiresCompatibilities: ["EC2"],
346 runtimePlatform: hostRuntimePlatform,
347 });
348 const Launch = Action(
349 "MixedLaunch",
350 Effect.gen(function* () {
351 const runTask = yield* AWS.ECS.RunTask(cluster, task);
352 return () => runTask({ launchType: "EC2", count: 1 });
353 }).pipe(Effect.provide(AWS.ECS.RunTaskHttp)),
354 );
355 return { launched: yield* Launch({}) };
356 }),
357 ),
358 );
359
360 expect(Exit.isFailure(exit)).toBe(true);
361 const message = Exit.isFailure(exit) ? Cause.pretty(exit.cause) : "";
362 // Each resource is named with WHERE it lands and WHY — never the old
363 // "(some local, some live)" that hid which one and for what reason.
364 expect(message).toContain("Binding client spans mixed data planes");
365 expect(message).toContain("MixedCluster → real cloud (live mode)");
366 expect(message).toContain("MixedTask → local emulator");
367
368 yield* stack.destroy();
369 }),
370 { tags: ["provider:aws", "provider:aws:ecs", "live"], timeout: 300_000 },
371);