Skip to content

Commit

Permalink
Add support for environment_variables config option
Browse files Browse the repository at this point in the history
This builds on the initial work from @emellis and adds
support for the environment variables config option.

These changes include:

* Updating the config object with an environment_variables property
* Adding the concept of "chain merge" to config.  This allows
a dict to be updated (merged) instead of returning on the first value
found.
* Update API based deployer to inject env vars when needed.  This is
needed for both create_function and update_function calls.
* Changed update_function_code to just update_function which will
optionally update function config if required
* Update packager to add environment variables to SAM template.

There's still a few things in the initial PR that I'll need to circle
back on in follow up PRs.
  • Loading branch information
jamesls committed Apr 3, 2017
1 parent aac2ed6 commit 507dd11
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 51 deletions.
20 changes: 15 additions & 5 deletions chalice/awsclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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:
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions chalice/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
24 changes: 24 additions & 0 deletions chalice/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions chalice/deploy/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
55 changes: 28 additions & 27 deletions chalice/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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/'
)
}
}
Expand All @@ -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='<placeholder>',
api_gateway_stage='dev'):
# type: (Chalice, str, str) -> Dict[str, Any]
def generate_sam_template(self, config, code_uri='<placeholder>'):
# 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,
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 18 additions & 3 deletions tests/functional/test_awsclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions tests/functional/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
36 changes: 32 additions & 4 deletions tests/unit/deploy/test_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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):
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Loading

0 comments on commit 507dd11

Please sign in to comment.