1import * as codeartifact from "@distilled.cloud/aws/codeartifact";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 { Providers } from "../Providers.ts";
11
12export interface RepositoryProps {
13 /**
14 * Name of the domain that contains the repository. Pass a `Domain`'s
15 * `domainName` attribute. Changing the domain replaces the repository.
16 */
17 domain: string;
18 /**
19 * AWS account ID that owns the domain, if it differs from the caller's
20 * account (cross-account domains).
21 */
22 domainOwner?: string;
23 /**
24 * Name of the repository (2-100 chars). If omitted a deterministic physical
25 * name is generated. Changing the name replaces the repository.
26 */
27 repositoryName?: string;
28 /**
29 * Human-readable description of the repository.
30 */
31 description?: string;
32 /**
33 * Names of other repositories in the same domain to configure as upstream
34 * sources. Requests fall through to upstreams (and then any external
35 * connection) on a cache miss.
36 */
37 upstreams?: string[];
38 /**
39 * A single external connection (e.g. `public:npmjs`, `public:pypi`,
40 * `public:maven-central`) to a public package registry. AWS allows at most
41 * one external connection per repository.
42 */
43 externalConnection?: string;
44 /**
45 * User-defined tags.
46 */
47 tags?: Record<string, string>;
48}
49
50export interface Repository extends Resource<
51 "AWS.CodeArtifact.Repository",
52 RepositoryProps,
53 {
54 /** Physical name of the repository. */
55 repositoryName: string;
56 /** ARN of the repository. */
57 repositoryArn: string;
58 /** Name of the domain containing the repository. */
59 domainName: string;
60 /** AWS account ID that owns the containing domain. */
61 domainOwner: string;
62 /** AWS account that manages the repository resource. */
63 administratorAccount: string;
64 },
65 never,
66 Providers
67> {}
68
69/**
70 * An AWS CodeArtifact repository — a package store inside a
71 * {@link Domain}. Repositories host packages (npm, PyPI, Maven, NuGet, etc.)
72 * and can chain to upstream repositories and a single external connection to a
73 * public registry.
74 *
75 * ### Creating a Repository
76 * **Example:** Basic Repository
77 * ```typescript
78 * const domain = yield* CodeArtifact.Domain("packages", {});
79 * const repo = yield* CodeArtifact.Repository("npm-store", {
80 * domain: domain.domainName,
81 * });
82 * ```
83 *
84 * **Example:** Repository with an external connection to npmjs
85 * ```typescript
86 * const repo = yield* CodeArtifact.Repository("npm-store", {
87 * domain: domain.domainName,
88 * description: "Proxy of the public npm registry",
89 * externalConnection: "public:npmjs",
90 * });
91 * ```
92 *
93 * **Example:** Repository with an upstream
94 * ```typescript
95 * const shared = yield* CodeArtifact.Repository("shared", {
96 * domain: domain.domainName,
97 * });
98 * const app = yield* CodeArtifact.Repository("app", {
99 * domain: domain.domainName,
100 * upstreams: [shared.repositoryName],
101 * });
102 * ```
103 *
104 * @resource
105 */
106export const Repository = Resource<Repository>("AWS.CodeArtifact.Repository");
107
108/** Convert a CodeArtifact wire tag list into a plain record. */
109const toTagRecord = (
110 tags: ReadonlyArray<{ key?: string; value?: string }> | undefined,
111): Record<string, string> =>
112 Object.fromEntries(
113 (tags ?? [])
114 .filter(
115 (tag): tag is { key: string; value: string } =>
116 typeof tag.key === "string" && typeof tag.value === "string",
117 )
118 .map((tag) => [tag.key, tag.value]),
119 );
120
121const upstreamNames = (
122 upstreams: ReadonlyArray<{ repositoryName?: string }> | undefined,
123): string[] =>
124 (upstreams ?? [])
125 .map((u) => u.repositoryName)
126 .filter((n): n is string => typeof n === "string");
127
128const externalConnectionNames = (
129 connections: ReadonlyArray<{ externalConnectionName?: string }> | undefined,
130): string[] =>
131 (connections ?? [])
132 .map((c) => c.externalConnectionName)
133 .filter((n): n is string => typeof n === "string");
134
135const sameSet = (a: readonly string[], b: readonly string[]): boolean =>
136 a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
137
138export const RepositoryProvider = () =>
139 Provider.effect(
140 Repository,
141 Effect.gen(function* () {
142 const toName = (id: string, props: Partial<RepositoryProps>) =>
143 props.repositoryName
144 ? Effect.succeed(props.repositoryName)
145 : createPhysicalName({ id, maxLength: 100 });
146
147 const getRepository = Effect.fn(function* (
148 domain: string,
149 domainOwner: string | undefined,
150 name: string,
151 ) {
152 const response = yield* codeartifact
153 .describeRepository({ domain, domainOwner, repository: name })
154 .pipe(
155 Effect.catchTag("ResourceNotFoundException", () =>
156 Effect.succeed(undefined),
157 ),
158 );
159 return response?.repository;
160 });
161
162 const toAttrs = (
163 repository: codeartifact.RepositoryDescription,
164 name: string,
165 domain: string,
166 ) => ({
167 repositoryName: repository.name ?? name,
168 repositoryArn: repository.arn!,
169 domainName: repository.domainName ?? domain,
170 domainOwner: repository.domainOwner ?? "",
171 administratorAccount: repository.administratorAccount ?? "",
172 });
173
174 const syncTags = Effect.fn(function* (
175 arn: string,
176 desiredTags: Record<string, string>,
177 ) {
178 const observed = yield* codeartifact
179 .listTagsForResource({ resourceArn: arn })
180 .pipe(Effect.catch(() => Effect.succeed(undefined)));
181 const { removed, upsert } = diffTags(
182 toTagRecord(observed?.tags),
183 desiredTags,
184 );
185 if (upsert.length > 0) {
186 yield* codeartifact.tagResource({
187 resourceArn: arn,
188 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
189 });
190 }
191 if (removed.length > 0) {
192 yield* codeartifact.untagResource({
193 resourceArn: arn,
194 tagKeys: removed,
195 });
196 }
197 });
198
199 return {
200 stables: ["repositoryName", "repositoryArn", "domainName"],
201
202 diff: Effect.fn(function* ({ id, olds, news }) {
203 if (!isResolved(news)) return undefined;
204 if (
205 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
206 ) {
207 return { action: "replace" } as const;
208 }
209 // The containing domain is immutable — replace on change.
210 if (
211 (news?.domain ?? undefined) !== (olds?.domain ?? undefined) ||
212 (news?.domainOwner ?? undefined) !==
213 (olds?.domainOwner ?? undefined)
214 ) {
215 return { action: "replace" } as const;
216 }
217 }),
218
219 read: Effect.fn(function* ({ id, olds, output }) {
220 const name =
221 output?.repositoryName ?? (yield* toName(id, olds ?? {}));
222 const domain = output?.domainName ?? olds?.domain;
223 if (domain === undefined) return undefined;
224 const repository = yield* getRepository(
225 domain,
226 olds?.domainOwner,
227 name,
228 );
229 if (repository?.arn === undefined) return undefined;
230 const attrs = toAttrs(repository, name, domain);
231 const tags = yield* codeartifact
232 .listTagsForResource({ resourceArn: attrs.repositoryArn })
233 .pipe(
234 Effect.map((res) => toTagRecord(res.tags)),
235 Effect.catch(() => Effect.succeed({})),
236 );
237 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
238 }),
239
240 reconcile: Effect.fn(function* ({ id, news, output, session }) {
241 const name = output?.repositoryName ?? (yield* toName(id, news));
242 const domain = news.domain;
243 const domainOwner = news.domainOwner;
244 const internalTags = yield* createInternalTags(id);
245 const desiredTags = { ...internalTags, ...news.tags };
246 const desiredUpstreams = news.upstreams ?? [];
247
248 // 1. Observe — cloud state is authoritative.
249 let observed = yield* getRepository(domain, domainOwner, name);
250
251 // 2. Ensure — create if missing. Tolerate a concurrent-create race.
252 if (observed?.arn === undefined) {
253 observed = yield* codeartifact
254 .createRepository({
255 domain,
256 domainOwner,
257 repository: name,
258 description: news.description,
259 upstreams: desiredUpstreams.map((repositoryName) => ({
260 repositoryName,
261 })),
262 tags: Object.entries(desiredTags).map(([key, value]) => ({
263 key,
264 value,
265 })),
266 })
267 .pipe(
268 Effect.map((res) => res.repository),
269 Effect.catchTag("ConflictException", () =>
270 getRepository(domain, domainOwner, name),
271 ),
272 );
273 if (observed?.arn === undefined) {
274 observed = yield* getRepository(domain, domainOwner, name);
275 }
276 } else {
277 // 3a. Sync description + upstreams via updateRepository if they drift.
278 const descChanged =
279 (news.description ?? undefined) !==
280 (observed.description ?? undefined);
281 const upstreamsChanged = !sameSet(
282 desiredUpstreams,
283 upstreamNames(observed.upstreams),
284 );
285 if (descChanged || upstreamsChanged) {
286 observed = yield* codeartifact
287 .updateRepository({
288 domain,
289 domainOwner,
290 repository: name,
291 description: news.description,
292 upstreams: desiredUpstreams.map((repositoryName) => ({
293 repositoryName,
294 })),
295 })
296 .pipe(Effect.map((res) => res.repository ?? observed));
297 }
298 }
299
300 // 3b. Sync the external connection — AWS allows at most one.
301 const desiredConn = news.externalConnection;
302 const currentConns = externalConnectionNames(
303 observed!.externalConnections,
304 );
305 for (const conn of currentConns) {
306 if (conn !== desiredConn) {
307 yield* codeartifact.disassociateExternalConnection({
308 domain,
309 domainOwner,
310 repository: name,
311 externalConnection: conn,
312 });
313 }
314 }
315 if (
316 desiredConn !== undefined &&
317 !currentConns.includes(desiredConn)
318 ) {
319 observed = yield* codeartifact
320 .associateExternalConnection({
321 domain,
322 domainOwner,
323 repository: name,
324 externalConnection: desiredConn,
325 })
326 .pipe(Effect.map((res) => res.repository ?? observed));
327 }
328
329 // 3c. Sync tags — diff against OBSERVED cloud tags.
330 yield* syncTags(observed!.arn!, desiredTags);
331
332 // 4. Return fresh attributes.
333 yield* session.note(name);
334 return toAttrs(observed!, name, domain);
335 }),
336
337 delete: Effect.fn(function* ({ output, olds }) {
338 yield* codeartifact
339 .deleteRepository({
340 domain: output.domainName,
341 domainOwner: output.domainOwner || olds?.domainOwner,
342 repository: output.repositoryName,
343 })
344 .pipe(
345 // Deleting a downstream repository propagates eventually — a
346 // shared upstream deleted right after can transiently fail with
347 // "being used as an upstream repository".
348 Effect.retry({
349 while: (e): boolean => e._tag === "ConflictException",
350 schedule: Schedule.spaced("2 seconds"),
351 times: 10,
352 }),
353 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
354 );
355 }),
356
357 // Repositories are scoped to a domain; there is no global enumeration
358 // without knowing the domain, so list() returns an empty set.
359 list: () => Effect.succeed([]),
360 };
361 }),
362 );