From 29c66e228a06dd9ea520c983e79a8c61525e623b Mon Sep 17 00:00:00 2001 From: Filipp Fediakov Date: Mon, 24 Jul 2023 20:27:44 +0100 Subject: [PATCH] feat(scheduler): ScheduleGroup (#26196) This PR contains implementation of ScheduleGroup. A Schedule is the main resource in Amazon EventBridge Scheduler, this PR adds ScheduleGroup which can be used to group Schedules and on which Schedule depends. Every AWS account comes with a default group for schedules. Customers can also create a custom groups to organise schedules that share a common purpose or belong to the same environment. Schedule has a property `group` that determines what group is the schedule associated with. To be able to test adding schedules to the group I have added property `group` to private class `Schedule` and used `Lazy` functionality to be able to update `group` of the schedule dynamically. Implementation is based on RFC: https://github.com/aws/aws-cdk-rfcs/blob/master/text/0474-event-bridge-scheduler-l2.md 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 | 53 ++- .../@aws-cdk/aws-scheduler-alpha/lib/group.ts | 356 ++++++++++++++++++ .../@aws-cdk/aws-scheduler-alpha/lib/index.ts | 3 +- .../lib/private/schedule.ts | 13 + .../aws-scheduler-alpha/lib/schedule.ts | 4 +- .../rosetta/default.ts-fixture | 2 +- .../aws-scheduler-alpha/test/group.test.ts | 301 +++++++++++++++ 7 files changed, 725 insertions(+), 7 deletions(-) create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/lib/group.ts create mode 100644 packages/@aws-cdk/aws-scheduler-alpha/test/group.test.ts diff --git a/packages/@aws-cdk/aws-scheduler-alpha/README.md b/packages/@aws-cdk/aws-scheduler-alpha/README.md index 7cc01e45a962a..0ca6cddd19a7f 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/README.md +++ b/packages/@aws-cdk/aws-scheduler-alpha/README.md @@ -44,6 +44,7 @@ TODO: Schedule is not yet fully implemented. See section in [L2 Event Bridge Sch Only an L2 class is created that wraps the L1 class and handles the following properties: - schedule +- schedule group - target (only LambdaInvoke is supported for now) - flexibleTimeWindow will be set to `{ mode: 'OFF' }` @@ -97,7 +98,31 @@ const oneTimeSchedule = new Schedule(this, 'Schedule', { ### Grouping Schedules -TODO: Group 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) +Your AWS account comes with a default scheduler group. You can access default group in CDK with: + +```text +const defaultGroup = Group.fromDefaultGroup(this, "DefaultGroup"); +``` + +If not specified a schedule is added to the default group. However, you can also add the schedule to a custom scheduling group managed by you: + +```text +const group = new Group(this, "Group", { + groupName: "MyGroup", +}); + +const target = new targets.LambdaInvoke(props.func, { + input: ScheduleTargetInput.fromObject({ + "payload": "useful", + }), +}); + +new Schedule(this, 'Schedule', { + scheduleExpression: ScheduleExpression.rate(Duration.minutes(10)), + target, + group, +}); +``` ## Scheduler Targets @@ -164,4 +189,28 @@ TODO: Not yet implemented. See section in [L2 Event Bridge Scheduler RFC](https: ### Metrics for a Group -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) +To view metrics for a specific group you can use methods on class `Group`: + +```ts +const group = new Group(this, "Group", { + groupName: "MyGroup", +}); + +new cloudwatch.Alarm(this, 'MyGroupErrorAlarm', { + metric: group.metricTargetErrors(), + evaluationPeriods: 1, + threshold: 0 +}); + +// Or use default group +const defaultGroup = Group.fromDefaultGroup(this, "DefaultGroup"); +new cloudwatch.Alarm(this, 'DefaultGroupErrorAlarm', { + metric: defaultGroup.metricTargetErrors(), + evaluationPeriods: 1, + threshold: 0 +}); +``` + +See full list of metrics and their description at +[Monitoring Using CloudWatch Metrics](https://docs.aws.amazon.com/scheduler/latest/UserGuide/monitoring-cloudwatch.html) +in the *AWS Event Bridge Scheduler User Guide*. diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/group.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/group.ts new file mode 100644 index 0000000000000..536f53b4022dc --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/group.ts @@ -0,0 +1,356 @@ +import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler'; +import { Arn, ArnFormat, Aws, IResource, PhysicalName, RemovalPolicy, Resource, Stack } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +export interface GroupProps { + /** + * The name of the schedule group. + * + * Up to 64 letters (uppercase and lowercase), numbers, hyphens, underscores and dots are allowed. + * + * @default - A unique name will be generated + */ + readonly groupName?: string; + + /** + * The removal policy for the group. If the group is removed also all schedules are removed. + * + * @default RemovalPolicy.RETAIN + */ + readonly removalPolicy?: RemovalPolicy; +} + +export interface IGroup extends IResource { + /** + * The name of the schedule group + * + * @attribute + */ + readonly groupName: string; + + /** + * The arn of the schedule group + * + * @attribute + */ + readonly groupArn: string; + + /** + * Return the given named metric for this group schedules + * + * @default - sum over 5 minutes + */ + metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the number of invocations that were throttled because it exceeds your service quotas. + * + * @see https://docs.aws.amazon.com/scheduler/latest/UserGuide/scheduler-quotas.html + * + * @default - sum over 5 minutes + */ + metricThrottled(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for all invocation attempts. + * + * @default - sum over 5 minutes + */ + metricAttempts(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Emitted when the target returns an exception after EventBridge Scheduler calls the target API. + * + * @default - sum over 5 minutes + */ + metricTargetErrors(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for invocation failures due to API throttling by the target. + * + * @default - sum over 5 minutes + */ + metricTargetThrottled(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for dropped invocations when EventBridge Scheduler stops attempting to invoke the target after a schedule's retry policy has been exhausted. + * + * @default - sum over 5 minutes + */ + metricDropped(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for invocations delivered to the DLQ + * + * @default - sum over 5 minutes + */ + metricSentToDLQ(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for failed invocations that also failed to deliver to DLQ. + * + * @default - sum over 5 minutes + */ + metricFailedToBeSentToDLQ(errorCode?: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for delivery of failed invocations to DLQ when the payload of the event sent to the DLQ exceeds the maximum size allowed by Amazon SQS. + * + * @default - sum over 5 minutes + */ + metricSentToDLQTrunacted(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Grant the indicated permissions on this group to the given principal + */ + grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant; + /** + * Grant list and get schedule permissions for schedules in this group to the given principal + */ + grantReadSchedules(identity: iam.IGrantable): iam.Grant; + /** + * Grant create and update schedule permissions for schedules in this group to the given principal + */ + grantWriteSchedules(identity: iam.IGrantable): iam.Grant; + /** + * Grant delete schedule permission for schedules in this group to the given principal + */ + grantDeleteSchedules(identity: iam.IGrantable): iam.Grant +} + +abstract class GroupBase extends Resource implements IGroup { + /** + * The name of the schedule group + * + * @attribute + */ + public abstract readonly groupName: string; + + /** + * The arn of the schedule group + * + * @attribute + */ + public abstract readonly groupArn: string; + + /** + * Return the given named metric for this group schedules + * + * @default - sum over 5 minutes + */ + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: 'AWS/Scheduler', + metricName, + dimensionsMap: { ScheduleGroup: this.groupName }, + statistic: 'sum', + ...props, + }).attachTo(this); + } + + /** + * Metric for the number of invocations that were throttled because it exceeds your service quotas. + * + * @see https://docs.aws.amazon.com/scheduler/latest/UserGuide/scheduler-quotas.html + * + * @default - sum over 5 minutes + */ + public metricThrottled(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('InvocationThrottleCount', props); + } + + /** + * Metric for all invocation attempts. + * + * @default - sum over 5 minutes + */ + public metricAttempts(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('InvocationAttemptCount', props); + } + + /** + * Emitted when the target returns an exception after EventBridge Scheduler calls the target API. + * + * @default - sum over 5 minutes + */ + public metricTargetErrors(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('TargetErrorCount', props); + } + + /** + * Metric for invocation failures due to API throttling by the target. + * + * @default - sum over 5 minutes + */ + public metricTargetThrottled(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('TargetErrorThrottledCount', props); + } + + /** + * Metric for dropped invocations when EventBridge Scheduler stops attempting to invoke the target after a schedule's retry policy has been exhausted. + * + * @default - sum over 5 minutes + */ + public metricDropped(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('InvocationDroppedCount', props); + } + + /** + * Metric for invocations delivered to the DLQ + * + * @default - sum over 5 minutes + */ + metricSentToDLQ(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('InvocationsSentToDeadLetterCount', props); + } + + /** + * Metric for failed invocations that also failed to deliver to DLQ. + * + * @default - sum over 5 minutes + */ + public metricFailedToBeSentToDLQ(errorCode?: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + if (errorCode) { + return this.metric(`InvocationsFailedToBeSentToDeadLetterCount_${errorCode}`, props); + } + + return this.metric('InvocationsFailedToBeSentToDeadLetterCount', props); + } + + /** + * Metric for delivery of failed invocations to DLQ when the payload of the event sent to the DLQ exceeds the maximum size allowed by Amazon SQS. + * + * @default - sum over 5 minutes + */ + public metricSentToDLQTrunacted(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('InvocationsSentToDeadLetterCount_Truncated_MessageSizeExceeded', props); + } + + /** + * Grant the indicated permissions on this group to the given principal + */ + public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee, + actions, + resourceArns: [this.groupArn], + scope: this, + }); + } + + private arnForScheduleInGroup(scheduleName: string): string { + return Arn.format({ + region: this.env.region, + account: this.env.account, + partition: Aws.PARTITION, + service: 'scheduler', + resource: 'schedule', + resourceName: this.groupName + '/' + scheduleName, + }); + } + + /** + * Grant list and get schedule permissions for schedules in this group to the given principal + */ + public grantReadSchedules(identity: iam.IGrantable) { + return iam.Grant.addToPrincipal({ + grantee: identity, + actions: ['scheduler:GetSchedule', 'scheduler:ListSchedules'], + resourceArns: [this.arnForScheduleInGroup('*')], + scope: this, + }); + } + + /** + * Grant create and update schedule permissions for schedules in this group to the given principal + */ + public grantWriteSchedules(identity: iam.IGrantable): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee: identity, + actions: ['scheduler:CreateSchedule', 'scheduler:UpdateSchedule'], + resourceArns: [this.arnForScheduleInGroup('*')], + scope: this, + }); + } + + /** + * Grant delete schedule permission for schedules in this group to the given principal + */ + public grantDeleteSchedules(identity: iam.IGrantable): iam.Grant { + return iam.Grant.addToPrincipal({ + grantee: identity, + actions: ['scheduler:DeleteSchedule'], + resourceArns: [this.arnForScheduleInGroup('*')], + scope: this, + }); + } +} + +export class Group extends GroupBase { + /** + * Import an external group by ARN. + * + * @param scope construct scope + * @param id construct id + * @param groupArn the ARN of the group to import (e.g. `arn:aws:scheduler:region:account-id:schedule-group/group-name`) + */ + public static fromGroupArn(scope: Construct, id: string, groupArn: string): IGroup { + const arnComponents = Stack.of(scope).splitArn(groupArn, ArnFormat.SLASH_RESOURCE_NAME); + const groupName = arnComponents.resourceName!; + class Import extends GroupBase { + groupName = groupName; + groupArn = groupArn; + } + return new Import(scope, id); + } + + /** + * Import a default schedule group. + * + * @param scope construct scope + * @param id construct id + */ + public static fromDefaultGroup(scope: Construct, id: string): IGroup { + return Group.fromGroupName(scope, id, 'default'); + } + + /** + * Import an existing group with a given name. + * + * @param scope construct scope + * @param id construct id + * @param groupName the name of the existing group to import + */ + public static fromGroupName(scope: Construct, id: string, groupName: string): IGroup { + const groupArn = Stack.of(scope).formatArn({ + service: 'scheduler', + resource: 'schedule-group', + resourceName: groupName, + }); + return Group.fromGroupArn(scope, id, groupArn); + } + + public readonly groupName: string; + public readonly groupArn: string; + + public constructor(scope: Construct, id: string, props: GroupProps) { + super(scope, id, { + physicalName: props.groupName ?? PhysicalName.GENERATE_IF_NEEDED, + }); + + const group = new CfnScheduleGroup(this, 'Resource', { + name: this.physicalName, + }); + + group.applyRemovalPolicy(props.removalPolicy); + + this.groupArn = this.getResourceArnAttribute(group.attrArn, { + service: 'scheduler', + resource: 'schedule-group', + resourceName: this.physicalName, + }); + this.groupName = this.physicalName; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts index c2ff54e61f61b..f6b79a57257ae 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/index.ts @@ -1,3 +1,4 @@ export * from './schedule-expression'; export * from './input'; -export * from './schedule'; \ No newline at end of file +export * from './schedule'; +export * from './group'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts index 0e2b33742d18f..3d8bf5f9e6672 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/private/schedule.ts @@ -1,6 +1,7 @@ import { Resource } from 'aws-cdk-lib'; import { CfnSchedule } from 'aws-cdk-lib/aws-scheduler'; import { Construct } from 'constructs'; +import { IGroup } from '../group'; import { ISchedule } from '../schedule'; import { ScheduleExpression } from '../schedule-expression'; @@ -36,19 +37,31 @@ export interface ScheduleProps { * @default - no value */ readonly description?: string; + + /** + * The schedule's group. + * + * @deafult - By default a schedule will be associated with the `default` group. + */ + readonly group?: IGroup; } /** * An EventBridge Schedule */ export class Schedule extends Resource implements ISchedule { + public readonly group?: IGroup; + constructor(scope: Construct, id: string, props: ScheduleProps) { super(scope, id); + this.group = props.group; + new CfnSchedule(this, 'Resource', { flexibleTimeWindow: { mode: 'OFF' }, scheduleExpression: props.schedule.expressionString, scheduleExpressionTimezone: props.schedule.timeZone?.timezoneName, + groupName: this.group?.groupName, target: { ...props.target.bind(this), }, diff --git a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts index 23bcd9406c0d2..6f51b262353cf 100644 --- a/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts +++ b/packages/@aws-cdk/aws-scheduler-alpha/lib/schedule.ts @@ -3,6 +3,4 @@ import { IResource } from 'aws-cdk-lib'; /** * Interface representing a created or an imported `Schedule`. */ -export interface ISchedule extends IResource { - -} +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 776fd224ec9b1..661565e353ad3 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, ScheduleTargetInput, ContextAttribute } from '@aws-cdk/aws-scheduler-alpha'; +import { ScheduleExpression, ScheduleTargetInput, ContextAttribute, Group } 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/group.test.ts b/packages/@aws-cdk/aws-scheduler-alpha/test/group.test.ts new file mode 100644 index 0000000000000..28fa388a9c313 --- /dev/null +++ b/packages/@aws-cdk/aws-scheduler-alpha/test/group.test.ts @@ -0,0 +1,301 @@ +import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import * as cw from 'aws-cdk-lib/aws-cloudwatch'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler'; +import { ScheduleExpression, ScheduleTargetInput } from '../lib'; +import { Group, GroupProps } from '../lib/group'; +import { Schedule, targets } from '../lib/private'; + +describe('Schedule Group', () => { + let stack: Stack; + let func: lambda.IFunction; + let role: iam.IRole; + 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('creates a group with default properties', () => { + const props: GroupProps = {}; + const group = new Group(stack, 'TestGroup', props); + + expect(group).toBeInstanceOf(Group); + expect(group.groupName).toBeDefined(); + expect(group.groupArn).toBeDefined(); + + const resource = group.node.findChild('Resource') as CfnScheduleGroup; + expect(resource).toBeInstanceOf(CfnScheduleGroup); + expect(resource.name).toEqual(group.groupName); + }); + + test('creates a group with removal policy', () => { + const props: GroupProps = { + removalPolicy: RemovalPolicy.RETAIN, + }; + new Group(stack, 'TestGroup', props); + + Template.fromStack(stack).hasResource('AWS::Scheduler::ScheduleGroup', { + DeletionPolicy: 'Retain', + }); + }); + + test('creates a group with specified name', () => { + const props: GroupProps = { + groupName: 'MyGroup', + }; + const group = new Group(stack, 'TestGroup', props); + const resource = group.node.findChild('Resource') as CfnScheduleGroup; + expect(resource).toBeInstanceOf(CfnScheduleGroup); + expect(resource.name).toEqual(group.groupName); + + Template.fromStack(stack).hasResource('AWS::Scheduler::ScheduleGroup', { + Properties: { + Name: `${props.groupName}`, + }, + }); + }); + + test('creates a group from ARN', () => { + const groupArn = 'arn:aws:scheduler:region:account-id:schedule-group/group-name'; + const group = Group.fromGroupArn(stack, 'TestGroup', groupArn); + + expect(group.groupArn).toBeDefined(); + expect(group.groupName).toEqual('group-name'); + + const groups = Template.fromStack(stack).findResources('AWS::Scheduler::ScheduleGroup'); + expect(groups).toEqual({}); + }); + + test('creates a group from name', () => { + const groupName = 'MyGroup'; + const group = Group.fromGroupName(stack, 'TestGroup', groupName); + + expect(group.groupArn).toBeDefined(); + expect(group.groupName).toEqual(groupName); + + const groups = Template.fromStack(stack).findResources('AWS::Scheduler::ScheduleGroup'); + expect(groups).toEqual({}); + }); + + test('creates a group from default group', () => { + const group = Group.fromDefaultGroup(stack, 'DefaultGroup'); + + expect(group.groupArn).toBeDefined(); + expect(group.groupName).toEqual('default'); + + const groups = Template.fromStack(stack).findResources('AWS::Scheduler::ScheduleGroup'); + expect(groups).toEqual({}); + }); + + test('adds schedules to the group', () => { + const props: GroupProps = { + groupName: 'MyGroup', + }; + const group = new Group(stack, 'TestGroup', props); + + const schedule1 = new Schedule(stack, 'MyScheduleDummy1', { + schedule: expr, + group: group, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromText('test'), + }, func), + }); + const schedule2 = new Schedule(stack, 'MyScheduleDummy2', { + schedule: expr, + group: group, + target: new targets.LambdaInvoke({ + role, + input: ScheduleTargetInput.fromText('test'), + }, func), + }); + + expect(schedule1.group).toEqual(group); + expect(schedule2.group).toEqual(group); + + Template.fromStack(stack).hasResource('AWS::Scheduler::Schedule', { + Properties: { + GroupName: `${props.groupName}`, + }, + }); + }); + + test('grantReadSchedules', () => { + // GIVEN + const props: GroupProps = { + groupName: 'MyGroup', + }; + const group = new Group(stack, 'TestGroup', props); + + const user = new iam.User(stack, 'User'); + + // WHEN + group.grantReadSchedules(user); + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'scheduler:GetSchedule', + 'scheduler:ListSchedules', + ], + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':scheduler:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':schedule/MyGroup/*', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + }); + }); + + test('grantWriteSchedules', () => { + // GIVEN + const props: GroupProps = { + groupName: 'MyGroup', + }; + const group = new Group(stack, 'TestGroup', props); + + const user = new iam.User(stack, 'User'); + + // WHEN + group.grantWriteSchedules(user); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'scheduler:CreateSchedule', + 'scheduler:UpdateSchedule', + ], + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':scheduler:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':schedule/MyGroup/*', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + }); + }); + + test('grantDeleteSchedules', () => { + // GIVEN + const props: GroupProps = { + groupName: 'MyGroup', + }; + const group = new Group(stack, 'TestGroup', props); + + const user = new iam.User(stack, 'User'); + + // WHEN + group.grantDeleteSchedules(user); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'scheduler:DeleteSchedule', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':scheduler:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':schedule/MyGroup/*', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + }); + }); + + test('Target Error Metrics', () => { + // GIVEN + const props: GroupProps = { + groupName: 'MyGroup', + }; + const group = new Group(stack, 'TestGroup', props); + + // WHEN + const metricTargetErrors = group.metricTargetErrors({ + period: Duration.minutes(1), + }); + + new cw.Alarm(stack, 'GroupTargetErrorAlarm', { + metric: metricTargetErrors, + evaluationPeriods: 1, + threshold: 1, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Dimensions: Match.arrayWith([ + Match.objectLike({ + Name: 'ScheduleGroup', + Value: 'MyGroup', + }), + ]), + MetricName: 'TargetErrorCount', + Namespace: 'AWS/Scheduler', + }); + }); +}); \ No newline at end of file