# # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy of this # software and associated documentation files (the "Software"), to deal in the Software # without restriction, including without limitation the rights to use, copy, modify, # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # AWSTemplateFormatVersion: 2010-09-09 Description: > **WARNING** This template will create and use the following AWS resources: Amazon API Gateway, DynamoDB, CloudTrail, CloudWatch, Lambda, and Simple Storage Service (S3). You will be billed for the resources used if you create a stack from this template. Resources: CustomResourceEndpoint: DependsOn: - LambdaFunction - CloudWatchRole - CustomResourceScalingTrail Type: 'AWS::ApiGateway::RestApi' Properties: Name: Custom Resource Scaling API Description: A new REST API in API Gateway to use with Application Auto Scaling and a mock custom resource. Body: swagger: "2.0" info: description: "Swagger REST API Specification" version: "1.0.0" title: "Scaling API" license: name: MIT-0 url: https://spdx.org/licenses/MIT-0.html basePath: "/v1" schemes: - "http" produces: - "application/json" paths: /scalableTargetDimensions/{scalableTargetDimensionId}: get: tags: - "ScalableTargets" summary: "Describe target" description: "Returns information about a registered scalable target dimension, \ including the desired and actual capacity." operationId: "controllers.default_controller.scalable_target_id_get" parameters: - name: "scalableTargetDimensionId" in: "path" description: "The identifier of a scalable target dimension to retrieve." required: true type: "string" format: "" responses: '200': description: "A JSON object that contains information about the resource." schema: '$ref': "#/definitions/ScalableTargetDimension" '400': description: "Client Error" schema: '$ref': "#/definitions/Error" '403': description: "Client Error: Not authorized" schema: '$ref': "#/definitions/Error" '404': description: "Client Error: ScalableTargetDimension not found" schema: '$ref': "#/definitions/Error" '429': description: "Client Error: Too Many Requests" schema: '$ref': "#/definitions/Error" '500': description: "Server Error" schema: '$ref': "#/definitions/Error" x-tags: - tag: "ScalableTargets" security: - sigv4: [] x-amazon-apigateway-integration: type: aws_proxy uri: !Sub |- arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations passthroughBehavior: "when_no_match" httpMethod: "POST" responses: default: statusCode: "500" '200': statusCode: "200" '403': statusCode: "403" '404': statusCode: "404" '429': statusCode: "429" '4\d{2}': statusCode: "400" requestParameters: integration.request.path.scalableTargetDimensionId: "method.request.path.scalableTargetDimensionId" patch: tags: - "ScalableTargets" summary: "Update ScalableTargetDimension" operationId: "controllers.default_controller.scalable_target_id_patch" consumes: - "application/json" parameters: - name: "scalableTargetDimensionId" in: "path" description: "The identifier of the scalable target dimension to update." required: true type: "string" format: "" - in: "body" name: "updateRequest" description: "A request sent in JSON to update the scalable target dimension." required: true schema: '$ref': "#/definitions/ScalableTargetDimensionUpdate" responses: '200': description: "A JSON object that contains information about the resource." schema: '$ref': "#/definitions/ScalableTargetDimension" '400': description: "Client Error" schema: '$ref': "#/definitions/Error" '403': description: "Client Error: Not authorized" schema: '$ref': "#/definitions/Error" '404': description: "Client Error: ScalableTargetDimension not found" schema: '$ref': "#/definitions/Error" '429': description: "Client Error: Too Many Requests" schema: '$ref': "#/definitions/Error" '500': description: "Server Error" schema: '$ref': "#/definitions/Error" x-tags: - tag: "ScalableTargets" security: - sigv4: [] x-amazon-apigateway-integration: type: aws_proxy uri: !Sub |- arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations passthroughBehavior: "when_no_match" httpMethod: "POST" responses: default: statusCode: "500" '200': statusCode: "200" '403': statusCode: "403" '404': statusCode: "404" '429': statusCode: "429" '4\d{2}': statusCode: "400" requestParameters: integration.request.path.scalableTargetDimensionId: "method.request.path.scalableTargetDimensionId" securityDefinitions: sigv4: type: "apiKey" name: "Authorization" in: "header" x-amazon-apigateway-authtype: "awsSigv4" definitions: Error: type: "object" properties: message: type: "string" ScalableTargetDimension: description: "A resource that represents the scaling state for a single dimension \ of a scalable target." type: "object" required: - "actualCapacity" - "desiredCapacity" - "scalableTargetDimensionId" - "scalingStatus" - "version" properties: scalableTargetDimensionId: type: "string" format: "" description: "A unique identifier representing a specific scalable target dimension." version: type: "string" format: "" description: "The version associated with the scalable target dimension." actualCapacity: type: "number" format: "double" description: "The actual capacity of the scalable target dimension." desiredCapacity: type: "number" format: "double" description: "The desired capacity of the scalable target dimension." scalingStatus: type: "string" format: "" description: "The current status of scaling activity." enum: - "Pending" # scaling action has not yet begun - "InProgress" # scaling action is in progress - "Successful" # last scaling action was successful - "Failed" # last scaling action has failed resourceName: type: "string" format: "" description: "Optional user-friendly name for a specific resource." dimensionName: type: "string" format: "" description: "Optional user-friendly name for the scalable dimension associated with the resource." failureReason: type: "string" format: "" description: "Optional failure reason that is provided if a scaling action fails." ScalableTargetDimensionUpdate: description: "An update to be applied to the scalable target dimension." type: "object" required: - "desiredCapacity" properties: desiredCapacity: type: "number" format: "double" description: "The new desired capacity of the target." ProdStageDescription: Type: "AWS::ApiGateway::Stage" Properties: DeploymentId: !Ref ProdDeployment Description: "Prod stage" RestApiId: !Ref CustomResourceEndpoint StageName: "prod" MethodSettings: - HttpMethod: "*" LoggingLevel: "INFO" ResourcePath: "/*" MetricsEnabled: true DataTraceEnabled: true ProdDeployment: Type: "AWS::ApiGateway::Deployment" Properties: Description: "The prod path through which the Scaling API deployment is accessible." RestApiId: !Ref CustomResourceEndpoint CustomResourceDDBTable: Type: "AWS::DynamoDB::Table" Properties: AttributeDefinitions: - AttributeName: "scalableTargetDimensionId" AttributeType: "S" KeySchema: - AttributeName: "scalableTargetDimensionId" KeyType: "HASH" ProvisionedThroughput: ReadCapacityUnits: "5" WriteCapacityUnits: "5" TableName: "CustomScalableTargets" LambdaServiceRole: DependsOn: Account # This service role allows Lambda function to call DynamoDB and CloudWatch Type: "AWS::IAM::Role" Properties: RoleName: "CustomResource-LambdaServiceRole" AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Principal: Service: - "lambda.amazonaws.com" Action: - "sts:AssumeRole" Policies: - PolicyName: "LambdaDDBAccess" PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: - "dynamodb:DeleteItem" - "dynamodb:GetItem" - "dynamodb:PutItem" - "dynamodb:Scan" - "dynamodb:UpdateItem" Resource: ## substitute table name here !Sub |- arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${CustomResourceDDBTable} - PolicyName: "LambdaLogAccess" PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: - "logs:CreateLogGroup" Resource: !Sub |- arn:aws:logs:${AWS::Region}:${AWS::AccountId}:* - Effect: "Allow" Action: - "logs:CreateLogStream" - "logs:PutLogEvents" Resource: !Sub |- arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*:* CloudWatchRole: Type: "AWS::IAM::Role" Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - "apigateway.amazonaws.com" Action: "sts:AssumeRole" Path: "/" ManagedPolicyArns: - "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" Account: Type: "AWS::ApiGateway::Account" Properties: CloudWatchRoleArn: "Fn::GetAtt": - CloudWatchRole - Arn LambdaFunction: Type: "AWS::Lambda::Function" Properties: Description: "A Lambda function used to simulate scaling of a custom resource." Handler: "index.lambda_handler" Role: Fn::GetAtt: - "LambdaServiceRole" - "Arn" Code: ZipFile: | import os import boto3 import json from pprint import pformat FILE_NAME = os.path.basename(__file__) DDB_TABLE_NAME = 'CustomScalableTargets' DDB_TABLE_KEY_NAME = 'scalableTargetDimensionId' DDB_TABLE_ATTR_SCALING_STATUS = 'scalingStatus' DDB_TABLE_ATTR_ACTUAL_CAPACITY = 'actualCapacity' DDB_TABLE_ATTR_DESIRED_CAPACITY = 'desiredCapacity' SCALING_STATUS_PENDING = 'Pending' SCALING_STATUS_INPROGRESS = 'InProgress' SCALING_STATUS_SUCCESSFUL = 'Successful' HTTP_OK = '200' HTTP_BAD_REQUEST = '400' def __print(log): print('[' + FILE_NAME + '] ' + log) __print('Starting lambda_handler') dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(DDB_TABLE_NAME) def respond(err_message, res=None): return { 'statusCode': HTTP_BAD_REQUEST if err_message else HTTP_OK, 'body': err_message if err_message else json.dumps(res), 'headers': { 'Content-Type': 'application/json', }, } def get(table, scalable_target_dimension_id): __print("Calling get for scalable_target_dimension_id: " + scalable_target_dimension_id) item = table.get_item(Key={DDB_TABLE_KEY_NAME: scalable_target_dimension_id})['Item'] if item[DDB_TABLE_ATTR_SCALING_STATUS] in {SCALING_STATUS_PENDING, SCALING_STATUS_INPROGRESS}: actual_capacity = float(item[DDB_TABLE_ATTR_ACTUAL_CAPACITY]) desired_capacity = float(item[DDB_TABLE_ATTR_DESIRED_CAPACITY]) if desired_capacity > actual_capacity: patch(table, scalable_target_dimension_id, actual_capacity + min(1, desired_capacity - actual_capacity), SCALING_STATUS_INPROGRESS, False) elif desired_capacity < actual_capacity: patch(table, scalable_target_dimension_id, actual_capacity - min(1, actual_capacity - desired_capacity), SCALING_STATUS_INPROGRESS, False) else: # update status to Successful patch(table, scalable_target_dimension_id, actual_capacity, SCALING_STATUS_SUCCESSFUL, False) return item def patch(table, scalable_target_dimension_id, capacity, scaling_status=None, is_updating_desired=True): capacity_field_name = DDB_TABLE_ATTR_DESIRED_CAPACITY if is_updating_desired else "actualCapacity" __print("Calling patch for scalable_target_dimension_id: " + scalable_target_dimension_id + " with " + capacity_field_name + ": " + str(capacity)) response = table.update_item( Key={DDB_TABLE_KEY_NAME: scalable_target_dimension_id}, UpdateExpression="set " + capacity_field_name + " = :d, " + DDB_TABLE_ATTR_SCALING_STATUS + " = :s", ExpressionAttributeValues={':d': str(capacity), ':s': scaling_status if scaling_status else SCALING_STATUS_PENDING}, ReturnValues="ALL_NEW") return response['Attributes'] def lambda_handler(event, context): __print("Received event: " + json.dumps(event, indent=2)) operations = { 'GET': get, 'PATCH': patch } operation = event['httpMethod'] if operation in operations: __print("received Id: " + event['pathParameters']['scalableTargetDimensionId']) scalable_target_dimension_id = event['pathParameters']['scalableTargetDimensionId'] error_message = None response = None if operation == 'GET': response = operations[operation](table, scalable_target_dimension_id) else: # PATCH body_json = json.loads(event['body']) desired_capacity = body_json['desiredCapacity'] if desired_capacity < 0: error_message = 'Illegal desired capacity {}'.format(str(desired_capacity)) else: response = operations[operation](table, scalable_target_dimension_id, desired_capacity) __print(pformat(error_message)) if error_message else __print(pformat(response)) return respond(error_message, response) else: return respond('Unsupported method "{}"'.format(operation)) Runtime: "python3.6" Timeout: "25" DependsOn: - LambdaServiceRole - CustomResourceDDBTable LambdaInvokeGETPermission: # Grant API Gateway access to invoke this Lambda function DependsOn: LambdaFunction Type: 'AWS::Lambda::Permission' Properties: FunctionName: !GetAtt - LambdaFunction - Arn Action: 'lambda:InvokeFunction' Principal: apigateway.amazonaws.com SourceArn: # arn:aws:execute-api:region:account-id:api-id/stage/method/resource-path !Sub |- arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${CustomResourceEndpoint}/*/GET/scalableTargetDimensions/{scalableTargetDimensionId} LambdaInvokePATCHPermission: # Grant API Gateway access to invoke this Lambda function DependsOn: LambdaFunction Type: 'AWS::Lambda::Permission' Properties: FunctionName: !GetAtt - LambdaFunction - Arn Action: 'lambda:InvokeFunction' Principal: apigateway.amazonaws.com SourceArn: # arn:aws:execute-api:region:account-id:api-id/stage/method/resource-path !Sub |- arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${CustomResourceEndpoint}/*/PATCH/scalableTargetDimensions/{scalableTargetDimensionId} S3Bucket: DeletionPolicy: Retain Type: "AWS::S3::Bucket" Properties: {} BucketPolicy: Type: "AWS::S3::BucketPolicy" Properties: Bucket: Ref: S3Bucket PolicyDocument: Version: "2012-10-17" Statement: - Sid: "AWSCloudTrailAclCheck" Effect: "Allow" Principal: Service: "cloudtrail.amazonaws.com" Action: "s3:GetBucketAcl" Resource: !Sub |- arn:aws:s3:::${S3Bucket} - Sid: "AWSCloudTrailWrite" Effect: "Allow" Principal: Service: "cloudtrail.amazonaws.com" Action: "s3:PutObject" Resource: !Sub |- arn:aws:s3:::${S3Bucket}/AWSLogs/${AWS::AccountId}/* Condition: StringEquals: s3:x-amz-acl: "bucket-owner-full-control" CustomResourceScalingTrail: DependsOn: - BucketPolicy Type: "AWS::CloudTrail::Trail" Properties: S3BucketName: Ref: S3Bucket IsLogging: true Outputs: ProdResourceIdPrefix: Description: Application Auto Scaling Resource ID prefix for Prod. Value: !Sub |- https://${CustomResourceEndpoint}.execute-api.${AWS::Region}.amazonaws.com/${ProdStageDescription}/scalableTargetDimensions/ S3BucketName: Value: !Ref S3Bucket Description: The location of the CloudTrail logs for API-related event history.