| 1 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 8 | import type { Input } from "../../Input.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource, type ResourceClassLike } from "../../Resource.ts"; |
| 11 | import { Stack } from "../../Stack.ts"; |
| 12 | import { Stage } from "../../Stage.ts"; |
| 13 | import { sha256Object } from "../../Util/sha256.ts"; |
| 14 | import type { Providers } from "../Providers.ts"; |
| 15 | import type { Function } from "./Function.ts"; |
| 16 | |
| 17 | export interface VersionProps { |
| 18 | /** |
| 19 | * Managed Lambda function whose current code and versioned configuration |
| 20 | * should be published. |
| 21 | */ |
| 22 | function: Function; |
| 23 | } |
| 24 | |
| 25 | type 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 | |
| 59 | export interface Version extends Resource< |
| 60 | "AWS.Lambda.Version", |
| 61 | VersionProps, |
| 62 | VersionAttributes, |
| 63 | never, |
| 64 | Providers |
| 65 | > {} |
| 66 | |
| 67 | interface 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 | |
| 76 | interface VersionResource extends Resource< |
| 77 | "AWS.Lambda.Version", |
| 78 | VersionResourceProps, |
| 79 | VersionAttributes, |
| 80 | never, |
| 81 | Providers |
| 82 | > {} |
| 83 | |
| 84 | type 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 | */ |
| 91 | const resolvedProps = (props: VersionResourceProps): ResolvedVersionProps => |
| 92 | props as unknown as ResolvedVersionProps; |
| 93 | |
| 94 | const VersionResource = Resource<VersionResource>("AWS.Lambda.Version", { |
| 95 | defaultRemovalPolicy: "retain", |
| 96 | }); |
| 97 | |
| 98 | export 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 | */ |
| 157 | export 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 | |
| 172 | interface VersionMarker { |
| 173 | ownerHash: string; |
| 174 | instanceHash: string; |
| 175 | sourceHash: string; |
| 176 | } |
| 177 | |
| 178 | const MARKER_RE = |
| 179 | /^\[alchemy:v=1;o=([a-f0-9]{64});i=([a-f0-9]{64});s=([a-f0-9]{64})\]$/; |
| 180 | |
| 181 | const 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 | |
| 194 | const isNumberedVersion = (version: string | undefined): version is string => |
| 195 | version !== undefined && /^[1-9]\d*$/.test(version); |
| 196 | |
| 197 | export 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 | ); |