1import * as datazone from "@distilled.cloud/aws/datazone";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { unredact } from "./internal.ts";
10
11/**
12 * Lake Formation provisioning configuration for a blueprint.
13 */
14export interface BlueprintLakeFormationConfiguration {
15 /** The ARN of the role used to register S3 locations with Lake Formation. */
16 locationRegistrationRole?: string;
17 /** S3 locations to exclude from Lake Formation registration. */
18 locationRegistrationExcludeS3Locations?: string[];
19}
20
21/**
22 * A provisioning configuration attached to a blueprint configuration.
23 */
24export interface BlueprintProvisioningConfiguration {
25 /** Lake Formation settings applied when the blueprint provisions environments. */
26 lakeFormationConfiguration: BlueprintLakeFormationConfiguration;
27}
28
29export interface EnvironmentBlueprintConfigurationProps {
30 /**
31 * The identifier of the {@link Domain} to configure the blueprint in.
32 * Accepts a domain's `domainId` output. Changing it triggers a
33 * replacement.
34 */
35 domainId: string;
36 /**
37 * The managed environment blueprint to configure — a blueprint name
38 * (e.g. `"DefaultDataLake"`, `"DefaultDataWarehouse"`) or a raw blueprint
39 * identifier. Changing it triggers a replacement.
40 */
41 environmentBlueprint: string;
42 /**
43 * The regions in which environments may be created from this blueprint.
44 */
45 enabledRegions: string[];
46 /**
47 * The ARN of the role DataZone uses to provision environment resources
48 * (CloudFormation stacks) from this blueprint.
49 */
50 provisioningRoleArn?: string;
51 /**
52 * The ARN of the role DataZone uses to manage access grants to
53 * environment resources (e.g. Lake Formation permissions).
54 */
55 manageAccessRoleArn?: string;
56 /**
57 * The ARN of a permission boundary policy to apply to environment roles
58 * created from this blueprint.
59 */
60 environmentRolePermissionBoundary?: string;
61 /**
62 * Region-scoped provisioning parameters, keyed by region then parameter
63 * name (e.g. `{ "us-west-2": { S3Location: "s3://bucket" } }`).
64 */
65 regionalParameters?: Record<string, Record<string, string>>;
66 /**
67 * Global provisioning parameters applied in every enabled region.
68 */
69 globalParameters?: Record<string, string>;
70 /**
71 * Additional provisioning configurations (e.g. Lake Formation location
72 * registration).
73 */
74 provisioningConfigurations?: BlueprintProvisioningConfiguration[];
75}
76
77export interface EnvironmentBlueprintConfiguration extends Resource<
78 "AWS.DataZone.EnvironmentBlueprintConfiguration",
79 EnvironmentBlueprintConfigurationProps,
80 {
81 /** The identifier of the domain the configuration lives in. */
82 domainId: string;
83 /** The identifier of the configured environment blueprint. */
84 environmentBlueprintId: string;
85 /** The name of the configured environment blueprint. */
86 environmentBlueprintName: string;
87 /** The regions in which the blueprint is enabled. */
88 enabledRegions: string[];
89 /** The ARN of the provisioning role. */
90 provisioningRoleArn: string | undefined;
91 /** The ARN of the manage-access role. */
92 manageAccessRoleArn: string | undefined;
93 }
94> {}
95
96/**
97 * The account/domain configuration of an Amazon DataZone environment
98 * blueprint — enables a managed blueprint (like `DefaultDataLake`) in
99 * specific regions with the IAM roles DataZone should provision with.
100 *
101 * The blueprint itself is an AWS-managed definition; this resource owns only
102 * its per-domain configuration (a `PUT`-style singleton keyed by domain +
103 * blueprint).
104 *
105 * ### Configuring Blueprints
106 * **Example:** Enable the DefaultDataLake Blueprint
107 * ```typescript
108 * import * as DataZone from "alchemy/AWS/DataZone";
109 *
110 * const config = yield* DataZone.EnvironmentBlueprintConfiguration(
111 * "datalake",
112 * {
113 * domainId: domain.domainId,
114 * environmentBlueprint: "DefaultDataLake",
115 * enabledRegions: ["us-west-2"],
116 * provisioningRoleArn: provisioningRole.roleArn,
117 * manageAccessRoleArn: manageAccessRole.roleArn,
118 * regionalParameters: {
119 * "us-west-2": { S3Location: "s3://my-datalake-bucket" },
120 * },
121 * },
122 * );
123 * ```
124 *
125 * **Example:** Blueprint with Lake Formation Provisioning
126 * ```typescript
127 * const config = yield* DataZone.EnvironmentBlueprintConfiguration(
128 * "datalake",
129 * {
130 * domainId: domain.domainId,
131 * environmentBlueprint: "DefaultDataLake",
132 * enabledRegions: ["us-west-2"],
133 * provisioningRoleArn: provisioningRole.roleArn,
134 * provisioningConfigurations: [
135 * {
136 * lakeFormationConfiguration: {
137 * locationRegistrationRole: registrationRole.roleArn,
138 * },
139 * },
140 * ],
141 * },
142 * );
143 * ```
144 *
145 * @resource
146 */
147export const EnvironmentBlueprintConfiguration =
148 Resource<EnvironmentBlueprintConfiguration>(
149 "AWS.DataZone.EnvironmentBlueprintConfiguration",
150 );
151
152/** No blueprint with the requested name or id exists in the domain. */
153export class EnvironmentBlueprintNotFound extends Data.TaggedError(
154 "AWS.DataZone.EnvironmentBlueprintNotFound",
155)<{
156 readonly domainId: string;
157 readonly environmentBlueprint: string;
158}> {}
159
160/**
161 * Freshly created IAM roles are eventually consistent; the PUT can
162 * transiently reject a provisioning/manage-access role it cannot yet assume.
163 * Wrapped in an explicitly-typed helper so the `Effect.retry` conditional
164 * return type does not leak into declaration emit (see PATTERNS §7).
165 */
166const retryWhileRoleAssumeFails = <A, E extends { _tag: string }, R>(
167 self: Effect.Effect<A, E, R>,
168): Effect.Effect<A, E, R> =>
169 Effect.retry(self, {
170 while: (e) =>
171 (e._tag === "ValidationException" ||
172 e._tag === "AccessDeniedException") &&
173 "message" in e &&
174 typeof e.message === "string" &&
175 (e.message.toLowerCase().includes("role") ||
176 e.message.toLowerCase().includes("assume")),
177 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
178 });
179
180export const EnvironmentBlueprintConfigurationProvider = () =>
181 Provider.effect(
182 EnvironmentBlueprintConfiguration,
183 Effect.gen(function* () {
184 // Resolve a blueprint name-or-id to its { id, name } within the domain.
185 const resolveBlueprint = Effect.fn(function* (
186 domainId: string,
187 nameOrId: string,
188 ) {
189 const match = yield* datazone.listEnvironmentBlueprints
190 .items({
191 domainIdentifier: domainId,
192 managed: true,
193 })
194 .pipe(
195 Stream.filter((b) => b.name === nameOrId || b.id === nameOrId),
196 Stream.runHead,
197 Effect.map((head) =>
198 head._tag === "Some" ? head.value : undefined,
199 ),
200 );
201 if (match === undefined) {
202 return yield* new EnvironmentBlueprintNotFound({
203 domainId,
204 environmentBlueprint: nameOrId,
205 });
206 }
207 return { id: match.id, name: match.name };
208 });
209
210 // A deleted configuration — or one inside a deleted domain — is
211 // reported as AccessDeniedException, NOT ResourceNotFoundException:
212 // DataZone evaluates domain-scoped authorization before existence.
213 // Both mean "absent".
214 const getConfigurationOrUndefined = Effect.fn(function* (
215 domainId: string,
216 blueprintId: string,
217 ) {
218 return yield* datazone
219 .getEnvironmentBlueprintConfiguration({
220 domainIdentifier: domainId,
221 environmentBlueprintIdentifier: blueprintId,
222 })
223 .pipe(
224 Effect.catchTag("ResourceNotFoundException", () =>
225 Effect.succeed(undefined),
226 ),
227 Effect.catchTag("AccessDeniedException", () =>
228 Effect.succeed(undefined),
229 ),
230 );
231 });
232
233 return EnvironmentBlueprintConfiguration.Provider.of({
234 stables: ["domainId", "environmentBlueprintId"],
235
236 // Configurations are keyed by their parent domain — there is no
237 // account-level enumeration without a domain identifier.
238 list: () => Effect.succeed([]),
239
240 read: Effect.fn(function* ({ olds, output }) {
241 const domainId = output?.domainId ?? olds?.domainId;
242 if (domainId === undefined) return undefined;
243 const blueprint = output?.environmentBlueprintId
244 ? {
245 id: output.environmentBlueprintId,
246 name: output.environmentBlueprintName,
247 }
248 : olds?.environmentBlueprint
249 ? yield* resolveBlueprint(
250 domainId,
251 olds.environmentBlueprint,
252 ).pipe(
253 Effect.catchTag(
254 "AWS.DataZone.EnvironmentBlueprintNotFound",
255 () => Effect.succeed(undefined),
256 ),
257 // the domain itself may already be gone (reported as
258 // NotFound or as AccessDenied — auth precedes existence)
259 Effect.catchTag("ResourceNotFoundException", () =>
260 Effect.succeed(undefined),
261 ),
262 Effect.catchTag("AccessDeniedException", () =>
263 Effect.succeed(undefined),
264 ),
265 )
266 : undefined;
267 if (blueprint === undefined) return undefined;
268 const config = yield* getConfigurationOrUndefined(
269 domainId,
270 blueprint.id,
271 );
272 // An un-configured blueprint returns an empty configuration shell —
273 // treat "no enabled regions and no roles" as absent.
274 if (
275 config === undefined ||
276 ((config.enabledRegions ?? []).length === 0 &&
277 config.provisioningRoleArn === undefined &&
278 config.manageAccessRoleArn === undefined)
279 ) {
280 return undefined;
281 }
282 // Blueprint configurations have no tags — ownership is tracked
283 // purely by identity.
284 return {
285 domainId: config.domainId,
286 environmentBlueprintId: config.environmentBlueprintId,
287 environmentBlueprintName: blueprint.name,
288 enabledRegions: [...(config.enabledRegions ?? [])],
289 provisioningRoleArn: config.provisioningRoleArn,
290 manageAccessRoleArn: config.manageAccessRoleArn,
291 };
292 }),
293
294 diff: Effect.fn(function* ({ news, olds }) {
295 if (!isResolved(news)) return undefined;
296 if (olds === undefined) return undefined;
297 if (olds.domainId !== news.domainId) {
298 return { action: "replace" } as const;
299 }
300 if (olds.environmentBlueprint !== news.environmentBlueprint) {
301 return { action: "replace" } as const;
302 }
303 // regions, roles, and parameters converge via the idempotent PUT.
304 }),
305
306 reconcile: Effect.fn(function* ({ news, output, session }) {
307 const domainId = news.domainId;
308 const blueprint = output?.environmentBlueprintId
309 ? {
310 id: output.environmentBlueprintId,
311 name: output.environmentBlueprintName,
312 }
313 : yield* resolveBlueprint(domainId, news.environmentBlueprint);
314
315 // PutEnvironmentBlueprintConfiguration is a true upsert — one call
316 // converges greenfield, update, and adoption alike.
317 const config = yield* retryWhileRoleAssumeFails(
318 datazone.putEnvironmentBlueprintConfiguration({
319 domainIdentifier: domainId,
320 environmentBlueprintIdentifier: blueprint.id,
321 enabledRegions: news.enabledRegions,
322 provisioningRoleArn: news.provisioningRoleArn,
323 manageAccessRoleArn: news.manageAccessRoleArn,
324 environmentRolePermissionBoundary:
325 news.environmentRolePermissionBoundary,
326 regionalParameters: news.regionalParameters,
327 globalParameters: news.globalParameters,
328 provisioningConfigurations: news.provisioningConfigurations,
329 }),
330 );
331
332 yield* session.note(`${blueprint.name} (${blueprint.id})`);
333 return {
334 domainId: config.domainId,
335 environmentBlueprintId: config.environmentBlueprintId,
336 environmentBlueprintName: blueprint.name,
337 enabledRegions: [...(config.enabledRegions ?? news.enabledRegions)],
338 provisioningRoleArn: config.provisioningRoleArn,
339 manageAccessRoleArn: config.manageAccessRoleArn,
340 };
341 }),
342
343 delete: Effect.fn(function* ({ output }) {
344 yield* datazone
345 .deleteEnvironmentBlueprintConfiguration({
346 domainIdentifier: output.domainId,
347 environmentBlueprintIdentifier: output.environmentBlueprintId,
348 })
349 .pipe(
350 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
351 // deleting a configuration whose domain is already gone
352 // surfaces as AccessDenied — auth is checked before existence.
353 Effect.catchTag("AccessDeniedException", () => Effect.void),
354 );
355 }),
356 });
357 }),
358 );