Azure DevOps で CI/CD やってみる

# やりたいこと
Azure DevOps での CI/CD をトライアル的にやってみたので、その内容を書いてみます。
お試し感強めなのでご参考まで！

# パイプライン内容
パイプラインで行うのは Terraform での Azure リソースの管理で、構成は以下のような形。

![overview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743460/ac308621-6db1-b164-7308-905dfe1b0383.png)

Developer が PR を発行すると PR 時用のパイプラインが動作し、各種 Validation を行う。その後 `terraform plan` を行い、その内容の確認 (手動) を必須にしている。確認が取れれば PR パイプラインが成功して Merge 可能になる。
Merge された後は Merge 用のパイプラインが動作し、まず承認者によるリリースの承認を求めて、そこで承認が得られれば実際に `terraform apply` を実行する、という流れになる。

# パイプライン作成
Azure DevOps でのパイプライン作成は、クラシックパイプラインと YAML パイプラインがあり、クラシックは GUI 上で作成していくが、YAML の方はその名の通り YAML ファイルで作成する。

今回は他の SCM ツールでの CI/CD でも使われている YAML ファイルで作成する。以下のドキュメントを参考に作成していく。

https://learn.microsoft.com/ja-jp/azure/devops/pipelines/yaml-schema/?view=azure-pipelines

作成の際は VSCode などのエディタで作成してもよいが、Azure DevOps 上で編集すると Intelligence も使えるので便利。

https://learn.microsoft.com/ja-jp/azure/devops/pipelines/get-started/yaml-pipeline-editor?view=azure-devops#edit-a-yaml-pipeline

## Stage, Job, Step の関係
**各 Job は1つの Agent で実行される** という点に注意。Job 毎に環境が分かれてしまうので、ある Job で何かツールをインストールしても別の Job ではそのツールを使えない。そのため、ツールのインストールなどは Job 毎に行う必要がある。また、あるジョブで作成したファイルを別のジョブで参照することも基本的にはできない (Artifact として扱えばできるかも？)。

# Azure への接続
Azure へ接続 (認証) するにはいくつか方法があるが、今回は専用のサービスプリンシパルを作成してその認証情報を使う。権限のスコープは用途に応じて最小限にするのが良いためリポジトリ毎にサービスプリンシパルを作成するのがベター。

https://learn.microsoft.com/ja-jp/azure/devops/pipelines/library/connect-to-azure?view=azure-devops

Azure へのサービスコネクタを事前に作成して、その認証情報をパイプラインから呼び出す形になる。

```
- task: AzureCLI@2
  inputs:
    azureSubscription: {YOUR_SERVICE_CONNECTION}
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      az --version
      az account show
    addSpnToEnvironment: true
```

# Terraform 拡張機能
Terraform を使うにあたり、自前でスクリプトを書いてインストールしても良いが手間なので拡張機能を利用する。

https://marketplace.visualstudio.com/items?itemName=ms-devlabs.custom-terraform-tasks

「Get it free」をクリックしてリポジトリのある Organization にインストールする。
使う際は以下のようにタスクを呼び出してパラメータを設定する。

```
- task: TerraformTaskV4@4
  displayName: terraform init
  inputs:
    provider: 'azurerm'
    command: 'init'
    workingDirectory: {WORKING_DIR}
    backendServiceArm: {YOUR_SERVICE_CONNECTION}
    backendAzureRmResourceGroupName: {YOUR_RESOURCE_GROUP}
    backendAzureRmStorageAccountName: {YOUR_STORAGE_ACCOUNT}
    backendAzureRmContainerName: {YOUR_CONATINER}
    backendAzureRmKey: {YOUR_STATEFILE}
```

# PR トリガ
ここが一番分かりづらいところ。
YMAL スキーマの中に `trigger` や `pr` があり当初はそれらを使おうと思っていたがどうもうまく動作しなかった。よく調べてみると、GUI 側の設定でオーバーライドする必要があるそう。

https://zenn.dev/nuits_jp/articles/2023-07-02-azure-pipelines-pr-trigger

# 承認 (検証)
何らかリリースする際に内容を確認したり承認が必要だったりという場合には、手動検証と承認チェックを利用できる。

## 手動検証

https://learn.microsoft.com/ja-jp/azure/devops/pipelines/release/deploy-using-approvals?view=azure-devops#set-up-manual-validation

手動検証のタスクを利用することで、それ以降のフローを一時停止させて再開 or 拒否するかの確認をできるようになる。

## 承認チェック

https://learn.microsoft.com/ja-jp/azure/devops/pipelines/process/approvals?view=azure-devops&tabs=check-pass#approvals

リリースする環境を DevOps 上で事前に作成 (定義) しておき、その環境を YAML ファイル内で job に割り当てることでそのジョブを実行する前に承認を求められるようになる。

# PR パイプラインと Merge パイプライン
Azure DevOps ではパイプラインを複数作成可能で、それぞれ別の YAML ファイルに記述できる。
1つのファイルでどちらもトリガできるとは思うが、分かりづらくなるのでファイル分割した。

# 作成した YAML ファイル
ということで作成した2つの YAML ファイルは以下。正直パイプラインの完成度はイマイチなのでご参考までです！

``` onPullRequest.yml
pool: 
  vmImage: ubuntu-latest

# trigger 設定は Pull Request 時に CI を動作させるために、
# DevOps の GUI 設定で無効化している
# ファイルに残している理由:
# 1. trigger 設定を削除するとすべてのブランチへの Merge 時にトリガされてしまう
# 2. trigger: none にすると PR 時のトリガも動作しなくなってしまう
# https://zenn.dev/nuits_jp/articles/2023-07-02-azure-pipelines-pr-trigger
trigger:
  - main

stages:
- stage: Validation
  jobs:
  - job: Format
    steps:
    - task: TerraformInstaller@0
      displayName: install terraform
      inputs:
        terraformVersion: 'latest'

    - script: |
        cd infra/ && pwd
        terraform fmt -check
        if [ $? -ne 0 ]; then echo "You need to format"; exit 1; fi
      displayName: terraform fmt

  - job: Lint
    steps:
      - script: |
          curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
          tflint --version
        displayName: install tflint
      - script: |
          cd infra/ && pwd
          tflint --init
          tflint
        displayName: tflint

  - job: Validate
    steps:
      - task: TerraformInstaller@0
        displayName: install terraform
        inputs:
          terraformVersion: 'latest'

      - task: TerraformTaskV4@4
        displayName: terraform init
        inputs:
          provider: 'azurerm'
          command: 'init'
          workingDirectory: {WORKING_DIR}
          backendServiceArm: {YOUR_SERVICE_CONNECTION}
          backendAzureRmResourceGroupName: {YOUR_RESOURCE_GROUP}
          backendAzureRmStorageAccountName: {YOUR_STORAGE_ACCOUNT}
          backendAzureRmContainerName: {YOUR_CONATINER}
          backendAzureRmKey: {YOUR_STATEFILE}

      - task: TerraformTaskV4@4
        displayName: terraform validate
        inputs:
          provider: 'azurerm'
          command: 'validate'
          workingDirectory: {YOUR_WORKING_DIR}

- stage: Planning
  jobs:
  - job: Plan
    steps:
    - task: TerraformInstaller@0
      displayName: install terraform
      inputs:
        terraformVersion: 'latest'

    - task: TerraformTaskV4@4
      displayName: terraform init
      inputs:
        provider: 'azurerm'
        command: 'init'
        workingDirectory: {WORKING_DIR}
        backendServiceArm: {YOUR_SERVICE_CONNECTION}
        backendAzureRmResourceGroupName: {YOUR_RESOURCE_GROUP}
        backendAzureRmStorageAccountName: {YOUR_STORAGE_ACCOUNT}
        backendAzureRmContainerName: {YOUR_CONATINER}
        backendAzureRmKey: {YOUR_STATEFILE}

    - task: TerraformTaskV4@4
      displayName: terraform plan
      inputs:
        provider: 'azurerm'
        command: 'plan'
        workingDirectory: {YOUR_WORKING_DIR}
        environmentServiceNameAzureRM: {YOUR_SERVICE_CONNECTION}

- stage: RequestManualValidation
  jobs:
  - job: ManualValidation
    pool: server
    steps:
    - task: ManualValidation@0
      displayName: check terraform plan
      timeoutInMinutes: 1440 # task times out in 1 day
      inputs:
          notifyUsers: |
              {USER_EMAIL}
          instructions: 'Please validate the build configuration and resume'
          onTimeout: 'reject'
```

``` onMerge.yml
pool: 
  vmImage: ubuntu-latest

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - {TARGET_PATH}

stages:
- stage: Provisioning
  jobs:
  - deployment: Apply
    environment: Check Terraform Plan
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self

          - task: TerraformInstaller@0
            displayName: install terraform
            inputs:
              terraformVersion: 'latest'

          - task: TerraformTaskV4@4
            displayName: terraform init
            inputs:
              provider: 'azurerm'
              command: 'init'
              workingDirectory: {WORKING_DIR}
              backendServiceArm: {YOUR_SERVICE_CONNECTION}
              backendAzureRmResourceGroupName: {YOUR_RESOURCE_GROUP}
              backendAzureRmStorageAccountName: {YOUR_STORAGE_ACCOUNT}
              backendAzureRmContainerName: {YOUR_CONATINER}
              backendAzureRmKey: {YOUR_STATEFILE}

          - task: TerraformTaskV4@4
            displayName: terraform apply
            inputs:
              provider: 'azurerm'
              command: 'apply'
              workingDirectory: {WORKING_DIR}
              environmentServiceNameAzureRM: {YOUR_SERVICE_CONNECTION}
```


以上です。