1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 createInternalTags,
12 createTagsList,
13 diffTags,
14 hasTags,
15} from "../../Tags.ts";
16import { toTagRecord } from "./common.ts";
17
18export interface InstanceProfileProps {
19 /**
20 * Name of the instance profile. If omitted, a deterministic name is generated.
21 */
22 instanceProfileName?: string;
23 /**
24 * Optional IAM path prefix.
25 * @default "/"
26 */
27 path?: string;
28 /**
29 * Optional role attached to the instance profile.
30 */
31 roleName?: Input<string>;
32 /**
33 * User-defined tags to apply to the instance profile.
34 */
35 tags?: Record<string, string>;
36}
37
38export interface InstanceProfile extends Resource<
39 "AWS.IAM.InstanceProfile",
40 InstanceProfileProps,
41 {
42 /** The ARN of the instance profile. */
43 instanceProfileArn: string;
44 /** The name of the instance profile. */
45 instanceProfileName: string;
46 /** The stable unique ID of the instance profile. */
47 instanceProfileId: string | undefined;
48 /** The IAM path of the instance profile. */
49 path: string | undefined;
50 /** The role attached to the instance profile, if any. */
51 roleName: string | undefined;
52 /** The tags applied to the instance profile. */
53 tags: Record<string, string>;
54 },
55 never,
56 Providers
57> {}
58
59/**
60 * An IAM instance profile that can present a role to EC2 instances.
61 *
62 * `InstanceProfile` bridges IAM roles into EC2 so compute instances can assume
63 * the attached role through the instance metadata service.
64 * ### Attaching Roles to EC2
65 * **Example:** Create an Instance Profile
66 * ```typescript
67 * const role = yield* Role("InstanceRole", {
68 * assumeRolePolicyDocument: {
69 * Version: "2012-10-17",
70 * Statement: [{
71 * Effect: "Allow",
72 * Principal: { Service: "ec2.amazonaws.com" },
73 * Action: ["sts:AssumeRole"],
74 * }],
75 * },
76 * });
77 *
78 * const profile = yield* InstanceProfile("WebProfile", {
79 * roleName: role.roleName,
80 * });
81 * ```
82 *
83 * @resource
84 */
85export const InstanceProfile = Resource<InstanceProfile>(
86 "AWS.IAM.InstanceProfile",
87);
88
89export const InstanceProfileProvider = () =>
90 Provider.effect(
91 InstanceProfile,
92 Effect.gen(function* () {
93 const toName = (id: string, props: InstanceProfileProps) =>
94 props.instanceProfileName
95 ? Effect.succeed(props.instanceProfileName)
96 : createPhysicalName({ id, maxLength: 128 });
97
98 const readInstanceProfile = Effect.fn(function* (name: string) {
99 const response = yield* iam
100 .getInstanceProfile({
101 InstanceProfileName: name,
102 })
103 .pipe(
104 Effect.catchTag("NoSuchEntityException", () =>
105 Effect.succeed(undefined),
106 ),
107 );
108 return response?.InstanceProfile;
109 });
110
111 const syncRole = Effect.fn(function* ({
112 profileName,
113 currentRoleName,
114 nextRoleName,
115 }: {
116 profileName: string;
117 currentRoleName: string | undefined;
118 nextRoleName: string | undefined;
119 }) {
120 if (currentRoleName && currentRoleName !== nextRoleName) {
121 yield* iam
122 .removeRoleFromInstanceProfile({
123 InstanceProfileName: profileName,
124 RoleName: currentRoleName,
125 })
126 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
127 }
128 if (nextRoleName && currentRoleName !== nextRoleName) {
129 yield* iam.addRoleToInstanceProfile({
130 InstanceProfileName: profileName,
131 RoleName: nextRoleName,
132 });
133 }
134 });
135
136 return {
137 stables: [
138 "instanceProfileArn",
139 "instanceProfileName",
140 "instanceProfileId",
141 ],
142 diff: Effect.fn(function* ({ id, olds, news }) {
143 if (!isResolved(news)) return;
144 if (
145 (yield* toName(id, olds ?? ({} as InstanceProfileProps))) !==
146 (yield* toName(id, news))
147 ) {
148 return { action: "replace" } as const;
149 }
150 if ((olds?.path ?? "/") !== (news.path ?? "/")) {
151 return { action: "replace" } as const;
152 }
153 }),
154 // IAM is global; `listInstanceProfiles` enumerates every instance
155 // profile in the account. Paginate exhaustively and map each item to
156 // the same Attributes shape `read` produces.
157 list: () =>
158 iam.listInstanceProfiles.pages({}).pipe(
159 Stream.runCollect,
160 Effect.map((chunk) =>
161 Array.from(chunk).flatMap((page) =>
162 (page.InstanceProfiles ?? []).map((profile) => ({
163 instanceProfileArn: profile.Arn,
164 instanceProfileName: profile.InstanceProfileName,
165 instanceProfileId: profile.InstanceProfileId as
166 | string
167 | undefined,
168 path: profile.Path as string | undefined,
169 roleName: profile.Roles?.[0]?.RoleName,
170 tags: toTagRecord(profile.Tags),
171 })),
172 ),
173 ),
174 ),
175 read: Effect.fn(function* ({ id, olds, output }) {
176 const name =
177 output?.instanceProfileName ??
178 (yield* toName(id, olds ?? ({} as InstanceProfileProps)));
179 const profile = yield* readInstanceProfile(name);
180 if (!profile?.Arn || !profile.InstanceProfileName) {
181 return undefined;
182 }
183 return {
184 instanceProfileArn: profile.Arn,
185 instanceProfileName: profile.InstanceProfileName,
186 instanceProfileId: profile.InstanceProfileId,
187 path: profile.Path,
188 roleName: profile.Roles?.[0]?.RoleName,
189 tags: toTagRecord(profile.Tags),
190 };
191 }),
192 reconcile: Effect.fn(function* ({ id, news, output, session }) {
193 const name = output?.instanceProfileName ?? (yield* toName(id, news));
194 const desiredTags = {
195 ...(yield* createInternalTags(id)),
196 ...news.tags,
197 };
198
199 // Observe — fetch the live instance profile (or absence).
200 let profile = yield* readInstanceProfile(name);
201
202 // Ensure — create the profile when it is missing. Tolerate races
203 // and surface a clear error when an unrelated foreign profile
204 // already owns the name.
205 if (!profile?.Arn) {
206 yield* iam
207 .createInstanceProfile({
208 InstanceProfileName: name,
209 Path: news.path,
210 Tags: createTagsList(desiredTags),
211 })
212 .pipe(
213 Effect.catchTag("EntityAlreadyExistsException", () =>
214 Effect.gen(function* () {
215 const existing = yield* readInstanceProfile(name);
216 if (!existing?.Arn) {
217 return yield* Effect.fail(
218 new Error(
219 `Instance profile '${name}' already exists but could not be described`,
220 ),
221 );
222 }
223 if (!hasTags(desiredTags, existing.Tags)) {
224 return yield* Effect.fail(
225 new Error(
226 `Instance profile '${name}' already exists and is not managed by alchemy`,
227 ),
228 );
229 }
230 }),
231 ),
232 );
233 profile = yield* readInstanceProfile(name);
234 if (!profile?.Arn || !profile.InstanceProfileName) {
235 return yield* Effect.fail(
236 new Error(
237 `Instance profile '${name}' was not readable after create`,
238 ),
239 );
240 }
241 }
242
243 // Sync role attachment — diff the observed role on the profile
244 // against the desired role and only swap when they differ.
245 const observedRoleName = profile.Roles?.[0]?.RoleName;
246 const desiredRoleName = news.roleName as string | undefined;
247 yield* syncRole({
248 profileName: name,
249 currentRoleName: observedRoleName,
250 nextRoleName: desiredRoleName,
251 });
252
253 // Sync tags — use the cloud's actual tags as the baseline so
254 // adoption / out-of-band tag changes converge correctly.
255 const observedTags = toTagRecord(profile.Tags);
256 const { removed, upsert } = diffTags(observedTags, desiredTags);
257 if (upsert.length > 0) {
258 yield* iam.tagInstanceProfile({
259 InstanceProfileName: name,
260 Tags: upsert,
261 });
262 }
263 if (removed.length > 0) {
264 yield* iam.untagInstanceProfile({
265 InstanceProfileName: name,
266 TagKeys: removed,
267 });
268 }
269
270 // Re-read for fresh attributes (the profile's `Roles` array
271 // changes after `syncRole`).
272 const fresh = yield* readInstanceProfile(name);
273 if (!fresh?.Arn || !fresh.InstanceProfileName) {
274 return yield* Effect.fail(
275 new Error(
276 `Instance profile '${name}' was not readable after sync`,
277 ),
278 );
279 }
280
281 yield* session.note(fresh.Arn);
282 return {
283 instanceProfileArn: fresh.Arn,
284 instanceProfileName: fresh.InstanceProfileName,
285 instanceProfileId: fresh.InstanceProfileId,
286 path: fresh.Path,
287 roleName: fresh.Roles?.[0]?.RoleName,
288 tags: desiredTags,
289 };
290 }),
291 delete: Effect.fn(function* ({ output }) {
292 const profile = yield* readInstanceProfile(
293 output.instanceProfileName,
294 );
295 for (const role of profile?.Roles ?? []) {
296 if (role.RoleName) {
297 yield* iam
298 .removeRoleFromInstanceProfile({
299 InstanceProfileName: output.instanceProfileName,
300 RoleName: role.RoleName,
301 })
302 .pipe(
303 Effect.catchTag("NoSuchEntityException", () => Effect.void),
304 );
305 }
306 }
307 yield* iam
308 .deleteInstanceProfile({
309 InstanceProfileName: output.instanceProfileName,
310 })
311 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
312 }),
313 };
314 }),
315 );