From 1433ff23b07e50e621ee95b2e1aa2323d2bd7378 Mon Sep 17 00:00:00 2001 From: Luca Pizzini Date: Thu, 26 Oct 2023 19:04:24 +0200 Subject: [PATCH] feat(scheduler-alpha): target properties override (#27603) Allows to override a `Schedule` target's properties. Supported properties: `input`, `maxEventAge`, and `retryAttempts`. Example: ```ts declare const target: targets.LambdaInvoke; const oneTimeSchedule = new Schedule(this, 'Schedule', { schedule: ScheduleExpression.rate(cdk.Duration.hours(12)), target, targetOverrides: { input: ScheduleTargetInput.fromText("Overriding Target Input"), maxEventAge: Duration.seconds(180), retryAttempts: 5, }, }); ``` Closes #27545. ---- *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 | 16 +- .../aws-scheduler-alpha/lib/schedule.ts | 72 ++++++++- .../aws-scheduler-alpha/test/input.test.ts | 20 +++ .../aws-cdk-scheduler-schedule.assets.json | 4 +- .../aws-cdk-scheduler-schedule.template.json | 45 ++++++ .../integ.schedule.js.snapshot/manifest.json | 8 +- .../test/integ.schedule.js.snapshot/tree.json | 58 ++++++++ .../test/integ.schedule.ts | 19 ++- .../test/retry-policy.test.ts | 138 ++++++++++++++++++ 9 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/test/retry-policy.test.ts diff --git a/packages/@aws-cdk/aws-scheduler-alpha/README.md b/packages/@aws-cdk/aws-scheduler-alpha/README.md index 3dd6f1cbc6d2f..d4870ce51a5e1 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/README.md +++ b/packages/@aws-cdk/aws-scheduler-alpha/README.md @@ -256,8 +256,22 @@ const target = new targets.LambdaInvoke(fn, { ## Overriding Target Properties -TODO: Not yet implemented. See section in [L2 Event Bridge Scheduler RFC](https://github.com/aws/aws-cdk-rfcs/blob/master/text/0474-event-bridge-scheduler-l2.md) +If you wish to reuse the same target in multiple schedules, you can override target properties like `input`, +`retryAttempts` and `maxEventAge` when creating a Schedule using the `targetOverrides` parameter: +```ts +declare const target: targets.LambdaInvoke; + +const oneTimeSchedule = new Schedule(this, 'Schedule', { + schedule: ScheduleExpression.rate(cdk.Duration.hours(12)), + target, + targetOverrides: { + input: ScheduleTargetInput.fromText('Overriding Target Input'), + maxEventAge: Duration.seconds(180), + retryAttempts: 5, + }, +}); +``` ## Monitoring diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts index 036d95313eec9..bd8d1e004a43d 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts @@ -1,9 +1,10 @@ -import { IResource, Resource } from 'aws-cdk-lib'; +import { Duration, IResource, Resource } from 'aws-cdk-lib'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as kms from 'aws-cdk-lib/aws-kms'; import { CfnSchedule } from 'aws-cdk-lib/aws-scheduler'; import { Construct } from 'constructs'; import { IGroup } from './group'; +import { ScheduleTargetInput } from './input'; import { ScheduleExpression } from './schedule-expression'; import { IScheduleTarget } from './target'; @@ -15,10 +16,12 @@ export interface ISchedule extends IResource { * The name of the schedule. */ readonly scheduleName: string; + /** * The schedule group associated with this schedule. */ readonly group?: IGroup; + /** * The arn of the schedule. */ @@ -30,6 +33,32 @@ export interface ISchedule extends IResource { readonly key?: kms.IKey; } +export interface ScheduleTargetProps { + /** + * The text, or well-formed JSON, passed to the target. + * + * If you are configuring a templated Lambda, AWS Step Functions, or Amazon EventBridge target, + * the input must be a well-formed JSON. For all other target types, a JSON is not required. + * + * @default - The target's input is used. + */ + readonly input?: ScheduleTargetInput; + + /** + * The maximum amount of time, in seconds, to continue to make retry attempts. + * + * @default - The target's maximumEventAgeInSeconds is used. + */ + readonly maxEventAge?: Duration; + + /** + * The maximum number of retry attempts to make before the request fails. + * + * @default - The target's maximumRetryAttempts is used. + */ + readonly retryAttempts?: number; +} + /** * Construction properties for `Schedule`. */ @@ -45,6 +74,11 @@ export interface ScheduleProps { */ readonly target: IScheduleTarget; + /** + * Allows to override target properties when creating a new schedule. + */ + readonly targetOverrides?: ScheduleTargetProps; + /** * The name of the schedule. * @@ -183,10 +217,12 @@ export class Schedule extends Resource implements ISchedule { * The schedule group associated with this schedule. */ public readonly group?: IGroup; + /** * The arn of the schedule. */ public readonly scheduleArn: string; + /** * The name of the schedule. */ @@ -197,6 +233,11 @@ export class Schedule extends Resource implements ISchedule { */ readonly key?: kms.IKey; + /** + * A `RetryPolicy` object that includes information about the retry policy settings. + */ + private readonly retryPolicy?: CfnSchedule.RetryPolicyProperty; + constructor(scope: Construct, id: string, props: ScheduleProps) { super(scope, id, { physicalName: props.scheduleName, @@ -211,6 +252,8 @@ export class Schedule extends Resource implements ISchedule { this.key.grantEncryptDecrypt(targetConfig.role); } + this.retryPolicy = targetConfig.retryPolicy; + const resource = new CfnSchedule(this, 'Resource', { name: this.physicalName, flexibleTimeWindow: { mode: 'OFF' }, @@ -222,9 +265,11 @@ export class Schedule extends Resource implements ISchedule { target: { arn: targetConfig.arn, roleArn: targetConfig.role.roleArn, - input: targetConfig.input?.bind(this), + input: props.targetOverrides?.input ? + props.targetOverrides?.input?.bind(this) : + targetConfig.input?.bind(this), deadLetterConfig: targetConfig.deadLetterConfig, - retryPolicy: targetConfig.retryPolicy, + retryPolicy: this.renderRetryPolicy(props.targetOverrides?.maxEventAge?.toSeconds(), props.targetOverrides?.retryAttempts), ecsParameters: targetConfig.ecsParameters, kinesisParameters: targetConfig.kinesisParameters, eventBridgeParameters: targetConfig.eventBridgeParameters, @@ -240,4 +285,25 @@ export class Schedule extends Resource implements ISchedule { resourceName: `${this.group?.groupName ?? 'default'}/${this.physicalName}`, }); } + + private renderRetryPolicy( + maximumEventAgeInSeconds?: number, + maximumRetryAttempts?: number, + ): CfnSchedule.RetryPolicyProperty | undefined { + const policy = { + ...this.retryPolicy, + maximumEventAgeInSeconds: maximumEventAgeInSeconds ?? this.retryPolicy?.maximumEventAgeInSeconds, + maximumRetryAttempts: maximumRetryAttempts ?? this.retryPolicy?.maximumRetryAttempts, + }; + + if (policy.maximumEventAgeInSeconds && (policy.maximumEventAgeInSeconds < 60 || policy.maximumEventAgeInSeconds > 86400)) { + throw new Error(`maximumEventAgeInSeconds must be between 60 and 86400, got ${policy.maximumEventAgeInSeconds}`); + } + if (policy.maximumRetryAttempts && (policy.maximumRetryAttempts < 0 || policy.maximumRetryAttempts > 185)) { + throw new Error(`maximumRetryAttempts must be between 0 and 185, got ${policy.maximumRetryAttempts}`); + } + + const isEmptyPolicy = Object.values(policy).every(value => value === undefined); + return !isEmptyPolicy ? policy : undefined; + } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts b/packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts index afb890b7957ec..07aa43853e182 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts @@ -146,4 +146,24 @@ describe('schedule target input', () => { }, }); }); + + test('can override target input', () => { + // WHEN + const input = ScheduleTargetInput.fromText('Original Input'); + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func, input), + targetOverrides: { + input: ScheduleTargetInput.fromText('Overridden Input'), + }, + enabled: false, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Scheduler::Schedule', { + Target: { + Input: '"Overridden Input"', + }, + }); + }); }); 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 7d8aaf4c06d21..9c12eb7d9b585 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": "33.0.0", "files": { - "99b9aff7b7d42e6c47dd13d7034964f12b93cbe638fde0815e636313a0e7c9b0": { + "70a4ff6207a6b7ce2e7a4354be513e0143bb5f5c671d6826cfb30c010875e4bd": { "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": "99b9aff7b7d42e6c47dd13d7034964f12b93cbe638fde0815e636313a0e7c9b0.json", + "objectKey": "70a4ff6207a6b7ce2e7a4354be513e0143bb5f5c671d6826cfb30c010875e4bd.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 69f6f034ea5c6..e27219e674e1d 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 @@ -114,6 +114,11 @@ "Arn" ] }, + "Input": "\"Input Text\"", + "RetryPolicy": { + "MaximumEventAgeInSeconds": 180, + "MaximumRetryAttempts": 3 + }, "RoleArn": { "Fn::GetAtt": [ "Role1ABCC5F0", @@ -139,6 +144,41 @@ "Arn" ] }, + "Input": "\"Input Text\"", + "RetryPolicy": { + "MaximumEventAgeInSeconds": 180, + "MaximumRetryAttempts": 3 + }, + "RoleArn": { + "Fn::GetAtt": [ + "Role1ABCC5F0", + "Arn" + ] + } + } + } + }, + "TargetOverrideScheduleFF8CB184": { + "Type": "AWS::Scheduler::Schedule", + "Properties": { + "FlexibleTimeWindow": { + "Mode": "OFF" + }, + "ScheduleExpression": "rate(12 hours)", + "ScheduleExpressionTimezone": "Etc/UTC", + "State": "ENABLED", + "Target": { + "Arn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "Input": "\"Changed Text\"", + "RetryPolicy": { + "MaximumEventAgeInSeconds": 360, + "MaximumRetryAttempts": 5 + }, "RoleArn": { "Fn::GetAtt": [ "Role1ABCC5F0", @@ -217,6 +257,11 @@ "Arn" ] }, + "Input": "\"Input Text\"", + "RetryPolicy": { + "MaximumEventAgeInSeconds": 180, + "MaximumRetryAttempts": 3 + }, "RoleArn": { "Fn::GetAtt": [ "Role1ABCC5F0", 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 89f544c6f0432..9e6c88e76c10e 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}/99b9aff7b7d42e6c47dd13d7034964f12b93cbe638fde0815e636313a0e7c9b0.json", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/70a4ff6207a6b7ce2e7a4354be513e0143bb5f5c671d6826cfb30c010875e4bd.json", "requiresBootstrapStackVersion": 6, "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", "additionalDependencies": [ @@ -70,6 +70,12 @@ "data": "DisabledScheduleA1DF7F0F" } ], + "/aws-cdk-scheduler-schedule/TargetOverrideSchedule/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "TargetOverrideScheduleFF8CB184" + } + ], "/aws-cdk-scheduler-schedule/AllSchedulerErrorsAlarm/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 a16dc5b4a296f..3f5cd77076180 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 @@ -219,6 +219,11 @@ "Role1ABCC5F0", "Arn" ] + }, + "input": "\"Input Text\"", + "retryPolicy": { + "maximumEventAgeInSeconds": 180, + "maximumRetryAttempts": 3 } } } @@ -262,6 +267,59 @@ "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" + } + }, + "TargetOverrideSchedule": { + "id": "TargetOverrideSchedule", + "path": "aws-cdk-scheduler-schedule/TargetOverrideSchedule", + "children": { + "Resource": { + "id": "Resource", + "path": "aws-cdk-scheduler-schedule/TargetOverrideSchedule/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Scheduler::Schedule", + "aws:cdk:cloudformation:props": { + "flexibleTimeWindow": { + "mode": "OFF" + }, + "scheduleExpression": "rate(12 hours)", + "scheduleExpressionTimezone": "Etc/UTC", + "state": "ENABLED", + "target": { + "arn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "roleArn": { + "Fn::GetAtt": [ + "Role1ABCC5F0", + "Arn" + ] + }, + "input": "\"Changed Text\"", + "retryPolicy": { + "maximumEventAgeInSeconds": 360, + "maximumRetryAttempts": 5 } } } 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 23ae4d845bbc5..11284409a7930 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/integ.schedule.ts @@ -15,6 +15,11 @@ class SomeLambdaTarget implements scheduler.IScheduleTarget { return { arn: this.fn.functionArn, role: this.role, + input: scheduler.ScheduleTargetInput.fromText('Input Text'), + retryPolicy: { + maximumEventAgeInSeconds: 180, + maximumRetryAttempts: 3, + }, }; } } @@ -45,6 +50,16 @@ new scheduler.Schedule(stack, 'DisabledSchedule', { enabled: false, }); +new scheduler.Schedule(stack, 'TargetOverrideSchedule', { + schedule: expression, + target: target, + targetOverrides: { + input: scheduler.ScheduleTargetInput.fromText('Changed Text'), + maxEventAge: cdk.Duration.seconds(360), + retryAttempts: 5, + }, +}); + new cloudwatch.Alarm(stack, 'AllSchedulerErrorsAlarm', { metric: scheduler.Schedule.metricAllErrors(), threshold: 1, @@ -60,4 +75,6 @@ new scheduler.Schedule(stack, 'CustomerKmsSchedule', { new IntegTest(app, 'integtest-schedule', { testCases: [stack], -}); \ No newline at end of file +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-scheduler-alpha/test/retry-policy.test.ts b/packages/@aws-cdk/aws-scheduler-alpha/test/retry-policy.test.ts new file mode 100644 index 0000000000000..99b462169a64b --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/retry-policy.test.ts @@ -0,0 +1,138 @@ +import { App, Duration, Stack } from 'aws-cdk-lib'; + +import { Template } from 'aws-cdk-lib/assertions'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { IScheduleTarget, ScheduleExpression, ScheduleTargetConfig } from '../lib'; +import { Schedule } from '../lib/schedule'; + +class SomeLambdaTarget implements IScheduleTarget { + public constructor(private readonly fn: lambda.IFunction) { + } + + public bind(): ScheduleTargetConfig { + return { + arn: this.fn.functionArn, + retryPolicy: { + maximumEventAgeInSeconds: 180, + maximumRetryAttempts: 10, + }, + role: iam.Role.fromRoleArn(this.fn, 'ImportedRole', 'arn:aws:iam::123456789012:role/someRole'), + }; + } +} + +describe('schedule target retry policy', () => { + let stack: Stack; + let func: lambda.IFunction; + const expr = ScheduleExpression.at(new Date(Date.UTC(1969, 10, 20, 0, 0, 0))); + + beforeEach(() => { + const app = new App(); + stack = new Stack(app); + func = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('foo'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_LATEST, + tracing: lambda.Tracing.PASS_THROUGH, + }); + }); + + test('create a schedule with retry policy', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new SomeLambdaTarget(func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + RetryPolicy: { + MaximumEventAgeInSeconds: 180, + MaximumRetryAttempts: 10, + }, + }, + }, + }); + }); + + test('can override retry policy', () => { + // WHEN + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func), + targetOverrides: { + maxEventAge: Duration.seconds(120), + retryAttempts: 5, + }, + enabled: false, + }); + + // THEN + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + RetryPolicy: { + MaximumEventAgeInSeconds: 120, + MaximumRetryAttempts: 5, + }, + }, + }, + }); + }); + + test('apply maximumEventAge min value validation', () => { + expect(() => { + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func), + targetOverrides: { + maxEventAge: Duration.seconds(50), + retryAttempts: 5, + }, + enabled: false, + }); + }).toThrow(/maximumEventAgeInSeconds must be between 60 and 86400, got 50/); + }); + + test('apply maximumEventAge max value validation', () => { + expect(() => { + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func), + targetOverrides: { + maxEventAge: Duration.seconds(100000), + retryAttempts: 5, + }, + enabled: false, + }); + }).toThrow(/maximumEventAgeInSeconds must be between 60 and 86400, got 100000/); + }); + + test('apply maximumRetryAttempts min value validation', () => { + expect(() => { + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func), + targetOverrides: { + maxEventAge: Duration.seconds(120), + retryAttempts: -1, + }, + enabled: false, + }); + }).toThrow(/maximumRetryAttempts must be between 0 and 185, got -1/); + }); + + test('apply maximumRetryAttempts max value validation', () => { + expect(() => { + new Schedule(stack, 'TestSchedule', { + schedule: expr, + target: new SomeLambdaTarget(func), + targetOverrides: { + maxEventAge: Duration.seconds(120), + retryAttempts: 200, + }, + enabled: false, + }); + }).toThrow(/maximumRetryAttempts must be between 0 and 185, got 200/); + }); +});