From bc9f3de653248de5808f83b7fb8f3ed5f6fc554e Mon Sep 17 00:00:00 2001 From: Jacco Kulman Date: Thu, 29 Jun 2023 21:46:08 +0200 Subject: [PATCH] feat(scheduler): ScheduleTargetInput (#25663) This PR contains implementation of ScheduleTargetInput. While a schedule is the main resource in Amazon EventBridge Scheduler, this PR adds ScheduleTargetInput on which ScheduleTargetBase depends. Every Schedule has a target that determines what extra information is sent to the target when the schedule is triggered. Also 4 ContextAttributes can be used that will be resolved at trigger-time. To be able to create sensible unit tests, also the a start is made to add the `Schedule` and the `LambdaInvoke` target as described in the RFC. Implementation is based on RFC: https://github.com/aws/aws-cdk-rfcs/blob/master/text/0474-event-bridge-scheduler-l2.md Also added a small fix to 2 of the unit tests of the previous PR for this module. Advances https://github.com/aws/aws-cdk/issues/23394 ---- *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 | 33 +++- .../@aws-cdk/aws-scheduler-alpha/lib/index.ts | 4 +- .../@aws-cdk/aws-scheduler-alpha/lib/input.ts | 122 +++++++++++++++ .../aws-scheduler-alpha/lib/private/index.ts | 2 + .../lib/private/schedule.ts | 57 +++++++ .../lib/private/targets.ts | 58 +++++++ .../aws-scheduler-alpha/lib/schedule.ts | 8 + .../rosetta/default.ts-fixture | 2 +- .../aws-scheduler-alpha/test/input.test.ts | 143 ++++++++++++++++++ 9 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/lib/input.ts create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/lib/private/index.ts create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/lib/private/targets.ts create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts diff --git a/packages/@aws-cdk/aws-scheduler-alpha/README.md b/packages/@aws-cdk/aws-scheduler-alpha/README.md index 171cecdf66854..7cc01e45a962a 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/README.md +++ b/packages/@aws-cdk/aws-scheduler-alpha/README.md @@ -37,7 +37,15 @@ This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aw ## Defining a schedule -TODO: Schedule is 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) +TODO: Schedule is not yet fully 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) + +[comment]: <> (TODO: change for each PR that implements more functionality) + +Only an L2 class is created that wraps the L1 class and handles the following properties: + +- schedule +- target (only LambdaInvoke is supported for now) +- flexibleTimeWindow will be set to `{ mode: 'OFF' }` ### Schedule Expressions @@ -95,10 +103,31 @@ TODO: Group is not yet implemented. See section in [L2 Event Bridge Scheduler RF TODO: Scheduler Targets Module is 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) +Only LambdaInvoke target is added for now. + ### Input -TODO: Target Input is 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) +Target can be invoked with a custom input. Class `ScheduleTargetInput` supports free form text input and JSON-formatted object input: + +```ts +const input = ScheduleTargetInput.fromObject({ + 'QueueName': 'MyQueue' +}); +``` + +You can include context attributes in your target payload. EventBridge Scheduler will replace each keyword with +its respective value and deliver it to the target. See +[full list of supported context attributes](https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-context-attributes.html): +1. `ContextAttribute.scheduleArn()` – The ARN of the schedule. +2. `ContextAttribute.scheduledTime()` – The time you specified for the schedule to invoke its target, for example, 2022-03-22T18:59:43Z. +3. `ContextAttribute.executionId()` – The unique ID that EventBridge Scheduler assigns for each attempted invocation of a target, for example, d32c5kddcf5bb8c3. +4. `ContextAttribute.attemptNumber()` – A counter that identifies the attempt number for the current invocation, for example, 1. + +```ts +const text = `Attempt number: ${ContextAttribute.attemptNumber}`; +const input = ScheduleTargetInput.fromText(text); +``` ### Specifying Execution Role diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts index c00ab258ae963..c2ff54e61f61b 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts @@ -1 +1,3 @@ -export * from './schedule-expression'; \ No newline at end of file +export * from './schedule-expression'; +export * from './input'; +export * from './schedule'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/input.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/input.ts new file mode 100644 index 0000000000000..682ce0687e374 --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/input.ts @@ -0,0 +1,122 @@ +import { DefaultTokenResolver, IResolveContext, Stack, StringConcat, Token, Tokenization } from 'aws-cdk-lib'; +import { ISchedule } from './schedule'; + +/** + * The text, or well-formed JSON, passed to the target of the schedule. + */ +export abstract class ScheduleTargetInput { + /** + * Pass text to the target, it is possible to embed `ContextAttributes` + * that will be resolved to actual values while the CloudFormation is + * deployed or cdk Tokens that will be resolved when the CloudFormation + * templates are generated by CDK. + * + * The target input value will be a single string that you pass. + * For passing complex values like JSON object to a target use method + * `ScheduleTargetInput.fromObject()` instead. + * + * @param text Text to use as the input for the target + */ + public static fromText(text: string): ScheduleTargetInput { + return new FieldAwareEventInput(text); + } + + /** + * Pass a JSON object to the target, it is possible to embed `ContextAttributes` and other + * cdk references. + * + * @param obj object to use to convert to JSON to use as input for the target + */ + public static fromObject(obj: any): ScheduleTargetInput { + return new FieldAwareEventInput(obj); + } + + protected constructor() { + } + + /** + * Return the input properties for this input object + */ + public abstract bind(schedule: ISchedule): string; +} + +class FieldAwareEventInput extends ScheduleTargetInput { + constructor(private readonly input: any) { + super(); + } + + public bind(schedule: ISchedule): string { + class Replacer extends DefaultTokenResolver { + constructor() { + super(new StringConcat()); + } + + public resolveToken(t: Token, _context: IResolveContext) { + return Token.asString(t); + } + } + + const stack = Stack.of(schedule); + return stack.toJsonString(Tokenization.resolve(this.input, { + scope: schedule, + resolver: new Replacer(), + })); + } +} + +/** + * Represents a field in the event pattern + * + * @see https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-context-attributes.html + */ +export class ContextAttribute { + /** + * The ARN of the schedule. + */ + public static get scheduleArn(): string { + return this.fromName('schedule-arn'); + } + + /** + * The time you specified for the schedule to invoke its target, for example, + * 2022-03-22T18:59:43Z. + */ + public static get scheduledTime(): string { + return this.fromName('scheduled-time'); + } + + /** + * The unique ID that EventBridge Scheduler assigns for each attempted invocation of + * a target, for example, d32c5kddcf5bb8c3. + */ + public static get executionId(): string { + return this.fromName('execution-id'); + } + + /** + * A counter that identifies the attempt number for the current invocation, for + * example, 1. + */ + public static get attemptNumber(): string { + return this.fromName('attempt-number'); + } + + /** + * Escape hatch for other ContextAttribute that might be resolved in future. + * + * @param name - name will replace xxx in + */ + public static fromName(name: string): string { + return new ContextAttribute(name).toString(); + } + + private constructor(public readonly name: string) { + } + + /** + * Convert the path to the field in the event pattern to JSON + */ + public toString() { + return ``; + } +} diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/private/index.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/index.ts new file mode 100644 index 0000000000000..acb4914fd0c93 --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/index.ts @@ -0,0 +1,2 @@ +export * from './schedule'; +export * from './targets'; diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts new file mode 100644 index 0000000000000..0e2b33742d18f --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts @@ -0,0 +1,57 @@ +import { Resource } from 'aws-cdk-lib'; +import { CfnSchedule } from 'aws-cdk-lib/aws-scheduler'; +import { Construct } from 'constructs'; +import { ISchedule } from '../schedule'; +import { ScheduleExpression } from '../schedule-expression'; + +/** + * DISCLAIMER: WORK IN PROGRESS, INTERFACE MIGHT CHANGE + * + * This unit is not yet finished. Only rudimentary Schedule is implemented in order + * to be able to create some sensible unit tests + */ + +export interface IScheduleTarget { + bind(_schedule: ISchedule): CfnSchedule.TargetProperty; +} + +/** + * Construction properties for `Schedule`. + */ +export interface ScheduleProps { + /** + * The expression that defines when the schedule runs. Can be either a `at`, `rate` + * or `cron` expression. + */ + readonly schedule: ScheduleExpression; + + /** + * The schedule's target details. + */ + readonly target: IScheduleTarget; + + /** + * The description you specify for the schedule. + * + * @default - no value + */ + readonly description?: string; +} + +/** + * An EventBridge Schedule + */ +export class Schedule extends Resource implements ISchedule { + constructor(scope: Construct, id: string, props: ScheduleProps) { + super(scope, id); + + new CfnSchedule(this, 'Resource', { + flexibleTimeWindow: { mode: 'OFF' }, + scheduleExpression: props.schedule.expressionString, + scheduleExpressionTimezone: props.schedule.timeZone?.timezoneName, + target: { + ...props.target.bind(this), + }, + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/private/targets.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/targets.ts new file mode 100644 index 0000000000000..1edff1b13db57 --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/targets.ts @@ -0,0 +1,58 @@ +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { CfnSchedule } from 'aws-cdk-lib/aws-scheduler'; +import { ScheduleTargetInput } from '../input'; +import { ISchedule } from '../schedule'; + +/** + * DISCLAIMER: WORK IN PROGRESS, INTERFACE MIGHT CHANGE + * + * This unit is not yet finished. The LambaInvoke target is only implemented to be able + * to create some sensible unit tests. + */ + +export namespace targets { + export interface ScheduleTargetBaseProps { + readonly role?: iam.IRole; + readonly input?: ScheduleTargetInput; + } + + abstract class ScheduleTargetBase { + constructor( + private readonly baseProps: ScheduleTargetBaseProps, + protected readonly targetArn: string, + ) { + } + + protected abstract addTargetActionToRole(role: iam.IRole): void; + + protected bindBaseTargetConfig(_schedule: ISchedule): CfnSchedule.TargetProperty { + if (typeof this.baseProps.role === undefined) { + throw Error('A role is needed (for now)'); + } + this.addTargetActionToRole(this.baseProps.role!); + return { + arn: this.targetArn, + roleArn: this.baseProps.role!.roleArn, + input: this.baseProps.input?.bind(_schedule), + }; + } + + bind(schedule: ISchedule): CfnSchedule.TargetProperty { + return this.bindBaseTargetConfig(schedule); + } + } + + export class LambdaInvoke extends ScheduleTargetBase { + constructor( + baseProps: ScheduleTargetBaseProps, + private readonly func: lambda.IFunction, + ) { + super(baseProps, func.functionArn); + } + + protected addTargetActionToRole(role: iam.IRole): void { + this.func.grantInvoke(role); + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts new file mode 100644 index 0000000000000..23bcd9406c0d2 --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts @@ -0,0 +1,8 @@ +import { IResource } from 'aws-cdk-lib'; + +/** + * Interface representing a created or an imported `Schedule`. + */ +export interface ISchedule extends IResource { + +} 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 71131d04c63a3..776fd224ec9b1 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-scheduler-alpha/rosetta/default.ts-fixture @@ -7,7 +7,7 @@ import * as kms from 'aws-cdk-lib/aws-kms'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import { App, Stack, TimeZone, Duration } from 'aws-cdk-lib'; -import { ScheduleExpression } from '@aws-cdk/aws-scheduler-alpha'; +import { ScheduleExpression, ScheduleTargetInput, ContextAttribute } 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/input.test.ts b/packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts new file mode 100644 index 0000000000000..adb6042ba3879 --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/input.test.ts @@ -0,0 +1,143 @@ +import { 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 { ContextAttribute, ScheduleExpression, ScheduleTargetInput } from '../lib'; +import { Schedule, targets } from '../lib/private'; + +describe('schedule target input', () => { + let stack: Stack; + let role: iam.IRole; + let func: lambda.IFunction; + const expr = ScheduleExpression.at(new Date(Date.UTC(1969, 10, 20, 0, 0, 0))); + + beforeEach(() => { + stack = new Stack(); + role = iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::123456789012:role/johndoe'); + func = lambda.Function.fromFunctionArn(stack, 'Function', 'arn:aws:lambda:us-east-1:123456789012:function/somefunc'); + }); + + test('create an input from text', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromText('test'), + }, func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + Input: '"test"', + }, + }, + }); + }); + + test('create an input from text with a ref inside', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromText(stack.account), + }, func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + Input: { + 'Fn::Join': ['', ['"', { Ref: 'AWS::AccountId' }, '"']], + }, + }, + }, + }); + }); + + test('create an input from object', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromObject({ + test: 'test', + }), + }, func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + Input: '{"test":"test"}', + }, + }, + }); + }); + + test('create an input from object with a ref', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromObject({ + test: stack.account, + }), + }, func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + Input: { + 'Fn::Join': ['', [ + '{"test":"', + { Ref: 'AWS::AccountId' }, + '"}', + ]], + }, + }, + }, + }); + }); + + test('create an input with fromText with ContextAttribute', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromText(`Test=${ContextAttribute.scheduleArn}`), + }, func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + Input: '"Test="', + }, + }, + }); + }); + + test('create an input with fromObject with ContextAttribute', () => { + new Schedule(stack, 'MyScheduleDummy', { + schedule: expr, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromObject({ + arn: ContextAttribute.scheduleArn, + att: ContextAttribute.attemptNumber, + xid: ContextAttribute.executionId, + tim: ContextAttribute.scheduledTime, + cus: ContextAttribute.fromName('escapehatch'), + }), + }, func), + }); + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + Target: { + Input: '{"arn":"",' + + '"att":"",' + + '"xid":"",' + + '"tim":"",' + + '"cus":""}', + }, + }, + }); + }); +});