1import * as eks from "@distilled.cloud/aws/eks";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import type { Input } from "../../Input.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14
15export interface FargateProfileProps {
16 /**
17 * Name of the EKS cluster that owns this Fargate profile.
18 */
19 clusterName: Input<string>;
20 /**
21 * Name of the Fargate profile. If omitted, a unique name is generated.
22 */
23 fargateProfileName?: string;
24 /**
25 * ARN of the pod execution IAM role that Fargate pods assume. The role must
26 * trust `eks-fargate-pods.amazonaws.com`. Changing this replaces the profile.
27 */
28 podExecutionRoleArn: Input<string>;
29 /**
30 * Selectors (namespace + optional labels) that decide which pods run on
31 * Fargate. Changing this replaces the profile.
32 */
33 selectors: eks.FargateProfileSelector[];
34 /**
35 * Subnet IDs to run Fargate pods in. **Fargate pods require PRIVATE subnets
36 * only** — a subnet that auto-assigns a public IP is rejected by EKS. Changing
37 * this replaces the profile.
38 */
39 subnets?: Input<string>[];
40 /**
41 * User-defined tags to apply to the Fargate profile.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface FargateProfile extends Resource<
47 "AWS.EKS.FargateProfile",
48 FargateProfileProps,
49 {
50 /** The name of the Fargate profile. */
51 fargateProfileName: string;
52 /** The ARN of the Fargate profile. */
53 fargateProfileArn: string;
54 /** The name of the EKS cluster the profile belongs to. */
55 clusterName: string;
56 /** The profile status (e.g. `CREATING`, `ACTIVE`). */
57 status: eks.FargateProfileStatus;
58 /** The ARN of the pod execution role used by pods matched by the profile. */
59 podExecutionRoleArn: string;
60 /** The IDs of the (private) subnets pods are launched into. */
61 subnets: string[];
62 /** The namespace/label selectors that route pods onto Fargate. */
63 selectors: eks.FargateProfileSelector[];
64 /** The tags applied to the Fargate profile. */
65 tags: Record<string, string>;
66 },
67 never,
68 Providers
69> {}
70
71/**
72 * An Amazon EKS Fargate profile — declares which pods (by namespace + labels)
73 * run on AWS Fargate serverless compute instead of on EC2 nodes.
74 *
75 * Fargate profiles are immutable except for tags: any change to selectors, the
76 * pod execution role, or subnets forces a replacement. Create and delete are
77 * asynchronous (`CREATING` → `ACTIVE`, `DELETING` → gone, ~1–2 min each) and the
78 * provider waits for the terminal state. EKS allows only one Fargate profile per
79 * cluster to be creating or deleting at a time, so the provider retries the
80 * `ResourceInUseException` that surfaces when a peer profile operation is in
81 * flight.
82 *
83 * **Fargate pods must run in private subnets** — pass private subnet IDs only.
84 * ### Creating Fargate Profiles
85 * **Example:** Run the `default` Namespace on Fargate
86 * ```typescript
87 * const profile = yield* FargateProfile("DefaultFargate", {
88 * clusterName: cluster.clusterName,
89 * podExecutionRoleArn: podRole.roleArn,
90 * subnets: network.privateSubnetIds,
91 * selectors: [{ namespace: "default" }],
92 * });
93 * ```
94 *
95 * **Example:** Select Pods by Namespace and Labels
96 * ```typescript
97 * const profile = yield* FargateProfile("BatchFargate", {
98 * clusterName: cluster.clusterName,
99 * podExecutionRoleArn: podRole.roleArn,
100 * subnets: network.privateSubnetIds,
101 * selectors: [
102 * { namespace: "batch", labels: { compute: "fargate" } },
103 * ],
104 * });
105 * ```
106 *
107 * @resource
108 */
109export const FargateProfile = Resource<FargateProfile>(
110 "AWS.EKS.FargateProfile",
111);
112
113class FargateProfileNotReady extends Data.TaggedError(
114 "EKS.FargateProfileNotReady",
115)<{
116 status: eks.FargateProfileStatus | undefined;
117}> {}
118
119class FargateProfileStillExists extends Data.TaggedError(
120 "EKS.FargateProfileStillExists",
121)<{}> {}
122
123class FargateProfileBusy extends Data.TaggedError(
124 "EKS.FargateProfileBusy",
125)<{}> {}
126
127const normalizeTags = (tags: Record<string, string | undefined> | undefined) =>
128 Object.fromEntries(
129 Object.entries(tags ?? {}).filter(
130 (entry): entry is [string, string] => entry[1] !== undefined,
131 ),
132 );
133
134// ~10 min at 5s spacing — Fargate profile transitions complete in 1–2 min.
135const waitSchedule = Schedule.max([
136 Schedule.spaced("5 seconds"),
137 Schedule.recurs(120),
138]);
139
140// One profile per cluster may be creating/deleting at a time; back off on the
141// ResourceInUseException that peer operations raise (bounded).
142const busySchedule = Schedule.max([
143 Schedule.spaced("10 seconds"),
144 Schedule.recurs(30),
145]);
146
147const mapFargateProfile = (
148 profile: eks.FargateProfile,
149 tags: Record<string, string>,
150): FargateProfile["Attributes"] => ({
151 fargateProfileName: profile.fargateProfileName!,
152 fargateProfileArn: profile.fargateProfileArn!,
153 clusterName: profile.clusterName!,
154 status: profile.status ?? "CREATING",
155 podExecutionRoleArn: profile.podExecutionRoleArn!,
156 subnets: profile.subnets ?? [],
157 selectors: profile.selectors ?? [],
158 tags,
159});
160
161export const FargateProfileProvider = () =>
162 Provider.effect(
163 FargateProfile,
164 Effect.gen(function* () {
165 const toProfileName = (
166 id: string,
167 props: { fargateProfileName?: string } = {},
168 ) =>
169 props.fargateProfileName
170 ? Effect.succeed(props.fargateProfileName)
171 : createPhysicalName({ id, maxLength: 63 });
172
173 const toClientRequestToken = (id: string, action: string) =>
174 createPhysicalName({
175 id: `${id}-${action}`,
176 maxLength: 64,
177 delimiter: "-",
178 });
179
180 const readProfile = Effect.fn(function* ({
181 clusterName,
182 fargateProfileName,
183 }: {
184 clusterName: string;
185 fargateProfileName: string;
186 }) {
187 const described = yield* eks
188 .describeFargateProfile({ clusterName, fargateProfileName })
189 .pipe(
190 Effect.catchTag("ResourceNotFoundException", () =>
191 Effect.succeed(undefined),
192 ),
193 );
194 const profile = described?.fargateProfile;
195 if (
196 !profile?.fargateProfileArn ||
197 !profile.fargateProfileName ||
198 !profile.clusterName ||
199 !profile.podExecutionRoleArn
200 ) {
201 return undefined;
202 }
203 return mapFargateProfile(profile, normalizeTags(profile.tags));
204 });
205
206 const waitForProfileActive = (
207 clusterName: string,
208 fargateProfileName: string,
209 ) =>
210 readProfile({ clusterName, fargateProfileName }).pipe(
211 Effect.flatMap((state) => {
212 if (!state) {
213 return Effect.fail(
214 new FargateProfileNotReady({ status: undefined }),
215 );
216 }
217 if (state.status === "ACTIVE") {
218 return Effect.succeed(state);
219 }
220 if (
221 state.status === "CREATE_FAILED" ||
222 state.status === "DELETE_FAILED"
223 ) {
224 return Effect.fail(
225 new Error(
226 `EKS Fargate profile '${fargateProfileName}' entered ${state.status}`,
227 ),
228 );
229 }
230 return Effect.fail(
231 new FargateProfileNotReady({ status: state.status }),
232 );
233 }),
234 Effect.retry({
235 while: (error) => error instanceof FargateProfileNotReady,
236 schedule: waitSchedule,
237 }),
238 );
239
240 const waitForProfileDeleted = (
241 clusterName: string,
242 fargateProfileName: string,
243 ) =>
244 readProfile({ clusterName, fargateProfileName }).pipe(
245 Effect.flatMap((state) =>
246 state
247 ? Effect.fail(new FargateProfileStillExists())
248 : Effect.succeed(undefined),
249 ),
250 Effect.retry({
251 while: (error) => error instanceof FargateProfileStillExists,
252 schedule: waitSchedule,
253 }),
254 );
255
256 return {
257 stables: ["fargateProfileArn", "fargateProfileName", "clusterName"],
258 // Enumerate every Fargate profile across the account/region.
259 // `listFargateProfiles` is cluster-scoped, so first enumerate all
260 // clusters, list each cluster's profiles, then hydrate via
261 // `describeFargateProfile`.
262 list: () =>
263 Effect.gen(function* () {
264 const clusterNames = yield* eks.listClusters.pages({}).pipe(
265 Stream.runCollect,
266 Effect.map((chunk) =>
267 Array.from(chunk).flatMap((page) => page.clusters ?? []),
268 ),
269 );
270
271 const perCluster = yield* Effect.forEach(
272 clusterNames,
273 (clusterName) =>
274 eks.listFargateProfiles.pages({ clusterName }).pipe(
275 Stream.runCollect,
276 Effect.map((chunk) =>
277 Array.from(chunk).flatMap(
278 (page) => page.fargateProfileNames ?? [],
279 ),
280 ),
281 Effect.flatMap((names) =>
282 Effect.forEach(
283 names,
284 (fargateProfileName) =>
285 readProfile({ clusterName, fargateProfileName }),
286 { concurrency: 5 },
287 ),
288 ),
289 ),
290 { concurrency: 5 },
291 );
292
293 return perCluster
294 .flat()
295 .filter(
296 (state): state is FargateProfile["Attributes"] =>
297 state !== undefined,
298 );
299 }),
300 diff: Effect.fn(function* ({
301 id,
302 olds = {} as FargateProfileProps,
303 news,
304 }) {
305 if (!isResolved(news)) return;
306 if (
307 (yield* toProfileName(id, olds)) !==
308 (yield* toProfileName(id, news ?? {}))
309 ) {
310 return { action: "replace" } as const;
311 }
312 if (olds.clusterName !== news.clusterName) {
313 return { action: "replace" } as const;
314 }
315 if (olds.podExecutionRoleArn !== news.podExecutionRoleArn) {
316 return { action: "replace" } as const;
317 }
318 if (
319 JSON.stringify(olds.subnets ?? []) !==
320 JSON.stringify(news.subnets ?? [])
321 ) {
322 return { action: "replace" } as const;
323 }
324 if (
325 JSON.stringify(olds.selectors ?? []) !==
326 JSON.stringify(news.selectors ?? [])
327 ) {
328 return { action: "replace" } as const;
329 }
330 }),
331 read: Effect.fn(function* ({ id, olds, output }) {
332 const clusterName = (output?.clusterName ??
333 (olds?.clusterName as string | undefined)) as string | undefined;
334 if (!clusterName) return undefined;
335 const fargateProfileName =
336 output?.fargateProfileName ??
337 (yield* toProfileName(id, olds ?? {}));
338 const state = yield* readProfile({ clusterName, fargateProfileName });
339 if (!state) return undefined;
340 return (yield* hasAlchemyTags(id, state.tags))
341 ? state
342 : Unowned(state);
343 }),
344 reconcile: Effect.fn(function* ({ id, news, output, session }) {
345 const clusterName = news.clusterName as string;
346 const fargateProfileName = yield* toProfileName(id, news);
347 const desiredTags = {
348 ...(yield* createInternalTags(id)),
349 ...news.tags,
350 };
351
352 // Observe — cloud state is authoritative.
353 let state = yield* readProfile({ clusterName, fargateProfileName });
354
355 // Ensure — create if missing. A create raises ResourceInUseException
356 // if another profile on the cluster is creating/deleting; back off
357 // and retry. Tolerate a create race with a peer reconciler.
358 if (!state) {
359 yield* eks
360 .createFargateProfile({
361 clusterName,
362 fargateProfileName,
363 podExecutionRoleArn: news.podExecutionRoleArn as string,
364 selectors: news.selectors,
365 subnets: news.subnets as string[] | undefined,
366 tags: desiredTags,
367 clientRequestToken: yield* toClientRequestToken(id, "create"),
368 })
369 .pipe(
370 Effect.catchTag("ResourceInUseException", () =>
371 Effect.fail(new FargateProfileBusy()),
372 ),
373 Effect.retry({
374 while: (error) => error instanceof FargateProfileBusy,
375 schedule: busySchedule,
376 }),
377 Effect.catchIf(
378 (error) => error instanceof FargateProfileBusy,
379 () => Effect.void,
380 ),
381 );
382
383 yield* session.note(
384 `Creating EKS Fargate profile ${fargateProfileName}...`,
385 );
386 state = yield* waitForProfileActive(
387 clusterName,
388 fargateProfileName,
389 );
390 }
391
392 // Sync tags — the only mutable aspect. Diff observed cloud tags
393 // against desired.
394 const { removed, upsert } = diffTags(state.tags, desiredTags);
395 if (upsert.length > 0) {
396 yield* eks.tagResource({
397 resourceArn: state.fargateProfileArn,
398 tags: Object.fromEntries(
399 upsert.map((tag) => [tag.Key, tag.Value] as const),
400 ),
401 });
402 }
403 if (removed.length > 0) {
404 yield* eks.untagResource({
405 resourceArn: state.fargateProfileArn,
406 tagKeys: removed,
407 });
408 }
409
410 yield* session.note(state.fargateProfileArn);
411
412 const final = yield* readProfile({ clusterName, fargateProfileName });
413 if (!final) {
414 return yield* Effect.fail(
415 new Error(
416 `EKS Fargate profile '${fargateProfileName}' could not be read after reconcile`,
417 ),
418 );
419 }
420 return final;
421 }),
422 delete: Effect.fn(function* ({ output }) {
423 yield* eks
424 .deleteFargateProfile({
425 clusterName: output.clusterName,
426 fargateProfileName: output.fargateProfileName,
427 })
428 .pipe(
429 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
430 // A concurrent profile create/delete on the cluster raises
431 // ResourceInUseException — back off and retry the delete.
432 Effect.catchTag("ResourceInUseException", () =>
433 Effect.fail(new FargateProfileBusy()),
434 ),
435 Effect.retry({
436 while: (error) => error instanceof FargateProfileBusy,
437 schedule: busySchedule,
438 }),
439 Effect.catchIf(
440 (error) => error instanceof FargateProfileBusy,
441 () => Effect.void,
442 ),
443 );
444 yield* waitForProfileDeleted(
445 output.clusterName,
446 output.fargateProfileName,
447 );
448 }),
449 };
450 }),
451 );