Advanced Terraform Build Pipeline

Production ready Terraform Build pipeline

At Axxit, we care about the DevOps setup of our customers. A professional setup increases the deployment velocity and the quality in general. In this blogpost I am giving away the Azure DevOps pipeline template to manage Terraform (Infrastructure as Code) and explain it step by step.

This template can be used both during pull request reviews (CI) and when deploying Terraform (CD) in order to create the Terraform plan.

You can find the full template here in my DevOps samples repository.

Why a build pipeline is important

It’s extremely important to have a robust build pipeline in place when dealing with Infrastructure as Code. This ensures you have validated and scanned your code and that you know what is about to change if you’ll deploy.

Step by step explanation

1. Define input parameters

Define all of the input parameters that you need, provide default values if possible.

  parameters:
    - name: ARTIFACTS_DIRECTORY
      type: string
      default: 'artifacts'
    - name: ENVIRONMENT
      type: string
    - name: SERVICE_CONNECTION
      type: string
    - name: TERRAFORM_BACKEND
      type: string
    - name: TERRAFORM_PLAN
      type: string
      default: 'out.tfplan'
    - name: TERRAFORM_VARIABLES
      type: string
    - name: TERRAFORM_VERSION
      type: string
    - name: TOKENIZE_SOURCES
      type: string
      default: '[]'
    - name: WORKING_DIRECTORY
      type: string
      default: '.'

2. Checkout the repository

Fetch your repository contents to ensure your pipeline can access them.

  - checkout: self
    displayName: Checkout Repository

3. Tokenize placeholders

Optionally, replace the placeholders in your .tfbackend with settings from your library.

  - task: qetza.replacetokens.replacetokens-task.replacetokens@6
    displayName: Tokenize
    condition: ne('${{ parameters.TOKENIZE_SOURCES }}', '[]')
    inputs:
      sources: "${{ parameters.TOKENIZE_SOURCES }}"
      encoding: "auto"
      addBOM: true
      missingVarLog: "error"
      tokenPrefix: "#{"
      tokenSuffix: "}#"
      logLevel: debug
      rootDirectory: ${{ parameters.WORKING_DIRECTORY }}

My azure.tfbackend file looks like this:

  resource_group_name  = "#{AZURE_RESOURCE_GROUP_NAME}#"
  storage_account_name = "#{AZURE_STORAGE_ACCOUNT_NAME}#"
  container_name       = "#{AZURE_STORAGE_ACCOUNT_CONTAINER_NAME}#"
  subscription_id      = "#{AZURE_SUBSCRIPTION_ID}#"
  key                  = "#{TERRAFORM_STATE_NAME}#"

4. Terraform Installation

Consistently install the pinned Terraform version. This is important to avoid breaking changes on new Terraform versions. I’ve seen situations where a new version broke everything, since then I am always pinning the version.

  - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@1
    displayName: Terraform Install
    inputs:
      terraformVersion: ${{ parameters.TERRAFORM_VERSION }}

5. Terraform Format

Ensure the Terraform code follows standard formatting guidelines. Only when you are dealing with pull requests.

  - bash: |
      terraform fmt \
        -check \
        -recursive
    displayName: Terraform Format
    condition: eq(variables['Build.Reason'], 'PullRequest')
    workingDirectory: ${{ parameters.WORKING_DIRECTORY }}

6. Terraform Setup with Azure Authentication

Make sure the Terraform can use OpenID Connect settings to connect to your Azure subscription.

  - task: AzureCLI@2
    displayName: Terraform Setup
    inputs:
      azureSubscription: ${{ parameters.SERVICE_CONNECTION }}
      addSpnToEnvironment: true
      scriptType: bash
      scriptLocation: inlineScript
      inlineScript: |
        echo "##vso[task.setvariable variable=ARM_USE_OIDC]true"
        echo "##vso[task.setvariable variable=ARM_OIDC_TOKEN]$idToken"
        echo "##vso[task.setvariable variable=ARM_CLIENT_ID]$servicePrincipalId"
        echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query id --output tsv)"
        echo "##vso[task.setvariable variable=ARM_TENANT_ID]$tenantId"

7. Terraform Initialization

Initialize Terraform to set up the backend state and download provider plugins.

  - bash: |
      terraform init \
        -backend-config="${{ parameters.TERRAFORM_BACKEND }}" \
        -input=false
    displayName: Terraform Init
    workingDirectory: ${{ parameters.WORKING_DIRECTORY }}

8. Terraform Validate

Validate your Terraform configuration without applying it. Only when you are dealing with pull requests.

  - bash: |
      terraform validate \
        -no-color
    displayName: Terraform Validate
    condition: eq(variables['Build.Reason'], 'PullRequest')
    workingDirectory: ${{ parameters.WORKING_DIRECTORY }}

9. Security Scanning with Checkov

Identify security issues in your Terraform code using Checkov. This is the best free tool out there as of writing and we run it on pull requests.

  - bash: |
      pip install checkov
      checkov --directory .
    displayName: Terraform Scan
    condition: eq(variables['Build.Reason'], 'PullRequest')
    workingDirectory: ${{ parameters.WORKING_DIRECTORY }}

10. Terraform Plan

Generate a Terraform execution plan to preview infrastructure changes.

  - bash: |
      terraform plan \
        -input=false \
        -lock=false \
        -no-color \
        -var-file="${{ parameters.TERRAFORM_VARIABLES }}" \
        --out="$(Build.ArtifactStagingDirectory)/${{ parameters.TERRAFORM_PLAN }}"
    displayName: Terraform Plan
    workingDirectory: ${{ parameters.WORKING_DIRECTORY }}

11. Comment Terraform Plan

Comment the Terraform plan in a pull request scenario to ease the reviewing process and to maintain a history of plans.

  - bash: |
      terraform show -no-color "$(Build.ArtifactStagingDirectory)/${{ parameters.TERRAFORM_PLAN }}" > "$(Build.ArtifactStagingDirectory)/plan_output.txt"

      # Read the Terraform plan output
      planOutput="$(cat "$(Build.ArtifactStagingDirectory)/plan_output.txt")"

      # Truncate if output exceeds Azure DevOps limits
      contentLength="$(echo -n "$planOutput" | wc -c)"
      if [ "$contentLength" -gt 149950 ]; then
        planOutput="$(echo -n "$planOutput" | cut -c1-149950)"
        planOutput="$planOutput\n... (truncated)"
      fi

      # Build the collapsible comment content
      commentContent="<details>\n<summary>Show Terraform Plan</summary>\n\n\`\`\`terraform\n$planOutput\n\`\`\`\n</details>"

      # Escape double quotes for JSON
      commentEscaped="$(echo "$commentContent" | sed 's/\"/\\"/g')"

      # Build JSON body for the comment
      json="$(printf '{"comments":[{"parentCommentId":0,"content":"%s","commentType":1}],"status":1}' "$commentEscaped")"

      # Construct the Azure DevOps API URL for the current PR
      url="$(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.Name)/pullRequests/$(System.PullRequest.PullRequestId)/threads?api-version=5.1"

      # Post the comment
      curl --request POST "$url" \
           --header "Content-Type: application/json" \
           --header "Accept: application/json" \
           --header "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
           --data "$json" \
           --verbose
    displayName: Comment Terraform Plan
    condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))
    workingDirectory: ${{ parameters.WORKING_DIRECTORY }}
    env:
      SYSTEM_ACCESSTOKEN: $(System.AccessToken)

In order to get this working, you need to make sure Project Collection Build Service has Contribute to pull requests option enabled. You can find this setting under Project Settings > Repositories > Security.

This results in the following:

Terraform Plan Comment on Pull Request (censored)

12. Publish Terraform Plan Artifact

Upload the Terraform plan artifact for deployments in non-pull request scenarios.

  - task: PublishPipelineArtifact@1
    displayName: Upload Terraform Plan
    condition: ne(variables['Build.Reason'], 'PullRequest')
    inputs:
      targetPath: $(Build.ArtifactStagingDirectory)/${{ parameters.TERRAFORM_PLAN }}
      artifactName: ${{ parameters.ARTIFACTS_DIRECTORY }}

This pipeline template ensures robust validation and secure setup of your infrastructure.

How to use the pipeline template

In this example you can see how you can use the template.

  jobs:
  - job: CI
    displayName: Continuous Integration
    steps:
      - template: ./templates/build.yaml
        parameters:
          ENVIRONMENT: $(ENVIRONMENT)
          SERVICE_CONNECTION: $(SERVICE_CONNECTION)
          TERRAFORM_BACKEND: configuration/azure.tfbackend
          TERRAFORM_VERSION: 1.10.5
          TERRAFORM_VARIABLES: configuration/test.tfvars
          TOKENIZE_SOURCES: configuration/azurerm.tfbackend
          WORKING_DIRECTORY: ./infra/terraform

Questions? Need help?

Do you have any questions or do you need help in general?

Feel free to reach out.

Troubleshooting

It’s possible that the comment on the pull request does not work, in that case you will get the following exception:

{“$id”:”1",”innerException”:null,”message”:”TF401027: You need the Git ‘PullRequestContribute’ permission to perform this action. Details: identity ‘Build\\20ce9c9f-ef60–4069–8d17-df991a5b559e’, scope ‘repository’.”,”typeName”:”Microsoft.TeamFoundation.Git.Server.GitNeedsPermissionException, Microsoft.TeamFoundation.Git.Server”,”typeKey”:”GitNeedsPermissionException”,”errorCode”:0,”eventId”:3000}

This means the Project Collection Build Service does not have Contribute to pull requests option enabled, see step 11. If you can’t find Project Collection Build Service you can search for the GUID in the exception message, in this case: 20ce9c9f-ef60–4069–8d17-df991a5b559e

Project Collection Build Service
Volgende
Volgende

How to set up the .NET Aspire Dashboard on Container App Environments