Skip to content

Commit

Permalink
Merge branch 'scheduled-events'
Browse files Browse the repository at this point in the history
* scheduled-events:
  Add scheduled events feature to changelog
  Add documentation for scheduled events
  Add support for deploying scheduled events
  Update awsclient with needed cloudwatch events APIs
  Add user facing API for scheduled events
  • Loading branch information
jamesls committed Jul 5, 2017
2 parents 0f13291 + 83eafe1 commit 2fa94a1
Show file tree
Hide file tree
Showing 15 changed files with 940 additions and 94 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.rst
Expand Up @@ -3,10 +3,12 @@ CHANGELOG
=========

Next Release (TBD)
============
==================

* Fix unicode responses being quoted in python 2.7
(`#262 <https://github.com/awslabs/chalice/issues/262>`__)
* Add support for scheduled events
(`#390 <https://github.com/awslabs/chalice/issues/390>`__)


0.10.1
Expand Down
2 changes: 1 addition & 1 deletion chalice/__init__.py
Expand Up @@ -3,7 +3,7 @@
ChaliceViewError, BadRequestError, UnauthorizedError, ForbiddenError,
NotFoundError, ConflictError, TooManyRequestsError, Response, CORSConfig,
CustomAuthorizer, CognitoUserPoolAuthorizer, IAMAuthorizer,
AuthResponse, AuthRoute
AuthResponse, AuthRoute, Cron, Rate
)

__version__ = '0.10.1'
100 changes: 99 additions & 1 deletion chalice/app.py
Expand Up @@ -428,6 +428,7 @@ def __init__(self, app_name, configure_logs=True):
self.log = logging.getLogger(self.app_name)
self._authorizers = {}
self.builtin_auth_handlers = []
self.event_sources = []
if self.configure_logs:
self._configure_logging()

Expand Down Expand Up @@ -490,9 +491,22 @@ def _register_authorizer(auth_func):
execution_role=execution_role,
)
self.builtin_auth_handlers.append(auth_config)
return ChaliceAuthorizer(name, auth_func, auth_config)
return ChaliceAuthorizer(auth_name, auth_func, auth_config)
return _register_authorizer

def schedule(self, expression, name=None):
def _register_schedule(event_func):
handler_name = name
if handler_name is None:
handler_name = event_func.__name__
event_source = CloudWatchEventSource(
name=handler_name,
schedule_expression=expression,
handler_string='app.%s' % event_func.__name__)
self.event_sources.append(event_source)
return ScheduledEventHandler(event_func)
return _register_schedule

def route(self, path, **kwargs):
def _register_view(view_func):
self._add_route(path, view_func, **kwargs)
Expand Down Expand Up @@ -775,3 +789,87 @@ class AuthRoute(object):
def __init__(self, path, methods):
self.path = path
self.methods = methods


class EventSource(object):
def __init__(self, name, handler_string):
self.name = name
self.handler_string = handler_string


class CloudWatchEventSource(EventSource):
def __init__(self, name, handler_string, schedule_expression):
super(CloudWatchEventSource, self).__init__(name, handler_string)
self.schedule_expression = schedule_expression


class ScheduleExpression(object):
def to_string(self):
raise NotImplementedError("to_string")


class Rate(ScheduleExpression):
MINUTES = 'MINUTES'
HOURS = 'HOURS'
DAYS = 'DAYS'

def __init__(self, value, unit):
self.value = value
self.unit = unit

def to_string(self):
unit = self.unit.lower()
if self.value == 1:
# Remove the 's' from the end if it's singular.
# This is required by the cloudwatch events API.
unit = unit[:-1]
return 'rate(%s %s)' % (self.value, unit)


class Cron(ScheduleExpression):
def __init__(self, minutes, hours, day_of_month, month, day_of_week, year):
self.minutes = minutes
self.hours = hours
self.day_of_month = day_of_month
self.month = month
self.day_of_week = day_of_week
self.year = year

def to_string(self):
return 'cron(%s %s %s %s %s %s)' % (
self.minutes,
self.hours,
self.day_of_month,
self.month,
self.day_of_week,
self.year,
)


class ScheduledEventHandler(object):
def __init__(self, func):
self.func = func

def __call__(self, event, context):
event_obj = self._convert_to_obj(event)
return self.func(event_obj)

def _convert_to_obj(self, event_dict):
return CloudWatchEvent(event_dict)


class CloudWatchEvent(object):
def __init__(self, event_dict):
self.version = event_dict['version']
self.account = event_dict['account']
self.region = event_dict['region']
self.detail = event_dict['detail']
self.detail_type = event_dict['detail-type']
self.source = event_dict['source']
self.time = event_dict['time']
self.event_id = event_dict['id']
self.resources = event_dict['resources']
self._event_dict = event_dict

def to_dict(self):
return self._event_dict
32 changes: 32 additions & 0 deletions chalice/app.pyi
Expand Up @@ -110,6 +110,7 @@ class Chalice(object):
debug = ... # type: bool
authorizers = ... # type: Dict[str, Dict[str, Any]]
builtin_auth_handlers = ... # type: List[BuiltinAuthConfig]
event_sources = ... # type: List[CloudWatchEventSource]

def __init__(self, app_name: str) -> None: ...

Expand Down Expand Up @@ -148,3 +149,34 @@ class AuthResponse(object):
routes = ... # type: Union[str, AuthRoute]
principal_id = ... # type: str
context = ... # type: Optional[Dict[str, str]]


class EventSource(object):
name = ... # type: str
handler_string = ... # type: str


class CloudWatchEventSource(EventSource):
schedule_expression = ... # type: Union[str, ScheduleExpression]


class ScheduleExpression(object):
def to_string(self) -> str: ...


class Rate(ScheduleExpression):
unit = ... # type: int
value = ... # type: str

def to_string(self) -> str: ...


class Cron(ScheduleExpression):
minutes = ... # type: Union[str, int]
hours = ... # type: Union[str, int]
day_of_month = ... # type: Union[str, int]
month = ... # type: Union[str, int]
day_of_week = ... # type: Union[str, int]
year = ... # type: Union[str, int]

def to_string(self) -> str: ...
129 changes: 81 additions & 48 deletions chalice/awsclient.py
Expand Up @@ -387,57 +387,54 @@ def add_permission_for_apigateway_if_needed(self, function_name,
``self.add_permission_for_apigateway(...).
"""
has_necessary_permissions = False
client = self._client('lambda')
try:
policy = self.get_function_policy(function_name)
except client.exceptions.ResourceNotFoundException:
pass
else:
source_arn = self._build_source_arn_str(region_name, account_id,
rest_api_id)
# Here's what a sample policy looks like after add_permission()
# has been previously called:
# {
# "Id": "default",
# "Statement": [
# {
# "Action": "lambda:InvokeFunction",
# "Condition": {
# "ArnLike": {
# "AWS:SourceArn": <source_arn>
# }
# },
# "Effect": "Allow",
# "Principal": {
# "Service": "apigateway.amazonaws.com"
# },
# "Resource": "arn:aws:lambda:us-west-2:aid:function:name",
# "Sid": "e4755709-067e-4254-b6ec-e7f9639e6f7b"
# }
# ],
# "Version": "2012-10-17"
# }
# So we need to check if there's a policy that looks like this.
for statement in policy.get('Statement', []):
if self._gives_apigateway_access(statement, function_name,
source_arn):
has_necessary_permissions = True
break
if not has_necessary_permissions:
self.add_permission_for_apigateway(
function_name, region_name, account_id, rest_api_id, random_id)

def _gives_apigateway_access(self, statement, function_name, source_arn):
policy = self.get_function_policy(function_name)
source_arn = self._build_source_arn_str(region_name, account_id,
rest_api_id)
if self._policy_gives_access(policy, source_arn, 'apigateway'):
return
self.add_permission_for_apigateway(
function_name, region_name, account_id, rest_api_id, random_id)

def _policy_gives_access(self, policy, source_arn, service_name):
# type: (Dict[str, Any], str, str) -> bool
# Here's what a sample policy looks like after add_permission()
# has been previously called:
# {
# "Id": "default",
# "Statement": [
# {
# "Action": "lambda:InvokeFunction",
# "Condition": {
# "ArnLike": {
# "AWS:SourceArn": <source_arn>
# }
# },
# "Effect": "Allow",
# "Principal": {
# "Service": "apigateway.amazonaws.com"
# },
# "Resource": "arn:aws:lambda:us-west-2:aid:function:name",
# "Sid": "e4755709-067e-4254-b6ec-e7f9639e6f7b"
# }
# ],
# "Version": "2012-10-17"
# }
# So we need to check if there's a policy that looks like this.
for statement in policy.get('Statement', []):
if self._statement_gives_arn_access(statement, source_arn,
service_name):
return True
return False

def _statement_gives_arn_access(self, statement, source_arn, service_name):
# type: (Dict[str, Any], str, str) -> bool
if not statement['Action'] == 'lambda:InvokeFunction':
return False
if statement.get('Condition', {}).get('ArnLike',
{}).get('AWS:SourceArn',
'') != source_arn:
if statement.get('Condition', {}).get(
'ArnLike', {}).get('AWS:SourceArn', '') != source_arn:
return False
if statement.get('Principal', {}).get('Service', '') != \
'apigateway.amazonaws.com':
'%s.amazonaws.com' % service_name:
return False
# We're not checking the "Resource" key because we're assuming
# that lambda.get_policy() is returning the policy for the particular
Expand All @@ -453,8 +450,11 @@ def get_function_policy(self, function_name):
"""
client = self._client('lambda')
policy = client.get_policy(FunctionName=function_name)
return json.loads(policy['Policy'])
try:
policy = client.get_policy(FunctionName=function_name)
return json.loads(policy['Policy'])
except client.exceptions.ResourceNotFoundException:
return {'Statement': []}

def download_sdk(self, rest_api_id, output_dir,
api_gateway_stage=DEFAULT_STAGE_NAME,
Expand Down Expand Up @@ -601,6 +601,39 @@ def add_permission_for_authorizer(self, rest_api_id, function_arn,
SourceArn=source_arn,
)

def get_or_create_rule_arn(self, rule_name, schedule_expression):
# type: (str, str) -> str
events = self._client('events')
# put_rule is idempotent so we can safely call it even if it already
# exists.
rule_arn = events.put_rule(Name=rule_name,
ScheduleExpression=schedule_expression)
return rule_arn['RuleArn']

def connect_rule_to_lambda(self, rule_name, function_arn):
# type: (str, str) -> None
events = self._client('events')
events.put_targets(Rule=rule_name,
Targets=[{'Id': '1', 'Arn': function_arn}])

def add_permission_for_scheduled_event(self, rule_arn,
function_arn):
# type: (str, str) -> None
lambda_client = self._client('lambda')
policy = self.get_function_policy(function_arn)
if self._policy_gives_access(policy, rule_arn, 'events'):
return
random_id = self._random_id()
# We should be checking if the permission already exists and only
# adding it if necessary.
lambda_client.add_permission(
Action='lambda:InvokeFunction',
FunctionName=function_arn,
StatementId=random_id,
Principal='events.amazonaws.com',
SourceArn=rule_arn,
)

def _random_id(self):
# type: () -> str
return str(uuid.uuid4())
19 changes: 19 additions & 0 deletions chalice/config.py
Expand Up @@ -308,6 +308,25 @@ def __init__(self, backend, api_handler_arn,
self.region = region
self.chalice_version = chalice_version
self.lambda_functions = lambda_functions
self._fixup_lambda_functions_if_needed()

def _fixup_lambda_functions_if_needed(self):
# type: () -> None
# In version 0.10.0 of chalice, 'lambda_functions'
# was introduced where the value was just the string ARN.
# With the introduction of scheduled events, we need to
# be able to distinguish the purpose of the lambda function.
# To smooth this over, we'll convert the old format to the
# new one. The deployer.py module will take care of writing out
# a new deployed.json in the correct format.
if all(isinstance(v, dict) for v in self.lambda_functions.values()):
return
for k, v in self.lambda_functions.items():
# In 0.10.0 the only type of lambda function we supported
# was custom authorizers so we can safely assume the type
# was authorizer.
self.lambda_functions[k] = {'type': 'authorizer',
'arn': v}

@classmethod
def from_dict(cls, data):
Expand Down

0 comments on commit 2fa94a1

Please sign in to comment.