| 1 | import * as autoscaling from "@distilled.cloud/aws/auto-scaling"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 6 | import type { Input } from "../../Input.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | import type { AutoScalingGroup as AutoScalingGroupResource } from "./AutoScalingGroup.ts"; |
| 12 | |
| 13 | export type ScheduledActionName = string; |
| 14 | |
| 15 | export 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 | |
| 56 | export 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 | */ |
| 133 | export 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`. |
| 139 | const 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 | |
| 150 | export 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 | ); |