1import {
2 decodeFunctionImageSource,
3 functionImagePlatform,
4 hashFunctionImageBuild,
5 parseFunctionImageUri,
6} from "@/AWS/Lambda/FunctionImage.ts";
7import { validateFunctionPackageProps } from "@/AWS/Lambda/Function.ts";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { expect, layer } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import * as Result from "effect/Result";
14
15const describe = layer(NodeServices.layer);
16
17describe("Lambda Function images", (it) => {
18 it.effect(
19 "maps Lambda architectures to Docker platforms",
20 () =>
21 Effect.sync(() => {
22 expect(functionImagePlatform("x86_64")).toBe("linux/amd64");
23 expect(functionImagePlatform("arm64")).toBe("linux/arm64");
24 }),
25 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
26 );
27
28 it.effect(
29 "parses tagged and digest-pinned private ECR image URIs",
30 () =>
31 Effect.gen(function* () {
32 const tagged = yield* parseFunctionImageUri(
33 "Tagged",
34 "123456789012.dkr.ecr.eu-west-3.amazonaws.com/team/worker:release",
35 );
36 expect(tagged).toMatchObject({
37 registryId: "123456789012",
38 region: "eu-west-3",
39 repositoryName: "team/worker",
40 imageId: { imageTag: "release" },
41 });
42
43 const digest = `sha256:${"a".repeat(64)}`;
44 const pinned = yield* parseFunctionImageUri(
45 "Pinned",
46 `123456789012.dkr.ecr.us-east-1.amazonaws.com/worker@${digest}`,
47 );
48 expect(pinned.imageId).toEqual({ imageDigest: digest });
49 expect(pinned.repositoryUri).toBe(
50 "123456789012.dkr.ecr.us-east-1.amazonaws.com/worker",
51 );
52 }),
53 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
54 );
55
56 it.effect(
57 "rejects unsupported or incomplete ECR image URIs",
58 () =>
59 Effect.gen(function* () {
60 const fips = yield* Effect.result(
61 parseFunctionImageUri(
62 "Fips",
63 "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com/worker:latest",
64 ),
65 );
66 expect(Result.isFailure(fips)).toBe(true);
67 if (Result.isFailure(fips)) {
68 expect(fips.failure.message).toContain("do not support ECR FIPS");
69 }
70
71 const untagged = yield* Effect.result(
72 parseFunctionImageUri(
73 "Untagged",
74 "123456789012.dkr.ecr.us-east-1.amazonaws.com/worker",
75 ),
76 );
77 expect(Result.isFailure(untagged)).toBe(true);
78 if (Result.isFailure(untagged)) {
79 expect(untagged.failure.message).toContain(
80 "explicit ECR tag or digest",
81 );
82 }
83 }),
84 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
85 );
86
87 it.effect(
88 "rejects mixed image sources and ZIP-only image options",
89 () =>
90 Effect.gen(function* () {
91 const mixedSource = yield* Effect.result(
92 decodeFunctionImageSource("MixedSource", {
93 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/worker:latest",
94 context: "./lambda",
95 dockerfile: "Dockerfile",
96 }),
97 );
98 expect(Result.isFailure(mixedSource)).toBe(true);
99 if (Result.isFailure(mixedSource)) {
100 expect(mixedSource.failure.message).toContain(
101 "exactly one image source",
102 );
103 }
104
105 const mixedPackage = yield* Effect.result(
106 validateFunctionPackageProps("MixedPackage", {
107 image: { uri: "example" },
108 main: "./handler.ts",
109 runtime: "nodejs22.x",
110 layers: ["arn:aws:lambda:us-east-1:123456789012:layer:example:1"],
111 }),
112 );
113 expect(Result.isFailure(mixedPackage)).toBe(true);
114 if (Result.isFailure(mixedPackage)) {
115 expect(mixedPackage.failure.message).toContain(
116 "main, runtime, layers",
117 );
118 }
119 }),
120 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
121 );
122
123 it.effect(
124 "does not hash files excluded by .dockerignore",
125 () =>
126 Effect.gen(function* () {
127 const fs = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const context = yield* fs.makeTempDirectoryScoped({
130 prefix: "alchemy-lambda-image-hash-",
131 });
132 yield* fs.writeFileString(
133 path.join(context, "Dockerfile"),
134 "FROM scratch\nCOPY . /app\n",
135 );
136 yield* fs.makeDirectory(path.join(context, "ignored"), {
137 recursive: true,
138 });
139 yield* fs.writeFileString(
140 path.join(context, ".dockerignore"),
141 "ignored/**\n!ignored/included.txt\n",
142 );
143 yield* fs.writeFileString(
144 path.join(context, "ignored", "excluded.txt"),
145 "one",
146 );
147 yield* fs.writeFileString(
148 path.join(context, "ignored", "included.txt"),
149 "one",
150 );
151
152 const source = { context, dockerfile: "Dockerfile" };
153 const initial = yield* hashFunctionImageBuild(source, "x86_64");
154 yield* fs.writeFileString(
155 path.join(context, "ignored", "excluded.txt"),
156 "two",
157 );
158 expect(yield* hashFunctionImageBuild(source, "x86_64")).toBe(initial);
159
160 yield* fs.writeFileString(
161 path.join(context, "ignored", "included.txt"),
162 "two",
163 );
164 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
165 initial,
166 );
167 }),
168 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
169 );
170
171 it.effect(
172 "preserves escaped Docker ignore wildcards",
173 () =>
174 Effect.gen(function* () {
175 const fs = yield* FileSystem.FileSystem;
176 const path = yield* Path.Path;
177 const context = yield* fs.makeTempDirectoryScoped({
178 prefix: "alchemy-lambda-image-ignore-escape-",
179 });
180 yield* fs.writeFileString(
181 path.join(context, "Dockerfile"),
182 "FROM scratch\nCOPY . /app\n",
183 );
184 yield* fs.writeFileString(
185 path.join(context, ".dockerignore"),
186 "file\\?.txt\n",
187 );
188 yield* fs.writeFileString(path.join(context, "file?.txt"), "one");
189 yield* fs.makeDirectory(path.join(context, "file"));
190 yield* fs.writeFileString(path.join(context, "file", "a.txt"), "one");
191
192 const source = { context, dockerfile: "Dockerfile" };
193 const initial = yield* hashFunctionImageBuild(source, "x86_64");
194 yield* fs.writeFileString(path.join(context, "file?.txt"), "two");
195 expect(yield* hashFunctionImageBuild(source, "x86_64")).toBe(initial);
196
197 yield* fs.writeFileString(path.join(context, "file", "a.txt"), "two");
198 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
199 initial,
200 );
201 }),
202 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
203 );
204
205 it.effect(
206 "hashes Dockerfile, build args, architecture, and relative context contents",
207 () =>
208 Effect.gen(function* () {
209 const fs = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const root = yield* fs.makeTempDirectoryScoped({
212 prefix: "alchemy-lambda-image-inputs-",
213 });
214 const first = path.join(root, "first");
215 const second = path.join(root, "second");
216 yield* fs.makeDirectory(first, { recursive: true });
217 yield* fs.makeDirectory(second, { recursive: true });
218
219 for (const context of [first, second]) {
220 yield* fs.writeFileString(
221 path.join(context, "Dockerfile"),
222 "FROM scratch\nCOPY app.txt /app.txt\n",
223 );
224 yield* fs.writeFileString(path.join(context, "app.txt"), "hello");
225 }
226
227 const source = {
228 context: first,
229 dockerfile: "Dockerfile",
230 buildArgs: { B: "two", A: "one" },
231 };
232 const initial = yield* hashFunctionImageBuild(source, "x86_64");
233 expect(
234 yield* hashFunctionImageBuild(
235 {
236 context: second,
237 dockerfile: "Dockerfile",
238 buildArgs: { A: "one", B: "two" },
239 },
240 "x86_64",
241 ),
242 ).toBe(initial);
243 expect(
244 yield* hashFunctionImageBuild(
245 {
246 ...source,
247 buildArgs: { A: "changed", B: "two" },
248 },
249 "x86_64",
250 ),
251 ).not.toBe(initial);
252 expect(yield* hashFunctionImageBuild(source, "arm64")).not.toBe(
253 initial,
254 );
255
256 yield* fs.writeFileString(
257 path.join(first, "Dockerfile"),
258 "FROM scratch\nCOPY app.txt /renamed.txt\n",
259 );
260 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
261 initial,
262 );
263 }),
264 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
265 );
266
267 it.effect(
268 "hashes copied filesystem metadata",
269 () =>
270 Effect.gen(function* () {
271 const fs = yield* FileSystem.FileSystem;
272 const path = yield* Path.Path;
273 const context = yield* fs.makeTempDirectoryScoped({
274 prefix: "alchemy-lambda-image-metadata-",
275 });
276 const bootstrap = path.join(context, "bootstrap");
277 yield* fs.writeFileString(
278 path.join(context, "Dockerfile"),
279 "FROM scratch\nCOPY . /app\n",
280 );
281 yield* fs.writeFileString(bootstrap, "#!/bin/sh\n");
282 yield* fs.chmod(bootstrap, 0o644);
283
284 const source = { context, dockerfile: "Dockerfile" };
285 const initial = yield* hashFunctionImageBuild(source, "x86_64");
286
287 yield* fs.chmod(bootstrap, 0o755);
288 const executable = yield* hashFunctionImageBuild(source, "x86_64");
289 expect(executable).not.toBe(initial);
290
291 yield* fs.makeDirectory(path.join(context, "empty"));
292 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
293 executable,
294 );
295 }),
296 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
297 );
298
299 it.effect(
300 "hashes symbolic link targets without following them",
301 () =>
302 Effect.gen(function* () {
303 const fs = yield* FileSystem.FileSystem;
304 const path = yield* Path.Path;
305 const context = yield* fs.makeTempDirectoryScoped({
306 prefix: "alchemy-lambda-image-symlink-",
307 });
308 yield* fs.writeFileString(
309 path.join(context, "Dockerfile"),
310 "FROM scratch\nCOPY . /app\n",
311 );
312 yield* fs.writeFileString(path.join(context, "target-a"), "same");
313 yield* fs.writeFileString(path.join(context, "target-b"), "same");
314 const link = path.join(context, "current");
315 yield* fs.symlink("target-a", link);
316
317 const source = { context, dockerfile: "Dockerfile" };
318 const initial = yield* hashFunctionImageBuild(source, "x86_64");
319
320 yield* fs.remove(link);
321 yield* fs.symlink("target-b", link);
322 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
323 initial,
324 );
325 }),
326 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
327 );
328
329 it.effect(
330 "uses a Dockerfile-specific ignore file instead of the context root",
331 () =>
332 Effect.gen(function* () {
333 const fs = yield* FileSystem.FileSystem;
334 const path = yield* Path.Path;
335 const context = yield* fs.makeTempDirectoryScoped({
336 prefix: "alchemy-lambda-image-ignore-",
337 });
338 yield* fs.writeFileString(
339 path.join(context, "Lambda.Dockerfile"),
340 "FROM scratch\nCOPY . /app\n",
341 );
342 yield* fs.writeFileString(
343 path.join(context, ".dockerignore"),
344 "root-only.txt\n",
345 );
346 yield* fs.writeFileString(
347 path.join(context, "Lambda.Dockerfile.dockerignore"),
348 "specific-only.txt\n",
349 );
350 yield* fs.writeFileString(
351 path.join(context, "specific-only.txt"),
352 "one",
353 );
354 yield* fs.writeFileString(path.join(context, "root-only.txt"), "one");
355
356 const source = { context, dockerfile: "Lambda.Dockerfile" };
357 const initial = yield* hashFunctionImageBuild(source, "x86_64");
358 yield* fs.writeFileString(
359 path.join(context, "specific-only.txt"),
360 "two",
361 );
362 expect(yield* hashFunctionImageBuild(source, "x86_64")).toBe(initial);
363
364 yield* fs.writeFileString(path.join(context, "root-only.txt"), "two");
365 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
366 initial,
367 );
368 }),
369 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
370 );
371
372 it.effect(
373 "does not alias an external Docker ignore file into the context",
374 () =>
375 Effect.gen(function* () {
376 const fs = yield* FileSystem.FileSystem;
377 const path = yield* Path.Path;
378 const root = yield* fs.makeTempDirectoryScoped({
379 prefix: "alchemy-lambda-image-external-ignore-",
380 });
381 const context = path.join(root, "context");
382 const dockerfile = path.join(root, "Lambda.Dockerfile");
383 yield* fs.makeDirectory(context);
384 yield* fs.writeFileString(dockerfile, "FROM scratch\nCOPY . /app\n");
385 yield* fs.writeFileString(
386 `${dockerfile}.dockerignore`,
387 "ignored.txt\n",
388 );
389 yield* fs.writeFileString(path.join(context, "ignored.txt"), "one");
390 yield* fs.writeFileString(
391 path.join(context, "Lambda.Dockerfile.dockerignore"),
392 "one",
393 );
394
395 const source = { context, dockerfile };
396 const initial = yield* hashFunctionImageBuild(source, "x86_64");
397 yield* fs.writeFileString(path.join(context, "ignored.txt"), "two");
398 expect(yield* hashFunctionImageBuild(source, "x86_64")).toBe(initial);
399
400 yield* fs.writeFileString(
401 path.join(context, "Lambda.Dockerfile.dockerignore"),
402 "two",
403 );
404 expect(yield* hashFunctionImageBuild(source, "x86_64")).not.toBe(
405 initial,
406 );
407 }),
408 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
409 );
410
411 it.effect(
412 "requires an explicit Dockerfile for local image sources",
413 () =>
414 Effect.gen(function* () {
415 const fs = yield* FileSystem.FileSystem;
416 const context = yield* fs.makeTempDirectoryScoped({
417 prefix: "alchemy-lambda-image-schema-",
418 });
419 const result = yield* Effect.result(
420 decodeFunctionImageSource("MissingDockerfile", { context }),
421 );
422 expect(Result.isFailure(result)).toBe(true);
423 }),
424 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
425 );
426});