1import * as aas from "@distilled.cloud/aws/application-auto-scaling";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { diffTags, hasAlchemyTags, createInternalTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import { SERVICE_NAMESPACES } from "./internal.ts";
11
12export interface ScalableTargetProps {
13 /**
14 * The namespace of the AWS service that provides the resource,
15 * e.g. `ecs`, `dynamodb`, `lambda`.
16 *
17 * Changing this triggers a replacement.
18 */
19 serviceNamespace: aas.ServiceNamespace;
20 /**
21 * The identifier of the resource to scale, in the format the service
22 * expects — e.g. `service/{clusterName}/{serviceName}` for an ECS service
23 * or `table/{tableName}` for a DynamoDB table.
24 *
25 * Changing this triggers a replacement.
26 */
27 resourceId: string;
28 /**
29 * The scalable dimension of the resource,
30 * e.g. `ecs:service:DesiredCount` or `dynamodb:table:ReadCapacityUnits`.
31 *
32 * Changing this triggers a replacement.
33 */
34 scalableDimension: aas.ScalableDimension;
35 /**
36 * The minimum capacity that Application Auto Scaling may scale in to.
37 */
38 minCapacity: number;
39 /**
40 * The maximum capacity that Application Auto Scaling may scale out to.
41 */
42 maxCapacity: number;
43 /**
44 * IAM role that allows Application Auto Scaling to modify the scalable
45 * target on your behalf. For services that support service-linked roles
46 * (ECS, DynamoDB, Lambda, ...) omit this — AWS creates and uses the
47 * service-linked role automatically.
48 */
49 roleArn?: string;
50 /**
51 * Suspend/resume the target's dynamic and scheduled scaling activities.
52 */
53 suspendedState?: aas.SuspendedState;
54 /**
55 * User-defined tags to apply to the scalable target.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface ScalableTarget extends Resource<
61 "AWS.ApplicationAutoScaling.ScalableTarget",
62 ScalableTargetProps,
63 {
64 /**
65 * Namespace of the AWS service that provides the resource.
66 */
67 serviceNamespace: aas.ServiceNamespace;
68 /**
69 * Identifier of the scaled resource.
70 */
71 resourceId: string;
72 /**
73 * Scalable dimension being managed.
74 */
75 scalableDimension: aas.ScalableDimension;
76 /**
77 * ARN of the scalable target.
78 */
79 scalableTargetArn: string;
80 /**
81 * Minimum capacity Application Auto Scaling may scale in to.
82 */
83 minCapacity: number;
84 /**
85 * Maximum capacity Application Auto Scaling may scale out to.
86 */
87 maxCapacity: number;
88 /**
89 * IAM role Application Auto Scaling uses to modify the target
90 * (service-linked role when none was supplied).
91 */
92 roleArn: string;
93 /**
94 * Suspension state of dynamic and scheduled scaling activities.
95 */
96 suspendedState: aas.SuspendedState | undefined;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An Application Auto Scaling scalable target — registers a resource's
104 * capacity dimension (an ECS service's desired count, a DynamoDB table's
105 * read capacity, Lambda provisioned concurrency, ...) so that scaling
106 * policies and scheduled actions can act on it.
107 *
108 * A scalable target is uniquely identified by the
109 * (`serviceNamespace`, `resourceId`, `scalableDimension`) triple; changing
110 * any part of the triple replaces the target. Deregistering a scalable
111 * target deletes the scaling policies and scheduled actions associated
112 * with it.
113 * ### Creating Scalable Targets
114 * **Example:** Scale an ECS Service
115 * ```typescript
116 * const target = yield* ScalableTarget("ApiScaling", {
117 * serviceNamespace: "ecs",
118 * resourceId: Output.interpolate`service/${cluster.clusterName}/${service.serviceName}`,
119 * scalableDimension: "ecs:service:DesiredCount",
120 * minCapacity: 1,
121 * maxCapacity: 3,
122 * });
123 * ```
124 *
125 * **Example:** Scale DynamoDB Read Capacity
126 * ```typescript
127 * const target = yield* ScalableTarget("TableReadScaling", {
128 * serviceNamespace: "dynamodb",
129 * resourceId: Output.interpolate`table/${table.tableName}`,
130 * scalableDimension: "dynamodb:table:ReadCapacityUnits",
131 * minCapacity: 1,
132 * maxCapacity: 10,
133 * });
134 * ```
135 *
136 * ### Suspending Scaling
137 * **Example:** Suspend Dynamic Scale-In
138 * ```typescript
139 * yield* ScalableTarget("ApiScaling", {
140 * serviceNamespace: "ecs",
141 * resourceId: Output.interpolate`service/${cluster.clusterName}/${service.serviceName}`,
142 * scalableDimension: "ecs:service:DesiredCount",
143 * minCapacity: 1,
144 * maxCapacity: 3,
145 * suspendedState: { DynamicScalingInSuspended: true },
146 * });
147 * ```
148 *
149 * @resource
150 */
151export const ScalableTarget = Resource<ScalableTarget>(
152 "AWS.ApplicationAutoScaling.ScalableTarget",
153);
154
155export const ScalableTargetProvider = () =>
156 Provider.effect(
157 ScalableTarget,
158 Effect.gen(function* () {
159 const describe = (props: {
160 serviceNamespace: aas.ServiceNamespace;
161 resourceId: string;
162 scalableDimension: aas.ScalableDimension;
163 }) =>
164 aas
165 .describeScalableTargets({
166 ServiceNamespace: props.serviceNamespace,
167 ResourceIds: [props.resourceId],
168 ScalableDimension: props.scalableDimension,
169 })
170 .pipe(
171 Effect.map((res) =>
172 res.ScalableTargets?.find(
173 (t) =>
174 t.ResourceId === props.resourceId &&
175 t.ScalableDimension === props.scalableDimension,
176 ),
177 ),
178 );
179
180 const toAttributes = (
181 target: aas.ScalableTarget,
182 ): ScalableTarget["Attributes"] => ({
183 serviceNamespace: target.ServiceNamespace,
184 resourceId: target.ResourceId,
185 scalableDimension: target.ScalableDimension,
186 scalableTargetArn: target.ScalableTargetARN ?? "",
187 minCapacity: target.MinCapacity,
188 maxCapacity: target.MaxCapacity,
189 roleArn: target.RoleARN,
190 suspendedState: target.SuspendedState,
191 });
192
193 const observedTags = (scalableTargetArn: string) =>
194 aas.listTagsForResource({ ResourceARN: scalableTargetArn }).pipe(
195 Effect.map((res) =>
196 Object.fromEntries(
197 Object.entries(res.Tags ?? {}).filter(
198 (entry): entry is [string, string] => entry[1] !== undefined,
199 ),
200 ),
201 ),
202 Effect.catchTag("ResourceNotFoundException", () =>
203 Effect.succeed({} as Record<string, string>),
204 ),
205 );
206
207 // Derive the (namespace, resourceId, dimension) identity triple.
208 // `output` is preferred (it always survives state persistence);
209 // props may have lost Output-valued fields in a `creating`-state
210 // round-trip, so tolerate undefined and report "unknown identity".
211 const identityOf = (
212 output:
213 | Pick<
214 ScalableTarget["Attributes"],
215 "serviceNamespace" | "resourceId" | "scalableDimension"
216 >
217 | undefined,
218 props: Partial<ScalableTargetProps> | undefined,
219 ) => {
220 const serviceNamespace =
221 output?.serviceNamespace ?? props?.serviceNamespace;
222 const resourceId = output?.resourceId ?? props?.resourceId;
223 const scalableDimension =
224 output?.scalableDimension ?? props?.scalableDimension;
225 return serviceNamespace !== undefined &&
226 resourceId !== undefined &&
227 scalableDimension !== undefined
228 ? { serviceNamespace, resourceId, scalableDimension }
229 : undefined;
230 };
231
232 return {
233 stables: [
234 "serviceNamespace",
235 "resourceId",
236 "scalableDimension",
237 "scalableTargetArn",
238 ],
239
240 // Account/region-wide enumeration. The describe API requires a
241 // `ServiceNamespace` filter, so union the per-namespace pages over
242 // every known namespace.
243 list: () =>
244 Effect.forEach(
245 SERVICE_NAMESPACES,
246 (namespace) =>
247 aas.describeScalableTargets
248 .pages({ ServiceNamespace: namespace })
249 .pipe(
250 Stream.runCollect,
251 Effect.map((chunk) =>
252 Array.from(chunk).flatMap((page) =>
253 (page.ScalableTargets ?? []).map(toAttributes),
254 ),
255 ),
256 ),
257 { concurrency: 4 },
258 ).pipe(Effect.map((groups) => groups.flat())),
259
260 diff: Effect.fn(function* ({ news, olds }) {
261 if (!isResolved(news)) return undefined;
262 // The identity triple is immutable — any change replaces the
263 // target. Only compare sides that are actually known: a
264 // half-created state row may have lost Output-valued props.
265 for (const key of [
266 "serviceNamespace",
267 "resourceId",
268 "scalableDimension",
269 ] as const) {
270 const oldValue = olds?.[key];
271 if (
272 oldValue !== undefined &&
273 isResolved(oldValue) &&
274 oldValue !== news[key]
275 ) {
276 return { action: "replace" } as const;
277 }
278 }
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const identity = identityOf(output, olds);
283 if (identity === undefined) return undefined;
284 const found = yield* describe(identity);
285 if (!found) return undefined;
286 const attrs = toAttributes(found);
287 const tags = found.ScalableTargetARN
288 ? yield* observedTags(found.ScalableTargetARN)
289 : {};
290 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, session }) {
294 const identity = {
295 serviceNamespace: news.serviceNamespace,
296 resourceId: news.resourceId,
297 scalableDimension: news.scalableDimension,
298 };
299 const internalTags = yield* createInternalTags(id);
300 const desiredTags = { ...news.tags, ...internalTags };
301
302 // Observe — cloud state is authoritative.
303 const existing = yield* describe(identity);
304
305 // Ensure + sync capacity — `registerScalableTarget` is an upsert
306 // (a put keyed by the identity triple), so one call both creates
307 // a missing target and converges min/max/role/suspendedState on
308 // an existing one. Tags in the request only apply on creation;
309 // existing targets are converged by the tag sync below.
310 yield* aas.registerScalableTarget({
311 ServiceNamespace: identity.serviceNamespace,
312 ResourceId: identity.resourceId,
313 ScalableDimension: identity.scalableDimension,
314 MinCapacity: news.minCapacity,
315 MaxCapacity: news.maxCapacity,
316 RoleARN: news.roleArn,
317 SuspendedState: news.suspendedState,
318 ...(existing === undefined ? { Tags: desiredTags } : {}),
319 });
320
321 // Re-observe to pick up the generated ARN and the service-linked
322 // role AWS resolved.
323 const found = yield* describe(identity);
324 const scalableTargetArn =
325 found?.ScalableTargetARN ?? existing?.ScalableTargetARN;
326
327 // Sync tags — diff observed cloud tags against desired.
328 if (scalableTargetArn) {
329 const currentTags = yield* observedTags(scalableTargetArn);
330 const { removed, upsert } = diffTags(currentTags, desiredTags);
331 if (upsert.length > 0) {
332 yield* aas.tagResource({
333 ResourceARN: scalableTargetArn,
334 Tags: Object.fromEntries(
335 upsert.map((t) => [t.Key, t.Value] as const),
336 ),
337 });
338 }
339 if (removed.length > 0) {
340 yield* aas.untagResource({
341 ResourceARN: scalableTargetArn,
342 TagKeys: removed,
343 });
344 }
345 }
346
347 yield* session.note(
348 `${identity.serviceNamespace}/${identity.resourceId}`,
349 );
350
351 return {
352 serviceNamespace: identity.serviceNamespace,
353 resourceId: identity.resourceId,
354 scalableDimension: identity.scalableDimension,
355 scalableTargetArn: scalableTargetArn ?? "",
356 minCapacity: found?.MinCapacity ?? news.minCapacity,
357 maxCapacity: found?.MaxCapacity ?? news.maxCapacity,
358 roleArn: found?.RoleARN ?? news.roleArn ?? "",
359 suspendedState: found?.SuspendedState ?? news.suspendedState,
360 };
361 }),
362
363 // Deregistering also deletes any scaling policies and scheduled
364 // actions still attached to the target; already-gone targets
365 // surface as the typed `ObjectNotFoundException` and are success.
366 delete: Effect.fn(function* ({ output }) {
367 yield* aas
368 .deregisterScalableTarget({
369 ServiceNamespace: output.serviceNamespace,
370 ResourceId: output.resourceId,
371 ScalableDimension: output.scalableDimension,
372 })
373 .pipe(
374 Effect.catchTag("ObjectNotFoundException", () => Effect.void),
375 );
376 }),
377 };
378 }),
379 );