import * as scheduler from "@distilled.cloud/aws/scheduler"; import * as Effect from "effect/Effect"; import * as Schedule_ from "effect/Schedule"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; import type { Input } from "../../Input.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import type { Providers } from "../Providers.ts"; /** * EventBridge Scheduler validates that the target's execution role is * assumable by `scheduler.amazonaws.com`. A freshly-created IAM role can take * well over a minute to propagate, surfacing as the typed * `ExecutionRoleNotAssumable` error ("The execution role you provide must * allow AWS EventBridge Scheduler to assume the role"). Retry (bounded) until * propagation completes. */ const retryUntilRoleAssumable = ( effect: Effect.Effect, ): Effect.Effect => Effect.retry(effect, { while: (e) => e._tag === "ExecutionRoleNotAssumable", schedule: Schedule_.spaced("5 seconds"), times: 24, }); export interface ScheduleProps { /** * Schedule name. If omitted, Alchemy generates a deterministic name. */ name?: string; /** * Optional schedule group. Defaults to the AWS default group. */ groupName?: Input; /** * Required schedule expression, such as `rate(5 minutes)` or `cron(...)`. */ scheduleExpression: string; /** * Optional start date. */ startDate?: Date; /** * Optional end date. */ endDate?: Date; /** * Optional description. */ description?: string; /** * Optional timezone for cron or at expressions. */ scheduleExpressionTimezone?: string; /** * Desired schedule state. */ state?: string; /** * Optional KMS key ARN. */ kmsKeyArn?: Input; /** * Scheduler target configuration. */ target: Input; /** * Flexible time window configuration. */ flexibleTimeWindow?: Input; /** * Action after a one-time schedule completes. */ actionAfterCompletion?: string; } /** * An EventBridge Scheduler schedule. * * `Schedule` is the canonical time-based delivery primitive. High-level helpers * like `every`, `cron`, and `at` can synthesize the target role and scheduler * target configuration on top of this resource. * ### Creating Schedules * **Example:** Hourly Schedule * ```typescript * const schedule = yield* Schedule("HourlyJob", { * scheduleExpression: "rate(1 hour)", * target: { * Arn: fn.functionArn, * RoleArn: role.roleArn, * }, * flexibleTimeWindow: { * Mode: "OFF", * }, * }); * ``` * * @resource */ export interface Schedule extends Resource< "AWS.Scheduler.Schedule", ScheduleProps, { /** * ARN of the schedule. */ scheduleArn: string; /** * Name of the schedule. */ scheduleName: string; /** * Name of the schedule group containing the schedule. */ groupName: string; /** * Current state of the schedule (`ENABLED` or `DISABLED`). */ state: string | undefined; }, never, Providers > {} export const Schedule = Resource("AWS.Scheduler.Schedule"); export const ScheduleProvider = () => Provider.effect( Schedule, Effect.gen(function* () { const toName = (id: string, props: ScheduleProps) => props.name ? Effect.succeed(props.name) : createPhysicalName({ id, maxLength: 64 }); return { stables: ["scheduleArn", "scheduleName", "groupName"], diff: Effect.fn(function* ({ id, olds, news }) { if (!isResolved(news)) return undefined; if ((yield* toName(id, olds)) !== (yield* toName(id, news))) { return { action: "replace" } as const; } if ((olds.groupName ?? "default") !== (news.groupName ?? "default")) { return { action: "replace" } as const; } }), read: Effect.fn(function* ({ id, olds, output }) { const scheduleName = output?.scheduleName ?? (yield* toName(id, olds)); const groupName = output?.groupName ?? (olds.groupName as string | undefined) ?? "default"; const described = yield* scheduler .getSchedule({ Name: scheduleName, GroupName: groupName !== "default" ? groupName : undefined, }) .pipe( Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined), ), ); if (!described?.Arn || !described.Name) { return undefined; } return { scheduleArn: described.Arn, scheduleName: described.Name, groupName: described.GroupName ?? groupName, state: described.State, }; }), reconcile: Effect.fn(function* ({ id, news, output, session }) { const scheduleName = output?.scheduleName ?? (yield* toName(id, news)); const groupName = output?.groupName ?? (news.groupName as string | undefined) ?? "default"; const groupNameParam = groupName !== "default" ? groupName : undefined; const desiredConfig = { ScheduleExpression: news.scheduleExpression, StartDate: news.startDate, EndDate: news.endDate, Description: news.description, ScheduleExpressionTimezone: news.scheduleExpressionTimezone, State: news.state, KmsKeyArn: news.kmsKeyArn as string | undefined, Target: news.target as scheduler.Target, FlexibleTimeWindow: (news.flexibleTimeWindow as | scheduler.FlexibleTimeWindow | undefined) ?? { Mode: "OFF" as const, }, ActionAfterCompletion: news.actionAfterCompletion, }; // Observe — fetch live schedule. let observed = yield* scheduler .getSchedule({ Name: scheduleName, GroupName: groupNameParam }) .pipe( Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined), ), ); // Ensure — create if missing. EventBridge Scheduler does not support // tagging individual schedules (only schedule groups), so ownership // can't be branded/verified via tags. A `ConflictException` means a // schedule with this deterministic name already exists; fall through // to the sync (`updateSchedule`) path to converge it. if (!observed?.Arn) { yield* scheduler .createSchedule({ Name: scheduleName, GroupName: groupNameParam, ...desiredConfig, }) .pipe( retryUntilRoleAssumable, Effect.catchTag("ConflictException", () => Effect.void), ); observed = yield* scheduler .getSchedule({ Name: scheduleName, GroupName: groupNameParam }) .pipe( Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined), ), ); } if (!observed?.Arn) { return yield* Effect.fail( new Error(`Failed to read created Schedule '${scheduleName}'`), ); } const scheduleArn = observed.Arn; // Sync schedule configuration. Scheduler doesn't support a partial // update — `updateSchedule` is a full PUT — so we always send the // full desired config. Fields like `state` are reflected in // `observed.State`; sending a no-op update is cheap. yield* scheduler .updateSchedule({ Name: scheduleName, GroupName: groupNameParam, ...desiredConfig, }) .pipe(retryUntilRoleAssumable); yield* session.note(scheduleArn); return { scheduleArn, scheduleName, groupName, state: news.state ?? observed.State, }; }), list: () => Effect.gen(function* () { // Enumerate every schedule in the account/region. Omitting // GroupName makes ListSchedules return summaries across all // schedule groups, paginated. const summaries = yield* scheduler.listSchedules.pages({}).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.Schedules ?? []).filter( (s): s is scheduler.ScheduleSummary & { Name: string } => s.Name != null, ), ), ), ); // Hydrate each summary via GetSchedule into the exact `read` // shape. Skip schedules deleted between list and get. const rows = yield* Effect.forEach( summaries, (summary) => { const groupName = summary.GroupName ?? "default"; return scheduler .getSchedule({ Name: summary.Name, GroupName: groupName !== "default" ? groupName : undefined, }) .pipe( Effect.map((described) => described.Arn && described.Name ? { scheduleArn: described.Arn, scheduleName: described.Name, groupName: described.GroupName ?? groupName, state: described.State, } : undefined, ), Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined), ), ); }, { concurrency: 10 }, ); return rows.filter( (row): row is Schedule["Attributes"] => row !== undefined, ); }), delete: Effect.fn(function* ({ output }) { yield* scheduler .deleteSchedule({ Name: output.scheduleName, GroupName: output.groupName !== "default" ? output.groupName : undefined, }) .pipe( Effect.catchTag("ResourceNotFoundException", () => Effect.void), ); }), }; }), );