diff --git a/chalice/awsclient.py b/chalice/awsclient.py index d0bca529b9..a8d2952b81 100644 --- a/chalice/awsclient.py +++ b/chalice/awsclient.py @@ -48,8 +48,9 @@ def lambda_function_exists(self, name): except client.exceptions.ResourceNotFoundException: return False - def create_function(self, function_name, role_arn, zip_contents): - # type: (str, str, str) -> str + def create_function(self, function_name, role_arn, zip_contents, + environment_variables=None): + # type: (str, str, str, Optional[Dict[str, str]]) -> str kwargs = { 'FunctionName': function_name, 'Runtime': 'python2.7', @@ -58,6 +59,8 @@ def create_function(self, function_name, role_arn, zip_contents): 'Role': role_arn, 'Timeout': 60, } + if environment_variables is not None: + kwargs['Environment'] = {"Variables": environment_variables} client = self._client('lambda') attempts = 0 while True: @@ -75,10 +78,17 @@ def create_function(self, function_name, role_arn, zip_contents): continue return response['FunctionArn'] - def update_function_code(self, function_name, zip_contents): - # type: (str, str) -> Dict[str, Any] - return self._client('lambda').update_function_code( + def update_function(self, function_name, zip_contents, + environment_variables=None): + # type: (str, str, Optional[Dict[str, str]]) -> Dict[str, Any] + lambda_client = self._client('lambda') + return_value = lambda_client.update_function_code( FunctionName=function_name, ZipFile=zip_contents) + if environment_variables is not None: + lambda_client.update_function_configuration( + FunctionName=function_name, + Environment={"Variables": environment_variables}) + return return_value def get_role_arn_for_name(self, name): # type: (str) -> str diff --git a/chalice/cli/__init__.py b/chalice/cli/__init__.py index ea9477c2f6..92aeb725b9 100644 --- a/chalice/cli/__init__.py +++ b/chalice/cli/__init__.py @@ -286,12 +286,12 @@ def package(ctx, single_file, stage, out): if single_file: dirname = tempfile.mkdtemp() try: - packager.package_app(dirname) + packager.package_app(config, dirname) create_zip_file(source_dir=dirname, outfile=out) finally: shutil.rmtree(dirname) else: - packager.package_app(out) + packager.package_app(config, out) def run_local_server(app_obj, port): diff --git a/chalice/config.py b/chalice/config.py index ab5c488bc7..6f6fdba5df 100644 --- a/chalice/config.py +++ b/chalice/config.py @@ -138,6 +138,25 @@ def _chain_lookup(self, name, varies_per_chalice_stage=False): if isinstance(cfg_dict, dict) and cfg_dict.get(name) is not None: return cfg_dict[name] + def _chain_merge(self, name): + # type: (str) -> Dict[str, Any] + # Merge values for all search dicts instead of returning on first + # found. + search_dicts = [ + # This is reverse order to _chain_lookup(). + self._default_params, + self._config_from_disk, + self._config_from_disk.get('stages', {}).get( + self.chalice_stage, {}), + self._user_provided_params, + ] + final = {} + for cfg_dict in search_dicts: + value = cfg_dict.get(name, {}) + if isinstance(value, dict): + final.update(value) + return final + @property def config_file_version(self): # type: () -> str @@ -178,6 +197,11 @@ def autogen_policy(self): return self._chain_lookup('autogen_policy', varies_per_chalice_stage=True) + @property + def environment_variables(self): + # type: () -> Dict[str, str] + return self._chain_merge('environment_variables') + def deployed_resources(self, chalice_stage_name): # type: (str) -> Optional[DeployedResources] """Return resources associated with a given stage. diff --git a/chalice/deploy/deployer.py b/chalice/deploy/deployer.py index 108796f652..167e6f78bd 100644 --- a/chalice/deploy/deployer.py +++ b/chalice/deploy/deployer.py @@ -243,10 +243,11 @@ def _first_time_lambda_create(self, config, function_name): role_arn = self._get_or_create_lambda_role_arn(config, function_name) zip_filename = self._packager.create_deployment_package( config.project_dir) - with open(zip_filename, 'rb') as f: - zip_contents = f.read() + zip_contents = self._osutils.get_file_contents( + zip_filename, binary=True) return self._aws_client.create_function( - function_name, role_arn, zip_contents) + function_name, role_arn, zip_contents, + config.environment_variables) def _update_lambda_function(self, config, lambda_name): # type: (Config, str) -> None @@ -264,7 +265,8 @@ def _update_lambda_function(self, config, lambda_name): zip_contents = self._osutils.get_file_contents( deployment_package_filename, binary=True) print "Sending changes to lambda." - self._aws_client.update_function_code(lambda_name, zip_contents) + self._aws_client.update_function(lambda_name, zip_contents, + config.environment_variables) def _write_config_to_disk(self, config): # type: (Config) -> None diff --git a/chalice/package.py b/chalice/package.py index a9b2f3ee30..df8e7dff85 100644 --- a/chalice/package.py +++ b/chalice/package.py @@ -29,12 +29,8 @@ def create_app_packager(config): config, ApplicationPolicyHandler( osutils, AppPolicyGenerator(osutils)))), - LambdaDeploymentPackager(), - # TODO: remove duplication here. - ApplicationPolicyHandler(osutils, AppPolicyGenerator(osutils)), - config.chalice_app, - config.project_dir, - config.autogen_policy) + LambdaDeploymentPackager() + ) class PreconfiguredPolicyGenerator(object): @@ -67,7 +63,9 @@ class SAMTemplateGenerator(object): 'Value': { 'Fn::Sub': ( 'https://${RestAPI}.execute-api.${AWS::Region}' - '.amazonaws.com/dev/' + # The api_gateway_stage is filled in when + # the template is built. + '.amazonaws.com/%s/' ) } } @@ -79,26 +77,37 @@ def __init__(self, swagger_generator, policy_generator): self._swagger_generator = swagger_generator self._policy_generator = policy_generator - def generate_sam_template(self, app, code_uri='', - api_gateway_stage='dev'): - # type: (Chalice, str, str) -> Dict[str, Any] + def generate_sam_template(self, config, code_uri=''): + # type: (Config, str) -> Dict[str, Any] template = copy.deepcopy(self._BASE_TEMPLATE) resources = { - 'APIHandler': self._generate_serverless_function(app, code_uri), - 'RestAPI': self._generate_rest_api(app, api_gateway_stage), + 'APIHandler': self._generate_serverless_function(config, code_uri), + 'RestAPI': self._generate_rest_api( + config.chalice_app, config.api_gateway_stage), } template['Resources'] = resources + self._update_endpoint_url_output(template, config) return template - def _generate_serverless_function(self, app, code_uri): - # type: (Chalice, str) -> Dict[str, Any] + def _update_endpoint_url_output(self, template, config): + # type: (Dict[str, Any], Config) -> None + url = template['Outputs']['EndpointURL']['Value']['Fn::Sub'] + template['Outputs']['EndpointURL']['Value']['Fn::Sub'] = ( + url % config.api_gateway_stage) + + def _generate_serverless_function(self, config, code_uri): + # type: (Config, str) -> Dict[str, Any] properties = { 'Runtime': 'python2.7', 'Handler': 'app.app', 'CodeUri': code_uri, - 'Events': self._generate_function_events(app), + 'Events': self._generate_function_events(config.chalice_app), 'Policies': [self._generate_iam_policy()], } + if config.environment_variables: + properties['Environment'] = { + 'Variables': config.environment_variables + } return { 'Type': 'AWS::Serverless::Function', 'Properties': properties, @@ -144,33 +153,25 @@ class AppPackager(object): def __init__(self, sam_templater, # type: SAMTemplateGenerator lambda_packager, # type: LambdaDeploymentPackager - policy_gen, # type: ApplicationPolicyHandler - app, # type: Chalice - project_dir, # type: str - autogen_policy=True # type: bool ): # type: (...) -> None self._sam_templater = sam_templater self._lambda_packaager = lambda_packager - self._app = app - self._policy_gen = policy_gen - self._project_dir = project_dir - self._autogen_policy = autogen_policy def _to_json(self, doc): # type: (Any) -> str return json.dumps(doc, indent=2, separators=(',', ': ')) - def package_app(self, outdir): - # type: (str) -> None + def package_app(self, config, outdir): + # type: (Config, str) -> None # Deployment package zip_file = os.path.join(outdir, 'deployment.zip') self._lambda_packaager.create_deployment_package( - self._project_dir, zip_file) + config.project_dir, zip_file) # SAM template sam_template = self._sam_templater.generate_sam_template( - self._app, './deployment.zip') + config, './deployment.zip') if not os.path.isdir(outdir): os.makedirs(outdir) with open(os.path.join(outdir, 'sam.json'), 'w') as f: diff --git a/tests/functional/test_awsclient.py b/tests/functional/test_awsclient.py index fc3a6be499..091e49425e 100644 --- a/tests/functional/test_awsclient.py +++ b/tests/functional/test_awsclient.py @@ -24,12 +24,26 @@ def test_deploy_rest_api(stubbed_session): stubbed_session.verify_stubs() -def test_update_function_code(stubbed_session): +def test_update_function_code_only(stubbed_session): stubbed_session.stub('lambda').update_function_code( FunctionName='name', ZipFile=b'foo').returns({}) stubbed_session.activate_stubs() awsclient = TypedAWSClient(stubbed_session) - awsclient.update_function_code('name', b'foo') + awsclient.update_function('name', b'foo') + stubbed_session.verify_stubs() + + +def test_update_function_code_and_deploy(stubbed_session): + lambda_client = stubbed_session.stub('lambda') + lambda_client.update_function_code( + FunctionName='name', ZipFile=b'foo').returns({}) + lambda_client.update_function_configuration( + FunctionName='name', + Environment={'Variables': {"FOO": "BAR"}}).returns({}) + stubbed_session.activate_stubs() + awsclient = TypedAWSClient(stubbed_session) + awsclient.update_function( + 'name', b'foo', {"FOO": "BAR"}) stubbed_session.verify_stubs() @@ -215,11 +229,12 @@ def test_create_function_succeeds_first_try(self, stubbed_session): Handler='app.app', Role='myarn', Timeout=60, + Environment={'Variables': {'FOO': 'BAR'}}, ).returns({'FunctionArn': 'arn:12345:name'}) stubbed_session.activate_stubs() awsclient = TypedAWSClient(stubbed_session) assert awsclient.create_function( - 'name', 'myarn', b'foo') == 'arn:12345:name' + 'name', 'myarn', b'foo', {'FOO': 'BAR'}) == 'arn:12345:name' stubbed_session.verify_stubs() def test_create_function_is_retried_and_succeeds(self, stubbed_session): diff --git a/tests/functional/test_package.py b/tests/functional/test_package.py index 16c69389e4..03657b1fcb 100644 --- a/tests/functional/test_package.py +++ b/tests/functional/test_package.py @@ -30,7 +30,7 @@ def test_can_create_app_packager_with_no_autogen(tmpdir): config = Config.create(project_dir=str(appdir), chalice_app=sample_app()) p = package.create_app_packager(config) - p.package_app(str(outdir)) + p.package_app(config, str(outdir)) # We're not concerned with the contents of the files # (those are tested in the unit tests), we just want to make # sure they're written to disk and look (mostly) right. @@ -45,7 +45,7 @@ def test_will_create_outdir_if_needed(tmpdir): config = Config.create(project_dir=str(appdir), chalice_app=sample_app()) p = package.create_app_packager(config) - p.package_app(outdir) + p.package_app(config, str(outdir)) contents = os.listdir(str(outdir)) assert 'deployment.zip' in contents assert 'sam.json' in contents diff --git a/tests/unit/deploy/test_deployer.py b/tests/unit/deploy/test_deployer.py index 81169e5775..37972ba392 100644 --- a/tests/unit/deploy/test_deployer.py +++ b/tests/unit/deploy/test_deployer.py @@ -326,7 +326,7 @@ def test_lambda_deployer_repeated_deploy(app_policy, sample_app): packager.deployment_package_filename.return_value = 'packages.zip' # Given the lambda function already exists: aws_client.lambda_function_exists.return_value = True - aws_client.update_function_code.return_value = {"FunctionArn": "myarn"} + aws_client.update_function.return_value = {"FunctionArn": "myarn"} # And given we don't want chalice to manage our iam role for the lambda # function: cfg = Config.create( @@ -335,7 +335,8 @@ def test_lambda_deployer_repeated_deploy(app_policy, sample_app): manage_iam_role=False, app_name='appname', iam_role_arn=True, - project_dir='./myproject' + project_dir='./myproject', + environment_variables={"FOO": "BAR"}, ) d = LambdaDeployer(aws_client, packager, None, osutils, app_policy) @@ -351,8 +352,35 @@ def test_lambda_deployer_repeated_deploy(app_policy, sample_app): './myproject') # And should result in the lambda function being updated with the API. - aws_client.update_function_code.assert_called_with( - lambda_function_name, 'package contents') + aws_client.update_function.assert_called_with( + lambda_function_name, 'package contents', {"FOO": "BAR"}) + + +def test_lambda_deployer_initial_deploy(app_policy, sample_app): + osutils = InMemoryOSUtils({'packages.zip': b'package contents'}) + aws_client = mock.Mock(spec=TypedAWSClient) + aws_client.create_function.return_value = 'lambda-arn' + packager = mock.Mock(LambdaDeploymentPackager) + packager.create_deployment_package.return_value = 'packages.zip' + cfg = Config.create( + chalice_stage='dev', + app_name='myapp', + chalice_app=sample_app, + manage_iam_role=False, + iam_role_arn='role-arn', + project_dir='.', + environment_variables={"FOO": "BAR"}, + ) + + d = LambdaDeployer(aws_client, packager, None, osutils, app_policy) + deployed = d.deploy(cfg, None, 'dev') + assert deployed == { + 'api_handler_arn': 'lambda-arn', + 'api_handler_name': 'myapp-dev', + } + aws_client.create_function.assert_called_with( + 'myapp-dev', 'role-arn', b'package contents', + {"FOO": "BAR"}) def test_cant_have_options_with_cors(sample_app): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c537461471..2e74256e4b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -103,3 +103,46 @@ def test_can_create_deployed_resource_from_dict(): assert d.api_gateway_stage == 'stage' assert d.region == 'region' assert d.chalice_version == '1.0.0' + + +def test_environment_from_top_level(): + config_from_disk = {'environment_variables': {"foo": "bar"}} + c = Config('dev', config_from_disk=config_from_disk) + assert c.environment_variables == config_from_disk['environment_variables'] + + +def test_environment_from_stage_leve(): + config_from_disk = { + 'stages': { + 'prod': { + 'environment_variables': {"foo": "bar"} + } + } + } + c = Config('prod', config_from_disk=config_from_disk) + assert c.environment_variables == \ + config_from_disk['stages']['prod']['environment_variables'] + + +def test_env_vars_chain_merge(): + config_from_disk = { + 'environment_variables': { + 'top_level': 'foo', + 'shared_key': 'from-top', + }, + 'stages': { + 'prod': { + 'environment_variables': { + 'stage_var': 'bar', + 'shared_key': 'from-stage', + } + } + } + } + c = Config('prod', config_from_disk=config_from_disk) + resolved = c.environment_variables + assert resolved == { + 'top_level': 'foo', + 'stage_var': 'bar', + 'shared_key': 'from-stage', + } diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 31ce490874..f5d1a49eef 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -49,8 +49,9 @@ def test_sam_generates_sam_template_basic(sample_app, mock_policy_generator): p = package.SAMTemplateGenerator(mock_swagger_generator, mock_policy_generator) - template = p.generate_sam_template(sample_app, 'code-uri', - api_gateway_stage='dev') + config = Config.create(chalice_app=sample_app, + api_gateway_stage='dev') + template = p.generate_sam_template(config, 'code-uri') # Verify the basic structure is in place. The specific parts # are validated in other tests. assert template['AWSTemplateFormatVersion'] == '2010-09-09' @@ -68,7 +69,9 @@ def test_sam_injects_policy(sample_app, mock_policy_generator.generate_policy_from_app_source.return_value = { 'iam': 'policy', } - template = p.generate_sam_template(sample_app) + config = Config.create(chalice_app=sample_app, + api_gateway_stage='dev') + template = p.generate_sam_template(config) assert template['Resources']['APIHandler']['Properties']['Policies'] == [{ 'iam': 'policy', }] @@ -82,6 +85,47 @@ def test_sam_injects_swagger_doc(sample_app, mock_swagger_generator.generate_swagger.return_value = { 'swagger': 'document' } - template = p.generate_sam_template(sample_app) + config = Config.create(chalice_app=sample_app, + api_gateway_stage='dev') + template = p.generate_sam_template(config) properties = template['Resources']['RestAPI']['Properties'] assert properties['DefinitionBody'] == {'swagger': 'document'} + + +def test_can_inject_environment_vars(sample_app, + mock_swagger_generator, + mock_policy_generator): + p = package.SAMTemplateGenerator( + mock_swagger_generator, mock_policy_generator) + mock_swagger_generator.generate_swagger.return_value = { + 'swagger': 'document' + } + config = Config.create( + chalice_app=sample_app, + api_gateway_stage='dev', + environment_variables={ + 'FOO': 'BAR' + } + ) + template = p.generate_sam_template(config) + properties = template['Resources']['APIHandler']['Properties'] + assert 'Environment' in properties + assert properties['Environment']['Variables'] == {'FOO': 'BAR'} + + +def test_endpoint_url_reflects_apig_stage(sample_app, + mock_swagger_generator, + mock_policy_generator): + p = package.SAMTemplateGenerator( + mock_swagger_generator, mock_policy_generator) + mock_swagger_generator.generate_swagger.return_value = { + 'swagger': 'document' + } + config = Config.create( + chalice_app=sample_app, + api_gateway_stage='prod', + ) + template = p.generate_sam_template(config) + endpoint_url = template['Outputs']['EndpointURL']['Value']['Fn::Sub'] + assert endpoint_url == ( + 'https://${RestAPI}.execute-api.${AWS::Region}.amazonaws.com/prod/')