1import * as aas from "@distilled.cloud/aws/application-auto-scaling";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { SERVICE_NAMESPACES, retryWhileTargetPropagates } from "./internal.ts";
10
11export interface ScheduledActionProps {
12 /**
13 * Scheduled action name. If omitted, a deterministic name is generated.
14 *
15 * Changing this triggers a replacement.
16 */
17 scheduledActionName?: string;
18 /**
19 * The namespace of the AWS service that provides the scalable target,
20 * e.g. `ecs`, `dynamodb`.
21 *
22 * Changing this triggers a replacement.
23 */
24 serviceNamespace: aas.ServiceNamespace;
25 /**
26 * The identifier of the resource the action's scalable target scales,
27 * e.g. `service/{clusterName}/{serviceName}` or `table/{tableName}`.
28 *
29 * Changing this triggers a replacement.
30 */
31 resourceId: string;
32 /**
33 * The scalable dimension of the scalable target,
34 * e.g. `ecs:service:DesiredCount` or `dynamodb:table:ReadCapacityUnits`.
35 *
36 * Changing this triggers a replacement.
37 */
38 scalableDimension: aas.ScalableDimension;
39 /**
40 * The schedule expression: `at(yyyy-mm-ddThh:mm:ss)` for one-time actions,
41 * `rate(value unit)` or `cron(fields)` for recurring actions.
42 */
43 schedule: string;
44 /**
45 * Time zone for the schedule expression (IANA name, e.g.
46 * `America/Los_Angeles`).
47 * @default "UTC"
48 */
49 timezone?: string;
50 /**
51 * ISO-8601 date-time at which a recurring schedule begins.
52 */
53 startTime?: string;
54 /**
55 * ISO-8601 date-time at which a recurring schedule ends.
56 */
57 endTime?: string;
58 /**
59 * The new minimum and/or maximum capacity applied when the action runs.
60 */
61 scalableTargetAction: aas.ScalableTargetAction;
62}
63
64export interface ScheduledAction extends Resource<
65 "AWS.ApplicationAutoScaling.ScheduledAction",
66 ScheduledActionProps,
67 {
68 /**
69 * Name of the scheduled action.
70 */
71 scheduledActionName: string;
72 /**
73 * ARN of the scheduled action.
74 */
75 scheduledActionArn: string;
76 /**
77 * Namespace of the AWS service that provides the scalable target.
78 */
79 serviceNamespace: aas.ServiceNamespace;
80 /**
81 * Identifier of the scaled resource.
82 */
83 resourceId: string;
84 /**
85 * Scalable dimension the action applies to.
86 */
87 scalableDimension: aas.ScalableDimension;
88 /**
89 * Schedule expression (`at(...)`, `rate(...)`, or `cron(...)`).
90 */
91 schedule: string;
92 /**
93 * IANA timezone the schedule is evaluated in.
94 */
95 timezone: string | undefined;
96 /**
97 * Capacity bounds applied when the action fires.
98 */
99 scalableTargetAction: aas.ScalableTargetAction | undefined;
100 },
101 never,
102 Providers
103> {}
104
105/**
106 * An Application Auto Scaling scheduled action — adjusts a scalable target's
107 * minimum and/or maximum capacity on a one-time (`at(...)`) or recurring
108 * (`cron(...)` / `rate(...)`) schedule.
109 *
110 * The action applies to the scalable target identified by the
111 * (`serviceNamespace`, `resourceId`, `scalableDimension`) triple, which must
112 * be registered (see {@link ScalableTarget}) before the action is created —
113 * pass the target's outputs so deployment orders correctly.
114 * ### Scheduling Capacity Changes
115 * **Example:** Scale Out for Business Hours
116 * ```typescript
117 * yield* ScheduledAction("BusinessHoursScaleOut", {
118 * serviceNamespace: target.serviceNamespace,
119 * resourceId: target.resourceId,
120 * scalableDimension: target.scalableDimension,
121 * schedule: "cron(0 8 ? * MON-FRI *)",
122 * timezone: "America/Los_Angeles",
123 * scalableTargetAction: { MinCapacity: 3, MaxCapacity: 10 },
124 * });
125 * ```
126 *
127 * **Example:** One-Time Capacity Bump
128 * ```typescript
129 * yield* ScheduledAction("LaunchDayBump", {
130 * serviceNamespace: target.serviceNamespace,
131 * resourceId: target.resourceId,
132 * scalableDimension: target.scalableDimension,
133 * schedule: "at(2030-01-01T00:00:00)",
134 * scalableTargetAction: { MinCapacity: 5 },
135 * });
136 * ```
137 *
138 * @resource
139 */
140export const ScheduledAction = Resource<ScheduledAction>(
141 "AWS.ApplicationAutoScaling.ScheduledAction",
142);
143
144export const ScheduledActionProvider = () =>
145 Provider.effect(
146 ScheduledAction,
147 Effect.gen(function* () {
148 const toName = (
149 id: string,
150 props: { scheduledActionName?: string } = {},
151 ) =>
152 props.scheduledActionName
153 ? Effect.succeed(props.scheduledActionName)
154 : createPhysicalName({ id, maxLength: 255 });
155
156 const describe = (props: {
157 serviceNamespace: aas.ServiceNamespace;
158 scheduledActionName: string;
159 resourceId?: string;
160 scalableDimension?: aas.ScalableDimension;
161 }) =>
162 aas
163 .describeScheduledActions({
164 ServiceNamespace: props.serviceNamespace,
165 ScheduledActionNames: [props.scheduledActionName],
166 ResourceId: props.resourceId,
167 ScalableDimension: props.scalableDimension,
168 })
169 .pipe(
170 Effect.map((res) =>
171 res.ScheduledActions?.find(
172 (a) => a.ScheduledActionName === props.scheduledActionName,
173 ),
174 ),
175 );
176
177 const toAttributes = (
178 action: aas.ScheduledAction,
179 ): ScheduledAction["Attributes"] => ({
180 scheduledActionName: action.ScheduledActionName,
181 scheduledActionArn: action.ScheduledActionARN,
182 serviceNamespace: action.ServiceNamespace,
183 resourceId: action.ResourceId,
184 scalableDimension: action.ScalableDimension ?? "",
185 schedule: action.Schedule,
186 timezone: action.Timezone,
187 scalableTargetAction: action.ScalableTargetAction,
188 });
189
190 return {
191 stables: [
192 "scheduledActionName",
193 "scheduledActionArn",
194 "serviceNamespace",
195 "resourceId",
196 "scalableDimension",
197 ],
198
199 // Account/region-wide enumeration; the describe API requires a
200 // `ServiceNamespace` filter, so union the per-namespace pages.
201 list: () =>
202 Effect.forEach(
203 SERVICE_NAMESPACES,
204 (namespace) =>
205 aas.describeScheduledActions
206 .pages({ ServiceNamespace: namespace })
207 .pipe(
208 Stream.runCollect,
209 Effect.map((chunk) =>
210 Array.from(chunk).flatMap((page) =>
211 (page.ScheduledActions ?? []).map(toAttributes),
212 ),
213 ),
214 ),
215 { concurrency: 4 },
216 ).pipe(Effect.map((groups) => groups.flat())),
217
218 diff: Effect.fn(function* ({ id, news, olds }) {
219 if (!isResolved(news)) return undefined;
220 const oldName = yield* toName(id, olds ?? {});
221 const newName = yield* toName(id, news);
222 if (oldName !== newName) {
223 return { action: "replace" } as const;
224 }
225 // The target triple pins the action to a scalable target — any
226 // change replaces the action. Only compare sides that are known:
227 // a half-created state row may have lost Output-valued props.
228 for (const key of [
229 "serviceNamespace",
230 "resourceId",
231 "scalableDimension",
232 ] as const) {
233 const oldValue = olds?.[key];
234 if (
235 oldValue !== undefined &&
236 isResolved(oldValue) &&
237 oldValue !== news[key]
238 ) {
239 return { action: "replace" } as const;
240 }
241 }
242 }),
243
244 read: Effect.fn(function* ({ id, olds, output }) {
245 const serviceNamespace =
246 output?.serviceNamespace ?? olds?.serviceNamespace;
247 if (serviceNamespace === undefined) return undefined;
248 const scheduledActionName =
249 output?.scheduledActionName ?? (yield* toName(id, olds ?? {}));
250 const action = yield* describe({
251 serviceNamespace,
252 scheduledActionName,
253 resourceId: output?.resourceId ?? olds?.resourceId,
254 scalableDimension:
255 output?.scalableDimension ?? olds?.scalableDimension,
256 });
257 return action ? toAttributes(action) : undefined;
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, session }) {
261 const scheduledActionName = yield* toName(id, news);
262
263 // Ensure + sync — `putScheduledAction` is the single
264 // create-or-update API, keyed by (name, target triple). Note the
265 // AWS semantics: unspecified StartTime/EndTime are *deleted* on
266 // update, which matches declarative convergence exactly. A put
267 // issued immediately after the target registers can race its
268 // propagation — retry the typed ObjectNotFoundException briefly.
269 yield* retryWhileTargetPropagates(
270 aas.putScheduledAction({
271 ScheduledActionName: scheduledActionName,
272 ServiceNamespace: news.serviceNamespace,
273 ResourceId: news.resourceId,
274 ScalableDimension: news.scalableDimension,
275 Schedule: news.schedule,
276 Timezone: news.timezone,
277 StartTime:
278 news.startTime !== undefined
279 ? new Date(news.startTime)
280 : undefined,
281 EndTime:
282 news.endTime !== undefined ? new Date(news.endTime) : undefined,
283 ScalableTargetAction: news.scalableTargetAction,
284 }),
285 );
286
287 // Observe final state so attributes carry the generated ARN.
288 const action = yield* describe({
289 serviceNamespace: news.serviceNamespace,
290 scheduledActionName,
291 resourceId: news.resourceId,
292 scalableDimension: news.scalableDimension,
293 });
294 if (action === undefined) {
295 return yield* Effect.fail(
296 new aas.ObjectNotFoundException({
297 message: `Scheduled action '${scheduledActionName}' was not readable after PutScheduledAction`,
298 }),
299 );
300 }
301
302 yield* session.note(action.ScheduledActionARN);
303 return toAttributes(action);
304 }),
305
306 // Deleting the parent scalable target implicitly deletes its
307 // scheduled actions, so an action that is already gone (typed
308 // `ObjectNotFoundException`) is success.
309 delete: Effect.fn(function* ({ output }) {
310 yield* aas
311 .deleteScheduledAction({
312 ScheduledActionName: output.scheduledActionName,
313 ServiceNamespace: output.serviceNamespace,
314 ResourceId: output.resourceId,
315 ScalableDimension: output.scalableDimension,
316 })
317 .pipe(
318 Effect.catchTag("ObjectNotFoundException", () => Effect.void),
319 );
320 }),
321 };
322 }),
323 );