Display Terraform Plan summary as a PR comment in Azure DevOps

Standard

Terraform logoIn the previous article, we used Terraform output variables in an Azure DevOps YAML Pipeline. In this article we will go further by displaying the summary of changes as a comment in the PR.

The context

In my team we use Terraform with Azure DevOps to provision our infrastructure. As some of our developers are not yet experienced with Azure nor Terraform, we decided it could be great if IT guys could validate when we make changes to the Terraform files at the PR step. Easily, we were able to automatically add them as PR reviewers as soon as a .tf file was touched. However, it can be a bit long to either look at the changes in the commits or the result of the tf plan step in the PR build.
That’s why we wondered if we could summarize the changes by annotating the PR (as SonarQube can do) with those changes.

Getting the changes

As a reminder, we use Terraform tasks library by Microsoft.
When making a Terraform plan, it generates a binary file which is unreadable. It’s also hard to pipe the output of the plan step as it is run by the task.
Hopefully, the command show is available for Terraform (see the releated doc), it translates the previously generated binary plan as human readable plan or a machine readable plan (as a JSON file). Awesome !
What’s even greater, is that the tasks library has this use anticipated because when using the plan command, the show command is also run and an output variable is available with the path of the generated machine readable plan. We juste have to read this JSON and parse it !

Posting the comment

In Azure DevOps, APIs are available to do almost everything. Here we need to find the endpoint to create a PR thread and post the comment.
This route exists as of version 5.1 of the API here, we have to construct a compliant JSON body and POST to it using the Azure DevOps job token. Piece of cake !

Making everything work

This Powershell script named TerraformAnnotate.ps1 running in the Powershell task does the job, I’ll explain it after :

Param ( 
    [Parameter(Mandatory = $true)][String]$JsonPlanPath
)

$json = Get-Content $JsonPlanPath | ConvertFrom-Json
$changes = $json.resource_changes `
| Where-Object { ($_.change.actions[0] -ne 'no-op') -and ($_.change.actions[0] -ne 'read') }

function ToIcon($action) {
    switch ($action) {
        "create" { ":sparkles:" }
        "update" { ":pencil2:" }
        "delete" { ":bomb:" }
        Default { return $action }
    }
}

$request = @{ comments = New-Object Collections.ArrayList; status = "active" }

if (-not $changes) {
    $comment = @{ content = "**Terraform Plan changes summary :**`r`nNo Changes ! :thumbsup:`r`n`r`nThis comment thread is closed as there are no changes !"; commentType = "text" }
    $request.status = "closed"
}
else {
    $comment = @{ content = "**Terraform Plan changes summary :**`r`n"; commentType = "codeChange" }
    foreach ($change in $changes) {
        $actions = $change.change.actions | ForEach-Object { ToIcon($_) }
        $nameBefore = $change.change.before.name
        $nameAfter = $change.change.after.name

        if($nameBefore -and $nameAfter -and ($nameBefore -ne $nameAfter)) {
            $resource = "$nameBefore :arrow_right: $nameAfter"
        } elseif ($nameBefore -and $nameAfter) {
            $resource = $nameBefore
        } elseif ($nameBefore -and (-not $nameAfter)) {
            $resource = $nameBefore
        } elseif ($nameAfter -and (-not $nameBefore)) {
            $resource = $nameAfter
        }

        $comment.content += "$([System.String]::Join(" ", $actions)) $resource ($($change.type))`r`n"
    }
    $comment.content += "`r`nThis comment thread is active, don't forget to mark it as resolved or won't fix !"    
}

$linkUrl = [System.Uri]::EscapeUriString("${env:System_TeamFoundationCollectionUri}${env:System_TeamProject}/_build/results?buildId=${env:BUILD_BUILDID}&view=logs&j=${env:SYSTEM_JOBID}")
$comment.content += "`r`nSee [Pipeline ${env:BUILD_BUILDNUMBER} logs]($linkUrl)"

$request.comments.Add($comment) | Out-Null
$url = "${env:System_TeamFoundationCollectionUri}${env:System_TeamProject}/_apis/git/repositories/${env:Build_Repository_ID}/pullRequests/${env:system_pullRequest_pullRequestId}/threads?api-version=5.1"
Invoke-RestMethod -Method "POST" -Uri $url -Body ($request | ConvertTo-Json) -ContentType "application/json" -Headers @{ Authorization = "Bearer ${env:SYSTEM_ACCESSTOKEN}" }

First of all, we read the JSON in the file and find the resources that did change (ie. action is neither no-op nor read). Then we create the body of the request with a collection of comments. We then iterate on every change to add the summary to the comment content and we can then invoke the endpoint with the body we created. Note that here we map emojis to each action.
In order to make it work in our YAML pipeline we have to pay attention to two specific points :

      - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV1@0
        name: terraformPlan
        displayName: 'Terraform : plan'
        condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))
        inputs:
          command: plan
          workingDirectory: '$(System.DefaultWorkingDirectory)/eng/terraform'
          commandOptions: '-out=tfplan -var-file=dev.tfvars'
          environmentServiceNameAzureRM: ${{variables.EnvironmentServiceArm}}
          backendServiceArm: ${{variables.BackendServiceArm}}
          backendAzureRmResourceGroupName: ${{variables.BackendResourceGroup}}
          backendAzureRmStorageAccountName: ${{variables.BackendStorageAccount}}
          backendAzureRmContainerName: ${{variables.BackendContainer}}
          backendAzureRmKey: ${{variables.BackendKey}}

      - task: PowerShell@2
        displayName: 'Comment PR with Terraform Plan'
        condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))
        inputs:
          filePath: '$(System.DefaultWorkingDirectory)/eng/TerraformAnnotate.ps1'
          arguments: '-JsonPlanPath $(terraformPlan.jsonPlanFilePath)'
          workingDirectory: '$(System.DefaultWorkingDirectory)/eng'
        env:
          SYSTEM_ACCESSTOKEN: $(System.AccessToken)

First, we need to name the Terraform task (here terraformPlan) in order to use the output variable. Secondly, we must specify the environment variable SYSTEM_ACCESSTOKEN in the Powershell task in order to access it inside the script.

The result

As soon as the build for the PR is run, we can observe the comment :
Terraform plan comment
Here, I’m creating an Azure App Configuration and modify my Azure Web App

Going further

You might have noticed in the script that when there’s no change, we ask for the thread to be closed. This way, when you have a policy where comment resolution is mandatory, you are not bothered.
When there’s a change and the policy is mandatory, someone will have to acknowledge the comment. You can of course change this behavior. Enjoy !

9 thoughts on “Display Terraform Plan summary as a PR comment in Azure DevOps

  1. Steve

    Thanks Nathan. So, if I understand it correctly,
    1. We take the output of “terraformPlan” task as jsonOutputVariablesPath
    2. Run the powershell script task “TerraformAnnotate.ps1” with the argument as -JsonPlanPath $(terraformPlan.jsonOutputVariablesPath)’

    correct?

    Where do you specify the output variable for the terraform plan task in the yaml pipeline?

      • STEVE

        I am trying to follow the steps and facing some problem. Here is how my terraformPlan and powershell tasks are defined in the yaml pipeline.

        – task: charleszipp.azure-pipelines-tasks-terraform.azure-pipelines-tasks-terraform-cli.TerraformCLI@0
        name: terraformPlan
        displayName: ‘terraform Plan’
        inputs:
        command: plan
        workingDirectory: ‘config/tfconfig/$(TF_VAR_dns_suffix)’
        environmentServiceName: TestGTM
        commandOptions: ‘-out=tfplan -no-color -var=”dns_prefix=$(TF_VAR_dns_prefix)” -var=”dns_suffix=$(TF_VAR_dns_suffix)” -var=”name_resource_group=$(TF_VAR_name_resource_group)”‘

        – task: PowerShell@2
        displayName: ‘Comment PR with Terraform Plan’
        inputs:
        targetType: filePath
        filePath: ‘config/tfconfig/$(TF_VAR_dns_suffix)/azure-pipelines/TerraformAnnotate.ps1’
        arguments: ‘-JsonPlanPath $(terraformPlan.jsonPlanFilePath)’
        workingDirectory: ‘config/tfconfig/$(TF_VAR_dns_suffix)’
        env:
        SYSTEM_ACCESSTOKEN: $(System.AccessToken)

        After running the pipeline, i am getting the following error message.
        terraformPlan.jsonPlanFilePath: /home/vsts/work/_temp/9592c5c5-6df2-4458-84dd-9a9596585833.ps1:2
        Line |
        2 | … erraformAnnotate.ps1′ -JsonPlanPath $(terraformPlan.jsonPlanFilePath)
        | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        | The term ‘terraformPlan.jsonPlanFilePath’ is not recognized as
        | the name of a cmdlet, function, script file, or operable
        | program. Check the spelling of the name, or if a path was
        | included, verify that the path is correct and try again.

        Am I doing anything wrong here?

        • Hello there!
          It seems that we are not using the same terraform task mine is ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV1@0 yours is charleszipp.azure-pipelines-tasks-terraform.azure-pipelines-tasks-terraform-cli.TerraformCLI@0
          That would explain the different behavior as your task might not generate the JSON plan.
          If you want to keep your task, you might run a `tf show -json` with your plan to generate the json.

  2. Chris DaMour

    i did something very similar to this for PRs. But i also do it during releases and take the plan and auto generate release notes in CHANGELOG.md on master commits with the tag. Additionally I took the json and looked for changes and conditionally run the tf apply only if there are changes, since our tf apply stages require approval in pipelines this means a bunch of no-op changes to TF repo dont cause a ton of noise.

    Where do we get to see the sauce of TerraformAnnotate.ps1

  3. DanT

    Hi Nat – where are you instantiating the $request object? If I create one explicitly – I’m then hitting permissions issues with the API call. Do you need to define additional permissions on the repo to allow PR comments? I’m using a private ACI build agent that is running with the context of a managed identity. Thanks.

  4. Davie

    Hi Nat,
    Do you know why my terraformPlan.jsonPlanFilePath value is empty? My terraform plan step ran successfully. I’m running the following as my powershell script and it’s showing nothing:

    $JsonPlanPath = $(TerraformPlan.jsonPlanFilePath)
    Write-Output “json ” $JsonPlanPath

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.