1import * as codeartifact from "@distilled.cloud/aws/codeartifact";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface DomainProps {
14 /**
15 * Name of the domain (2-50 chars, lowercase letters, digits and hyphens).
16 * If omitted a deterministic physical name is generated. Changing the name
17 * replaces the domain.
18 */
19 domainName?: string;
20 /**
21 * ARN of a KMS key used to encrypt assets in the domain. Defaults to an
22 * AWS-managed key. Immutable — changing it replaces the domain.
23 */
24 encryptionKey?: string;
25 /**
26 * User-defined tags.
27 */
28 tags?: Record<string, string>;
29}
30
31export interface Domain extends Resource<
32 "AWS.CodeArtifact.Domain",
33 DomainProps,
34 {
35 /** Physical name of the domain. */
36 domainName: string;
37 /** ARN of the domain. */
38 domainArn: string;
39 /** AWS account ID that owns the domain. */
40 owner: string;
41 /** Domain status (`Active` or `Deleted`). */
42 status: string;
43 /** ARN of the KMS key used to encrypt assets in the domain. */
44 encryptionKey: string;
45 /** ARN of the S3 bucket backing the domain's asset storage. */
46 s3BucketArn: string;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * An AWS CodeArtifact domain — the top-level container that groups a set of
54 * package repositories and provides a single point for encryption, ownership
55 * and cross-account access control.
56 *
57 * ### Creating a Domain
58 * **Example:** Basic Domain
59 * ```typescript
60 * const domain = yield* CodeArtifact.Domain("packages", {});
61 * ```
62 *
63 * **Example:** Domain with a customer-managed KMS key
64 * ```typescript
65 * const domain = yield* CodeArtifact.Domain("packages", {
66 * domainName: "my-org",
67 * encryptionKey: key.keyArn,
68 * tags: { team: "platform" },
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const Domain = Resource<Domain>("AWS.CodeArtifact.Domain");
75
76/** Convert a CodeArtifact wire tag list into a plain record. */
77const toTagRecord = (
78 tags: ReadonlyArray<{ key?: string; value?: string }> | undefined,
79): Record<string, string> =>
80 Object.fromEntries(
81 (tags ?? [])
82 .filter(
83 (tag): tag is { key: string; value: string } =>
84 typeof tag.key === "string" && typeof tag.value === "string",
85 )
86 .map((tag) => [tag.key, tag.value]),
87 );
88
89export const DomainProvider = () =>
90 Provider.effect(
91 Domain,
92 Effect.gen(function* () {
93 const toName = (id: string, props: Partial<DomainProps>) =>
94 props.domainName
95 ? Effect.succeed(props.domainName)
96 : createPhysicalName({ id, maxLength: 50 });
97
98 const getDomain = Effect.fn(function* (name: string) {
99 const response = yield* codeartifact
100 .describeDomain({ domain: name })
101 .pipe(
102 Effect.catchTag("ResourceNotFoundException", () =>
103 Effect.succeed(undefined),
104 ),
105 );
106 return response?.domain;
107 });
108
109 const toAttrs = (
110 domain: codeartifact.DomainDescription,
111 name: string,
112 ) => ({
113 domainName: domain.name ?? name,
114 domainArn: domain.arn!,
115 owner: domain.owner ?? "",
116 status: domain.status ?? "Active",
117 encryptionKey: domain.encryptionKey ?? "",
118 s3BucketArn: domain.s3BucketArn ?? "",
119 });
120
121 const syncTags = Effect.fn(function* (
122 arn: string,
123 desiredTags: Record<string, string>,
124 ) {
125 const observed = yield* codeartifact
126 .listTagsForResource({ resourceArn: arn })
127 .pipe(Effect.catch(() => Effect.succeed(undefined)));
128 const { removed, upsert } = diffTags(
129 toTagRecord(observed?.tags),
130 desiredTags,
131 );
132 if (upsert.length > 0) {
133 yield* codeartifact.tagResource({
134 resourceArn: arn,
135 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
136 });
137 }
138 if (removed.length > 0) {
139 yield* codeartifact.untagResource({
140 resourceArn: arn,
141 tagKeys: removed,
142 });
143 }
144 });
145
146 return {
147 stables: ["domainName", "domainArn", "owner"],
148
149 diff: Effect.fn(function* ({ id, olds, news }) {
150 if (!isResolved(news)) return undefined;
151 if (
152 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
153 ) {
154 return { action: "replace" } as const;
155 }
156 // The encryption key is immutable — replace on change.
157 if (
158 (news?.encryptionKey ?? undefined) !==
159 (olds?.encryptionKey ?? undefined)
160 ) {
161 return { action: "replace" } as const;
162 }
163 }),
164
165 read: Effect.fn(function* ({ id, olds, output }) {
166 const name = output?.domainName ?? (yield* toName(id, olds ?? {}));
167 const domain = yield* getDomain(name);
168 if (domain?.arn === undefined) return undefined;
169 const attrs = toAttrs(domain, name);
170 const tags = yield* codeartifact
171 .listTagsForResource({ resourceArn: attrs.domainArn })
172 .pipe(
173 Effect.map((res) => toTagRecord(res.tags)),
174 Effect.catch(() => Effect.succeed({})),
175 );
176 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
177 }),
178
179 reconcile: Effect.fn(function* ({ id, news, output, session }) {
180 const name = output?.domainName ?? (yield* toName(id, news));
181 const internalTags = yield* createInternalTags(id);
182 const desiredTags = { ...internalTags, ...news.tags };
183
184 // 1. Observe — cloud state is authoritative.
185 let observed = yield* getDomain(name);
186
187 // 2. Ensure — domains are immutable; create if missing. Tolerate a
188 // concurrent-create race as an existing domain, and retry through
189 // the deletion-propagation window where a freshly deleted domain
190 // still rejects creates (ConflictException) while describeDomain
191 // already reports it gone.
192 if (observed?.arn === undefined) {
193 observed = yield* codeartifact
194 .createDomain({
195 domain: name,
196 encryptionKey: news.encryptionKey,
197 tags: Object.entries(desiredTags).map(([key, value]) => ({
198 key,
199 value,
200 })),
201 })
202 .pipe(
203 Effect.map((res) => res.domain),
204 Effect.catchTag("ConflictException", () => getDomain(name)),
205 Effect.repeat({
206 until: (domain): boolean => domain?.arn !== undefined,
207 schedule: Schedule.spaced("2 seconds"),
208 times: 10,
209 }),
210 );
211 }
212
213 // 3. Sync tags — diff against OBSERVED cloud tags.
214 yield* syncTags(observed!.arn!, desiredTags);
215
216 // 4. Return fresh attributes.
217 yield* session.note(name);
218 return toAttrs(observed!, name);
219 }),
220
221 delete: Effect.fn(function* ({ output }) {
222 // DeleteDomain is idempotent — deleting a non-existent domain
223 // succeeds (its typed error union has no not-found variant). A
224 // domain whose repositories were deleted moments earlier can
225 // transiently Conflict while those deletions propagate.
226 yield* codeartifact.deleteDomain({ domain: output.domainName }).pipe(
227 Effect.retry({
228 while: (e): boolean => e._tag === "ConflictException",
229 schedule: Schedule.spaced("2 seconds"),
230 times: 10,
231 }),
232 );
233 }),
234
235 list: () =>
236 codeartifact.listDomains.pages({}).pipe(
237 Stream.runCollect,
238 Effect.map((chunk) =>
239 Array.from(chunk)
240 .flatMap((page) => page.domains ?? [])
241 .flatMap((d) =>
242 d.arn !== undefined && d.name !== undefined
243 ? [
244 {
245 domainName: d.name,
246 domainArn: d.arn,
247 owner: d.owner ?? "",
248 status: d.status ?? "Active",
249 encryptionKey: d.encryptionKey ?? "",
250 s3BucketArn: "",
251 },
252 ]
253 : [],
254 ),
255 ),
256 ),
257 };
258 }),
259 );