Azure DevOps, Professional, Terraform, YAML Pipelines

Azure DevOps Terraform Task

Introduction

As development teams implement more and more Infrastructure as Code (IaC) leveraging Azure DevOps(ADO) there is a need for to ADO tasks that integrate easily and help improve the software development process. This post will specifically focus on the Azure DevOps Terraform Task

Update: To see this task in action check out my TheYAMLPipelineOne GitHub Repo

The Problem

With growth in popularity on Azure DevOps Microsoft Hosted Agents we need the ability to consistently deploy software on machines that organizations don’t maintain and managed. Because of this there needs to be task(s) to install/validate that the require software for build/deployment is configured correctly on the hosted agent.

An additional ask would be feature/functionality that easily integrates into ADO and provides for a better overall developer experience.

The Azure DevOps Implementation

The Azure DevOps Terraform Task does both of these. First installing Terraform can be configured to a specific version passed in at build:

parameters:
- name: terraformVersion
  type: string
  
steps:
  - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
    displayName: install terraform
    inputs:
        terraformVersion: ${{ parameters.terraformVersion }}

This ensure that the correct version of Terraform is installed on the Microsoft hosted agent. After this we can use the additional commands via TerraformCLI@0.

For instance, to initialize Terraform against an azurerm backend.

parameters:
- name: serviceName
  type: string
- name: TerraformDirectory
  type: string
- name: AzureSubscriptionServiceConnectionName
  type: string
- name: TerraformStateStorageAccountResourceGroupName
  type: string
- name: TerraformStateStorageAccountName
  type: string
- name: TerraformStateStorageAccountContainerName
  type: string
  
steps:
  - task: TerraformCLI@0
    displayName: 'Terraform : init'
    inputs:
      command: init
      backendType: azurerm
      workingDirectory: ${{ parameters.TerraformDirectory }}
      backendServiceArm: ${{ parameters.AzureSubscriptionServiceConnectionName }}
      backendAzureRmResourceGroupName: ${{ parameters.TerraformStateStorageAccountResourceGroupName }}
      backendAzureRmStorageAccountName: ${{ parameters.TerraformStateStorageAccountName }}
      backendAzureRmContainerName: ${{ parameters.TerraformStateStorageAccountContainerName }}
      backendAzureRmKey: ${{ parameters.serviceName }}.tfstate

Then a plan:

parameters:
- name: TerraformDirectory
  type: string
- name: AzureSubscriptionServiceConnectionName
  type: string
- name: commandOptions
  default: '-out=$(System.DefaultWorkingDirectory)/terraform.tfplan -detailed-exitcode'
- name: additionalParameters
  type: object
  default: []

steps:
    - task: TerraformCLI@0
      displayName: 'Terraform : plan'
      inputs:
        command: plan
        workingDirectory: ${{ parameters.TerraformDirectory }}
        publishPlanResults: ${{ parameters.AzureSubscriptionServiceConnectionName }}
        environmentServiceName: ${{ parameters.AzureSubscriptionServiceConnectionName }}
        commandOptions: ${{ parameters.commandOptions }}

Take note on this one of the publishPlanResults and the commandOptions. We will show what these translate to later.

Finally, the apply:

parameters:
- name: TerraformDirectory
  type: string
- name: AzureSubscriptionServiceConnectionName
  type: string
- name: additionalParameters
  type: object
  default: []
  
steps:
- task: TerraformCLI@0
  displayName: 'Terraform : apply'
  condition: and(succeeded(), eq(variables['TERRAFORM_PLAN_HAS_CHANGES'],'true'))
  inputs:
    command: apply
    workingDirectory: ${{ parameters.TerraformDirectory }}
    commandOptions: '$(System.DefaultWorkingDirectory)/terraform.tfplan'
    environmentServiceName: ${{ parameters.AzureSubscriptionServiceConnectionName }}

Notice a condition on this apply checking against TERRAFORM_PLAN_HAS_CHANGES this is a variable created by the plan command and writes back if the Terraform will actually make any infrastructure changes. In the scenario where IaC is stored and deployed with application code (which I strongly recommend) this will skip the Terraform apply step if there aren’t any changes to apply.

Here is a screenshot confirming the apply was skipped in the case of no changes being detected:

The Azure DevOps Experience

In addition to providing the ability to skip over the apply step if no changes have occurred another key thing this task provides is the publishing of the plan directly to ADO.

Notice we have a new tab on the ADO pipeline called ‘Terraform Plan’

This tab lets me choose which plan to evaluate, in my case the plan was done per environment. Here is Dev where no changes were detected:

And here is an example of a UAT environment where changes were detected:

This information was previously available in the output of the tasks; however, by moving this to a new tab can quickly navigate and see any changes. Plus, with the push to have business users release code this is less daunting for someone without a technical background to look at.

Conclusion

Using the Azure Terraform CLI task can greatly improve the experience when working with Terraform within ADO.