1import * as Lambda from "@distilled.cloud/aws/lambda";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { deepEqual, isResolved } from "../../Diff.ts";
8import type { Input } from "../../Input.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource, type ResourceClassLike } from "../../Resource.ts";
11import { Stack } from "../../Stack.ts";
12import { Stage } from "../../Stage.ts";
13import { sha256Object } from "../../Util/sha256.ts";
14import type { Providers } from "../Providers.ts";
15import type { Function } from "./Function.ts";
16
17export interface VersionProps {
18 /**
19 * Managed Lambda function whose current code and versioned configuration
20 * should be published.
21 */
22 function: Function;
23}
24
25type VersionAttributes = {
26 /**
27 * Name of the Lambda function this version belongs to.
28 */
29 functionName: string;
30 /**
31 * Unqualified ARN of the Lambda function.
32 */
33 functionArn: string;
34 /**
35 * Immutable version number assigned by Lambda, represented as a string.
36 */
37 version: string;
38 /**
39 * ARN qualified with this immutable version number.
40 */
41 versionArn: string;
42 /**
43 * Secret-safe fingerprint of the function ARN, code, and versioned
44 * configuration.
45 */
46 sourceHash: string;
47 /**
48 * Base64-encoded SHA-256 of the published function code, as reported by
49 * Lambda.
50 */
51 codeSha256: string;
52 /**
53 * SHA-256 of the post-binding, versioned Lambda configuration. Secret values
54 * contribute to this digest but are never stored in state.
55 */
56 configSha256: string;
57};
58
59export interface Version extends Resource<
60 "AWS.Lambda.Version",
61 VersionProps,
62 VersionAttributes,
63 never,
64 Providers
65> {}
66
67interface VersionResourceProps extends VersionProps {
68 /**
69 * Internal dependency on a non-stable Function attribute. Its value is not
70 * the release identity; it prevents publication from racing a Function
71 * update whose publishable configuration has not settled yet.
72 */
73 deploymentHash: string;
74}
75
76interface VersionResource extends Resource<
77 "AWS.Lambda.Version",
78 VersionResourceProps,
79 VersionAttributes,
80 never,
81 Providers
82> {}
83
84type ResolvedVersionProps = Input.ResolveProps<VersionResourceProps>;
85
86/**
87 * Resource references resolve to their Attributes before provider lifecycle
88 * methods run. Provider's generic Props type intentionally preserves the
89 * author-facing resource type, so expose that engine guarantee in one place.
90 */
91const resolvedProps = (props: VersionResourceProps): ResolvedVersionProps =>
92 props as unknown as ResolvedVersionProps;
93
94const VersionResource = Resource<VersionResource>("AWS.Lambda.Version", {
95 defaultRemovalPolicy: "retain",
96});
97
98export interface VersionClass extends ResourceClassLike<Version> {
99 (
100 id: string,
101 props: { function: Function },
102 ): Effect.Effect<Version, never, Providers>;
103 ref(
104 id: string,
105 options?: { stage?: string; stack?: string },
106 ): Effect.Effect<Version>;
107}
108
109/**
110 * An immutable numbered version of a managed Lambda function.
111 *
112 * The provider publishes only after the Function's code and configuration
113 * have settled. Re-applying unchanged code and versioned configuration reuses
114 * the existing version. Function-level operational changes, such as reserved
115 * concurrency, do not publish a new version.
116 *
117 * Versions default to **retain** on removal. Moving or deleting an alias never
118 * deletes an older version, so in-flight durable executions can continue
119 * replaying against the code that started them. Use `destroy()` only when the
120 * exact numbered version is safe to remove.
121 *
122 * ### Publishing a Version
123 * **Example:** Publish a Managed Function
124 * ```typescript
125 * const fn = yield* AWS.Lambda.Function("Handler", {
126 * main: import.meta.resolve("./handler.ts"),
127 * });
128 * const version = yield* AWS.Lambda.Version("HandlerVersion", {
129 * function: fn,
130 * });
131 * ```
132 *
133 * ### Promoting with an Alias
134 * **Example:** Stable Production Alias
135 * ```typescript
136 * const version = yield* AWS.Lambda.Version("CampaignRunVersion", {
137 * function: campaign.function,
138 * });
139 * const live = yield* AWS.Lambda.Alias("CampaignRunLive", {
140 * version,
141 * aliasName: "live",
142 * });
143 * ```
144 *
145 * ### Explicit Deletion
146 * **Example:** Delete the Exact Version on Stack Destroy
147 * ```typescript
148 * import { destroy } from "alchemy/RemovalPolicy";
149 *
150 * const disposable = yield* AWS.Lambda.Version("PreviewVersion", {
151 * function: fn,
152 * }).pipe(destroy());
153 * ```
154 *
155 * @resource
156 */
157export const Version = Object.assign(
158 (id: string, props: VersionProps) =>
159 VersionResource(id, {
160 function: props.function,
161 deploymentHash: props.function.code.hash,
162 }) as unknown as Effect.Effect<Version, never, Providers>,
163 {
164 Type: VersionResource.Type,
165 Provider: VersionResource.Provider,
166 Self: VersionResource.Self,
167 Aliases: VersionResource.Aliases,
168 ref: VersionResource.ref as VersionClass["ref"],
169 },
170) as unknown as VersionClass;
171
172interface VersionMarker {
173 ownerHash: string;
174 instanceHash: string;
175 sourceHash: string;
176}
177
178const MARKER_RE =
179 /^\[alchemy:v=1;o=([a-f0-9]{64});i=([a-f0-9]{64});s=([a-f0-9]{64})\]$/;
180
181const parseMarker = (
182 description: string | undefined,
183): VersionMarker | undefined => {
184 const match = description?.match(MARKER_RE);
185 return match
186 ? {
187 ownerHash: match[1],
188 instanceHash: match[2],
189 sourceHash: match[3],
190 }
191 : undefined;
192};
193
194const isNumberedVersion = (version: string | undefined): version is string =>
195 version !== undefined && /^[1-9]\d*$/.test(version);
196
197export const VersionProvider = () =>
198 Provider.effect(
199 VersionResource,
200 Effect.gen(function* () {
201 const ownerIdentity = Effect.fn(function* (
202 id: string,
203 fqn: string,
204 instanceId: string,
205 ) {
206 const stack = yield* Stack;
207 const stage = yield* Stage;
208 return {
209 ownerHash: yield* sha256Object({
210 stack: stack.name,
211 stage,
212 fqn,
213 id,
214 }),
215 instanceHash: yield* sha256Object({ instanceId }),
216 };
217 });
218
219 const sourceHashOf = (
220 functionArn: string,
221 codeSha256: string,
222 configSha256: string,
223 ) =>
224 sha256Object({
225 functionArn,
226 codeSha256,
227 configSha256,
228 });
229
230 const markerFor = Effect.fn(function* (
231 id: string,
232 fqn: string,
233 instanceId: string,
234 sourceHash: string,
235 ) {
236 const identity = yield* ownerIdentity(id, fqn, instanceId);
237 return {
238 ...identity,
239 sourceHash,
240 description: `[alchemy:v=1;o=${identity.ownerHash};i=${identity.instanceHash};s=${sourceHash}]`,
241 };
242 });
243
244 const publishableConfiguration = (
245 configuration: Lambda.FunctionConfiguration,
246 ) => ({
247 runtime: configuration.Runtime,
248 role: configuration.Role,
249 handler: configuration.Handler,
250 timeout: configuration.Timeout,
251 memorySize: configuration.MemorySize,
252 vpc: configuration.VpcConfig
253 ? {
254 subnetIds: [...(configuration.VpcConfig.SubnetIds ?? [])].sort(),
255 securityGroupIds: [
256 ...(configuration.VpcConfig.SecurityGroupIds ?? []),
257 ].sort(),
258 ipv6AllowedForDualStack:
259 configuration.VpcConfig.Ipv6AllowedForDualStack,
260 }
261 : undefined,
262 deadLetterConfig: configuration.DeadLetterConfig,
263 environment: Object.fromEntries(
264 Object.entries(configuration.Environment?.Variables ?? {}).map(
265 ([key, value]) => [
266 key,
267 Redacted.isRedacted(value) ? Redacted.value(value) : value,
268 ],
269 ),
270 ),
271 kmsKeyArn: configuration.KMSKeyArn,
272 tracingConfig: configuration.TracingConfig,
273 layers: configuration.Layers?.map((layer) => layer.Arn),
274 fileSystemConfigs: configuration.FileSystemConfigs,
275 packageType: configuration.PackageType,
276 imageConfig: configuration.ImageConfigResponse?.ImageConfig,
277 architectures: configuration.Architectures,
278 ephemeralStorage: configuration.EphemeralStorage,
279 snapStart: configuration.SnapStart?.ApplyOn,
280 loggingConfig: configuration.LoggingConfig,
281 tenancyConfig: configuration.TenancyConfig,
282 capacityProviderConfig: configuration.CapacityProviderConfig,
283 durableConfig: configuration.DurableConfig,
284 });
285
286 const completeConfiguration = Effect.fn(function* (
287 functionName: string,
288 configuration: Lambda.FunctionConfiguration,
289 ) {
290 if (
291 !configuration.FunctionArn ||
292 !configuration.FunctionName ||
293 !configuration.CodeSha256
294 ) {
295 throw new Error(
296 `Lambda function ${functionName} did not return the hashes required to publish a version.`,
297 );
298 }
299 return {
300 functionArn: configuration.FunctionArn.replace(
301 /:(?:\$LATEST|[1-9]\d*)$/,
302 "",
303 ),
304 functionName: configuration.FunctionName,
305 codeSha256: configuration.CodeSha256,
306 configSha256: yield* sha256Object(
307 publishableConfiguration(configuration),
308 ),
309 };
310 });
311
312 const snapshot = Effect.fn(function* (
313 configuration: Lambda.FunctionConfiguration,
314 ) {
315 if (!isNumberedVersion(configuration.Version)) return undefined;
316 const complete = yield* completeConfiguration(
317 configuration.FunctionName ?? "unknown",
318 configuration,
319 );
320 const sourceHash = yield* sourceHashOf(
321 complete.functionArn,
322 complete.codeSha256,
323 complete.configSha256,
324 );
325 return {
326 ...complete,
327 version: configuration.Version,
328 versionArn: `${complete.functionArn}:${configuration.Version}`,
329 sourceHash,
330 } satisfies Version["Attributes"];
331 });
332
333 const getVersion = (functionName: string, version: string) =>
334 Lambda.getFunctionConfiguration({
335 FunctionName: functionName,
336 Qualifier: version,
337 }).pipe(
338 Effect.catchTag("ResourceNotFoundException", () =>
339 Effect.succeed(undefined),
340 ),
341 );
342
343 const listVersions = (functionName: string) =>
344 Lambda.listVersionsByFunction
345 .items({ FunctionName: functionName })
346 .pipe(
347 Stream.runCollect,
348 Effect.map((chunk) =>
349 Array.from(chunk).filter((version) =>
350 isNumberedVersion(version.Version),
351 ),
352 ),
353 Effect.catchTag("ResourceNotFoundException", () =>
354 Effect.succeed([]),
355 ),
356 );
357
358 const waitForLatest = Effect.fn(function* (functionName: string) {
359 const configuration = yield* Lambda.getFunctionConfiguration({
360 FunctionName: functionName,
361 Qualifier: "$LATEST",
362 }).pipe(
363 Effect.repeat({
364 schedule: Schedule.spaced("1 second"),
365 until: (current) =>
366 current.State !== "Pending" &&
367 current.State !== "Deactivating" &&
368 current.LastUpdateStatus !== "InProgress",
369 times: 45,
370 }),
371 );
372 if (
373 configuration.State === "Failed" ||
374 configuration.State === "Deactivated" ||
375 configuration.State === "Deleting" ||
376 configuration.LastUpdateStatus === "Failed"
377 ) {
378 return yield* Effect.die(
379 new Error(
380 `Lambda function ${functionName} did not settle successfully: ${
381 configuration.LastUpdateStatusReason ??
382 configuration.StateReason ??
383 configuration.LastUpdateStatus ??
384 configuration.State ??
385 "unknown state"
386 }`,
387 ),
388 );
389 }
390 return configuration;
391 });
392
393 const findOwnedVersion = Effect.fn(function* (
394 functionName: string,
395 marker: VersionMarker,
396 ) {
397 const versions = yield* listVersions(functionName);
398 const matches = versions
399 .flatMap((version) => {
400 const parsed = parseMarker(version.Description);
401 return parsed?.ownerHash === marker.ownerHash &&
402 parsed.sourceHash === marker.sourceHash
403 ? [version]
404 : [];
405 })
406 .sort(
407 (a, b) =>
408 Number.parseInt(b.Version!, 10) - Number.parseInt(a.Version!, 10),
409 );
410 return (
411 matches.find(
412 (version) =>
413 parseMarker(version.Description)?.instanceHash ===
414 marker.instanceHash,
415 ) ?? matches[0]
416 );
417 });
418
419 return {
420 nuke: { skip: true },
421 stables: ["functionName", "functionArn"],
422 diff: Effect.fn(function* ({ olds, news, output }) {
423 const pendingFunctionName = (
424 news as unknown as {
425 function?: { functionName?: Input<string> };
426 }
427 ).function?.functionName;
428 if (
429 output &&
430 pendingFunctionName !== undefined &&
431 isResolved(pendingFunctionName) &&
432 resolvedProps(olds).function.functionName !== pendingFunctionName
433 ) {
434 return { action: "replace" } as const;
435 }
436 if (!isResolved(news)) {
437 return output ? ({ action: "update" } as const) : undefined;
438 }
439 const resolvedOlds = resolvedProps(olds);
440 const resolvedNews = resolvedProps(news);
441 if (!output) return undefined;
442 if (
443 !Object.prototype.hasOwnProperty.call(
444 resolvedNews.function,
445 "code",
446 ) ||
447 !deepEqual(resolvedOlds.function, resolvedNews.function)
448 ) {
449 return { action: "update" } as const;
450 }
451 return { action: "noop" } as const;
452 }),
453 read: Effect.fn(function* ({ id, fqn, instanceId, olds, output }) {
454 if (output) {
455 const observed = yield* getVersion(
456 output.functionName,
457 output.version,
458 );
459 if (!observed) return undefined;
460 const attrs = yield* snapshot(observed);
461 if (!attrs) return undefined;
462 const marker = parseMarker(observed.Description);
463 const identity = yield* ownerIdentity(id, fqn, instanceId);
464 return marker?.ownerHash === identity.ownerHash &&
465 marker.sourceHash === attrs.sourceHash
466 ? attrs
467 : Unowned(attrs);
468 }
469
470 const functionName = olds
471 ? resolvedProps(olds).function.functionName
472 : undefined;
473 if (!functionName) return undefined;
474 const latest = yield* waitForLatest(functionName);
475 const complete = yield* completeConfiguration(functionName, latest);
476 const sourceHash = yield* sourceHashOf(
477 complete.functionArn,
478 complete.codeSha256,
479 complete.configSha256,
480 );
481 const desired = yield* markerFor(id, fqn, instanceId, sourceHash);
482 const recovered = yield* findOwnedVersion(functionName, desired);
483 return recovered ? yield* snapshot(recovered) : undefined;
484 }),
485 list: () =>
486 Effect.gen(function* () {
487 const functionNames = yield* Lambda.listFunctions.items({}).pipe(
488 Stream.runCollect,
489 Effect.map((chunk) =>
490 Array.from(chunk)
491 .map((fn) => fn.FunctionName)
492 .filter((name): name is string => name !== undefined),
493 ),
494 );
495 const versions = yield* Effect.forEach(
496 functionNames,
497 (functionName) =>
498 listVersions(functionName).pipe(
499 Effect.flatMap((items) =>
500 Effect.forEach(items, snapshot, { concurrency: 10 }),
501 ),
502 Effect.map((items) =>
503 items.filter(
504 (item): item is Version["Attributes"] =>
505 item !== undefined,
506 ),
507 ),
508 ),
509 { concurrency: 10 },
510 );
511 return versions.flat();
512 }),
513 reconcile: Effect.fn(function* ({
514 id,
515 fqn,
516 instanceId,
517 news,
518 session,
519 }) {
520 const resolvedNews = resolvedProps(news);
521 const ensure = Effect.gen(function* () {
522 const latest = yield* waitForLatest(
523 resolvedNews.function.functionName,
524 );
525 const complete = yield* completeConfiguration(
526 resolvedNews.function.functionName,
527 latest,
528 );
529 const sourceHash = yield* sourceHashOf(
530 complete.functionArn,
531 complete.codeSha256,
532 complete.configSha256,
533 );
534 const marker = yield* markerFor(id, fqn, instanceId, sourceHash);
535 const existing = yield* findOwnedVersion(
536 complete.functionName,
537 marker,
538 );
539 if (existing) {
540 const attrs = yield* snapshot(existing);
541 if (attrs) return attrs;
542 }
543
544 const published = yield* Lambda.publishVersion({
545 FunctionName: complete.functionName,
546 CodeSha256: complete.codeSha256,
547 RevisionId: latest.RevisionId,
548 Description: marker.description,
549 });
550 const attrs = yield* snapshot(published);
551 if (
552 !attrs ||
553 attrs.sourceHash !== sourceHash ||
554 published.Description !== marker.description
555 ) {
556 return yield* Effect.die(
557 new Error(
558 `Lambda function ${complete.functionName} did not return the expected published version.`,
559 ),
560 );
561 }
562 return attrs;
563 }).pipe(
564 Effect.retry({
565 while: (error) =>
566 error._tag === "PreconditionFailedException" ||
567 error._tag === "ResourceConflictException",
568 schedule: Schedule.max([
569 Schedule.exponential("250 millis"),
570 Schedule.recurs(8),
571 ]),
572 }),
573 );
574
575 const attrs = yield* ensure;
576 yield* session.note(`Version ${attrs.functionName}:${attrs.version}`);
577 return attrs;
578 }),
579 delete: Effect.fn(function* ({ output }) {
580 yield* Lambda.deleteFunction({
581 FunctionName: output.functionName,
582 Qualifier: output.version,
583 }).pipe(
584 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
585 );
586 }),
587 };
588 }),
589 );