The Power of GitOps: Azure CI/CD Pipeline For Terraform
Oct 05, 2023In this article, we will see the benefits of GitOps with Terraform and I’ll share with you an Azure DevOps pipeline to deploy your Terraform code to Azure.
If you don’t have an Azure DevOps account yet, you can start for free here: https://azure.microsoft.com/en-ca/products/devops.
Let’s get started!
What is GitOps?
GitOps is an operational framework to do Infrastructure as Code (IaC) properly.
GitOps = IaC + Git + CI/CD
Without GitOps, engineers use IaC and apply their changes directly to the infrastructure.
Everybody who works on the code needs access to the infrastructure.
This comes with many problems:
- There is no automated tests (high risk of errors).
- Collaboration is hard.
- Not very secure environment.
With GitOps:
- Every engineer can push their changes to Git.
- Before accepting the changes, a Pull Request allows code reviews and knowledge sharing.
- Once the PR is merged, a CI/CD pipeline runs automated tests and deploys the infrastructure.
- Only the pipeline agent needs write access to the infrastructure.
This results in:
- A better collaboration.
- Git makes it easy to rollback and track history.
- Automated tests increase confidence.
- The environment is more secure.
- There are less permissions to manage.
- Deployments are faster.
Pipeline
The pipeline runs when a pull request is completed and merged to the main branch.
Once started, this pipeline runs a terraform validate command to make sure the syntaxe is OK. It runs a terraform plan command, publishes the plan and wait for an approver to confirm that everything is correct before moving forward.
Once an approver approves the plan, it runs a terraform apply command to deploy the resources. Once the deployment is over, it adds a readonly resource lock to the resource group to avoid any manual changes in the future.
Let’s see how to implement such a pipeline together.
Install the Terraform task
In this pipeline, we are using the Terraform task from Jason Johnson. I like to use this task because it allows us to publish the Terraform plan to the UI.
You have to install it to your Azure DevOps organization to be able to run the pipeline that we will create.
Create the Service Connection
To allow Azure DevOps to communicate with Azure, you have to create a service connection. Go to Project settings > Service connections and click on the New service connection button.
From there, choose the Azure Resource Manager connection type and the Service Principal (automatic) authentication method.
Select the subscription where you want to deploy your Terraform code, enter AzureSubscription in the name field, check the box to grant access permission to all pipelines and click on save.
Change the service principal role
In our pipeline, we will create and delete resource locks. To do this, the service principal should have the User Access Administrator role or the Owner role but by default Azure DevOps only assigns the Contributor role.
To change the role, first retrieve the service principal’s display name: from the service connection panel, select the new created service connection then click on Manage Service Principal.
This will open the Azure portal and display the service principal’s details. Copy the display name and navigate to your Azure subscription from the Azure portal.
From your subscription panel, click on Access Control (IAM) and the Add button > add role assignment.
Under Priviliged administrator roles, choose the User Access Administrator role.
For the member, select the service principal with the previously copied display name.
In the condition tab, select Not constrained.
Review and assign the role.
Create the environment
Back to Azure DevOps. Navigate to Pipelines > Environments and create a new environment with no resource named Azure.
Once the environment is created, go to the Approvals and checks tab and configure new approvals. For simplicity, choose your name in the list but keep in mind that it is better to create a new user group for that. Any member of the group would then be able to approve the deployment.
The trigger
All the code will be added to an azure-pipelines.yml file.
This pipeline will run for every commit on the main branch in the directory 2023-10-terraform-pipeline.
trigger:
branches:
include:
- main
paths:
include:
- 2023-10-terraform-pipeline
The variables
Here, we have a couple of variables that are self explanatory.
variables:
artifactName: terraform
serviceConnection: AzureSubscription
buildAgent: ubuntu-latest
terraformDir: $(System.DefaultWorkingDirectory)/2023-10-terraform-pipeline/terraform
target: $(build.artifactstagingdirectory)
publishedArtifactsDirectory: "$(Pipeline.Workspace)/$(artifactName)"
planName: tfplan
resourceGroupToLock: rg-01
resourceLockName: terraform-lock
The Plan stage
In this stage, we want to run automated tests and generate the artifacts we’ll need in the Deploy stage.
When a pull request is created, it is the only stage that runs.
In our pipeline, I just added a terraform validate and a terraform plan.
If you want to run more advanced tests, you can do it here.
I also included a az cli command to delete and recreate a readonly lock before and after the terraform plan.
The readonly lock prevents people from modifying the infrastructure manually and compromise the Terraform state.
stages:
- stage: Plan
jobs:
- job: Plan
steps:
- task: TerraformCLI@1
displayName: terraform init
inputs:
command: "init"
backendType: "azurerm"
workingDirectory: "$(terraformDir)"
backendServiceArm: "$(serviceConnection)"
- task: TerraformCLI@1
displayName: terraform validate
inputs:
command: "validate"
backendType: "azurerm"
workingDirectory: "$(terraformDir)"
environmentServiceName: "$(serviceConnection)"
- task: AzureCLI@2
displayName: Delete lock
inputs:
azureSubscription: $(serviceConnection)
scriptType: "pscore"
scriptLocation: "inlineScript"
inlineScript: |
az lock delete --name $(resourceLockName) --resource-group $(resourceGroupToLock)
- task: TerraformCLI@1
displayName: terraform plan
inputs:
command: "plan"
backendType: "azurerm"
workingDirectory: "$(terraformDir)"
commandOptions: "-input=false -out=$(planName)"
environmentServiceName: "$(serviceConnection)"
publishPlanResults: "Terraform plan"
- task: AzureCLI@2
displayName: Create lock
inputs:
azureSubscription: $(serviceConnection)
scriptType: "pscore"
scriptLocation: "inlineScript"
inlineScript: |
if($(az group exists --name $(resourceGroupToLock)) -eq $true)
{
az lock create --name $(resourceLockName) --resource-group $(resourceGroupToLock) --lock-type ReadOnly --notes "This resource is managed by Terraform."
}
- task: CopyFiles@2
displayName: Copy files
inputs:
SourceFolder: "$(terraformDir)"
Contents: |
.terraform.lock.hcl
**/*.tf
**/*.tfvars
**/*tfplan*
TargetFolder: "$(target)"
- publish: "$(target)"
artifact: "$(artifactName)"
The Deploy stage
This stage is not executed during pull requests.
In this stage, we apply the terraform plan generated by the previous stage. By retrieving the plan from the artifacts, we ensure that only what has been approved from the previous stage will be deployed.
Notice that we use a deployment job. This is important because it allows use the specify the environment previously created.
There is also an az cli command to delete and recreate the readonly lock before and after the terraform apply.
- stage: Deploy
displayName: Deploy
condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
jobs:
- deployment: Deploy
environment: Azure
pool:
vmImage: $(buildAgent)
strategy:
runOnce:
deploy:
steps:
- download: "current"
artifact: $(artifactName)
- task: AzureCLI@2
displayName: Delete lock
inputs:
azureSubscription: $(serviceConnection)
scriptType: "pscore"
scriptLocation: "inlineScript"
inlineScript: |
az lock delete --name $(resourceLockName) --resource-group $(resourceGroupToLock)
- task: TerraformCLI@1
displayName: terraform init
inputs:
command: "init"
backendType: "azurerm"
workingDirectory: "$(publishedArtifactsDirectory)"
backendServiceArm: "$(serviceConnection)"
- task: TerraformCLI@1
displayName: terraform apply
inputs:
command: "apply"
commandOptions: '-input=false "$(planName)"'
backendType: "azurerm"
workingDirectory: "$(publishedArtifactsDirectory)"
environmentServiceName: "$(serviceConnection)"
- task: AzureCLI@2
displayName: Create lock
inputs:
azureSubscription: $(serviceConnection)
scriptType: "pscore"
scriptLocation: "inlineScript"
inlineScript: |
az lock create --name $(resourceLockName) --resource-group $(resourceGroupToLock) --lock-type ReadOnly --notes "This resource is managed by Terraform."
Run the pipeline
Before running the pipeline for the first time, you still have to manually create the storage account for the Terraform state.
I included a PowerShell script storage-account.ps1 to create the storage account.
You have to change the storage account’s name as it has to be unique. Do not forget to change it in the main.tf file too.
$resourceGroupName = 'rg-tfstate-cac'
$storageAccountName = 'satfstatecac20230917'
$location = 'canadacentral'
az group create --location $location --name $resourceGroupName
az storage account create --name $storageAccountName --resource-group $resourceGroupName --location $location --sku Standard_LRS
az storage container create --name state --account-name $storageAccountName
az storage account blob-service-properties update --account-name $storageAccountName --enable-change-feed true --enable-versioning true
main.tf
Now that the storage is created, you can commit your code and import the pipeline to Azure DevOps.
To do so, go back to Azure DevOps > Pipelines and click on the Create Pipeline button.
Follow the instructions to load your pipeline from your source version control. It could be from Azure repos, GitHub, Bitbucket…
In the configure tab, you have to choose Existing Azure Pipelines YAML file to select your YAML file from your repository.
Once your repository is configured, run the pipeline.
After the Plan stage, the pipeline will stop and wait for your approval.
You will be able to validate the plan in the new Terraform plan tab.
If you are satisfied with the plan, you can approve the pipeline and your infrastructure will be deployed to Azure.
Conclusion
In summary, we saw that GitOps allows:
- A better collaboration.
- Git makes it easy to rollback and track history.
- Automated tests increase confidence.
- The environment is more secure with less permissions to manage.
- Deployments are faster.
Plus, we saw how to create an Azure DevOps pipeline to deploy your Terraform code with GitOps.
The code is available on GitHub here: terraform pipeline.
If you are looking for a solution to destroy the created infrastructure with Azure DevOps, check out the next article: Automated Destruction: Azure DevOps Pipeline for Terraform Cleanup.