| 1 | import * as iam from "@distilled.cloud/aws/iam"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | |
| 11 | /** |
| 12 | * Deleting a service-linked role is asynchronous: `deleteServiceLinkedRole` |
| 13 | * returns a deletion task that can end in `FAILED` (usually because the |
| 14 | * linked service still has resources using the role). The failure reason — |
| 15 | * including the offending resource ARNs, when the service reports them — |
| 16 | * is surfaced on this error. |
| 17 | */ |
| 18 | export class ServiceLinkedRoleDeletionFailed extends Data.TaggedError( |
| 19 | "ServiceLinkedRoleDeletionFailed", |
| 20 | )<{ |
| 21 | readonly roleName: string; |
| 22 | readonly status: string; |
| 23 | readonly reason: string | undefined; |
| 24 | }> { |
| 25 | override get message() { |
| 26 | return `IAM service-linked role ${this.roleName} deletion ${this.status}${this.reason ? `: ${this.reason}` : ""}`; |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | export interface ServiceLinkedRoleProps { |
| 31 | /** |
| 32 | * The service principal the role is linked to, e.g. |
| 33 | * `autoscaling.amazonaws.com` or `elasticbeanstalk.amazonaws.com`. |
| 34 | * The linked service owns the role's name, trust policy, and permissions. |
| 35 | */ |
| 36 | awsServiceName: string; |
| 37 | /** |
| 38 | * Optional suffix appended to the AWS-generated role name |
| 39 | * (`AWSServiceRoleFor<Service>_<customSuffix>`). Only some services allow |
| 40 | * suffixes; services that don't reject the create with `InvalidInputException`. |
| 41 | * Use a suffix to create multiple service-linked roles for the same service. |
| 42 | */ |
| 43 | customSuffix?: string; |
| 44 | /** |
| 45 | * Optional description for the role. The description is the only aspect of |
| 46 | * a service-linked role IAM allows editing after creation. |
| 47 | */ |
| 48 | description?: string; |
| 49 | } |
| 50 | |
| 51 | export interface ServiceLinkedRole extends Resource< |
| 52 | "AWS.IAM.ServiceLinkedRole", |
| 53 | ServiceLinkedRoleProps, |
| 54 | { |
| 55 | /** The name of the service-linked role. */ |
| 56 | roleName: string; |
| 57 | /** The ARN of the service-linked role. */ |
| 58 | roleArn: string; |
| 59 | /** The stable unique ID of the role. */ |
| 60 | roleId: string | undefined; |
| 61 | /** The IAM path of the role. */ |
| 62 | path: string | undefined; |
| 63 | /** The AWS service principal the role is linked to. */ |
| 64 | awsServiceName: string; |
| 65 | /** The custom suffix appended to the role name, if any. */ |
| 66 | customSuffix: string | undefined; |
| 67 | /** The description of the role. */ |
| 68 | description: string | undefined; |
| 69 | }, |
| 70 | never, |
| 71 | Providers |
| 72 | > {} |
| 73 | |
| 74 | /** |
| 75 | * An IAM role linked to (and managed by) a specific AWS service. |
| 76 | * |
| 77 | * The linked service controls the role's trust and permissions policies; the |
| 78 | * only mutable aspect is the description. Deletion is asynchronous — the |
| 79 | * provider submits a deletion task and waits (bounded) for it to complete, |
| 80 | * failing with {@link ServiceLinkedRoleDeletionFailed} when the linked service |
| 81 | * still has resources using the role. |
| 82 | * |
| 83 | * Some services auto-create their service-linked role on first use; deploying |
| 84 | * this resource over an existing role adopts it (the create API reports the |
| 85 | * collision and the provider converges on the existing role). |
| 86 | * ### Creating Service-Linked Roles |
| 87 | * **Example:** Auto Scaling Service-Linked Role |
| 88 | * ```typescript |
| 89 | * const role = yield* ServiceLinkedRole("AutoScalingRole", { |
| 90 | * awsServiceName: "autoscaling.amazonaws.com", |
| 91 | * }); |
| 92 | * ``` |
| 93 | * |
| 94 | * **Example:** Suffixed Role for a Dedicated Workload |
| 95 | * ```typescript |
| 96 | * const role = yield* ServiceLinkedRole("WorkloadRole", { |
| 97 | * awsServiceName: "autoscaling.amazonaws.com", |
| 98 | * customSuffix: "analytics", |
| 99 | * description: "Auto Scaling role scoped to the analytics workload", |
| 100 | * }); |
| 101 | * ``` |
| 102 | * |
| 103 | * @resource |
| 104 | */ |
| 105 | export const ServiceLinkedRole = Resource<ServiceLinkedRole>( |
| 106 | "AWS.IAM.ServiceLinkedRole", |
| 107 | ); |
| 108 | |
| 109 | /** |
| 110 | * Derive the custom suffix back out of an `AWSServiceRoleFor..._suffix` |
| 111 | * role name. Base (unsuffixed) service-linked role names never contain an |
| 112 | * underscore, so everything after the first `_` is the suffix. |
| 113 | */ |
| 114 | const suffixFromRoleName = (roleName: string): string | undefined => { |
| 115 | const index = roleName.indexOf("_"); |
| 116 | return index === -1 ? undefined : roleName.slice(index + 1); |
| 117 | }; |
| 118 | |
| 119 | const toAttributes = ( |
| 120 | role: iam.Role, |
| 121 | awsServiceName: string, |
| 122 | ): ServiceLinkedRole["Attributes"] => ({ |
| 123 | roleName: role.RoleName, |
| 124 | roleArn: role.Arn, |
| 125 | roleId: role.RoleId, |
| 126 | path: role.Path, |
| 127 | awsServiceName, |
| 128 | customSuffix: suffixFromRoleName(role.RoleName), |
| 129 | description: role.Description, |
| 130 | }); |
| 131 | |
| 132 | export const ServiceLinkedRoleProvider = () => |
| 133 | Provider.effect( |
| 134 | ServiceLinkedRole, |
| 135 | Effect.gen(function* () { |
| 136 | // Service-linked roles live under a deterministic path |
| 137 | // (`/aws-service-role/{service}/`), so we can always find ours by |
| 138 | // listing that path and matching the (optional) custom suffix — the |
| 139 | // role NAME itself is generated by AWS and not derivable up front. |
| 140 | const findRole = Effect.fn(function* (props: { |
| 141 | awsServiceName: string; |
| 142 | customSuffix?: string; |
| 143 | }) { |
| 144 | const roles = yield* iam.listRoles |
| 145 | .pages({ PathPrefix: `/aws-service-role/${props.awsServiceName}/` }) |
| 146 | .pipe( |
| 147 | Stream.runCollect, |
| 148 | Effect.map((chunk) => |
| 149 | Array.from(chunk).flatMap((page) => page.Roles ?? []), |
| 150 | ), |
| 151 | ); |
| 152 | return roles.find((role) => |
| 153 | props.customSuffix |
| 154 | ? role.RoleName.endsWith(`_${props.customSuffix}`) |
| 155 | : !role.RoleName.includes("_"), |
| 156 | ); |
| 157 | }); |
| 158 | |
| 159 | const getRole = Effect.fn(function* (roleName: string) { |
| 160 | const response = yield* iam |
| 161 | .getRole({ RoleName: roleName }) |
| 162 | .pipe( |
| 163 | Effect.catchTag("NoSuchEntityException", () => |
| 164 | Effect.succeed(undefined), |
| 165 | ), |
| 166 | ); |
| 167 | return response?.Role; |
| 168 | }); |
| 169 | |
| 170 | const submitDeletion = Effect.fn(function* (roleName: string) { |
| 171 | return yield* iam.deleteServiceLinkedRole({ RoleName: roleName }).pipe( |
| 172 | Effect.retry({ |
| 173 | while: (error) => |
| 174 | error._tag === "LimitExceededException" || |
| 175 | error._tag === "ServiceFailureException", |
| 176 | schedule: Schedule.max([ |
| 177 | Schedule.exponential("500 millis"), |
| 178 | Schedule.recurs(6), |
| 179 | ]), |
| 180 | }), |
| 181 | Effect.catchTag("NoSuchEntityException", () => |
| 182 | Effect.succeed(undefined), |
| 183 | ), |
| 184 | ); |
| 185 | }); |
| 186 | |
| 187 | // Deletion is asynchronous. Poll both task status and the role itself on |
| 188 | // a bounded budget (30 × 2s = 60s); observable role absence, not merely |
| 189 | // task success/absence, is terminal. IAM can transiently mark a deletion |
| 190 | // task FAILED even though immediately resubmitting it succeeds. Treat a |
| 191 | // failed or expired task as a request to re-observe and resubmit within |
| 192 | // the same budget. If a dependency really remains, every task continues |
| 193 | // to fail and the final typed error retains the resource in state. |
| 194 | const deleteAndWait = Effect.fn(function* (roleName: string) { |
| 195 | let deletionTaskId: string | undefined; |
| 196 | let lastStatus = "NOT_SUBMITTED"; |
| 197 | let lastReason: string | undefined; |
| 198 | for (let attempt = 0; attempt < 30; attempt++) { |
| 199 | // The role's observable absence is the terminal condition. A newly |
| 200 | // submitted deletion task can briefly return NoSuchEntity before |
| 201 | // its status record propagates, so task absence alone is not proof. |
| 202 | const role = yield* getRole(roleName); |
| 203 | if (!role) { |
| 204 | return; |
| 205 | } |
| 206 | |
| 207 | if (deletionTaskId === undefined) { |
| 208 | const submitted = yield* submitDeletion(roleName); |
| 209 | deletionTaskId = submitted?.DeletionTaskId; |
| 210 | lastStatus = deletionTaskId ? "SUBMITTED" : "NOT_FOUND"; |
| 211 | } |
| 212 | |
| 213 | if (deletionTaskId !== undefined) { |
| 214 | const status = yield* iam |
| 215 | .getServiceLinkedRoleDeletionStatus({ |
| 216 | DeletionTaskId: deletionTaskId, |
| 217 | }) |
| 218 | .pipe( |
| 219 | // Both the task record and IAM itself are eventually |
| 220 | // consistent. Keep polling the role on transient status-read |
| 221 | // failures; never translate them into successful deletion. |
| 222 | Effect.catchTag("NoSuchEntityException", () => |
| 223 | Effect.succeed("TASK_NOT_FOUND" as const), |
| 224 | ), |
| 225 | Effect.catchTag("ServiceFailureException", () => |
| 226 | Effect.succeed(undefined), |
| 227 | ), |
| 228 | ); |
| 229 | if (status === "TASK_NOT_FOUND") { |
| 230 | lastStatus = status; |
| 231 | deletionTaskId = undefined; |
| 232 | } else if (status !== undefined) { |
| 233 | lastStatus = status.Status; |
| 234 | if (status.Status === "FAILED") { |
| 235 | const usage = (status.Reason?.RoleUsageList ?? []) |
| 236 | .flatMap((entry) => entry.Resources ?? []) |
| 237 | .join(", "); |
| 238 | lastReason = [status.Reason?.Reason, usage] |
| 239 | .filter((part) => part) |
| 240 | .join(" — "); |
| 241 | // AWS documents that a new request must be submitted after a |
| 242 | // failed deletion task. The next bounded iteration does so. |
| 243 | deletionTaskId = undefined; |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | yield* Effect.sleep("2 seconds"); |
| 248 | } |
| 249 | return yield* Effect.fail( |
| 250 | new ServiceLinkedRoleDeletionFailed({ |
| 251 | roleName, |
| 252 | status: "TIMED_OUT", |
| 253 | reason: |
| 254 | lastReason ?? |
| 255 | `deletion task ${deletionTaskId ?? "not returned"} was ${lastStatus}, but role remained observable after 60s`, |
| 256 | }), |
| 257 | ); |
| 258 | }); |
| 259 | |
| 260 | return { |
| 261 | stables: [ |
| 262 | "roleName", |
| 263 | "roleArn", |
| 264 | "roleId", |
| 265 | "path", |
| 266 | "awsServiceName", |
| 267 | "customSuffix", |
| 268 | ], |
| 269 | list: () => |
| 270 | Effect.gen(function* () { |
| 271 | // IAM is global; every service-linked role lives under the |
| 272 | // `/aws-service-role/` path, with the linked service name as |
| 273 | // the next path segment. |
| 274 | const roles = yield* iam.listRoles |
| 275 | .pages({ PathPrefix: "/aws-service-role/" }) |
| 276 | .pipe( |
| 277 | Stream.runCollect, |
| 278 | Effect.map((chunk) => |
| 279 | Array.from(chunk).flatMap((page) => page.Roles ?? []), |
| 280 | ), |
| 281 | ); |
| 282 | return roles.flatMap((role) => { |
| 283 | const awsServiceName = role.Path.split("/")[2]; |
| 284 | return awsServiceName ? [toAttributes(role, awsServiceName)] : []; |
| 285 | }); |
| 286 | }), |
| 287 | diff: Effect.fn(function* ({ olds, news }) { |
| 288 | if (!isResolved(news)) return; |
| 289 | // The linked service and suffix are baked into the AWS-generated |
| 290 | // role name — changing either requires a new role. |
| 291 | if (olds.awsServiceName !== news.awsServiceName) { |
| 292 | return { action: "replace" } as const; |
| 293 | } |
| 294 | if ( |
| 295 | (olds.customSuffix ?? undefined) !== |
| 296 | (news.customSuffix ?? undefined) |
| 297 | ) { |
| 298 | return { action: "replace" } as const; |
| 299 | } |
| 300 | }), |
| 301 | read: Effect.fn(function* ({ olds, output }) { |
| 302 | // Service-linked roles cannot carry user tags (IAM rejects |
| 303 | // modifications beyond the description), so there is no Alchemy |
| 304 | // ownership brand — the role is an account singleton per |
| 305 | // (service, suffix) and is reported as-is. |
| 306 | const role = output?.roleName |
| 307 | ? yield* getRole(output.roleName) |
| 308 | : olds?.awsServiceName !== undefined |
| 309 | ? yield* findRole(olds) |
| 310 | : undefined; |
| 311 | if (!role) { |
| 312 | return undefined; |
| 313 | } |
| 314 | const awsServiceName = |
| 315 | olds?.awsServiceName ?? |
| 316 | output?.awsServiceName ?? |
| 317 | role.Path.split("/")[2] ?? |
| 318 | ""; |
| 319 | return toAttributes(role, awsServiceName); |
| 320 | }), |
| 321 | reconcile: Effect.fn(function* ({ news, output, session }) { |
| 322 | // Observe — prefer the cached role name; fall back to searching |
| 323 | // the service's deterministic path. |
| 324 | let observed = output?.roleName |
| 325 | ? yield* getRole(output.roleName) |
| 326 | : undefined; |
| 327 | if (!observed) { |
| 328 | observed = yield* findRole(news); |
| 329 | } |
| 330 | |
| 331 | // Ensure — create when missing. AWS reports an existing role |
| 332 | // (auto-created by the service, or a concurrent reconcile) as |
| 333 | // `InvalidInputException`; re-observe and adopt it. If nothing |
| 334 | // is found the input really was invalid — rethrow. |
| 335 | if (!observed) { |
| 336 | const created = yield* iam |
| 337 | .createServiceLinkedRole({ |
| 338 | AWSServiceName: news.awsServiceName, |
| 339 | CustomSuffix: news.customSuffix, |
| 340 | Description: news.description, |
| 341 | }) |
| 342 | .pipe( |
| 343 | Effect.catchTag("InvalidInputException", (error) => |
| 344 | findRole(news).pipe( |
| 345 | Effect.flatMap((existing) => |
| 346 | existing |
| 347 | ? Effect.succeed({ Role: existing }) |
| 348 | : Effect.fail(error), |
| 349 | ), |
| 350 | ), |
| 351 | ), |
| 352 | ); |
| 353 | observed = created.Role; |
| 354 | } |
| 355 | if (!observed) { |
| 356 | return yield* Effect.fail( |
| 357 | new Error( |
| 358 | `Service-linked role for '${news.awsServiceName}' was created but could not be described`, |
| 359 | ), |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | // Sync description — the only aspect IAM lets us edit on a |
| 364 | // service-linked role. Only converge when the user declared one; |
| 365 | // an omitted prop leaves the service's default description alone. |
| 366 | if ( |
| 367 | news.description !== undefined && |
| 368 | observed.Description !== news.description |
| 369 | ) { |
| 370 | yield* iam |
| 371 | .updateRole({ |
| 372 | RoleName: observed.RoleName, |
| 373 | Description: news.description, |
| 374 | }) |
| 375 | .pipe( |
| 376 | // A few linked services lock the role entirely — treat the |
| 377 | // description as service-managed rather than failing. |
| 378 | Effect.catchTag( |
| 379 | "UnmodifiableEntityException", |
| 380 | () => Effect.void, |
| 381 | ), |
| 382 | ); |
| 383 | } |
| 384 | |
| 385 | // Return fresh attributes. |
| 386 | const fresh = (yield* getRole(observed.RoleName)) ?? observed; |
| 387 | yield* session.note(fresh.Arn); |
| 388 | return toAttributes(fresh, news.awsServiceName); |
| 389 | }), |
| 390 | delete: Effect.fn(function* ({ output }) { |
| 391 | yield* deleteAndWait(output.roleName); |
| 392 | }), |
| 393 | }; |
| 394 | }), |
| 395 | ); |