From 540493d406eb9c8ee171f4b755d63190d948f006 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 9 May 2026 23:04:17 -0400 Subject: [PATCH] Add Lambda versioning and alias support --- chalice/awsclient.py | 84 ++++++++++ chalice/config.py | 6 + chalice/deploy/appgraph.py | 1 + chalice/deploy/models.py | 1 + chalice/deploy/planner.py | 240 +++++++++++++++++++++++++---- chalice/deploy/swagger.py | 47 +++++- chalice/package.py | 135 +++++++++++++--- docs/source/topics/configfile.rst | 19 +++ tests/functional/test_awsclient.py | 63 ++++++++ tests/unit/deploy/test_appgraph.py | 14 ++ tests/unit/deploy/test_planner.py | 219 +++++++++++++++++++++++++- tests/unit/deploy/test_swagger.py | 36 +++++ tests/unit/test_config.py | 23 +++ tests/unit/test_package.py | 20 +++ 14 files changed, 849 insertions(+), 59 deletions(-) diff --git a/chalice/awsclient.py b/chalice/awsclient.py index 2cf2b3129..483b3b3d1 100644 --- a/chalice/awsclient.py +++ b/chalice/awsclient.py @@ -1002,6 +1002,87 @@ def _update_function_config( if kwargs: self._do_update_function_config(function_name, kwargs) + def publish_function_version( + self, function_name: str + ) -> Dict[str, Any]: + lambda_client = self._client('lambda') + try: + result = lambda_client.publish_version( + FunctionName=function_name + ) + except lambda_client.exceptions.ResourceConflictException: + result = self._latest_published_function_version(function_name) + self._wait_for_active_function_version( + function_name, result['Version']) + return result + + def _latest_published_function_version( + self, function_name: str + ) -> Dict[str, Any]: + lambda_client = self._client('lambda') + latest = None # type: Optional[Dict[str, Any]] + kwargs = {'FunctionName': function_name} # type: Dict[str, Any] + while True: + response = lambda_client.list_versions_by_function(**kwargs) + for version in response.get('Versions', []): + version_name = version.get('Version') + if version_name is not None and version_name.isdigit(): + if latest is None: + latest = version + elif int(version_name) > int(latest.get('Version', '0')): + latest = version + marker = response.get('NextMarker') + if marker is None: + break + kwargs['Marker'] = marker + if latest is None: + raise RuntimeError( + 'Unable to find published version for %s' % function_name + ) + return latest + + def _wait_for_active_function_version( + self, function_name: str, version: str + ) -> None: + lambda_client = self._client('lambda') + function_version = '%s:%s' % (function_name, version) + for _ in range(self.LAMBDA_CREATE_ATTEMPTS): + config = lambda_client.get_function_configuration( + FunctionName=function_version + ) + active = config.get('State') == 'Active' + updated = config.get('LastUpdateStatus') in (None, 'Successful') + if active and updated: + return + self._sleep(self.DELAY_TIME) + raise RuntimeError( + 'Timed out waiting for published version %s to become active' % + function_version + ) + + def create_or_update_function_alias( + self, function_name: str, alias_name: str, function_version: str + ) -> Dict[str, Any]: + lambda_client = self._client('lambda') + try: + alias = lambda_client.get_alias( + FunctionName=function_name, + Name=alias_name, + ) + except lambda_client.exceptions.ResourceNotFoundException: + return lambda_client.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion=function_version, + ) + if alias.get('FunctionVersion') == function_version: + return alias + return lambda_client.update_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion=function_version, + ) + def _do_update_function_config( self, function_name: str, kwargs: Dict[str, Any] ) -> None: @@ -1854,6 +1935,7 @@ def update_lambda_event_source( batch_size: int, maximum_batching_window_in_seconds: Optional[int] = 0, maximum_concurrency: Optional[int] = None, + function_name: Optional[str] = None, ) -> None: lambda_client = self._client('lambda') batch_window = maximum_batching_window_in_seconds @@ -1866,6 +1948,8 @@ def update_lambda_event_source( kwargs['ScalingConfig'] = { 'MaximumConcurrency': maximum_concurrency } + if function_name is not None: + kwargs['FunctionName'] = function_name self._call_client_method_with_retries( lambda_client.update_event_source_mapping, kwargs, diff --git a/chalice/config.py b/chalice/config.py index 6f7d571ea..04cbd1bd8 100644 --- a/chalice/config.py +++ b/chalice/config.py @@ -343,6 +343,12 @@ def reserved_concurrency(self) -> int: varies_per_chalice_stage=True, varies_per_function=True) + @property + def lambda_alias(self) -> Optional[str]: + return self._chain_lookup('lambda_alias', + varies_per_chalice_stage=True, + varies_per_function=True) + def scope(self, chalice_stage: str, function_name: str) -> Config: # Used to create a new config object that's scoped to a different # stage and/or function. This creates a completely separate copy. diff --git a/chalice/deploy/appgraph.py b/chalice/deploy/appgraph.py index 4572fffb2..7a506b56b 100644 --- a/chalice/deploy/appgraph.py +++ b/chalice/deploy/appgraph.py @@ -575,6 +575,7 @@ def _build_lambda_function( layers=lambda_layers, managed_layer=self._get_managed_lambda_layer(config), xray=config.xray_enabled, + lambda_alias=config.lambda_alias, ) self._inject_role_traits(function, role) return function diff --git a/chalice/deploy/models.py b/chalice/deploy/models.py index 887f5a120..441d448fd 100644 --- a/chalice/deploy/models.py +++ b/chalice/deploy/models.py @@ -205,6 +205,7 @@ class LambdaFunction(ManagedModel): layers: List[str] managed_layer: Opt[LambdaLayer] = None log_group: Opt[LogGroup] = None + lambda_alias: Opt[str] = None def dependencies(self) -> List[Model]: resources: List[Model] = [] diff --git a/chalice/deploy/planner.py b/chalice/deploy/planner.py index a22072584..e9f3d199d 100644 --- a/chalice/deploy/planner.py +++ b/chalice/deploy/planner.py @@ -493,7 +493,6 @@ def _plan_lambdafunction(self, resource): 'subnet_ids': resource.subnet_ids, 'layers': layers } - api_calls.extend([ (models.APICall( method_name='create_function', @@ -544,6 +543,62 @@ def _plan_lambdafunction(self, resource): ) ]) api_calls.append(concurrency_api_call) + if resource.lambda_alias is not None: + version_varname = '%s_lambda_version' % resource.resource_name + version_result_varname = ( + '%s_lambda_version_result' % resource.resource_name + ) + alias_result_varname = ( + '%s_lambda_alias_result' % resource.resource_name + ) + api_calls.extend([ + (models.APICall( + method_name='publish_function_version', + params={ + 'function_name': resource.function_name, + }, + output_var=version_result_varname, + ), "Publishing lambda function version: %s\n" % + resource.function_name), + models.JPSearch( + 'Version', + input_var=version_result_varname, + output_var=version_varname, + ), + (models.APICall( + method_name='create_or_update_function_alias', + params={ + 'function_name': resource.function_name, + 'alias_name': resource.lambda_alias, + 'function_version': Variable(version_varname), + }, + output_var=alias_result_varname, + ), "Updating lambda function alias: %s:%s\n" % + (resource.function_name, resource.lambda_alias)), + models.JPSearch( + 'AliasArn', + input_var=alias_result_varname, + output_var=varname, + ), + models.RecordResourceValue( + resource_type='lambda_function', + resource_name=resource.resource_name, + name='lambda_alias', + value=resource.lambda_alias, + ), + models.RecordResourceVariable( + resource_type='lambda_function', + resource_name=resource.resource_name, + name='lambda_alias_arn', + variable_name=varname, + ), + models.RecordResourceVariable( + resource_type='lambda_function', + resource_name=resource.resource_name, + name='lambda_version', + variable_name=version_varname, + ), + ]) return api_calls def _plan_managediamrole(self, resource): @@ -664,6 +719,42 @@ def _plan_snslambdasubscription(self, resource): # from the topic. deployed = self._remote_state.resource_deployed_values(resource) subscription_arn = deployed['subscription_arn'] + if self._deployed_lambda_arn_needs_update( + deployed.get('lambda_arn'), + resource.lambda_function.lambda_alias, + ): + return instruction_for_topic_arn + [ + models.APICall( + method_name='add_permission_for_sns_topic', + params={'topic_arn': Variable(topic_arn_varname), + 'function_arn': function_arn}, + ), + (models.APICall( + method_name='subscribe_function_to_topic', + params={'topic_arn': Variable(topic_arn_varname), + 'function_arn': function_arn}, + output_var=subscribe_varname, + ), 'Subscribing %s to SNS topic %s\n' + % (resource.lambda_function.function_name, + resource.topic) + ), + models.APICall( + method_name='unsubscribe_from_topic', + params={'subscription_arn': subscription_arn}, + ), + models.APICall( + method_name='remove_permission_for_sns_topic', + params={'topic_arn': deployed['topic_arn'], + 'function_arn': deployed['lambda_arn']}, + ), + ] + self._batch_record_resource( + 'sns_event', resource.resource_name, { + 'topic': resource.topic, + 'lambda_arn': Variable(function_arn.name), + 'subscription_arn': Variable(subscribe_varname), + 'topic_arn': Variable(topic_arn_varname), + } + ) return instruction_for_topic_arn + self._batch_record_resource( 'sns_event', resource.resource_name, { 'topic': resource.topic, @@ -728,23 +819,31 @@ def _plan_sqseventsource(self, resource): if self._remote_state.resource_exists(resource): deployed = self._remote_state.resource_deployed_values(resource) uuid = deployed['event_uuid'] + params = { + 'event_uuid': uuid, + 'batch_size': resource.batch_size, + 'maximum_batching_window_in_seconds': + resource.maximum_batching_window_in_seconds, + 'maximum_concurrency': resource.maximum_concurrency + } + if self._deployed_lambda_arn_needs_update( + deployed.get('lambda_arn'), + resource.lambda_function.lambda_alias, + ): + params['function_name'] = function_arn return instruction_for_queue_arn + [ models.APICall( method_name='update_lambda_event_source', - params={ - 'event_uuid': uuid, - 'batch_size': resource.batch_size, - 'maximum_batching_window_in_seconds': - resource.maximum_batching_window_in_seconds, - 'maximum_concurrency': resource.maximum_concurrency - } + params=params, ) ] + self._batch_record_resource( 'sqs_event', resource.resource_name, { 'queue_arn': deployed['queue_arn'], 'event_uuid': uuid, 'queue': queue_name, - 'lambda_arn': deployed['lambda_arn'], + 'lambda_arn': self._recorded_lambda_arn( + deployed['lambda_arn'], function_arn, resource + ), } ) return instruction_for_queue_arn + [ @@ -790,20 +889,28 @@ def _plan_kinesiseventsource(self, resource): if self._remote_state.resource_exists(resource): deployed = self._remote_state.resource_deployed_values(resource) uuid = deployed['event_uuid'] + params = {'event_uuid': uuid, + 'batch_size': resource.batch_size, + 'maximum_batching_window_in_seconds': + resource.maximum_batching_window_in_seconds} + if self._deployed_lambda_arn_needs_update( + deployed.get('lambda_arn'), + resource.lambda_function.lambda_alias, + ): + params['function_name'] = function_arn return instruction_for_stream_arn + [ models.APICall( method_name='update_lambda_event_source', - params={'event_uuid': uuid, - 'batch_size': resource.batch_size, - 'maximum_batching_window_in_seconds': - resource.maximum_batching_window_in_seconds} + params=params, ) ] + self._batch_record_resource( 'kinesis_event', resource.resource_name, { 'kinesis_arn': deployed['kinesis_arn'], 'event_uuid': uuid, 'stream': resource.stream, - 'lambda_arn': deployed['lambda_arn'], + 'lambda_arn': self._recorded_lambda_arn( + deployed['lambda_arn'], function_arn, resource + ), } ) return instruction_for_stream_arn + [ @@ -873,19 +980,27 @@ def _plan_dynamodbeventsource(self, resource): if self._remote_state.resource_exists(resource): deployed = self._remote_state.resource_deployed_values(resource) uuid = deployed['event_uuid'] + params = {'event_uuid': uuid, + 'batch_size': resource.batch_size, + 'maximum_batching_window_in_seconds': + resource.maximum_batching_window_in_seconds} + if self._deployed_lambda_arn_needs_update( + deployed.get('lambda_arn'), + resource.lambda_function.lambda_alias, + ): + params['function_name'] = function_arn return instructions + [ models.APICall( method_name='update_lambda_event_source', - params={'event_uuid': uuid, - 'batch_size': resource.batch_size, - 'maximum_batching_window_in_seconds': - resource.maximum_batching_window_in_seconds} + params=params, ) ] + self._batch_record_resource( 'dynamodb_event', resource.resource_name, { 'stream_arn': deployed['stream_arn'], 'event_uuid': deployed['event_uuid'], - 'lambda_arn': deployed['lambda_arn'], + 'lambda_arn': self._recorded_lambda_arn( + deployed['lambda_arn'], function_arn, resource + ), } ) return instructions + [ @@ -928,7 +1043,27 @@ def _plan_s3bucketnotification(self, resource): function_arn = Variable( '%s_lambda_arn' % resource.lambda_function.resource_name ) - return self._arn_parse_instructions(function_arn) + [ + cleanup_plan = [] # type: List[InstructionMsg] + if self._remote_state.resource_exists(resource): + deployed = self._remote_state.resource_deployed_values(resource) + if self._deployed_lambda_arn_needs_update( + deployed.get('lambda_arn'), + resource.lambda_function.lambda_alias, + ): + cleanup_plan = [ + models.APICall( + method_name='disconnect_s3_bucket_from_lambda', + params={'bucket': deployed['bucket'], + 'function_arn': deployed['lambda_arn']}, + ), + models.APICall( + method_name='remove_permission_for_s3_event', + params={'bucket': deployed['bucket'], + 'function_arn': deployed['lambda_arn'], + 'account_id': Variable('account_id')}, + ), + ] + notification_plan = cast(List[InstructionMsg], [ models.APICall( method_name='add_permission_for_s3_event', params={'bucket': resource.bucket, @@ -945,6 +1080,8 @@ def _plan_s3bucketnotification(self, resource): ), 'Configuring S3 events in bucket %s to function %s\n' % (resource.bucket, resource.lambda_function.function_name) ), + ]) + cleanup_plan + record_plan = cast(List[InstructionMsg], [ models.RecordResourceValue( resource_type='s3_event', resource_name=resource.resource_name, @@ -957,7 +1094,32 @@ def _plan_s3bucketnotification(self, resource): name='lambda_arn', variable_name=function_arn.name, ), - ] + ]) + return ( + self._arn_parse_instructions(function_arn) + + notification_plan + record_plan + ) + + def _recorded_lambda_arn( + self, deployed_lambda_arn, function_arn, resource + ): + # type: (str, Variable, models.FunctionEventSubscriber) -> Any + if self._deployed_lambda_arn_needs_update( + deployed_lambda_arn, + resource.lambda_function.lambda_alias, + ): + return Variable(function_arn.name) + return deployed_lambda_arn + + def _deployed_lambda_arn_needs_update(self, deployed_lambda_arn, alias): + # type: (Any, Optional[str]) -> bool + if not isinstance(deployed_lambda_arn, str): + return False + parts = deployed_lambda_arn.split(':') + qualifier = None + if len(parts) >= 8 and parts[5] == 'function': + qualifier = parts[7] + return qualifier != alias def _create_cloudwatchevent(self, resource): # type: (models.CloudWatchEventBase) -> Sequence[InstructionMsg] @@ -1033,21 +1195,32 @@ def _create_websocket_function_config(self, function): 'name': function.function_name, 'varname': varname, 'lambda_arn_var': Variable(varname), + 'permission_function_name': ( + Variable(varname) if function.lambda_alias is not None + else function.function_name + ) } def _inject_websocket_integrations(self, configs): # type: (Dict[str, Any]) -> Sequence[InstructionMsg] instructions = [] # type: List[InstructionMsg] for key, config in configs.items(): + variables = ['partition', 'region_name', 'account_id'] + function_uri = ( + 'arn:{partition}:lambda:{region_name}:{account_id}:function' + ':%s' % config['name'] + ) + if config['function'].lambda_alias is not None: + function_uri = '{%s}' % config['varname'] + variables = ['partition', 'region_name', config['varname']] instructions.append( models.StoreValue( name='websocket-%s-integration-lambda-path' % key, value=StringFormat( 'arn:{partition}:apigateway:{region_name}:lambda:path/' - '2015-03-31/functions/arn:{partition}' - ':lambda:{region_name}:{account_id}:function' - ':%s/invocations' % config['name'], - ['partition', 'region_name', 'account_id'], + '2015-03-31/functions/%s/invocations' % + function_uri, + variables, ), ), ) @@ -1123,7 +1296,8 @@ def _plan_websocketapi(self, resource): shared_plan_epilogue += [ models.APICall( method_name='add_permission_for_apigateway_v2', - params={'function_name': function_config['name'], + params={'function_name': + function_config['permission_function_name'], 'region_name': Variable('region_name'), 'account_id': Variable('account_id'), 'api_id': Variable('websocket_api_id')}, @@ -1222,9 +1396,12 @@ def _plan_websocketapi(self, resource): def _plan_restapi(self, resource): # type: (models.RestAPI) -> Sequence[InstructionMsg] function = resource.lambda_function - function_name = function.function_name varname = '%s_lambda_arn' % function.resource_name lambda_arn_var = Variable(varname) + function_name = function.function_name + permission_function_name = function_name # type: Union[str, Variable] + if function.lambda_alias is not None: + permission_function_name = lambda_arn_var # There's a set of shared instructions that are needed # in both the update as well as the initial create case. # That's what this shared_plan_premable is for. @@ -1257,7 +1434,7 @@ def _plan_restapi(self, resource): ), models.APICall( method_name='add_permission_for_apigateway', - params={'function_name': function_name, + params={'function_name': permission_function_name, 'region_name': Variable('region_name'), 'account_id': Variable('account_id'), 'rest_api_id': Variable('rest_api_id')}, @@ -1284,10 +1461,15 @@ def _plan_restapi(self, resource): ), ] # type: List[InstructionMsg] for auth in resource.authorizers: + auth_function_name: Union[str, Variable] = auth.function_name + if auth.lambda_alias is not None: + auth_function_name = Variable( + '%s_lambda_arn' % auth.resource_name + ) shared_plan_epilogue.append( models.APICall( method_name='add_permission_for_apigateway', - params={'function_name': auth.function_name, + params={'function_name': auth_function_name, 'region_name': Variable('region_name'), 'account_id': Variable('account_id'), 'rest_api_id': Variable('rest_api_id')}, diff --git a/chalice/deploy/swagger.py b/chalice/deploy/swagger.py index 98590bde1..369aaef9d 100644 --- a/chalice/deploy/swagger.py +++ b/chalice/deploy/swagger.py @@ -254,26 +254,46 @@ def _add_preflight_request(self, cors, methods, swagger_for_path): class CFNSwaggerGenerator(SwaggerGenerator): def __init__(self): # type: () -> None - pass + self._lambda_alias = None # type: Optional[str] + self._authorizer_aliases = {} # type: Dict[str, Optional[str]] + + def generate_swagger(self, app, rest_api=None): + # type: (Chalice, Optional[RestAPI]) -> Dict[str, Any] + if rest_api is not None and rest_api.lambda_function is not None: + self._lambda_alias = rest_api.lambda_function.lambda_alias + self._authorizer_aliases = { + auth.resource_name: auth.lambda_alias + for auth in rest_api.authorizers + } + return super(CFNSwaggerGenerator, self).generate_swagger( + app, rest_api + ) def _uri(self, lambda_arn=None): # type: (Optional[str]) -> Any + alias = '' + if self._lambda_alias is not None: + alias = ':%s' % self._lambda_alias return { 'Fn::Sub': ( 'arn:${AWS::Partition}:apigateway:${AWS::Region}' ':lambda:path/2015-03-31' - '/functions/${APIHandler.Arn}/invocations' + '/functions/${APIHandler.Arn}%s/invocations' % alias ) } def _auth_uri(self, authorizer): # type: (ChaliceAuthorizer) -> Any + resource_name = to_cfn_resource_name(authorizer.name) + alias = '' + if self._authorizer_aliases.get(authorizer.name) is not None: + alias = ':%s' % self._authorizer_aliases[authorizer.name] return { 'Fn::Sub': ( 'arn:${AWS::Partition}:apigateway:${AWS::Region}' ':lambda:path/2015-03-31' - '/functions/${%s.Arn}/invocations' % to_cfn_resource_name( - authorizer.name) + '/functions/${%s.Arn}%s/invocations' % + (resource_name, alias) ) } @@ -305,12 +325,29 @@ class TerraformSwaggerGenerator(SwaggerGenerator): def __init__(self): # type: () -> None - pass + self._lambda_alias = None # type: Optional[str] + self._authorizer_aliases = {} # type: Dict[str, Optional[str]] + + def generate_swagger(self, app, rest_api=None): + # type: (Chalice, Optional[RestAPI]) -> Dict[str, Any] + if rest_api is not None and rest_api.lambda_function is not None: + self._lambda_alias = rest_api.lambda_function.lambda_alias + self._authorizer_aliases = { + auth.resource_name: auth.lambda_alias + for auth in rest_api.authorizers + } + return super(TerraformSwaggerGenerator, self).generate_swagger( + app, rest_api + ) def _uri(self, lambda_arn=None): # type: (Optional[str]) -> Any + if self._lambda_alias is not None: + return '${aws_lambda_alias.api_handler.invoke_arn}' return '${aws_lambda_function.api_handler.invoke_arn}' def _auth_uri(self, authorizer): # type: (ChaliceAuthorizer) -> Any + if self._authorizer_aliases.get(authorizer.name) is not None: + return '${aws_lambda_alias.%s.invoke_arn}' % authorizer.name return '${aws_lambda_function.%s.invoke_arn}' % (authorizer.name) diff --git a/chalice/package.py b/chalice/package.py index 3b5ed4533..2606e6ac7 100644 --- a/chalice/package.py +++ b/chalice/package.py @@ -271,6 +271,11 @@ def _generate_lambdafunction(self, resource, template): } lambdafunction_definition['Properties'].update( reserved_concurrency_config) + if resource.lambda_alias is not None: + lambdafunction_definition['Properties']['AutoPublishAlias'] = ( + resource.lambda_alias) + lambdafunction_definition['Properties'][ + 'AutoPublishAliasAllProperties'] = True layers = list(resource.layers) or [] # type: List[Any] if self._chalice_layer: @@ -300,6 +305,26 @@ def _generate_lambdafunction(self, resource, template): resources[cfn_name] = lambdafunction_definition self._add_iam_role(resource, resources[cfn_name]) + def _lambda_alias_arn(self, resource_name, resources): + # type: (str, Dict[str, Any]) -> Optional[Dict[str, Any]] + alias = resources[resource_name]['Properties'].get('AutoPublishAlias') + if alias is None: + return None + return { + 'Fn::Sub': [ + '${FunctionArn}:%s' % alias, + {'FunctionArn': {'Fn::GetAtt': [resource_name, 'Arn']}}, + ] + } + + def _lambda_permission_function_name(self, resource_name, resources, + default): + # type: (str, Dict[str, Any], Any) -> Any + alias_arn = self._lambda_alias_arn(resource_name, resources) + if alias_arn is not None: + return alias_arn + return default + def _add_iam_role(self, resource, cfn_resource): # type: (models.LambdaFunction, Dict[str, Any]) -> None role = resource.role @@ -343,7 +368,8 @@ def _generate_restapi(self, resource, template): resources['APIHandlerInvokePermission'] = { 'Type': 'AWS::Lambda::Permission', 'Properties': { - 'FunctionName': {'Ref': 'APIHandler'}, + 'FunctionName': self._lambda_permission_function_name( + 'APIHandler', resources, {'Ref': 'APIHandler'}), 'Action': 'lambda:InvokeFunction', 'Principal': self._options.service_principal('apigateway'), 'SourceArn': { @@ -360,7 +386,11 @@ def _generate_restapi(self, resource, template): resources[auth_cfn_name + 'InvokePermission'] = { 'Type': 'AWS::Lambda::Permission', 'Properties': { - 'FunctionName': {'Fn::GetAtt': [auth_cfn_name, 'Arn']}, + 'FunctionName': self._lambda_permission_function_name( + auth_cfn_name, + resources, + {'Fn::GetAtt': [auth_cfn_name, 'Arn']}, + ), 'Action': 'lambda:InvokeFunction', 'Principal': self._options.service_principal('apigateway'), 'SourceArn': { @@ -413,6 +443,11 @@ def _inject_restapi_outputs(self, template): def _add_websocket_lambda_integration( self, api_ref, websocket_handler, resources): # type: (Dict[str, Any], str, Dict[str, Any]) -> None + alias = resources[websocket_handler]['Properties'].get( + 'AutoPublishAlias') + alias_suffix = '' + if alias is not None: + alias_suffix = ':%s' % alias resources['%sAPIIntegration' % websocket_handler] = { 'Type': 'AWS::ApiGatewayV2::Integration', 'Properties': { @@ -427,7 +462,8 @@ def _add_websocket_lambda_integration( ':lambda:path/2015-03-31/functions/arn' ':${AWS::Partition}:lambda:${AWS::Region}' ':${AWS::AccountId}:function' - ':${WebsocketHandler}/invocations' + ':${WebsocketHandler}%s/invocations' % + alias_suffix ), {'WebsocketHandler': {'Ref': websocket_handler}} ], @@ -441,7 +477,11 @@ def _add_websocket_lambda_invoke_permission( resources['%sInvokePermission' % websocket_handler] = { 'Type': 'AWS::Lambda::Permission', 'Properties': { - 'FunctionName': {'Ref': websocket_handler}, + 'FunctionName': self._lambda_permission_function_name( + websocket_handler, + resources, + {'Ref': websocket_handler}, + ), 'Action': 'lambda:InvokeFunction', 'Principal': self._options.service_principal('apigateway'), 'SourceArn': { @@ -855,6 +895,20 @@ def _fref(self, lambda_function, attr='arn'): return '${aws_lambda_function.%s.%s}' % ( lambda_function.resource_name, attr) + def _lambda_ref(self, lambda_function, attr='arn'): + # type: (models.LambdaFunction, str) -> str + if lambda_function.lambda_alias is not None: + return '${aws_lambda_alias.%s.%s}' % ( + lambda_function.resource_name, attr) + return self._fref(lambda_function, attr) + + def _lambda_ref_by_name(self, resource_name, template, attr='arn'): + # type: (str, Dict[str, Any], str) -> str + aliases = template['resource'].get('aws_lambda_alias', {}) + if resource_name in aliases: + return '${aws_lambda_alias.%s.%s}' % (resource_name, attr) + return '${aws_lambda_function.%s.%s}' % (resource_name, attr) + def _arnref(self, arn_template, **kw): # type: (str, str) -> str d = dict( @@ -886,8 +940,22 @@ def _generate_managediamrole(self, resource, template): def _add_websocket_lambda_integration( self, websocket_api_id, websocket_handler, template): # type: (str, str, Dict[str, Any]) -> None - websocket_handler_function_name = \ - "${aws_lambda_function.%s.function_name}" % websocket_handler + if websocket_handler in template['resource'].get( + 'aws_lambda_alias', {} + ): + websocket_function_arn = self._lambda_ref_by_name( + websocket_handler, template, 'arn') + function_expr = "%(websocket_function_arn)s" + else: + websocket_handler_function_name = ( + "${aws_lambda_function.%s.function_name}" % websocket_handler + ) + websocket_function_arn = websocket_handler_function_name + function_expr = ( + "arn:%(partition)s:lambda:%(region)s" + ":%(account_id)s:function" + ":%(websocket_function_arn)s" + ) resource_definition = { 'api_id': websocket_api_id, 'connection_type': 'INTERNET', @@ -895,11 +963,9 @@ def _add_websocket_lambda_integration( 'integration_type': 'AWS_PROXY', 'integration_uri': self._arnref( "arn:%(partition)s:apigateway:%(region)s" - ":lambda:path/2015-03-31/functions/arn" - ":%(partition)s:lambda:%(region)s" - ":%(account_id)s:function" - ":%(websocket_handler_function_name)s/invocations", - websocket_handler_function_name=websocket_handler_function_name + ":lambda:path/2015-03-31/functions/" + + function_expr + "/invocations", + websocket_function_arn=websocket_function_arn ) } template['resource'].setdefault( @@ -909,8 +975,16 @@ def _add_websocket_lambda_integration( def _add_websocket_lambda_invoke_permission( self, websocket_api_id, websocket_handler, template): # type: (str, str, Dict[str, Any]) -> None - websocket_handler_function_name = \ - "${aws_lambda_function.%s.function_name}" % websocket_handler + if websocket_handler in template['resource'].get( + 'aws_lambda_alias', {} + ): + websocket_handler_function_name = self._lambda_ref_by_name( + websocket_handler, template, 'arn') + else: + websocket_handler_function_name = ( + "${aws_lambda_function.%s.function_name}" % + websocket_handler + ) resource_definition = { "function_name": websocket_handler_function_name, "action": "lambda:InvokeFunction", @@ -1085,7 +1159,7 @@ def _generate_s3bucketnotification(self, resource, template): bnotify = { 'events': resource.events, - 'lambda_function_arn': self._fref(resource.lambda_function) + 'lambda_function_arn': self._lambda_ref(resource.lambda_function) } if resource.prefix: @@ -1111,7 +1185,7 @@ def _generate_s3bucketnotification(self, resource, template): resource.resource_name] = { 'statement_id': resource.resource_name, 'action': 'lambda:InvokeFunction', - 'function_name': self._fref(resource.lambda_function), + 'function_name': self._lambda_ref(resource.lambda_function), 'principal': self._options.service_principal('s3'), 'source_account': '${data.aws_caller_identity.chalice.account_id}', 'source_arn': ('arn:${data.aws_partition.chalice.partition}:' @@ -1134,7 +1208,7 @@ def _generate_sqseventsource(self, resource, template): 'batch_size': resource.batch_size, 'maximum_batching_window_in_seconds': resource.maximum_batching_window_in_seconds, - 'function_name': self._fref(resource.lambda_function), + 'function_name': self._lambda_ref(resource.lambda_function), } if resource.maximum_concurrency: aws_lambda_event_source_mapping["scaling_config"] = { @@ -1155,7 +1229,7 @@ def _generate_kinesiseventsource(self, resource, template): 'starting_position': resource.starting_position, 'maximum_batching_window_in_seconds': resource.maximum_batching_window_in_seconds, - 'function_name': self._fref(resource.lambda_function) + 'function_name': self._lambda_ref(resource.lambda_function) } def _generate_dynamodbeventsource(self, resource, template): @@ -1167,7 +1241,7 @@ def _generate_dynamodbeventsource(self, resource, template): 'starting_position': resource.starting_position, 'maximum_batching_window_in_seconds': resource.maximum_batching_window_in_seconds, - 'function_name': self._fref(resource.lambda_function), + 'function_name': self._lambda_ref(resource.lambda_function), } def _generate_snslambdasubscription(self, resource, template): @@ -1184,11 +1258,11 @@ def _generate_snslambdasubscription(self, resource, template): resource.resource_name] = { 'topic_arn': topic_arn, 'protocol': 'lambda', - 'endpoint': self._fref(resource.lambda_function) + 'endpoint': self._lambda_ref(resource.lambda_function) } template['resource'].setdefault('aws_lambda_permission', {})[ resource.resource_name] = { - 'function_name': self._fref(resource.lambda_function), + 'function_name': self._lambda_ref(resource.lambda_function), 'action': 'lambda:InvokeFunction', 'principal': self._options.service_principal('sns'), 'source_arn': topic_arn @@ -1225,12 +1299,12 @@ def _cwe_helper(self, resource, template): 'rule': '${aws_cloudwatch_event_rule.%s.name}' % ( resource.resource_name), 'target_id': resource.resource_name, - 'arn': self._fref(resource.lambda_function) + 'arn': self._lambda_ref(resource.lambda_function) } template['resource'].setdefault( 'aws_lambda_permission', {})[ resource.resource_name] = { - 'function_name': self._fref(resource.lambda_function), + 'function_name': self._lambda_ref(resource.lambda_function), 'action': 'lambda:InvokeFunction', 'principal': self._options.service_principal('events'), 'source_arn': "${aws_cloudwatch_event_rule.%s.arn}" % ( @@ -1286,6 +1360,8 @@ def _generate_lambdafunction(self, resource, template): if resource.layers: func_definition.setdefault('layers', []).extend( list(resource.layers)) + if resource.lambda_alias is not None: + func_definition['publish'] = True if isinstance(resource.role, models.ManagedIAMRole): func_definition['role'] = '${aws_iam_role.%s.arn}' % ( @@ -1305,6 +1381,17 @@ def _generate_lambdafunction(self, resource, template): } template['resource'].setdefault('aws_lambda_function', {})[ resource.resource_name] = func_definition + self._add_lambda_alias(resource, template) + + def _add_lambda_alias(self, resource, template): + # type: (models.LambdaFunction, Dict[str, Any]) -> None + if resource.lambda_alias is not None: + template['resource'].setdefault('aws_lambda_alias', {})[ + resource.resource_name] = { + 'name': resource.lambda_alias, + 'function_name': self._fref(resource, 'function_name'), + 'function_version': self._fref(resource, 'version'), + } def _generate_log_group(self, resource, remplate): # type: (models.LogGroup, Dict[str, Any]) -> None @@ -1359,7 +1446,7 @@ def _generate_restapi(self, resource, template): template['resource'].setdefault('aws_lambda_permission', {})[ resource.resource_name + '_invoke'] = { - 'function_name': self._fref(resource.lambda_function), + 'function_name': self._lambda_ref(resource.lambda_function), 'action': 'lambda:InvokeFunction', 'principal': self._options.service_principal('apigateway'), 'source_arn': @@ -1381,7 +1468,7 @@ def _generate_restapi(self, resource, template): for auth in resource.authorizers: template['resource']['aws_lambda_permission'][ auth.resource_name + '_invoke'] = { - 'function_name': self._fref(auth), + 'function_name': self._lambda_ref(auth), 'action': 'lambda:InvokeFunction', 'principal': self._options.service_principal('apigateway'), 'source_arn': ( diff --git a/docs/source/topics/configfile.rst b/docs/source/topics/configfile.rst index 35a498dd2..13732fb84 100644 --- a/docs/source/topics/configfile.rst +++ b/docs/source/topics/configfile.rst @@ -276,6 +276,24 @@ will be no reserved concurrency allocations. For more information, see `AWS Documentation on managing concurrency`_. +``lambda_alias`` +~~~~~~~~~~~~~~~~ + +The name of the AWS Lambda alias that Chalice should update after each +deployment. When this value is configured, Chalice publishes a new Lambda +function version after updating ``$LATEST`` and then updates the alias to +point to that published version. Event sources and API integrations are +configured to invoke the alias ARN. This value can be provided per stage as +well as per Lambda function. + +If you remove this setting, Chalice will stop publishing versions and updating +the alias. On the next deploy, Chalice-managed integrations are updated back +to the unqualified Lambda function ARN. For direct deployments, Chalice does +not delete the existing Lambda alias from AWS. Delete the alias manually if it +is no longer needed. For packaged deployments, the generated SAM or Terraform +template no longer includes the alias resource when this setting is removed. + + ``subnet_ids`` ~~~~~~~~~~~~~~ @@ -388,6 +406,7 @@ that can be applied per function: * ``lambda_memory_size`` * ``lambda_timeout`` * ``layers`` +* ``lambda_alias`` * ``manage_iam_role`` * ``reserved_concurrency`` * ``security_group_ids`` diff --git a/tests/functional/test_awsclient.py b/tests/functional/test_awsclient.py index 1a69fb1e4..cea36c693 100644 --- a/tests/functional/test_awsclient.py +++ b/tests/functional/test_awsclient.py @@ -3879,6 +3879,69 @@ def test_can_update_lambda_event_source(stubbed_session): stubbed_session.verify_stubs() +def test_can_update_lambda_event_source_function_name(stubbed_session): + lambda_stub = stubbed_session.stub('lambda') + lambda_stub.update_event_source_mapping( + UUID='my-uuid', + BatchSize=5, + MaximumBatchingWindowInSeconds=60, + FunctionName='function:live', + ).returns({}) + + stubbed_session.activate_stubs() + client = TypedAWSClient(stubbed_session) + client.update_lambda_event_source( + event_uuid='my-uuid', batch_size=5, + maximum_batching_window_in_seconds=60, + function_name='function:live', + ) + stubbed_session.verify_stubs() + + +def test_can_publish_function_version(stubbed_session): + lambda_stub = stubbed_session.stub('lambda') + lambda_stub.publish_version( + FunctionName='function-name', + ).returns({'FunctionArn': 'function-arn:2', 'Version': '2'}) + lambda_stub.get_function_configuration( + FunctionName='function-name:2', + ).returns({'State': 'Active', 'LastUpdateStatus': 'Successful'}) + + stubbed_session.activate_stubs() + client = TypedAWSClient(stubbed_session) + result = client.publish_function_version('function-name') + assert result == {'FunctionArn': 'function-arn:2', 'Version': '2'} + stubbed_session.verify_stubs() + + +def test_can_update_function_alias(stubbed_session): + lambda_stub = stubbed_session.stub('lambda') + lambda_stub.get_alias( + FunctionName='function-name', + Name='live', + ).returns({ + 'AliasArn': 'function-arn:live', + 'Name': 'live', + 'FunctionVersion': '1', + }) + lambda_stub.update_alias( + FunctionName='function-name', + Name='live', + FunctionVersion='2', + ).returns({ + 'AliasArn': 'function-arn:live', + 'Name': 'live', + 'FunctionVersion': '2', + }) + + stubbed_session.activate_stubs() + client = TypedAWSClient(stubbed_session) + result = client.create_or_update_function_alias( + 'function-name', 'live', '2') + assert result['FunctionVersion'] == '2' + stubbed_session.verify_stubs() + + def test_can_create_log_group(stubbed_session): logs_stub = stubbed_session.stub('logs') logs_stub.create_log_group( diff --git a/tests/unit/deploy/test_appgraph.py b/tests/unit/deploy/test_appgraph.py index 0a2a023e0..c47f188dd 100644 --- a/tests/unit/deploy/test_appgraph.py +++ b/tests/unit/deploy/test_appgraph.py @@ -68,6 +68,7 @@ def create_config(self, app, app_name='lambda-only', api_gateway_custom_domain=None, websocket_api_custom_domain=None, log_retention_in_days=None, + lambda_alias=None, project_dir='.'): kwargs = { 'chalice_app': app, @@ -102,6 +103,8 @@ def create_config(self, app, app_name='lambda-only', kwargs['reserved_concurrency'] = reserved_concurrency if log_retention_in_days is not None: kwargs['log_retention_in_days'] = log_retention_in_days + if lambda_alias is not None: + kwargs['lambda_alias'] = lambda_alias kwargs['layers'] = layers config = Config.create(**kwargs) return config @@ -382,6 +385,17 @@ def test_can_build_lambda_function_app_with_reserved_concurrency( xray=None, ) + def test_can_build_lambda_function_with_alias( + self, sample_app_lambda_only): + builder = ApplicationGraphBuilder() + config = self.create_config(sample_app_lambda_only, + automatic_layer=False, + iam_role_arn='role:arn', + lambda_alias='live') + application = builder.build(config, stage_name='dev') + function = application.resources[0] + assert function.lambda_alias == 'live' + def test_multiple_lambda_functions_share_role_and_package( self, sample_app_lambda_only): # We're going to add another lambda_function to our app. diff --git a/tests/unit/deploy/test_planner.py b/tests/unit/deploy/test_planner.py index d0d934b63..2f33e7ebf 100644 --- a/tests/unit/deploy/test_planner.py +++ b/tests/unit/deploy/test_planner.py @@ -20,7 +20,8 @@ def create_function_resource(name, function_name=None, runtime='python2.7', handler='app.app', tags=None, timeout=60, memory_size=128, deployment_package=None, - role=None, layers=None, managed_layer=None): + role=None, layers=None, managed_layer=None, + lambda_alias=None): if function_name is None: function_name = 'appname-dev-%s' % name if environment_variables is None: @@ -48,6 +49,7 @@ def create_function_resource(name, function_name=None, layers=layers, reserved_concurrency=None, managed_layer=managed_layer, + lambda_alias=lambda_alias, ) @@ -737,6 +739,48 @@ def test_can_update_lambda_function_with_managed_layer(self): assert plan[3].method_name == 'update_function' assert plan[3].params['layers'] == [Variable('layer_version_arn')] + def test_can_publish_version_and_update_alias(self): + function = create_function_resource( + 'function_name', + lambda_alias='live', + ) + self.remote_state.declare_no_resources_exists() + plan = self.determine_plan(function) + assert plan[3] == models.APICall( + method_name='publish_function_version', + params={ + 'function_name': 'appname-dev-function_name', + }, + output_var='function_name_lambda_version_result', + ) + assert plan[4] == models.JPSearch( + 'Version', + input_var='function_name_lambda_version_result', + output_var='function_name_lambda_version', + ) + assert plan[5] == models.APICall( + method_name='create_or_update_function_alias', + params={ + 'function_name': 'appname-dev-function_name', + 'alias_name': 'live', + 'function_version': Variable('function_name_lambda_version'), + }, + output_var='function_name_lambda_alias_result', + ) + assert plan[6] == models.JPSearch( + 'AliasArn', + input_var='function_name_lambda_alias_result', + output_var='function_name_lambda_arn', + ) + self.assert_recorded_values( + plan, 'lambda_function', 'function_name', { + 'lambda_arn': Variable('function_name_lambda_arn'), + 'lambda_alias': 'live', + 'lambda_alias_arn': Variable('function_name_lambda_arn'), + 'lambda_version': Variable('function_name_lambda_version'), + } + ) + def test_can_create_function_with_reserved_concurrency(self): function = create_function_resource('function_name') function.reserved_concurrency = 5 @@ -1454,6 +1498,29 @@ def test_can_plan_rest_api(self): 'Creating Rest API\n' ] + def test_rest_api_permission_uses_alias_arn(self): + function = create_function_resource( + 'function_name', lambda_alias='live') + rest_api = models.RestAPI( + resource_name='rest_api', + swagger_doc={'swagger': '2.0'}, + endpoint_type='EDGE', + minimum_compression='100', + api_gateway_stage='api', + xray=False, + lambda_function=function, + ) + plan = self.determine_plan(rest_api) + assert plan[9] == models.APICall( + method_name='add_permission_for_apigateway', + params={ + 'function_name': Variable('function_name_lambda_arn'), + 'region_name': Variable('region_name'), + 'account_id': Variable('account_id'), + 'rest_api_id': Variable('rest_api_id'), + } + ) + def test_can_update_rest_api_with_policy(self): function = create_function_resource('function_name') rest_api = models.RestAPI( @@ -1877,6 +1944,83 @@ def test_can_update_sqs_event_with_queue_arn(self): } ) + def test_can_update_sqs_event_to_lambda_alias(self): + function = create_function_resource( + 'function_name', lambda_alias='live') + sqs_event_source = models.SQSEventSource( + resource_name='function_name-sqs-event-source', + queue=models.QueueARN(arn='arn:sqs:myqueue'), + batch_size=10, + lambda_function=function, + maximum_batching_window_in_seconds=0 + ) + self.remote_state.declare_resource_exists( + sqs_event_source, + queue='myqueue', + queue_arn='arn:sqs:myqueue', + resource_type='sqs_event', + lambda_arn='arn:aws:lambda:us-west-2:123:function:old', + event_uuid='my-uuid', + ) + plan = self.determine_plan(sqs_event_source) + assert plan[1] == models.APICall( + method_name='update_lambda_event_source', + params={ + 'event_uuid': 'my-uuid', + 'batch_size': 10, + 'maximum_batching_window_in_seconds': 0, + 'maximum_concurrency': None, + 'function_name': Variable('function_name_lambda_arn'), + }, + ) + self.assert_recorded_values( + plan, 'sqs_event', 'function_name-sqs-event-source', { + 'queue_arn': 'arn:sqs:myqueue', + 'event_uuid': 'my-uuid', + 'queue': 'myqueue', + 'lambda_arn': Variable('function_name_lambda_arn') + } + ) + + def test_can_update_sqs_event_from_lambda_alias(self): + function = create_function_resource('function_name') + sqs_event_source = models.SQSEventSource( + resource_name='function_name-sqs-event-source', + queue=models.QueueARN(arn='arn:sqs:myqueue'), + batch_size=10, + lambda_function=function, + maximum_batching_window_in_seconds=0 + ) + self.remote_state.declare_resource_exists( + sqs_event_source, + queue='myqueue', + queue_arn='arn:sqs:myqueue', + resource_type='sqs_event', + lambda_arn=( + 'arn:aws:lambda:us-west-2:123:function:old:live' + ), + event_uuid='my-uuid', + ) + plan = self.determine_plan(sqs_event_source) + assert plan[1] == models.APICall( + method_name='update_lambda_event_source', + params={ + 'event_uuid': 'my-uuid', + 'batch_size': 10, + 'maximum_batching_window_in_seconds': 0, + 'maximum_concurrency': None, + 'function_name': Variable('function_name_lambda_arn'), + }, + ) + self.assert_recorded_values( + plan, 'sqs_event', 'function_name-sqs-event-source', { + 'queue_arn': 'arn:sqs:myqueue', + 'event_uuid': 'my-uuid', + 'queue': 'myqueue', + 'lambda_arn': Variable('function_name_lambda_arn') + } + ) + def test_sqs_event_source_exists_updates_batch_size(self): function = create_function_resource('function_name') sqs_event_source = models.SQSEventSource( @@ -2145,6 +2289,45 @@ def test_can_update_kinesis_event_source(self): } ) + def test_can_update_kinesis_event_from_lambda_alias(self): + function = create_function_resource('function_name') + kinesis_event_source = models.KinesisEventSource( + resource_name='function_name-kinesis-event-source', + stream='mystream', + batch_size=10, + starting_position='LATEST', + maximum_batching_window_in_seconds=60, + lambda_function=function + ) + self.remote_state.declare_resource_exists( + kinesis_event_source, + stream='mystream', + kinesis_arn='arn:aws:kinesis:stream', + resource_type='kinesis_event', + lambda_arn=( + 'arn:aws:lambda:us-west-2:123:function:old:live' + ), + event_uuid='my-uuid', + ) + plan = self.determine_plan(kinesis_event_source) + assert plan[5] == models.APICall( + method_name='update_lambda_event_source', + params={ + 'event_uuid': 'my-uuid', + 'batch_size': 10, + 'maximum_batching_window_in_seconds': 60, + 'function_name': Variable('function_name_lambda_arn'), + } + ) + self.assert_recorded_values( + plan, 'kinesis_event', 'function_name-kinesis-event-source', { + 'kinesis_arn': 'arn:aws:kinesis:stream', + 'event_uuid': 'my-uuid', + 'stream': 'mystream', + 'lambda_arn': Variable('function_name_lambda_arn') + } + ) + class TestPlanDynamoDBSubscription(BasePlannerTests): def test_can_plan_dynamodb_event_source(self): @@ -2191,6 +2374,40 @@ def test_can_plan_dynamodb_event_source_update(self): }, ) + def test_can_plan_dynamodb_event_source_from_lambda_alias(self): + function = create_function_resource('function_name') + event_source = models.DynamoDBEventSource( + resource_name='handler-dynamodb-event-source', + stream_arn='arn:stream', batch_size=100, + maximum_batching_window_in_seconds=60, + starting_position='LATEST', lambda_function=function) + self.remote_state.declare_resource_exists( + event_source, + stream_arn='arn:stream', + resource_type='dynamodb_event', + lambda_arn=( + 'arn:aws:lambda:us-west-2:123:function:old:live' + ), + event_uuid='my-uuid', + ) + plan = self.determine_plan(event_source) + assert plan[0] == models.APICall( + method_name='update_lambda_event_source', + params={ + 'event_uuid': 'my-uuid', + 'batch_size': 100, + 'maximum_batching_window_in_seconds': 60, + 'function_name': Variable('function_name_lambda_arn'), + }, + ) + self.assert_recorded_values( + plan, 'dynamodb_event', 'handler-dynamodb-event-source', { + 'stream_arn': 'arn:stream', + 'event_uuid': 'my-uuid', + 'lambda_arn': Variable('function_name_lambda_arn') + } + ) + class TestRemoteState(object): def setup_method(self): diff --git a/tests/unit/deploy/test_swagger.py b/tests/unit/deploy/test_swagger.py index 900a94a78..8b4a8c28d 100644 --- a/tests/unit/deploy/test_swagger.py +++ b/tests/unit/deploy/test_swagger.py @@ -955,6 +955,27 @@ def foo(): } +def test_cfn_uses_lambda_alias_for_route_integrations(sample_app): + swagger_gen = CFNSwaggerGenerator() + rest_api = RestAPI( + resource_name='dev', + swagger_doc={}, + lambda_function=mock.Mock(spec=['lambda_alias'], lambda_alias='live'), + minimum_compression="", + api_gateway_stage="xyz", + endpoint_type="EDGE", + ) + doc = swagger_gen.generate_swagger(sample_app, rest_api) + uri = doc['paths']['/']['get']['x-amazon-apigateway-integration']['uri'] + assert uri == { + 'Fn::Sub': ( + 'arn:${AWS::Partition}:apigateway:${AWS::Region}' + ':lambda:path/2015-03-31/functions/' + '${APIHandler.Arn}:live/invocations' + ) + } + + def test_custom_auth_with_tf(sample_app): swagger_gen = TerraformSwaggerGenerator() @@ -982,3 +1003,18 @@ def foo(): 'authorizerUri': '${aws_lambda_function.auth.invoke_arn}' } } + + +def test_tf_uses_lambda_alias_for_route_integrations(sample_app): + swagger_gen = TerraformSwaggerGenerator() + rest_api = RestAPI( + resource_name='dev', + swagger_doc={}, + lambda_function=mock.Mock(spec=['lambda_alias'], lambda_alias='live'), + minimum_compression="", + api_gateway_stage="xyz", + endpoint_type="EDGE", + ) + doc = swagger_gen.generate_swagger(sample_app, rest_api) + uri = doc['paths']['/']['get']['x-amazon-apigateway-integration']['uri'] + assert uri == '${aws_lambda_alias.api_handler.invoke_arn}' diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 19c8f7d4d..0445f563a 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -193,6 +193,29 @@ def test_stage_overrides_function_values(): assert c.lambda_timeout == 20 +def test_can_chain_lambda_alias_values(): + disk_config = { + 'lambda_alias': 'global', + 'lambda_functions': { + 'api_handler': { + 'lambda_alias': 'function', + }, + }, + 'stages': { + 'dev': { + 'lambda_alias': 'stage', + 'lambda_functions': { + 'api_handler': { + 'lambda_alias': 'stage-function', + }, + }, + }, + }, + } + c = Config(chalice_stage='dev', config_from_disk=disk_config) + assert c.lambda_alias == 'stage-function' + + def test_can_create_scope_obj_with_new_function(): disk_config = { 'lambda_timeout': 10, diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index aed1698ee..5d32b80dd 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -449,6 +449,18 @@ def test_adds_reserved_concurrency_when_provided(self, sample_app): tf_resource = self.get_function(template) assert tf_resource['reserved_concurrent_executions'] == 5 + def test_adds_alias_when_provided(self, sample_app): + function = self.lambda_function() + function.lambda_alias = 'live' + template = self.template_gen.generate([function]) + tf_resource = self.get_function(template) + assert tf_resource['publish'] is True + assert template['resource']['aws_lambda_alias']['foo'] == { + 'name': 'live', + 'function_name': '${aws_lambda_function.foo.function_name}', + 'function_version': '${aws_lambda_function.foo.version}', + } + def test_adds_log_group_resource_when_configured(self, sample_app): function = self.lambda_function() name = function.resource_name + '-log-group' @@ -1244,6 +1256,14 @@ def test_adds_reserved_concurrency_when_provided(self, sample_app): cfn_resource = list(template['Resources'].values())[0] assert cfn_resource['Properties']['ReservedConcurrentExecutions'] == 5 + def test_adds_alias_when_provided(self, sample_app): + function = self.lambda_function() + function.lambda_alias = 'live' + template = self.template_gen.generate([function]) + cfn_resource = list(template['Resources'].values())[0] + assert cfn_resource['Properties']['AutoPublishAlias'] == 'live' + assert cfn_resource['Properties']['AutoPublishAliasAllProperties'] + def test_adds_log_group_resource_when_configured(self, sample_app): function = self.lambda_function() function.log_group = models.LogGroup(