1import * as AWS from "@/AWS";
2import * as Provider from "@/Provider";
3import { destroy } from "@/RemovalPolicy";
4import { isResourceState, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import * as Lambda from "@distilled.cloud/aws/lambda";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { fileURLToPath } from "node:url";
10
11const handlerV1Path = fileURLToPath(
12 new URL("./fixtures/version-handler-v1.ts", import.meta.url),
13);
14const handlerV2Path = fileURLToPath(
15 new URL("./fixtures/version-handler-v2.ts", import.meta.url),
16);
17
18const { test } = Test.make({ providers: AWS.providers() });
19
20test.provider(
21 "publish, recover, promote, retain, list, and explicitly delete versions",
22 (stack) =>
23 Effect.gen(function* () {
24 yield* stack.destroy();
25
26 const program = ({
27 handlerPath,
28 envVersion,
29 reservedConcurrentExecutions,
30 includeVersion = true,
31 includeAlias = true,
32 destroyVersion = false,
33 }: {
34 handlerPath: string;
35 envVersion: string;
36 reservedConcurrentExecutions?: number;
37 includeVersion?: boolean;
38 includeAlias?: boolean;
39 destroyVersion?: boolean;
40 }) =>
41 Effect.gen(function* () {
42 const fn = yield* AWS.Lambda.Function("VersionedFn", {
43 main: handlerPath,
44 handler: "handler",
45 isExternal: true,
46 functionUrl: false,
47 env: { VERSION: envVersion },
48 reservedConcurrentExecutions,
49 });
50
51 const version = includeVersion
52 ? yield* AWS.Lambda.Version("Release", {
53 function: fn,
54 }).pipe(destroy(destroyVersion))
55 : undefined;
56
57 const live =
58 includeAlias && version
59 ? yield* AWS.Lambda.Alias("Live", {
60 version,
61 aliasName: "live",
62 })
63 : undefined;
64
65 return { fn, version, live };
66 });
67
68 // --- create ---
69 const created = yield* stack.deploy(
70 program({ handlerPath: handlerV1Path, envVersion: "one" }),
71 );
72 const v1 = created.version!;
73
74 expect(v1.version).toMatch(/^[1-9]\d*$/);
75 expect(v1.versionArn).toBe(`${v1.functionArn}:${v1.version}`);
76 expect(v1.codeSha256).toBeTruthy();
77 expect(v1.configSha256).toBeTruthy();
78 expect(v1.sourceHash).toMatch(/^[a-f0-9]{64}$/);
79 expect(created.live!.functionVersion).toBe(v1.version);
80
81 const cloudV1 = yield* getVersionOrUndefined(v1.functionName, v1.version);
82 expect(cloudV1?.FunctionArn).toBe(v1.versionArn);
83 expect(cloudV1?.CodeSha256).toBe(v1.codeSha256);
84 const countV1 = (yield* numberedVersions(v1.functionName)).length;
85
86 // --- noop ---
87 const unchanged = yield* stack.deploy(
88 program({ handlerPath: handlerV1Path, envVersion: "one" }),
89 );
90 expect(unchanged.version!.version).toBe(v1.version);
91 expect((yield* numberedVersions(v1.functionName)).length).toBe(countV1);
92
93 // --- code change + alias promotion ---
94 const codeChanged = yield* stack.deploy(
95 program({ handlerPath: handlerV2Path, envVersion: "one" }),
96 );
97 const v2 = codeChanged.version!;
98 expect(v2.version).not.toBe(v1.version);
99 expect(v2.codeSha256).not.toBe(v1.codeSha256);
100 expect(codeChanged.live!.aliasArn).toBe(created.live!.aliasArn);
101 expect(codeChanged.live!.functionVersion).toBe(v2.version);
102 expect((yield* numberedVersions(v1.functionName)).length).toBe(
103 countV1 + 1,
104 );
105 expect(
106 yield* getVersionOrUndefined(v1.functionName, v1.version),
107 ).toBeDefined();
108
109 // --- versioned configuration change ---
110 const configChanged = yield* stack.deploy(
111 program({ handlerPath: handlerV2Path, envVersion: "two" }),
112 );
113 const current = configChanged.version!;
114 expect(current.version).not.toBe(v2.version);
115 expect(current.codeSha256).toBe(v2.codeSha256);
116 expect(current.configSha256).not.toBe(v2.configSha256);
117 const countCurrent = (yield* numberedVersions(v1.functionName)).length;
118 expect(countCurrent).toBe(countV1 + 2);
119
120 // --- operational-only change ---
121 const operational = yield* stack.deploy(
122 program({
123 handlerPath: handlerV2Path,
124 envVersion: "two",
125 reservedConcurrentExecutions: 0,
126 }),
127 );
128 expect(operational.version!.version).toBe(current.version);
129 expect((yield* numberedVersions(v1.functionName)).length).toBe(
130 countCurrent,
131 );
132
133 // --- crash after PublishVersion, before state write ---
134 // State resolves to an Effect that initializes and yields the concrete
135 // state-store service.
136 const state = yield* yield* State;
137 const stage = stack.stage;
138 const fqns = yield* state.list({ stack: stack.name, stage });
139 const rows = yield* Effect.forEach(fqns, (fqn) =>
140 state
141 .get({ stack: stack.name, stage, fqn })
142 .pipe(Effect.map((row) => ({ fqn, row }))),
143 );
144 const versionRow = rows.find(
145 (row): row is { fqn: string; row: ResourceState } =>
146 isResourceState(row.row) &&
147 row.row.resourceType === "AWS.Lambda.Version",
148 );
149 if (!versionRow?.row.props) {
150 return yield* Effect.die(
151 new Error("no persisted AWS.Lambda.Version props found after deploy"),
152 );
153 }
154 yield* state.set({
155 stack: stack.name,
156 stage,
157 fqn: versionRow.fqn,
158 value: {
159 ...versionRow.row,
160 props: versionRow.row.props,
161 status: "creating",
162 attr: undefined,
163 },
164 });
165
166 const recovered = yield* stack.deploy(
167 program({
168 handlerPath: handlerV2Path,
169 envVersion: "two",
170 reservedConcurrentExecutions: 0,
171 }),
172 );
173 expect(recovered.version!.version).toBe(current.version);
174 expect((yield* numberedVersions(v1.functionName)).length).toBe(
175 countCurrent,
176 );
177
178 // --- retain on removal and recover on re-add ---
179 yield* stack.deploy(
180 program({
181 handlerPath: handlerV2Path,
182 envVersion: "two",
183 reservedConcurrentExecutions: 0,
184 includeVersion: false,
185 includeAlias: false,
186 }),
187 );
188 expect(
189 yield* getVersionOrUndefined(v1.functionName, current.version),
190 ).toBeDefined();
191
192 const readded = yield* stack.deploy(
193 program({
194 handlerPath: handlerV2Path,
195 envVersion: "two",
196 reservedConcurrentExecutions: 0,
197 }),
198 );
199 expect(readded.version!.version).toBe(current.version);
200 expect((yield* numberedVersions(v1.functionName)).length).toBe(
201 countCurrent,
202 );
203
204 // --- list + nuke posture ---
205 const provider = yield* Provider.findProvider(AWS.Lambda.Version);
206 expect(provider.nuke?.skip).toBe(true);
207 const listed = yield* provider.list();
208 expect(
209 listed.some((version) => version.versionArn === current.versionArn),
210 ).toBe(true);
211 expect(listed.every((version) => version.version !== "$LATEST")).toBe(
212 true,
213 );
214
215 // --- explicitly authorized, qualified-only deletion ---
216 yield* stack.deploy(
217 program({
218 handlerPath: handlerV2Path,
219 envVersion: "two",
220 // Force an operational-only Version update so the explicit removal
221 // policy is persisted before the resource is removed.
222 reservedConcurrentExecutions: 1,
223 destroyVersion: true,
224 }),
225 );
226 yield* stack.deploy(
227 program({
228 handlerPath: handlerV2Path,
229 envVersion: "two",
230 reservedConcurrentExecutions: 1,
231 includeVersion: false,
232 includeAlias: false,
233 }),
234 );
235
236 expect(
237 yield* getVersionOrUndefined(v1.functionName, current.version),
238 ).toBeUndefined();
239 expect(
240 yield* Lambda.getFunctionConfiguration({
241 FunctionName: v1.functionName,
242 Qualifier: "$LATEST",
243 }),
244 ).toBeDefined();
245 expect(
246 yield* getVersionOrUndefined(v1.functionName, v1.version),
247 ).toBeDefined();
248
249 yield* stack.destroy();
250 }).pipe(
251 Effect.tap(() => stack.destroy()),
252 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
253 ),
254 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
255);
256
257const numberedVersions = Effect.fn(function* (functionName: string) {
258 const response = yield* Lambda.listVersionsByFunction({
259 FunctionName: functionName,
260 });
261 return (response.Versions ?? []).filter(
262 (version) => version.Version !== "$LATEST",
263 );
264});
265
266const getVersionOrUndefined = Effect.fn(function* (
267 functionName: string,
268 version: string,
269) {
270 return yield* Lambda.getFunctionConfiguration({
271 FunctionName: functionName,
272 Qualifier: version,
273 }).pipe(
274 Effect.catchTag("ResourceNotFoundException", () =>
275 Effect.succeed(undefined),
276 ),
277 );
278});