1import * as emr from "@distilled.cloud/aws/emr";
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 StudioProps {
14 /**
15 * Descriptive name of the Studio. If omitted, a deterministic physical
16 * name is generated. Updateable in place.
17 */
18 studioName?: string;
19 /**
20 * Authentication mode — `IAM` or `SSO` (IAM Identity Center). Changing the
21 * mode replaces the Studio.
22 * @default "IAM"
23 */
24 authMode?: "IAM" | "SSO";
25 /**
26 * VPC the Studio workspaces and notebooks connect to. Changing the VPC
27 * replaces the Studio.
28 */
29 vpcId: string;
30 /**
31 * Subnets of the VPC the Studio can use (at most 5). Updateable in place
32 * (subnets can be added, not removed).
33 */
34 subnetIds: string[];
35 /**
36 * ARN of the IAM service role the Studio assumes, trusting
37 * `elasticmapreduce.amazonaws.com`. Changing the role replaces the Studio.
38 */
39 serviceRole: string;
40 /**
41 * ARN of the IAM user role Studio users assume (`SSO` auth mode only).
42 * Changing the role replaces the Studio.
43 */
44 userRole?: string;
45 /**
46 * Security group for the Studio workspace (must allow outbound TCP 18888
47 * to the engine security group and outbound HTTPS 443). Changing it
48 * replaces the Studio.
49 */
50 workspaceSecurityGroupId: string;
51 /**
52 * Security group for the Studio engine (must allow inbound TCP 18888 from
53 * the workspace security group). Changing it replaces the Studio.
54 */
55 engineSecurityGroupId: string;
56 /**
57 * S3 location that backs up Studio workspaces and notebooks, e.g.
58 * `"s3://my-bucket/studio/"`. Updateable in place.
59 */
60 defaultS3Location: string;
61 /**
62 * Description of the Studio. Updateable in place.
63 */
64 description?: string;
65 /**
66 * Authentication endpoint of your identity provider (IAM federation only).
67 * Changing it replaces the Studio.
68 */
69 idpAuthUrl?: string;
70 /**
71 * Name the IdP uses for its RelayState parameter (IAM federation only).
72 * Changing it replaces the Studio.
73 */
74 idpRelayStateParameterName?: string;
75 /**
76 * Whether trusted identity propagation is enabled for the Studio.
77 * Changing it replaces the Studio.
78 */
79 trustedIdentityPropagationEnabled?: boolean;
80 /**
81 * Whether IAM Identity Center user assignment is `REQUIRED` or `OPTIONAL`
82 * (SSO auth mode only). Changing it replaces the Studio.
83 */
84 idcUserAssignment?: "REQUIRED" | "OPTIONAL";
85 /**
86 * ARN of the IAM Identity Center instance (SSO auth mode only). Changing
87 * it replaces the Studio.
88 */
89 idcInstanceArn?: string;
90 /**
91 * KMS key ARN used to encrypt Studio workspace and notebook files.
92 * Changing it replaces the Studio.
93 */
94 encryptionKeyArn?: string;
95 /**
96 * User-defined tags for the Studio.
97 */
98 tags?: Record<string, string>;
99}
100
101export interface Studio extends Resource<
102 "AWS.EMR.Studio",
103 StudioProps,
104 {
105 /** The ID of the Studio (e.g. `es-0123456789ABCDEFGHIJKLMNOP`). */
106 studioId: string;
107 /** The ARN of the Studio. */
108 studioArn: string;
109 /** The name of the Studio. */
110 studioName: string;
111 /** The unique access URL of the Studio. */
112 url: string | undefined;
113 /** The tags applied to the Studio. */
114 tags: Record<string, string>;
115 },
116 never,
117 Providers
118> {}
119
120/**
121 * An Amazon EMR Studio — a web-based IDE for notebooks and interactive
122 * workloads that attaches to EMR clusters.
123 *
124 * A Studio itself is free; you pay for the clusters it attaches to. Each
125 * Studio needs a VPC with subnets, a workspace and an engine security group,
126 * an IAM service role, and an S3 backup location.
127 * ### Creating a Studio
128 * **Example:** IAM-Authenticated Studio
129 * ```typescript
130 * const studio = yield* Studio("Notebooks", {
131 * authMode: "IAM",
132 * vpcId: vpc.vpcId,
133 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
134 * serviceRole: serviceRole.roleArn,
135 * workspaceSecurityGroupId: workspaceSg.groupId,
136 * engineSecurityGroupId: engineSg.groupId,
137 * defaultS3Location: Output.interpolate`s3://${bucket.bucketName}/studio/`,
138 * });
139 * ```
140 *
141 * **Example:** Studio with Description and Tags
142 * ```typescript
143 * const studio = yield* Studio("Notebooks", {
144 * authMode: "IAM",
145 * vpcId: vpc.vpcId,
146 * subnetIds: [subnetA.subnetId],
147 * serviceRole: serviceRole.roleArn,
148 * workspaceSecurityGroupId: workspaceSg.groupId,
149 * engineSecurityGroupId: engineSg.groupId,
150 * defaultS3Location: Output.interpolate`s3://${bucket.bucketName}/studio/`,
151 * description: "Data-science notebooks",
152 * tags: { team: "analytics" },
153 * });
154 * ```
155 *
156 * @resource
157 */
158export const Studio = Resource<Studio>("AWS.EMR.Studio");
159
160const toTagRecord = (
161 tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined,
162): Record<string, string> =>
163 Object.fromEntries(
164 (tags ?? [])
165 .filter(
166 (tag): tag is { Key: string; Value: string } =>
167 typeof tag.Key === "string" && typeof tag.Value === "string",
168 )
169 .map((tag) => [tag.Key, tag.Value]),
170 );
171
172const sameStringSet = (
173 a: readonly string[] | undefined,
174 b: readonly string[] | undefined,
175): boolean => {
176 const left = [...(a ?? [])].sort();
177 const right = [...(b ?? [])].sort();
178 return left.length === right.length && left.every((v, i) => v === right[i]);
179};
180
181/**
182 * A freshly created IAM service role (or its inline policy) takes a few
183 * seconds to propagate (IAM eventual consistency); CreateStudio validates
184 * both assumability and S3-location access at create time and surfaces that
185 * window as the typed StudioServiceRoleNotAssumable /
186 * StudioServiceRoleMissingS3Access tags. Bounded retry (~40s max) — a
187 * genuinely misconfigured role still fails, just slower.
188 *
189 * NOTE: keep this helper at module scope with an EXPLICIT return annotation —
190 * an inlined `Effect.retry` in provider lifecycle code leaves `Retry.Return`'s
191 * conditional type unresolved in the provider's inferred layer type, which
192 * TypeScript's declaration emit widens to an `unknown` R.
193 */
194const retryWhileRolePropagates = <A, E extends { _tag: string }, R>(
195 self: Effect.Effect<A, E, R>,
196): Effect.Effect<A, E, R> =>
197 Effect.retry(self, {
198 while: (e) =>
199 e._tag === "StudioServiceRoleNotAssumable" ||
200 e._tag === "StudioServiceRoleMissingS3Access",
201 schedule: Schedule.max([Schedule.fixed("4 seconds"), Schedule.recurs(10)]),
202 });
203
204export const StudioProvider = () =>
205 Provider.effect(
206 Studio,
207 Effect.gen(function* () {
208 const toName = (id: string, props: Partial<StudioProps>) =>
209 props.studioName
210 ? Effect.succeed(props.studioName)
211 : createPhysicalName({ id, maxLength: 80 });
212
213 const readStudio = Effect.fn(function* (studioId: string) {
214 const response = yield* emr
215 .describeStudio({ StudioId: studioId })
216 .pipe(
217 Effect.catchTag("StudioNotFound", () => Effect.succeed(undefined)),
218 );
219 return response?.Studio;
220 });
221
222 // Studio names are not unique; the deterministic physical name is.
223 // Used when state was lost and we only know the derived name.
224 const findStudioByName = Effect.fn(function* (name: string) {
225 const matches = yield* emr.listStudios.items({}).pipe(
226 Stream.filter((summary) => summary.Name === name),
227 Stream.take(1),
228 Stream.runCollect,
229 );
230 const summary = Array.from(matches)[0];
231 return summary?.StudioId
232 ? yield* readStudio(summary.StudioId)
233 : undefined;
234 });
235
236 const toAttrs = Effect.fn(function* (studio: emr.Studio) {
237 if (!studio.StudioId || !studio.StudioArn || !studio.Name) {
238 return yield* Effect.fail(
239 new Error(`EMR Studio '${studio.StudioId}' is missing its ARN`),
240 );
241 }
242 return {
243 studioId: studio.StudioId,
244 studioArn: studio.StudioArn,
245 studioName: studio.Name,
246 url: studio.Url,
247 tags: toTagRecord(studio.Tags),
248 };
249 });
250
251 return {
252 stables: ["studioId", "studioArn"],
253
254 diff: Effect.fn(function* ({ olds, news }) {
255 if (!isResolved(news)) return undefined;
256 const n = news;
257 const o = olds;
258 if (n === undefined || o === undefined) return undefined;
259 // Create-only properties force a replacement. (Name, description,
260 // subnets, and the S3 location update in place.)
261 if ((n.authMode ?? "IAM") !== (o.authMode ?? "IAM")) {
262 return { action: "replace" } as const;
263 }
264 if (n.vpcId !== o.vpcId) {
265 return { action: "replace" } as const;
266 }
267 if (n.serviceRole !== o.serviceRole) {
268 return { action: "replace" } as const;
269 }
270 if ((n.userRole ?? undefined) !== (o.userRole ?? undefined)) {
271 return { action: "replace" } as const;
272 }
273 if (n.workspaceSecurityGroupId !== o.workspaceSecurityGroupId) {
274 return { action: "replace" } as const;
275 }
276 if (n.engineSecurityGroupId !== o.engineSecurityGroupId) {
277 return { action: "replace" } as const;
278 }
279 if ((n.idpAuthUrl ?? undefined) !== (o.idpAuthUrl ?? undefined)) {
280 return { action: "replace" } as const;
281 }
282 if (
283 (n.idpRelayStateParameterName ?? undefined) !==
284 (o.idpRelayStateParameterName ?? undefined)
285 ) {
286 return { action: "replace" } as const;
287 }
288 if (
289 (n.trustedIdentityPropagationEnabled ?? undefined) !==
290 (o.trustedIdentityPropagationEnabled ?? undefined)
291 ) {
292 return { action: "replace" } as const;
293 }
294 if (
295 (n.idcUserAssignment ?? undefined) !==
296 (o.idcUserAssignment ?? undefined)
297 ) {
298 return { action: "replace" } as const;
299 }
300 if (
301 (n.idcInstanceArn ?? undefined) !== (o.idcInstanceArn ?? undefined)
302 ) {
303 return { action: "replace" } as const;
304 }
305 if (
306 (n.encryptionKeyArn ?? undefined) !==
307 (o.encryptionKeyArn ?? undefined)
308 ) {
309 return { action: "replace" } as const;
310 }
311 }),
312
313 read: Effect.fn(function* ({ id, olds, output }) {
314 const studio = output?.studioId
315 ? yield* readStudio(output.studioId)
316 : yield* findStudioByName(yield* toName(id, olds ?? {}));
317 if (!studio) return undefined;
318 const attrs = yield* toAttrs(studio);
319 return (yield* hasAlchemyTags(id, attrs.tags))
320 ? attrs
321 : Unowned(attrs);
322 }),
323
324 reconcile: Effect.fn(function* ({ id, news, output, session }) {
325 const props = news!;
326 const name = yield* toName(id, props);
327 const internalTags = yield* createInternalTags(id);
328 const desiredTags = { ...internalTags, ...props.tags };
329
330 // 1. Observe — cloud state is authoritative; output is only an id
331 // cache.
332 let observed = output?.studioId
333 ? yield* readStudio(output.studioId)
334 : yield* findStudioByName(name);
335
336 // 2. Ensure — create if missing. Create errors (invalid security
337 // groups, role, subnets) propagate directly.
338 if (observed === undefined) {
339 const created = yield* retryWhileRolePropagates(
340 emr.createStudio({
341 Name: name,
342 AuthMode: props.authMode ?? "IAM",
343 VpcId: props.vpcId,
344 SubnetIds: props.subnetIds,
345 ServiceRole: props.serviceRole,
346 UserRole: props.userRole,
347 WorkspaceSecurityGroupId: props.workspaceSecurityGroupId,
348 EngineSecurityGroupId: props.engineSecurityGroupId,
349 DefaultS3Location: props.defaultS3Location,
350 Description: props.description,
351 IdpAuthUrl: props.idpAuthUrl,
352 IdpRelayStateParameterName: props.idpRelayStateParameterName,
353 TrustedIdentityPropagationEnabled:
354 props.trustedIdentityPropagationEnabled,
355 IdcUserAssignment: props.idcUserAssignment,
356 IdcInstanceArn: props.idcInstanceArn,
357 EncryptionKeyArn: props.encryptionKeyArn,
358 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
359 Key,
360 Value,
361 })),
362 }),
363 );
364 if (!created.StudioId) {
365 return yield* Effect.fail(
366 new Error(`CreateStudio for '${name}' returned no StudioId`),
367 );
368 }
369 observed = yield* readStudio(created.StudioId);
370 if (observed === undefined) {
371 return yield* Effect.fail(
372 new Error(
373 `EMR Studio '${created.StudioId}' not visible after create`,
374 ),
375 );
376 }
377 }
378 const studioId = observed.StudioId!;
379
380 // 3. Sync — compute the update delta from OBSERVED state.
381 const update: emr.UpdateStudioInput = { StudioId: studioId };
382 let mutated = false;
383 if (observed.Name !== name) {
384 update.Name = name;
385 mutated = true;
386 }
387 if (
388 props.description !== undefined &&
389 props.description !== observed.Description
390 ) {
391 update.Description = props.description;
392 mutated = true;
393 }
394 if (!sameStringSet(props.subnetIds, observed.SubnetIds)) {
395 update.SubnetIds = props.subnetIds;
396 mutated = true;
397 }
398 if (props.defaultS3Location !== observed.DefaultS3Location) {
399 update.DefaultS3Location = props.defaultS3Location;
400 mutated = true;
401 }
402 if (mutated) {
403 yield* emr.updateStudio(update);
404 }
405
406 // 3b. Sync tags — diff against OBSERVED cloud tags.
407 const observedTags = toTagRecord(observed.Tags);
408 const { removed, upsert } = diffTags(observedTags, desiredTags);
409 if (upsert.length > 0) {
410 yield* emr.addTags({ ResourceId: studioId, Tags: upsert });
411 }
412 if (removed.length > 0) {
413 yield* emr.removeTags({ ResourceId: studioId, TagKeys: removed });
414 }
415
416 // 4. Return fresh attributes.
417 const final = yield* readStudio(studioId);
418 yield* session.note(studioId);
419 return yield* toAttrs(final ?? observed);
420 }),
421
422 delete: Effect.fn(function* ({ output }) {
423 yield* emr
424 .deleteStudio({ StudioId: output.studioId })
425 .pipe(Effect.catchTag("StudioNotFound", () => Effect.void));
426 }),
427
428 list: () =>
429 emr.listStudios.items({}).pipe(
430 Stream.runCollect,
431 Effect.map((chunk) =>
432 Array.from(chunk).flatMap((summary) =>
433 summary.StudioId ? [summary.StudioId] : [],
434 ),
435 ),
436 Effect.flatMap(
437 Effect.forEach((studioId) => readStudio(studioId), {
438 concurrency: 4,
439 }),
440 ),
441 Effect.map((studios) =>
442 studios.filter(
443 (studio): studio is emr.Studio => studio !== undefined,
444 ),
445 ),
446 Effect.flatMap(
447 Effect.forEach((studio) => toAttrs(studio), { concurrency: 4 }),
448 ),
449 ),
450 };
451 }),
452 );