1import * as ecrpublic from "@distilled.cloud/aws/ecr-public";
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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { AccountID } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { Region } from "../Region.ts";
13
14// Amazon ECR Public is a single global registry hosted only in us-east-1. Every
15// control-plane call must target that region regardless of the ambient stack
16// region, so we pin it on every distilled operation.
17const US_EAST_1 = "us-east-1";
18const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
19 effect.pipe(Effect.provideService(Region, Effect.succeed(US_EAST_1)));
20
21export type RepositoryName = string;
22export type RepositoryArn =
23 `arn:aws:ecr-public::${AccountID}:repository/${RepositoryName}`;
24
25/**
26 * Catalog display metadata shown on the public.ecr.aws gallery page for a
27 * repository. All fields are optional and free-form.
28 */
29export interface PublicRepositoryCatalogData {
30 /** Short description of the contents of the repository. */
31 description?: string;
32 /** CPU architectures the images support (e.g. `["x86-64", "ARM 64"]`). */
33 architectures?: string[];
34 /** Operating systems the images support (e.g. `["Linux"]`). */
35 operatingSystems?: string[];
36 /** Detailed "about" markdown shown on the gallery page. */
37 aboutText?: string;
38 /** Usage instructions markdown shown on the gallery page. */
39 usageText?: string;
40}
41
42export interface PublicRepositoryProps {
43 /**
44 * Name of the public repository. If omitted, a unique name is generated.
45 * Must be lowercase.
46 */
47 repositoryName?: string;
48 /**
49 * Catalog display metadata for the public gallery page.
50 */
51 catalogData?: PublicRepositoryCatalogData;
52 /**
53 * Repository permission policy JSON granting push/pull access to other
54 * principals. Omit for a private-push, public-pull repository.
55 */
56 policyText?: string;
57 /**
58 * User-defined tags to apply to the repository.
59 */
60 tags?: Record<string, string>;
61}
62
63export interface PublicRepository extends Resource<
64 "AWS.ECRPublic.Repository",
65 PublicRepositoryProps,
66 {
67 /** The name of the public repository. */
68 repositoryName: RepositoryName;
69 /** The ARN of the public repository. */
70 repositoryArn: RepositoryArn;
71 /** The public pull URI, e.g. `public.ecr.aws/<alias>/<name>`. */
72 repositoryUri: string;
73 /** The AWS account ID of the registry. */
74 registryId: string;
75 /** The JSON repository permissions policy, if any. */
76 policyText?: string;
77 /** The tags attached to the repository. */
78 tags: Record<string, string>;
79 },
80 never,
81 Providers
82> {}
83
84/**
85 * An Amazon ECR Public repository on the public.ecr.aws registry. Images pushed
86 * here are pullable by anyone. ECR Public is a global service hosted only in
87 * `us-east-1`; this resource pins every control-plane call there regardless of
88 * the stack region.
89 *
90 * ### Creating Public Repositories
91 * **Example:** Basic Public Repository
92 * ```typescript
93 * const repo = yield* PublicRepository("MyPublicRepo", {});
94 * ```
95 *
96 * **Example:** With Catalog Metadata
97 * ```typescript
98 * const repo = yield* PublicRepository("MyPublicRepo", {
99 * catalogData: {
100 * description: "My awesome container image",
101 * architectures: ["x86-64", "ARM 64"],
102 * operatingSystems: ["Linux"],
103 * aboutText: "# About\nThis image does X.",
104 * usageText: "docker pull public.ecr.aws/...",
105 * },
106 * });
107 * ```
108 *
109 * ### Access Policies
110 * **Example:** Grant Cross-Account Push
111 * ```typescript
112 * const repo = yield* PublicRepository("MyPublicRepo", {
113 * policyText: JSON.stringify({
114 * Version: "2012-10-17",
115 * Statement: [
116 * {
117 * Sid: "AllowPush",
118 * Effect: "Allow",
119 * Principal: { AWS: "arn:aws:iam::123456789012:root" },
120 * Action: ["ecr-public:BatchCheckLayerAvailability", "ecr-public:PutImage"],
121 * },
122 * ],
123 * }),
124 * });
125 * ```
126 *
127 * @resource
128 */
129export const PublicRepository = Resource<PublicRepository>(
130 "AWS.ECRPublic.Repository",
131);
132
133const toCatalogInput = (
134 catalogData: PublicRepositoryCatalogData | undefined,
135): ecrpublic.RepositoryCatalogDataInput | undefined =>
136 catalogData
137 ? {
138 description: catalogData.description,
139 architectures: catalogData.architectures,
140 operatingSystems: catalogData.operatingSystems,
141 aboutText: catalogData.aboutText,
142 usageText: catalogData.usageText,
143 }
144 : undefined;
145
146const toTagRecord = (
147 tags: ecrpublic.Tag[] | undefined,
148): Record<string, string> =>
149 Object.fromEntries(
150 (tags ?? [])
151 .filter(
152 (t): t is { Key: string; Value: string } =>
153 typeof t.Key === "string" && typeof t.Value === "string",
154 )
155 .map((t) => [t.Key, t.Value]),
156 );
157
158export const PublicRepositoryProvider = () =>
159 Provider.effect(
160 PublicRepository,
161 Effect.gen(function* () {
162 const toRepositoryName = (
163 id: string,
164 props: { repositoryName?: string } = {},
165 ) =>
166 props.repositoryName
167 ? Effect.succeed(props.repositoryName)
168 : createPhysicalName({ id, maxLength: 205, lowercase: true });
169
170 const observe = (repositoryName: string) =>
171 pin(
172 ecrpublic.describeRepositories({
173 repositoryNames: [repositoryName],
174 }),
175 ).pipe(
176 Effect.map((r) => r.repositories?.[0]),
177 Effect.catchTag("RepositoryNotFoundException", () =>
178 Effect.succeed(undefined),
179 ),
180 );
181
182 const readPolicy = (repositoryName: string) =>
183 pin(ecrpublic.getRepositoryPolicy({ repositoryName })).pipe(
184 Effect.map((r) => r.policyText),
185 Effect.catchTag("RepositoryPolicyNotFoundException", () =>
186 Effect.succeed(undefined),
187 ),
188 );
189
190 const syncPolicy = Effect.fn(function* (
191 repositoryName: string,
192 desired: string | undefined,
193 ) {
194 const observed = yield* readPolicy(repositoryName);
195 if (desired) {
196 if (observed !== desired) {
197 yield* pin(
198 ecrpublic.setRepositoryPolicy({
199 repositoryName,
200 policyText: desired,
201 }),
202 );
203 }
204 } else if (observed !== undefined) {
205 yield* pin(ecrpublic.deleteRepositoryPolicy({ repositoryName })).pipe(
206 Effect.catchTag(
207 "RepositoryPolicyNotFoundException",
208 () => Effect.void,
209 ),
210 );
211 }
212 });
213
214 const syncTags = Effect.fn(function* (
215 repositoryArn: string,
216 desiredTags: Record<string, string>,
217 ) {
218 const listed = yield* pin(
219 ecrpublic.listTagsForResource({ resourceArn: repositoryArn }),
220 );
221 const observedTags = toTagRecord(listed.tags);
222 const { removed, upsert } = diffTags(observedTags, desiredTags);
223 if (upsert.length > 0) {
224 yield* pin(
225 ecrpublic.tagResource({ resourceArn: repositoryArn, tags: upsert }),
226 );
227 }
228 if (removed.length > 0) {
229 yield* pin(
230 ecrpublic.untagResource({
231 resourceArn: repositoryArn,
232 tagKeys: removed,
233 }),
234 );
235 }
236 });
237
238 return {
239 stables: [
240 "repositoryArn",
241 "repositoryName",
242 "repositoryUri",
243 "registryId",
244 ],
245 diff: Effect.fn(function* ({ id, olds, news }) {
246 if (!isResolved(news)) return;
247 if (
248 (yield* toRepositoryName(id, olds ?? {})) !==
249 (yield* toRepositoryName(id, news ?? {}))
250 ) {
251 return { action: "replace" } as const;
252 }
253 }),
254 read: Effect.fn(function* ({ id, olds, output }) {
255 const repositoryName =
256 output?.repositoryName ?? (yield* toRepositoryName(id, olds ?? {}));
257 const repository = yield* observe(repositoryName);
258 if (!repository?.repositoryArn || !repository.repositoryUri) {
259 return undefined;
260 }
261 const listed = yield* pin(
262 ecrpublic.listTagsForResource({
263 resourceArn: repository.repositoryArn,
264 }),
265 );
266 const attrs = {
267 repositoryName,
268 repositoryArn: repository.repositoryArn as RepositoryArn,
269 repositoryUri: repository.repositoryUri,
270 registryId: repository.registryId!,
271 policyText: yield* readPolicy(repositoryName),
272 tags: toTagRecord(listed.tags),
273 };
274 return (yield* hasAlchemyTags(id, toTagRecord(listed.tags)))
275 ? attrs
276 : Unowned(attrs);
277 }),
278 reconcile: Effect.fn(function* ({ id, news, session }) {
279 const repositoryName = yield* toRepositoryName(id, news);
280 const internalTags = yield* createInternalTags(id);
281 const desiredTags = { ...internalTags, ...news.tags };
282
283 // Observe — cloud state is authoritative.
284 let repository = yield* observe(repositoryName);
285
286 // Ensure — create if missing. Tolerate the AlreadyExists race.
287 if (!repository?.repositoryArn || !repository.repositoryUri) {
288 const created = yield* pin(
289 ecrpublic.createRepository({
290 repositoryName,
291 catalogData: toCatalogInput(news.catalogData),
292 tags: Object.entries(desiredTags).map(([Key, Value]) => ({
293 Key,
294 Value,
295 })),
296 }),
297 ).pipe(
298 Effect.map((r) => r.repository),
299 Effect.catchTag("RepositoryAlreadyExistsException", () =>
300 observe(repositoryName),
301 ),
302 );
303 repository = created;
304 if (!repository?.repositoryArn || !repository.repositoryUri) {
305 return yield* Effect.fail(
306 new Error(
307 `Failed to create or read public repository ${repositoryName}`,
308 ),
309 );
310 }
311 } else if (news.catalogData) {
312 // Sync catalog data on updates (create already set it).
313 yield* pin(
314 ecrpublic.putRepositoryCatalogData({
315 repositoryName,
316 catalogData: toCatalogInput(news.catalogData)!,
317 }),
318 );
319 }
320
321 const repositoryArn = repository.repositoryArn as RepositoryArn;
322
323 yield* syncPolicy(repositoryName, news.policyText);
324 yield* syncTags(repositoryArn, desiredTags);
325
326 yield* session.note(repositoryArn);
327 return {
328 repositoryName,
329 repositoryArn,
330 repositoryUri: repository.repositoryUri,
331 registryId: repository.registryId!,
332 policyText: news.policyText,
333 tags: desiredTags,
334 };
335 }),
336 // Enumerate every public repository in the account. `describeRepositories`
337 // is paginated (items under `repositories`).
338 list: () =>
339 Effect.gen(function* () {
340 const repositories = yield* pin(
341 ecrpublic.describeRepositories.pages({}).pipe(
342 Stream.runCollect,
343 Effect.map((chunk) =>
344 Array.from(chunk).flatMap((page) => page.repositories ?? []),
345 ),
346 ),
347 );
348 return yield* Effect.forEach(
349 repositories.filter(
350 (
351 r,
352 ): r is ecrpublic.Repository & {
353 repositoryName: string;
354 repositoryArn: string;
355 repositoryUri: string;
356 } =>
357 r.repositoryName != null &&
358 r.repositoryArn != null &&
359 r.repositoryUri != null,
360 ),
361 (repository) =>
362 Effect.gen(function* () {
363 const listed = yield* pin(
364 ecrpublic.listTagsForResource({
365 resourceArn: repository.repositoryArn,
366 }),
367 );
368 return {
369 repositoryName: repository.repositoryName,
370 repositoryArn: repository.repositoryArn as RepositoryArn,
371 repositoryUri: repository.repositoryUri,
372 registryId: repository.registryId!,
373 policyText: yield* readPolicy(repository.repositoryName),
374 tags: toTagRecord(listed.tags),
375 };
376 }),
377 { concurrency: 10 },
378 );
379 }),
380 delete: Effect.fn(function* ({ output }) {
381 yield* pin(
382 ecrpublic.deleteRepository({
383 repositoryName: output.repositoryName,
384 force: true,
385 }),
386 ).pipe(
387 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
388 );
389 }),
390 };
391 }),
392 );