@@ -18,9 +18,11 @@

import botocore.exceptions
import tabulate
import yaml
from yoke.config import YokeConfig
from yoke.shell import build as yoke_build

import yolo.build
import yolo.client
from yolo import const
import yolo.exceptions
@@ -67,29 +69,8 @@ def build(self, service, stage):
'Service "{}" is not a Lambda service.'.format(service)
)

# Fake it 'till we make it: prepare data to be passed over to yoke.
args = yolo.client.FakeYokeArgs(func=yoke_build, config=None)
yoke_config = service_cfg['yoke']
# Get the working directory of the service yoke is handling.
# Default to the current directory.
yoke_working_dir = os.path.abspath(
yoke_config.get('working_dir', '.'))
env_dict = yoke_config.get('environment', {})
# Let the `yoke` config section override the stage.
# There is a `yoke.stage` present, we use that instead of the stage
# supplied to `yolo`.
# Do this in case the yolo stage is not the same as the yoke stage.
# For example, the yolo stage might be `dev`, but the yoke stage
# might be `Development`.
yoke_stage = yoke_config.get('stage', stage)
config = YokeConfig(
shellargs=args,
project_dir=yoke_working_dir,
stage=yoke_stage,
env_dict=env_dict)
args.config = config.get_config()
# Directly hook into yoke
yoke_build(args)
# Use yolo's built-in Lambda build function.
yolo.build.python_build_lambda_function(service_cfg)

def push(self, service, stage, bucket):
"""Push a local build of a Lambda service up into S3.
@@ -127,9 +108,13 @@ def push(self, service, stage, bucket):
)

lambda_fn_path = os.path.join(
os.path.abspath(service_cfg['dist_path']),
os.path.abspath(service_cfg['build']['dist_dir']),
'lambda_function.zip'
)
swagger_yaml_path = service_cfg['deploy']['apigateway'][
'swagger_template'
]

bucket.upload_file(
Filename=lambda_fn_path,
Key=os.path.join(bucket_folder_prefix, 'lambda_function.zip'),
@@ -142,9 +127,6 @@ def push(self, service, stage, bucket):
):
# grab the rendered swagger file from the working_dir
# and upload it to the S3 bucket
swagger_yaml_path = os.path.join(
service_cfg['yoke']['working_dir'], 'swagger.yml'
)
bucket.upload_file(
Filename=swagger_yaml_path,
Key=os.path.join(bucket_folder_prefix, const.SWAGGER_YAML),
@@ -282,7 +264,7 @@ def deploy(self, service, stage, version, bucket):
os.remove(temp_yolo_yaml_path)
os.close(fp)

lambda_fn_cfg = self.build_yolo_file.services[service][
lambda_fn_cfg = self.build_yolo_file.services[service]['deploy'][
'lambda_function_configuration'
]

@@ -305,7 +287,7 @@ def deploy(self, service, stage, version, bucket):
# NOTE(szilveszter): We have to use the Yoke-specific stage, if
# available, because that's the stage we're putting the base path
# mapping in place for.
yoke_stage = service_cfg['yoke'].get('stage', stage)
yoke_stage = service_cfg.get('yoke', {}).get('stage', stage)
self._create_lambda_alias_for_stage(
lambda_fn_cfg['FunctionName'], fn_version, yoke_stage
)
@@ -652,12 +634,14 @@ def _fetch_param_page(next_token=None):

# If there are no paramters defined in the yolo.yaml, we can skip
# parameter checking and copying entirely.
if 'parameters' in service_cfg:
if 'parameters' in service_cfg['deploy']:

# Get stage specific parameter config, or get the default if this
# is an ad-hoc/custom stage.
build_yolofile_params = service_cfg['parameters']['stages'].get(
# TODO(larsbutler): handle index errors if no default defined.
stage, service_cfg['parameters']['stages']['default']
build_yolofile_params = service_cfg['deploy'][
'parameters'
]['stages'].get(
stage, service_cfg['deploy']['parameters']['stages']['default']
)
build_yolofile_param_names = set(
x['name'] for x in build_yolofile_params
@@ -769,16 +753,54 @@ def _deploy_api(self, service, stage, swagger_contents):
# APIs will clobber each other.
# Possible solution: Parameterize the APIs based on stage name.
service_cfg = self.yolo_file.services[service]
apig_config = service_cfg['apigateway']
apig_config = service_cfg['deploy']['apigateway']
apig_client = self.faws_client.aws_client(
self.context.account.account_number,
'apigateway',
region_name=self.context.stage.region,
)

rest_api_name = apig_config['rest_api_name']
rest_api_id = None
rest_api_id = self._create_or_update_rest_api(
apig_client, rest_api_name, swagger_contents
)

# Set up authorizers:
self._deploy_api_authorizers(apig_client, rest_api_id, service_cfg)

# Set up integrations (request/response templates):
self._deploy_api_integrations(apig_client, rest_api_id, service_cfg,
swagger_contents)

# Deploy the API to the target stage:
# NOTE(szilveszter): We have to use the Yoke-specific stage, if
# available, because that's the stage we're putting the base path
# mapping in place for.
yoke_stage = service_cfg.get('yoke', {}).get('stage', stage)
print('Deploying API to stage "{}"...'.format(yoke_stage))
apig_client.create_deployment(
restApiId=rest_api_id,
stageName=yoke_stage,
)

print('Configuring API Gateway/Lambda base path mapping...')
self._add_apig_lambda_base_path_mapping(service, stage)
print('Done!')

def _create_or_update_rest_api(self, apig_client, rest_api_name, swagger_contents):
"""Create/update a REST API with the given API definition.
:param apig_client:
:class:`botocore.client.APIGateway` instance.
:param rest_api_name:
Name of the API Gateway REST API.
:param str swagger_contents:
Contents of the fully rendered Swagger definition that should be
uploaded as the REST API specification.
:returns:
The unique ID of the API Gateway REST API.
"""
# Create/update REST API:
try:
rest_api_id = self._get_rest_api_id(rest_api_name)
@@ -799,21 +821,84 @@ def _deploy_api(self, service, stage, swagger_contents):
body=swagger_contents,
parameters=dict(basepath='prepend'),
)
return rest_api_id

# Deploy the API to the target stage:
# NOTE(szilveszter): We have to use the Yoke-specific stage, if
# available, because that's the stage we're putting the base path
# mapping in place for.
yoke_stage = service_cfg['yoke'].get('stage', stage)
print('Deploying API to stage "{}"...'.format(yoke_stage))
apig_client.create_deployment(
restApiId=rest_api_id,
stageName=yoke_stage,
)
def _deploy_api_authorizers(self, apig_client, rest_api_id, service_cfg):
print('Deploying API authorizers...')

print('Configuring API Gateway/Lambda base path mapping...')
self._add_apig_lambda_base_path_mapping(service, stage)
print('Done!')
# TODO: don't always create one; if one exists, use that
authorizers = service_cfg['deploy']['apigateway']['authorizers']
for authorizer in authorizers:
print('Deploy authorizer "{}"...'.format(authorizer['name']))
apig_client.create_authorizer(
restApiId=rest_api_id, **authorizer
)

def _deploy_api_integrations(self, apig_client, rest_api_id, service_cfg,
swagger_contents):
print('Deploying API integrations...')

integration = service_cfg['deploy']['apigateway']['integration']
swagger_data = yaml.safe_load(swagger_contents)

for resource in self._get_api_resources(apig_client, rest_api_id):
# Not all resources will have methods defined. For example,
# namespaces such as /foo/bar will not have a method defined, but
# a child /foo/bar/baz might.
# In other words, only concrete resources that have explicit
# methods defined will have `resourceMethods` in API Gateway.
for method in resource.get('resourceMethods', {}).keys():
print(
'Creating integration for resource '
'"{meth} {path}"...'.format(
meth=method,
path=resource['path'],
)
)
# Add default integration request templates:
apig_client.put_integration(
restApiId=rest_api_id,
resourceId=resource['id'],
httpMethod=method,
# TODO: explain this
integrationHttpMethod='POST',
requestTemplates=DEFAULT_REQUEST_TEMPLATES,
**integration
)
# Now add default integration response templates:
# loop through response codes defined for each endpoint
# get the config for that code, else use default

if swagger_data.get('basePath', ''):
resource_path = resource['path'].split(
swagger_data['basePath']
)[1]
else:
resource_path = resource['path']
relevant_resp_codes = swagger_data['paths'][resource_path].get(
method.lower()
).get('responses').keys()
# loop through these status codes and get the default response
# template, then set up the integration response:
for resp_code in relevant_resp_codes:
resp_integration = DEFAULT_INTEGRATION_RESPONSES.get(
str(resp_code),
DEFAULT_INTEGRATION_RESPONSES['default'],
)
apig_client.put_integration_response(
restApiId=rest_api_id,
resourceId=resource['id'],
httpMethod=method,
**resp_integration
)

def _get_api_resources(self, apig_client, rest_api_id):
"""Get all resource defintions for a given REST API."""

paginator = apig_client.get_paginator('get_resources')
for page in paginator.paginate(restApiId=rest_api_id):
for resource in page['items']:
yield resource

def _get_rest_api_id(self, rest_api_name):
"""Get the ID of a AWS::ApiGateway::RestApi resource, give its name.
@@ -860,7 +945,7 @@ def _add_apig_lambda_base_path_mapping(self, service, stage):
service_cfg = self.yolo_file.services[service]
stage_cfg = self.yolo_file.get_stage_config(stage)

apigateway_configs = service_cfg['apigateway']
apigateway_configs = service_cfg['deploy']['apigateway']
apig_client = self.faws_client.aws_client(
self.context.account.account_number,
'apigateway',
@@ -873,10 +958,13 @@ def _add_apig_lambda_base_path_mapping(self, service, stage):
rest_api_id = self._get_rest_api_id(
apigateway_config['rest_api_name']
)
yoke_stage = service_cfg['yoke'].get('stage', stage)
yoke_stage = service_cfg.get('yoke', {}).get('stage', stage)
# Add base path mapping
base_path = apigateway_config['base_path'].strip()
domain_name = apigateway_config['custom_domain']
domains = apigateway_config['domains']
# TODO(larsbutler): Can we assume there is only one?
[domain] = domains
domain_name = domain['domain_name']
base_path = domain['base_path']
if domain_name == '':
# This is an easy way to let us know the domain does not exist for
# the given stage, so let's skip base path mapping creation.
@@ -982,3 +1070,110 @@ def show(self, service, stage):
table.append((key, value))

print(tabulate.tabulate(table, headers='firstrow', tablefmt='simple'))


DEFAULT_JSON_REQUEST_TEMPLATE = """\
{
"rawContext": {
"apiId": "$context.apiId",
"authorizer": {
"principalId": "$context.authorizer.principalId",
"claims": {
"property": "$context.authorizer.claims.property"
}
},
"httpMethod": "$context.httpMethod",
"identity": {
"accountId": "$context.identity.accountId",
"apiKey": "$context.identity.apiKey",
"caller": "$context.identity.caller",
"cognitoAuthenticationProvider": "$context.identity.cognitoAuthenticationProvider",
"cognitoAuthenticationType": "$context.identity.cognitoAuthenticationType",
"cognitoIdentityId": "$context.identity.cognitoIdentityId",
"cognitoIdentityPoolId": "$context.identity.cognitoIdentityPoolId",
"sourceIp": "$context.identity.sourceIp",
"user": "$context.identity.user",
"userAgent": "$context.identity.userAgent",
"userArn": "$context.identity.userArn"
},
"requestId": "$context.requestId",
"resourceId": "$context.resourceId",
"resourcePath": "$context.resourcePath",
"stage": "$context.stage"
},
"parameters": {
"gateway": {
"id": "$context.apiId",
"stage": "$context.stage",
"request-id" : "$context.requestId",
"resource-path" : "$context.resourcePath",
"http-method": "$context.httpMethod",
"stage-data": {
#foreach($param in $stageVariables.keySet())
"$param": "$util.escapeJavaScript($stageVariables.get($param))"
#if($foreach.hasNext),#end
#end
}
},
"requestor": {
"source-ip": "$context.identity.sourceIp",
"user-agent": "$context.identity.userAgent",
"account-id" : "$context.identity.accountId",
"api-key" : "$context.identity.apiKey",
"caller": "$context.identity.caller",
"user": "$context.identity.user",
"user-arn" : "$context.identity.userArn"
},
"request": {
"querystring": {
#foreach($param in $input.params().querystring.keySet())
"$param": "$util.escapeJavaScript($input.params().querystring.get($param))"#if($foreach.hasNext),#end
#end
},
"path": {
#foreach($param in $input.params().path.keySet())
"$param": "$util.escapeJavaScript($input.params().path.get($param))"
#if($foreach.hasNext),#end
#end
},
"header": {
#foreach($param in $input.params().header.keySet())
"$param": "$util.escapeJavaScript($input.params().header.get($param))"
#if($foreach.hasNext),#end
#end
},
"body": $input.json('$')
}
}
}
""" # noqa
DEFAULT_REQUEST_TEMPLATES = {
'application/json': DEFAULT_JSON_REQUEST_TEMPLATE,
}
APPLICATION_JSON_RESPONSE_FMT = (
'{"error": {"code": %(rc)s, "message": $input.json(\'$.errorMessage\')}}'
)
RESPONSE_CODES = [
300, 301, 302, 303, 304, 305, 307,
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414,
415, 416, 417, 418, 422, 423,
500, 501, 502, 503, 504, 505,
]
DEFAULT_INTEGRATION_RESPONSES = {
str(resp_code): {
'responseTemplates': {
'application/json': (
APPLICATION_JSON_RESPONSE_FMT % dict(rc=resp_code)
),
},
'selectionPattern': '^{rc}:.*'.format(rc=resp_code),
'statusCode': str(resp_code),
}
for resp_code in RESPONSE_CODES
}
DEFAULT_INTEGRATION_RESPONSES['default'] = {
'responseTemplates': {
'application/json': '__passthrough__'
},
'statusCode': '200',
}
@@ -114,9 +114,33 @@ class YoloFile(object):
volup.Optional('stage'): STRING_SCHEMA,
})
APIGATEWAY_SCHEMA = volup.Schema({
volup.Optional('custom_domain'): STRING_OR_DICT_SCHEMA,
volup.Optional('base_path'): STRING_SCHEMA,
volup.Optional('rest_api_name'): STRING_SCHEMA,
volup.Required('rest_api_name'): STRING_SCHEMA,
volup.Required('swagger_template'): STRING_SCHEMA,

volup.Optional('domains'): [{
volup.Optional('domain_name'): STRING_OR_DICT_SCHEMA,
volup.Optional('base_path'): STRING_SCHEMA,
}],

volup.Optional('authorizers'): [{
volup.Required('name'): STRING_SCHEMA,
volup.Required('type'): volup.Any(
'TOKEN', 'REQUEST', 'COGNITO_USER_POOLS'
),
volup.Optional('providerARNs'): [STRING_SCHEMA],
volup.Optional('authType'): STRING_SCHEMA,
volup.Optional('authorizerUri'): STRING_SCHEMA,
volup.Optional('authorizerCredentials'): STRING_SCHEMA,
volup.Optional('identitySource'): STRING_SCHEMA,
volup.Optional('identityValidationExpression'): STRING_SCHEMA,
volup.Optional('authorizerResultTtlInSeconds'): int,
}],
volup.Optional('integration'): {
volup.Required('type'): STRING_SCHEMA,
volup.Required('uri'): STRING_SCHEMA,
volup.Required('passthroughBehavior'): STRING_SCHEMA,
volup.Optional('credentials'): STRING_SCHEMA,
},
})
SERVICE_TYPE_S3 = 's3'
SERVICE_TYPE_LAMBDA = 'lambda'
@@ -130,24 +154,23 @@ class YoloFile(object):
# Many services, keyed by name
STRING_SCHEMA: { # service name, arbitary string
volup.Required('type'): volup.Any(*SERVICE_TYPES),
# Only required for S3 types services.
# TODO(larsbutler): It might make sense to also use this for
# placing lambda service build artifacts (swagger.json,
# lambda_function.zip, etc.) so that we consistently handle
# uploads/pushes of builds, rather than being implicit about
# everything and placing build artifacts into source directories--
# which is not ideal.
volup.Optional('dist_path'): STRING_SCHEMA,
volup.Optional('parameters'): PARAMETERS_SCHEMA,
volup.Optional('yoke'): YOKE_SCHEMA,
# TODO(larsbutler): Make these conditional on the service type (s3)
volup.Optional('bucket_name'): STRING_SCHEMA,
# TODO(larsbutler): Make these conditional on the service type
# (lambda-apigateway)
# Can be a simple dict, or a list of dicts as well.
volup.Optional('apigateway'): volup.Any(APIGATEWAY_SCHEMA, [APIGATEWAY_SCHEMA]),
# Only required for lambda/lambda-apigateway services.
volup.Optional('lambda_function_configuration'): YOKE_LAMBDA_FN_CFG,
volup.Optional('build'): {
volup.Required('working_dir'): STRING_SCHEMA,
volup.Required('dist_dir'): STRING_SCHEMA,
volup.Required('include'): [STRING_SCHEMA],
volup.Optional('dependencies'): STRING_SCHEMA,
},
volup.Optional('deploy'): {
# TODO(larsbutler): Make these conditional on the service type
# (lambda-apigateway)
# Can be a simple dict, or a list of dicts as well.
volup.Optional('apigateway'): APIGATEWAY_SCHEMA,
# Only required for lambda/lambda-apigateway services.
volup.Optional('lambda_function_configuration'): YOKE_LAMBDA_FN_CFG,
volup.Optional('parameters'): PARAMETERS_SCHEMA,
},
}
})
# top-level schema