| 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 | */ |
| 30 | import { Action } from "@/Action"; |
| 31 | import * as AWS from "@/AWS"; |
| 32 | import { flociServices } from "@/AWS/Local/FlociServices.ts"; |
| 33 | import * as Alchemy from "@/index.ts"; |
| 34 | import * as Test from "@/Test/Alchemy"; |
| 35 | import { liveContext } from "./fixtures/live.ts"; |
| 36 | import * as DynamoDB from "@distilled.cloud/aws/dynamodb"; |
| 37 | import * as S3 from "@distilled.cloud/aws/s3"; |
| 38 | import * as SQS from "@distilled.cloud/aws/sqs"; |
| 39 | import * as SSM from "@distilled.cloud/aws/ssm"; |
| 40 | import { expect } from "alchemy-test"; |
| 41 | import * as Cause from "effect/Cause"; |
| 42 | import * as Effect from "effect/Effect"; |
| 43 | import * as Exit from "effect/Exit"; |
| 44 | import * as Layer from "effect/Layer"; |
| 45 | import * as Redacted from "effect/Redacted"; |
| 46 | import * as Stream from "effect/Stream"; |
| 47 | import { fileURLToPath } from "node:url"; |
| 48 | |
| 49 | const { test } = Test.make({ providers: AWS.providers(), dev: true }); |
| 50 | |
| 51 | const echoHandlerPath = fileURLToPath( |
| 52 | new URL("./fixtures/actions/echo.mjs", import.meta.url), |
| 53 | ); |
| 54 | |
| 55 | /** The emulator runs task containers on THIS machine. */ |
| 56 | const 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. */ |
| 63 | const 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 | |
| 70 | test.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 | |
| 175 | test.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 | |
| 227 | test.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 | |
| 277 | test.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 | |
| 326 | test.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 | ); |