| 1 | import type * as lambda from "@distilled.cloud/aws/lambda"; |
| 2 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { sha256, sha256Object } from "../../Util/sha256.ts"; |
| 12 | import { zipFiles, type ZipFile } from "../../Util/zip.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | |
| 15 | /** |
| 16 | * Reference to an S3 bucket: a raw bucket name or anything exposing a |
| 17 | * `bucketName` attribute (e.g. an `AWS.S3.Bucket` resource). |
| 18 | */ |
| 19 | export type BucketRef = string | { bucketName: string }; |
| 20 | |
| 21 | /** Resolve a {@link BucketRef} to its bucket name. */ |
| 22 | const bucketNameOf = (bucket: BucketRef): string => |
| 23 | typeof bucket === "string" ? bucket : bucket.bucketName; |
| 24 | |
| 25 | /** |
| 26 | * Layer content stored in S3. Use this instead of {@link LayerVersionProps.path} |
| 27 | * for archives larger than the 50 MB direct-upload limit. |
| 28 | */ |
| 29 | export interface LayerVersionS3Content { |
| 30 | /** |
| 31 | * S3 bucket holding the layer archive — an `AWS.S3.Bucket` resource or a |
| 32 | * raw bucket name. |
| 33 | */ |
| 34 | bucket: BucketRef; |
| 35 | /** Key of the layer archive object. */ |
| 36 | key: string; |
| 37 | /** Version id of the object, for versioned buckets. */ |
| 38 | objectVersion?: string; |
| 39 | } |
| 40 | |
| 41 | export interface LayerVersionProps { |
| 42 | /** |
| 43 | * Name of the layer. If omitted, a unique name is generated. |
| 44 | */ |
| 45 | layerName?: string; |
| 46 | /** |
| 47 | * Local path to the layer content — either a directory whose contents are |
| 48 | * packaged into a zip archive, or an existing `.zip` file uploaded as-is. |
| 49 | * |
| 50 | * Layer archives are extracted into `/opt` at runtime, so the directory's |
| 51 | * layout must match the runtime's convention (e.g. `nodejs/node_modules` |
| 52 | * for Node.js dependencies, `bin/` for executables). File modes are |
| 53 | * preserved, so executables keep their `+x` bit. |
| 54 | * |
| 55 | * Exactly one of `path` or {@link s3} is required. |
| 56 | */ |
| 57 | path?: string; |
| 58 | /** |
| 59 | * Layer content already uploaded to S3. Required for archives above the |
| 60 | * 50 MB direct-upload limit. |
| 61 | * |
| 62 | * Exactly one of {@link path} or `s3` is required. |
| 63 | */ |
| 64 | s3?: LayerVersionS3Content; |
| 65 | /** |
| 66 | * Description of this version of the layer. |
| 67 | */ |
| 68 | description?: string; |
| 69 | /** |
| 70 | * Runtimes the layer is compatible with. Used to filter the layer in the |
| 71 | * console; Lambda does not enforce it. |
| 72 | */ |
| 73 | compatibleRuntimes?: lambda.Runtime[]; |
| 74 | /** |
| 75 | * Instruction set architectures the layer is compatible with. |
| 76 | */ |
| 77 | compatibleArchitectures?: lambda.Architecture[]; |
| 78 | /** |
| 79 | * The layer's software license — an SPDX identifier, a URL, or the full |
| 80 | * license text. |
| 81 | */ |
| 82 | licenseInfo?: string; |
| 83 | } |
| 84 | |
| 85 | export interface LayerVersion extends Resource< |
| 86 | "AWS.Lambda.LayerVersion", |
| 87 | LayerVersionProps, |
| 88 | { |
| 89 | /** |
| 90 | * Name of the layer. |
| 91 | */ |
| 92 | layerName: string; |
| 93 | /** |
| 94 | * ARN of the layer (without a version suffix). |
| 95 | */ |
| 96 | layerArn: string; |
| 97 | /** |
| 98 | * ARN of this specific layer version. This is what |
| 99 | * {@link FunctionProps.layers} takes. |
| 100 | */ |
| 101 | layerVersionArn: string; |
| 102 | /** |
| 103 | * Version number assigned by Lambda. |
| 104 | */ |
| 105 | version: number; |
| 106 | /** |
| 107 | * Hash of the packaged content and publish configuration. Any change to |
| 108 | * it publishes a new version, since layer versions are immutable. |
| 109 | */ |
| 110 | sourceHash: string; |
| 111 | /** |
| 112 | * Base64-encoded SHA-256 of the layer archive, as reported by Lambda. |
| 113 | */ |
| 114 | codeSha256?: string; |
| 115 | /** |
| 116 | * Size of the layer archive in bytes. |
| 117 | */ |
| 118 | codeSize?: number; |
| 119 | /** |
| 120 | * Date the version was created, in ISO-8601 format. |
| 121 | */ |
| 122 | createdDate?: string; |
| 123 | /** |
| 124 | * Description of the version. |
| 125 | */ |
| 126 | description?: string; |
| 127 | /** |
| 128 | * Runtimes the layer is compatible with. |
| 129 | */ |
| 130 | compatibleRuntimes?: lambda.Runtime[]; |
| 131 | /** |
| 132 | * Architectures the layer is compatible with. |
| 133 | */ |
| 134 | compatibleArchitectures?: lambda.Architecture[]; |
| 135 | /** |
| 136 | * The layer's software license. |
| 137 | */ |
| 138 | licenseInfo?: string; |
| 139 | }, |
| 140 | never, |
| 141 | Providers |
| 142 | > {} |
| 143 | |
| 144 | /** |
| 145 | * A version of a Lambda layer — a zip archive of libraries, a custom runtime, |
| 146 | * or other dependencies that Lambda extracts into `/opt` alongside your |
| 147 | * function code. |
| 148 | * |
| 149 | * Layer versions are immutable, so changing the content or any publish |
| 150 | * setting publishes a new version under the same layer and retires the one |
| 151 | * it supersedes. `layerVersionArn` and `version` therefore change on update; |
| 152 | * `layerName` and `layerArn` stay put. |
| 153 | * |
| 154 | * ### Publishing a Layer |
| 155 | * **Example:** Package a Local Directory |
| 156 | * ```typescript |
| 157 | * // ./layers/deps contains nodejs/node_modules/... |
| 158 | * const deps = yield* LayerVersion("Deps", { |
| 159 | * path: "./layers/deps", |
| 160 | * compatibleRuntimes: ["nodejs22.x"], |
| 161 | * }); |
| 162 | * ``` |
| 163 | * |
| 164 | * **Example:** Publish an Existing Archive |
| 165 | * ```typescript |
| 166 | * const layer = yield* LayerVersion("Ffmpeg", { |
| 167 | * path: "./dist/ffmpeg-layer.zip", |
| 168 | * description: "static ffmpeg build", |
| 169 | * compatibleArchitectures: ["arm64"], |
| 170 | * }); |
| 171 | * ``` |
| 172 | * |
| 173 | * **Example:** Publish From S3 |
| 174 | * ```typescript |
| 175 | * const layer = yield* LayerVersion("BigLayer", { |
| 176 | * s3: { |
| 177 | * bucket, |
| 178 | * key: "layers/big-layer.zip", |
| 179 | * }, |
| 180 | * }); |
| 181 | * ``` |
| 182 | * |
| 183 | * ### Attaching to a Function |
| 184 | * **Example:** Use a Layer in a Function |
| 185 | * ```typescript |
| 186 | * const fn = yield* Function("Handler", { |
| 187 | * main: import.meta.resolve("./handler.ts"), |
| 188 | * layers: [deps], |
| 189 | * }); |
| 190 | * ``` |
| 191 | * |
| 192 | * @resource |
| 193 | */ |
| 194 | export const LayerVersion = Resource<LayerVersion>("AWS.Lambda.LayerVersion"); |
| 195 | |
| 196 | /** |
| 197 | * Lambda rejects a `PublishLayerVersion` request whose inline archive exceeds |
| 198 | * 50 MB — larger archives must go through S3. |
| 199 | */ |
| 200 | const MAX_INLINE_ARCHIVE_SIZE = 50 * 1024 * 1024; |
| 201 | |
| 202 | export const LayerVersionProvider = () => |
| 203 | Provider.effect( |
| 204 | LayerVersion, |
| 205 | Effect.gen(function* () { |
| 206 | const createLayerName = (id: string, layerName?: string) => |
| 207 | layerName |
| 208 | ? Effect.succeed(layerName) |
| 209 | : createPhysicalName({ |
| 210 | id, |
| 211 | // Layer names are limited to 140 characters. |
| 212 | maxLength: 140, |
| 213 | delimiter: "-", |
| 214 | }); |
| 215 | |
| 216 | /** |
| 217 | * Read `directory` recursively into deterministic zip entries, keeping |
| 218 | * each file's unix mode so executables stay executable under `/opt`. |
| 219 | */ |
| 220 | const readDirectoryEntries = Effect.fn(function* (directory: string) { |
| 221 | const fs = yield* FileSystem.FileSystem; |
| 222 | const path = yield* Path.Path; |
| 223 | const names = yield* fs.readDirectory(directory, { recursive: true }); |
| 224 | const entries = yield* Effect.forEach( |
| 225 | names, |
| 226 | Effect.fn(function* (name) { |
| 227 | const file = path.join(directory, name); |
| 228 | const info = yield* fs.stat(file); |
| 229 | if (info.type !== "File") return []; |
| 230 | return [ |
| 231 | { |
| 232 | // Zip archives always use forward slashes. |
| 233 | path: name.replaceAll("\\", "/"), |
| 234 | content: yield* fs.readFile(file), |
| 235 | mode: info.mode, |
| 236 | } satisfies ZipFile, |
| 237 | ]; |
| 238 | }), |
| 239 | { concurrency: 16 }, |
| 240 | ); |
| 241 | return entries.flat(); |
| 242 | }); |
| 243 | |
| 244 | /** |
| 245 | * Resolve the props' content into a `PublishLayerVersion` payload plus |
| 246 | * a hash of everything that would force a new version. |
| 247 | */ |
| 248 | const packageContent = Effect.fn(function* (props: LayerVersionProps) { |
| 249 | const config = { |
| 250 | description: props.description, |
| 251 | compatibleRuntimes: props.compatibleRuntimes, |
| 252 | compatibleArchitectures: props.compatibleArchitectures, |
| 253 | licenseInfo: props.licenseInfo, |
| 254 | }; |
| 255 | if (props.s3 && props.path) { |
| 256 | return yield* Effect.die( |
| 257 | "AWS.Lambda.LayerVersion accepts either `path` or `s3`, not both.", |
| 258 | ); |
| 259 | } |
| 260 | if (props.s3) { |
| 261 | // Hash the resolved coordinates, not the props, so a bucket passed |
| 262 | // as a resource and the same bucket passed by name agree. |
| 263 | const s3 = { |
| 264 | S3Bucket: bucketNameOf(props.s3.bucket), |
| 265 | S3Key: props.s3.key, |
| 266 | S3ObjectVersion: props.s3.objectVersion, |
| 267 | } satisfies lambda.LayerVersionContentInput; |
| 268 | return { |
| 269 | content: s3, |
| 270 | sourceHash: yield* sha256Object({ s3, config }), |
| 271 | }; |
| 272 | } |
| 273 | if (!props.path) { |
| 274 | return yield* Effect.die( |
| 275 | "AWS.Lambda.LayerVersion requires either `path` or `s3`.", |
| 276 | ); |
| 277 | } |
| 278 | const fs = yield* FileSystem.FileSystem; |
| 279 | const path = yield* Path.Path; |
| 280 | const resolved = path.resolve(props.path); |
| 281 | const info = yield* fs.stat(resolved); |
| 282 | if (info.type !== "Directory" && path.extname(resolved) !== ".zip") { |
| 283 | return yield* Effect.die( |
| 284 | `AWS.Lambda.LayerVersion \`path\` must be a directory or a .zip archive: ${resolved}`, |
| 285 | ); |
| 286 | } |
| 287 | const archive = |
| 288 | info.type === "Directory" |
| 289 | ? yield* zipFiles(yield* readDirectoryEntries(resolved)) |
| 290 | : yield* fs.readFile(resolved); |
| 291 | if (archive.byteLength > MAX_INLINE_ARCHIVE_SIZE) { |
| 292 | return yield* Effect.die( |
| 293 | `AWS.Lambda.LayerVersion archive is ${archive.byteLength} bytes, above Lambda's ${MAX_INLINE_ARCHIVE_SIZE} byte direct-upload limit. Upload it to S3 and use \`s3\` instead.`, |
| 294 | ); |
| 295 | } |
| 296 | return { |
| 297 | content: { |
| 298 | ZipFile: new Uint8Array(archive), |
| 299 | } satisfies lambda.LayerVersionContentInput, |
| 300 | // Hash the archive bytes rather than the source path so the same |
| 301 | // content checked out at a different path does not republish. |
| 302 | sourceHash: yield* sha256Object({ |
| 303 | archive: yield* sha256(archive), |
| 304 | config, |
| 305 | }), |
| 306 | }; |
| 307 | }); |
| 308 | |
| 309 | const snapshot = ( |
| 310 | layerName: string, |
| 311 | sourceHash: string, |
| 312 | // `listLayerVersions` items carry the same fields as a |
| 313 | // `getLayerVersion` response minus `LayerArn`/`Content`, so both |
| 314 | // shapes snapshot through here. |
| 315 | version: lambda.GetLayerVersionResponse | lambda.LayerVersionsListItem, |
| 316 | ): LayerVersion["Attributes"] | undefined => { |
| 317 | if (!version.LayerVersionArn || !version.Version) { |
| 318 | return undefined; |
| 319 | } |
| 320 | const layerArn = |
| 321 | ("LayerArn" in version ? version.LayerArn : undefined) ?? |
| 322 | // `arn:…:layer:{name}:{version}` — the layer ARN is the version ARN |
| 323 | // without its trailing version segment. |
| 324 | version.LayerVersionArn.slice( |
| 325 | 0, |
| 326 | version.LayerVersionArn.lastIndexOf(":"), |
| 327 | ); |
| 328 | const content = "Content" in version ? version.Content : undefined; |
| 329 | return { |
| 330 | layerName, |
| 331 | layerArn, |
| 332 | layerVersionArn: version.LayerVersionArn, |
| 333 | version: version.Version, |
| 334 | sourceHash, |
| 335 | codeSha256: content?.CodeSha256, |
| 336 | codeSize: content?.CodeSize, |
| 337 | createdDate: version.CreatedDate, |
| 338 | description: version.Description || undefined, |
| 339 | compatibleRuntimes: version.CompatibleRuntimes, |
| 340 | compatibleArchitectures: version.CompatibleArchitectures, |
| 341 | licenseInfo: version.LicenseInfo, |
| 342 | }; |
| 343 | }; |
| 344 | |
| 345 | const getVersion = (layerName: string, version: number) => |
| 346 | Lambda.getLayerVersion({ |
| 347 | LayerName: layerName, |
| 348 | VersionNumber: version, |
| 349 | }).pipe( |
| 350 | Effect.catchTag("ResourceNotFoundException", () => |
| 351 | Effect.succeed(undefined), |
| 352 | ), |
| 353 | ); |
| 354 | |
| 355 | return { |
| 356 | // `layerVersionArn`/`version` deliberately are NOT stable: an update |
| 357 | // publishes a new version under the same layer. |
| 358 | stables: ["layerName", "layerArn"], |
| 359 | // A layer version is immutable, but the *layer* is not: publishing |
| 360 | // re-uses the layer name and mints version N+1. So a content or |
| 361 | // config change is an UPDATE, not a replacement — a replacement would |
| 362 | // mint a fresh instance id, hence a fresh layer name, and every |
| 363 | // change would strand a new layer stuck at version 1. |
| 364 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 365 | if (!isResolved(news)) return; |
| 366 | if (!output) return undefined; |
| 367 | // Auto-generated names are engine-owned: the published layer's name |
| 368 | // stays authoritative even if the generator would name this id |
| 369 | // differently today. Only an explicit `layerName` forces a replace. |
| 370 | if ((news.layerName ?? output.layerName) !== output.layerName) { |
| 371 | return { action: "replace" } as const; |
| 372 | } |
| 373 | const { sourceHash } = yield* packageContent(news); |
| 374 | if ( |
| 375 | sourceHash !== output.sourceHash || |
| 376 | olds.description !== news.description || |
| 377 | olds.licenseInfo !== news.licenseInfo || |
| 378 | !deepEqual(olds.compatibleRuntimes, news.compatibleRuntimes) || |
| 379 | !deepEqual( |
| 380 | olds.compatibleArchitectures, |
| 381 | news.compatibleArchitectures, |
| 382 | ) |
| 383 | ) { |
| 384 | return { action: "update" } as const; |
| 385 | } |
| 386 | return { action: "noop" } as const; |
| 387 | }), |
| 388 | read: Effect.fn(function* ({ output }) { |
| 389 | if (!output) return undefined; |
| 390 | const version = yield* getVersion(output.layerName, output.version); |
| 391 | return version |
| 392 | ? snapshot(output.layerName, output.sourceHash, version) |
| 393 | : undefined; |
| 394 | }), |
| 395 | list: () => |
| 396 | Effect.gen(function* () { |
| 397 | // Layers carry no tags, so there is nothing to filter ownership |
| 398 | // on — enumerate every version of every layer in the ambient |
| 399 | // account/region. |
| 400 | const layerNames = yield* Lambda.listLayers.items({}).pipe( |
| 401 | Stream.runCollect, |
| 402 | Effect.map((chunk) => |
| 403 | Array.from(chunk) |
| 404 | .map((layer) => layer.LayerName) |
| 405 | .filter((name): name is string => name !== undefined), |
| 406 | ), |
| 407 | ); |
| 408 | const versions = yield* Effect.forEach( |
| 409 | layerNames, |
| 410 | (layerName) => |
| 411 | Lambda.listLayerVersions.items({ LayerName: layerName }).pipe( |
| 412 | Stream.runCollect, |
| 413 | Effect.map((chunk) => |
| 414 | Array.from(chunk).flatMap((version) => { |
| 415 | const attrs = snapshot(layerName, "", version); |
| 416 | return attrs ? [attrs] : []; |
| 417 | }), |
| 418 | ), |
| 419 | Effect.catchTag("ResourceNotFoundException", () => |
| 420 | Effect.succeed([] as LayerVersion["Attributes"][]), |
| 421 | ), |
| 422 | ), |
| 423 | { concurrency: 10 }, |
| 424 | ); |
| 425 | return versions.flat(); |
| 426 | }), |
| 427 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 428 | const layerName = |
| 429 | output?.layerName ?? (yield* createLayerName(id, news.layerName)); |
| 430 | const { content, sourceHash } = yield* packageContent(news); |
| 431 | |
| 432 | // Observe: an interrupted apply may have already published the |
| 433 | // version recorded in state. Republishing would leak a version, so |
| 434 | // reuse it when it still exists and still matches the desired |
| 435 | // content. |
| 436 | if ( |
| 437 | output?.version !== undefined && |
| 438 | output.sourceHash === sourceHash |
| 439 | ) { |
| 440 | const existing = yield* getVersion(layerName, output.version); |
| 441 | const attrs = existing && snapshot(layerName, sourceHash, existing); |
| 442 | if (attrs) return attrs; |
| 443 | } |
| 444 | |
| 445 | const published = yield* Lambda.publishLayerVersion({ |
| 446 | LayerName: layerName, |
| 447 | Content: content, |
| 448 | Description: news.description, |
| 449 | CompatibleRuntimes: news.compatibleRuntimes, |
| 450 | CompatibleArchitectures: news.compatibleArchitectures, |
| 451 | LicenseInfo: news.licenseInfo, |
| 452 | }); |
| 453 | |
| 454 | const attrs = snapshot(layerName, sourceHash, published); |
| 455 | if (!attrs) { |
| 456 | return yield* Effect.die( |
| 457 | `Lambda layer ${layerName} did not return complete attributes.`, |
| 458 | ); |
| 459 | } |
| 460 | |
| 461 | // Retire the version this one supersedes, so an update doesn't |
| 462 | // leak a version per deploy. Functions already referencing the old |
| 463 | // version keep working — Lambda retains a copy until nothing |
| 464 | // refers to it. |
| 465 | if ( |
| 466 | output?.version !== undefined && |
| 467 | output.version !== attrs.version |
| 468 | ) { |
| 469 | yield* Lambda.deleteLayerVersion({ |
| 470 | LayerName: layerName, |
| 471 | VersionNumber: output.version, |
| 472 | }).pipe( |
| 473 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 474 | ); |
| 475 | } |
| 476 | |
| 477 | yield* session.note(`Layer ${attrs.layerName}:${attrs.version}`); |
| 478 | |
| 479 | return attrs; |
| 480 | }), |
| 481 | delete: Effect.fn(function* ({ output }) { |
| 482 | yield* Lambda.deleteLayerVersion({ |
| 483 | LayerName: output.layerName, |
| 484 | VersionNumber: output.version, |
| 485 | }).pipe( |
| 486 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 487 | ); |
| 488 | }), |
| 489 | }; |
| 490 | }), |
| 491 | ); |