1import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import type { AutoScalingGroup as AutoScalingGroupResource } from "./AutoScalingGroup.ts";
12
13export type ScheduledActionName = string;
14
15export interface ScheduledActionProps {
16 /**
17 * Scheduled action name. If omitted, a deterministic name is generated.
18 */
19 scheduledActionName?: string;
20 /**
21 * Auto Scaling Group whose capacity the action changes.
22 */
23 autoScalingGroup: Input<string> | AutoScalingGroupResource;
24 /**
25 * Cron expression describing a recurring schedule (e.g. `"0 9 * * MON-FRI"`).
26 * Omit for a one-time action scheduled via `startTime`.
27 */
28 recurrence?: string;
29 /**
30 * ISO-8601 timestamp the action first runs (recurring) or the single time it
31 * runs (one-time). Must be in the future.
32 */
33 startTime?: string;
34 /**
35 * ISO-8601 timestamp after which the recurring action stops running.
36 */
37 endTime?: string;
38 /**
39 * IANA time zone the `recurrence` is evaluated in (e.g. `"America/New_York"`).
40 */
41 timeZone?: string;
42 /**
43 * Minimum group size to set when the action runs.
44 */
45 minSize?: number;
46 /**
47 * Maximum group size to set when the action runs.
48 */
49 maxSize?: number;
50 /**
51 * Desired capacity to set when the action runs.
52 */
53 desiredCapacity?: number;
54}
55
56export interface ScheduledAction extends Resource<
57 "AWS.AutoScaling.ScheduledAction",
58 ScheduledActionProps,
59 {
60 /**
61 * Name of the scheduled action.
62 */
63 scheduledActionName: ScheduledActionName;
64 /**
65 * ARN of the scheduled action.
66 */
67 scheduledActionARN: string;
68 /**
69 * Name of the Auto Scaling Group the action applies to.
70 */
71 autoScalingGroupName: string;
72 /**
73 * Cron expression for recurring actions.
74 */
75 recurrence?: string;
76 /**
77 * ISO-8601 time the action first fires.
78 */
79 startTime?: string;
80 /**
81 * ISO-8601 time after which the recurrence stops.
82 */
83 endTime?: string;
84 /**
85 * IANA timezone the recurrence is evaluated in.
86 */
87 timeZone?: string;
88 /**
89 * Minimum group size applied when the action fires.
90 */
91 minSize?: number;
92 /**
93 * Maximum group size applied when the action fires.
94 */
95 maxSize?: number;
96 /**
97 * Desired capacity applied when the action fires.
98 */
99 desiredCapacity?: number;
100 },
101 never,
102 Providers
103> {}
104
105/**
106 * A scheduled scaling action that changes an Auto Scaling Group's capacity on a
107 * recurring cron schedule or at a single future time.
108 *
109 * ### Creating a Scheduled Action
110 * **Example:** Scale up every weekday morning
111 * ```typescript
112 * const action = yield* ScheduledAction("MorningScaleUp", {
113 * autoScalingGroup: group,
114 * recurrence: "0 9 * * MON-FRI",
115 * timeZone: "America/New_York",
116 * minSize: 2,
117 * maxSize: 10,
118 * desiredCapacity: 4,
119 * });
120 * ```
121 *
122 * **Example:** One-time capacity change
123 * ```typescript
124 * const action = yield* ScheduledAction("BlackFriday", {
125 * autoScalingGroup: group,
126 * startTime: "2026-11-27T00:00:00Z",
127 * desiredCapacity: 20,
128 * });
129 * ```
130 *
131 * @resource
132 */
133export const ScheduledAction = Resource<ScheduledAction>(
134 "AWS.AutoScaling.ScheduledAction",
135);
136
137// A whole AutoScalingGroup resource resolves to its bare Attributes before
138// reaching the provider — narrow on the attributes shape, never on `Type`.
139const toAutoScalingGroupName = (
140 input: ScheduledActionProps["autoScalingGroup"] | undefined,
141): string | undefined =>
142 typeof input === "string"
143 ? input
144 : typeof (input as { autoScalingGroupName?: unknown } | undefined)
145 ?.autoScalingGroupName === "string"
146 ? (input as unknown as { autoScalingGroupName: string })
147 .autoScalingGroupName
148 : undefined;
149
150export const ScheduledActionProvider = () =>
151 Provider.effect(
152 ScheduledAction,
153 Effect.gen(function* () {
154 const toName = (
155 id: string,
156 props: { scheduledActionName?: string } = {},
157 ) =>
158 props.scheduledActionName
159 ? Effect.succeed(props.scheduledActionName)
160 : createPhysicalName({ id, maxLength: 255, lowercase: true });
161
162 const describeAction = ({
163 autoScalingGroupName,
164 scheduledActionName,
165 }: {
166 autoScalingGroupName: string | undefined;
167 scheduledActionName: string;
168 }) =>
169 autoscaling
170 .describeScheduledActions({
171 AutoScalingGroupName: autoScalingGroupName,
172 ScheduledActionNames: [scheduledActionName],
173 } as any)
174 .pipe(
175 Effect.map((result) => result.ScheduledUpdateGroupActions?.[0]),
176 // A deleted/absent Auto Scaling Group surfaces as
177 // `ValidationError: AutoScalingGroup ... not found` (typed
178 // `AutoScalingGroupNotFound` via the auto-scaling patch); treat it
179 // as "action gone" so refresh/read converge instead of failing.
180 Effect.catchTag("AutoScalingGroupNotFound", () =>
181 Effect.succeed(undefined),
182 ),
183 );
184
185 const toAttributes = (
186 action: autoscaling.ScheduledUpdateGroupAction,
187 ): ScheduledAction["Attributes"] => ({
188 scheduledActionName: action.ScheduledActionName!,
189 scheduledActionARN: action.ScheduledActionARN!,
190 autoScalingGroupName: action.AutoScalingGroupName!,
191 recurrence: action.Recurrence,
192 startTime: action.StartTime
193 ? new Date(action.StartTime).toISOString()
194 : undefined,
195 endTime: action.EndTime
196 ? new Date(action.EndTime).toISOString()
197 : undefined,
198 timeZone: action.TimeZone,
199 minSize: action.MinSize,
200 maxSize: action.MaxSize,
201 desiredCapacity: action.DesiredCapacity,
202 });
203
204 return {
205 stables: [
206 "scheduledActionName",
207 "scheduledActionARN",
208 "autoScalingGroupName",
209 ],
210 // `describeScheduledActions` enumerates scheduled actions across every
211 // Auto Scaling Group in the account/region when no AutoScalingGroupName
212 // filter is supplied, so no parent enumeration is needed.
213 list: () =>
214 autoscaling.describeScheduledActions.pages({} as any).pipe(
215 Stream.runCollect,
216 Effect.map((chunk) =>
217 Array.from(chunk).flatMap((page) =>
218 (page.ScheduledUpdateGroupActions ?? []).map(toAttributes),
219 ),
220 ),
221 ),
222 diff: Effect.fn(function* ({ id, olds, news: _news }) {
223 if (!isResolved(_news)) return undefined;
224 const news = _news as typeof olds;
225 const oldName = yield* toName(id, olds ?? {});
226 const newName = yield* toName(id, news ?? {});
227 const oldGroup = toAutoScalingGroupName(olds.autoScalingGroup);
228 const newGroup = toAutoScalingGroupName(news.autoScalingGroup);
229 if (
230 oldName !== newName ||
231 (oldGroup !== undefined &&
232 newGroup !== undefined &&
233 oldGroup !== newGroup)
234 ) {
235 return { action: "replace" } as const;
236 }
237
238 if (!deepEqual(olds, news)) {
239 return {
240 action: "update",
241 stables: [
242 "scheduledActionName",
243 "scheduledActionARN",
244 "autoScalingGroupName",
245 ],
246 } as const;
247 }
248 }),
249 read: Effect.fn(function* ({ id, olds, output }) {
250 const autoScalingGroupName =
251 output?.autoScalingGroupName ??
252 toAutoScalingGroupName(olds?.autoScalingGroup);
253 const scheduledActionName =
254 output?.scheduledActionName ?? (yield* toName(id, olds ?? {}));
255 const action = yield* describeAction({
256 autoScalingGroupName,
257 scheduledActionName,
258 });
259 return action ? toAttributes(action) : undefined;
260 }),
261 reconcile: Effect.fn(function* ({ id, news, output, session }) {
262 const autoScalingGroupName =
263 output?.autoScalingGroupName ??
264 toAutoScalingGroupName(news.autoScalingGroup);
265 if (!autoScalingGroupName) {
266 return yield* Effect.die(
267 new Error(
268 "ScheduledAction requires a resolvable autoScalingGroup name",
269 ),
270 );
271 }
272 const scheduledActionName =
273 output?.scheduledActionName ?? (yield* toName(id, news));
274
275 // Ensure + Sync — `putScheduledUpdateGroupAction` is the single
276 // create-or-update API. It overwrites the whole action, so we issue
277 // it unconditionally (idempotent on matching params).
278 yield* autoscaling.putScheduledUpdateGroupAction({
279 AutoScalingGroupName: autoScalingGroupName,
280 ScheduledActionName: scheduledActionName,
281 Recurrence: news.recurrence,
282 StartTime: news.startTime ? new Date(news.startTime) : undefined,
283 EndTime: news.endTime ? new Date(news.endTime) : undefined,
284 TimeZone: news.timeZone,
285 MinSize: news.minSize,
286 MaxSize: news.maxSize,
287 DesiredCapacity: news.desiredCapacity,
288 } as any);
289
290 const action = yield* describeAction({
291 autoScalingGroupName,
292 scheduledActionName,
293 }).pipe(
294 Effect.flatMap((action) =>
295 action
296 ? Effect.succeed(action)
297 : Effect.fail(
298 new Error(
299 `Scheduled action '${scheduledActionName}' was not readable after reconcile`,
300 ),
301 ),
302 ),
303 );
304 yield* session.note(scheduledActionName);
305 return toAttributes(action);
306 }),
307 delete: Effect.fn(function* ({ output }) {
308 // `deleteScheduledAction` is idempotent — a missing action returns
309 // success. Retry only the transient contention fault.
310 yield* autoscaling
311 .deleteScheduledAction({
312 AutoScalingGroupName: output.autoScalingGroupName,
313 ScheduledActionName: output.scheduledActionName,
314 } as any)
315 .pipe(
316 Effect.retry({
317 while: (error) => error._tag === "ResourceContentionFault",
318 schedule: Schedule.max([
319 Schedule.recurs(5),
320 Schedule.exponential("250 millis"),
321 ]),
322 }),
323 );
324 }),
325 };
326 }),
327 );