1import * as eks from "@distilled.cloud/aws/eks";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12
13export interface PodIdentityAssociationProps {
14 /**
15 * Target cluster name.
16 */
17 clusterName: Input<string>;
18 /**
19 * Kubernetes namespace that owns the service account.
20 */
21 namespace: string;
22 /**
23 * Kubernetes service account name.
24 */
25 serviceAccount: string;
26 /**
27 * IAM role ARN assumed by pods for this association.
28 */
29 roleArn: Input<string>;
30 /**
31 * Disable session tags for the issued credentials.
32 */
33 disableSessionTags?: boolean;
34 /**
35 * Optional target role ARN for chained role assumption.
36 */
37 targetRoleArn?: Input<string>;
38 /**
39 * Optional inline session policy JSON.
40 */
41 policy?: string;
42 /**
43 * User-defined tags to apply to the association.
44 */
45 tags?: Record<string, string>;
46}
47
48export interface PodIdentityAssociation extends Resource<
49 "AWS.EKS.PodIdentityAssociation",
50 PodIdentityAssociationProps,
51 {
52 /** The ARN of the pod identity association. */
53 associationArn: string;
54 /** The ID of the pod identity association. */
55 associationId: string;
56 /** The name of the EKS cluster the association belongs to. */
57 clusterName: string;
58 /** The Kubernetes namespace of the bound service account. */
59 namespace: string;
60 /** The name of the Kubernetes service account bound to the role. */
61 serviceAccount: string;
62 /** The ARN of the IAM role pods assume via the association. */
63 roleArn: string;
64 /** Whether EKS session tags are disabled on the assumed-role session. */
65 disableSessionTags: boolean;
66 /** The ARN of the target role for role chaining, if configured. */
67 targetRoleArn: string | undefined;
68 /** The external ID EKS uses when assuming the target role. */
69 externalId: string | undefined;
70 /** The ARN of the add-on that owns the association, if add-on managed. */
71 ownerArn: string | undefined;
72 /** The inline policy document attached to the generated role, if any. */
73 policy: string | undefined;
74 /** The tags applied to the association. */
75 tags: Record<string, string>;
76 },
77 never,
78 Providers
79> {}
80
81/**
82 * An Amazon EKS pod identity association that binds a service account to an IAM role.
83 *
84 * `PodIdentityAssociation` is the canonical workload-identity resource for EKS
85 * clusters that use EKS Pod Identity instead of IRSA.
86 * ### Managing Pod Identity
87 * **Example:** Bind a Service Account to a Role
88 * ```typescript
89 * const association = yield* PodIdentityAssociation("ApiIdentity", {
90 * clusterName: cluster.clusterName,
91 * namespace: "default",
92 * serviceAccount: "api",
93 * roleArn: podRole.roleArn,
94 * });
95 * ```
96 *
97 * @resource
98 */
99export const PodIdentityAssociation = Resource<PodIdentityAssociation>(
100 "AWS.EKS.PodIdentityAssociation",
101);
102
103export const PodIdentityAssociationProvider = () =>
104 Provider.effect(
105 PodIdentityAssociation,
106 Effect.gen(function* () {
107 const toClientRequestToken = (id: string, action: string) =>
108 createPhysicalName({
109 id: `${id}-${action}`,
110 maxLength: 64,
111 delimiter: "-",
112 });
113
114 return {
115 stables: ["associationArn", "associationId"],
116 // `list()` enumerates every pod identity association across the
117 // account/region. The list op is cluster-scoped, so first enumerate all
118 // clusters (`listClusters`), then list each cluster's associations
119 // (`listPodIdentityAssociations`), then hydrate each summary via
120 // `describePodIdentityAssociation` to produce the full Attributes shape.
121 list: () =>
122 Effect.gen(function* () {
123 const clusterNames = yield* eks.listClusters.pages({}).pipe(
124 Stream.runCollect,
125 Effect.map((chunk) =>
126 Array.from(chunk).flatMap((page) => page.clusters ?? []),
127 ),
128 );
129
130 const perCluster = yield* Effect.forEach(
131 clusterNames,
132 (clusterName) =>
133 eks.listPodIdentityAssociations.pages({ clusterName }).pipe(
134 Stream.runCollect,
135 Effect.map((chunk) =>
136 Array.from(chunk).flatMap(
137 (page) => page.associations ?? [],
138 ),
139 ),
140 Effect.flatMap((summaries) =>
141 Effect.forEach(
142 summaries,
143 (summary) =>
144 summary.associationId
145 ? readAssociationById({
146 clusterName,
147 associationId: summary.associationId,
148 })
149 : Effect.succeed(undefined),
150 { concurrency: 5 },
151 ),
152 ),
153 ),
154 { concurrency: 5 },
155 );
156
157 return perCluster
158 .flat()
159 .filter(
160 (association): association is NonNullable<typeof association> =>
161 association !== undefined,
162 );
163 }),
164 diff: Effect.fn(function* ({ olds, news }) {
165 if (!isResolved(news)) return;
166 if (olds.clusterName !== news.clusterName) {
167 return { action: "replace" } as const;
168 }
169
170 if (olds.namespace !== news.namespace) {
171 return { action: "replace" } as const;
172 }
173
174 if (olds.serviceAccount !== news.serviceAccount) {
175 return { action: "replace" } as const;
176 }
177 }),
178 read: Effect.fn(function* ({ id, olds, output }) {
179 if (output?.associationId) {
180 return yield* readAssociationById({
181 clusterName: output.clusterName,
182 associationId: output.associationId,
183 });
184 }
185
186 return yield* findAssociation({
187 id,
188 clusterName: olds.clusterName as string,
189 namespace: olds.namespace,
190 serviceAccount: olds.serviceAccount,
191 });
192 }),
193 reconcile: Effect.fn(function* ({ id, news, output, session }) {
194 const clusterName = news.clusterName as string;
195 const desiredTags = {
196 ...(yield* createInternalTags(id)),
197 ...news.tags,
198 };
199
200 // Observe — locate the association either by stored
201 // associationId or by listing on (cluster, namespace, sa).
202 let state = output?.associationId
203 ? yield* readAssociationById({
204 clusterName,
205 associationId: output.associationId,
206 })
207 : yield* findAssociation({
208 id,
209 clusterName,
210 namespace: news.namespace,
211 serviceAccount: news.serviceAccount,
212 });
213
214 // Ensure — create if missing. Tolerate `ResourceInUseException`
215 // as a race with a peer reconciler.
216 if (!state) {
217 yield* eks
218 .createPodIdentityAssociation({
219 clusterName,
220 namespace: news.namespace,
221 serviceAccount: news.serviceAccount,
222 roleArn: news.roleArn as string,
223 disableSessionTags: news.disableSessionTags,
224 targetRoleArn: news.targetRoleArn as string | undefined,
225 policy: news.policy,
226 tags: desiredTags,
227 clientRequestToken: yield* toClientRequestToken(id, "create"),
228 })
229 .pipe(
230 Effect.catchTag("ResourceInUseException", () => Effect.void),
231 );
232
233 state = yield* findAssociation({
234 id,
235 clusterName,
236 namespace: news.namespace,
237 serviceAccount: news.serviceAccount,
238 });
239
240 if (!state) {
241 return yield* Effect.fail(
242 new Error(
243 `PodIdentityAssociation '${news.namespace}/${news.serviceAccount}' could not be read after creation`,
244 ),
245 );
246 }
247 }
248
249 // Sync mutable fields — observed ↔ desired.
250 const desiredRoleArn = news.roleArn as string;
251 const desiredTargetRoleArn = news.targetRoleArn as string | undefined;
252 const desiredDisableSessionTags = news.disableSessionTags ?? false;
253 if (
254 state.roleArn !== desiredRoleArn ||
255 state.disableSessionTags !== desiredDisableSessionTags ||
256 state.targetRoleArn !== desiredTargetRoleArn ||
257 state.policy !== news.policy
258 ) {
259 yield* eks.updatePodIdentityAssociation({
260 clusterName,
261 associationId: state.associationId,
262 roleArn: desiredRoleArn,
263 disableSessionTags: news.disableSessionTags,
264 targetRoleArn: desiredTargetRoleArn,
265 policy: news.policy,
266 clientRequestToken: yield* toClientRequestToken(id, "update"),
267 });
268 }
269
270 // Sync tags — diff observed cloud tags against desired.
271 const { removed, upsert } = diffTags(state.tags, desiredTags);
272 if (upsert.length > 0) {
273 yield* eks.tagResource({
274 resourceArn: state.associationArn,
275 tags: Object.fromEntries(
276 upsert.map((tag) => [tag.Key, tag.Value] as const),
277 ),
278 });
279 }
280 if (removed.length > 0) {
281 yield* eks.untagResource({
282 resourceArn: state.associationArn,
283 tagKeys: removed,
284 });
285 }
286
287 // Re-read final state for fresh attributes.
288 const final = yield* readAssociationById({
289 clusterName,
290 associationId: state.associationId,
291 });
292 if (!final) {
293 return yield* Effect.fail(
294 new Error(
295 `PodIdentityAssociation '${state.associationId}' could not be read after reconcile`,
296 ),
297 );
298 }
299
300 yield* session.note(final.associationArn);
301 return final;
302 }),
303 delete: Effect.fn(function* ({ output }) {
304 yield* eks
305 .deletePodIdentityAssociation({
306 clusterName: output.clusterName,
307 associationId: output.associationId,
308 })
309 .pipe(
310 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
311 );
312 }),
313 };
314 }),
315 );
316
317const normalizeTags = (tags: Record<string, string | undefined> | undefined) =>
318 Object.fromEntries(
319 Object.entries(tags ?? {}).filter(
320 (entry): entry is [string, string] => entry[1] !== undefined,
321 ),
322 );
323
324const mapAssociation = (association: eks.PodIdentityAssociation) => ({
325 associationArn: association.associationArn!,
326 associationId: association.associationId!,
327 clusterName: association.clusterName!,
328 namespace: association.namespace!,
329 serviceAccount: association.serviceAccount!,
330 roleArn: association.roleArn!,
331 disableSessionTags: association.disableSessionTags ?? false,
332 targetRoleArn: association.targetRoleArn,
333 externalId: association.externalId,
334 ownerArn: association.ownerArn,
335 policy: association.policy,
336 tags: normalizeTags(association.tags),
337});
338
339const readAssociationById = Effect.fn(function* ({
340 clusterName,
341 associationId,
342}: {
343 clusterName: string;
344 associationId: string;
345}) {
346 const response = yield* eks
347 .describePodIdentityAssociation({
348 clusterName,
349 associationId,
350 })
351 .pipe(
352 Effect.catchTag("ResourceNotFoundException", () =>
353 Effect.succeed(undefined),
354 ),
355 );
356
357 const association = response?.association;
358 if (
359 !association?.associationArn ||
360 !association.associationId ||
361 !association.clusterName ||
362 !association.namespace ||
363 !association.serviceAccount ||
364 !association.roleArn
365 ) {
366 return undefined;
367 }
368
369 return mapAssociation(association);
370});
371
372const findAssociation = Effect.fn(function* ({
373 id,
374 clusterName,
375 namespace,
376 serviceAccount,
377}: {
378 id: string;
379 clusterName: string;
380 namespace: string;
381 serviceAccount: string;
382}) {
383 let nextToken: string | undefined;
384 type Association = {
385 associationArn: string;
386 associationId: string;
387 clusterName: string;
388 namespace: string;
389 serviceAccount: string;
390 roleArn: string;
391 disableSessionTags: boolean;
392 targetRoleArn: string | undefined;
393 externalId: string | undefined;
394 ownerArn: string | undefined;
395 policy: string | undefined;
396 tags: Record<string, string>;
397 };
398 let firstMatch: Association | undefined;
399
400 while (true) {
401 const response = yield* eks.listPodIdentityAssociations({
402 clusterName,
403 namespace,
404 serviceAccount,
405 nextToken,
406 });
407
408 for (const summary of response.associations ?? []) {
409 if (!summary.associationId) {
410 continue;
411 }
412
413 const association = yield* readAssociationById({
414 clusterName,
415 associationId: summary.associationId,
416 });
417
418 if (!association) continue;
419
420 if (yield* hasAlchemyTags(id, association.tags)) {
421 return association as Association;
422 }
423 firstMatch ??= association as Association;
424 }
425
426 if (!response.nextToken) {
427 return firstMatch ? Unowned(firstMatch) : undefined;
428 }
429
430 nextToken = response.nextToken;
431 }
432});