1import * as ecr from "@distilled.cloud/aws/ecr";
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 { isResolved } from "../../Diff.ts";
7import { Docker } from "../../Docker/Docker.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 hashDockerBuildInputs,
15 resolveDockerBuildPaths,
16} from "../../Docker/BuildHash.ts";
17
18/**
19 * Docker login credentials for the account's private ECR registry, in the
20 * shape `Docker.image.push` expects. The password is the decoded ECR bearer
21 * token — a credential — so it is carried as `Redacted` and only unwrapped
22 * inside `docker push`'s ephemeral auth config.
23 */
24export interface EcrRegistryCredentials {
25 username: string;
26 password: Redacted.Redacted<string>;
27 server: string;
28}
29
30/**
31 * Resolves Docker login credentials for the account's private ECR registry
32 * by decoding `ecr.getAuthorizationToken`.
33 */
34export const getEcrRegistryCredentials = Effect.gen(function* () {
35 const auth = yield* ecr.getAuthorizationToken({});
36 const authData = auth.authorizationData?.[0];
37 const token = authData?.authorizationToken;
38 const proxyEndpoint = authData?.proxyEndpoint;
39 if (!token || !proxyEndpoint) {
40 return yield* Effect.die(
41 new Error("Failed to get ECR authorization token"),
42 );
43 }
44 const password = yield* Effect.sync(() => {
45 const raw = Redacted.isRedacted(token) ? Redacted.value(token) : token;
46 const decoded = Buffer.from(raw, "base64").toString("utf8");
47 const [, secret] = decoded.split(":", 2);
48 return Redacted.make(secret);
49 });
50 return {
51 username: "AWS",
52 password,
53 server: proxyEndpoint.replace(/^https?:\/\//, ""),
54 } satisfies EcrRegistryCredentials;
55});
56
57/**
58 * Builds a Docker image from a local context and pushes it to the account's
59 * private ECR registry. Shared by the `ECR.Image` provider and the
60 * `ECS.Task` runtime resource (which materializes a generated Dockerfile
61 * into its bundle context before delegating here).
62 */
63export const buildAndPushEcrImage = Effect.fn(function* (
64 docker: Docker["Service"],
65 options: {
66 /** Full image reference to build and push, e.g. `<repositoryUri>:<tag>`. */
67 imageUri: string;
68 /** Docker build context directory. */
69 context: string;
70 /** Absolute path to the Dockerfile. Defaults to `Dockerfile` in `context`. */
71 dockerfile?: string;
72 /** Target platform, e.g. `"linux/amd64"`. */
73 platform?: string;
74 /** Docker build arguments. */
75 buildArgs?: Record<string, string>;
76 /** Additional arguments appended to `docker image build`. */
77 args?: string[];
78 },
79) {
80 const credentials = yield* getEcrRegistryCredentials;
81 yield* docker.image.build({
82 tag: options.imageUri,
83 context: options.context,
84 file: options.dockerfile,
85 platform: options.platform,
86 "build-arg": options.buildArgs,
87 args: options.args,
88 });
89 // Pushes are idempotent; transient registry-transport failures (Docker
90 // Desktop's embedded proxy timing out under load, ECR 503s, credential
91 // helper contention) resolve on a bounded re-attempt.
92 yield* docker.image.push(options.imageUri, credentials).pipe(
93 Effect.retry({
94 while: (): boolean => true,
95 schedule: Schedule.exponential("2 seconds"),
96 times: 3,
97 }),
98 );
99 return options.imageUri;
100});
101
102export interface ImageProps {
103 /**
104 * URI of the target ECR repository, e.g. `repository.repositoryUri` from an
105 * `ECR.Repository`. When omitted, the Image auto-creates (and owns) a
106 * repository whose name is derived from the logical id; the owned
107 * repository is force-deleted when the Image is destroyed.
108 */
109 repositoryUri?: string;
110 /**
111 * Docker build context directory. Every file under the context (plus the
112 * Dockerfile, platform, and build args) participates in the content hash
113 * that identifies the image. An existing image with the same hash is reused;
114 * missing images are rebuilt and pushed to the desired repository.
115 */
116 context: string;
117 /**
118 * Path to the Dockerfile, relative to `context` unless absolute.
119 * @default "Dockerfile"
120 */
121 dockerfile?: string;
122 /**
123 * Target platform for the image build. Fargate defaults to X86_64, so the
124 * default pins `linux/amd64` — without it an image built on an ARM64 host
125 * (e.g. Apple Silicon) is rejected at task start.
126 * @default "linux/amd64"
127 */
128 platform?: string;
129 /**
130 * Docker build arguments (`--build-arg`).
131 */
132 buildArgs?: Record<string, string>;
133}
134
135export interface Image extends Resource<
136 "AWS.ECR.Image",
137 ImageProps,
138 {
139 /** Full image reference, `<repositoryUri>:<imageTag>`. */
140 imageUri: string;
141 /** Registry manifest digest, e.g. `sha256:...`. */
142 digest: string;
143 /** URI of the repository the image was pushed to. */
144 repositoryUri: string;
145 /** Name of the repository the image was pushed to. */
146 repositoryName: string;
147 /** Content-hash tag the image was pushed under. */
148 imageTag: string;
149 /** Whether this Image auto-created (and owns) the backing repository. */
150 ownsRepository: boolean;
151 },
152 never,
153 Providers
154> {}
155
156/**
157 * A Docker image built from a local context and pushed to a private Amazon
158 * ECR repository.
159 *
160 * The image is identified by a content hash over the build context,
161 * Dockerfile, platform, and build args. An existing image with that hash is
162 * reused; a missing image is rebuilt and pushed. A content change produces a
163 * new tag on the same resource, so replacement is never needed.
164 *
165 * ### Building Images
166 * **Example:** Push to an ECR Repository
167 * ```typescript
168 * const repository = yield* AWS.ECR.Repository("AppRepository", {});
169 * const image = yield* AWS.ECR.Image("AppImage", {
170 * repositoryUri: repository.repositoryUri,
171 * context: "./app",
172 * });
173 * ```
174 *
175 * **Example:** Auto-created Repository
176 * ```typescript
177 * const image = yield* AWS.ECR.Image("AppImage", {
178 * context: "./app",
179 * });
180 * ```
181 *
182 * ### Moving an Unchanged Build Context
183 * **Example:** Relocate identical build inputs without rebuilding
184 * ```diff
185 * const image = yield* AWS.ECR.Image("AppImage", {
186 * repositoryUri: repository.repositoryUri,
187 * - context: "./app",
188 * + context: "./relocated-app",
189 * });
190 * ```
191 *
192 * Absolute paths are not part of the image's identity. With identical build
193 * inputs and the same repository setting, this plans a no-op. Content,
194 * Dockerfile, platform, build-argument, and repository changes still update
195 * the image. A deleted image or auto-created repository is recreated on deploy.
196 *
197 * ### Build Configuration
198 * **Example:** Custom Dockerfile, Platform, and Build Args
199 * ```typescript
200 * const image = yield* AWS.ECR.Image("WorkerImage", {
201 * repositoryUri: repository.repositoryUri,
202 * context: "./worker",
203 * dockerfile: "Dockerfile.worker",
204 * platform: "linux/arm64",
205 * buildArgs: { NODE_ENV: "production" },
206 * });
207 * ```
208 *
209 * ### Consuming the Image
210 * **Example:** Reference from a Task Definition
211 * ```typescript
212 * const task = yield* AWS.ECS.Task("ApiTask", {
213 * main: import.meta.url,
214 * sidecars: [{ name: "proxy", image: image.imageUri, essential: false }],
215 * });
216 * ```
217 *
218 * @resource
219 */
220export const Image = Resource<Image>("AWS.ECR.Image");
221
222/** Derives the repository name from a repository URI (`<registry>/<name>`). */
223const repositoryNameFromUri = (repositoryUri: string) =>
224 repositoryUri.split("/").slice(1).join("/");
225
226export const ImageProvider = () =>
227 Provider.effect(
228 Image,
229 Effect.gen(function* () {
230 const docker = yield* Docker;
231
232 const toOwnedRepositoryName = (id: string) =>
233 createPhysicalName({
234 id,
235 maxLength: 256,
236 lowercase: true,
237 });
238
239 // Content hash over everything that affects the built image: the
240 // Dockerfile, every file in the build context (relative path + bytes),
241 // the target platform, and the build args. Absolute paths deliberately
242 // do NOT participate, so relocating an identical context (e.g. a temp
243 // dir) produces the same tag.
244 const hashBuildInputs = (props: ImageProps) =>
245 hashDockerBuildInputs(
246 {
247 context: props.context,
248 dockerfile: props.dockerfile ?? "Dockerfile",
249 platform: props.platform ?? "linux/amd64",
250 buildArgs: props.buildArgs,
251 },
252 "all",
253 );
254
255 // Observe the pushed image in ECR. Missing repository or tag → undefined.
256 const describeImage = Effect.fn(function* (
257 repositoryName: string,
258 imageTag: string,
259 ) {
260 const described = yield* ecr
261 .describeImages({
262 repositoryName,
263 imageIds: [{ imageTag }],
264 })
265 .pipe(
266 Effect.catchTag(
267 ["ImageNotFoundException", "RepositoryNotFoundException"],
268 () => Effect.succeed(undefined),
269 ),
270 );
271 return described?.imageDetails?.[0];
272 });
273
274 // Ensure the auto-created repository exists. Idempotent: tolerates
275 // `RepositoryAlreadyExistsException` as a race / re-run and re-describes.
276 const ensureOwnedRepository = Effect.fn(function* (
277 id: string,
278 repositoryName: string,
279 ) {
280 const internalTags = yield* createInternalTags(id);
281 const created = yield* ecr
282 .createRepository({
283 repositoryName,
284 imageTagMutability: "MUTABLE",
285 tags: Object.entries(internalTags).map(([Key, Value]) => ({
286 Key,
287 Value,
288 })),
289 })
290 .pipe(
291 Effect.catchTag("RepositoryAlreadyExistsException", () =>
292 ecr
293 .describeRepositories({ repositoryNames: [repositoryName] })
294 .pipe(
295 Effect.map((res) => ({ repository: res.repositories?.[0] })),
296 ),
297 ),
298 );
299 const repositoryUri = created.repository?.repositoryUri;
300 if (!repositoryUri) {
301 return yield* Effect.fail(
302 new Error(
303 `Failed to create or read ECR repository '${repositoryName}'`,
304 ),
305 );
306 }
307 return repositoryUri;
308 });
309
310 return {
311 read: Effect.fn(function* ({ output }) {
312 // The content hash cannot be recovered without running a build, so
313 // a lost-state read has nothing to look up.
314 if (!output) return undefined;
315 const detail = yield* describeImage(
316 output.repositoryName,
317 output.imageTag,
318 );
319 if (!detail?.imageDigest) return undefined;
320 return { ...output, digest: detail.imageDigest };
321 }),
322 // Content changes don't change Props (the context path stays the
323 // same while the files under it change), so surface them here: hash
324 // the build inputs and request an update when the hash drifts from
325 // the pushed tag. Replacement is never needed — a new hash is just a
326 // new tag + digest on the same resource.
327 diff: Effect.fn(function* ({ news, output, olds }) {
328 if (!isResolved(news) || !output) return undefined;
329 const hash = yield* hashBuildInputs(news);
330 if (
331 hash !== output.imageTag ||
332 olds.repositoryUri !== news.repositoryUri
333 ) {
334 return { action: "update" } as const;
335 }
336 const image = yield* describeImage(output.repositoryName, hash);
337 if (!image?.imageDigest) return { action: "update" } as const;
338 // Build paths may move without changing an existing image.
339 return { action: "noop" } as const;
340 }),
341 reconcile: Effect.fn(function* ({ id, news, output, session }) {
342 // Resolve the target repository: user-supplied URI, or an
343 // auto-created repository owned by this Image.
344 const { repositoryName, repositoryUri, ownsRepository } =
345 yield* Effect.gen(function* () {
346 if (news.repositoryUri) {
347 return {
348 repositoryName: repositoryNameFromUri(news.repositoryUri),
349 repositoryUri: news.repositoryUri,
350 ownsRepository: false,
351 };
352 }
353 const repositoryName = output?.ownsRepository
354 ? output.repositoryName
355 : yield* toOwnedRepositoryName(id);
356 const repositoryUri = yield* ensureOwnedRepository(
357 id,
358 repositoryName,
359 );
360 return { repositoryName, repositoryUri, ownsRepository: true };
361 });
362
363 const { context, dockerfile } = yield* resolveDockerBuildPaths({
364 context: news.context,
365 dockerfile: news.dockerfile ?? "Dockerfile",
366 });
367 const imageTag = yield* hashBuildInputs(news);
368 const imageUri = `${repositoryUri}:${imageTag}`;
369
370 // Observe — if this exact content hash is already pushed, there is
371 // nothing to build (crash-safe: a re-run after a mid-reconcile
372 // failure converges without a rebuild).
373 const observed = yield* describeImage(repositoryName, imageTag);
374 if (observed?.imageDigest) {
375 yield* session.note(imageUri);
376 return {
377 imageUri,
378 digest: observed.imageDigest,
379 repositoryUri,
380 repositoryName,
381 imageTag,
382 ownsRepository,
383 };
384 }
385
386 yield* session.note(`building ${imageUri}`);
387 yield* buildAndPushEcrImage(docker, {
388 imageUri,
389 context,
390 dockerfile,
391 platform: news.platform ?? "linux/amd64",
392 buildArgs: news.buildArgs,
393 // Engines using the containerd image store attach provenance
394 // attestations by default, turning the pushed tag into an OCI
395 // image index — which Lambda (a valid consumer of these images)
396 // rejects. Disable: single-manifest pushes work everywhere.
397 args: ["--provenance=false"],
398 });
399
400 const pushed = yield* describeImage(repositoryName, imageTag);
401 if (!pushed?.imageDigest) {
402 return yield* Effect.fail(
403 new Error(`Image ${imageUri} not found in ECR after push`),
404 );
405 }
406 yield* session.note(imageUri);
407 return {
408 imageUri,
409 digest: pushed.imageDigest,
410 repositoryUri,
411 repositoryName,
412 imageTag,
413 ownsRepository,
414 };
415 }),
416 // Enumerate every tagged image across every repository in the
417 // account/region. Repository ownership is recorded per-instance in
418 // state and cannot be recovered from enumeration, so listed items are
419 // conservatively marked `ownsRepository: false` — orphan deletion
420 // removes the tag but leaves the repository (ECR.Repository's own
421 // list()/delete() covers repositories).
422 list: () =>
423 Effect.gen(function* () {
424 const repositories = yield* ecr.describeRepositories.pages({}).pipe(
425 Stream.runCollect,
426 Effect.map((chunk) =>
427 Array.from(chunk).flatMap((page) => page.repositories ?? []),
428 ),
429 );
430 const nested = yield* Effect.forEach(
431 repositories.filter(
432 (
433 r,
434 ): r is ecr.Repository & {
435 repositoryName: string;
436 repositoryUri: string;
437 } => r.repositoryName != null && r.repositoryUri != null,
438 ),
439 (repository) =>
440 ecr.describeImages
441 .pages({ repositoryName: repository.repositoryName })
442 .pipe(
443 Stream.runCollect,
444 Effect.map((chunk) =>
445 Array.from(chunk).flatMap(
446 (page) => page.imageDetails ?? [],
447 ),
448 ),
449 Effect.map((details) =>
450 details.flatMap((detail) =>
451 detail.imageDigest === undefined
452 ? []
453 : (detail.imageTags ?? []).map((imageTag) => ({
454 imageUri: `${repository.repositoryUri}:${imageTag}`,
455 digest: detail.imageDigest!,
456 repositoryUri: repository.repositoryUri,
457 repositoryName: repository.repositoryName,
458 imageTag,
459 ownsRepository: false,
460 })),
461 ),
462 ),
463 // Repository deleted between the list and the describe.
464 Effect.catchTag("RepositoryNotFoundException", () =>
465 Effect.succeed([]),
466 ),
467 ),
468 { concurrency: 10 },
469 );
470 return nested.flat();
471 }),
472 delete: Effect.fn(function* ({ output }) {
473 if (output.ownsRepository) {
474 yield* ecr
475 .deleteRepository({
476 repositoryName: output.repositoryName,
477 force: true,
478 })
479 .pipe(
480 Effect.catchTag(
481 "RepositoryNotFoundException",
482 () => Effect.void,
483 ),
484 );
485 return;
486 }
487 // Foreign repository: delete only our tag. `batchDeleteImage`
488 // reports a missing image via its `failures` array (not an error),
489 // so this is naturally idempotent.
490 yield* ecr
491 .batchDeleteImage({
492 repositoryName: output.repositoryName,
493 imageIds: [{ imageTag: output.imageTag }],
494 })
495 .pipe(
496 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
497 );
498 }),
499 };
500 }),
501 );