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 { deepEqual, 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 type { Providers } from "../Providers.ts";
13import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
14
15export interface AddonProps {
16 /**
17 * Target cluster name.
18 */
19 clusterName: Input<string>;
20 /**
21 * Add-on name, such as `metrics-server`.
22 */
23 addonName: string;
24 /**
25 * Optional add-on version. If omitted, EKS chooses the default compatible version.
26 */
27 addonVersion?: string;
28 /**
29 * IAM role ARN used by the add-on's service account.
30 */
31 serviceAccountRoleArn?: Input<string>;
32 /**
33 * Conflict resolution strategy used during create and update.
34 */
35 resolveConflicts?: eks.ResolveConflicts;
36 /**
37 * Optional add-on configuration JSON string.
38 */
39 configurationValues?: string;
40 /**
41 * Optional pod identity associations managed by the add-on.
42 */
43 podIdentityAssociations?: eks.AddonPodIdentityAssociations[];
44 /**
45 * Optional namespace override. Changing this requires replacement.
46 */
47 namespaceConfig?: eks.AddonNamespaceConfigRequest;
48 /**
49 * Preserve the add-on installation when the Alchemy resource is deleted.
50 */
51 preserveOnDelete?: boolean;
52 /**
53 * User-defined tags to apply to the add-on.
54 */
55 tags?: Record<string, string>;
56}
57
58export interface Addon extends Resource<
59 "AWS.EKS.Addon",
60 AddonProps,
61 {
62 /** The ARN of the add-on. */
63 addonArn: string;
64 /** The name of the add-on (e.g. `vpc-cni`, `coredns`). */
65 addonName: string;
66 /** The name of the EKS cluster the add-on is installed on. */
67 clusterName: string;
68 /** The add-on status (e.g. `ACTIVE`, `DEGRADED`). */
69 status: eks.AddonStatus;
70 /** The installed version of the add-on. */
71 addonVersion: string | undefined;
72 /** The IAM role ARN bound to the add-on's service account, if any. */
73 serviceAccountRoleArn: string | undefined;
74 /** The add-on's configuration values (JSON or YAML). */
75 configurationValues: string | undefined;
76 /** The ARNs of the pod identity associations owned by the add-on. */
77 podIdentityAssociations: string[];
78 /** The Kubernetes namespace the add-on is installed in. */
79 namespace: string | undefined;
80 /** The publisher of the add-on. */
81 publisher: string | undefined;
82 /** The owner of the add-on. */
83 owner: string | undefined;
84 /** The tags applied to the add-on. */
85 tags: Record<string, string>;
86 /** Health issues currently reported for the add-on. */
87 healthIssues: eks.AddonIssue[];
88 },
89 never,
90 Providers
91> {}
92
93/**
94 * An Amazon EKS managed add-on installed on a cluster.
95 *
96 * `Addon` is intended for optional managed add-ons. On Auto Mode clusters, many
97 * core components are already provided by AWS and do not need to be modeled as
98 * explicit add-on resources.
99 * ### Managing Add-ons
100 * **Example:** Install Metrics Server
101 * ```typescript
102 * const metricsServer = yield* Addon("MetricsServer", {
103 * clusterName: cluster.clusterName,
104 * addonName: "metrics-server",
105 * });
106 * ```
107 *
108 * @resource
109 */
110export const Addon = Resource<Addon>("AWS.EKS.Addon");
111
112const normalizeTags = (tags: Record<string, string | undefined> | undefined) =>
113 Object.fromEntries(
114 Object.entries(tags ?? {}).filter(
115 (entry): entry is [string, string] => entry[1] !== undefined,
116 ),
117 );
118
119const mapAddon = (addon: eks.Addon) => ({
120 addonArn: addon.addonArn!,
121 addonName: addon.addonName!,
122 clusterName: addon.clusterName!,
123 status: addon.status ?? "CREATING",
124 addonVersion: addon.addonVersion,
125 serviceAccountRoleArn: addon.serviceAccountRoleArn,
126 configurationValues: addon.configurationValues,
127 podIdentityAssociations: addon.podIdentityAssociations ?? [],
128 namespace: addon.namespaceConfig?.namespace,
129 publisher: addon.publisher,
130 owner: addon.owner,
131 tags: normalizeTags(addon.tags),
132 healthIssues: addon.health?.issues ?? [],
133});
134
135const readAddon = Effect.fn(function* ({
136 clusterName,
137 addonName,
138}: {
139 clusterName: string;
140 addonName: string;
141}) {
142 const response = yield* eks
143 .describeAddon({
144 clusterName,
145 addonName,
146 })
147 .pipe(
148 Effect.catchTag("ResourceNotFoundException", () =>
149 Effect.succeed(undefined),
150 ),
151 );
152
153 const addon = response?.addon;
154 if (!addon?.addonArn || !addon.addonName || !addon.clusterName) {
155 return undefined;
156 }
157
158 return mapAddon(addon);
159});
160
161class AddonNotReady extends Data.TaggedError("AddonNotReady")<{
162 readonly clusterName: string;
163 readonly addonName: string;
164 readonly status: string | undefined;
165}> {
166 override get message(): string {
167 return `addon '${this.clusterName}/${this.addonName}' is not ACTIVE (status: ${this.status ?? "absent"})`;
168 }
169}
170
171class AddonStillExists extends Data.TaggedError("AddonStillExists")<{
172 readonly clusterName: string;
173 readonly addonName: string;
174}> {}
175export const AddonProvider = () =>
176 Provider.effect(
177 Addon,
178 Effect.gen(function* () {
179 const toClientRequestToken = (id: string, action: string) =>
180 createPhysicalName({
181 id: `${id}-${action}`,
182 maxLength: 64,
183 delimiter: "-",
184 });
185
186 return {
187 stables: ["addonArn"],
188 // Add-ons are keyed by (clusterName, addonName) and `listAddons`
189 // requires a cluster. Enumerate every cluster, list its add-ons,
190 // then hydrate each via `describeAddon` to produce full Attributes.
191 list: () =>
192 Effect.gen(function* () {
193 const clusterNames = yield* eks.listClusters.pages({}).pipe(
194 Stream.runCollect,
195 Effect.map((chunk) =>
196 Array.from(chunk).flatMap((page) => page.clusters ?? []),
197 ),
198 );
199
200 const perCluster = yield* Effect.forEach(
201 clusterNames,
202 (clusterName) =>
203 eks.listAddons.pages({ clusterName }).pipe(
204 Stream.runCollect,
205 Effect.map((chunk) =>
206 Array.from(chunk).flatMap((page) => page.addons ?? []),
207 ),
208 Effect.flatMap((addonNames) =>
209 Effect.forEach(
210 addonNames,
211 (addonName) => readAddon({ clusterName, addonName }),
212 { concurrency: 5 },
213 ),
214 ),
215 ),
216 { concurrency: 5 },
217 );
218
219 return perCluster
220 .flat()
221 .filter(
222 (addon): addon is NonNullable<typeof addon> =>
223 addon !== undefined,
224 );
225 }),
226 diff: Effect.fn(function* ({ olds, news }) {
227 if (!isResolved(news)) return;
228 if (olds.clusterName !== news.clusterName) {
229 return { action: "replace" } as const;
230 }
231
232 if (olds.addonName !== news.addonName) {
233 return { action: "replace" } as const;
234 }
235
236 if (!deepEqual(olds.namespaceConfig, news.namespaceConfig)) {
237 return { action: "replace" } as const;
238 }
239 }),
240 read: Effect.fn(function* ({ id, olds }) {
241 const clusterName = olds.clusterName as string | undefined;
242 // A crashed prior run can persist a row before its unresolved
243 // inputs were stripped — nothing observable yet.
244 if (clusterName === undefined || olds.addonName === undefined) {
245 return undefined;
246 }
247 const state = yield* readAddon({
248 clusterName,
249 addonName: olds.addonName,
250 });
251 if (!state) return undefined;
252 return (yield* hasAlchemyTags(id, state.tags))
253 ? state
254 : Unowned(state);
255 }),
256 reconcile: Effect.fn(function* ({ id, news, session }) {
257 const clusterName = news.clusterName as string;
258 const addonName = news.addonName;
259 const desiredTags = {
260 ...(yield* createInternalTags(id)),
261 ...news.tags,
262 };
263
264 // Observe — fetch live cloud state by (clusterName, addonName).
265 let state = yield* readAddon({
266 clusterName,
267 addonName,
268 });
269
270 // Ensure — create if missing. Tolerate `ResourceInUseException`
271 // as a race with a peer reconciler: re-read and continue with
272 // sync.
273 if (!state) {
274 yield* eks
275 .createAddon({
276 clusterName,
277 addonName,
278 addonVersion: news.addonVersion,
279 serviceAccountRoleArn: news.serviceAccountRoleArn as
280 | string
281 | undefined,
282 resolveConflicts: news.resolveConflicts,
283 configurationValues: news.configurationValues,
284 podIdentityAssociations: news.podIdentityAssociations,
285 namespaceConfig: news.namespaceConfig,
286 tags: desiredTags,
287 clientRequestToken: yield* toClientRequestToken(id, "create"),
288 })
289 .pipe(
290 Effect.catchTag("ResourceInUseException", () => Effect.void),
291 );
292
293 state = yield* waitForAddonActive({
294 clusterName,
295 addonName,
296 });
297 }
298
299 // Sync addon version / config — diff observed against desired.
300 // updateAddon handles version, role, resolve conflicts, config
301 // values, and pod identity associations atomically.
302 const podIdentityChanged =
303 JSON.stringify(state.podIdentityAssociations ?? []) !==
304 JSON.stringify(news.podIdentityAssociations ?? []);
305 if (
306 (news.addonVersion !== undefined &&
307 state.addonVersion !== news.addonVersion) ||
308 state.serviceAccountRoleArn !==
309 (news.serviceAccountRoleArn as string | undefined) ||
310 state.configurationValues !== news.configurationValues ||
311 podIdentityChanged
312 ) {
313 yield* eks.updateAddon({
314 clusterName,
315 addonName,
316 addonVersion: news.addonVersion,
317 serviceAccountRoleArn: news.serviceAccountRoleArn as
318 | string
319 | undefined,
320 resolveConflicts: news.resolveConflicts,
321 configurationValues: news.configurationValues,
322 podIdentityAssociations: news.podIdentityAssociations,
323 clientRequestToken: yield* toClientRequestToken(id, "update"),
324 });
325 state = yield* waitForAddonActive({
326 clusterName,
327 addonName,
328 });
329 }
330
331 // Sync tags — diff observed cloud tags against desired.
332 const { removed, upsert } = diffTags(state.tags, desiredTags);
333 if (upsert.length > 0) {
334 yield* eks.tagResource({
335 resourceArn: state.addonArn,
336 tags: Object.fromEntries(
337 upsert.map((tag) => [tag.Key, tag.Value] as const),
338 ),
339 });
340 }
341 if (removed.length > 0) {
342 yield* eks.untagResource({
343 resourceArn: state.addonArn,
344 tagKeys: removed,
345 });
346 }
347
348 yield* session.note(state.addonArn);
349 return state;
350 }),
351 delete: Effect.fn(function* ({ olds, output }) {
352 yield* eks
353 .deleteAddon({
354 clusterName: output.clusterName,
355 addonName: output.addonName,
356 preserve: olds.preserveOnDelete,
357 })
358 .pipe(
359 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
360 );
361
362 if (!olds.preserveOnDelete) {
363 yield* waitForAddonDeleted({
364 clusterName: output.clusterName,
365 addonName: output.addonName,
366 });
367 }
368 }),
369 };
370 }),
371 );
372
373const waitForAddonActive = Effect.fn(function* ({
374 clusterName,
375 addonName,
376}: {
377 clusterName: string;
378 addonName: string;
379}) {
380 return yield* readAddon({
381 clusterName,
382 addonName,
383 }).pipe(
384 Effect.flatMap((addon) => {
385 if (!addon) {
386 return Effect.fail(
387 new AddonNotReady({
388 clusterName,
389 addonName,
390 status: undefined,
391 }),
392 );
393 }
394
395 switch (addon.status) {
396 case "ACTIVE":
397 return Effect.succeed(addon);
398 case "CREATE_FAILED":
399 case "UPDATE_FAILED":
400 case "DELETE_FAILED":
401 return Effect.fail(
402 new Error(
403 `Addon '${clusterName}/${addonName}' entered terminal status '${addon.status}'`,
404 ),
405 );
406 default:
407 return Effect.fail(
408 new AddonNotReady({
409 clusterName,
410 addonName,
411 status: addon.status,
412 }),
413 );
414 }
415 }),
416 Effect.retry({
417 while: (error) => error instanceof AddonNotReady,
418 // Flat 5s polls, ~20 min budget (uncapped exponential sleeps for
419 // multi-minute stretches late in the wait — looks like a deadlock).
420 // Node-bound addons (e.g. HyperPod task governance) stay DEGRADED
421 // until nodes join and pull images, which can take most of the
422 // budget when the addon installs alongside its node group.
423 schedule: Schedule.max([
424 Schedule.spaced("5 seconds"),
425 Schedule.recurs(240),
426 ]),
427 }),
428 );
429});
430
431const waitForAddonDeleted = Effect.fn(function* ({
432 clusterName,
433 addonName,
434}: {
435 clusterName: string;
436 addonName: string;
437}) {
438 yield* readAddon({
439 clusterName,
440 addonName,
441 }).pipe(
442 Effect.flatMap((addon) =>
443 addon
444 ? Effect.fail(new AddonStillExists({ clusterName, addonName }))
445 : Effect.void,
446 ),
447 Effect.retry({
448 while: (error) => error instanceof AddonStillExists,
449 // Flat 5s polls, ~10 min budget (uncapped exponential sleeps for
450 // multi-minute stretches late in the wait — looks like a deadlock).
451 schedule: Schedule.max([
452 Schedule.spaced("5 seconds"),
453 Schedule.recurs(120),
454 ]),
455 }),
456 );
457});