1import * as iam from "@distilled.cloud/aws/iam";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource, type ResourceBinding } from "../../Resource.ts";
11import {
12 createInternalTags,
13 createTagsList,
14 diffTags,
15 hasAlchemyTags,
16} from "../../Tags.ts";
17import type { AccountID } from "../Environment.ts";
18import { AWSEnvironment } from "../Environment.ts";
19import type { Providers } from "../Providers.ts";
20import type { PolicyDocument, PolicyStatement } from "./Policy.ts";
21import { toWireSeconds } from "../../Util/Duration.ts";
22import {
23 parsePolicyDocument,
24 stringifyPolicyDocument,
25 toTagRecord,
26} from "./common.ts";
27
28export type RoleName = string;
29export type RoleArn = `arn:aws:iam::${AccountID}:role/${RoleName}`;
30
31/**
32 * IAM misuses `MalformedPolicyDocument` for an eventual-consistency case: a
33 * trust policy whose `Principal` names a *freshly created* user/role is
34 * rejected with "Invalid principal in policy" until that principal finishes
35 * propagating through IAM (typically a few seconds). That specific message is
36 * retryable; a genuinely malformed document (bad syntax/fields) is not and
37 * fails fast because its message never matches.
38 */
39const invalidPrincipalRetry = {
40 while: (e: { _tag: string; message?: string }) =>
41 e._tag === "MalformedPolicyDocumentException" &&
42 (e.message?.includes("Invalid principal") ?? false),
43 schedule: Schedule.exponential("2 seconds"),
44 times: 5,
45} as const;
46
47export interface RoleProps {
48 /**
49 * Name of the role. If omitted, a unique name will be generated.
50 */
51 roleName?: string;
52 /**
53 * Optional IAM path prefix for the role.
54 * @default "/"
55 */
56 path?: string;
57 /**
58 * IAM trust policy for the role. Optional when a binding contributes the
59 * trust statements (see the `assumeRolePolicyStatements` binding field) — at
60 * least one of the two must supply a statement.
61 */
62 assumeRolePolicyDocument?: PolicyDocument;
63 /**
64 * Managed policy ARNs to attach to the role.
65 */
66 managedPolicyArns?: string[];
67 /**
68 * Inline policies keyed by policy name.
69 */
70 inlinePolicies?: Record<string, PolicyDocument>;
71 /**
72 * Optional description for the role.
73 */
74 description?: string;
75 /**
76 * Maximum session duration, e.g. `"4 hours"` or `Duration.hours(4)`.
77 * Sent to IAM as whole seconds (a bare number is milliseconds).
78 */
79 maxSessionDuration?: Duration.Input;
80 /**
81 * Optional managed policy ARN used as the permissions boundary.
82 */
83 permissionsBoundary?: string;
84 /**
85 * User-defined tags to apply to the role.
86 */
87 tags?: Record<string, string>;
88}
89
90export interface Role extends Resource<
91 "AWS.IAM.Role",
92 RoleProps,
93 {
94 /** The ARN of the role. */
95 roleArn: RoleArn;
96 /** The name of the role. */
97 roleName: RoleName;
98 /** The stable unique ID of the role. */
99 roleId: string | undefined;
100 /** The IAM path of the role. */
101 path: string | undefined;
102 /** The trust policy in effect for the role. */
103 assumeRolePolicyDocument: PolicyDocument;
104 /** Managed policy ARNs attached to the role. */
105 managedPolicyArns: string[];
106 /** Inline policies embedded in the role, keyed by policy name. */
107 inlinePolicies: Record<string, PolicyDocument>;
108 /** The description of the role. */
109 description: string | undefined;
110 /** The maximum session duration, in seconds. */
111 maxSessionDuration: number | undefined;
112 /** The managed policy ARN used as the permissions boundary, if any. */
113 permissionsBoundary: string | undefined;
114 /** The tags applied to the role. */
115 tags: Record<string, string>;
116 },
117 {
118 /**
119 * IAM policy statements contributed by bindings (e.g. a consumer granting
120 * this role access to a resource). They are folded into a managed inline
121 * policy named `alchemy-bindings` on the role.
122 */
123 policyStatements?: PolicyStatement[];
124 /**
125 * Trust-policy (assume-role) statements contributed by bindings — e.g. a
126 * consumer declaring which service principal may assume this role. They are
127 * merged into the role's `assumeRolePolicyDocument`.
128 */
129 assumeRolePolicyStatements?: PolicyStatement[];
130 },
131 Providers
132> {}
133
134/**
135 * Name of the inline policy the role provider synthesizes from binding-supplied
136 * {@link PolicyStatement}s.
137 */
138const BINDINGS_POLICY_NAME = "alchemy-bindings";
139
140/**
141 * Merge the user's inline policies with a synthetic `alchemy-bindings` policy
142 * built from binding-supplied statements (deduped by JSON identity, sorted for
143 * a stable document).
144 */
145const mergeBoundInlinePolicies = (
146 inlinePolicies: Record<string, PolicyDocument> | undefined,
147 bindings: ResourceBinding<Role["Binding"]>[],
148): Record<string, PolicyDocument> => {
149 const statements = bindings
150 .filter((binding) => (binding as { action?: string }).action !== "delete")
151 .flatMap((binding) => binding.data?.policyStatements ?? []);
152 if (statements.length === 0) return inlinePolicies ?? {};
153 const deduped = Array.from(
154 new Map(statements.map((s) => [JSON.stringify(s), s])).values(),
155 ).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
156 return {
157 ...inlinePolicies,
158 [BINDINGS_POLICY_NAME]: { Version: "2012-10-17", Statement: deduped },
159 };
160};
161
162/**
163 * Merge the user's trust policy with binding-supplied assume-role statements
164 * (deduped by JSON identity, sorted for a stable document).
165 */
166const mergeBoundAssumeRolePolicy = (
167 doc: PolicyDocument | undefined,
168 bindings: ResourceBinding<Role["Binding"]>[],
169): PolicyDocument => {
170 const bound = bindings
171 .filter((binding) => (binding as { action?: string }).action !== "delete")
172 .flatMap((binding) => binding.data?.assumeRolePolicyStatements ?? []);
173 const statements = [...(doc?.Statement ?? []), ...bound];
174 const deduped = Array.from(
175 new Map(statements.map((s) => [JSON.stringify(s), s])).values(),
176 ).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
177 return { Version: "2012-10-17", Statement: deduped };
178};
179
180/**
181 * An IAM role for AWS services and runtimes.
182 * ### Creating Roles
183 * **Example:** ECS Task Role
184 * ```typescript
185 * const role = yield* Role("TaskRole", {
186 * assumeRolePolicyDocument: {
187 * Version: "2012-10-17",
188 * Statement: [{
189 * Effect: "Allow",
190 * Principal: { Service: "ecs-tasks.amazonaws.com" },
191 * Action: ["sts:AssumeRole"],
192 * }],
193 * },
194 * });
195 * ```
196 *
197 * ### Granting Permissions
198 * **Example:** Attach a Customer-Managed Policy
199 * ```typescript
200 * const policy = yield* Policy("AppPolicy", {
201 * policyDocument: {
202 * Version: "2012-10-17",
203 * Statement: [{
204 * Effect: "Allow",
205 * Action: ["s3:GetObject"],
206 * Resource: ["arn:aws:s3:::my-bucket/*"],
207 * }],
208 * },
209 * });
210 *
211 * const role = yield* Role("AppRole", {
212 * assumeRolePolicyDocument: {
213 * Version: "2012-10-17",
214 * Statement: [{
215 * Effect: "Allow",
216 * Principal: { Service: "lambda.amazonaws.com" },
217 * Action: ["sts:AssumeRole"],
218 * }],
219 * },
220 * managedPolicyArns: [policy.policyArn],
221 * inlinePolicies: {
222 * Logs: {
223 * Version: "2012-10-17",
224 * Statement: [{
225 * Effect: "Allow",
226 * Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
227 * Resource: ["*"],
228 * }],
229 * },
230 * },
231 * });
232 * ```
233 *
234 * @resource
235 */
236export const Role = Resource<Role>("AWS.IAM.Role");
237
238export const RoleProvider = () =>
239 Provider.effect(
240 Role,
241 Effect.gen(function* () {
242 const toRoleName = (id: string, props: { roleName?: string } = {}) =>
243 props.roleName
244 ? Effect.succeed(props.roleName)
245 : createPhysicalName({ id, maxLength: 64 });
246
247 const readInlinePolicies = Effect.fn(function* (roleName: string) {
248 const policyNames = yield* iam.listRolePolicies
249 .items({ RoleName: roleName })
250 .pipe(
251 Stream.runCollect,
252 Effect.map((chunk) => Array.from(chunk)),
253 );
254 const entries = yield* Effect.all(
255 policyNames.map((policyName) =>
256 iam
257 .getRolePolicy({
258 RoleName: roleName,
259 PolicyName: policyName,
260 })
261 .pipe(
262 Effect.map(
263 (response) =>
264 [
265 policyName,
266 parsePolicyDocument(response.PolicyDocument),
267 ] as const,
268 ),
269 Effect.catchTag("NoSuchEntityException", () =>
270 Effect.succeed([policyName, undefined] as const),
271 ),
272 ),
273 ),
274 );
275 return Object.fromEntries(
276 entries.filter(
277 (entry): entry is [string, PolicyDocument] =>
278 entry[1] !== undefined,
279 ),
280 );
281 });
282
283 const readManagedPolicies = Effect.fn(function* (roleName: string) {
284 const attached = yield* iam.listAttachedRolePolicies
285 .items({ RoleName: roleName })
286 .pipe(
287 Stream.runCollect,
288 Effect.map((chunk) => Array.from(chunk)),
289 );
290 return attached
291 .map((policy) => policy.PolicyArn)
292 .filter(
293 (policyArn): policyArn is string => typeof policyArn === "string",
294 );
295 });
296
297 const readTags = Effect.fn(function* (roleName: string) {
298 const listed = yield* iam.listRoleTags({
299 RoleName: roleName,
300 });
301 return toTagRecord(listed.Tags);
302 });
303
304 const syncManagedPolicies = Effect.fn(function* ({
305 roleName,
306 olds,
307 news,
308 }: {
309 roleName: string;
310 olds: string[];
311 news: string[];
312 }) {
313 const oldSet = new Set(olds);
314 const newSet = new Set(news);
315
316 for (const policyArn of news) {
317 if (!oldSet.has(policyArn)) {
318 yield* iam.attachRolePolicy({
319 RoleName: roleName,
320 PolicyArn: policyArn,
321 });
322 }
323 }
324
325 for (const policyArn of olds) {
326 if (!newSet.has(policyArn)) {
327 yield* iam
328 .detachRolePolicy({
329 RoleName: roleName,
330 PolicyArn: policyArn,
331 })
332 .pipe(
333 Effect.catchTag("NoSuchEntityException", () => Effect.void),
334 );
335 }
336 }
337 });
338
339 const syncInlinePolicies = Effect.fn(function* ({
340 roleName,
341 olds,
342 news,
343 }: {
344 roleName: string;
345 olds: Record<string, PolicyDocument>;
346 news: Record<string, PolicyDocument>;
347 }) {
348 for (const [policyName, document] of Object.entries(news)) {
349 if (
350 JSON.stringify(olds[policyName] ?? null) !==
351 JSON.stringify(document)
352 ) {
353 yield* iam.putRolePolicy({
354 RoleName: roleName,
355 PolicyName: policyName,
356 PolicyDocument: stringifyPolicyDocument(document),
357 });
358 }
359 }
360
361 for (const policyName of Object.keys(olds)) {
362 if (!(policyName in news)) {
363 yield* iam
364 .deleteRolePolicy({
365 RoleName: roleName,
366 PolicyName: policyName,
367 })
368 .pipe(
369 Effect.catchTag("NoSuchEntityException", () => Effect.void),
370 );
371 }
372 }
373 });
374
375 return {
376 stables: ["roleArn", "roleName"],
377 list: () =>
378 Effect.gen(function* () {
379 // IAM is global; `listRoles` enumerates every role in the
380 // account. Paginate exhaustively, then hydrate each role's
381 // managed/inline policies and tags (the list summary omits
382 // them) to produce the same Attributes shape `read` returns.
383 const roles = yield* iam.listRoles.pages({}).pipe(
384 Stream.runCollect,
385 Effect.map((chunk) =>
386 Array.from(chunk)
387 .flatMap((page) => page.Roles ?? [])
388 // Service-linked roles are owned by AWS and cannot be
389 // modified or deleted by us (UnmodifiableEntityException).
390 .filter(
391 (role) => !role.Path?.startsWith("/aws-service-role/"),
392 ),
393 ),
394 );
395
396 const hydrated = yield* Effect.forEach(
397 roles,
398 (role) =>
399 Effect.gen(function* () {
400 const assumeRolePolicyDocument = parsePolicyDocument(
401 role.AssumeRolePolicyDocument,
402 );
403 if (!assumeRolePolicyDocument) {
404 return undefined;
405 }
406 const [managedPolicyArns, inlinePolicies, tags] =
407 yield* Effect.all([
408 readManagedPolicies(role.RoleName),
409 readInlinePolicies(role.RoleName),
410 readTags(role.RoleName),
411 ]);
412 return {
413 roleArn: role.Arn as RoleArn,
414 roleName: role.RoleName,
415 roleId: role.RoleId,
416 path: role.Path,
417 assumeRolePolicyDocument,
418 managedPolicyArns,
419 inlinePolicies,
420 description: role.Description,
421 maxSessionDuration: role.MaxSessionDuration,
422 permissionsBoundary:
423 role.PermissionsBoundary?.PermissionsBoundaryArn,
424 tags,
425 };
426 }).pipe(
427 // A role may be deleted concurrently mid-hydration.
428 Effect.catchTag("NoSuchEntityException", () =>
429 Effect.succeed(undefined),
430 ),
431 ),
432 { concurrency: 8 },
433 );
434
435 return hydrated.filter(
436 (attrs): attrs is NonNullable<typeof attrs> =>
437 attrs !== undefined,
438 );
439 }),
440 diff: Effect.fn(function* ({ id, olds, news = {} }) {
441 if (!isResolved(news)) return;
442 if (
443 (yield* toRoleName(id, olds ?? {})) !==
444 (yield* toRoleName(id, news ?? {}))
445 ) {
446 return { action: "replace" } as const;
447 }
448 if ((olds?.path ?? "/") !== (news?.path ?? "/")) {
449 return { action: "replace" } as const;
450 }
451 }),
452 read: Effect.fn(function* ({ id, olds, output }) {
453 const roleName =
454 output?.roleName ?? (yield* toRoleName(id, olds ?? {}));
455 const role = yield* iam
456 .getRole({
457 RoleName: roleName,
458 })
459 .pipe(
460 Effect.catchTag("NoSuchEntityException", () =>
461 Effect.succeed(undefined),
462 ),
463 );
464 if (!role?.Role) {
465 return undefined;
466 }
467
468 const [managedPolicyArns, inlinePolicies, tags] = yield* Effect.all([
469 readManagedPolicies(roleName),
470 readInlinePolicies(roleName),
471 readTags(roleName),
472 ]);
473
474 const assumeRolePolicyDocument =
475 parsePolicyDocument(role.Role.AssumeRolePolicyDocument) ??
476 output?.assumeRolePolicyDocument;
477 if (!assumeRolePolicyDocument) {
478 return undefined;
479 }
480
481 const attrs = {
482 roleArn: role.Role.Arn as RoleArn,
483 roleName: role.Role.RoleName,
484 roleId: role.Role.RoleId,
485 path: role.Role.Path,
486 assumeRolePolicyDocument,
487 managedPolicyArns,
488 inlinePolicies,
489 description: role.Role.Description,
490 maxSessionDuration: role.Role.MaxSessionDuration,
491 permissionsBoundary:
492 role.Role.PermissionsBoundary?.PermissionsBoundaryArn,
493 tags,
494 };
495 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
496 }),
497 reconcile: Effect.fn(function* ({
498 id,
499 news = {},
500 output,
501 session,
502 bindings,
503 }) {
504 const roleName = output?.roleName ?? (yield* toRoleName(id, news));
505 // Fold binding-supplied policy statements into the inline policies.
506 const inlinePolicies = mergeBoundInlinePolicies(
507 news.inlinePolicies,
508 bindings,
509 );
510 // Merge binding-supplied trust statements into the trust policy.
511 const assumeRolePolicyDocument = mergeBoundAssumeRolePolicy(
512 news.assumeRolePolicyDocument,
513 bindings,
514 );
515 if (assumeRolePolicyDocument.Statement.length === 0) {
516 return yield* Effect.die(
517 "Role requires a trust policy: set `assumeRolePolicyDocument` or bind `assumeRolePolicyStatements`.",
518 );
519 }
520 const desiredTags = {
521 ...(yield* createInternalTags(id)),
522 ...news.tags,
523 };
524 const desiredMaxSessionDuration = toWireSeconds(
525 news.maxSessionDuration,
526 );
527
528 // Observe — read the role from IAM. Absence is signalled by
529 // `NoSuchEntityException`; ownership has already been verified
530 // upstream so adopting a `Unowned` role is the engine's call.
531 let observedRole = yield* iam
532 .getRole({ RoleName: roleName })
533 .pipe(
534 Effect.catchTag("NoSuchEntityException", () =>
535 Effect.succeed(undefined),
536 ),
537 );
538
539 // Ensure — create the role when missing. A peer reconciler may
540 // have created it concurrently; tolerate that race by reading
541 // the existing role.
542 if (!observedRole?.Role) {
543 observedRole = yield* iam
544 .createRole({
545 Path: news.path,
546 RoleName: roleName,
547 AssumeRolePolicyDocument: stringifyPolicyDocument(
548 assumeRolePolicyDocument,
549 ),
550 Description: news.description,
551 MaxSessionDuration: desiredMaxSessionDuration,
552 PermissionsBoundary: news.permissionsBoundary,
553 Tags: createTagsList(desiredTags),
554 })
555 .pipe(
556 Effect.retry(invalidPrincipalRetry),
557 Effect.catchTag("EntityAlreadyExistsException", () =>
558 iam.getRole({ RoleName: roleName }),
559 ),
560 );
561 }
562
563 const observedAssumePolicy = parsePolicyDocument(
564 observedRole.Role?.AssumeRolePolicyDocument,
565 );
566 const observedDescription = observedRole.Role?.Description;
567 const observedMaxSessionDuration =
568 observedRole.Role?.MaxSessionDuration;
569 const observedPermissionsBoundary =
570 observedRole.Role?.PermissionsBoundary?.PermissionsBoundaryArn;
571
572 // Sync assume-role policy — only call updateAssumeRolePolicy
573 // when the document actually differs.
574 if (
575 JSON.stringify(observedAssumePolicy ?? null) !==
576 JSON.stringify(assumeRolePolicyDocument)
577 ) {
578 yield* iam
579 .updateAssumeRolePolicy({
580 RoleName: roleName,
581 PolicyDocument: stringifyPolicyDocument(
582 assumeRolePolicyDocument,
583 ),
584 })
585 .pipe(Effect.retry(invalidPrincipalRetry));
586 }
587
588 // Sync description / maxSessionDuration via updateRole.
589 if (
590 observedDescription !== news.description ||
591 observedMaxSessionDuration !== desiredMaxSessionDuration
592 ) {
593 yield* iam.updateRole({
594 RoleName: roleName,
595 Description: news.description,
596 MaxSessionDuration: desiredMaxSessionDuration,
597 });
598 }
599
600 // Sync permissions boundary — put when desired, delete when
601 // cleared, no-op when unchanged.
602 if (news.permissionsBoundary !== observedPermissionsBoundary) {
603 if (news.permissionsBoundary) {
604 yield* iam.putRolePermissionsBoundary({
605 RoleName: roleName,
606 PermissionsBoundary: news.permissionsBoundary,
607 });
608 } else if (observedPermissionsBoundary) {
609 yield* iam
610 .deleteRolePermissionsBoundary({
611 RoleName: roleName,
612 })
613 .pipe(
614 Effect.catchTag("NoSuchEntityException", () => Effect.void),
615 );
616 }
617 }
618
619 // Sync managed and inline policies — observe the live state
620 // and apply only the delta. This is robust to manual edits in
621 // the AWS console and to adoption.
622 const [observedManagedPolicies, observedInlinePolicies] =
623 yield* Effect.all([
624 readManagedPolicies(roleName),
625 readInlinePolicies(roleName),
626 ]);
627 yield* syncManagedPolicies({
628 roleName,
629 olds: observedManagedPolicies,
630 news: news.managedPolicyArns ?? [],
631 });
632 yield* syncInlinePolicies({
633 roleName,
634 olds: observedInlinePolicies,
635 news: inlinePolicies,
636 });
637
638 // Sync tags against the cloud's actual tags so adoption /
639 // out-of-band tag changes converge.
640 const observedTags = yield* readTags(roleName);
641 const { removed, upsert } = diffTags(observedTags, desiredTags);
642 if (upsert.length > 0) {
643 yield* iam.tagRole({
644 RoleName: roleName,
645 Tags: upsert,
646 });
647 }
648 if (removed.length > 0) {
649 yield* iam.untagRole({
650 RoleName: roleName,
651 TagKeys: removed,
652 });
653 }
654
655 // Re-read for fresh attributes after all mutations.
656 const liveRole = yield* iam.getRole({ RoleName: roleName });
657 const roleArn = (liveRole.Role?.Arn ??
658 observedRole.Role?.Arn ??
659 `arn:aws:iam::${(yield* AWSEnvironment.current).accountId}:role/${roleName}`) as RoleArn;
660
661 yield* session.note(roleArn);
662 return {
663 roleArn,
664 roleName: liveRole.Role?.RoleName ?? roleName,
665 roleId: liveRole.Role?.RoleId ?? observedRole.Role?.RoleId,
666 path:
667 liveRole.Role?.Path ??
668 observedRole.Role?.Path ??
669 news.path ??
670 "/",
671 assumeRolePolicyDocument,
672 managedPolicyArns: news.managedPolicyArns ?? [],
673 inlinePolicies,
674 description: liveRole.Role?.Description ?? news.description,
675 maxSessionDuration:
676 liveRole.Role?.MaxSessionDuration ?? desiredMaxSessionDuration,
677 permissionsBoundary:
678 liveRole.Role?.PermissionsBoundary?.PermissionsBoundaryArn ??
679 news.permissionsBoundary,
680 tags: desiredTags,
681 };
682 }),
683 delete: Effect.fn(function* ({ output }) {
684 yield* iam
685 .deleteRolePermissionsBoundary({
686 RoleName: output.roleName,
687 })
688 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
689
690 yield* iam.listRolePolicies.items({ RoleName: output.roleName }).pipe(
691 Stream.mapEffect((policyName) =>
692 iam
693 .deleteRolePolicy({
694 RoleName: output.roleName,
695 PolicyName: policyName,
696 })
697 .pipe(
698 Effect.catchTag("NoSuchEntityException", () => Effect.void),
699 ),
700 ),
701 Stream.runDrain,
702 // The role itself may already be gone.
703 Effect.catchTag("NoSuchEntityException", () => Effect.void),
704 );
705
706 yield* iam.listAttachedRolePolicies
707 .items({ RoleName: output.roleName })
708 .pipe(
709 Stream.mapEffect((policy) =>
710 iam
711 .detachRolePolicy({
712 RoleName: output.roleName,
713 PolicyArn: policy.PolicyArn!,
714 })
715 .pipe(
716 Effect.catchTag("NoSuchEntityException", () => Effect.void),
717 ),
718 ),
719 Stream.runDrain,
720 // The role itself may already be gone.
721 Effect.catchTag("NoSuchEntityException", () => Effect.void),
722 );
723
724 yield* iam
725 .deleteRole({
726 RoleName: output.roleName,
727 })
728 .pipe(
729 Effect.retry({
730 while: (error) =>
731 error._tag === "ConcurrentModificationException" ||
732 error._tag === "DeleteConflictException" ||
733 error._tag === "LimitExceededException" ||
734 error._tag === "ServiceFailureException",
735 schedule: Schedule.max([
736 Schedule.exponential("250 millis"),
737 Schedule.recurs(8),
738 ]),
739 }),
740 Effect.catchTag("NoSuchEntityException", () => Effect.void),
741 );
742
743 // IAM deletion is eventually consistent. Do not let the engine
744 // discard state until the role is actually absent, otherwise a
745 // following clean run (or nuke) can still discover the old role.
746 for (let attempt = 0; attempt < 30; attempt++) {
747 const remaining = yield* iam
748 .getRole({ RoleName: output.roleName })
749 .pipe(
750 Effect.catchTag("NoSuchEntityException", () =>
751 Effect.succeed(undefined),
752 ),
753 );
754 if (remaining === undefined) return;
755 yield* Effect.sleep("1 second");
756 }
757 yield* Effect.die(
758 new Error(
759 `IAM role ${output.roleName} remained observable 30 seconds after delete`,
760 ),
761 );
762 }),
763 };
764 }),
765 );