From 6554e48908662de31aa5dba4578007c857c2403d Mon Sep 17 00:00:00 2001 From: sakurai-ryo <58683719+sakurai-ryo@users.noreply.github.com> Date: Fri, 15 Dec 2023 21:49:34 +0900 Subject: [PATCH] feat(scheduler): flexible time windows (#28098) This PR adds support for configuring flexible time windows. ## Description Currently, users cannot configure the `flexibleTimeWindow` feature in the Scheduler construct. This feature enhances flexibility and reliability, allowing tasks to be invoked within a defined time window. https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-flexible-time-windows.html CloudFormation allows users to take advantage of this feature as follows. With this template, it will invokes the target within 10 minutes after the scheduled time. ```yaml Resources: Schedule: Type: AWS::Scheduler::Schedule Properties: FlexibleTimeWindow: Mode: "FLEXIBLE" # or "OFF" MaximumWindowInMinutes: 10 # between 1 and 1440 Name: "sample-schedule" ScheduleExpression: "cron(0 9 * * ? *)" State: "ENABLED" Target: Arn: hoge RoleArn: hoge ``` ## Changes ### add Enum indicating flexible time window mode Currently there are only two modes, FLEXIBLE and OFF, so there is no problem using boolean instead of enum. But I think it's better to use Enum to prepare for future expansion. https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-scheduler-schedule-flexibletimewindow.html ### add property to `ScheduleProps` interface `flexibleTimeWindowMode` property defaults to `OFF` to avoid a breaking change. ```ts interface ScheduleProps { // .... /** * Determines whether the schedule is invoked within a flexible time window. * * @see https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-flexible-time-windows.html * * @default - FlexibleTimeWindowMode.OFF */ readonly flexibleTimeWindowMode?: FlexibleTimeWindowMode; /** * The maximum time window during which the schedule can be invoked. * * @default - Required if flexibleTimeWindowMode is FLEXIBLE. */ readonly maximumWindowInMinutes?: Duration; } ``` ### set the added property to `CfnSchedule` construct Basically, just set the values as documented, but with the following validations. - If `flexibleTimeWindowMode` is `FLEXIBLE` - `maximumWindowInMinutes` must be specified - `maximumWindowInMinutes` must be set from 1 to 1440 minutes https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-scheduler-schedule-flexibletimewindow.html In addition, I added some unit tests and integ-tests. ### others - fixed typo in README - `customizeable` => `customizable` ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-scheduler-alpha/README.md | 21 ++++++- .../aws-scheduler-alpha/lib/schedule.ts | 58 ++++++++++++++++++- .../rosetta/default.ts-fixture | 2 +- .../aws-cdk-scheduler-schedule.assets.json | 4 +- .../aws-cdk-scheduler-schedule.template.json | 31 ++++++++++ .../integ.schedule.js.snapshot/manifest.json | 8 ++- .../test/integ.schedule.js.snapshot/tree.json | 49 ++++++++++++++++ .../test/integ.schedule.ts | 6 ++ .../aws-scheduler-alpha/test/schedule.test.ts | 56 +++++++++++++++++- 9 files changed, 227 insertions(+), 8 deletions(-) diff --git a/packages/@aws-cdk/aws-scheduler-alpha/README.md b/packages/@aws-cdk/aws-scheduler-alpha/README.md index f4840acc8362c..2685e57c6715e 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/README.md +++ b/packages/@aws-cdk/aws-scheduler-alpha/README.md @@ -23,7 +23,7 @@ of millions of tasks across many AWS services without provisioning or managing u 2. **Targets**: A target is an API operation that EventBridge Scheduler calls on your behalf every time your schedule runs. EventBridge Scheduler supports two types of targets: templated targets and universal targets. Templated targets invoke common API operations across a core groups of services. For example, EventBridge Scheduler supports templated targets for invoking AWS Lambda Function or starting execution of Step Function state -machine. For API operations that are not supported by templated targets you can use customizeable universal targets. Universal targets support calling +machine. For API operations that are not supported by templated targets you can use customizable universal targets. Universal targets support calling more than 6,000 API operations across over 270 AWS services. 3. **Schedule Group**: A schedule group is an Amazon EventBridge Scheduler resource that you use to organize your schedules. Your AWS account comes with a default scheduler group. A new schedule will always be added to a scheduling group. If you do not provide a scheduling group to add to, it @@ -157,7 +157,7 @@ new Schedule(this, 'Schedule', { The `@aws-cdk/aws-scheduler-targets-alpha` module includes classes that implement the `IScheduleTarget` interface for various AWS services. EventBridge Scheduler supports two types of targets: templated targets invoke common API -operations across a core groups of services, and customizeable universal targets that you can use to call more +operations across a core groups of services, and customizable universal targets that you can use to call more than 6,000 operations across over 270 services. A list of supported targets can be found at `@aws-cdk/aws-scheduler-targets-alpha`. ### Input @@ -241,6 +241,23 @@ const schedule = new Schedule(this, 'Schedule', { > Visit [Data protection in Amazon EventBridge Scheduler](https://docs.aws.amazon.com/scheduler/latest/UserGuide/data-protection.html) for more details. +## Configuring flexible time windows + +You can configure flexible time windows by specifying the `timeWindow` property. +Flexible time windows is disabled by default. + +```ts +declare const target: targets.LambdaInvoke; + +const schedule = new Schedule(this, 'Schedule', { + schedule: ScheduleExpression.rate(Duration.hours(12)), + target, + timeWindow: TimeWindow.flexible(Duration.hours(10)), +}); +``` + +> Visit [Configuring flexible time windows](https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-flexible-time-windows.html) for more details. + ## Error-handling You can configure how your schedule handles failures, when EventBridge Scheduler is unable to deliver an event diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts index 8fefbe068a5b5..52e22ac5e0495 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts @@ -59,6 +59,47 @@ export interface ScheduleTargetProps { readonly retryAttempts?: number; } +/** + * A time window during which EventBridge Scheduler invokes the schedule. + */ +export class TimeWindow { + /** + * TimeWindow is disabled. + */ + public static off(): TimeWindow { + return new TimeWindow('OFF'); + } + + /** + * TimeWindow is enabled. + */ + public static flexible(maxWindow: Duration): TimeWindow { + if (maxWindow.toMinutes() < 1 || maxWindow.toMinutes() > 1440) { + throw new Error(`The provided duration must be between 1 minute and 1440 minutes, got ${maxWindow.toMinutes()}`); + } + return new TimeWindow('FLEXIBLE', maxWindow); + } + + /** + * Determines whether the schedule is invoked within a flexible time window. + */ + public readonly mode: 'OFF' | 'FLEXIBLE'; + + /** + * The maximum time window during which the schedule can be invoked. + * + * Must be between 1 to 1440 minutes. + * + * @default - no value + */ + public readonly maxWindow?: Duration; + + private constructor(mode: 'OFF' | 'FLEXIBLE', maxWindow?: Duration) { + this.mode = mode; + this.maxWindow = maxWindow; + } +} + /** * Construction properties for `Schedule`. */ @@ -104,6 +145,7 @@ export interface ScheduleProps { /** * Indicates whether the schedule is enabled. + * * @default true */ readonly enabled?: boolean; @@ -115,6 +157,15 @@ export interface ScheduleProps { */ readonly key?: kms.IKey; + /** + * A time window during which EventBridge Scheduler invokes the schedule. + * + * @see https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-flexible-time-windows.html + * + * @default TimeWindow.off() + */ + readonly timeWindow?: TimeWindow; + /** * The date, in UTC, after which the schedule can begin invoking its target. * EventBridge Scheduler ignores start for one-time schedules. @@ -270,11 +321,16 @@ export class Schedule extends Resource implements ISchedule { this.retryPolicy = targetConfig.retryPolicy; + const flexibleTimeWindow = props.timeWindow ?? TimeWindow.off(); + this.validateTimeFrame(props.start, props.end); const resource = new CfnSchedule(this, 'Resource', { name: this.physicalName, - flexibleTimeWindow: { mode: 'OFF' }, + flexibleTimeWindow: { + mode: flexibleTimeWindow.mode, + maximumWindowInMinutes: flexibleTimeWindow.maxWindow?.toMinutes(), + }, scheduleExpression: props.schedule.expressionString, scheduleExpressionTimezone: props.schedule.timeZone?.timezoneName, groupName: this.group?.groupName, diff --git a/packages/@aws-cdk/aws-scheduler-alpha/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-scheduler-alpha/rosetta/default.ts-fixture index cbb0128852cb7..4c66c83a7dd44 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-scheduler-alpha/rosetta/default.ts-fixture @@ -8,7 +8,7 @@ import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as targets from '@aws-cdk/aws-scheduler-targets-alpha'; import { App, Stack, TimeZone, Duration } from 'aws-cdk-lib'; -import { ScheduleExpression, ScheduleTargetInput, ContextAttribute, Group, Schedule } from '@aws-cdk/aws-scheduler-alpha'; +import { ScheduleExpression, ScheduleTargetInput, ContextAttribute, Group, Schedule, TimeWindow } from '@aws-cdk/aws-scheduler-alpha'; class Fixture extends cdk.Stack { constructor(scope: Construct, id: string) { diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.assets.json b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.assets.json index 03dd7cb96ad07..eb0c8d4f91218 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.assets.json +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.assets.json @@ -1,7 +1,7 @@ { "version": "35.0.0", "files": { - "77d06d03c78dc7776966b7c7ee414cc19be012ccfba5d7a9b1e425718920ab3e": { + "7c2f669a6fc34b993d394fcc754d832f1fb720665c9db5146c9856a595ddaac2": { "source": { "path": "aws-cdk-scheduler-schedule.template.json", "packaging": "file" @@ -9,7 +9,7 @@ "destinations": { "current_account-current_region": { "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "77d06d03c78dc7776966b7c7ee414cc19be012ccfba5d7a9b1e425718920ab3e.json", + "objectKey": "7c2f669a6fc34b993d394fcc754d832f1fb720665c9db5146c9856a595ddaac2.json", "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" } } diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.template.json b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.template.json index 5cdf90fef2ef6..f3d2bc6233f52 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.template.json +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/aws-cdk-scheduler-schedule.template.json @@ -349,6 +349,37 @@ } } }, + "UseFlexibleTimeWindowBF55D3ED": { + "Type": "AWS::Scheduler::Schedule", + "Properties": { + "FlexibleTimeWindow": { + "MaximumWindowInMinutes": 10, + "Mode": "FLEXIBLE" + }, + "ScheduleExpression": "rate(12 hours)", + "ScheduleExpressionTimezone": "Etc/UTC", + "State": "ENABLED", + "Target": { + "Arn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "Input": "\"Input Text\"", + "RetryPolicy": { + "MaximumEventAgeInSeconds": 180, + "MaximumRetryAttempts": 3 + }, + "RoleArn": { + "Fn::GetAtt": [ + "Role1ABCC5F0", + "Arn" + ] + } + } + } + }, "ScheduleWithTimeFrameC1C8BDCC": { "Type": "AWS::Scheduler::Schedule", "Properties": { diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/manifest.json b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/manifest.json index 4fbc5e0eb40aa..836a18c963b27 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/manifest.json +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/manifest.json @@ -18,7 +18,7 @@ "validateOnSynth": false, "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", - "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/77d06d03c78dc7776966b7c7ee414cc19be012ccfba5d7a9b1e425718920ab3e.json", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/7c2f669a6fc34b993d394fcc754d832f1fb720665c9db5146c9856a595ddaac2.json", "requiresBootstrapStackVersion": 6, "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", "additionalDependencies": [ @@ -118,6 +118,12 @@ "data": "CustomerKmsSchedule12B1FEFE" } ], + "/aws-cdk-scheduler-schedule/UseFlexibleTimeWindow/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "UseFlexibleTimeWindowBF55D3ED" + } + ], "/aws-cdk-scheduler-schedule/ScheduleWithTimeFrame/Resource": [ { "type": "aws:cdk:logicalId", diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/tree.json b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/tree.json index d99f1d9237516..d903ee961eaa2 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/tree.json +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.js.snapshot/tree.json @@ -616,6 +616,55 @@ "version": "0.0.0" } }, + "UseFlexibleTimeWindow": { + "id": "UseFlexibleTimeWindow", + "path": "aws-cdk-scheduler-schedule/UseFlexibleTimeWindow", + "children": { + "Resource": { + "id": "Resource", + "path": "aws-cdk-scheduler-schedule/UseFlexibleTimeWindow/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Scheduler::Schedule", + "aws:cdk:cloudformation:props": { + "flexibleTimeWindow": { + "mode": "FLEXIBLE", + "maximumWindowInMinutes": 10 + }, + "scheduleExpression": "rate(12 hours)", + "scheduleExpressionTimezone": "Etc/UTC", + "state": "ENABLED", + "target": { + "arn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "roleArn": { + "Fn::GetAtt": [ + "Role1ABCC5F0", + "Arn" + ] + }, + "input": "\"Input Text\"", + "retryPolicy": { + "maximumEventAgeInSeconds": 180, + "maximumRetryAttempts": 3 + } + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_scheduler.CfnSchedule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-scheduler-alpha.Schedule", + "version": "0.0.0" + } + }, "ScheduleWithTimeFrame": { "id": "ScheduleWithTimeFrame", "path": "aws-cdk-scheduler-schedule/ScheduleWithTimeFrame", diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.ts index 59a16c5d89fa2..86cd851dc8f45 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.ts @@ -90,6 +90,12 @@ new scheduler.Schedule(stack, 'CustomerKmsSchedule', { key, }); +new scheduler.Schedule(stack, 'UseFlexibleTimeWindow', { + schedule: expression, + target: target, + timeWindow: scheduler.TimeWindow.flexible(cdk.Duration.minutes(10)), +}); + const currentYear = new Date().getFullYear(); new scheduler.Schedule(stack, 'ScheduleWithTimeFrame', { schedule: expression, diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/schedule.test.ts b/packages/@aws-cdk/aws-scheduler-alpha/test/schedule.test.ts index b931769fc660b..a99fcf88aef3f 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/schedule.test.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/schedule.test.ts @@ -4,7 +4,7 @@ import { Template } from 'aws-cdk-lib/assertions'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as kms from 'aws-cdk-lib/aws-kms'; import * as lambda from 'aws-cdk-lib/aws-lambda'; -import { IScheduleTarget, Schedule, ScheduleTargetConfig } from '../lib'; +import { IScheduleTarget, Schedule, ScheduleTargetConfig, TimeWindow } from '../lib'; import { ScheduleExpression } from '../lib/schedule-expression'; class SomeLambdaTarget implements IScheduleTarget { @@ -161,4 +161,58 @@ describe('Schedule', () => { }).toThrow(`start must precede end, got start: ${start}, end: ${end}`); }); }); + + describe('flexibleTimeWindow', () => { + test('flexibleTimeWindow mode is set to OFF by default', () => { + // WHEN + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func, role), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Scheduler::Schedule', { + FlexibleTimeWindow: { + Mode: 'OFF', + }, + }); + }); + + test('flexibleTimeWindow mode can be set to FLEXIBLE', () => { + // WHEN + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func, role), + timeWindow: TimeWindow.flexible(Duration.minutes(1440)), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Scheduler::Schedule', { + FlexibleTimeWindow: { + Mode: 'FLEXIBLE', + MaximumWindowInMinutes: 1440, + }, + }); + }); + + test('throw error when maximumWindowInMinutes is greater than 1440', () => { + expect(() => { + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func, role), + timeWindow: TimeWindow.flexible(Duration.minutes(1441)), + }); + }).toThrow('The provided duration must be between 1 minute and 1440 minutes, got 1441'); + }); + + test('throw error when maximumWindowInMinutes is less than 1', () => { + expect(() => { + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func, role), + timeWindow: TimeWindow.flexible(Duration.minutes(0)), + }); + }).toThrow('The provided duration must be between 1 minute and 1440 minutes, got 0'); + }); + }); });