| 1 | import type * as scheduler from "@distilled.cloud/aws/scheduler"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type { Input, InputProps } from "../../Input.ts"; |
| 4 | import type { Cluster } from "../ECS/Cluster.ts"; |
| 5 | import * as IAM from "../IAM/index.ts"; |
| 6 | import type { Function } from "../Lambda/Function.ts"; |
| 7 | import type { Queue } from "../SQS/Queue.ts"; |
| 8 | import { Schedule } from "./Schedule.ts"; |
| 9 | import type { ScheduleGroup } from "./ScheduleGroup.ts"; |
| 10 | |
| 11 | /** |
| 12 | * Accumulated state of a fluent schedule builder (`every`/`cron`/`at` plus |
| 13 | * any `.named(...)` and target options applied so far). |
| 14 | */ |
| 15 | export interface ScheduleBuilderState { |
| 16 | /** Schedule expression: `rate(...)`, `cron(...)`, or `at(...)`. */ |
| 17 | expression: string; |
| 18 | /** Explicit schedule name set via `.named(...)`. */ |
| 19 | name?: string; |
| 20 | /** Schedule group the schedule is created in. */ |
| 21 | group?: ScheduleGroup; |
| 22 | /** Description on the backing schedule. */ |
| 23 | description?: string; |
| 24 | /** Timezone for cron or at expressions. */ |
| 25 | timezone?: string; |
| 26 | /** Date before which the schedule does not fire. */ |
| 27 | startDate?: Date; |
| 28 | /** Date after which the schedule stops firing. */ |
| 29 | endDate?: Date; |
| 30 | /** Desired schedule state (`ENABLED` / `DISABLED`). */ |
| 31 | state?: string; |
| 32 | /** KMS key ARN used to encrypt the schedule's target input. */ |
| 33 | kmsKeyArn?: string; |
| 34 | /** |
| 35 | * Flexible time window configuration. |
| 36 | * @default { Mode: "OFF" } |
| 37 | */ |
| 38 | flexibleTimeWindow?: scheduler.FlexibleTimeWindow; |
| 39 | /** Action after a one-time schedule completes (e.g. `DELETE`). */ |
| 40 | actionAfterCompletion?: string; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Options accepted by the `every`/`cron`/`at` schedule builders. |
| 45 | */ |
| 46 | export interface ScheduleOptions { |
| 47 | /** Schedule group the schedule is created in. */ |
| 48 | group?: ScheduleGroup; |
| 49 | /** Description on the backing schedule. */ |
| 50 | description?: string; |
| 51 | /** Timezone for cron or at expressions. */ |
| 52 | timezone?: string; |
| 53 | /** Date before which the schedule does not fire. */ |
| 54 | startDate?: Date; |
| 55 | /** Date after which the schedule stops firing. */ |
| 56 | endDate?: Date; |
| 57 | /** Desired schedule state (`ENABLED` / `DISABLED`). */ |
| 58 | state?: string; |
| 59 | /** KMS key ARN used to encrypt the schedule's target input. */ |
| 60 | kmsKeyArn?: string; |
| 61 | /** |
| 62 | * Flexible time window configuration. |
| 63 | * @default { Mode: "OFF" } |
| 64 | */ |
| 65 | flexibleTimeWindow?: scheduler.FlexibleTimeWindow; |
| 66 | /** Action after a one-time schedule completes (e.g. `DELETE`). */ |
| 67 | actionAfterCompletion?: string; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Target options for `.toLambda(fn, props)`. |
| 72 | */ |
| 73 | export interface LambdaTargetProps { |
| 74 | /** JSON-serializable payload passed to the function as the event. */ |
| 75 | input?: unknown; |
| 76 | /** Retry policy for failed invocations. */ |
| 77 | retryPolicy?: scheduler.RetryPolicy; |
| 78 | /** Dead-letter queue for undeliverable invocations. */ |
| 79 | deadLetterConfig?: scheduler.DeadLetterConfig; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Target options for `.toQueue(queue, payload, props)` — note the message |
| 84 | * payload is passed as `.toQueue`'s second argument, not via `input`. |
| 85 | */ |
| 86 | export interface QueueTargetProps { |
| 87 | /** JSON-serializable payload used as the message body. */ |
| 88 | input?: unknown; |
| 89 | /** Retry policy for failed deliveries. */ |
| 90 | retryPolicy?: scheduler.RetryPolicy; |
| 91 | /** Dead-letter queue for undeliverable deliveries. */ |
| 92 | deadLetterConfig?: scheduler.DeadLetterConfig; |
| 93 | /** SQS-specific parameters (e.g. `MessageGroupId` for FIFO queues). */ |
| 94 | sqs?: scheduler.SqsParameters; |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Target options for `.toEcsTask(props)`. |
| 99 | */ |
| 100 | export interface EcsTaskTargetProps { |
| 101 | /** ECS cluster the task runs on. */ |
| 102 | cluster: Cluster; |
| 103 | /** |
| 104 | * Task definition and the roles Scheduler passes to it. |
| 105 | * |
| 106 | * This is a builder argument (not resource Props), so the fields are |
| 107 | * `Input`-typed explicitly: an `AWS.ECS.Task` resource — whose attributes |
| 108 | * are `Output`s — is directly assignable here. |
| 109 | */ |
| 110 | task: { |
| 111 | /** ARN of the task definition to run. */ |
| 112 | taskDefinitionArn: Input<string>; |
| 113 | /** ARN of the task role (`iam:PassRole` is granted automatically). */ |
| 114 | taskRoleArn: Input<string>; |
| 115 | /** ARN of the execution role (`iam:PassRole` is granted automatically). */ |
| 116 | executionRoleArn: Input<string>; |
| 117 | }; |
| 118 | /** Subnets for the task's awsvpc network configuration. */ |
| 119 | subnets: Input<string[]>; |
| 120 | /** Security groups for the task's awsvpc network configuration. */ |
| 121 | securityGroups?: Input<string[]>; |
| 122 | /** |
| 123 | * Whether the task gets a public IP. |
| 124 | * @default false |
| 125 | */ |
| 126 | assignPublicIp?: boolean; |
| 127 | /** |
| 128 | * Number of tasks to launch per invocation. |
| 129 | * @default 1 |
| 130 | */ |
| 131 | taskCount?: number; |
| 132 | /** JSON-serializable payload passed to the task as the event. */ |
| 133 | input?: unknown; |
| 134 | /** Retry policy for failed task launches. */ |
| 135 | retryPolicy?: scheduler.RetryPolicy; |
| 136 | /** Dead-letter queue for undeliverable invocations. */ |
| 137 | deadLetterConfig?: scheduler.DeadLetterConfig; |
| 138 | } |
| 139 | |
| 140 | export const every = (value: string, options: ScheduleOptions = {}) => |
| 141 | makeBuilder({ |
| 142 | expression: |
| 143 | value.startsWith("rate(") || value.startsWith("cron(") |
| 144 | ? value |
| 145 | : `rate(${value})`, |
| 146 | ...options, |
| 147 | }); |
| 148 | |
| 149 | export const cron = (expression: string, options: ScheduleOptions = {}) => |
| 150 | makeBuilder({ |
| 151 | expression, |
| 152 | ...options, |
| 153 | }); |
| 154 | |
| 155 | export const at = (date: Date, options: ScheduleOptions = {}) => |
| 156 | makeBuilder({ |
| 157 | expression: `at(${date.toISOString().replace(/\.\d{3}Z$/, "Z")})`, |
| 158 | ...options, |
| 159 | }); |
| 160 | |
| 161 | /** |
| 162 | * The fluent schedule builder returned by `every`/`cron`/`at`. Route it to a |
| 163 | * target with `.toLambda`/`.toQueue`/`.toEcsTask`, or consume it on the host |
| 164 | * Function with `consumeSchedule(builder, handler)`. |
| 165 | */ |
| 166 | export type ScheduleBuilder = ReturnType<typeof makeBuilder>; |
| 167 | |
| 168 | const makeBuilder = (state: ScheduleBuilderState) => ({ |
| 169 | /** |
| 170 | * The accumulated builder state (expression, name, group, ...). Read by |
| 171 | * `consumeSchedule` to materialize the schedule against the host Function. |
| 172 | */ |
| 173 | state, |
| 174 | |
| 175 | named: (name: string) => |
| 176 | makeBuilder({ |
| 177 | ...state, |
| 178 | name, |
| 179 | }), |
| 180 | |
| 181 | toLambda: (fn: Function, props: LambdaTargetProps = {}) => |
| 182 | materializeSchedule( |
| 183 | state, |
| 184 | fn.LogicalId, |
| 185 | [ |
| 186 | { |
| 187 | Effect: "Allow", |
| 188 | Action: ["lambda:InvokeFunction"], |
| 189 | Resource: [fn.functionArn], |
| 190 | }, |
| 191 | ], |
| 192 | { |
| 193 | Arn: fn.functionArn as any, |
| 194 | Input: toInput(props.input), |
| 195 | RetryPolicy: props.retryPolicy, |
| 196 | DeadLetterConfig: props.deadLetterConfig, |
| 197 | }, |
| 198 | ), |
| 199 | |
| 200 | toQueue: ( |
| 201 | queue: Queue, |
| 202 | payload?: unknown, |
| 203 | props: Omit<QueueTargetProps, "input"> = {}, |
| 204 | ) => |
| 205 | materializeSchedule( |
| 206 | state, |
| 207 | queue.LogicalId, |
| 208 | [ |
| 209 | { |
| 210 | Effect: "Allow", |
| 211 | Action: ["sqs:SendMessage"], |
| 212 | Resource: [queue.queueArn], |
| 213 | }, |
| 214 | ], |
| 215 | { |
| 216 | Arn: queue.queueArn as any, |
| 217 | Input: toInput(payload), |
| 218 | RetryPolicy: props.retryPolicy, |
| 219 | DeadLetterConfig: props.deadLetterConfig, |
| 220 | SqsParameters: props.sqs, |
| 221 | }, |
| 222 | ), |
| 223 | |
| 224 | toEcsTask: (props: EcsTaskTargetProps) => |
| 225 | materializeSchedule( |
| 226 | state, |
| 227 | props.cluster.LogicalId, |
| 228 | [ |
| 229 | { |
| 230 | Effect: "Allow", |
| 231 | Action: ["ecs:RunTask"], |
| 232 | Resource: [props.task.taskDefinitionArn], |
| 233 | }, |
| 234 | { |
| 235 | Effect: "Allow", |
| 236 | Action: ["iam:PassRole"], |
| 237 | Resource: [props.task.taskRoleArn, props.task.executionRoleArn], |
| 238 | }, |
| 239 | ], |
| 240 | { |
| 241 | Arn: props.cluster.clusterArn as any, |
| 242 | Input: toInput(props.input), |
| 243 | RetryPolicy: props.retryPolicy, |
| 244 | DeadLetterConfig: props.deadLetterConfig, |
| 245 | EcsParameters: { |
| 246 | TaskDefinitionArn: props.task.taskDefinitionArn, |
| 247 | TaskCount: props.taskCount ?? 1, |
| 248 | LaunchType: "FARGATE", |
| 249 | NetworkConfiguration: { |
| 250 | awsvpcConfiguration: { |
| 251 | Subnets: props.subnets, |
| 252 | SecurityGroups: props.securityGroups, |
| 253 | AssignPublicIp: props.assignPublicIp ? "ENABLED" : "DISABLED", |
| 254 | }, |
| 255 | }, |
| 256 | }, |
| 257 | }, |
| 258 | ), |
| 259 | }); |
| 260 | |
| 261 | const materializeSchedule = ( |
| 262 | state: ScheduleBuilderState, |
| 263 | targetId: string, |
| 264 | statements: any[], |
| 265 | // `InputProps` so target fields may carry unresolved `Output`s (e.g. a |
| 266 | // Task's `taskDefinitionArn`); they flow into the `Schedule` resource's |
| 267 | // Input-typed `target` prop and resolve at deploy time. |
| 268 | target: InputProps<Omit<scheduler.Target, "RoleArn">>, |
| 269 | ) => |
| 270 | Effect.gen(function* () { |
| 271 | const scheduleId = state.name ?? `${targetId}Schedule`; |
| 272 | const role = yield* IAM.Role(`${scheduleId}Role`, { |
| 273 | assumeRolePolicyDocument: { |
| 274 | Version: "2012-10-17", |
| 275 | Statement: [ |
| 276 | { |
| 277 | Effect: "Allow", |
| 278 | Principal: { |
| 279 | Service: "scheduler.amazonaws.com", |
| 280 | }, |
| 281 | Action: ["sts:AssumeRole"], |
| 282 | }, |
| 283 | ], |
| 284 | }, |
| 285 | inlinePolicies: { |
| 286 | ScheduleTarget: { |
| 287 | Version: "2012-10-17", |
| 288 | Statement: statements, |
| 289 | }, |
| 290 | }, |
| 291 | }); |
| 292 | |
| 293 | return yield* Schedule(scheduleId, { |
| 294 | name: state.name, |
| 295 | groupName: state.group?.scheduleGroupName, |
| 296 | scheduleExpression: state.expression, |
| 297 | startDate: state.startDate, |
| 298 | endDate: state.endDate, |
| 299 | description: state.description, |
| 300 | scheduleExpressionTimezone: state.timezone, |
| 301 | state: state.state, |
| 302 | kmsKeyArn: state.kmsKeyArn, |
| 303 | flexibleTimeWindow: state.flexibleTimeWindow ?? { |
| 304 | Mode: "OFF", |
| 305 | }, |
| 306 | actionAfterCompletion: state.actionAfterCompletion, |
| 307 | target: { |
| 308 | ...target, |
| 309 | RoleArn: role.roleArn, |
| 310 | } as any, |
| 311 | }); |
| 312 | }); |
| 313 | |
| 314 | const toInput = (value: unknown) => |
| 315 | value === undefined |
| 316 | ? undefined |
| 317 | : typeof value === "string" |
| 318 | ? value |
| 319 | : JSON.stringify(value); |