├── README.md ├── azure-pipeline.png ├── azure-pipeline.yml ├── azure-pipelines-tf-plan-apply-template.yml └── tf ├── main.tf ├── outputs.tf ├── tfstate.tf └── variables.tf /README.md: -------------------------------------------------------------------------------- 1 | | phase | Status | 2 | |--------|--------| 3 | | prepare |[![Build Status](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_apis/build/status/azure-devops-terraform?branchName=master&stageName=prepare)](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_build/latest?definitionId=113&branchName=master)| 4 | | plan dev |[![Build Status](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_apis/build/status/azure-devops-terraform?branchName=master&stageName=plan%20dev)](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_build/latest?definitionId=113&branchName=master)| 5 | | apply dev |[![Build Status](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_apis/build/status/azure-devops-terraform?branchName=master&stageName=apply%20dev)](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_build/latest?definitionId=113&branchName=master)| 6 | | plan prod |[![Build Status](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_apis/build/status/azure-devops-terraform?branchName=master&stageName=plan%20prod)](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_build/latest?definitionId=113&branchName=master)| 7 | | apply prod |[![Build Status](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_apis/build/status/azure-devops-terraform?branchName=master&stageName=apply%20prod)](https://dev.azure.com/mabenoit-ms/MyOwnBacklog/_build/latest?definitionId=113&branchName=master)| 8 | 9 | Terraform deployment with Azure DevOps, leveraging Azure pipelines in [YAML](http://aka.ms/yaml) with [Environment](https://docs.microsoft.com/azure/devops/pipelines/yaml-schema#environment) and [Checks](https://docs.microsoft.com/azure/devops/pipelines/process/checks). 10 | 11 | ![Azure pipeline](/azure-pipeline.png) 12 | 13 | # Setup 14 | 15 | ## Setup Azure Storage for TF state 16 | 17 | ``` 18 | #!/bin/bash 19 | 20 | environment=dev 21 | TFSTATE_RESOURCE_GROUP_NAME=tfstate-$environment 22 | TFSTATE_STORAGE_ACCOUNT_NAME=tfstate$RANDOM$environment 23 | TFSTATE_BLOB_CONTAINER_NAME=tfstate-$environment 24 | 25 | az group create -n $TFSTATE_RESOURCE_GROUP_NAME -l eastus 26 | az storage account create -g $TFSTATE_RESOURCE_GROUP_NAME -n $TFSTATE_STORAGE_ACCOUNT_NAME --sku Standard_LRS --encryption-services blob 27 | TFSTATE_STORAGE_ACCOUNT_KEY=$(az storage account keys list -g $TFSTATE_RESOURCE_GROUP_NAME --account-name $TFSTATE_STORAGE_ACCOUNT_NAME --query [0].value -o tsv) 28 | az storage container create -n $TFSTATE_BLOB_CONTAINER_NAME --account-name $TFSTATE_STORAGE_ACCOUNT_NAME --account-key $TFSTATE_STORAGE_ACCOUNT_KEY 29 | 30 | az group lock create --lock-type CanNotDelete -n CanNotDelete -g $TFSTATE_RESOURCE_GROUP_NAME 31 | ``` 32 | 33 | > Note: You could repeat this setup above per `environment`: QA, PROD, etc. That's a best practice to leverage different resources per environment, having more granular RBAC controls, etc. 34 | 35 | ## Setup Terraform access to Azure 36 | 37 | When Terraform will deploy your Azure resources,it will need the appropriate rights to talk to Azure and perform such actions, [this tutorial](https://docs.microsoft.com/azure/virtual-machines/linux/terraform-install-configure) provides the details of this configuration you need to do. Below are the commands extracted from there to be able to reuse the different values necessary for further setups. 38 | 39 | ``` 40 | TENANT_ID=$(az account show --query tenantId -o tsv) 41 | SUBSCRIPTION_ID=$(az account show --query id -o tsv) 42 | 43 | environment=dev 44 | spName=tf-sp-$environment 45 | TF_SP_SECRET=$(az ad sp create-for-rbac -n $spName --role Contributor --query password -o tsv) 46 | TF_SP_ID=$(az ad sp show --id http://$spName --query appId -o tsv) 47 | ``` 48 | 49 | > Note: You could repeat this setup above per `environment`: QA, PROD, etc. That's a best practice to leverage different resources per environment, having more granular RBAC controls, etc. 50 | 51 | ## Setup Azure DevOps 52 | 53 | Prerequisites: 54 | - To be able to leverage the Multi-stage pipelines Preview feature, [you need to turn it on](https://docs.microsoft.com/azure/devops/pipelines/process/stages). 55 | - To be able to install a specific version of Terraform on the agent, [install this Marketplace task](https://marketplace.visualstudio.com/items?itemName=ms-devlabs.custom-terraform-tasks) 56 | 57 | To setup Azure pipelines in Azure DevOps we will use the Azure DevOps CLI instead of the UI. For the setup and to login accordingly to your Azure DevOps organization and project, you will need to follow the instructions [here](https://docs.microsoft.com/azure/devops/cli/get-started?view=azure-devops). 58 | 59 | Now you will be able to run the bash commands below: 60 | ``` 61 | BUILD_NAME= 62 | GITHUB_URL=https://github.com/mathieu-benoit/azure-devops-terraform 63 | 64 | #If your source code is in GitHub, you may want to create by CLI your GitHub service endpoint (otherwise via the UI), you will be asked for your GitHub access token. 65 | SERVICE_ENDPOINT_NAME=azure-devops-terraform 66 | az devops service-endpoint github create \ 67 | --name $SERVICE_ENDPOINT_NAME \ 68 | --github-url $GITHUB_URL 69 | 70 | az pipelines create \ 71 | --name $BUILD_NAME \ 72 | --repository $GITHUB_URL \ 73 | --branch master \ 74 | --yml-path azure-pipeline.yml \ 75 | --service-connection $SERVICE_ENDPOINT_NAME 76 | --skip-first-run 77 | 78 | #Once the pipeline is created we need to configure its associated variables, by creating 3 different Variables Groups: 79 | environment=dev 80 | az pipelines variable-group create \ 81 | --name tf-sp-group-$environment \ 82 | --authorize true \ 83 | --variables clientId=$TF_SP_ID clientSecret=$TF_SP_SECRET tenantId=$TENANT_ID subscriptionId=$SUBSCRIPTION_ID 84 | az pipelines variable-group create \ 85 | --name tf-state-group-$environment \ 86 | --authorize true \ 87 | --variables tfStateStorageAccountAccessKey=$TFSTATE_STORAGE_ACCOUNT_KEY tfStateStorageAccountName=$TFSTATE_STORAGE_ACCOUNT_NAME tfStateStorageContainerName=$TFSTATE_BLOB_CONTAINER_NAME 88 | az pipelines variable-group create \ 89 | --name tf-deployment-group-$environment \ 90 | --authorize true \ 91 | --variables location= resourceGroupName= 92 | 93 | #Let's run our first build! 94 | az pipelines run \ 95 | --name $BUILD_NAME \ 96 | --open 97 | 98 | #You may want to open this pipeline definition via the UI to track it 99 | az pipelines show \ 100 | --name $BUILD_NAME \ 101 | --open 102 | ``` 103 | 104 | > Note: You could repeat this Variable Groups setup above per `environment`: QA, PROD, etc. 105 | 106 | Optionaly, you could pause this pipeline by adding a manual approval step on the Environment by setting up a [Check Approval](https://docs.microsoft.com/azure/devops/pipelines/process/checks#approvals). This manual approval is right after `terraform plan` and right before `terraform apply`, a good way to make sure everything will be deployed as expected. 107 | 108 | ## Optional - Protect your Terraform State files with Private Endpoints for Azure Storage 109 | 110 | Like illustrated in my blog article [Protect your Terraform State files with Private Endpoints for Azure Storage](https://alwaysupalwayson.blogspot.com/2020/03/protect-your-terraform-state-files-with.html) by running the commands below you will be able to leverage Azure Private Endpoint: 111 | ``` 112 | vnetName 113 | subnetName= 114 | privateEndpointName= 115 | storageAccountId=$(az storage account show -g $TFSTATE_RESOURCE_GROUP_NAME -n $TFSTATE_STORAGE_ACCOUNT_NAME --query id -o tsv) 116 | az network vnet subnet update -n $subnetName -g $TFSTATE_RESOURCE_GROUP_NAME --vnet-name $vnetName --disable-private-endpoint-network-policies true 117 | az network private-endpoint create \ 118 | -n $privateEndpointName \ 119 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 120 | --vnet-name $vnetName \ 121 | --subnet $subnetName \ 122 | --private-connection-resource-id $storageAccountId \ 123 | --group-id blob \ 124 | --connection-name $privateEndpointName 125 | az storage account update \ 126 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 127 | -n $storageName \ 128 | --default-action Deny 129 | zoneName="privatelink.blob.core.windows.net" 130 | az network private-dns zone create \ 131 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 132 | -n $zoneName 133 | az network private-dns link vnet create \ 134 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 135 | --zone-name $zoneName \ 136 | -n $privateDnsName \ 137 | --virtual-network $vnetName \ 138 | --registration-enabled false 139 | networkInterfaceId=$(az network private-endpoint show \ 140 | -n $privateEndpointName \ 141 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 142 | --query 'networkInterfaces[0].id' \ 143 | -o tsv) 144 | privateIpAddress=$(az resource show \ 145 | --ids $networkInterfaceId \ 146 | --api-version 2019-04-01 \ 147 | --query properties.ipConfigurations[0].properties.privateIPAddress \ 148 | -o tsv) 149 | az network private-dns record-set a create \ 150 | -n $TFSTATE_STORAGE_ACCOUNT_NAME 151 | --zone-name $zoneName 152 | -g $TFSTATE_RESOURCE_GROUP_NAME 153 | az network private-dns record-set a add-record \ 154 | --record-set-name $TFSTATE_STORAGE_ACCOUNT_NAME \ 155 | --zone-name $zoneName \ 156 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 157 | -a $privateIpAddress 158 | ``` 159 | 160 | Furthermore, you will have to have your own Azure Pipeline agent, in my case I'm hosting it on AKS, here are the setup you will need to have to link your AKS's VNET to your TFState's VNET: 161 | ``` 162 | aksResourceGroupName= 163 | aksVnetName= 164 | vNet1Id=$(az network vnet show \ 165 | -g $TFSTATE_RESOURCE_GROUP_NAME \ 166 | -n $vnetName \ 167 | --query id --out tsv) 168 | vNet2Id=$(az network vnet show \ 169 | -g $aksResourceGroupName \ 170 | -n $aksVnetName \ 171 | --query id --out tsv) 172 | az network vnet peering create \ 173 | -n tfstate-aks \ 174 | -g $rg \ 175 | --vnet-name $vnetName \ 176 | --remote-vnet $vNet2Id \ 177 | --allow-vnet-access 178 | az network vnet peering create \ 179 | -n aks-tfstate \ 180 | -g $aksResourceGroupName \ 181 | --vnet-name $aksVnetName \ 182 | --remote-vnet $vNet1Id \ 183 | --allow-vnet-access 184 | az network private-dns link vnet create \ 185 | -g $rg \ 186 | --zone-name $zoneName \ 187 | -n $privateDnsName \ 188 | --virtual-network $aksVnetName \ 189 | --registration-enabled false 190 | ``` 191 | 192 | # Further considerations 193 | 194 | - Use Azure Key Vault to store secrets to be used by Azure pipelines, you could easily [leverage Azure KeyVault from Variable Groups](https://docs.microsoft.com/azure/devops/pipelines/library/variable-groups?view=azure-devops&tabs=yaml#link-secrets-from-an-azure-key-vault) 195 | - You may want to add more Azure services to deploy in the [tf](/tf) folder ;) 196 | 197 | # Resources 198 | 199 | - [Terraform on Azure](https://docs.microsoft.com/azure/terraform) 200 | - [Running Terraform in Automation 201 | ](https://learn.hashicorp.com/terraform/development/running-terraform-in-automation) 202 | - [Cloud Native Azure Infrastructure Deployment Using Terraform](https://www.hashicorp.com/resources/cloud-native-azure-infrastructure-deployment-using-terraform) 203 | - [Microsoft Learn - Provision infrastructure in Azure Pipelines](https://docs.microsoft.com/learn/modules/provision-infrastructure-azure-pipelines/) 204 | - [Find out more about Terraform on Azure](https://cloudblogs.microsoft.com/opensource/tag/terraform) 205 | -------------------------------------------------------------------------------- /azure-pipeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieu-benoit/azure-devops-terraform/629ee2b361a8772082aec15ede182e7fe37bff9b/azure-pipeline.png -------------------------------------------------------------------------------- /azure-pipeline.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | batch: true 3 | branches: 4 | include: 5 | - '*' 6 | paths: 7 | exclude: 8 | - README.md 9 | - azure-pipeline.png 10 | 11 | pr: none 12 | 13 | variables: 14 | terraformVersion: 0.12.26 15 | tfFilesArtifactName: 'tf-files' 16 | skipComponentGovernanceDetection: true 17 | 18 | pool: 19 | #vmImage: 'ubuntu-latest' 20 | name: mabenoittesttf 21 | 22 | stages: 23 | - stage: 'prepare' 24 | displayName: 'prepare' 25 | jobs: 26 | - job: 'prepare' 27 | displayName: 'prepare' 28 | steps: 29 | - publish: '$(system.defaultWorkingDirectory)/tf' 30 | artifact: $(tfFilesArtifactName) 31 | - task: TerraformInstaller@0 32 | inputs: 33 | terraformVersion: $(terraformVersion) 34 | - script: | 35 | terraform init \ 36 | -backend=false 37 | terraform validate 38 | workingDirectory: $(system.defaultWorkingDirectory)/tf 39 | failOnStderr: true 40 | displayName: 'terraform validate' 41 | - template: azure-pipelines-tf-plan-apply-template.yml 42 | parameters: 43 | environment: dev 44 | - template: azure-pipelines-tf-plan-apply-template.yml 45 | parameters: 46 | environment: prod 47 | -------------------------------------------------------------------------------- /azure-pipelines-tf-plan-apply-template.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | environment: test 3 | 4 | stages: 5 | - stage: plan_${{ parameters.environment }} 6 | displayName: plan ${{ parameters.environment }} 7 | variables: 8 | - name: tfPlanArtifactName 9 | value: tf-plan-${{ parameters.environment }} 10 | - group: tf-sp-group-${{ parameters.environment }} 11 | - group: tf-state-group-${{ parameters.environment }} 12 | - group: tf-deployment-group-${{ parameters.environment }} 13 | jobs: 14 | - job: 'plan' 15 | displayName: 'plan' 16 | steps: 17 | - checkout: none 18 | - download: current 19 | artifact: $(tfFilesArtifactName) 20 | - task: TerraformInstaller@0 21 | inputs: 22 | terraformVersion: $(terraformVersion) 23 | - script: | 24 | terraform -version 25 | terraform init \ 26 | -backend-config="storage_account_name=$(tfStateStorageAccountName)" \ 27 | -backend-config="container_name=$(tfStateStorageContainerName)" 28 | chmod +x .terraform/plugins/linux_amd64/terraform-provider-* 29 | terraform plan \ 30 | -var resource_group_name=$(resourceGroupName) \ 31 | -var location=$(location) \ 32 | -out=tf-plan 33 | workingDirectory: $(pipeline.workspace)/$(tfFilesArtifactName) 34 | failOnStderr: true 35 | displayName: 'terraform plan' 36 | env: 37 | ARM_TENANT_ID: $(tenantId) 38 | ARM_SUBSCRIPTION_ID: $(subscriptionId) 39 | ARM_CLIENT_ID: $(clientId) 40 | ARM_CLIENT_SECRET: $(clientSecret) 41 | ARM_ACCESS_KEY: $(tfStateStorageAccountAccessKey) 42 | - publish: '$(pipeline.workspace)/$(tfFilesArtifactName)' 43 | artifact: $(tfPlanArtifactName) 44 | - stage: apply_${{ parameters.environment }} 45 | displayName: apply ${{ parameters.environment }} 46 | variables: 47 | - name: tfPlanArtifactName 48 | value: tf-plan-${{ parameters.environment }} 49 | - group: tf-sp-group-${{ parameters.environment }} 50 | - group: tf-state-group-${{ parameters.environment }} 51 | jobs: 52 | - deployment: 'apply' 53 | displayName: 'apply' 54 | environment: tf-${{ parameters.environment }} 55 | strategy: 56 | runOnce: 57 | deploy: 58 | steps: 59 | - checkout: none 60 | - download: current 61 | artifact: $(tfPlanArtifactName) 62 | - task: TerraformInstaller@0 63 | inputs: 64 | terraformVersion: $(terraformVersion) 65 | - script: | 66 | chmod +x .terraform/plugins/linux_amd64/terraform-provider-* 67 | terraform apply tf-plan 68 | terraform output resource_group_id 69 | workingDirectory: $(pipeline.workspace)/$(tfPlanArtifactName) 70 | failOnStderr: true 71 | displayName: 'terraform apply' 72 | env: 73 | ARM_TENANT_ID: $(tenantId) 74 | ARM_SUBSCRIPTION_ID: $(subscriptionId) 75 | ARM_CLIENT_ID: $(clientId) 76 | ARM_CLIENT_SECRET: $(clientSecret) 77 | ARM_ACCESS_KEY: $(tfStateStorageAccountAccessKey) 78 | -------------------------------------------------------------------------------- /tf/main.tf: -------------------------------------------------------------------------------- 1 | provider "azurerm" { 2 | version = "=2.9.0" 3 | features {} 4 | } 5 | 6 | resource "azurerm_resource_group" "rg" { 7 | name = var.resource_group_name 8 | location = var.location 9 | } 10 | -------------------------------------------------------------------------------- /tf/outputs.tf: -------------------------------------------------------------------------------- 1 | output "resource_group_id" { 2 | description = "Example of output to get the resource group id" 3 | value = azurerm_resource_group.rg.id 4 | } 5 | -------------------------------------------------------------------------------- /tf/tfstate.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "azurerm" { 3 | key = "terraform.tfstate" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tf/variables.tf: -------------------------------------------------------------------------------- 1 | variable "resource_group_name" { 2 | description = "Name of the resource group" 3 | } 4 | 5 | variable "location" { 6 | description = "Location of the resource group" 7 | } 8 | --------------------------------------------------------------------------------