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 !

13 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

    • Alexander

      Hi Chris,

      Is there any particular reason why your Terraform apply requires approval, even after implementing this? I mean, the changes would’ve already been approved by the one approving the PR, right? So wouldn’t it be redundant to have to approve the same plan once again?

      Is this perhaps because the ones that can approve PRs differs from the ones allowed to approve the actual provisioning of the infrastructure?

      I’m curious, because we also require approval for our Terraform apply, and I’d like to apply something similar to this as well, but I’m still thinking about the reasons why I would keep the approval for the actual Terraform apply, when it has already been approved in the PR.

      Thankful for any input!

  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

  5. Alexander

    Hi Nathanael,

    Thanks for a great post!

    I’m thinking about implementing something similar, on our already existing flow (in short below):

    Terraform init and plan —> Manual approval stage of plan —> Terraform apply

    What I’m wondering is, what would be the reason of keeping the manual approval stage of the plan before Terraform apply, if the PR displaying the plan in comments has already been approved? Would this be redundant?

    Could a reason perhaps be because the ones that can approve PRs differs from the ones allowed to approve the actual provisioning of the infrastructure?

    I’m looking for reasons to keep the approval stage before the Terraform apply in the CD pipeline, even when the plan has been approved in the PR.

    I’m curious, because as mentioned we also require an approval before our Terraform apply, and I’d like to apply something similar to this as well, but I’m still thinking about the reasons why I would keep the approval for the actual Terraform apply, when it has already been approved in the PR.

    Thank you!

  6. chuti

    Hello Nathanael,
    Thanks for your post.
    I have a DevOps cycle with a pipeline that includes GCP, Git, Jenkins, and Terraform. I use Terraform’s configuration and variable files to manage client-specific details, with the variable file.

    The challenge I am facing is that when I initiate a Terraform plan via Git, the Terraform apply output generates thousands of lines of information, making it difficult for me to extract only the relevant details. I am specifically interested in identifying the lines that have changed in the Terraform variable file, determining the success of the pipeline, and pinpointing any errors caused by the actions of the Operation Team.

    I’m seeking advice and suggestions on how to address this issue and extract the necessary information from the Terraform apply output more effectively. Are there any best practices or techniques to filter and analyze the output to obtain the desired insights?

    Any guidance or insights would be greatly appreciated. Thank you in advance!

Leave a Reply

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