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:
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