Skip to content

Latest commit

 

History

History
575 lines (515 loc) · 16.6 KB

DeviceFarm.md

File metadata and controls

575 lines (515 loc) · 16.6 KB

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}'