1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as ecr from "@distilled.cloud/aws/ecr";
4import * as Lambda from "@distilled.cloud/aws/lambda";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Schedule from "effect/Schedule";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15const fixture = `${import.meta.dirname}/fixtures/image-function`;
16const coreFunctionName = "alchemy-test-lambda-container-image";
17const armFunctionName = "alchemy-test-lambda-container-image-arm64";
18const zipFunctionName = "alchemy-test-lambda-container-image-zip";
19const externalFunctionName = "alchemy-test-lambda-container-image-external";
20const externalRepositoryName = "alchemy-test-lambda-external-image";
21const skipLive = !process.env.AWS_TEST_SLOW || !!process.env.FAST;
22const liveTestTimeout = 240_000;
23
24const imageFunction = (
25 context: string,
26 functionName: string,
27 architecture: AWS.Lambda.FunctionArchitecture,
28) =>
29 AWS.Lambda.Function("ContainerFunction", {
30 functionName,
31 image: { context, dockerfile: "Dockerfile" },
32 architecture,
33 env: {
34 IMAGE_FUNCTION_ENV: "bound",
35 },
36 functionUrl: false,
37 });
38
39const zipFunction = (functionName: string) =>
40 AWS.Lambda.Function("ContainerFunction", {
41 functionName,
42 main: `${import.meta.dirname}/timeout-handler.ts`,
43 handler: "handler",
44 isExternal: true,
45 functionUrl: false,
46 });
47
48// Docker builds, ECR pushes, Lambda propagation, and the provider's bounded
49// CloudWatch Logs cleanup make these unsuitable for the default fast sweep.
50// Each test is kept within one deletion window and runs only when explicitly
51// requested with `AWS_TEST_SLOW=1`.
52test.provider.skipIf(skipLive)(
53 "builds, invokes, updates, and destroys an image function",
54 (stack) =>
55 Effect.gen(function* () {
56 const fs = yield* FileSystem.FileSystem;
57 const path = yield* Path.Path;
58 yield* stack.destroy();
59
60 const context = yield* fs.makeTempDirectoryScoped({
61 prefix: "alchemy-lambda-image-",
62 });
63 yield* fs.copy(fixture, context, { overwrite: true });
64
65 const first = yield* stack.deploy(
66 imageFunction(context, coreFunctionName, "x86_64"),
67 );
68 expect(first.functionName).toBe(coreFunctionName);
69 expect(first.code.image?.digest).toMatch(/^sha256:/);
70 expect(first.code.image?.imageUri).toContain(`:${first.code.hash}`);
71 expect(first.code.image!.resolvedImageUri).toBe(
72 `${first.code.image!.repositoryUri}@${first.code.image!.digest}`,
73 );
74 yield* assertRepositoryImmutable(first.code.image!.repositoryName);
75 yield* assertFunctionImage(first.functionName, "x86_64");
76 expect(yield* invoke(first.functionName, "one")).toEqual({
77 marker: "one",
78 environment: "bound",
79 event: { hello: "image" },
80 });
81
82 // Unchanged context: the engine and ECR tag are both a no-op.
83 const unchanged = yield* stack.deploy(
84 imageFunction(context, coreFunctionName, "x86_64"),
85 );
86 expect(unchanged.code.hash).toBe(first.code.hash);
87 expect(unchanged.code.image?.digest).toBe(first.code.image?.digest);
88
89 // Repository drift alone must plan an update so reconcile can restore
90 // the content-addressing and Lambda pull contracts.
91 yield* ecr.putImageTagMutability({
92 repositoryName: unchanged.code.image!.repositoryName,
93 imageTagMutability: "MUTABLE",
94 });
95 yield* ecr.deleteRepositoryPolicy({
96 repositoryName: unchanged.code.image!.repositoryName,
97 });
98 const repositoryDriftPlan = yield* stack.plan(
99 imageFunction(context, coreFunctionName, "x86_64"),
100 );
101 expect(repositoryDriftPlan.resources["ContainerFunction"]).toMatchObject({
102 action: "update",
103 });
104 const repaired = yield* stack.deploy(
105 imageFunction(context, coreFunctionName, "x86_64"),
106 );
107 yield* assertRepositoryImmutable(repaired.code.image!.repositoryName);
108 yield* assertRepositoryPolicy(repaired.code.image!.repositoryName);
109
110 // A context change creates a new content-addressed tag and updates the
111 // existing Lambda in place.
112 yield* fs.writeFileString(path.join(context, "marker.txt"), "two\n");
113 const updated = yield* stack.deploy(
114 imageFunction(context, coreFunctionName, "x86_64"),
115 );
116 expect(updated.functionName).toBe(first.functionName);
117 expect(updated.code.hash).not.toBe(first.code.hash);
118 expect(updated.code.image?.digest).not.toBe(first.code.image?.digest);
119 yield* assertRepositoryImmutable(updated.code.image!.repositoryName);
120 expect((yield* invoke(updated.functionName, "two")).marker).toBe("two");
121
122 // Architecture changes update the image function. The separate arm64
123 // smoke test below performs the platform-specific build and invocation.
124 const armPlan = yield* stack.plan(
125 imageFunction(context, coreFunctionName, "arm64"),
126 );
127 expect(armPlan.resources["ContainerFunction"]).toMatchObject({
128 action: "update",
129 });
130
131 // Package type is immutable. A fixed function name must be deleted
132 // before the Zip replacement can reuse it.
133 const zipPlan = yield* stack.plan(zipFunction(coreFunctionName));
134 expect(zipPlan.resources["ContainerFunction"]).toMatchObject({
135 action: "replace",
136 deleteFirst: true,
137 });
138
139 const repositoryName = updated.code.image!.repositoryName;
140 yield* stack.destroy();
141 yield* assertFunctionDeleted(coreFunctionName);
142 yield* assertRepositoryDeleted(repositoryName);
143 }).pipe(
144 Effect.tap(() => stack.destroy()),
145 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
146 ),
147 {
148 tags: ["provider:aws", "provider:aws:ecr", "provider:aws:lambda", "live"],
149 timeout: liveTestTimeout,
150 },
151);
152
153test.provider.skipIf(skipLive)(
154 "deploys an existing ECR tag, detects retagging, and applies image overrides",
155 (stack) =>
156 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 yield* stack.destroy();
160
161 const context = yield* fs.makeTempDirectoryScoped({
162 prefix: "alchemy-lambda-external-image-",
163 });
164 yield* fs.copy(fixture, context, { overwrite: true });
165
166 const program = (
167 uri?: string,
168 imageConfig?: AWS.Lambda.FunctionImageConfig,
169 ) =>
170 Effect.gen(function* () {
171 const repository = yield* AWS.ECR.Repository("ExternalRepository", {
172 repositoryName: externalRepositoryName,
173 });
174 const image = yield* AWS.ECR.Image("ExternalImage", {
175 repositoryUri: repository.repositoryUri,
176 context,
177 platform: "linux/amd64",
178 });
179 const func =
180 uri === undefined
181 ? undefined
182 : yield* AWS.Lambda.Function("ExternalFunction", {
183 functionName: externalFunctionName,
184 image: { uri, ...imageConfig },
185 architecture: "x86_64",
186 env: { IMAGE_FUNCTION_ENV: "external" },
187 functionUrl: false,
188 });
189 return { image, func };
190 });
191
192 const aliasTag = "lambda";
193 const pointAliasAt = Effect.fn(function* (
194 repositoryName: string,
195 imageTag: string,
196 ) {
197 const response = yield* ecr.batchGetImage({
198 repositoryName,
199 imageIds: [{ imageTag }],
200 });
201 const image = response.images?.[0];
202 if (image?.imageManifest === undefined) {
203 return yield* Effect.fail(
204 new Error(
205 `ECR image ${repositoryName}:${imageTag} has no manifest`,
206 ),
207 );
208 }
209 yield* ecr
210 .putImage({
211 repositoryName,
212 imageManifest: image.imageManifest,
213 imageManifestMediaType: image.imageManifestMediaType,
214 imageTag: aliasTag,
215 })
216 .pipe(
217 Effect.catchTag("ImageAlreadyExistsException", () => Effect.void),
218 );
219 });
220
221 // Materialize a private ECR image independently of the Lambda function,
222 // then pass its now-literal URI through the external-image create path.
223 const source = yield* stack.deploy(program());
224 yield* pointAliasAt(source.image.repositoryName, source.image.imageTag);
225 const aliasUri = `${source.image.repositoryUri}:${aliasTag}`;
226 const tagged = yield* stack.deploy(
227 program(aliasUri, {
228 command: ["index.alternate"],
229 entryPoint: ["/lambda-entrypoint.sh"],
230 workingDirectory: "/var/task",
231 }),
232 );
233 if (tagged.func === undefined) {
234 return yield* Effect.fail(
235 new Error("External function was not created"),
236 );
237 }
238 expect(tagged.func.code.image).toMatchObject({
239 source: "uri",
240 imageUri: aliasUri,
241 ownsRepository: false,
242 });
243 yield* assertFunctionImage(tagged.func.functionName, "x86_64");
244 yield* assertFunctionImageConfig(tagged.func.functionName, {
245 Command: ["index.alternate"],
246 EntryPoint: ["/lambda-entrypoint.sh"],
247 WorkingDirectory: "/var/task",
248 });
249 expect(yield* invokeAlternate(tagged.func.functionName)).toBe(
250 "alternate",
251 );
252
253 // Rebuild an independently-managed image, then repoint the same alias.
254 // The URI string is unchanged, so only digest resolution can detect this.
255 yield* fs.writeFileString(path.join(context, "marker.txt"), "two\n");
256 const rebuilt = yield* stack.deploy(
257 program(aliasUri, {
258 command: ["index.alternate"],
259 entryPoint: ["/lambda-entrypoint.sh"],
260 workingDirectory: "/var/task",
261 }),
262 );
263 yield* pointAliasAt(rebuilt.image.repositoryName, rebuilt.image.imageTag);
264
265 // Omitting the overrides while updating the digest clears every override.
266 const retaggedPlan = yield* stack.plan(program(aliasUri));
267 expect(retaggedPlan.resources.ExternalFunction).toMatchObject({
268 action: "update",
269 });
270 const retagged = yield* stack.deploy(program(aliasUri));
271 if (retagged.func === undefined) {
272 return yield* Effect.fail(
273 new Error("External function was not updated"),
274 );
275 }
276 expect(retagged.func.code.image).toMatchObject({
277 source: "uri",
278 imageUri: aliasUri,
279 digest: rebuilt.image.digest,
280 ownsRepository: false,
281 });
282 yield* assertFunctionImageConfig(retagged.func.functionName, undefined);
283 expect((yield* invoke(retagged.func.functionName, "two")).marker).toBe(
284 "two",
285 );
286
287 yield* stack.destroy();
288 yield* assertFunctionDeleted(externalFunctionName);
289 yield* assertRepositoryDeleted(externalRepositoryName);
290 }).pipe(
291 Effect.tap(() => stack.destroy()),
292 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
293 ),
294 {
295 tags: ["provider:aws", "provider:aws:ecr", "provider:aws:lambda", "live"],
296 timeout: liveTestTimeout,
297 },
298);
299
300test.provider.skipIf(skipLive)(
301 "builds and invokes an arm64 image function",
302 (stack) =>
303 Effect.gen(function* () {
304 const fs = yield* FileSystem.FileSystem;
305 yield* stack.destroy();
306
307 const context = yield* fs.makeTempDirectoryScoped({
308 prefix: "alchemy-lambda-image-arm64-",
309 });
310 yield* fs.copy(fixture, context, { overwrite: true });
311
312 const deployed = yield* stack.deploy(
313 imageFunction(context, armFunctionName, "arm64"),
314 );
315 expect(deployed.functionName).toBe(armFunctionName);
316 expect(deployed.code.image?.digest).toMatch(/^sha256:/);
317 yield* assertRepositoryImmutable(deployed.code.image!.repositoryName);
318 yield* assertFunctionImage(deployed.functionName, "arm64");
319 expect(yield* invoke(deployed.functionName, "one")).toEqual({
320 marker: "one",
321 environment: "bound",
322 event: { hello: "image" },
323 });
324
325 const repositoryName = deployed.code.image!.repositoryName;
326 yield* stack.destroy();
327 yield* assertFunctionDeleted(armFunctionName);
328 yield* assertRepositoryDeleted(repositoryName);
329 }).pipe(
330 Effect.tap(() => stack.destroy()),
331 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
332 ),
333 {
334 tags: ["provider:aws", "provider:aws:ecr", "provider:aws:lambda", "live"],
335 timeout: liveTestTimeout,
336 },
337);
338
339test.provider.skipIf(skipLive)(
340 "plans a Zip function replacement with an image function",
341 (stack) =>
342 Effect.gen(function* () {
343 const fs = yield* FileSystem.FileSystem;
344 yield* stack.destroy();
345
346 const context = yield* fs.makeTempDirectoryScoped({
347 prefix: "alchemy-lambda-image-replacement-",
348 });
349 yield* fs.copy(fixture, context, { overwrite: true });
350
351 const deployed = yield* stack.deploy(zipFunction(zipFunctionName));
352 expect(deployed.functionName).toBe(zipFunctionName);
353 yield* assertFunctionPackageType(deployed.functionName, "Zip");
354
355 const renamedPlan = yield* stack.plan(
356 zipFunction(`${zipFunctionName}-renamed`),
357 );
358 expect(renamedPlan.resources["ContainerFunction"]).toMatchObject({
359 action: "replace",
360 deleteFirst: false,
361 });
362
363 const imagePlan = yield* stack.plan(
364 imageFunction(context, zipFunctionName, "x86_64"),
365 );
366 expect(imagePlan.resources["ContainerFunction"]).toMatchObject({
367 action: "replace",
368 deleteFirst: true,
369 });
370
371 yield* stack.destroy();
372 yield* assertFunctionDeleted(zipFunctionName);
373 }).pipe(
374 Effect.tap(() => stack.destroy()),
375 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
376 ),
377 {
378 tags: ["provider:aws", "provider:aws:lambda", "live"],
379 timeout: liveTestTimeout,
380 },
381);
382
383const ImageInvocationResponse = Schema.Struct({
384 marker: Schema.optionalKey(Schema.String),
385 environment: Schema.optionalKey(Schema.String),
386 event: Schema.Struct({ hello: Schema.String }),
387 handler: Schema.optionalKey(Schema.String),
388});
389
390const invokePayload = Effect.fn(function* (deployedFunctionName: string) {
391 return yield* Lambda.invoke({
392 FunctionName: deployedFunctionName,
393 Payload: JSON.stringify({ hello: "image" }),
394 }).pipe(
395 Effect.flatMap((response) =>
396 Effect.gen(function* () {
397 if (response.FunctionError) {
398 return yield* Effect.fail(
399 new Error(`Lambda invocation failed: ${response.FunctionError}`),
400 );
401 }
402 const payload = response.Payload
403 ? yield* response.Payload.pipe(Stream.decodeText(), Stream.mkString)
404 : "";
405 const json = yield* Effect.try({
406 try: () => JSON.parse(payload) as unknown,
407 catch: (cause) =>
408 new Error("Lambda returned an invalid JSON payload", { cause }),
409 });
410 return yield* Schema.decodeUnknownEffect(ImageInvocationResponse)(json);
411 }),
412 ),
413 );
414});
415
416const invoke = Effect.fn(function* (
417 deployedFunctionName: string,
418 expectedMarker: string,
419) {
420 return yield* invokePayload(deployedFunctionName).pipe(
421 Effect.filterOrFail(
422 (response) => response.marker === expectedMarker,
423 () => new Error(`Lambda is not serving marker ${expectedMarker} yet`),
424 ),
425 Effect.retry({
426 schedule: Schedule.spaced("2 seconds"),
427 times: 10,
428 }),
429 );
430});
431
432const invokeAlternate = Effect.fn(function* (deployedFunctionName: string) {
433 return yield* invokePayload(deployedFunctionName).pipe(
434 Effect.filterOrFail(
435 (response) => response.handler === "alternate",
436 () => new Error("Lambda is not serving the alternate image command yet"),
437 ),
438 Effect.map((response) => response.handler),
439 Effect.retry({
440 schedule: Schedule.spaced("2 seconds"),
441 times: 10,
442 }),
443 );
444});
445
446const assertFunctionImage = Effect.fn(function* (
447 deployedFunctionName: string,
448 architecture: AWS.Lambda.FunctionArchitecture,
449) {
450 yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe(
451 Effect.filterOrFail(
452 (response) =>
453 response.Configuration?.State === "Active" &&
454 response.Configuration.LastUpdateStatus === "Successful" &&
455 response.Configuration?.PackageType === "Image" &&
456 response.Configuration.Architectures?.[0] === architecture &&
457 response.Code?.ResolvedImageUri?.includes("@sha256:") === true,
458 () => new Error("Lambda image configuration has not propagated yet"),
459 ),
460 Effect.retry({
461 schedule: Schedule.spaced("2 seconds"),
462 times: 10,
463 }),
464 );
465});
466
467const assertFunctionImageConfig = Effect.fn(function* (
468 deployedFunctionName: string,
469 expected: Lambda.ImageConfig | undefined,
470) {
471 yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe(
472 Effect.filterOrFail(
473 (response) => {
474 const observed =
475 response.Configuration?.ImageConfigResponse?.ImageConfig;
476 if (expected === undefined) {
477 return (
478 observed?.Command === undefined &&
479 observed?.EntryPoint === undefined &&
480 observed?.WorkingDirectory === undefined
481 );
482 }
483 return (
484 JSON.stringify(observed?.Command) ===
485 JSON.stringify(expected.Command) &&
486 JSON.stringify(observed?.EntryPoint) ===
487 JSON.stringify(expected.EntryPoint) &&
488 observed?.WorkingDirectory === expected.WorkingDirectory
489 );
490 },
491 () => new Error("Lambda image overrides have not propagated yet"),
492 ),
493 Effect.retry({
494 schedule: Schedule.spaced("2 seconds"),
495 times: 10,
496 }),
497 );
498});
499
500const assertFunctionPackageType = Effect.fn(function* (
501 deployedFunctionName: string,
502 packageType: Lambda.PackageType,
503) {
504 yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe(
505 Effect.filterOrFail(
506 (response) => response.Configuration?.PackageType === packageType,
507 () => new Error(`Lambda package type is not ${packageType} yet`),
508 ),
509 Effect.retry({
510 schedule: Schedule.spaced("2 seconds"),
511 times: 10,
512 }),
513 );
514});
515
516const assertFunctionDeleted = Effect.fn(function* (
517 deployedFunctionName: string,
518) {
519 yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe(
520 Effect.flatMap(() =>
521 Effect.fail(new Error(`Function ${deployedFunctionName} still exists`)),
522 ),
523 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
524 Effect.retry({
525 schedule: Schedule.spaced("1 second"),
526 times: 8,
527 }),
528 );
529});
530
531const assertRepositoryDeleted = Effect.fn(function* (repositoryName: string) {
532 yield* ecr.describeRepositories({ repositoryNames: [repositoryName] }).pipe(
533 Effect.flatMap(() =>
534 Effect.fail(new Error(`Repository ${repositoryName} still exists`)),
535 ),
536 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
537 Effect.retry({
538 schedule: Schedule.spaced("1 second"),
539 times: 8,
540 }),
541 );
542});
543
544const assertRepositoryImmutable = Effect.fn(function* (repositoryName: string) {
545 yield* ecr.describeRepositories({ repositoryNames: [repositoryName] }).pipe(
546 Effect.filterOrFail(
547 (response) =>
548 response.repositories?.[0]?.imageTagMutability === "IMMUTABLE",
549 () => new Error(`Repository ${repositoryName} is not immutable`),
550 ),
551 );
552});
553
554const assertRepositoryPolicy = Effect.fn(function* (repositoryName: string) {
555 yield* ecr.getRepositoryPolicy({ repositoryName }).pipe(
556 Effect.filterOrFail(
557 (response) =>
558 response.policyText?.includes("LambdaECRImageRetrievalPolicy") === true,
559 () => new Error(`Repository ${repositoryName} has no Lambda pull policy`),
560 ),
561 );
562});