1import * as ecr from "@distilled.cloud/aws/ecr";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Schema from "effect/Schema";
5import { Docker } from "../../Docker/Docker.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 createInternalTags,
9 createTagsList,
10 hasAlchemyTags,
11} from "../../Tags.ts";
12import { sha256Object } from "../../Util/sha256.ts";
13import {
14 hashDockerBuildInputs,
15 resolveDockerBuildPaths,
16} from "../../Docker/BuildHash.ts";
17import { buildAndPushEcrImage } from "../ECR/Image.ts";
18import { AWSEnvironment } from "../Environment.ts";
19import { normalizePolicyDocument } from "../IAM/Policy.ts";
20import type { FunctionImageConfig } from "./Function.ts";
21
22/** A Lambda container image built from a local Docker context. */
23export interface FunctionDockerImageSource extends FunctionImageConfig {
24 uri?: never;
25 /** Docker build context directory. */
26 context: string;
27 /** Dockerfile path, relative to {@link context} unless absolute. */
28 dockerfile: string;
29 /**
30 * Docker build arguments (`--build-arg`).
31 *
32 * Build arguments are stored in deployment state and may be retained in
33 * image layers. Do not use them for secrets.
34 */
35 buildArgs?: Record<string, string>;
36}
37
38/** An existing private ECR image, addressed by tag or immutable digest. */
39export interface FunctionEcrImageSource extends FunctionImageConfig {
40 /**
41 * Full private ECR image URI, including a tag or digest.
42 *
43 * The repository must be in the same AWS Region as the Lambda function.
44 * Alchemy observes the current ECR digest on every plan, so repointing a
45 * tag updates the function even when the URI string itself is unchanged.
46 */
47 uri: string;
48 context?: never;
49 dockerfile?: never;
50 buildArgs?: never;
51}
52
53/**
54 * Image input for an image-packaged Lambda function.
55 *
56 * Source values must be literal because Lambda's circular-dependency
57 * pre-create phase needs the deployable image before normal Output resolution.
58 */
59export type FunctionImageSource =
60 | FunctionDockerImageSource
61 | FunctionEcrImageSource;
62
63// Instruction overrides ride along on the source object; they are function
64// configuration, not image identity, so they never enter the image hash.
65const FunctionImageConfigFields = {
66 command: Schema.optionalKey(Schema.Array(Schema.String)),
67 entryPoint: Schema.optionalKey(Schema.Array(Schema.String)),
68 workingDirectory: Schema.optionalKey(Schema.String),
69};
70
71const FunctionDockerImageSourceSchema = Schema.Struct({
72 context: Schema.String,
73 dockerfile: Schema.String,
74 buildArgs: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
75 ...FunctionImageConfigFields,
76}) satisfies Schema.Schema<FunctionDockerImageSource>;
77
78const FunctionEcrImageSourceSchema = Schema.Struct({
79 uri: Schema.String,
80 ...FunctionImageConfigFields,
81}) satisfies Schema.Schema<FunctionEcrImageSource>;
82
83const FunctionImageSourceRecordSchema = Schema.Record(
84 Schema.String,
85 Schema.Unknown,
86);
87
88export interface ParsedFunctionImageUri {
89 uri: string;
90 registryId: string;
91 registryHost: string;
92 region: string;
93 repositoryName: string;
94 repositoryUri: string;
95 imageId: { imageTag: string } | { imageDigest: string };
96}
97
98/** Parse and validate a tagged or digest-pinned private ECR image URI. */
99export const parseFunctionImageUri = Effect.fn("AWS.Lambda.parseImageUri")(
100 function* (id: string, uri: string) {
101 const slash = uri.indexOf("/");
102 if (slash <= 0 || slash === uri.length - 1) {
103 return yield* Effect.fail(
104 new Error(
105 `Function(${id}): image.uri must be a private ECR image URI including a tag or digest`,
106 ),
107 );
108 }
109
110 const registryHost = uri.slice(0, slash);
111 const registry = /^(\d{12})\.dkr\.ecr(-fips)?\.([a-z0-9-]+)\.(.+)$/.exec(
112 registryHost,
113 );
114 if (registry === null) {
115 return yield* Effect.fail(
116 new Error(
117 `Function(${id}): image.uri must use a private ECR registry such as 123456789012.dkr.ecr.us-east-1.amazonaws.com/repository:tag`,
118 ),
119 );
120 }
121 if (registry[2] !== undefined) {
122 return yield* Effect.fail(
123 new Error(
124 `Function(${id}): Lambda container images do not support ECR FIPS endpoints`,
125 ),
126 );
127 }
128
129 const reference = uri.slice(slash + 1);
130 const digestSeparator = reference.lastIndexOf("@");
131 const tagSeparator = reference.lastIndexOf(
132 ":",
133 digestSeparator >= 0 ? digestSeparator : reference.length,
134 );
135 if (digestSeparator >= 0 && tagSeparator >= 0) {
136 return yield* Effect.fail(
137 new Error(
138 `Function(${id}): image.uri must identify the image by either tag or digest, not both`,
139 ),
140 );
141 }
142 const repositoryName = reference.slice(
143 0,
144 digestSeparator >= 0 ? digestSeparator : tagSeparator,
145 );
146 if (repositoryName.length === 0) {
147 return yield* Effect.fail(
148 new Error(`Function(${id}): image.uri has no ECR repository name`),
149 );
150 }
151
152 const imageId = yield* Effect.gen(function* () {
153 if (digestSeparator >= 0) {
154 const imageDigest = reference.slice(digestSeparator + 1);
155 if (!/^sha256:[0-9a-f]{64}$/.test(imageDigest)) {
156 return yield* Effect.fail(
157 new Error(
158 `Function(${id}): image.uri digest must be sha256 followed by 64 lowercase hexadecimal characters`,
159 ),
160 );
161 }
162 return { imageDigest } as const;
163 }
164 if (tagSeparator <= 0 || tagSeparator === reference.length - 1) {
165 return yield* Effect.fail(
166 new Error(
167 `Function(${id}): image.uri must include an explicit ECR tag or digest`,
168 ),
169 );
170 }
171 return { imageTag: reference.slice(tagSeparator + 1) } as const;
172 });
173
174 return {
175 uri,
176 registryId: registry[1],
177 registryHost,
178 region: registry[3],
179 repositoryName,
180 repositoryUri: `${registryHost}/${repositoryName}`,
181 imageId,
182 } satisfies ParsedFunctionImageUri;
183 },
184);
185
186export interface FunctionImageAttributes {
187 /** Image source kind used by the Function. */
188 source: "build" | "uri";
189 /** Stable identity hash for the requested URI and resolved image digest. */
190 hash: string;
191 /** Tag- or digest-based URI passed to Lambda. */
192 imageUri: string;
193 /** Digest-pinned URI resolved from ECR. */
194 resolvedImageUri: string;
195 /** Registry manifest digest. */
196 digest: string;
197 /** Backing ECR repository name. */
198 repositoryName: string;
199 /** Backing ECR repository URI. */
200 repositoryUri: string;
201 /** Whether the Function owns and may delete the backing repository. */
202 ownsRepository: boolean;
203}
204
205export interface ResolveFunctionImageOptions {
206 id: string;
207 source: FunctionImageSource;
208 architecture: "x86_64" | "arm64";
209 previousImage?: Omit<FunctionImageAttributes, "hash">;
210 session: { note: (message: string) => Effect.Effect<void> };
211}
212
213export interface FunctionImageIdentity {
214 source: "build" | "uri";
215 hash: string;
216 imageUri?: string;
217 resolvedImageUri?: string;
218}
219
220/**
221 * Increment when the Lambda image build behavior changes in a way that must
222 * invalidate already-pushed content-addressed images.
223 */
224const functionImageBuilderVersion = 2;
225
226export const functionImagePlatform = (architecture: "x86_64" | "arm64") => {
227 if (architecture === "x86_64") {
228 return "linux/amd64";
229 }
230 if (architecture === "arm64") {
231 return "linux/arm64";
232 }
233 throw new Error(`Unsupported Lambda image architecture: ${architecture}`);
234};
235
236const hashDecodedFunctionImageBuild = Effect.fn(function* (
237 source: FunctionDockerImageSource,
238 architecture: "x86_64" | "arm64",
239) {
240 const buildHash = yield* hashDockerBuildInputs(
241 {
242 ...source,
243 platform: functionImagePlatform(architecture),
244 },
245 "effective",
246 );
247 return (yield* sha256Object({
248 builderVersion: functionImageBuilderVersion,
249 buildHash,
250 })).slice(0, 32);
251});
252
253/**
254 * Hash the Docker build context, Dockerfile, build args, target platform, and
255 * Lambda image-builder version without including absolute paths.
256 */
257export const hashFunctionImageBuild = Effect.fn(function* (
258 source: FunctionDockerImageSource,
259 architecture: "x86_64" | "arm64",
260) {
261 const decoded = yield* Schema.decodeUnknownEffect(
262 FunctionDockerImageSourceSchema,
263 )(source);
264 return yield* hashDecodedFunctionImageBuild(decoded, architecture);
265});
266
267export const decodeFunctionImageSource = Effect.fn(
268 "AWS.Lambda.decodeFunctionImageSource",
269)(function* (id: string, source: unknown) {
270 const record = yield* Schema.decodeUnknownEffect(
271 FunctionImageSourceRecordSchema,
272 )(source).pipe(
273 Effect.mapError(
274 (error) =>
275 new Error(`Function(${id}): image must be an object`, { cause: error }),
276 ),
277 );
278 const hasUri = record.uri !== undefined;
279 const hasBuildSource =
280 record.context !== undefined ||
281 record.dockerfile !== undefined ||
282 record.buildArgs !== undefined;
283 if (hasUri && hasBuildSource) {
284 return yield* Effect.fail(
285 new Error(
286 `Function(${id}): image.uri cannot be combined with image.context, image.dockerfile, or image.buildArgs; declare exactly one image source`,
287 ),
288 );
289 }
290 if (!hasUri && !hasBuildSource) {
291 return yield* Effect.fail(
292 new Error(
293 `Function(${id}): image must declare either image.uri or both image.context and image.dockerfile`,
294 ),
295 );
296 }
297 const decodeError = (error: unknown) =>
298 new Error(
299 `Function(${id}): image source values must be literal and valid before Lambda pre-create`,
300 { cause: error },
301 );
302 if (hasUri) {
303 return yield* Schema.decodeUnknownEffect(FunctionEcrImageSourceSchema)(
304 source,
305 ).pipe(
306 Effect.map((decoded): FunctionEcrImageSource => decoded),
307 Effect.mapError(decodeError),
308 );
309 }
310 return yield* Schema.decodeUnknownEffect(FunctionDockerImageSourceSchema)(
311 source,
312 ).pipe(
313 Effect.map((decoded): FunctionDockerImageSource => decoded),
314 Effect.mapError(decodeError),
315 );
316});
317
318const isFunctionEcrImageSource = (
319 source: FunctionImageSource,
320): source is FunctionEcrImageSource => source.uri !== undefined;
321
322/**
323 * Lambda-specific image resolver. It deliberately stays separate from the
324 * ECS/EKS image-source abstraction because Lambda deploys private ECR images
325 * directly and its managed local-build path owns a repository pull policy.
326 */
327export const makeFunctionImage = Effect.gen(function* () {
328 const docker = yield* Docker;
329
330 const createRepositoryName = (id: string) =>
331 createPhysicalName({
332 id: `${id}-image`,
333 maxLength: 256,
334 lowercase: true,
335 });
336
337 const desiredRepositoryPolicy = Effect.fn(function* () {
338 const { accountId, region } = yield* AWSEnvironment.current;
339 return JSON.stringify({
340 Version: "2012-10-17",
341 Statement: [
342 {
343 Sid: "LambdaECRImageRetrievalPolicy",
344 Effect: "Allow",
345 Principal: { Service: "lambda.amazonaws.com" },
346 Action: ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
347 Condition: {
348 StringLike: {
349 "aws:sourceArn": `arn:aws:lambda:${region}:${accountId}:function:*`,
350 },
351 },
352 },
353 ],
354 });
355 });
356
357 const ensureRepository = Effect.fn(function* (
358 id: string,
359 repositoryName: string,
360 ) {
361 let repository = yield* ecr
362 .describeRepositories({ repositoryNames: [repositoryName] })
363 .pipe(
364 Effect.map((response) => response.repositories?.[0]),
365 Effect.catchTag("RepositoryNotFoundException", () =>
366 Effect.succeed(undefined),
367 ),
368 );
369
370 if (!repository?.repositoryArn || !repository.repositoryUri) {
371 const tags = yield* createInternalTags(id);
372 repository = yield* ecr
373 .createRepository({
374 repositoryName,
375 imageTagMutability: "IMMUTABLE",
376 imageScanningConfiguration: { scanOnPush: true },
377 tags: createTagsList(tags),
378 })
379 .pipe(
380 Effect.map((response) => response.repository),
381 Effect.catchTag("RepositoryAlreadyExistsException", () =>
382 ecr
383 .describeRepositories({ repositoryNames: [repositoryName] })
384 .pipe(Effect.map((response) => response.repositories?.[0])),
385 ),
386 );
387 }
388
389 if (!repository?.repositoryArn || !repository.repositoryUri) {
390 return yield* Effect.die(
391 new Error(
392 `Failed to create or read Lambda image repository '${repositoryName}'`,
393 ),
394 );
395 }
396
397 const tags = yield* ecr.listTagsForResource({
398 resourceArn: repository.repositoryArn,
399 });
400 if (!(yield* hasAlchemyTags(id, tags.tags))) {
401 return yield* Effect.die(
402 new Error(
403 `Lambda image repository '${repositoryName}' exists but is not owned by Function(${id})`,
404 ),
405 );
406 }
407
408 // Tags are content hashes, so changing what an existing tag references
409 // would violate the identity contract used by Lambda deployment state.
410 // Verify ownership before migrating repositories created by older versions.
411 if (repository.imageTagMutability !== "IMMUTABLE") {
412 yield* ecr.putImageTagMutability({
413 repositoryName,
414 imageTagMutability: "IMMUTABLE",
415 });
416 }
417
418 const desiredPolicy = yield* desiredRepositoryPolicy();
419 const observedPolicy = yield* ecr
420 .getRepositoryPolicy({ repositoryName })
421 .pipe(
422 Effect.map((response) => response.policyText),
423 Effect.catchTag("RepositoryPolicyNotFoundException", () =>
424 Effect.succeed(undefined),
425 ),
426 );
427 if (
428 observedPolicy === undefined ||
429 normalizePolicyDocument(observedPolicy) !==
430 normalizePolicyDocument(desiredPolicy)
431 ) {
432 yield* ecr.setRepositoryPolicy({
433 repositoryName,
434 policyText: desiredPolicy,
435 });
436 }
437
438 return {
439 repositoryName,
440 repositoryUri: repository.repositoryUri,
441 };
442 });
443
444 const describeImage = Effect.fn(function* (
445 repositoryName: string,
446 imageTag: string,
447 ) {
448 const response = yield* ecr
449 .describeImages({
450 repositoryName,
451 imageIds: [{ imageTag }],
452 })
453 .pipe(
454 Effect.catchTag(
455 ["ImageNotFoundException", "RepositoryNotFoundException"],
456 () => Effect.succeed(undefined),
457 ),
458 );
459 return response?.imageDetails?.[0];
460 });
461
462 const resolveExternalImage = Effect.fn("AWS.Lambda.resolveExternalImage")(
463 function* (
464 id: string,
465 source: FunctionEcrImageSource,
466 previousImage?: Omit<FunctionImageAttributes, "hash">,
467 ) {
468 const parsed = yield* parseFunctionImageUri(id, source.uri);
469 const { region } = yield* AWSEnvironment.current;
470 if (parsed.region !== region) {
471 return yield* Effect.fail(
472 new Error(
473 `Function(${id}): ECR image region '${parsed.region}' must match Lambda region '${region}'`,
474 ),
475 );
476 }
477
478 const detail = yield* ecr
479 .describeImages({
480 registryId: parsed.registryId,
481 repositoryName: parsed.repositoryName,
482 imageIds: [parsed.imageId],
483 })
484 .pipe(
485 Effect.catchTag(
486 ["ImageNotFoundException", "RepositoryNotFoundException"],
487 () =>
488 Effect.fail(
489 new Error(
490 `Function(${id}): ECR image '${source.uri}' does not exist or is not accessible`,
491 ),
492 ),
493 ),
494 Effect.map((response) => response.imageDetails?.[0]),
495 );
496 const digest = detail?.imageDigest;
497 if (digest === undefined) {
498 return yield* Effect.fail(
499 new Error(
500 `Function(${id}): ECR image '${source.uri}' did not resolve to a registry digest`,
501 ),
502 );
503 }
504
505 const hash = (yield* sha256Object({ uri: source.uri, digest })).slice(
506 0,
507 32,
508 );
509 return {
510 source: "uri",
511 hash,
512 imageUri: source.uri,
513 resolvedImageUri: `${parsed.repositoryUri}@${digest}`,
514 digest,
515 repositoryName: parsed.repositoryName,
516 repositoryUri: parsed.repositoryUri,
517 ownsRepository:
518 previousImage?.ownsRepository === true &&
519 previousImage.repositoryUri === parsed.repositoryUri,
520 } satisfies FunctionImageAttributes;
521 },
522 );
523
524 const identity = Effect.fn(function* (
525 id: string,
526 source: FunctionImageSource,
527 architecture: "x86_64" | "arm64",
528 ) {
529 const decoded = yield* decodeFunctionImageSource(id, source);
530 if (isFunctionEcrImageSource(decoded)) {
531 const external = yield* resolveExternalImage(id, decoded);
532 return {
533 source: external.source,
534 hash: external.hash,
535 imageUri: external.imageUri,
536 resolvedImageUri: external.resolvedImageUri,
537 } satisfies FunctionImageIdentity;
538 }
539 return {
540 source: "build",
541 hash: yield* hashDecodedFunctionImageBuild(decoded, architecture),
542 } satisfies FunctionImageIdentity;
543 });
544
545 const hash = Effect.fn(function* (
546 id: string,
547 source: FunctionImageSource,
548 architecture: "x86_64" | "arm64",
549 ) {
550 return (yield* identity(id, source, architecture)).hash;
551 });
552
553 const resolve = Effect.fn(function* (options: ResolveFunctionImageOptions) {
554 const source = yield* decodeFunctionImageSource(options.id, options.source);
555 if (isFunctionEcrImageSource(source)) {
556 const image = yield* resolveExternalImage(
557 options.id,
558 source,
559 options.previousImage,
560 );
561 yield* options.session.note(image.imageUri);
562 return image;
563 }
564 const imageTag = yield* hashDecodedFunctionImageBuild(
565 source,
566 options.architecture,
567 );
568 const repositoryName =
569 options.previousImage?.ownsRepository === true
570 ? options.previousImage.repositoryName
571 : yield* createRepositoryName(options.id);
572 // Re-ensure even with persisted metadata: the repository or its Lambda
573 // pull policy may have drifted out-of-band.
574 const ensured = yield* ensureRepository(options.id, repositoryName);
575 const imageUri = `${ensured.repositoryUri}:${imageTag}`;
576
577 let detail = yield* describeImage(repositoryName, imageTag);
578 if (!detail?.imageDigest) {
579 const build = yield* resolveDockerBuildPaths(source);
580 yield* options.session.note(`Building Lambda image ${imageUri}...`);
581 const pushed = yield* buildAndPushEcrImage(docker, {
582 imageUri,
583 context: build.context,
584 dockerfile: build.dockerfile,
585 platform: functionImagePlatform(options.architecture),
586 buildArgs: source.buildArgs,
587 args: ["--provenance=false"],
588 }).pipe(
589 Effect.as(true),
590 Effect.catch((buildError) =>
591 // Two reconcilers can both observe a missing tag and build it. With
592 // immutable tags, the second push loses the race. Accept that failure
593 // only when ECR proves the desired content-addressed tag now exists.
594 describeImage(repositoryName, imageTag).pipe(
595 Effect.flatMap((image) =>
596 image?.imageDigest !== undefined
597 ? Effect.succeed(false)
598 : Effect.fail(buildError),
599 ),
600 ),
601 ),
602 );
603 detail = yield* describeImage(repositoryName, imageTag).pipe(
604 Effect.filterOrFail(
605 (image) => image?.imageDigest !== undefined,
606 () => new Error(`Image ${imageUri} not found in ECR after push`),
607 ),
608 Effect.retry({
609 schedule: Schedule.spaced("1 second"),
610 times: 8,
611 }),
612 );
613 yield* options.session.note(
614 pushed
615 ? `Pushed ${imageUri}`
616 : `Reused concurrently pushed ${imageUri}`,
617 );
618 }
619
620 const digest = detail?.imageDigest;
621 if (digest === undefined) {
622 return yield* Effect.fail(
623 new Error(`Image ${imageUri} has no registry digest after push`),
624 );
625 }
626 return {
627 source: "build",
628 hash: imageTag,
629 imageUri,
630 resolvedImageUri: `${ensured.repositoryUri}@${digest}`,
631 digest,
632 repositoryName,
633 repositoryUri: ensured.repositoryUri,
634 ownsRepository: true,
635 } satisfies FunctionImageAttributes;
636 });
637
638 const deleteRepository = (repositoryName: string) =>
639 ecr
640 .deleteRepository({ repositoryName, force: true })
641 .pipe(Effect.catchTag("RepositoryNotFoundException", () => Effect.void));
642
643 const isReady = Effect.fn(function* (
644 id: string,
645 repositoryName: string,
646 imageTag: string,
647 ) {
648 const repository = yield* ecr
649 .describeRepositories({ repositoryNames: [repositoryName] })
650 .pipe(
651 Effect.map((response) => response.repositories?.[0]),
652 Effect.catchTag("RepositoryNotFoundException", () =>
653 Effect.succeed(undefined),
654 ),
655 );
656 if (
657 repository?.repositoryArn === undefined ||
658 repository.repositoryUri === undefined ||
659 repository.imageTagMutability !== "IMMUTABLE"
660 ) {
661 return false;
662 }
663
664 const [tags, observedPolicy, image] = yield* Effect.all(
665 [
666 ecr
667 .listTagsForResource({ resourceArn: repository.repositoryArn })
668 .pipe(
669 Effect.catchTag("RepositoryNotFoundException", () =>
670 Effect.succeed(undefined),
671 ),
672 ),
673 ecr.getRepositoryPolicy({ repositoryName }).pipe(
674 Effect.map((response) => response.policyText),
675 Effect.catchTag(
676 [
677 "RepositoryNotFoundException",
678 "RepositoryPolicyNotFoundException",
679 ],
680 () => Effect.succeed(undefined),
681 ),
682 ),
683 describeImage(repositoryName, imageTag),
684 ] as const,
685 { concurrency: 3 },
686 );
687 if (tags === undefined || !(yield* hasAlchemyTags(id, tags.tags))) {
688 return false;
689 }
690
691 const desiredPolicy = yield* desiredRepositoryPolicy();
692 return (
693 observedPolicy !== undefined &&
694 normalizePolicyDocument(observedPolicy) ===
695 normalizePolicyDocument(desiredPolicy) &&
696 image?.imageDigest !== undefined
697 );
698 });
699
700 return { hash, identity, resolve, isReady, deleteRepository };
701});
702
703export interface FunctionImage extends Effect.Success<
704 typeof makeFunctionImage
705> {}