1/**
2 * `AWS.Lambda.Function` under `alchemy dev`: the dualized provider deploys
3 * the function INTO the floci emulator (RPC-sidecar-hosted
4 * `FlociFunctionProvider`) and hot-swaps its code on file change.
5 *
6 * Proof structure:
7 * - attrs carry the emulator shape — dummy-account ARN
8 * (`:000000000000:`) and a `*.lambda-url.us-east-1.localhost:4566`
9 * function URL — which the live cloud can never produce;
10 * - the function URL is host-reachable and serves the fixture's marker;
11 * - rewriting the fixture source (in a CLONE, the repo tree stays clean)
12 * is observed at the URL without another deploy — the sidecar watch
13 * loop rebuilds, uploads to the emulator's assets bucket, and floci's
14 * reactive S3 sync re-extracts (measured latency reported);
15 * - an engine-driven update (env change) between two rewrites does not
16 * detach the function from the watch loop's dev key — the next rewrite
17 * still serves (regression: the watcher memoized its one-time
18 * enrollment and never re-pointed the function after the live
19 * reconcile moved it to a content-addressed key);
20 * - a dualized Queue + a `bundle: false` Function + a distilled
21 * event-source mapping pump SQS messages through the containerized
22 * function into a dualized Bucket;
23 * - after destroy the function is gone from the emulator.
24 *
25 * Requires Docker (floci + the Lambda runtime container); skipped when the
26 * daemon is unavailable.
27 */
28import * as AWS from "@/AWS";
29import * as Endpoint from "@/AWS/Endpoint.ts";
30import * as Region from "@/AWS/Region.ts";
31import * as Test from "@/Test/Alchemy";
32import { Credentials } from "@distilled.cloud/aws/Credentials";
33import type { RegionName } from "@distilled.cloud/aws/Region";
34import * as Lambda from "@distilled.cloud/aws/lambda";
35import * as SQS from "@distilled.cloud/aws/sqs";
36import { expect } from "alchemy-test";
37import * as Effect from "effect/Effect";
38import * as FileSystem from "effect/FileSystem";
39import * as Layer from "effect/Layer";
40import * as Path from "effect/Path";
41import * as Redacted from "effect/Redacted";
42import * as Schedule from "effect/Schedule";
43import * as HttpClient from "effect/http/HttpClient";
44import { fileURLToPath } from "node:url";
45import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
46import {
47 dockerAvailable,
48 FLOCI_ENDPOINT,
49 rawS3GetObject,
50} from "../Local/fixtures/raw.ts";
51
52const { test } = Test.make({ providers: AWS.providers(), dev: true });
53
54const devFixtureDir = fileURLToPath(
55 new URL("./fixtures/floci-dev", import.meta.url),
56);
57const esmHandlerPath = fileURLToPath(
58 new URL("./fixtures/floci-esm/handler.mjs", import.meta.url),
59);
60
61/** Floci-scoped context for the raw distilled calls the test makes itself. */
62const flociContext = Layer.mergeAll(
63 Endpoint.of(FLOCI_ENDPOINT),
64 Region.of("us-east-1"),
65 Layer.succeed(
66 Credentials,
67 Effect.succeed({
68 accessKeyId: Redacted.make("test"),
69 secretAccessKey: Redacted.make("test"),
70 sessionToken: undefined,
71 region: "us-east-1" as RegionName,
72 }),
73 ),
74);
75
76test.provider.skipIf(!dockerAvailable)(
77 "dev deploys a Function into floci with a reachable URL and hot reload",
78 (stack) =>
79 Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const path = yield* Path.Path;
82 const client = yield* HttpClient.HttpClient;
83
84 yield* stack.destroy();
85
86 // Clone the fixture so the hot-reload rewrite never touches the repo
87 // tree.
88 const clone = yield* cloneFixture(devFixtureDir, {
89 prefix: "floci-dev-fn-",
90 });
91 const mainPath = path.join(clone, "handler.ts");
92
93 const fn = yield* stack.deploy(
94 AWS.Lambda.Function("DevFn", {
95 main: mainPath,
96 handler: "handler",
97 isExternal: true,
98 functionUrl: true,
99 env: { DEV_MARKER: "env-carried" },
100 }),
101 );
102
103 // Emulator-shaped identity: the live cloud can never produce these.
104 expect(fn.functionArn).toContain(":000000000000:");
105 expect(fn.functionArn).toContain(":us-east-1:");
106 expect(fn.functionUrl).toBeDefined();
107 expect(fn.functionUrl).toContain("localhost:4566");
108
109 // First invoke pulls the Lambda runtime image (~4.4s cold), then warm.
110 const first = yield* client.get(fn.functionUrl!).pipe(
111 Effect.flatMap((response) =>
112 Effect.map(response.text, (body) => ({
113 status: response.status,
114 body,
115 })),
116 ),
117 Effect.retry({
118 while: (): boolean => true,
119 schedule: Schedule.spaced("2 seconds"),
120 times: 60,
121 }),
122 Effect.repeat({
123 schedule: Schedule.spaced("2 seconds"),
124 until: (res): boolean =>
125 res.status === 200 && res.body.startsWith("marker-v1"),
126 times: 60,
127 }),
128 );
129 expect(first.status).toBe(200);
130 expect(first.body).toBe("marker-v1:env-carried");
131
132 // Hot reload: rewrite the CLONED source and wait (bounded) for the
133 // new marker to serve — no deploy in between. The sidecar watch loop
134 // rebuilds + uploads; floci's reactive S3 sync re-extracts.
135 const source = yield* fs.readFileString(mainPath);
136 const swapStartedAt = Date.now();
137 yield* fs.writeFileString(
138 mainPath,
139 source.replace(`"marker-v1"`, `"marker-v2"`),
140 );
141
142 const swapped = yield* client.get(fn.functionUrl!).pipe(
143 Effect.flatMap((response) =>
144 Effect.map(response.text, (body) => ({
145 status: response.status,
146 body,
147 })),
148 ),
149 Effect.retry({
150 while: (): boolean => true,
151 schedule: Schedule.spaced("250 millis"),
152 times: 20,
153 }),
154 Effect.repeat({
155 schedule: Schedule.spaced("250 millis"),
156 until: (res): boolean =>
157 res.status === 200 && res.body.startsWith("marker-v2"),
158 times: 240,
159 }),
160 );
161 const swapLatencyMs = Date.now() - swapStartedAt;
162 expect(swapped.body).toBe("marker-v2:env-carried");
163 yield* Effect.log(
164 `hot reload observed at the function URL in ${swapLatencyMs}ms`,
165 );
166
167 // Second swap: the function's code is now enrolled on the stable dev
168 // S3 key, so this one is a bare PutObject + floci reactive re-extract
169 // (no Lambda API call).
170 const secondStartedAt = Date.now();
171 yield* fs.writeFileString(
172 mainPath,
173 source.replace(`"marker-v1"`, `"marker-v3"`),
174 );
175 const reswapped = yield* client.get(fn.functionUrl!).pipe(
176 Effect.flatMap((response) =>
177 Effect.map(response.text, (body) => ({
178 status: response.status,
179 body,
180 })),
181 ),
182 Effect.retry({
183 while: (): boolean => true,
184 schedule: Schedule.spaced("250 millis"),
185 times: 20,
186 }),
187 Effect.repeat({
188 schedule: Schedule.spaced("250 millis"),
189 until: (res): boolean =>
190 res.status === 200 && res.body.startsWith("marker-v3"),
191 times: 240,
192 }),
193 );
194 const secondSwapLatencyMs = Date.now() - secondStartedAt;
195 expect(reswapped.body).toBe("marker-v3:env-carried");
196 yield* Effect.log(
197 `second hot reload (reactive S3 sync) observed in ${secondSwapLatencyMs}ms`,
198 );
199
200 // Engine-driven UPDATE between swaps (a prop change — here `env`):
201 // the live reconcile re-uploads the bundle to a content-addressed key
202 // and re-points the function THERE, detaching it from the watch
203 // loop's stable dev key. The watcher must notice and re-enroll on its
204 // next swap; before the fix the marker below never served (the
205 // PutObject landed on a key the function no longer read) until the
206 // next engine update happened to re-bundle it.
207 const updated = yield* stack.deploy(
208 AWS.Lambda.Function("DevFn", {
209 main: mainPath,
210 handler: "handler",
211 isExternal: true,
212 functionUrl: true,
213 env: { DEV_MARKER: "env-updated" },
214 }),
215 );
216 expect(updated.functionUrl).toBe(fn.functionUrl);
217 const afterUpdate = yield* client.get(fn.functionUrl!).pipe(
218 Effect.flatMap((response) =>
219 Effect.map(response.text, (body) => ({
220 status: response.status,
221 body,
222 })),
223 ),
224 Effect.retry({
225 while: (): boolean => true,
226 schedule: Schedule.spaced("250 millis"),
227 times: 20,
228 }),
229 Effect.repeat({
230 schedule: Schedule.spaced("250 millis"),
231 until: (res): boolean =>
232 res.status === 200 && res.body === "marker-v3:env-updated",
233 times: 240,
234 }),
235 );
236 expect(afterUpdate.body).toBe("marker-v3:env-updated");
237
238 yield* fs.writeFileString(
239 mainPath,
240 source.replace(`"marker-v1"`, `"marker-v4"`),
241 );
242 const afterUpdateSwap = yield* client.get(fn.functionUrl!).pipe(
243 Effect.flatMap((response) =>
244 Effect.map(response.text, (body) => ({
245 status: response.status,
246 body,
247 })),
248 ),
249 Effect.retry({
250 while: (): boolean => true,
251 schedule: Schedule.spaced("250 millis"),
252 times: 20,
253 }),
254 Effect.repeat({
255 schedule: Schedule.spaced("250 millis"),
256 until: (res): boolean =>
257 res.status === 200 && res.body.startsWith("marker-v4"),
258 times: 240,
259 }),
260 );
261 expect(afterUpdateSwap.body).toBe("marker-v4:env-updated");
262
263 // Destroy: the function (and its role) must be gone from the
264 // emulator.
265 yield* stack.destroy();
266 const gone = yield* Lambda.getFunction({
267 FunctionName: fn.functionName,
268 }).pipe(
269 Effect.map(() => false),
270 Effect.catchTag("ResourceNotFoundException", () =>
271 Effect.succeed(true),
272 ),
273 Effect.provide(flociContext),
274 Effect.repeat({
275 schedule: Schedule.spaced("1 second"),
276 until: (isGone): boolean => isGone,
277 times: 20,
278 }),
279 );
280 expect(gone).toBe(true);
281 }),
282 { tags: ["provider:aws", "provider:aws:lambda", "local"], timeout: 540_000 },
283);
284
285test.provider.skipIf(!dockerAvailable)(
286 "SQS -> dev Function (bundle: false) -> S3 event pumping in the emulator",
287 (stack) =>
288 Effect.gen(function* () {
289 yield* stack.destroy();
290
291 const markerKey = "floci-dev-esm-consumed.txt";
292
293 const outputs = yield* stack.deploy(
294 Effect.gen(function* () {
295 const queue = yield* AWS.SQS.Queue("DevEsmQueue");
296 const bucket = yield* AWS.S3.Bucket("DevEsmTarget", {
297 forceDestroy: true,
298 });
299 const fn = yield* AWS.Lambda.Function("DevEsmFn", {
300 main: esmHandlerPath,
301 handler: "handler",
302 bundle: false,
303 functionUrl: false,
304 env: { TARGET_BUCKET: bucket.bucketName },
305 });
306 // The dualized RESOURCE (requires the alchemy floci fork ≥
307 // 1.6.0-alchemy.2: its reconciler's ownership scan calls lambda
308 // ListTags on the `event-source-mapping:` ARN, and creation
309 // brands it with Tags).
310 const esm = yield* AWS.Lambda.EventSourceMapping("DevEsm", {
311 functionName: fn.functionName,
312 eventSourceArn: queue.queueArn,
313 batchSize: 1,
314 enabled: true,
315 });
316 return { queue, bucket, fn, esm };
317 }),
318 );
319 expect(outputs.queue.queueArn).toContain(":000000000000:");
320 expect(outputs.fn.functionArn).toContain(":000000000000:");
321 expect(outputs.esm.eventSourceMappingArn).toContain(":000000000000:");
322
323 yield* SQS.sendMessage({
324 QueueUrl: outputs.queue.queueUrl,
325 MessageBody: markerKey,
326 }).pipe(Effect.provide(flociContext));
327
328 // Bounded poll: floci's ESM poller delivers to the containerized
329 // function, whose S3 write-back proves consumption.
330 const consumed = yield* rawS3GetObject(
331 outputs.bucket.bucketName,
332 markerKey,
333 ).pipe(
334 Effect.repeat({
335 schedule: Schedule.spaced("3 seconds"),
336 until: (res): boolean => res.status === 200,
337 times: 40,
338 }),
339 );
340 expect(consumed.status).toBe(200);
341
342 // Destroy tears down the mapping (stopping its poller), the
343 // function, the queue, and the bucket in dependency order.
344 yield* stack.destroy();
345
346 const esmGone = yield* Lambda.getEventSourceMapping({
347 UUID: outputs.esm.uuid,
348 }).pipe(
349 Effect.map(() => false),
350 Effect.catchTag("ResourceNotFoundException", () =>
351 Effect.succeed(true),
352 ),
353 Effect.provide(flociContext),
354 );
355 expect(esmGone).toBe(true);
356
357 const gone = yield* Lambda.getFunction({
358 FunctionName: outputs.fn.functionName,
359 }).pipe(
360 Effect.map(() => false),
361 Effect.catchTag("ResourceNotFoundException", () =>
362 Effect.succeed(true),
363 ),
364 Effect.provide(flociContext),
365 Effect.repeat({
366 schedule: Schedule.spaced("1 second"),
367 until: (isGone): boolean => isGone,
368 times: 20,
369 }),
370 );
371 expect(gone).toBe(true);
372 }),
373 {
374 tags: [
375 "provider:aws",
376 "provider:aws:lambda",
377 "provider:aws:s3",
378 "provider:aws:sqs",
379 "local",
380 ],
381 timeout: 540_000,
382 },
383);