1import * as datazone from "@distilled.cloud/aws/datazone";
2import * as iam from "@distilled.cloud/aws/iam";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
13import { AWSEnvironment } from "../Environment.ts";
14import { syncDataZoneTags, toTagRecord, unredact } from "./internal.ts";
15
16/**
17 * Single sign-on configuration for a DataZone domain via IAM Identity Center.
18 */
19export interface DomainSingleSignOn {
20 /** The SSO type — `IAM_IDC` to enable IAM Identity Center, `DISABLED` otherwise. */
21 type?: string;
22 /** How users are assigned — `AUTOMATIC` or `MANUAL`. */
23 userAssignment?: string;
24 /** The ARN of the IAM Identity Center instance. */
25 idcInstanceArn?: string;
26}
27
28export interface DomainProps {
29 /**
30 * Display name of the domain. If omitted, a deterministic physical name is
31 * generated from the app, stage, and logical ID. The name is mutable — it
32 * converges via `UpdateDomain` without replacement.
33 */
34 name?: string;
35 /**
36 * A description of the domain.
37 */
38 description?: string;
39 /**
40 * The ARN of an existing IAM role for DataZone to act on behalf of domain
41 * users (metadata catalog, search, etc.). When omitted, an execution role
42 * is created automatically with `datazone.amazonaws.com` trust and the
43 * `AmazonDataZoneDomainExecutionRolePolicy` managed policy attached.
44 */
45 domainExecutionRole?: string;
46 /**
47 * The ARN of the service role used by V2 (SageMaker Unified Studio)
48 * domains. Only relevant when `domainVersion` is `"V2"`.
49 */
50 serviceRole?: string;
51 /**
52 * The identifier of a KMS key to encrypt the domain with. Changing it
53 * triggers a replacement.
54 */
55 kmsKeyIdentifier?: string;
56 /**
57 * The version of the domain — `"V1"` (classic DataZone) or `"V2"`
58 * (SageMaker Unified Studio). Changing it triggers a replacement.
59 * @default "V1"
60 */
61 domainVersion?: string;
62 /**
63 * Single sign-on configuration via IAM Identity Center.
64 */
65 singleSignOn?: DomainSingleSignOn;
66 /**
67 * Tags to apply to the domain. Merged with internal Alchemy tags.
68 */
69 tags?: Record<string, string>;
70}
71
72export interface Domain extends Resource<
73 "AWS.DataZone.Domain",
74 DomainProps,
75 {
76 /** The unique identifier of the domain (`dzd_...`). */
77 domainId: string;
78 /** The ARN of the domain. */
79 domainArn: string;
80 /** The display name of the domain. */
81 name: string;
82 /** The status of the domain (`AVAILABLE` once settled). */
83 status: string;
84 /** The URL of the DataZone data portal for this domain. */
85 portalUrl: string | undefined;
86 /** The identifier of the root domain unit. */
87 rootDomainUnitId: string | undefined;
88 /** The ARN of the domain execution role. */
89 domainExecutionRole: string;
90 /**
91 * Name of the auto-created execution role. `undefined` when an explicit
92 * {@link DomainProps.domainExecutionRole} is used.
93 */
94 roleName: string | undefined;
95 }
96> {}
97
98/**
99 * An Amazon DataZone domain — the top-level container for data-governance
100 * projects, environments, glossaries, and assets.
101 *
102 * `Domain` owns the domain lifecycle. Domain creation is asynchronous
103 * (typically 1–2 minutes) and is polled to `AVAILABLE` with a bounded wait.
104 * An execution role is created automatically (trusted by
105 * `datazone.amazonaws.com`, with the `AmazonDataZoneDomainExecutionRolePolicy`
106 * managed policy) unless an explicit `domainExecutionRole` is supplied.
107 *
108 * ### Creating Domains
109 * **Example:** Minimal Domain
110 * ```typescript
111 * import * as DataZone from "alchemy/AWS/DataZone";
112 *
113 * const domain = yield* DataZone.Domain("governance", {
114 * description: "Company-wide data governance domain",
115 * });
116 * ```
117 *
118 * **Example:** Domain with an Explicit Execution Role
119 * ```typescript
120 * const domain = yield* DataZone.Domain("governance", {
121 * name: "acme-governance",
122 * domainExecutionRole: role.roleArn,
123 * tags: { Team: "data-platform" },
124 * });
125 * ```
126 *
127 * ### Using the Domain
128 * **Example:** Create a Project in the Domain
129 * ```typescript
130 * const project = yield* DataZone.Project("analytics", {
131 * domainId: domain.domainId,
132 * description: "Analytics team project",
133 * });
134 * ```
135 *
136 * @resource
137 */
138export const Domain = Resource<Domain>("AWS.DataZone.Domain");
139
140/** The domain never leaves a failed create — surface it as a typed error. */
141export class DomainCreationFailed extends Data.TaggedError(
142 "AWS.DataZone.DomainCreationFailed",
143)<{
144 readonly domainId: string;
145 readonly status: string;
146}> {}
147
148/** Domain status values indicating an in-flight transition to wait out. */
149const DOMAIN_TRANSIENT = new Set(["CREATING", "DELETING"]);
150
151/**
152 * A freshly created (or re-observed) IAM role is eventually consistent;
153 * `createDomain` / `updateDomain` can transiently reject a role DataZone
154 * cannot yet assume. Wrapped in an explicitly-typed helper so the
155 * `Effect.retry` conditional return type does not leak into declaration emit
156 * and widen the provider layer's requirement to `unknown` (see PATTERNS §7).
157 */
158const retryWhileRoleAssumeFails = <A, E extends { _tag: string }, R>(
159 self: Effect.Effect<A, E, R>,
160): Effect.Effect<A, E, R> =>
161 Effect.retry(self, {
162 while: (e) =>
163 (e._tag === "ValidationException" ||
164 e._tag === "AccessDeniedException") &&
165 "message" in e &&
166 typeof e.message === "string" &&
167 (e.message.toLowerCase().includes("role") ||
168 e.message.toLowerCase().includes("assume")),
169 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
170 });
171
172const DOMAIN_EXECUTION_MANAGED_POLICY =
173 "arn:aws:iam::aws:policy/service-role/AmazonDataZoneDomainExecutionRolePolicy";
174
175export const DomainProvider = () =>
176 Provider.effect(
177 Domain,
178 Effect.gen(function* () {
179 const createName = Effect.fn(function* (id: string, props: DomainProps) {
180 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
181 });
182
183 const createRoleName = (id: string) =>
184 createPhysicalName({ id, maxLength: 64 });
185
186 // A deleted (or never-existing) domain is reported by GetDomain as
187 // AccessDeniedException ("User is not permitted to perform operation:
188 // GetDomain"), NOT ResourceNotFoundException — DataZone evaluates
189 // domain-scoped authorization before existence. Both mean "absent".
190 const getDomainOrUndefined = Effect.fn(function* (identifier: string) {
191 return yield* datazone.getDomain({ identifier }).pipe(
192 Effect.catchTag("ResourceNotFoundException", () =>
193 Effect.succeed(undefined),
194 ),
195 Effect.catchTag("AccessDeniedException", () =>
196 Effect.succeed(undefined),
197 ),
198 );
199 });
200
201 const findByName = Effect.fn(function* (name: string) {
202 const pages = yield* datazone.listDomains
203 .pages({})
204 .pipe(Stream.runCollect);
205 const summary = Array.from(pages)
206 .flatMap((page) => page.items ?? [])
207 .find(
208 (s) =>
209 unredact(s.name) === name &&
210 s.status !== "DELETING" &&
211 s.status !== "DELETED",
212 );
213 return summary?.id;
214 });
215
216 // Poll the domain to a settled (non-transient) status. V1 domains are
217 // usually AVAILABLE synchronously; V2 creation takes 1-2 minutes —
218 // bounded at ~3 minutes. An "absent" observation keeps polling: right
219 // after createDomain the creator's user profile can lag, surfacing the
220 // same AccessDenied that a deleted domain does.
221 const waitForSettled = Effect.fn(function* (identifier: string) {
222 return yield* getDomainOrUndefined(identifier).pipe(
223 Effect.repeat({
224 schedule: Schedule.fixed("5 seconds"),
225 until: (domain) =>
226 domain !== undefined && !DOMAIN_TRANSIENT.has(domain.status),
227 times: 36,
228 }),
229 );
230 });
231
232 // Poll the domain until it no longer exists. Domain deletion typically
233 // completes in 1-2 minutes — bounded at ~3 minutes.
234 const waitForGone = Effect.fn(function* (identifier: string) {
235 yield* getDomainOrUndefined(identifier).pipe(
236 Effect.repeat({
237 schedule: Schedule.fixed("5 seconds"),
238 until: (domain) =>
239 domain === undefined || domain.status === "DELETED",
240 times: 36,
241 }),
242 );
243 });
244
245 const domainArnOf = (
246 region: string,
247 accountId: string,
248 domainId: string,
249 ) => `arn:aws:datazone:${region}:${accountId}:domain/${domainId}`;
250
251 const ensureExecutionRole = Effect.fn(function* ({
252 id,
253 roleName,
254 accountId,
255 }: {
256 id: string;
257 roleName: string;
258 accountId: string;
259 }) {
260 const tags = yield* createInternalTags(id);
261 const role = yield* iam
262 .createRole({
263 RoleName: roleName,
264 AssumeRolePolicyDocument: JSON.stringify({
265 Version: "2012-10-17",
266 Statement: [
267 {
268 Effect: "Allow",
269 Principal: { Service: "datazone.amazonaws.com" },
270 Action: ["sts:AssumeRole", "sts:TagSession"],
271 Condition: {
272 StringEquals: { "aws:SourceAccount": accountId },
273 "ForAllValues:StringLike": { "aws:TagKeys": "datazone*" },
274 },
275 },
276 ],
277 }),
278 Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
279 })
280 .pipe(
281 Effect.catchTag("EntityAlreadyExistsException", () =>
282 iam.getRole({ RoleName: roleName }),
283 ),
284 );
285
286 yield* iam
287 .attachRolePolicy({
288 RoleName: roleName,
289 PolicyArn: DOMAIN_EXECUTION_MANAGED_POLICY,
290 })
291 .pipe(
292 // attaching an already-attached managed policy is a no-op, but
293 // tolerate racy duplicates anyway.
294 Effect.catchTag("InvalidInputException", () => Effect.void),
295 );
296
297 return role.Role.Arn;
298 });
299
300 return Domain.Provider.of({
301 stables: ["domainId", "domainArn", "rootDomainUnitId"],
302
303 list: () =>
304 Effect.gen(function* () {
305 const { region, accountId } = yield* AWSEnvironment.current;
306 const pages = yield* datazone.listDomains
307 .pages({})
308 .pipe(Stream.runCollect);
309 const summaries = Array.from(pages)
310 .flatMap((page) => page.items ?? [])
311 .filter((s) => s.status !== "DELETING" && s.status !== "DELETED");
312 return summaries.map((s) => ({
313 domainId: s.id,
314 domainArn: s.arn ?? domainArnOf(region, accountId, s.id),
315 name: unredact(s.name),
316 status: s.status,
317 portalUrl: s.portalUrl,
318 rootDomainUnitId: undefined as string | undefined,
319 domainExecutionRole: "",
320 roleName: undefined as string | undefined,
321 }));
322 }),
323
324 read: Effect.fn(function* ({ id, olds, output }) {
325 const { region, accountId } = yield* AWSEnvironment.current;
326 const domainId =
327 output?.domainId ??
328 (yield* findByName(
329 output?.name ?? (yield* createName(id, olds ?? {})),
330 ));
331 if (domainId === undefined) return undefined;
332 const domain = yield* getDomainOrUndefined(domainId);
333 if (
334 domain === undefined ||
335 domain.status === "DELETING" ||
336 domain.status === "DELETED"
337 ) {
338 return undefined;
339 }
340 const attrs = {
341 domainId: domain.id,
342 domainArn: domain.arn ?? domainArnOf(region, accountId, domain.id),
343 name: domain.name ?? "",
344 status: domain.status,
345 portalUrl: domain.portalUrl,
346 rootDomainUnitId: domain.rootDomainUnitId,
347 domainExecutionRole: domain.domainExecutionRole,
348 roleName: output?.roleName,
349 };
350 const tags = toTagRecord(domain.tags);
351 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
352 }),
353
354 diff: Effect.fn(function* ({ news, olds }) {
355 if (!isResolved(news)) return undefined;
356 if (olds?.kmsKeyIdentifier !== news?.kmsKeyIdentifier) {
357 return { action: "replace" } as const;
358 }
359 if (olds?.domainVersion !== news?.domainVersion) {
360 return { action: "replace" } as const;
361 }
362 // name, description, execution role, service role, SSO, and tags
363 // all converge via updateDomain / tagResource.
364 }),
365
366 reconcile: Effect.fn(function* ({ id, news, output, session }) {
367 const { accountId, region } = yield* AWSEnvironment.current;
368 const name = yield* createName(id, news);
369 const internalTags = yield* createInternalTags(id);
370 const desiredTags = { ...news.tags, ...internalTags };
371
372 // Ensure the execution role first — the domain cannot exist without
373 // one. Managed unless an explicit domainExecutionRole is provided.
374 let executionRoleArn = news.domainExecutionRole;
375 let roleName = output?.roleName;
376 if (executionRoleArn === undefined) {
377 roleName = roleName ?? (yield* createRoleName(id));
378 executionRoleArn = yield* ensureExecutionRole({
379 id,
380 roleName,
381 accountId,
382 });
383 }
384
385 // 1. OBSERVE — cloud state is authoritative; output is only an id
386 // cache. Fall back to a name lookup after state loss.
387 let domain = output?.domainId
388 ? yield* getDomainOrUndefined(output.domainId)
389 : undefined;
390 if (domain === undefined) {
391 const foundId = yield* findByName(name);
392 if (foundId !== undefined) {
393 domain = yield* getDomainOrUndefined(foundId);
394 }
395 }
396
397 if (domain === undefined) {
398 // 2. ENSURE — create and wait out the async provisioning.
399 const created = yield* retryWhileRoleAssumeFails(
400 datazone.createDomain({
401 name,
402 description: news.description,
403 domainExecutionRole: executionRoleArn,
404 serviceRole: news.serviceRole,
405 kmsKeyIdentifier: news.kmsKeyIdentifier,
406 domainVersion: news.domainVersion,
407 singleSignOn: news.singleSignOn,
408 tags: desiredTags,
409 }),
410 );
411 domain = (yield* waitForSettled(created.id)) ?? undefined;
412 if (domain === undefined || domain.status !== "AVAILABLE") {
413 return yield* new DomainCreationFailed({
414 domainId: created.id,
415 status: domain?.status ?? "CREATING",
416 });
417 }
418 } else {
419 // 3. SYNC — wait out any in-flight transition, then converge the
420 // mutable aspects by diffing OBSERVED state against desired.
421 domain = (yield* waitForSettled(domain.id)) ?? domain;
422 const drifted =
423 domain.name !== name ||
424 (domain.description ?? undefined) !==
425 (news.description ?? undefined) ||
426 domain.domainExecutionRole !== executionRoleArn ||
427 (news.serviceRole !== undefined &&
428 domain.serviceRole !== news.serviceRole);
429 if (drifted) {
430 yield* retryWhileRoleAssumeFails(
431 datazone.updateDomain({
432 identifier: domain.id,
433 name,
434 description: news.description,
435 domainExecutionRole: executionRoleArn,
436 serviceRole: news.serviceRole,
437 singleSignOn: news.singleSignOn,
438 }),
439 );
440 domain = (yield* getDomainOrUndefined(domain.id)) ?? domain;
441 }
442 }
443
444 const domainId = domain.id;
445 const domainArn =
446 domain.arn ?? domainArnOf(region, accountId, domainId);
447
448 // 3b. SYNC TAGS — diff against OBSERVED cloud tags (create-time tags
449 // only apply on first create; adoption may carry foreign tags).
450 yield* syncDataZoneTags(
451 domainArn,
452 toTagRecord(domain.tags),
453 desiredTags,
454 );
455
456 yield* session.note(domainId);
457 return {
458 domainId,
459 domainArn,
460 name: domain.name ?? name,
461 status: domain.status,
462 portalUrl: domain.portalUrl,
463 rootDomainUnitId: domain.rootDomainUnitId,
464 domainExecutionRole: executionRoleArn,
465 roleName,
466 };
467 }),
468
469 delete: Effect.fn(function* ({ output }) {
470 yield* datazone
471 .deleteDomain({
472 identifier: output.domainId,
473 skipDeletionCheck: true,
474 })
475 .pipe(
476 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
477 // an already-deleted domain surfaces as AccessDenied ("User is
478 // not permitted to perform operation: DeleteDomain") — DataZone
479 // checks domain-scoped auth before existence.
480 Effect.catchTag("AccessDeniedException", () => Effect.void),
481 // a concurrent DELETING domain rejects a second delete — the
482 // wait below observes it through to gone either way.
483 Effect.catchTag("ConflictException", () => Effect.void),
484 );
485 yield* waitForGone(output.domainId);
486
487 // Tear down the managed execution role (absent when an explicit
488 // domainExecutionRole was supplied). Every step tolerates a
489 // partially or fully removed role.
490 if (output.roleName !== undefined) {
491 const roleName = output.roleName;
492 yield* iam
493 .detachRolePolicy({
494 RoleName: roleName,
495 PolicyArn: DOMAIN_EXECUTION_MANAGED_POLICY,
496 })
497 .pipe(
498 Effect.catchTag("NoSuchEntityException", () => Effect.void),
499 );
500 yield* iam
501 .deleteRole({ RoleName: roleName })
502 .pipe(
503 Effect.catchTag("NoSuchEntityException", () => Effect.void),
504 );
505 }
506 }),
507 });
508 }),
509 );