Skip to content

Commit bca443e

Browse files
authored
Merge pull request #233 from nitrictech/fix/aws-schedules
fix: fix schedule deployment to AWS
2 parents 1623eaf + a434f72 commit bca443e

File tree

2 files changed

+211
-1
lines changed

2 files changed

+211
-1
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2021, Nitric Technologies Pty Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
import { cronToAwsCron } from './schedule';
15+
16+
describe('Cron Expression Conversion', () => {
17+
describe('Given an expression with more than 5 values', () => {
18+
const exp = '*/1 * * * ? *';
19+
20+
it('Should throw an error', () => {
21+
expect(() => {
22+
cronToAwsCron(exp);
23+
}).toThrow();
24+
});
25+
});
26+
27+
describe('Given a valid cron expression', () => {
28+
const exp = '*/1 * * * *';
29+
30+
describe('When converting the expression', () => {
31+
let awsExpValues: string[] = [];
32+
beforeAll(() => {
33+
// Expected result = '0/1 * * * ? *'
34+
awsExpValues = cronToAwsCron(exp).split(' ');
35+
});
36+
37+
it('Should replace the * with 0 for "every x unit" style values', () => {
38+
expect(awsExpValues[0]).toEqual('0/1');
39+
});
40+
41+
it('Should replace * in Day of Week with ? if DOW and DOM are both *', () => {
42+
expect(awsExpValues[4]).toEqual('?');
43+
});
44+
45+
it('Should output an expression with year added as a *', () => {
46+
expect(awsExpValues.length).toEqual(6);
47+
expect(awsExpValues[5]).toEqual('*');
48+
});
49+
});
50+
});
51+
52+
describe('Given a valid cron with a Day of Week value between 0-6', () => {
53+
const exp = '*/1 * * * 3';
54+
55+
describe('When converting the expression', () => {
56+
let awsExpValues: string[] = [];
57+
beforeAll(() => {
58+
// Expected result = '0/1 * ? * 3 *'
59+
awsExpValues = cronToAwsCron(exp).split(' ');
60+
});
61+
62+
it('increment the value by 1', () => {
63+
expect(awsExpValues[4]).toEqual('4');
64+
});
65+
66+
it('Should replace * in Day of Month with ?', () => {
67+
expect(awsExpValues[2]).toEqual('?');
68+
});
69+
});
70+
});
71+
72+
describe('Given a valid cron with a Day of Week value of 7 (Sunday)', () => {
73+
const exp = '*/1 * * * 7';
74+
75+
describe('When converting the expression', () => {
76+
let awsExpValues: string[] = [];
77+
beforeAll(() => {
78+
// Expected result = '0/1 * ? * 1 *'
79+
awsExpValues = cronToAwsCron(exp).split(' ');
80+
});
81+
82+
it('Should set the value to 1 (Sunday)', () => {
83+
expect(awsExpValues[4]).toEqual('1');
84+
});
85+
});
86+
});
87+
88+
describe('Given a valid cron with a Day of Week value range', () => {
89+
const exp = '*/1 * * * 1-3';
90+
91+
describe('When converting the expression', () => {
92+
let awsExpValues: string[] = [];
93+
beforeAll(() => {
94+
// Expected result = '0/1 * ? * 2-4 *'
95+
awsExpValues = cronToAwsCron(exp).split(' ');
96+
});
97+
98+
it('Should increment both values by 1', () => {
99+
expect(awsExpValues[4]).toEqual('2-4');
100+
});
101+
});
102+
});
103+
});

packages/plugins/aws/src/resources/schedule.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,77 @@ interface NitricScheduleEventBridgeArgs {
2121
topics: NitricSnsTopic[];
2222
}
2323

24+
/**
25+
* Converts a standard Crontab style cron expression to an AWS specific format.
26+
*
27+
* AWS appears to use a variation of the Quartz "Unix-like" Cron Expression Format.
28+
* Notable changes include:
29+
* - Removing the 'seconds' value (seconds are not supported)
30+
* - Making the 'year' value mandatory
31+
* - Providing a value for both Day of Month and Day of Year is not supported.
32+
*
33+
* Quartz CronExpression Docs:
34+
* https://www.javadoc.io/doc/org.quartz-scheduler/quartz/1.8.2/org/quartz/CronExpression.html
35+
*
36+
* AWS Specific CronExpressions Doc:
37+
* https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html#CronExpressions
38+
*
39+
* Crontab Expression Docs:
40+
* https://man7.org/linux/man-pages/man5/crontab.5.html
41+
*
42+
* @param crontab the crontab style cron expression string
43+
* @returns the input cron expression returned in the AWS specific format
44+
*/
45+
export const cronToAwsCron = (crontab: string): string => {
46+
let parts = crontab.split(' ');
47+
if (parts.length !== 5) {
48+
throw new Error(`Invalid Expression. Expected 5 expression values, received ${parts.length}`);
49+
}
50+
51+
// Replace */x (i.e. "every x minutes") style inputs to the AWS equivalent
52+
// AWS uses 0 instead of * for these expressions
53+
parts = parts.map((part) => part.replace(/^\*(?=\/.*)/g, '0'));
54+
55+
// Only day of week or day of month can be set with AWS, the other must be a ? char
56+
// See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html#CronExpressions - Restrictions
57+
const DAY_OF_MONTH = 2;
58+
const DAY_OF_WEEK = 4;
59+
if (parts[DAY_OF_WEEK] === '*') {
60+
parts[DAY_OF_WEEK] = '?';
61+
} else {
62+
if (parts[DAY_OF_MONTH] !== '*') {
63+
// TODO: We can support both in future by creating two EventRules - one for DOW, another for DOM.
64+
throw new Error('Invalid Expression. Day of Month and Day of Week expression component cannot both be set.');
65+
}
66+
parts[DAY_OF_MONTH] = '?';
67+
}
68+
69+
// We also need to adjust the Day of Week value
70+
// crontab uses 0-7 (0 or 7 is Sunday)
71+
// AWS uses 1-7 (Sunday-Saturday)
72+
parts[DAY_OF_WEEK] = parts[DAY_OF_WEEK].split('')
73+
.map((char) => {
74+
let num = parseInt(char);
75+
76+
if (!isNaN(num)) {
77+
// Check for standard 0-6 day range and increment
78+
if (num >= 0 && num <= 6) {
79+
return num + 1;
80+
} else {
81+
// otherwise default to Sunday
82+
return 1;
83+
}
84+
} else {
85+
return char;
86+
}
87+
})
88+
.join('');
89+
90+
// Add the year component, this doesn't exist in crontab expressions, so we default it to *
91+
parts = [...parts, '*'];
92+
return parts.join(' ');
93+
};
94+
2495
/**
2596
* Nitric EventBridge based Schedule
2697
*/
@@ -40,13 +111,20 @@ export class NitricScheduleEventBridge extends pulumi.ComponentResource {
40111

41112
this.name = schedule.name;
42113

114+
let awsCronValue = '';
115+
try {
116+
awsCronValue = cronToAwsCron(schedule.expression?.replace(/['"]+/g, ''));
117+
} catch (error) {
118+
throw new Error(`Failed to process expression for schedule ${this.name}. Details: ${(error as Error).message}`);
119+
}
120+
43121
if (topic) {
44122
const rule = new aws.cloudwatch.EventRule(
45123
`${schedule.name}Schedule`,
46124
{
47125
description: `Nitric schedule trigger for ${schedule.name}`,
48126
name: schedule.name,
49-
scheduleExpression: `cron(${schedule.expression?.replace(/['"]+/g, '')})`,
127+
scheduleExpression: `cron(${awsCronValue})`,
50128
},
51129
defaultResourceOptions,
52130
);
@@ -59,6 +137,35 @@ export class NitricScheduleEventBridge extends pulumi.ComponentResource {
59137
},
60138
defaultResourceOptions,
61139
);
140+
141+
const snsTopicSchedulePolicy = topic.sns.arn.apply((arn) =>
142+
aws.iam.getPolicyDocument({
143+
// TODO: According to the docs, 'conditions' are not supported for a policy involving EventBridge
144+
// See: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-use-resource-based.html#eb-sns-permissions
145+
// "You can't use of Condition blocks in Amazon SNS topic policies for EventBridge."
146+
// This means any EventBridge rule will be able to publish to this topic.
147+
policyId: '__default_policy_ID',
148+
statements: [
149+
{
150+
sid: '__default_statement_ID',
151+
effect: 'Allow',
152+
actions: ['SNS:Publish'],
153+
principals: [
154+
{
155+
type: 'Service',
156+
identifiers: ['events.amazonaws.com'],
157+
},
158+
],
159+
resources: [arn],
160+
},
161+
],
162+
}),
163+
);
164+
165+
new aws.sns.TopicPolicy(`${schedule.name}Target${topic.name}Policy`, {
166+
arn: topic.sns.arn,
167+
policy: snsTopicSchedulePolicy.apply((snsTopicPolicy) => snsTopicPolicy.json),
168+
});
62169
}
63170

64171
this.registerOutputs({

0 commit comments

Comments
 (0)