1/**
2 * Internal pod-identity + host-binding machinery behind the `aws-eks`
3 * cluster adapter's workload-identity implementation (see
4 * `../KubernetesAdapter.ts`). Each helper is an independently idempotent
5 * mini-reconciler: observe, ensure, converge.
6 */
7import * as eks from "@distilled.cloud/aws/eks";
8import * as iam from "@distilled.cloud/aws/iam";
9import * as Effect from "effect/Effect";
10import * as Stream from "effect/Stream";
11import { createPhysicalName } from "../../../PhysicalName.ts";
12import type { ResourceBinding } from "../../../Resource.ts";
13import { createInternalTags, hasTags } from "../../../Tags.ts";
14import type { PolicyStatement } from "../../IAM/Policy.ts";
15
16export const toClientRequestToken = (id: string, action: string) =>
17 createPhysicalName({
18 id: `${id}-${action}`,
19 maxLength: 64,
20 delimiter: "-",
21 });
22
23/**
24 * Ensure the pod-identity IAM role exists (trusts `pods.eks.amazonaws.com`).
25 * Idempotent: creates on miss, adopts a role we already own on race.
26 */
27export const ensurePodRole = Effect.fn(function* ({
28 id,
29 roleName,
30 managedPolicyArns,
31}: {
32 id: string;
33 roleName: string;
34 managedPolicyArns?: string[];
35}) {
36 const tags = yield* createInternalTags(id);
37 const role = yield* iam
38 .createRole({
39 RoleName: roleName,
40 AssumeRolePolicyDocument: JSON.stringify({
41 Version: "2012-10-17",
42 Statement: [
43 {
44 Effect: "Allow",
45 Principal: { Service: "pods.eks.amazonaws.com" },
46 Action: ["sts:AssumeRole", "sts:TagSession"],
47 },
48 ],
49 }),
50 Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
51 })
52 .pipe(
53 Effect.catchTag("EntityAlreadyExistsException", () =>
54 iam.getRole({ RoleName: roleName }).pipe(
55 Effect.filterOrFail(
56 (existing) => hasTags(tags, existing.Role?.Tags),
57 () =>
58 new Error(
59 `Role '${roleName}' already exists and is not managed by alchemy`,
60 ),
61 ),
62 ),
63 ),
64 );
65 for (const policyArn of managedPolicyArns ?? []) {
66 yield* iam
67 .attachRolePolicy({ RoleName: roleName, PolicyArn: policyArn })
68 .pipe(Effect.catchTag("LimitExceededException", () => Effect.void));
69 }
70 return role.Role!.Arn!;
71});
72
73export interface HostBindingContract {
74 env?: Record<string, any>;
75 policyStatements?: PolicyStatement[];
76}
77
78/**
79 * Land the IAM policy statements carried by a host's active bindings as
80 * the pod role's inline policy (or delete it when no statements remain).
81 * Binding env collection is generic and lives in the Kubernetes workload
82 * providers.
83 */
84export const attachPolicyStatements = Effect.fn(function* ({
85 roleName,
86 policyName,
87 bindings,
88}: {
89 roleName: string;
90 policyName: string;
91 bindings: ResourceBinding<HostBindingContract>[];
92}) {
93 const activeBindings = bindings.filter(
94 (
95 binding: ResourceBinding<HostBindingContract> & {
96 action?: string;
97 },
98 ) => binding.action !== "delete",
99 );
100
101 const policyStatements = activeBindings.flatMap(
102 (binding) =>
103 binding?.data?.policyStatements?.map((statement) => ({
104 ...statement,
105 Sid: statement.Sid?.replace(/[^A-Za-z0-9]+/gi, ""),
106 })) ?? [],
107 );
108
109 if (policyStatements.length > 0) {
110 yield* iam.putRolePolicy({
111 RoleName: roleName,
112 PolicyName: policyName,
113 PolicyDocument: JSON.stringify({
114 Version: "2012-10-17",
115 Statement: policyStatements,
116 }),
117 });
118 } else {
119 yield* iam
120 .deleteRolePolicy({ RoleName: roleName, PolicyName: policyName })
121 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
122 }
123});
124
125/** Observe an existing pod identity association for (cluster, ns, sa). */
126export const findAssociation = Effect.fn(function* ({
127 clusterName,
128 namespace,
129 serviceAccount,
130}: {
131 clusterName: string;
132 namespace: string;
133 serviceAccount: string;
134}) {
135 const listed = yield* eks.listPodIdentityAssociations({
136 clusterName,
137 namespace,
138 serviceAccount,
139 });
140 const summary = listed.associations?.[0];
141 if (!summary?.associationId) return undefined;
142 const described = yield* eks
143 .describePodIdentityAssociation({
144 clusterName,
145 associationId: summary.associationId,
146 })
147 .pipe(
148 Effect.catchTag("ResourceNotFoundException", () =>
149 Effect.succeed(undefined),
150 ),
151 );
152 const association = described?.association;
153 if (!association?.associationArn || !association.associationId) {
154 return undefined;
155 }
156 return {
157 associationArn: association.associationArn,
158 associationId: association.associationId,
159 roleArn: association.roleArn,
160 };
161});
162
163/** Ensure the pod identity association exists and points at roleArn. */
164export const ensureAssociation = Effect.fn(function* ({
165 id,
166 clusterName,
167 namespace,
168 serviceAccount,
169 roleArn,
170}: {
171 id: string;
172 clusterName: string;
173 namespace: string;
174 serviceAccount: string;
175 roleArn: string;
176}) {
177 let state = yield* findAssociation({
178 clusterName,
179 namespace,
180 serviceAccount,
181 });
182 if (!state) {
183 yield* eks
184 .createPodIdentityAssociation({
185 clusterName,
186 namespace,
187 serviceAccount,
188 roleArn,
189 tags: yield* createInternalTags(id),
190 clientRequestToken: yield* toClientRequestToken(id, "assoc"),
191 })
192 .pipe(Effect.catchTag("ResourceInUseException", () => Effect.void));
193 state = yield* findAssociation({
194 clusterName,
195 namespace,
196 serviceAccount,
197 });
198 if (!state) {
199 return yield* Effect.fail(
200 new Error(
201 `PodIdentityAssociation '${namespace}/${serviceAccount}' could not be read after creation`,
202 ),
203 );
204 }
205 } else if (state.roleArn !== roleArn) {
206 yield* eks.updatePodIdentityAssociation({
207 clusterName,
208 associationId: state.associationId,
209 roleArn,
210 clientRequestToken: yield* toClientRequestToken(id, "assoc-update"),
211 });
212 }
213 return {
214 associationArn: state.associationArn,
215 associationId: state.associationId,
216 };
217});
218
219/** Delete the pod identity association; missing is success. */
220export const deleteAssociation = Effect.fn(function* ({
221 clusterName,
222 associationId,
223}: {
224 clusterName: string;
225 associationId: string;
226}) {
227 yield* eks
228 .deletePodIdentityAssociation({
229 clusterName,
230 associationId,
231 })
232 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
233});
234
235/**
236 * Fully delete a pod IAM role: inline policies, managed-policy attachments,
237 * then the role itself. Idempotent throughout.
238 */
239export const deletePodRole = Effect.fn(function* (roleName: string) {
240 yield* iam.listRolePolicies.items({ RoleName: roleName }).pipe(
241 Stream.mapEffect((policyName) =>
242 iam
243 .deleteRolePolicy({
244 RoleName: roleName,
245 PolicyName: policyName,
246 })
247 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)),
248 ),
249 Stream.runDrain,
250 Effect.catchTag("NoSuchEntityException", () => Effect.void),
251 );
252
253 yield* iam.listAttachedRolePolicies.items({ RoleName: roleName }).pipe(
254 Stream.mapEffect((policy) =>
255 iam
256 .detachRolePolicy({
257 RoleName: roleName,
258 PolicyArn: policy.PolicyArn!,
259 })
260 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)),
261 ),
262 Stream.runDrain,
263 Effect.catchTag("NoSuchEntityException", () => Effect.void),
264 );
265
266 yield* iam
267 .deleteRole({ RoleName: roleName })
268 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
269});