Skip to content

Commit

Permalink
Added local-to-time to support hour range cron expression
Browse files Browse the repository at this point in the history
  • Loading branch information
mvalluriitv committed Mar 15, 2022
1 parent 9c3c13f commit 344d48b
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 8 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ __pycache__
.pytest_cache

# SAM
samconfig.toml
.aws-sam

# Other ignores
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ To build and deploy for the first time, run the following in your shell:
sam build --use-container
sam deploy --guided
```

**Note:** If you use `sam deploy` please change to your email in `samconfig.toml`
The first command will build the source of your application. The second command will package and deploy your application to AWS, with a series of prompts. If you choose to save your choices to `samconfig.toml`, then you no longer will need to pass the `--guided` flag, as the SAM CLI will read your settings from it.

**Note:** You will be asked for an email address: this is where notifications will be sent whenever the solution performs changes to any of your resources. The email you enter will not be used for any other purpose.
Expand Down Expand Up @@ -66,6 +66,7 @@ We have one EventBridge rule which must trigger at 12:00 in the local time of Li
* `scheduled-event-adjuster:enabled` = (any value)
* `scheduled-event-adjuster:local-timezone` = `Europe/Lisbon`
* `scheduled-event-adjuster:local-time` = `12:00`
* `scheduled-event-adjuster:local-to-time` = `18:00` (For range)

### Using custom tag prefixes

Expand Down
6 changes: 6 additions & 0 deletions adjust_schedule_function/lib/processors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ def _get_local_time_tag(self):
events must run.
"""
return '%s:%s' % (self._tag_prefix, 'local-time')

def _get_local_to_time_tag(self):
"""Returns the tag that specifies the local end time at which scheduled for ranges
events must run.
"""
return '%s:%s' % (self._tag_prefix, 'local-to-time')
18 changes: 13 additions & 5 deletions adjust_schedule_function/lib/processors/eventbridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def process_resources(self):

local_timezone = utils.get_tag_by_key(tags, self._get_local_timezone_tag())
local_time = utils.get_tag_by_key(tags, self._get_local_time_tag())
local_to_time = utils.get_tag_by_key(tags, self._get_local_to_time_tag())

if not local_timezone:
print("Skipping: EventBridge rule '{}' has no timezone defined (missing tag '{}')".format(rule['Name'],
Expand All @@ -45,20 +46,27 @@ def process_resources(self):
# calculator should handle it instead.)
current_recurrence = rule['ScheduleExpression'][5:][:-1]

new_recurrence = self._recurrence_calculator.calculate_recurrence(current_recurrence,
local_time,
local_timezone)
if local_to_time:
new_recurrence = self._recurrence_calculator.calculate_range_recurrence(current_recurrence,
local_time,
local_to_time,
local_timezone)
else:
new_recurrence = self._recurrence_calculator.calculate_recurrence(current_recurrence,
local_time,
local_timezone)
if new_recurrence != current_recurrence:
print("Calculated recurrence '{}' does not match current recurrence '{}'. This rule will be updated.".format(new_recurrence, current_recurrence))
self._eventbridge_service.update_rule_schedule(rule['Name'],
'cron(' + new_recurrence + ')')
self._eventbridge_service.update_rule(rule,
'cron(' + new_recurrence + ')')
changes.append({
'Type': 'EventBridgeRule',
'ResourceName': rule['Name'],
'ResourceArn': rule['Arn'],
'OriginalRecurrence': current_recurrence,
'NewRecurrence': new_recurrence,
'LocalTime': local_time,
'LocalToTime': local_to_time,
'LocalTimezone': local_timezone
})

Expand Down
98 changes: 97 additions & 1 deletion adjust_schedule_function/lib/recurrence.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def calculate_recurrence(self, current_recurrence, expected_time, timezone, star
expected to run.
timezone: The name of the timezone (e.g., 'Europe/Madrid') of the
local time.
start_date: A datetime object with the start date and time of the
start_time: A datetime object with the start date and time of the
event. If not provided, None is assumed.
Returns:
Expand Down Expand Up @@ -127,3 +127,99 @@ def calculate_recurrence(self, current_recurrence, expected_time, timezone, star
parsed_recurrence['rest'])

return new_recurrence

def calculate_range_recurrence(self, current_recurrence, expected_time, expected_to_time, timezone, start_time=None):
"""Calculates the correct recurrence expression for the given
recurrence, expected local time and local timezone.
The recurrence must be defined as a cron expression.
If the recurrence is not selective on the hour, or if the start date
will occur in more than a day in the future, this method will return
the current recurrence.
Args:
current_recurrence: A string with the current recurrence, as a cron
expression (e..g, '0 0 * * *')
expected_time: A string with the local time at which the action is
expected to run.
timezone: The name of the timezone (e.g., 'Europe/Madrid') of the
local time.
start_time: A datetime object with the start date and time of the
event. If not provided, None is assumed.
Returns:
A string with the appropriate recurrence, formatted as a cron
expression.
Raises:
NotImplementedError: The original recurrence contains anything
other than single hours or minutes (e.g., ranges). These are
currently not supported by this implementation."""

parsed_recurrence = parse_cron_expression(current_recurrence)

# For the time being, we don't handle cron expressions which specify
# anything but a specific hour and minute (i.e., ranges, multiple
# hours, etc.). This is a feature that will be implemented in the
# future.
# if not re.match(r'^\d+$', parsed_recurrence['hour']):
# raise NotImplementedError("This script cannot yet handle multiple hours in cron expressions: '{}'".format(current_recurrence))
# if not re.match(r'^\d+$', parsed_recurrence['minute']):
# raise NotImplementedError("This script cannot yet handle multiple minutes in cron expressions: '{}'".format(current_recurrence))

# If the cron expression is not selective on the hour, it does not make
# sense to keep going.
if parsed_recurrence['hour'] == '*':
print("Recurrence's cron expression ('{}') is not selective on the hour. Leaving recurrence as is.".format(current_recurrence))
return current_recurrence

utc_now = self._time_source.get_current_utc_datetime()

# If the start date is over a day in the future, skip it. (We need to
# reevaluate whether this logic belongs to this class.)
if start_time and (start_time - utc_now).days > 1:
print("Start date is over a day away. Leaving recurrence as is.")
return current_recurrence

# Determine when the event will run next, and compare the time with the
# expected local time at the specified timezone. If they match, then
# we're all good. If they don't, we need to update the recurrence.
#
# Note that we're adding one extra second to the time delta, to account
# for precision errors which might produce incorrect results. (See for
# example when the expected time is 14:00:00 and the delta causes us to
# see 13:59:59.998). This is pretty hacky and I should revisit this,
# for sure.

recurrence = CronTab(current_recurrence)
delta = timedelta(seconds = recurrence.next(default_utc=True) + 1)
utc_next_run = utc_now + delta
local_next_run = utc_next_run.astimezone(pytz.timezone(timezone))
local_next_run_time = local_next_run.strftime('%H:%M')

print("This event should run at '{} - {}' local time. The next run will occur at '{}', which is '{}' at specified local timezone '{}'.".format(expected_time, expected_to_time, utc_next_run.isoformat(), local_next_run_time, timezone))

if local_next_run_time == expected_time:
print("Times match. Current recurrence is correct.")
return current_recurrence

print("Times don't match. Current recurrence must be recalculated.")
local_expected_run = local_next_run.replace(hour=parser.parse(expected_time).hour,
minute=parser.parse(expected_time).minute)
utc_expected_run = local_expected_run.astimezone(pytz.timezone('UTC'))

local_expected_to_run = local_next_run.replace(hour=parser.parse(expected_to_time).hour,
minute=parser.parse(expected_to_time).minute)
utc_expected_to_run = local_expected_to_run.astimezone(pytz.timezone('UTC'))

# We should only change the hour and minute parts of the cron
# expression. The rest should be left as it was originally. The reason
# we're changing the minutes too is because some timezones don't have
# whole offsets. E.g., see "Indian Standard Time".
new_recurrence = '{} {}-{} {}'.format(utc_expected_run.minute,
utc_expected_run.hour,
utc_expected_to_run.hour,
parsed_recurrence['rest'])

return new_recurrence
12 changes: 12 additions & 0 deletions adjust_schedule_function/lib/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,15 @@ def update_rule_schedule(self, rule_name, schedule):
The new schedule of the rule, as a valid schedule expression.
"""
return self._client.put_rule(Name=rule_name, ScheduleExpression=schedule)

def update_rule(self, rule, schedule):
"""Update the schedule of the rule with the specified name.
Args:
rule:
The EventBridge rule.
schedule:
The new schedule of the rule, as a valid schedule expression.
"""
return self._client.put_rule(Name=rule['Name'], ScheduleExpression=schedule, Description=rule['Description'],
State=rule['State'])
12 changes: 12 additions & 0 deletions samconfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version = 0.1
[default]
[default.deploy]
[default.deploy.parameters]
stack_name = "aws-cron-job-bst-adjuster"
s3_bucket = "aws-sam-cli-managed-default-samclisourcebucket-1ezh4266xpl2u"
s3_prefix = "aws-cron-job-bst-adjuster"
region = "eu-west-1"
confirm_changeset = true
capabilities = "CAPABILITY_IAM"
parameter_overrides = "NotificationEmail=\"[email protected]\" TagPrefix=\"scheduled-event-adjuster\""
image_repositories = []

0 comments on commit 344d48b

Please sign in to comment.