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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface AccessPolicyAssociation {
13 /**
14 * ARN of the AWS-managed EKS access policy.
15 */
16 policyArn: string;
17 /**
18 * Scope the policy applies to.
19 */
20 accessScope: eks.AccessScope;
21}
22
23export interface AccessEntryProps {
24 /**
25 * Target cluster name.
26 */
27 clusterName: Input<string>;
28 /**
29 * IAM principal ARN to grant access to.
30 */
31 principalArn: Input<string>;
32 /**
33 * Optional Kubernetes groups for the principal.
34 */
35 kubernetesGroups?: string[];
36 /**
37 * Optional username to map inside Kubernetes.
38 */
39 username?: string;
40 /**
41 * Entry type, such as `STANDARD`.
42 */
43 type?: string;
44 /**
45 * Exact set of EKS access policies associated with this entry.
46 */
47 accessPolicies?: AccessPolicyAssociation[];
48 /**
49 * User-defined tags to apply to the access entry.
50 */
51 tags?: Record<string, string>;
52}
53
54export interface AccessEntry extends Resource<
55 "AWS.EKS.AccessEntry",
56 AccessEntryProps,
57 {
58 /** The ARN of the access entry. */
59 accessEntryArn: string;
60 /** The name of the EKS cluster the entry grants access to. */
61 clusterName: string;
62 /** The IAM principal ARN the entry maps into the cluster. */
63 principalArn: string;
64 /** The Kubernetes groups the principal is mapped to. */
65 kubernetesGroups: string[];
66 /** The Kubernetes username the principal is mapped to. */
67 username: string | undefined;
68 /** The access entry type (e.g. `STANDARD`, `EC2_LINUX`, `FARGATE_LINUX`). */
69 type: string | undefined;
70 /** The EKS access policies associated with the entry. */
71 accessPolicies: AccessPolicyAssociation[];
72 /** The tags applied to the access entry. */
73 tags: Record<string, string>;
74 },
75 never,
76 Providers
77> {}
78
79/**
80 * An Amazon EKS access entry that grants an IAM principal access to a cluster.
81 *
82 * `AccessEntry` owns both the entry itself and the exact set of associated EKS
83 * access policies, making cluster access explicit and updatable after initial
84 * cluster bootstrap.
85 * ### Managing Cluster Access
86 * **Example:** Grant Read Access to a Role
87 * ```typescript
88 * const viewer = yield* AccessEntry("ViewerAccess", {
89 * clusterName: cluster.clusterName,
90 * principalArn: viewerRole.roleArn,
91 * accessPolicies: [
92 * {
93 * policyArn:
94 * "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy",
95 * accessScope: {
96 * type: "cluster",
97 * },
98 * },
99 * ],
100 * });
101 * ```
102 *
103 * @resource
104 */
105export const AccessEntry = Resource<AccessEntry>("AWS.EKS.AccessEntry");
106
107export const AccessEntryProvider = () =>
108 Provider.succeed(AccessEntry, {
109 stables: ["accessEntryArn"],
110 diff: Effect.fn(function* ({ olds, news }) {
111 if (!isResolved(news)) return;
112 if (olds.clusterName !== news.clusterName) {
113 return { action: "replace" } as const;
114 }
115
116 if (olds.principalArn !== news.principalArn) {
117 return { action: "replace" } as const;
118 }
119
120 if ((olds.type ?? "STANDARD") !== (news.type ?? "STANDARD")) {
121 return { action: "replace" } as const;
122 }
123 }),
124 // Enumerate every access entry across every cluster in the account/region.
125 // `listAccessEntries` requires a cluster name, so enumerate clusters first
126 // (`listClusters`), then list access entry principal ARNs per cluster, then
127 // hydrate each one through `readAccessEntry` (describe + associated policies)
128 // to produce the full `read`-shaped Attributes. All pagination is exhausted.
129 list: Effect.fn(function* () {
130 const clusterNames = yield* eks.listClusters.items({}).pipe(
131 Stream.runCollect,
132 Effect.map((chunk) => Array.from(chunk)),
133 );
134
135 const perCluster = yield* Effect.forEach(
136 clusterNames,
137 (clusterName) =>
138 eks.listAccessEntries.items({ clusterName }).pipe(
139 Stream.runCollect,
140 Effect.map((chunk) => Array.from(chunk)),
141 Effect.flatMap((principalArns) =>
142 Effect.forEach(
143 principalArns,
144 (principalArn) =>
145 readAccessEntry({ clusterName, principalArn }),
146 { concurrency: 5 },
147 ),
148 ),
149 ),
150 { concurrency: 5 },
151 );
152
153 return perCluster
154 .flat()
155 .filter((entry): entry is NonNullable<typeof entry> => entry != null);
156 }),
157 read: Effect.fn(function* ({ id, olds, output }) {
158 const state = yield* readAccessEntry({
159 clusterName: (output?.clusterName ?? olds.clusterName) as string,
160 principalArn: (output?.principalArn ?? olds.principalArn) as string,
161 });
162 if (!state) return undefined;
163 return (yield* hasAlchemyTags(id, state.tags)) ? state : Unowned(state);
164 }),
165 reconcile: Effect.fn(function* ({ id, news, session }) {
166 const clusterName = news.clusterName as string;
167 const principalArn = news.principalArn as string;
168 const desiredTags = {
169 ...(yield* createInternalTags(id)),
170 ...news.tags,
171 };
172
173 // Observe — fetch live cloud state by (clusterName, principalArn).
174 let state = yield* readAccessEntry({
175 clusterName,
176 principalArn,
177 });
178
179 // Ensure — create the entry if missing. Tolerate
180 // `ResourceInUseException` as a race with a peer reconciler.
181 if (!state) {
182 yield* eks
183 .createAccessEntry({
184 clusterName,
185 principalArn,
186 kubernetesGroups: news.kubernetesGroups,
187 username: news.username,
188 type: news.type,
189 tags: desiredTags,
190 })
191 .pipe(Effect.catchTag("ResourceInUseException", () => Effect.void));
192
193 state = yield* readAccessEntry({
194 clusterName,
195 principalArn,
196 });
197
198 if (!state) {
199 return yield* Effect.fail(
200 new Error(
201 `AccessEntry '${principalArn}' could not be read after creation`,
202 ),
203 );
204 }
205 }
206
207 // Sync kubernetesGroups + username — observed ↔ desired.
208 const observedGroups = state.kubernetesGroups ?? [];
209 const desiredGroups = news.kubernetesGroups ?? [];
210 if (
211 JSON.stringify(observedGroups) !== JSON.stringify(desiredGroups) ||
212 state.username !== news.username
213 ) {
214 yield* eks.updateAccessEntry({
215 clusterName,
216 principalArn,
217 kubernetesGroups: desiredGroups,
218 username: news.username,
219 });
220 }
221
222 // Sync tags — diff observed cloud tags against desired.
223 const { removed, upsert } = diffTags(state.tags, desiredTags);
224 if (upsert.length > 0) {
225 yield* eks.tagResource({
226 resourceArn: state.accessEntryArn,
227 tags: Object.fromEntries(
228 upsert.map((tag) => [tag.Key, tag.Value] as const),
229 ),
230 });
231 }
232 if (removed.length > 0) {
233 yield* eks.untagResource({
234 resourceArn: state.accessEntryArn,
235 tagKeys: removed,
236 });
237 }
238
239 // Sync access policy associations — diff observed against desired.
240 const observedPolicies = state.accessPolicies;
241 const desiredPolicies = normalizeAccessPolicies(news.accessPolicies);
242 const observedPolicyMap = new Map(
243 observedPolicies.map((policy) => [policyKey(policy), policy]),
244 );
245 const desiredPolicyMap = new Map(
246 desiredPolicies.map((policy) => [policyKey(policy), policy]),
247 );
248
249 for (const [key, policy] of desiredPolicyMap) {
250 if (!observedPolicyMap.has(key)) {
251 yield* eks.associateAccessPolicy({
252 clusterName,
253 principalArn,
254 policyArn: policy.policyArn,
255 accessScope: policy.accessScope,
256 });
257 }
258 }
259
260 for (const [key, policy] of observedPolicyMap) {
261 if (!desiredPolicyMap.has(key)) {
262 yield* eks.disassociateAccessPolicy({
263 clusterName,
264 principalArn,
265 policyArn: policy.policyArn,
266 });
267 }
268 }
269
270 yield* session.note(state.accessEntryArn);
271
272 // Re-read final state so returned attributes reflect post-sync.
273 return yield* readAccessEntry({
274 clusterName,
275 principalArn,
276 }).pipe(
277 Effect.flatMap((finalState) =>
278 finalState
279 ? Effect.succeed(finalState)
280 : Effect.fail(
281 new Error(
282 `AccessEntry '${principalArn}' could not be read after reconcile`,
283 ),
284 ),
285 ),
286 );
287 }),
288 delete: Effect.fn(function* ({ output }) {
289 yield* eks
290 .deleteAccessEntry({
291 clusterName: output.clusterName,
292 principalArn: output.principalArn,
293 })
294 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
295 }),
296 });
297
298const normalizeTags = (tags: Record<string, string | undefined> | undefined) =>
299 Object.fromEntries(
300 Object.entries(tags ?? {}).filter(
301 (entry): entry is [string, string] => entry[1] !== undefined,
302 ),
303 );
304
305const comparePolicyAssociation = (
306 a: AccessPolicyAssociation,
307 b: AccessPolicyAssociation,
308) =>
309 a.policyArn.localeCompare(b.policyArn) ||
310 JSON.stringify(a.accessScope).localeCompare(JSON.stringify(b.accessScope));
311
312const normalizeAccessPolicies = (
313 policies:
314 | ReadonlyArray<AccessPolicyAssociation | eks.AssociatedAccessPolicy>
315 | undefined,
316): AccessPolicyAssociation[] =>
317 (policies ?? [])
318 .flatMap((policy) =>
319 policy.policyArn && policy.accessScope
320 ? [
321 {
322 policyArn: policy.policyArn,
323 accessScope: policy.accessScope,
324 },
325 ]
326 : [],
327 )
328 .sort(comparePolicyAssociation);
329
330const policyKey = (policy: AccessPolicyAssociation) =>
331 `${policy.policyArn}::${JSON.stringify(policy.accessScope)}`;
332
333const listAccessPolicies = Effect.fn(function* ({
334 clusterName,
335 principalArn,
336}: {
337 clusterName: string;
338 principalArn: string;
339}) {
340 const policies: AccessPolicyAssociation[] = [];
341 let nextToken: string | undefined;
342
343 while (true) {
344 const response = yield* eks.listAssociatedAccessPolicies({
345 clusterName,
346 principalArn,
347 nextToken,
348 });
349
350 policies.push(
351 ...normalizeAccessPolicies(response.associatedAccessPolicies),
352 );
353
354 if (!response.nextToken) {
355 break;
356 }
357
358 nextToken = response.nextToken;
359 }
360
361 return policies.sort(comparePolicyAssociation);
362});
363
364const readAccessEntry = Effect.fn(function* ({
365 clusterName,
366 principalArn,
367}: {
368 clusterName: string;
369 principalArn: string;
370}) {
371 const response = yield* eks
372 .describeAccessEntry({
373 clusterName,
374 principalArn,
375 })
376 .pipe(
377 Effect.catchTag("ResourceNotFoundException", () =>
378 Effect.succeed(undefined),
379 ),
380 );
381
382 const accessEntry = response?.accessEntry;
383 if (
384 !accessEntry?.accessEntryArn ||
385 !accessEntry.clusterName ||
386 !accessEntry.principalArn
387 ) {
388 return undefined;
389 }
390
391 return {
392 accessEntryArn: accessEntry.accessEntryArn,
393 clusterName: accessEntry.clusterName,
394 principalArn: accessEntry.principalArn,
395 kubernetesGroups: accessEntry.kubernetesGroups ?? [],
396 username: accessEntry.username,
397 type: accessEntry.type,
398 accessPolicies: yield* listAccessPolicies({
399 clusterName: accessEntry.clusterName,
400 principalArn: accessEntry.principalArn,
401 }),
402 tags: normalizeTags(accessEntry.tags),
403 };
404});