#!/usr/bin/env python3 # # Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). # You may not use this file except in compliance with the License. # A copy of the License is located at # # http://aws.amazon.com/apache2.0/ # # or in the "license" file accompanying this file. This file is distributed # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either # express or implied. See the License for the specific language governing # permissions and limitations under the License. import sys import re import yaml import base import os # Load up all wheel and wheel_participant routes import wheel import wheel_participant import time class Ref(str): pass class GetAtt(str): pass yaml.add_representer(Ref, lambda dumper, data: dumper.represent_scalar(u'!Ref', str(data))) yaml.add_representer(GetAtt, lambda dumper, data: dumper.represent_scalar(u'!GetAtt', str(data))) S3_PROXY_HEADERS = ['content-type', 'Content-Type', 'Date', 'content-length', 'Content-Length', 'Etag', 'etag'] # Recursive finder for config references def find_refs(config): if isinstance(config, dict): return find_refs(list(config.values())) elif isinstance(config, list): refs = set() for item in config: refs.update(find_refs(item)) return refs elif isinstance(config, Ref): return {str(config)} else: return set() PATH_PARAMETER_MATCHER = re.compile(r'{([a-zA-Z0-9_]+)\+?}') def path_to_parameters(path): return PATH_PARAMETER_MATCHER.findall(path) def snake_case_to_capitalized_words(string): return ''.join([s.capitalize() for s in string.split('_')]) def make_api_path_config(lambda_name, path): path_config = { 'x-amazon-apigateway-integration': { 'contentHandling': 'CONVERT_TO_TEXT', 'httpMethod': 'POST', 'passthroughBehavior': 'WHEN_NO_MATCH', 'responses': {'default': {'statusCode': 200}}, 'type': 'aws_proxy', 'uri': { 'Fn::Join': ['', [ 'arn:aws:apigateway:', Ref('AWS::Region'), ':lambda:path/2015-03-31/functions/arn:aws:lambda:', Ref('AWS::Region'), ':', Ref('AWS::AccountId'), ':function:', Ref(lambda_name), '/invocations' ]] } }, } if path != '/config': # The configuration variables need to be retrieved without security path_config['security'] = [{'apiUsers': []}] parameters = path_to_parameters(path) if parameters: path_config['parameters'] = [{'in': 'path', 'name': p, 'required': True, 'type': 'string'} for p in parameters] return path_config class TemplateCompiler: def __init__(self, in_dir, out_dir, *filenames): self.in_dir = in_dir self.out_dir = out_dir self.filenames = filenames def __enter__(self): self.configs = [yaml.safe_load(open(os.path.join(self.in_dir, name))) for name in self.filenames] for config in self.configs: config.setdefault('Resources', {}) config.setdefault('Outputs', {}) return tuple(self.configs) def __exit__(self, exc_type, exc_val, exc_tb): if not exc_type: global_resources = {} for template_filename, config in zip(self.filenames, self.configs): stack_prefix = snake_case_to_capitalized_words(template_filename.split('.')[0]) stack_name = f'{stack_prefix}Stack' for ref in find_refs(config): if ref not in config['Resources'] and not ref.startswith('AWS::'): config.setdefault('Parameters', {}) config['Parameters'][ref] = {'Type': 'String'} for resource in list(config['Resources'].keys()): if config['Resources'][resource]['Type'] == 'AWS::ApiGateway::Deployment': # Unique-ify the logical ID to force a new deployment for each stack update unique_resource_name = f"{resource}{str(int(time.time()))}" config['Resources'][unique_resource_name] = config['Resources'].pop(resource) config['Outputs'][resource] = {'Value': Ref(unique_resource_name)} else: config['Outputs'][resource] = {'Value': Ref(resource)} for output in config['Outputs']: global_resources[output] = stack_name with open(os.path.join(self.out_dir, template_filename), 'w') as f: f.write(yaml.dump(config, default_flow_style=False)) # Compile the overall configuration overall_config = yaml.safe_load(open(os.path.join(self.in_dir, 'aws-ops-wheel.yml'))) overall_config.setdefault('Resources', {}) overall_config.setdefault('Outputs', {}) for template_filename, config in zip(self.filenames, self.configs): stack_prefix = snake_case_to_capitalized_words(template_filename.split('.')[0]) stack_name = f'{stack_prefix}Stack' params = {} for p in config.get('Parameters', dict()): if p in global_resources: params[p] = GetAtt(f"{global_resources[p]}.Outputs.{p}") else: overall_config.setdefault('Parameters', dict()) overall_config['Parameters'][p] = config['Parameters'][p] params[p] = Ref(p) overall_config['Resources'][stack_name] = { 'Type': "AWS::CloudFormation::Stack", 'Properties': { 'TemplateURL': f'./compiled_templates/{template_filename}', 'TimeoutInMinutes': 20, 'Parameters': params, } } for p in global_resources: overall_config['Outputs'][p] = {'Value': GetAtt(f"{global_resources[p]}.Outputs.{p}")} with open(os.path.join(self.out_dir, 'aws-ops-wheel.yml'), 'w') as f: f.write(yaml.dump(overall_config)) def main(): in_dir, out_dir, static_asset_s3_prefix = sys.argv[1:4] static_asset_s3_prefix = static_asset_s3_prefix.strip('/') # Unfortunately we've had to split our template into multiple configs with the API config at the top so that # we could get past the 50kb limit of CloudFormation with TemplateCompiler( in_dir, out_dir, 'cognito.yml', 'lambda.yml', 'api_gateway.yml', 'api_gateway_lambda_roles.yml') as configs: cognito_config, lambda_config, api_config, api_lambda_roles_config = configs paths = {} for func in base.route.registry.values(): lambda_name = snake_case_to_capitalized_words(func.__name__) + 'Lambda' # Strip the parameter and return documentation out of the Lambda description as this confuses Lambda lambda_description = '' if func.__doc__: for line in func.__doc__.splitlines(): line = line.strip() if (':param' or ':return') in line: break if line: lambda_description += f'{line} ' # Generate Lambda Resources lambda_config['Resources'][lambda_name] = { 'Type': 'AWS::Lambda::Function', 'Properties': { 'Code': './build', 'Description': lambda_description, 'Environment': { 'Variables': { 'APP_CLIENT_ID': Ref('CognitoUserPoolClient'), 'USER_POOL_ID': Ref('CognitoUserPool'), 'PARTICIPANT_TABLE': Ref('participantDynamoDBTable'), 'WHEEL_TABLE': Ref('wheelDynamoDBTable'), } }, 'Handler': f"{func.__module__}.{func.__name__}", 'MemorySize': 128, 'Role': GetAtt('AWSOpsWheelLambdaRole.Arn'), 'Runtime': 'python3.9', 'Timeout': 3 } } path = f'/api/{func.route.path.lstrip("/")}' paths.setdefault(path, {}) for method in func.route.methods: paths[path][method.lower()] = make_api_path_config(lambda_name, func.route.path) stripped_path = path.lstrip('/') api_lambda_roles_config['Resources'][f"{lambda_name}GatewayPermissions{method}"] = { 'Type': 'AWS::Lambda::Permission', 'Properties': { 'Action': 'lambda:invokeFunction', 'FunctionName': Ref(lambda_name), 'Principal': 'apigateway.amazonaws.com', 'SourceArn': {'Fn::Join': ['', [ 'arn:aws:execute-api:', Ref('AWS::Region'), ':', Ref('AWS::AccountId'), ':', Ref("AWSOpsWheelAPI"), f"/*/{method.upper()}/{stripped_path}", ]]}, } } paths['/favicon.ico'] = {'get': { 'produces': [ 'image/x-icon' ], 'responses': { '200': { 'description': '200 response', 'schema': { '$ref': '#/definitions/Empty' }, 'headers': { 'Content-Length': { 'type': 'string' }, 'Content-Type': { 'type': 'string' } } } }, 'x-amazon-apigateway-integration': { 'responses': { 'default': { 'statusCode': '200', 'responseParameters': { 'method.response.header.Content-Type': 'integration.response.header.Content-Type', 'method.response.header.Content-Length': 'integration.response.header.Content-Length' }, 'contentHandling': 'CONVERT_TO_BINARY' } }, 'uri': f'{static_asset_s3_prefix}/favicon.ico', 'passthroughBehavior': 'when_no_match', 'httpMethod': 'GET', 'contentHandling': 'CONVERT_TO_BINARY', 'type': 'http' } }} paths['/static/{proxy+}'] = {'x-amazon-apigateway-any-method': { 'parameters': [{'in': 'path', 'name': 'proxy', 'required': True, 'type': 'string'}], 'produces': ['application/json'], 'responses': {}, 'x-amazon-apigateway-integration': { 'cacheKeyParameters': ['method.request.path.proxy'], 'cacheNamespace': 'static_assets', 'httpMethod': 'ANY', 'passthroughBehavior': 'when_no_match', 'requestParameters': {'integration.request.path.proxy': 'method.request.path.proxy'}, 'responses': {'default': {'statusCode': '200'}}, 'type': 'http_proxy', 'uri': f'{static_asset_s3_prefix}/{{proxy}}', 'contentHandling': 'CONVERT_TO_BINARY'} }} paths['/'] = {'x-amazon-apigateway-any-method': { 'parameters': [], 'produces': ['application/json'], 'responses': {}, 'x-amazon-apigateway-integration': { 'httpMethod': 'ANY', 'passthroughBehavior': 'when_no_match', 'requestParameters': {}, 'responses': {'default': {'statusCode': '200'}}, 'type': 'http_proxy', 'uri': f'{static_asset_s3_prefix}/index.production.html'} }} paths['/{proxy+}'] = {'x-amazon-apigateway-any-method': { 'parameters': [{'in': 'path', 'name': 'proxy', 'required': False, 'type': 'string'}], 'produces': ['application/json'], 'responses': {}, 'x-amazon-apigateway-integration': { 'cacheKeyParameters': ['method.request.path.proxy'], 'cacheNamespace': 'static_assets', 'httpMethod': 'ANY', 'passthroughBehavior': 'when_no_match', 'requestParameters': {'integration.request.path.proxy': 'method.request.path.proxy'}, 'responses': {'default': {'statusCode': '200'}}, 'type': 'http_proxy', 'uri': f'{static_asset_s3_prefix}/index.production.html'} }} api_config['Resources']['AWSOpsWheelAPI']['Properties']['Body'] = { 'schemes': ['https'], 'swagger': '2.0', 'info': {'title': 'AWSOpsWheel', 'version': '0.1'}, 'definitions': { 'Empty': {'title': 'Empty Schema', 'type': 'object'} }, 'x-amazon-apigateway-binary-media-types': ['audio/mpeg', 'audio/*', 'image/x-icon', 'application/font*', 'font/*'], 'basePath': '/', 'paths': paths, 'securityDefinitions': { 'apiUsers': { 'type': 'apiKey', 'name': 'Authorization', 'in': 'header', 'x-amazon-apigateway-authtype': 'cognito_user_pools', 'x-amazon-apigateway-authorizer': { 'type': 'COGNITO_USER_POOLS', 'providerARNs': [Ref('CognitoUserPoolArn')] } } } } if __name__ == '__main__': main()