1import * as AWS from "@/AWS";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import { zipFiles } from "@/Util/zip.ts";
5import * as Lambda from "@distilled.cloud/aws/lambda";
6import * as S3 from "@distilled.cloud/aws/s3";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { fileURLToPath } from "node:url";
10
11const layerV1Path = fileURLToPath(
12 new URL("./fixtures/layer-v1", import.meta.url),
13);
14const layerV2Path = fileURLToPath(
15 new URL("./fixtures/layer-v2", import.meta.url),
16);
17const timeoutHandlerPath = fileURLToPath(
18 new URL("./timeout-handler.ts", import.meta.url),
19);
20
21const { test } = Test.make({ providers: AWS.providers() });
22
23test.provider(
24 "publish, republish, attach to a function, list, delete layer version",
25 (stack) =>
26 Effect.gen(function* () {
27 yield* stack.destroy();
28
29 const program = ({
30 path,
31 description,
32 withFunction = false,
33 layered = true,
34 }: {
35 path: string;
36 description?: string;
37 withFunction?: boolean;
38 layered?: boolean;
39 }) =>
40 Effect.gen(function* () {
41 const layer = yield* AWS.Lambda.LayerVersion("Deps", {
42 path,
43 description,
44 compatibleRuntimes: ["nodejs22.x"],
45 });
46
47 const fn = withFunction
48 ? yield* AWS.Lambda.Function("LayeredFn", {
49 main: timeoutHandlerPath,
50 handler: "handler",
51 isExternal: true,
52 functionUrl: false,
53 // Pass the resource itself — `layers` also accepts a raw ARN.
54 layers: layered ? [layer] : [],
55 })
56 : undefined;
57
58 return { layer, fn };
59 });
60
61 // --- create ---
62 const created = yield* stack.deploy(program({ path: layerV1Path }));
63 const v1 = created.layer;
64
65 expect(v1.version).toBeGreaterThan(0);
66 expect(v1.layerVersionArn).toBe(`${v1.layerArn}:${v1.version}`);
67 expect(v1.compatibleRuntimes).toEqual(["nodejs22.x"]);
68
69 const cloudV1 = yield* getLayerVersionOrUndefined(
70 v1.layerName,
71 v1.version,
72 );
73 expect(cloudV1).toBeDefined();
74 expect(cloudV1!.LayerVersionArn).toBe(v1.layerVersionArn);
75 expect(cloudV1!.Content?.CodeSha256).toBe(v1.codeSha256);
76
77 // --- noop (identical content must not publish a new version) ---
78 const unchanged = yield* stack.deploy(program({ path: layerV1Path }));
79 expect(unchanged.layer.version).toBe(v1.version);
80 expect(unchanged.layer.layerVersionArn).toBe(v1.layerVersionArn);
81
82 // --- update (changed content publishes a new version of the SAME
83 // layer, rather than stranding a fresh layer at version 1) ---
84 const republished = yield* stack.deploy(program({ path: layerV2Path }));
85 const v2 = republished.layer;
86
87 expect(v2.layerName).toBe(v1.layerName);
88 expect(v2.layerArn).toBe(v1.layerArn);
89 expect(v2.version).toBe(v1.version + 1);
90 expect(v2.layerVersionArn).not.toBe(v1.layerVersionArn);
91 expect(v2.codeSha256).not.toBe(v1.codeSha256);
92
93 // The superseded version is retired, so updates don't leak one
94 // version per deploy.
95 expect(
96 yield* getLayerVersionOrUndefined(v1.layerName, v1.version),
97 ).toBeUndefined();
98
99 // --- update (changed description alone also republishes) ---
100 const described = yield* stack.deploy(
101 program({ path: layerV2Path, description: "with description" }),
102 );
103 expect(described.layer.layerName).toBe(v1.layerName);
104 expect(described.layer.version).toBe(v2.version + 1);
105 expect(described.layer.description).toBe("with description");
106
107 const currentLayer = described.layer;
108
109 // --- attach to a function ---
110 const attached = yield* stack.deploy(
111 program({
112 path: layerV2Path,
113 description: "with description",
114 withFunction: true,
115 }),
116 );
117 const functionName = attached.fn!.functionName;
118
119 expect(
120 (yield* getFunctionLayers(functionName)).map((layer) => layer.Arn),
121 ).toEqual([currentLayer.layerVersionArn]);
122
123 // --- detach ---
124 yield* stack.deploy(
125 program({
126 path: layerV2Path,
127 description: "with description",
128 withFunction: true,
129 layered: false,
130 }),
131 );
132 expect(yield* getFunctionLayers(functionName)).toEqual([]);
133
134 // --- list ---
135 const provider = yield* Provider.findProvider(AWS.Lambda.LayerVersion);
136 const versions = yield* provider.list();
137 expect(
138 versions.some(
139 (version) => version.layerVersionArn === currentLayer.layerVersionArn,
140 ),
141 ).toBe(true);
142
143 // --- delete ---
144 yield* stack.destroy();
145
146 expect(
147 yield* getLayerVersionOrUndefined(
148 currentLayer.layerName,
149 currentLayer.version,
150 ),
151 ).toBeUndefined();
152 }).pipe(
153 Effect.tap(() => stack.destroy()),
154 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
155 ),
156 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
157);
158
159// Both `layers` and `s3.bucket` accept either the resource itself or the raw
160// identifier. Switching a deployed stack between the two forms must converge
161// on the same cloud state rather than churning the layer version.
162test.provider(
163 "layers and s3.bucket accept a resource or a raw identifier, and cycle between them",
164 (stack) =>
165 Effect.gen(function* () {
166 yield* stack.destroy();
167
168 const archive = yield* zipFiles([
169 { path: "nodejs/from-s3.mjs", content: "export const via = 's3';\n" },
170 ]);
171
172 // --- s3.bucket: resource form ---
173 const s3Program = (byName: boolean) =>
174 Effect.gen(function* () {
175 const bucket = yield* AWS.S3.Bucket("LayerSource", {
176 forceDestroy: true,
177 });
178 const layer = yield* AWS.Lambda.LayerVersion("FromS3", {
179 s3: {
180 bucket: byName ? bucket.bucketName : bucket,
181 key: LAYER_KEY,
182 },
183 });
184 return { bucket, layer };
185 });
186
187 // The object has to exist before the layer publishes, so create the
188 // bucket on its own first.
189 const bucketOnly = yield* stack.deploy(
190 Effect.gen(function* () {
191 return yield* AWS.S3.Bucket("LayerSource", { forceDestroy: true });
192 }),
193 );
194 yield* S3.putObject({
195 Bucket: bucketOnly.bucketName,
196 Key: LAYER_KEY,
197 Body: new Uint8Array(archive),
198 });
199
200 const byResource = yield* stack.deploy(s3Program(false));
201 expect(byResource.layer.version).toBe(1);
202 expect(byResource.layer.layerVersionArn).toContain(":1");
203
204 // --- s3.bucket: cycle to the raw-name form ---
205 const byName = yield* stack.deploy(s3Program(true));
206 // Same bucket, expressed differently — must NOT republish.
207 expect(byName.layer.layerVersionArn).toBe(
208 byResource.layer.layerVersionArn,
209 );
210 expect(byName.layer.version).toBe(byResource.layer.version);
211
212 // --- and back again ---
213 const backToResource = yield* stack.deploy(s3Program(false));
214 expect(backToResource.layer.layerVersionArn).toBe(
215 byResource.layer.layerVersionArn,
216 );
217
218 // --- layers: both forms, cycled ---
219 const fnProgram = (byArn: boolean) =>
220 Effect.gen(function* () {
221 const bucket = yield* AWS.S3.Bucket("LayerSource", {
222 forceDestroy: true,
223 });
224 const layer = yield* AWS.Lambda.LayerVersion("FromS3", {
225 s3: { bucket, key: LAYER_KEY },
226 });
227 const fn = yield* AWS.Lambda.Function("RefFormFn", {
228 main: timeoutHandlerPath,
229 handler: "handler",
230 isExternal: true,
231 functionUrl: false,
232 layers: [byArn ? layer.layerVersionArn : layer],
233 });
234 return { bucket, layer, fn };
235 });
236
237 const withResource = yield* stack.deploy(fnProgram(false));
238 const layerArn = backToResource.layer.layerVersionArn;
239 expect(
240 (yield* getFunctionLayers(withResource.fn.functionName)).map(
241 (l) => l.Arn,
242 ),
243 ).toEqual([layerArn]);
244
245 const withArn = yield* stack.deploy(fnProgram(true));
246 // Cycling the reference form is a no-op, not a phantom update: same
247 // function, same attached layer, no re-issued configuration call.
248 expect(withArn.fn.functionName).toBe(withResource.fn.functionName);
249 expect(
250 (yield* getFunctionLayers(withArn.fn.functionName)).map((l) => l.Arn),
251 ).toEqual([layerArn]);
252
253 // ...and back to the resource form.
254 const backToRef = yield* stack.deploy(fnProgram(false));
255 expect(
256 (yield* getFunctionLayers(backToRef.fn.functionName)).map((l) => l.Arn),
257 ).toEqual([layerArn]);
258
259 yield* stack.destroy();
260 }).pipe(
261 Effect.tap(() => stack.destroy()),
262 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
263 ),
264 {
265 tags: ["provider:aws", "provider:aws:lambda", "provider:aws:s3", "live"],
266 timeout: 360_000,
267 },
268);
269
270const LAYER_KEY = "layers/from-s3.zip";
271
272const getLayerVersionOrUndefined = Effect.fn(function* (
273 layerName: string,
274 version: number,
275) {
276 return yield* Lambda.getLayerVersion({
277 LayerName: layerName,
278 VersionNumber: version,
279 }).pipe(
280 Effect.catchTag("ResourceNotFoundException", () =>
281 Effect.succeed(undefined),
282 ),
283 );
284});
285
286const getFunctionLayers = Effect.fn(function* (functionName: string) {
287 const configuration = yield* Lambda.getFunctionConfiguration({
288 FunctionName: functionName,
289 });
290 return configuration.Layers ?? [];
291});