A CloudFormation template that will build a CI/CD pipeline for testing a mobile app with Device Farm
This CloudFormation template was contributed by Adrian Hall, [email protected]
---
AWSTemplateFormatVersion: '2010-09-09'
Description: Mobile App CICD Demo
Parameters:
DeviceFarmProjectName:
Type: String
Default: demo-app-devicefarm
SourceBranchName:
Type: String
Default: master
CodeCommitRepoName:
Type: String
Default: demo-app-code-repo
CodeCommitRepoDescription:
Type: String
Default: demo-app-code-repo
BuildTimeoutInMinutes:
Type: Number
Default: 15
AppModuleName:
Type: String
Default: app
OutputApkKeyName:
Type: String
Default: app.apk
Resources:
# AWS CodeCommit Git repository to store the app's code
CodeRepo:
Type: AWS::CodeCommit::Repository
Properties:
RepositoryDescription: !Ref CodeCommitRepoDescription
RepositoryName: !Ref CodeCommitRepoName
LambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ''
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action: sts:AssumeRole
Path: "/"
Policies:
- PolicyName: LambdaPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Action: "devicefarm:*"
Resource: "*"
Effect: Allow
- Action: "logs:*"
Resource: "*"
Effect: Allow
- Action: "codepipeline:PutJob*"
Resource: "*"
Effect: Allow
DeviceFarmProjectFunction:
Type: AWS::Lambda::Function
Properties:
Description: "Creates, updates, deletes Device Farm projects"
Handler: "index.handler"
Runtime: "python3.6"
Role: !GetAtt ["LambdaRole", "Arn"]
Code:
ZipFile: |
import boto3
import cfnresponse
import sys
import traceback
def handle_delete(df, event):
arn = event['PhysicalResourceId']
df.delete_project(
arn = arn
)
return arn
def handle_update(df, event):
arn = event['PhysicalResourceId']
df.update_project(
arn = arn,
name = event['ResourceProperties']['ProjectName']
)
return arn
def handle_create(df, event):
resp = df.create_project(
name = event['ResourceProperties']['ProjectName']
)
return resp['project']['arn']
def get_top_device_pool(df, df_project_arn):
try:
resp = df.list_device_pools(
arn=df_project_arn,
type='CURATED'
)
pools = resp['devicePools']
for pool in pools:
if pool['name'] == 'Top Devices':
return pool['arn']
except:
print("Unable to get device pools: ", sys.exc_info()[0])
return None
def handler(event, context):
df = boto3.client('devicefarm', region_name='us-west-2')
project_arn = None
try:
if event['RequestType'] == 'Delete':
project_arn = handle_delete(df, event)
if event['RequestType'] == 'Update':
project_arn = handle_update(df, event)
if event['RequestType'] == 'Create':
project_arn = handle_create(df, event)
device_pool_arn = get_top_device_pool(df, project_arn)
cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Arn' : project_arn, 'DevicePoolArn': device_pool_arn}, project_arn)
except:
print("Unexpected error:", sys.exc_info()[0])
traceback.print_exc()
cfnresponse.send(event, context, cfnresponse.FAILED, None, None)
DeviceFarmProject:
Type: Custom::DeviceFarmProject
Properties:
ServiceToken: !GetAtt DeviceFarmProjectFunction.Arn
ProjectName: !Ref DeviceFarmProjectName
# Pipeline bucket
PipelineBucket:
Type: AWS::S3::Bucket
ArtifactBucket:
Type: AWS::S3::Bucket
Properties:
VersioningConfiguration:
Status: Enabled
CodeBuildServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ''
Effect: Allow
Principal:
Service:
- codebuild.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: CodePipelinePolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: CloudWatchLogsPolicy
Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource:
- "*"
- Sid: CodeCommitPolicy
Effect: Allow
Action:
- codecommit:GitPull
Resource:
- Fn::GetAtt:
- CodeRepo
- Arn
- Sid: S3Policy
Effect: Allow
Action:
- s3:Get*
- s3:Put*
Resource:
- "*"
- Action:
- ecr:GetAuthorizationToken
Resource: "*"
Effect: Allow
Builder:
Type: AWS::CodeBuild::Project
Properties:
Environment:
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/android-java-8:24.4.1
Type: LINUX_CONTAINER
ServiceRole:
Fn::GetAtt:
- CodeBuildServiceRole
- Arn
Source:
Type: CODEPIPELINE
BuildSpec: !Sub |
version: 0.1
phases:
pre_build:
commands:
- android-accept-licenses.sh "android update sdk --no-ui --all --filter \"android-$ANDROID_VERSION,tools,platform-tools,build-tools-$ANDROID_TOOLS_VERSION,extra-android-m2repository\""
- echo "y" | $ANDROID_HOME/tools/bin/sdkmanager "extras;m2repository;com;android;support;constraint;constraint-layout;1.0.2"
build:
commands:
- ./gradlew build
artifacts:
files:
- ${AppModuleName}/build/outputs/apk/debug/${AppModuleName}-debug.apk
discard-paths: yes
Artifacts:
Type: CODEPIPELINE
TimeoutInMinutes:
Ref: BuildTimeoutInMinutes
Deliver:
Type: AWS::CodeBuild::Project
Properties:
Environment:
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/android-java-8:24.4.1
Type: LINUX_CONTAINER
ServiceRole:
Fn::GetAtt:
- CodeBuildServiceRole
- Arn
Source:
Type: CODEPIPELINE
BuildSpec: !Sub |
version: 0.1
phases:
build:
commands:
- aws s3 cp --acl public-read ${AppModuleName}-debug.apk s3://${ArtifactBucket}/${OutputApkKeyName}
artifacts:
files:
- ${AppModuleName}-debug.apk
Artifacts:
Type: CODEPIPELINE
TimeoutInMinutes:
Ref: BuildTimeoutInMinutes
CodePipelineServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ''
Effect: Allow
Principal:
Service:
- codepipeline.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: CodePipelinePolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- s3:GetObject
- s3:GetObjectVersion
- s3:GetBucketVersioning
Resource: "*"
Effect: Allow
- Action:
- s3:PutObject
Resource:
- arn:aws:s3:::codepipeline*
- arn:aws:s3:::elasticbeanstalk*
Effect: Allow
- Action:
- codecommit:GetBranch
- codecommit:GetCommit
- codecommit:UploadArchive
- codecommit:GetUploadArchiveStatus
- codecommit:CancelUploadArchive
Resource:
Fn::GetAtt:
- CodeRepo
- Arn
Effect: Allow
- Action:
- codebuild:*
Resource: "*"
Effect: Allow
- Action:
- autoscaling:*
- cloudwatch:*
- s3:*
- sns:*
- cloudformation:*
- sqs:*
- iam:PassRole
Resource: "*"
Effect: Allow
- Action:
- lambda:InvokeFunction
- lambda:ListFunctions
Resource: "*"
Effect: Allow
CloudFormationServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ''
Effect: Allow
Principal:
Service:
- cloudformation.amazonaws.com
Action: sts:AssumeRole
Path: "/"
Policies:
- PolicyName: CloudFormationPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Action: "*"
Resource: "*"
Effect: Allow
StartDeviceFarmTestFunction:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
MemorySize: 128
Role: !GetAtt LambdaRole.Arn
Runtime: python3.6
Timeout: 300
Environment:
Variables:
PROJECT_ARN: !GetAtt DeviceFarmProject.Arn
DEVICE_POOL_ARN: !GetAtt DeviceFarmProject.DevicePoolArn
Code:
ZipFile: !Sub |
import boto3
import http.client
import os
import urllib.parse
import time
import tempfile
import zipfile
# Init codepipeline client
session = boto3.session.Session(
region_name='${AWS::Region}'
)
codepipeline = session.client('codepipeline')
# Init devicefarm client
df_session = boto3.session.Session(region_name='us-west-2')
devicefarm = df_session.client('devicefarm')
def check_devicefarm_test_status(job_id, job_data):
run_id = job_data['continuationToken']
resp = devicefarm.get_run(arn=run_id)
if resp['run']['result'] == 'PASSED':
codepipeline.put_job_success_result(jobId=job_id)
elif resp['run']['result'] == 'PENDING':
codepipeline.put_job_success_result(jobId=job_id, continuationToken=run_id)
else:
codepipeline.put_job_failure_result(jobId=job_id, failureDetails={'message': 'Failed', 'type': 'JobFailed'})
def start_test(job_id, job_data):
# Get temp directory path
tmp_dir = tempfile.mkdtemp()
# Get input artifact
input_zipfile = tmp_dir + "/input.zip"
input_s3_credentials = job_data['artifactCredentials']
input_s3_location = job_data['inputArtifacts'][0]['location']['s3Location']
session = boto3.session.Session(
region_name='${AWS::Region}',
aws_access_key_id=input_s3_credentials['accessKeyId'],
aws_secret_access_key=input_s3_credentials['secretAccessKey'],
aws_session_token=input_s3_credentials['sessionToken']
)
s3 = session.client('s3')
obj = s3.get_object(
Bucket=input_s3_location['bucketName'],
Key=input_s3_location['objectKey']
)
input_bytes = obj['Body'].read()
f = open(input_zipfile, 'wb')
f.write(input_bytes)
f.close()
# Since the input artifact is an APK inside a Zip file, we need to first extract the Zip file
zip = zipfile.ZipFile(input_zipfile, 'r')
zip.extractall(tmp_dir)
zip.close()
# Read APK bytes
apk_file = tmp_dir + "/${AppModuleName}-debug.apk"
f = open(apk_file, 'rb')
apk_bytes = f.read()
f.close()
# Create upload in DeviceFarm
resp = devicefarm.create_upload(
projectArn=os.environ['PROJECT_ARN'],
name="app.apk",
type='ANDROID_APP',
contentType='application/octet-stream'
)
upload_url = resp['upload']['url']
upload_arn = resp['upload']['arn']
# Set HTTP request headers
headers = {
"Content-type": "application/octet-stream",
"Content-length": len(apk_bytes)
}
parsed_url = urllib.parse.urlparse(upload_url)
http_conn = http.client.HTTPSConnection(parsed_url.netloc, 443)
http_conn.request("PUT", upload_url, apk_bytes, headers)
http_resp = http_conn.getresponse()
# Wait for upload to be processed
while True:
resp = devicefarm.get_upload(arn=upload_arn)
if(resp['upload']['status'] == "SUCCEEDED"):
break
if(resp['upload']['status'] == "FAILED"):
break
time.sleep(5)
# Schedule run
resp = devicefarm.schedule_run(
projectArn=os.environ['PROJECT_ARN'],
appArn=upload_arn,
devicePoolArn=os.environ['DEVICE_POOL_ARN'],
name=job_id,
test={
"type" : "BUILTIN_EXPLORER"
}
)
run_id = resp['run']['arn']
codepipeline = boto3.client('codepipeline')
codepipeline.put_job_success_result(jobId=job_id, continuationToken=run_id)
def handler(event, context):
try:
job_id = event['CodePipeline.job']['id']
job_data = event['CodePipeline.job']['data']
if 'continuationToken' in job_data:
check_devicefarm_test_status(job_id, job_data)
else:
start_test(job_id, job_data)
except Exception as e:
traceback.print_exc()
codepipeline.put_job_failure_result(jobId=job_id, failureDetails={'message': 'Failed', 'type': 'JobFailed'})
return True
Pipeline:
Type: AWS::CodePipeline::Pipeline
Properties:
ArtifactStore:
Type: S3
Location: !Ref PipelineBucket
RestartExecutionOnUpdate: 'true'
RoleArn:
Fn::GetAtt:
- CodePipelineServiceRole
- Arn
Stages:
- Name: Source
Actions:
- Name: SourceAction
ActionTypeId:
Category: Source
Owner: AWS
Version: '1'
Provider: CodeCommit
OutputArtifacts:
- Name: SourceBundle
Configuration:
BranchName:
Ref: SourceBranchName
RepositoryName:
Ref: CodeCommitRepoName
RunOrder: '1'
- Name: Build
Actions:
- Name: CodeBuild
InputArtifacts:
- Name: SourceBundle
ActionTypeId:
Category: Build
Owner: AWS
Version: '1'
Provider: CodeBuild
OutputArtifacts:
- Name: buildArtifact
Configuration:
ProjectName: !Ref Builder
RunOrder: '1'
- Name: Test
Actions:
- Name: RunDeviceFarmTest
InputArtifacts:
- Name: buildArtifact
ActionTypeId:
Category: Invoke
Owner: AWS
Version: '1'
Provider: Lambda
OutputArtifacts:
- Name: testRunId
Configuration:
FunctionName: !Ref StartDeviceFarmTestFunction
RunOrder: 1
- Name: Deliver
Actions:
- Name: CopyApkToS3
InputArtifacts:
- Name: buildArtifact
ActionTypeId:
Category: Build
Owner: AWS
Version: '1'
Provider: CodeBuild
Configuration:
ProjectName: !Ref Deliver
RunOrder: '1'
Outputs:
CodeRepoCloneUrlHttp:
Description: "Code Repo HTTP Clone URL"
Value: !GetAtt CodeRepo.CloneUrlHttp
OutputApkUrl:
Description: "URL to the latest built and tested APK"
Value: !Sub 'https://${ArtifactBucket.DualStackDomainName}/${OutputApkKeyName}'