1import * as ecs from "@distilled.cloud/aws/ecs";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import type { Input } from "../../Input.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasTags } from "../../Tags.ts";
12import { AWSEnvironment, type AccountID } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14import type { RegionID } from "../Region.ts";
15
16export type CapacityProviderName = string;
17export type CapacityProviderArn =
18 `arn:aws:ecs:${RegionID}:${AccountID}:capacity-provider/${CapacityProviderName}`;
19
20export interface CapacityProviderProps {
21 /**
22 * Capacity provider name. If omitted, a deterministic name is generated.
23 *
24 * Names beginning with `aws`, `ecs`, or `fargate` are reserved by AWS.
25 * Changing this triggers a replacement.
26 */
27 name?: string;
28 /**
29 * ARN of the EC2 Auto Scaling Group that backs this capacity provider.
30 *
31 * Cannot be changed after creation; changing this triggers a replacement.
32 */
33 autoScalingGroupArn: Input<string>;
34 /**
35 * Managed scaling configuration applied by ECS to the underlying ASG.
36 */
37 managedScaling?: ecs.ManagedScaling;
38 /**
39 * Whether ECS protects in-use container instances from ASG scale-in.
40 * @default "DISABLED"
41 */
42 managedTerminationProtection?: ecs.ManagedTerminationProtection;
43 /**
44 * Whether ECS sets container instances to DRAINING when terminated by ASG.
45 * @default "DISABLED"
46 */
47 managedDraining?: ecs.ManagedDraining;
48 /**
49 * User-defined tags to apply to the capacity provider.
50 */
51 tags?: Record<string, string>;
52}
53
54export interface CapacityProvider extends Resource<
55 "AWS.ECS.CapacityProvider",
56 CapacityProviderProps,
57 {
58 /** The ARN of the capacity provider. */
59 capacityProviderArn: CapacityProviderArn;
60 /** The name of the capacity provider. */
61 name: CapacityProviderName;
62 /** The current status, e.g. `ACTIVE`. */
63 status: ecs.CapacityProviderStatus;
64 /** The status of the most recent update to the provider. */
65 updateStatus: ecs.CapacityProviderUpdateStatus | undefined;
66 /** The Auto Scaling group backing the provider. */
67 autoScalingGroupArn: string;
68 /** The managed scaling configuration. */
69 managedScaling: ecs.ManagedScaling | undefined;
70 /** Whether managed termination protection is enabled. */
71 managedTerminationProtection: ecs.ManagedTerminationProtection | undefined;
72 /** Whether managed instance draining is enabled. */
73 managedDraining: ecs.ManagedDraining | undefined;
74 /** The tags attached to the capacity provider. */
75 tags: Record<string, string>;
76 },
77 never,
78 Providers
79> {}
80
81/**
82 * An Amazon ECS capacity provider backed by an EC2 Auto Scaling Group.
83 *
84 * Capacity providers are associated with one or more ECS clusters via
85 * {@link Cluster#capacityProviders} and are referenced by a service or task's
86 * capacity provider strategy.
87 *
88 * Only EC2 Auto Scaling Group-backed capacity providers are currently
89 * supported. The reserved AWS providers `FARGATE` and `FARGATE_SPOT` do not
90 * need to be created and can be referenced by name on a `Cluster` directly.
91 * ### Creating Capacity Providers
92 * **Example:** ASG-Backed Capacity Provider
93 * ```typescript
94 * const provider = yield* CapacityProvider("AppCapacityProvider", {
95 * autoScalingGroupArn: asg.autoScalingGroupArn,
96 * managedScaling: {
97 * status: "ENABLED",
98 * targetCapacity: 80,
99 * minimumScalingStepSize: 1,
100 * maximumScalingStepSize: 10,
101 * },
102 * managedTerminationProtection: "ENABLED",
103 * });
104 *
105 * yield* Cluster("AppCluster", {
106 * capacityProviders: [provider.name],
107 * defaultCapacityProviderStrategy: [
108 * { capacityProvider: provider.name, weight: 1 },
109 * ],
110 * });
111 * ```
112 *
113 * ### Adopting Existing Capacity Providers
114 * Foreign-tagged capacity providers (i.e. providers that exist in AWS but were
115 * not created by this stack/stage/logical-id) are surfaced as `Unowned` by
116 * `read`, and the engine fails with `OwnedBySomeoneElse` unless adoption is
117 * explicitly opted in via `--adopt` or {@link adopt}.
118 * **Example:** Adopt an existing provider
119 * ```typescript
120 * import { adopt } from "alchemy/AdoptPolicy";
121 *
122 * yield* CapacityProvider("AppCapacityProvider", {
123 * name: "existing-provider",
124 * autoScalingGroupArn: asg.autoScalingGroupArn,
125 * }).pipe(adopt());
126 * ```
127 *
128 * @resource
129 */
130export const CapacityProvider = Resource<CapacityProvider>(
131 "AWS.ECS.CapacityProvider",
132);
133
134export const CapacityProviderProvider = () =>
135 Provider.effect(
136 CapacityProvider,
137 Effect.gen(function* () {
138 const toEcsTags = (tags: Record<string, string>): ecs.Tag[] =>
139 Object.entries(tags).map(([key, value]) => ({ key, value }));
140
141 const fromEcsTags = (
142 tags: ecs.Tag[] | undefined,
143 ): Record<string, string> =>
144 Object.fromEntries(
145 (tags ?? [])
146 .filter(
147 (t): t is { key: string; value: string } =>
148 t.key !== undefined && t.value !== undefined,
149 )
150 .map((t) => [t.key, t.value]),
151 );
152
153 const toName = (id: string, props: { name?: string } = {}) =>
154 props.name
155 ? Effect.succeed(props.name)
156 : createPhysicalName({ id, maxLength: 255, lowercase: true });
157
158 const describe = (name: string) =>
159 ecs
160 .describeCapacityProviders({
161 capacityProviders: [name],
162 include: ["TAGS"],
163 })
164 .pipe(
165 Effect.map((res) =>
166 res.capacityProviders?.find((p) => p.name === name),
167 ),
168 );
169
170 return {
171 stables: ["capacityProviderArn", "name"],
172
173 diff: Effect.fn(function* ({ id, news, olds }) {
174 if (!isResolved(news)) return;
175 const oldName = yield* toName(id, olds ?? {});
176 const newName = yield* toName(id, news);
177 if (oldName !== newName) {
178 return { action: "replace" } as const;
179 }
180 if (
181 isResolved(olds.autoScalingGroupArn) &&
182 olds.autoScalingGroupArn !== news.autoScalingGroupArn
183 ) {
184 return { action: "replace" } as const;
185 }
186 }),
187
188 read: Effect.fn(function* ({ id, olds, output }) {
189 const name = output?.name ?? (yield* toName(id, olds ?? {}));
190 const found = yield* describe(name);
191 if (!found?.name || !found.capacityProviderArn) {
192 return undefined;
193 }
194 const internalTags = yield* createInternalTags(id);
195 const existingTags = fromEcsTags(found.tags);
196 const attrs = {
197 capacityProviderArn:
198 found.capacityProviderArn as CapacityProviderArn,
199 name: found.name,
200 status: (found.status ?? "ACTIVE") as ecs.CapacityProviderStatus,
201 updateStatus: found.updateStatus,
202 autoScalingGroupArn:
203 found.autoScalingGroupProvider?.autoScalingGroupArn ?? "",
204 managedScaling: found.autoScalingGroupProvider?.managedScaling,
205 managedTerminationProtection:
206 found.autoScalingGroupProvider?.managedTerminationProtection,
207 managedDraining: found.autoScalingGroupProvider?.managedDraining,
208 tags: existingTags,
209 };
210 return hasTags(internalTags, existingTags) ? attrs : Unowned(attrs);
211 }),
212
213 reconcile: Effect.fn(function* ({ id, news, session }) {
214 const { accountId, region } = yield* AWSEnvironment.current;
215 const name = yield* toName(id, news);
216 const internalTags = yield* createInternalTags(id);
217 const desiredTags = { ...internalTags, ...news.tags };
218 const autoScalingGroupArn = news.autoScalingGroupArn as string;
219
220 // Observe — fetch live cloud state. We fetch fresh on every
221 // reconcile so adoption, drift, and partial-prior-runs all
222 // converge.
223 let existing = yield* describe(name);
224
225 // Ensure — create the capacity provider if missing.
226 if (!existing?.name || !existing.capacityProviderArn) {
227 yield* ecs
228 .createCapacityProvider({
229 name,
230 autoScalingGroupProvider: {
231 autoScalingGroupArn,
232 managedScaling: news.managedScaling,
233 managedTerminationProtection:
234 news.managedTerminationProtection,
235 managedDraining: news.managedDraining,
236 },
237 tags: toEcsTags(desiredTags),
238 })
239 .pipe(
240 // ECS may surface concurrent creation either as
241 // `LimitExceededException` or a generic `ClientException`
242 // with name conflict semantics — re-describe and continue.
243 Effect.catchTag("LimitExceededException", () => Effect.void),
244 );
245 existing = yield* describe(name);
246 }
247
248 const capacityProviderArn = (existing?.capacityProviderArn ??
249 `arn:aws:ecs:${region}:${accountId}:capacity-provider/${name}`) as CapacityProviderArn;
250
251 // Sync managed scaling — observed ↔ desired. ECS
252 // updateCapacityProvider only mutates managed* fields; ASG ARN is
253 // immutable (replacement-triggering, handled in diff).
254 yield* ecs.updateCapacityProvider({
255 name,
256 autoScalingGroupProvider: {
257 managedScaling: news.managedScaling,
258 managedTerminationProtection: news.managedTerminationProtection,
259 managedDraining: news.managedDraining,
260 },
261 });
262
263 // Sync tags — diff observed cloud tags against desired.
264 const observedTags = fromEcsTags(existing?.tags);
265 const { removed, upsert } = diffTags(observedTags, desiredTags);
266 if (upsert.length > 0) {
267 yield* ecs.tagResource({
268 resourceArn: capacityProviderArn,
269 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
270 });
271 }
272 if (removed.length > 0) {
273 yield* ecs.untagResource({
274 resourceArn: capacityProviderArn,
275 tagKeys: removed,
276 });
277 }
278
279 // Re-read to capture status / updateStatus reflecting our changes.
280 const found = yield* describe(name);
281 yield* session.note(capacityProviderArn);
282
283 return {
284 capacityProviderArn,
285 name,
286 status: (found?.status ?? "ACTIVE") as ecs.CapacityProviderStatus,
287 updateStatus: found?.updateStatus,
288 autoScalingGroupArn:
289 found?.autoScalingGroupProvider?.autoScalingGroupArn ??
290 autoScalingGroupArn,
291 managedScaling:
292 found?.autoScalingGroupProvider?.managedScaling ??
293 news.managedScaling,
294 managedTerminationProtection:
295 found?.autoScalingGroupProvider?.managedTerminationProtection ??
296 news.managedTerminationProtection,
297 managedDraining:
298 found?.autoScalingGroupProvider?.managedDraining ??
299 news.managedDraining,
300 tags: desiredTags,
301 };
302 }),
303
304 list: () =>
305 Effect.gen(function* () {
306 // Enumerate every capacity provider in the account/region.
307 // `describeCapacityProviders` with no filter returns all providers
308 // and paginates via `nextToken`; it is a plain operation (no
309 // `.pages`), so drive the pagination with `Stream.paginate`.
310 const all = yield* Stream.paginate(
311 undefined as string | undefined,
312 (token) =>
313 ecs
314 .describeCapacityProviders({
315 include: ["TAGS"],
316 ...(token ? { nextToken: token } : {}),
317 })
318 .pipe(
319 Effect.map(
320 (res) =>
321 [
322 res.capacityProviders ?? [],
323 res.nextToken
324 ? Option.some(res.nextToken)
325 : Option.none<string>(),
326 ] as const,
327 ),
328 ),
329 ).pipe(
330 Stream.runCollect,
331 Effect.map((chunk) => Array.from(chunk)),
332 );
333
334 return all
335 .filter(
336 (
337 p,
338 ): p is ecs.CapacityProvider & {
339 name: string;
340 capacityProviderArn: string;
341 } =>
342 p.name != null &&
343 p.capacityProviderArn != null &&
344 // FARGATE / FARGATE_SPOT are AWS-managed reserved providers
345 // that this resource does not create or own.
346 p.name !== "FARGATE" &&
347 p.name !== "FARGATE_SPOT",
348 )
349 .map((p) => ({
350 capacityProviderArn:
351 p.capacityProviderArn as CapacityProviderArn,
352 name: p.name,
353 status: (p.status ?? "ACTIVE") as ecs.CapacityProviderStatus,
354 updateStatus: p.updateStatus,
355 autoScalingGroupArn:
356 p.autoScalingGroupProvider?.autoScalingGroupArn ?? "",
357 managedScaling: p.autoScalingGroupProvider?.managedScaling,
358 managedTerminationProtection:
359 p.autoScalingGroupProvider?.managedTerminationProtection,
360 managedDraining: p.autoScalingGroupProvider?.managedDraining,
361 tags: fromEcsTags(p.tags),
362 }));
363 }),
364
365 delete: Effect.fn(function* ({ output }) {
366 yield* ecs
367 .deleteCapacityProvider({ capacityProvider: output.name })
368 .pipe(
369 // Already gone — treat as success.
370 Effect.catchTag("InvalidParameterException", () => Effect.void),
371 Effect.catchTag("ClientException", () => Effect.void),
372 );
373 }),
374 };
375 }),
376 );