# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 AWSTemplateFormatVersion: 2010-09-09 Transform: "AWS::Serverless-2016-10-31" Description: Custom AWS CodePipeline action that enables EC2 build nodes. Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: "General" Parameters: - ProjectId - Label: default: "Custom Action Settings" Parameters: - CustomActionProviderName - CustomActionProviderCategory - CustomActionProviderVersion ParameterLabels: ProjectId: default: "Project ID" CustomActionProviderName: default: "Custom Action Provider Name" CustomActionProviderCategory: default: "Custom Action Provider Category" CustomActionProviderVersion: default: "Custom Action Provider Version" Parameters: # You can provide these parameters in your CreateProject API call. ProjectId: Type: String Description: Prefix that will be used for AWS resources generated by the template. Default: ec2-codepipeline-builders CustomActionProviderName: Type: String Description: Name of the custom action provider (used in CodePipeline Console UI). Default: EC2-CodePipeline-Builder CustomActionProviderCategory: Type: String Description: Category of the custom action provider (used in CodePipeline Console UI). AllowedValues: - Build - Deploy - Invoke - Test Default: Build CustomActionProviderVersion: Type: String Description: Version of the custom action provider (used in CodePipeline Console UI). Resources: # Supporting Lambda Functions JobCompletionHandler: Type: AWS::Serverless::Function Properties: Description: Handles result of job flow execution. CodeUri: lambda/job-completion-handler Handler: lambda.lambda_handler Runtime: python3.7 Role: !GetAtt JobCompletionHandlerExecutionRole.Arn MemorySize: 128 Timeout: 15 InstanceApi: Type: AWS::Serverless::Function Properties: Description: Manages EC2 instances that carry out custom action jobs. CodeUri: lambda/instance-api Handler: lambda.lambda_handler Runtime: python3.7 Role: !GetAtt InstanceApiExecutionRole.Arn MemorySize: 128 Timeout: 15 Environment: Variables: BUILDER_INSTANCE_PROFILE_ARN: !Sub "${Ec2BuilderInstanceProfile.Arn}" JobApi: Type: AWS::Serverless::Function Properties: Description: Runs and tracks SSM commands on EC2 instances. CodeUri: lambda/job-api Handler: lambda.lambda_handler Runtime: python3.7 Role: !GetAtt JobApiExecutionRole.Arn MemorySize: 128 Timeout: 15 Environment: Variables: SSM_DOCUMENT_NAME: !Ref RunBuildJobOnEc2Instance # CodePipeline Polling Function CodePipelinePoller: Type: AWS::Serverless::Function Properties: Description: Polls CodePipeline for Custom Actions. CodeUri: lambda/poller Handler: lambda.lambda_handler Runtime: python3.7 Role: !GetAtt CodePipelinePollerExecutionRole.Arn MemorySize: 128 Timeout: 15 Environment: Variables: STATE_MACHINE_ARN: !Ref Ec2BuilderStateMachine CUSTOM_ACTION_PROVIDER_NAME: !Ref CustomActionProviderName CUSTOM_ACTION_PROVIDER_CATEGORY: !Ref CustomActionProviderCategory CUSTOM_ACTION_PROVIDER_VERSION: !Ref CustomActionProviderVersion Events: # This event is used to react on started instances of the Custom Action CodePipelineActionStartedEvent: Type: CloudWatchEvent Properties: Pattern: source: - "aws.codepipeline" detail-type: - "CodePipeline Action Execution State Change" detail: state: - "STARTED" # This event is needed to make Custom Actions as completed once build is done. # TODO: replace with custom CloudWatch event at the end of Step Functions flow CheckCodePipelineScheduledEvent: Type: Schedule Properties: Schedule: rate(1 minute) # CodePipeline Custom Action Ec2BuildActionType: Type: AWS::CodePipeline::CustomActionType Properties: Category: !Ref CustomActionProviderCategory Provider: !Ref CustomActionProviderName Version: !Ref CustomActionProviderVersion ConfigurationProperties: - Name: ImageId Description: AMI to use for EC2 build instances. Key: true Required: true Secret: false Queryable: false Type: String - Name: InstanceType Description: Instance type for EC2 build instances. Key: true Required: true Secret: false Queryable: false Type: String - Name: Command Description: Command(s) to execute. Key: true Required: true Secret: false Queryable: false Type: String - Name: WorkingDirectory Description: Working directory for the command to execute. Key: true Required: false Secret: false Queryable: false Type: String - Name: OutputArtifactPath Description: Path of the file(-s) or directory(-es) to use as custom action output artifact. Key: true Required: false Secret: false Queryable: false Type: String InputArtifactDetails: MaximumCount: 1 MinimumCount: 0 OutputArtifactDetails: MaximumCount: 1 MinimumCount: 0 Settings: EntityUrlTemplate: !Sub "https://${AWS::Region}.console.aws.amazon.com/systems-manager/documents/${RunBuildJobOnEc2Instance}" ExecutionUrlTemplate: !Sub "https://${AWS::Region}.console.aws.amazon.com/states/home#/executions/details/{ExternalExecutionId}" # SSM Document RunBuildJobOnEc2Instance: Type: "AWS::SSM::Document" Properties: DocumentType: Command Content: schemaVersion: "2.2" description: Downloads build artifacts from S3 and runs specified build scripts. parameters: inputBucketName: description: "(Required) Specify the S3 bucket name of the input artifact." type: String default: "" maxChars: 4096 inputObjectKey: description: "(Required) Specify the S3 objectKey of the input artifact." type: String default: "" maxChars: 4096 commands: description: "(Required) Specify the commands to run or the paths to existing scripts on the instance." type: String displayType: textarea executionId: description: "(Required) Specify the pipeline execution ID" type: String default: "" maxChars: 4096 pipelineArn: description: "(Required) Specify the pipeline ARN" type: String default: "" maxChars: 4096 pipelineName: description: "(Required) Specify the pipeline Name" type: String default: "" maxChars: 4096 workingDirectory: type: String default: "" description: "(Optional) The path where the content will be downloaded and executed from on your instance." maxChars: 4096 outputArtifactPath: type: String default: "" description: "(Optional) The path of the output artifact to upload to S3." maxChars: 4096 outputBucketName: description: "(Optional) Specify the S3 bucket name of the output artifact." type: String default: "" maxChars: 4096 outputObjectKey: description: "(Optional) Specify the S3 objectKey of the output artifact." type: String default: "" maxChars: 4096 executionTimeout: description: "(Optional) The time in seconds for a command to complete before it is considered to have failed. Default is 3600 (1 hour). Maximum is 28800 (8 hours)." type: String default: "28800" allowedPattern: "([1-9][0-9]{0,3})|(1[0-9]{1,4})|(2[0-7][0-9]{1,3})|(28[0-7][0-9]{1,2})|(28800)" mainSteps: # Windows steps - name: windows_script precondition: StringEquals: [platformType, Windows] action: aws:runPowerShellScript inputs: runCommand: # Ensure that if a command fails the script does not proceed to the following commands - '$ErrorActionPreference = "Stop"' - '$ENV:PipelineExecutionId = "{{ executionId }}"' - '$ENV:PipelineArn = "{{ pipelineArn }}"' - '$ENV:PipelineName = "{{ pipelineName }}"' - '$jobDirectory = "{{ workingDirectory }}"' # Create temporary folder for build artifacts, if not provided - "if ([string]::IsNullOrEmpty($jobDirectory)) {" - " $parent = [System.IO.Path]::GetTempPath()" - " [string] $name = [System.Guid]::NewGuid()" - " $jobDirectory = (Join-Path $parent $name)" - " New-Item -ItemType Directory -Path $jobDirectory" # Set current location to the new folder - " Set-Location -Path $jobDirectory" - "}" # Download/unzip input artifact - "Read-S3Object -BucketName {{ inputBucketName }} -Key {{ inputObjectKey }} -File artifact.zip" - "Expand-Archive -Path artifact.zip -DestinationPath ." # Run the build commands - "$directory = Convert-Path ." - '$env:PATH += ";$directory"' - "{{ commands }}" # We need to check exit code explicitly here - "if (-not ($?)) { exit $LASTEXITCODE }" # Compress output artifacts, if specified - '$outputArtifactPath = "{{ outputArtifactPath }}"' - "if ($outputArtifactPath) {" - " Compress-Archive -Path $outputArtifactPath -DestinationPath output-artifact.zip" # Upload compressed artifact to S3 - ' $bucketName = "{{ outputBucketName }}"' - ' $objectKey = "{{ outputObjectKey }}"' - " if ($bucketName -and $objectKey) {" # Don't forget to encrypt the artifact - CodePipeline bucket has a policy to enforce this - " Write-S3Object -BucketName $bucketName -Key $objectKey -File output-artifact.zip -ServerSideEncryption aws:kms" - " }" - "}" workingDirectory: "{{ workingDirectory }}" timeoutSeconds: "{{ executionTimeout }}" # Step Functions Flow Ec2BuilderStateMachine: Type: AWS::StepFunctions::StateMachine Properties: StateMachineName: !Sub "${ProjectId}-build-flow" DefinitionString: !Sub |- { "Comment": "An example of the Amazon States Language that runs an AWS Batch job and monitors the job until it completes.", "StartAt": "Acquire Builder Flow", "States": { "Acquire Builder Flow": { "Type": "Parallel", "Branches": [ { "StartAt": "Start EC2 Instance", "States": { "Start EC2 Instance": { "Type": "Task", "Resource": "${InstanceApi.Arn}", "InputPath": "$.params.instance", "ResultPath": "$.status.instance", "Parameters": { "command": "start", "imageId.$": "$.imageId", "instanceType.$": "$.instanceType", "keyName.$": "$.keyName" }, "Next": "Wait Start" }, "Wait Start": { "Type": "Wait", "Seconds": 30, "Next": "Check Builder Start Status" }, "Check Builder Start Status": { "Type": "Task", "Resource": "${InstanceApi.Arn}", "InputPath": "$.status.instance", "ResultPath": "$.status.instance", "Parameters": { "command": "status_ssm", "instanceId.$": "$.instanceId" }, "Next": "Builder Started?" }, "Builder Started?": { "Type": "Choice", "Choices": [ { "Variable": "$.status.instance.status", "StringEquals": "STARTED", "Next": "Started" }, { "Variable": "$.status.instance.status", "StringEquals": "STOPPED", "Next": "Failed to Start" } ], "Default": "Wait Start" }, "Started": { "Type": "Pass", "End": true }, "Failed to Start": { "Type": "Fail", "Error": "StartError", "Cause": "EC2 instance failed to start." } } } ], "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 } ], "Catch": [ { "ErrorEquals": [ "States.ALL" ], "ResultPath": "$.errorDetails", "Next": "Report Completion" } ], "OutputPath": "$[0]", "Next": "Run Command Flow" }, "Run Command Flow": { "Type": "Parallel", "Branches": [ { "StartAt": "Start Command Execution", "States": { "Start Command Execution": { "Type": "Task", "Resource": "${JobApi.Arn}", "ResultPath": "$.status.command", "Parameters": { "command": "run", "instanceId.$": "$.status.instance.instanceId", "commandText.$": "$.params.command.commandText", "workingDirectory.$": "$.params.command.workingDirectory", "timeout.$": "$.params.command.timeout", "inputBucketName.$": "$.params.artifacts.input.bucketName", "inputObjectKey.$": "$.params.artifacts.input.objectKey", "outputArtifactPath.$": "$.params.artifacts.output.path", "outputBucketName.$": "$.params.artifacts.output.bucketName", "outputObjectKey.$": "$.params.artifacts.output.objectKey", "executionId.$": "$.params.pipeline.executionId", "pipelineArn.$": "$.params.pipeline.arn", "pipelineName.$": "$.params.pipeline.name" }, "Next": "Wait Command Completion" }, "Wait Command Completion": { "Type": "Wait", "Seconds": 30, "Next": "Check Command Status" }, "Check Command Status": { "Type": "Task", "Resource": "${JobApi.Arn}", "ResultPath": "$.status.command", "Parameters": { "command": "status", "instanceId.$": "$.status.instance.instanceId", "commandId.$": "$.status.command.commandId" }, "Next": "Command Completed?" }, "Command Completed?": { "Type": "Choice", "Choices": [ { "Variable": "$.status.command.status", "StringEquals": "SUCCESS", "Next": "Completed" }, { "Variable": "$.status.command.status", "StringEquals": "FAILED", "Next": "Failed to Complete" } ], "Default": "Wait Command Completion" }, "Completed": { "Type": "Pass", "End": true }, "Failed to Complete": { "Type": "Fail", "Error": "RunCommandError", "Cause": "SSM command completed with failures." } } } ], "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 } ], "Catch": [ { "ErrorEquals": [ "States.ALL" ], "ResultPath": "$.errorDetails", "Next": "Release Builder Flow" } ], "OutputPath": "$[0]", "Next": "Release Builder Flow" }, "Release Builder Flow": { "Type": "Parallel", "Branches": [ { "StartAt": "Stop EC2 Instance", "States": { "Stop EC2 Instance": { "Type": "Task", "Resource": "${InstanceApi.Arn}", "InputPath": "$.status.instance", "ResultPath": "$.status.instance", "Parameters": { "command": "stop", "instanceId.$": "$.instanceId" }, "Next": "Wait Stop" }, "Wait Stop": { "Type": "Wait", "Seconds": 30, "Next": "Check Builder Stop Status" }, "Check Builder Stop Status": { "Type": "Task", "Resource": "${InstanceApi.Arn}", "InputPath": "$.status.instance", "ResultPath": "$.status.instance", "Parameters": { "command": "status_ec2", "instanceId.$": "$.instanceId" }, "Next": "Builder Stopped?" }, "Builder Stopped?": { "Type": "Choice", "Choices": [ { "Variable": "$.status.instance.status", "StringEquals": "STOPPED", "Next": "Stopped" }, { "Variable": "$.status.instance.status", "StringEquals": "STARTED", "Next": "Failed to Stop" } ], "Default": "Wait Stop" }, "Stopped": { "Type": "Pass", "End": true }, "Failed to Stop": { "Type": "Fail", "Error": "StopError", "Cause": "EC2 instance failed to stop." } } } ], "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 } ], "Catch": [ { "ErrorEquals": [ "States.ALL" ], "ResultPath": "$.errorDetails", "Next": "Report Completion" } ], "OutputPath": "$[0]", "Next": "Report Completion" }, "Report Completion": { "Type": "Task", "Resource": "${JobCompletionHandler.Arn}", "End": true } } } RoleArn: !GetAtt Ec2BuilderStateMachineExecutionRole.Arn # Lambda Roles JobCompletionHandlerExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole CodePipelinePollerExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: root PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - codepipeline:PollForJobs - codepipeline:GetJobDetails - codepipeline:AcknowledgeJob - codepipeline:PutJobSuccessResult - codepipeline:PutJobFailureResult Resource: "*" - Effect: Allow Action: - states:DescribeExecution - states:StartExecution Resource: "*" InstanceApiExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: root PolicyDocument: Version: "2012-10-17" Statement: # This is necessary to start EC2 instance with Ec2BuilderRole - Effect: Allow Action: - iam:PassRole Resource: !GetAtt Ec2BuilderRole.Arn - Effect: Allow Action: - ec2:CreateTags - ec2:RunInstances - ec2:TerminateInstances - ec2:DescribeInstances - ec2:DescribeInstanceStatus Resource: "*" - Effect: Allow Action: - ssm:DescribeInstanceInformation Resource: "*" JobApiExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: root PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - ssm:SendCommand - ssm:ListCommands Resource: "*" # EC2 Role & Instance Profile Ec2BuilderRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - ec2.amazonaws.com Action: - sts:AssumeRole ManagedPolicyArns: # To be able to connect to SSM and execute commands - arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM # To be able to push docker images to ECR repositories - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser Ec2BuilderInstanceProfile: Type: AWS::IAM::InstanceProfile Properties: Roles: - !Ref Ec2BuilderRole # Step Functions Role Ec2BuilderStateMachineExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Principal: Service: - !Sub states.${AWS::Region}.amazonaws.com Action: "sts:AssumeRole" Path: "/" Policies: - PolicyName: StatesExecutionPolicy PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - "lambda:InvokeFunction" # TODO: restrict resources Resource: "*"