CodePipelineでSonarQubeの静的解析を自動化するCloudFormationテンプレート


前提条件

リポジトリやブランチを切った時にいちいち手動で静的解析&テストパイプラインなんて作ってられないよね!という人向け。
以下の経験があると分かりやすいはず。

  • CodePipelineで簡単なパイプラインを作ったことがある
  • buildspec.ymlをちょっと書いたことがある
  • CloudFormationテンプレートをちょっと書いたことがある
  • SonarQubeをなんとなく知ってる(前回前々回を読んでSonarQubeのサーバが起動できる)

パイプラインを作るためのIaCとビルド仕様

今回もいきなりCloudFormationテンプレートとbuildspec.ymlから。
buildspec.ymlは本当にビルドするときの名前にしたいので、buildspec_sonar.ymlと別名にしている。別にsonar/buildspec.ymlとかディレクトリを掘っても問題はない。
せっかくだから、静的解析が終わってエラーが見つかった場合はパイプラインが異常終了して分かるようにしておこう。

また、直接ファイルに書き込みたくないので、Parameter StoreからSonarQubeのエンドポイントとトークンを取得するようにする。

AWSTemplateFormatVersion: "2010-09-09"
Description:
  CI/CD Pipeline for Lambda Create

Parameters:
  Prefix:
    Description: "Project name prefix"
    Type: "String"
    Default: "Default"
  PrefixLower:
    Description: "Project name prefix(for S3 Bucket)"
    Type: "String"
    Default: "default"
  CodeCommitRepositoryName:
    Description: "Repository name on CodeCommit"
    Type: "String"
    Default: "Default"
  S3BucketNameSuffix:
    Description: "S3 bucket name for Artifact"
    Type: "String"
    Default: "-artifact-bucket"
  PipelineNameSuffix:
    Description: "PipelineName on CodePipeline"
    Type: "String"
    Default: "-Pipeline"
  BuildProjectNameSuffix:
    Description: "BuildProjectName on CodeBuild"
    Type: "String"
    Default: "-Sonar-Project"
  CodeCommitBranchName:
    Description: "Branch name on Repository"
    Type: "String"
    Default: "Issue"
  CodeBuildRoleSuffix:
    Description: "CodeBuild Service Role"
    Type: "String"
    Default: "-service-role"
  CodeBuildPolicyNameSuffix:
    Description: "IAM Policy Name for CodeBuild"
    Type: "String"
    Default: "-CodeBuild-Polycy"
  CodeBuildLogGroupNameSuffix:
    Description: "LogGroup Name for CodeBuild"
    Type: "String"
    Default: "-CodeBuild-LogGroup"

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "Project name prefix"
        Parameters:
          - Prefix
          - PrefixLower
      - Label:
          default: "S3 Configuration"
        Parameters:
          - S3BucketNameSuffix
      - Label:
          default: "Pipeline Configuration"
        Parameters:
          - PipelineNameSuffix
          - CodeCommitRepositoryName
          - CodeCommitBranchName
          - BuildProjectNameSuffix
      - Label:
          default: "IAM Role/Policy Configuration"
        Parameters:
          - CodeBuildRoleSuffix
          - CodeBuildPolicyNameSuffix
      - Label:
          default: "Log Configuration"
        Parameters:
          - CodeBuildLogGroupNameSuffix

Resources:
  # ------------------------------------------------------------#
  #  IAM Role
  # ------------------------------------------------------------#
  # ★1
  CODEBUILDIAMROLE: 
    Type: AWS::IAM::Role
    Properties: 
      RoleName: !Sub codebuild-${Prefix}${CodeBuildRoleSuffix}
      Description: !Sub Branch CI role for ${CodeCommitRepositoryName} ${CodeCommitBranchName}
      Path: /serivice-role/
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codebuild.amazonaws.com 
            Action: sts:AssumeRole
      Policies: 
        - PolicyName: !Sub ${Prefix}${CodeBuildPolicyNameSuffix}
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: AllowS3Bucket
                Effect: Allow
                Action:
                  - "s3:GetBucketAcl"
                  - "s3:GetBucketLocation"
                Resource: !GetAtt S3BUCKET.Arn
              - Sid: AllowS3Object
                Effect: Allow
                Action:
                  - "s3:PutObject"
                  - "s3:GetObject"
                  - "s3:GetObjectVersion"
                Resource: !Join [ "/", [ !GetAtt S3BUCKET.Arn, "*" ] ]
              - Sid: AllowCloudWatchLogs
                Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: !GetAtt CODEBUILDLOGGROUP.Arn
              - Sid: ParameterStorePutandAssumeRole
                Effect: Allow
                Action:
                  - "ssm:GetParameters"
                  - "sts:AssumeRole"
                Resource: '*'

  # ------------------------------------------------------------#
  #  S3 Bucket 
  # ------------------------------------------------------------#
  S3BUCKET: 
    Type: AWS::S3::Bucket
    Properties: 
      BucketName: !Sub ${PrefixLower}${S3BucketNameSuffix}

  # ------------------------------------------------------------#
  #  Cloud Watch Log Group
  # ------------------------------------------------------------#
  CODEBUILDLOGGROUP:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub ${Prefix}${CodeBuildLogGroupNameSuffix}

  # ------------------------------------------------------------#
  #  CodeBuild
  # ------------------------------------------------------------#
  CODEBUILD:
    Type: AWS::CodeBuild::Project
    Properties: 
      Name: !Sub ${Prefix}${BuildProjectNameSuffix}
      Source: 
        Type: CODEPIPELINE
        BuildSpec: buildspec_sonar.yml
      Artifacts: 
        Type: CODEPIPELINE
      # ★2
      Environment: 
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/amazonlinux2-x86_64-standard:3.0
        EnvironmentVariables:
          - Name: REPOSITORY_NAME
            Type: PLAINTEXT
            Value: !Sub ${CodeCommitRepositoryName}
          - Name: BRANCH_NAME
            Type: PLAINTEXT
            Value: !Sub ${CodeCommitBranchName}
      LogsConfig:
        CloudWatchLogs:
          GroupName: !Sub ${Prefix}${CodeBuildLogGroupNameSuffix}
          Status: ENABLED
      Cache: 
        Type: LOCAL
        Modes:
          - LOCAL_CUSTOM_CACHE
      ServiceRole: !GetAtt CODEBUILDIAMROLE.Arn

  # ------------------------------------------------------------#
  #  CodePipeline
  # ------------------------------------------------------------#
  PIPELINE:
    Type: AWS::CodePipeline::Pipeline
    DependsOn: CODEBUILDIAMROLE
    Properties: 
      Name: !Sub ${Prefix}${PipelineNameSuffix}
      ArtifactStore: 
        Location: !Ref S3BUCKET
        Type: S3
      RoleArn: !Sub arn:aws:iam::${AWS::AccountId}:role/CodePipelineRole
      Stages: 
        - Name: Source
          Actions:
            - RunOrder: 1
              Name: Source
              ActionTypeId: 
                Category: Source
                Owner: AWS
                Provider: CodeCommit
                Version: 1
              Configuration:
                RepositoryName: !Sub ${CodeCommitRepositoryName}
                BranchName: !Sub ${CodeCommitBranchName}
              OutputArtifacts: 
                - Name: SourceArtifact
        - Name: SonarQube
          Actions:
            - RunOrder: 2
              Name: Build
              ActionTypeId: 
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: 1
              Configuration:
                ProjectName: !Ref CODEBUILD
              InputArtifacts: 
                - Name: SourceArtifact
buildspec_sonar.yml
version: 0.2

# ★3
env:
  parameter-store:
    SONARQUBE_ENDPOINT: "SONARQUBE_ENDPOINT"
    SONARQUBE_TOKEN: ${REPOSITORY_NAME}-${BRANCH_NAME}-token

phases:
  install:
    runtime-versions:
      java: corretto8
  build:
    commands:
      - SONAR_PROJECTNAME=${REPOSITORY_NAME}-${BRANCH_NAME}
      - echo ${SONAR_PROJECTNAME}
      - echo build started on `date`
      - mvn install
      - echo build finished on `date`
      - echo SonarScanner started on `date`
      - echo command line sonar-scanner -Dsonar.projectName=${SONAR_PROJECTNAME} -Dsonar.projectKey=${SONAR_PROJECTNAME} -Dsonar.login=${SONARQUBE_TOKEN} -Dsonar.host.url=${SONARQUBE_ENDPOINT}
      # ★4
      - mvn sonar:sonar -Dsonar.projectName=${SONAR_PROJECTNAME} -Dsonar.projectKey=${SONAR_PROJECTNAME} -Dsonar.login=${SONARQUBE_TOKEN} -Dsonar.host.url=${SONARQUBE_ENDPOINT}
      - echo SonarScanner finished on `date`
  post_build:
    commands:
      # ★5
      - TASKID=`grep ceTaskId target/sonar/report-task.txt | sed s/ceTaskId=//`
      - "echo \"SonarQube Task Id: ${TASKID}\""
      - TASKSTATUS_URI=`grep ceTaskUrl target/sonar/report-task.txt | sed s/ceTaskUrl=//`
      - |
        TASKSTATUS="PENDING";
        while [ "${TASKSTATUS}" != "SUCCESS" ]; do
          if [ "${TASKSTATUS}" = "FAILED" ] || [ "{$TASKSTATUS}" = "CANCELLED" ]; then
            echo "SonarQube task ${TASKID} failed";
            exit 1;
          fi
          sleep 5;
          TASKSTATUS=`curl ${TASKSTATUS_URI} | jq -r ".task.status"`;
          echo "SonarQube analysis status is ${TASKSTATUS}";
        done
      - ANALYSISID=`curl ${TASKSTATUS_URI} | jq -r ".task.analysisId"`
      - QUALITYSTATUS=`curl ${SONARQUBE_ENDPOINT}api/qualitygates/project_status\?analysisId=${ANALYSISID} | jq -r ".projectStatus.status"`
      - |
        if [ "${QUALITYSTATUS}" = "OK" ]; then
          echo "SonarQube analysis complete. Quality Gate Passed.";
          exit 0;
        elif [ "${QUALITYSTATUS}" = "ERROR" ]; then
          echo "SonarQube analysis complete. Quality Gate Failed.";
          exit 1;
        else
          echo "An unexpected error occurred while attempting to analyze with SonarQube.";
          exit 1;
        fi      

cache:
  paths:
    - '/root/.m2/**/*'

★1 IAMロール

アーティファクトを受け渡すためのS3バケットとCloudWatch Logs関連のポリシに加えて、今回はパラメータストアから情報を取得するために以下のポリシを加えてある。

              - Sid: ParameterStorePutandAssumeRole
                Effect: Allow
                Action:
                  - "ssm:GetParameters"
                  - "sts:AssumeRole"
                Resource: '*'

★2 環境変数によるリポジトリ・ブランチ名の受け渡し

ここでビルドプロジェクトに環境変数を設定し、環境変数でリポジトリ名とブランチ名をbuildspec.ymlに渡してあげることで、buildspecを変更することなく複数のリポジトリ名とブランチ名を扱うことができる。

        EnvironmentVariables:
          - Name: REPOSITORY_NAME
            Type: PLAINTEXT
            Value: !Sub ${CodeCommitRepositoryName}
          - Name: BRANCH_NAME
            Type: PLAINTEXT
            Value: !Sub ${CodeCommitBranchName}

★3 Parameter Storeからの値の取得

冒頭に記載した通り、Parameter Storeからトークンの値を取得する。
トークンの値はあらかじめ[リポジトリ名]-[ブランチ名]-tokenで作っておく。
Community版使わないなら、ブランチ名いらないということらしいけどね……ケチケチな対応。
★2で作った環境変数が、ここで活きてくる。

env:
  parameter-store:
    SONARQUBE_ENDPOINT: "SONARQUBE_ENDPOINT"
    SONARQUBE_TOKEN: ${REPOSITORY_NAME}-${BRANCH_NAME}-token

★4 SonarScannerの起動

インターネットで検索するとSonarScannerはmvn sonar:sonarで実施する方法と、sonar-scannerをダウンロードする方法が出てくるが、後者は検査結果の情報をファイルダンプしてくれなくてハンドリングが面倒。前者の場合、target/sonar/report-task.txtに情報が出力されるので、post_buildの記述量が減る。

各パラメータは以下のような意味。他にもチューニングパラメータはたくさんあるが、今回はこれくらい抑えておけば充分。

パラメータ名 意味
sonar.projectName SonarQubeの解析結果が画面のタイトルになる部分。指定しないと、Mavenの場合はpom.xmlのartifactIdかnameの値が使われる
sonar.projectKey SonarQubeでプロジェクトを一位に識別する識別子
sonar.login 事前に払い出していたプロジェクトのトークン
sonar.host.url SonarQubeのサーバが起動しているエンドポイントのルートパス。間違えて末尾の"/"が重複するようなことをしてはいけない。謎のExceptionに丸一日悩まされた……
sonar.java.binaries ビルド結果がtargetディレクトリ以外に出力されるようであれば指定する。デフォルトはtarget

★5 解析結果によってCodeBuildの実行結果コードを変える

SonarScannerが出力したtarget/sonar/report-task.txtから以下のレコードを取得する。

ceTaskId=[タスクID]
ceTaskUrl=http://[SonarQubeのエンドポイント]/api/ce/task?id=[タスクID]

ここからタスクの実行状況を監視しているのがwhile文の部分。
さらに、while文の結果からjqコマンドでJSONを解析して、

http://[SonarQubeのエンドポイント]/api/qualitygates/project_status?analysisId=[解析結果のanalysisId]

をcurlで実行して解析結果を取得し、if文で正常/異常判定をしている。

実行するためのCLI

このCloudFormationテンプレートを実行するためのCLIは以下。
リポジトリ名、ブランチ名を組み合わせれば、他から呼び出して自動でパイプラインを作ったりもできるはず。

$ aws cloudformation create-stack --stack-name [スタック名] \
--template-body file://[CloudFormationテンプレートファイル] \
--capabilities CAPABILITY_NAMED_IAM \
--parameters ParameterKey=Prefix,ParameterValue=[各リソースのプレフィックス] \
ParameterKey=PrefixLower,ParameterValue=[S3向けプレフィックス] \
ParameterKey=CodeCommitRepositoryName,ParameterValue=[リポジトリ名] \
ParameterKey=CodeCommitBranchName,ParameterValue=[ブランチ名]