1import * as ecr from "@distilled.cloud/aws/ecr";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { AccountID } from "../Environment.ts";
12import type { PolicyDocument } from "../IAM/Policy.ts";
13import {
14 normalizePolicyDocument,
15 stringifyPolicyDocument,
16} from "../IAM/Policy.ts";
17import type { RegionID } from "../Region.ts";
18
19export type RepositoryName = string;
20export type RepositoryArn =
21 `arn:aws:ecr:${RegionID}:${AccountID}:repository/${RepositoryName}`;
22export type RepositoryUri =
23 `${AccountID}.dkr.ecr.${RegionID}.amazonaws.com/${RepositoryName}`;
24
25export interface RepositoryProps {
26 /**
27 * Name of the repository. If omitted, a unique name is generated.
28 */
29 repositoryName?: string;
30 /**
31 * Image tag mutability setting.
32 * @default "MUTABLE"
33 */
34 imageTagMutability?: ecr.ImageTagMutability;
35 /**
36 * Whether enhanced image scanning should run on push.
37 */
38 scanOnPush?: boolean;
39 /**
40 * Optional lifecycle policy document JSON.
41 */
42 lifecyclePolicyText?: string;
43 /**
44 * Repository permission policy controlling access from other AWS
45 * principals — either a structured IAM {@link PolicyDocument} or a raw
46 * JSON string (escape hatch / adoption of an existing policy). Omitting
47 * the prop removes any repository policy.
48 */
49 policy?: PolicyDocument | string;
50 /**
51 * User-defined tags to apply to the repository.
52 */
53 tags?: Record<string, string>;
54}
55
56export interface Repository extends Resource<
57 "AWS.ECR.Repository",
58 RepositoryProps,
59 {
60 /** The name of the repository. */
61 repositoryName: RepositoryName;
62 /** The ARN of the repository. */
63 repositoryArn: RepositoryArn;
64 /** The URI used to push/pull images, e.g. `<account>.dkr.ecr.<region>.amazonaws.com/<name>`. */
65 repositoryUri: RepositoryUri;
66 /** The AWS account ID of the registry. */
67 registryId: string;
68 /** Whether image tags are `MUTABLE` or `IMMUTABLE`. */
69 imageTagMutability: ecr.ImageTagMutability;
70 /** Whether repository images are scanned when they are pushed. */
71 scanOnPush: boolean;
72 /** The JSON lifecycle policy applied to the repository, if any. */
73 lifecyclePolicyText?: string;
74 /** The JSON repository permissions policy, if any. */
75 policy?: string;
76 /** The tags attached to the repository. */
77 tags: Record<string, string>;
78 },
79 never,
80 Providers
81> {}
82
83/**
84 * An Amazon ECR repository for container images.
85 * ### Creating Repositories
86 * **Example:** Task Image Repository
87 * ```typescript
88 * const repo = yield* Repository("TaskRepository", {
89 * scanOnPush: true,
90 * });
91 * ```
92 *
93 * ### Repository Policies
94 * **Example:** Grant Lambda Pull Access
95 * ```typescript
96 * const repo = yield* Repository("LambdaImages", {
97 * policy: {
98 * Version: "2012-10-17",
99 * Statement: [
100 * {
101 * Sid: "LambdaECRImageRetrieval",
102 * Effect: "Allow",
103 * Principal: { Service: "lambda.amazonaws.com" },
104 * Action: ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
105 * },
106 * ],
107 * },
108 * });
109 * ```
110 *
111 * @resource
112 */
113export const Repository = Resource<Repository>("AWS.ECR.Repository");
114
115export const RepositoryProvider = () =>
116 Provider.effect(
117 Repository,
118 Effect.gen(function* () {
119 const toRepositoryName = (
120 id: string,
121 props: { repositoryName?: string } = {},
122 ) =>
123 props.repositoryName
124 ? Effect.succeed(props.repositoryName)
125 : createPhysicalName({
126 id,
127 maxLength: 256,
128 lowercase: true,
129 });
130
131 const toPolicyText = (policy: PolicyDocument | string | undefined) =>
132 policy === undefined
133 ? undefined
134 : typeof policy === "string"
135 ? policy
136 : stringifyPolicyDocument(policy);
137
138 const readPolicy = (repositoryName: string) =>
139 ecr.getRepositoryPolicy({ repositoryName }).pipe(
140 Effect.map((response) => response.policyText),
141 Effect.catchTag(
142 [
143 "RepositoryPolicyNotFoundException",
144 "RepositoryNotFoundException",
145 ],
146 () => Effect.succeed(undefined),
147 ),
148 );
149
150 // Sync the repository policy — compare the OBSERVED policy against the
151 // desired one via `normalizePolicyDocument` (key order / whitespace
152 // insensitive) so a re-deploy of an equivalent document is a no-op.
153 const syncPolicy = Effect.fn(function* (
154 repositoryName: string,
155 desired: string | undefined,
156 ) {
157 const observed = yield* readPolicy(repositoryName);
158 if (desired !== undefined) {
159 if (
160 observed === undefined ||
161 normalizePolicyDocument(observed) !==
162 normalizePolicyDocument(desired)
163 ) {
164 yield* ecr.setRepositoryPolicy({
165 repositoryName,
166 policyText: desired,
167 });
168 }
169 } else if (observed !== undefined) {
170 yield* ecr
171 .deleteRepositoryPolicy({ repositoryName })
172 .pipe(
173 Effect.catchTag(
174 "RepositoryPolicyNotFoundException",
175 () => Effect.void,
176 ),
177 );
178 }
179 });
180
181 return {
182 stables: [
183 "repositoryArn",
184 "repositoryName",
185 "repositoryUri",
186 "registryId",
187 ],
188 diff: Effect.fn(function* ({ id, olds, news }) {
189 if (!isResolved(news)) return;
190 if (
191 (yield* toRepositoryName(id, olds ?? {})) !==
192 (yield* toRepositoryName(id, news ?? {}))
193 ) {
194 return { action: "replace" } as const;
195 }
196 }),
197 read: Effect.fn(function* ({ id, olds, output }) {
198 const repositoryName =
199 output?.repositoryName ?? (yield* toRepositoryName(id, olds ?? {}));
200 const described = yield* ecr
201 .describeRepositories({
202 repositoryNames: [repositoryName],
203 })
204 .pipe(
205 Effect.catchTag("RepositoryNotFoundException", () =>
206 Effect.succeed(undefined),
207 ),
208 );
209 const repository = described?.repositories?.[0];
210 if (!repository?.repositoryArn || !repository.repositoryUri) {
211 return undefined;
212 }
213 const listedTags = yield* ecr.listTagsForResource({
214 resourceArn: repository.repositoryArn,
215 });
216 const attrs = {
217 repositoryName,
218 repositoryArn: repository.repositoryArn as RepositoryArn,
219 repositoryUri: repository.repositoryUri as RepositoryUri,
220 registryId: repository.registryId!,
221 imageTagMutability:
222 repository.imageTagMutability ??
223 output?.imageTagMutability ??
224 "MUTABLE",
225 scanOnPush:
226 repository.imageScanningConfiguration?.scanOnPush ??
227 output?.scanOnPush ??
228 false,
229 lifecyclePolicyText: output?.lifecyclePolicyText,
230 policy: yield* readPolicy(repositoryName),
231 tags: output?.tags ?? {},
232 };
233 return (yield* hasAlchemyTags(id, listedTags.tags ?? []))
234 ? attrs
235 : Unowned(attrs);
236 }),
237 reconcile: Effect.fn(function* ({ id, news, output, session }) {
238 // Prefer the deployed name: regenerating would target a different
239 // repository if the generator's output for this id ever drifts.
240 const repositoryName =
241 output?.repositoryName ?? (yield* toRepositoryName(id, news));
242 const internalTags = yield* createInternalTags(id);
243 const desiredTags = { ...internalTags, ...news.tags };
244
245 // Observe — fetch live cloud state. We never trust prior `output`
246 // blindly: the repository may have been deleted out-of-band.
247 let described = yield* ecr
248 .describeRepositories({
249 repositoryNames: [repositoryName],
250 })
251 .pipe(
252 Effect.catchTag("RepositoryNotFoundException", () =>
253 Effect.succeed(undefined),
254 ),
255 );
256 let repository = described?.repositories?.[0];
257
258 // Ensure — create the repository if missing. Tolerate
259 // `RepositoryAlreadyExistsException` as a race with a peer
260 // reconciler: re-describe and continue with the sync path.
261 if (!repository?.repositoryArn || !repository.repositoryUri) {
262 const created = yield* ecr
263 .createRepository({
264 repositoryName,
265 imageTagMutability: news.imageTagMutability,
266 imageScanningConfiguration: news.scanOnPush
267 ? { scanOnPush: true }
268 : undefined,
269 tags: Object.entries(desiredTags).map(([Key, Value]) => ({
270 Key,
271 Value,
272 })),
273 })
274 .pipe(
275 Effect.catchTag("RepositoryAlreadyExistsException", () =>
276 ecr
277 .describeRepositories({
278 repositoryNames: [repositoryName],
279 })
280 .pipe(
281 Effect.map((res) => ({
282 repository: res.repositories?.[0],
283 })),
284 ),
285 ),
286 );
287 repository = created.repository;
288 if (!repository?.repositoryArn || !repository.repositoryUri) {
289 return yield* Effect.fail(
290 new Error(
291 `Failed to create or read repository ${repositoryName}`,
292 ),
293 );
294 }
295 }
296
297 const repositoryArn = repository.repositoryArn as RepositoryArn;
298
299 // Sync mutable repository settings against OBSERVED cloud state.
300 // These must converge for adopted repositories and out-of-band
301 // drift, not only when createRepository happens to run.
302 const desiredImageTagMutability =
303 news.imageTagMutability ?? "MUTABLE";
304 if (
305 (repository.imageTagMutability ?? "MUTABLE") !==
306 desiredImageTagMutability
307 ) {
308 yield* ecr.putImageTagMutability({
309 repositoryName,
310 imageTagMutability: desiredImageTagMutability,
311 });
312 }
313
314 const desiredScanOnPush = news.scanOnPush ?? false;
315 if (
316 (repository.imageScanningConfiguration?.scanOnPush ?? false) !==
317 desiredScanOnPush
318 ) {
319 yield* ecr.putImageScanningConfiguration({
320 repositoryName,
321 imageScanningConfiguration: {
322 scanOnPush: desiredScanOnPush,
323 },
324 });
325 }
326
327 // Sync lifecycle policy — observed ↔ desired.
328 if (news.lifecyclePolicyText) {
329 yield* ecr.putLifecyclePolicy({
330 repositoryName,
331 lifecyclePolicyText: news.lifecyclePolicyText,
332 });
333 }
334
335 // Sync repository policy — normalized observed ↔ desired.
336 const desiredPolicy = toPolicyText(news.policy);
337 yield* syncPolicy(repositoryName, desiredPolicy);
338
339 // Sync tags — diff observed cloud tags against desired.
340 const listedTags = yield* ecr.listTagsForResource({
341 resourceArn: repositoryArn,
342 });
343 const observedTags = Object.fromEntries(
344 (listedTags.tags ?? [])
345 .filter(
346 (t): t is { Key: string; Value: string } =>
347 typeof t.Key === "string" && typeof t.Value === "string",
348 )
349 .map((t) => [t.Key, t.Value]),
350 );
351 const { removed, upsert } = diffTags(observedTags, desiredTags);
352 if (upsert.length > 0) {
353 yield* ecr.tagResource({
354 resourceArn: repositoryArn,
355 tags: upsert,
356 });
357 }
358 if (removed.length > 0) {
359 yield* ecr.untagResource({
360 resourceArn: repositoryArn,
361 tagKeys: removed,
362 });
363 }
364
365 yield* session.note(repositoryArn);
366 return {
367 repositoryName,
368 repositoryArn,
369 repositoryUri: repository.repositoryUri as RepositoryUri,
370 registryId: repository.registryId!,
371 imageTagMutability: desiredImageTagMutability,
372 scanOnPush: desiredScanOnPush,
373 lifecyclePolicyText: news.lifecyclePolicyText,
374 policy: desiredPolicy,
375 tags: desiredTags,
376 };
377 }),
378 // Enumerate every repository in the account/region. `describeRepositories`
379 // is paginated (items under `repositories`); for each repo we fetch the
380 // tags and lifecycle policy so each element matches the full Attributes
381 // shape `read` produces and is directly usable by `delete`.
382 list: () =>
383 Effect.gen(function* () {
384 const repositories = yield* ecr.describeRepositories.pages({}).pipe(
385 Stream.runCollect,
386 Effect.map((chunk) =>
387 Array.from(chunk).flatMap((page) => page.repositories ?? []),
388 ),
389 );
390 return yield* Effect.forEach(
391 repositories.filter(
392 (
393 r,
394 ): r is ecr.Repository & {
395 repositoryName: string;
396 repositoryArn: string;
397 repositoryUri: string;
398 } =>
399 r.repositoryName != null &&
400 r.repositoryArn != null &&
401 r.repositoryUri != null,
402 ),
403 (repository) =>
404 Effect.gen(function* () {
405 const listedTags = yield* ecr.listTagsForResource({
406 resourceArn: repository.repositoryArn,
407 });
408 const tags = Object.fromEntries(
409 (listedTags.tags ?? [])
410 .filter(
411 (t): t is { Key: string; Value: string } =>
412 typeof t.Key === "string" &&
413 typeof t.Value === "string",
414 )
415 .map((t) => [t.Key, t.Value]),
416 );
417 const lifecyclePolicyText = yield* ecr
418 .getLifecyclePolicy({
419 repositoryName: repository.repositoryName,
420 })
421 .pipe(
422 Effect.map((res) => res.lifecyclePolicyText),
423 Effect.catchTag("LifecyclePolicyNotFoundException", () =>
424 Effect.succeed(undefined),
425 ),
426 );
427 return {
428 repositoryName: repository.repositoryName,
429 repositoryArn: repository.repositoryArn as RepositoryArn,
430 repositoryUri: repository.repositoryUri as RepositoryUri,
431 registryId: repository.registryId!,
432 imageTagMutability:
433 repository.imageTagMutability ?? "MUTABLE",
434 scanOnPush:
435 repository.imageScanningConfiguration?.scanOnPush ??
436 false,
437 lifecyclePolicyText,
438 policy: yield* readPolicy(repository.repositoryName),
439 tags,
440 };
441 }),
442 { concurrency: 10 },
443 );
444 }),
445 delete: Effect.fn(function* ({ output }) {
446 yield* ecr
447 .deleteRepository({
448 repositoryName: output.repositoryName,
449 force: true,
450 })
451 .pipe(
452 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
453 );
454 }),
455 };
456 }),
457 );