OneManITArmy

Setting up Terraform in Azure DevOps

Table of Contents

    Introduction

    Terraform is an Infrastructure as a Code tool where you can deploy Cloud resources from several Cloud Service Providers such as a Microsoft Azure, Amazon AWS, Google Cloud. There are several guides on how Terraform works, but most of them are done by performing Terraform commands on your local devices (CMD or Powershell).

    In the real world people use Azure DevOps as an example to deploy their Terraform code to their infrastructure.

    This blog describes how you can enroll Terraform in your Azure environment via Azure DevOps.

    Prerequisites

    • Service Connection to your Azure Subscription via Azure DevOps – To deploy Azure Resources on Azure
    • (Self) Hosted Agent on Azure DevOps – To deploy the pipeline code
    • Terraform basic knowledge (init, plan, and apply) – To understand how Terraform works
    • IDE (e.g. Visual Studio Code) – to write your code to Azure DevOps Repos

    Setting up Terraform in Azure DevOps

    1. Go to the Azure DevOps Marketplace and download Terraform by Microsoft DevLabs

    Setting up the Terraform files

    2. Create these files & folders shown below (or you can download all the files here)

    3. Add the tfstate parameters in your dev.tfvars file as this is needed to refer your tfstate file in Terraform.

    As for my example, I have used

    • Resource Group Name: rg-onemanitarmy-tfstate
    • Storage Account Name: stonemanitarmy
    • Blob Container Name: tfstate
    • Tfstate file key name: stonemanitarmy.terraform.tfstate
      Remember these variables as you will need to mention these in step 10!
    backend = {
      backendResourceGroupName                 = "<your tfstate resource group name>"
      backendStorageAccountName                = "<your tfstate storage account name>"
      backendContainerName                     = "<your tfstate blob container name>"
      backendKey                               = "<your tfstate backendkey name>.terraform.tfstate"
    }

    4. Create a Resource Group at main.tf as HelloWorld exercise

    resource "azurerm_resource_group" "rg-helloworld" {
    name = "rg-helloworld"
    location = "West Europe"
    }

    5. Add the mandatory code in providers.tf to run Terraform

    terraform {
      required_providers {
        azurerm = {
          source  = "hashicorp/azurerm"
          version = ">= 3.80.0"
        }
      }
        backend "azurerm" {
          resource_group_name  = var.backendResourceGroupName
          storage_account_name = var.backendStorageAccountName
          container_name       = var.backendContainerName
          key                  = var.backendKey
      }
    }
    
    # Configure the Microsoft Azure Provider
    provider "azurerm" {
      skip_provider_registration = true 
      features {}
    }

    6. Add tfstate variables in variables.tf that is needed for running the pipeline.

    variable "backendResourceGroupName" {
      description = "Terraform Backend Resource Group"
      type        = string
    }
    
    variable "backendStorageAccountName" {
      description = "Terraform Backend Storage Account"
      type        = string
    }
    
    variable "backendContainerName" {
      description = "Terraform Backend Container Name"
      type        = string
    }
    
    variable "backendKey" {
      description = "Terraform Backend Key"
      type        = string
    }
    
    variable "location" {
      default = "West Europe"
    }

    Setting up Azure Pipeline files

    10. Paste this code below in the terraform-preparation.yaml file and replace the variable values <> with your own (including the ones in step 3 regarding resource group name and storage account name).

    Context about the code
    This code (via Powershell) creates a Storage Account and a Blob Storage Container to store the .tfstate file there.
    Reason to store .tfstate file there is to store the .tfstate file centrally and to mitigate Git Conflicts in Azure Repos.

    # Preparations file to create Storage Account in advance to save .tfstate file.
    # Code in a nutshell:
      # Select Service Connection
      # Register Resource Provider Microsoft.Storage to create Storage resources in Azure.
      # Create Resource Group
      # Create Storage Account
      # Create Blob Container and store Terraform .tfstate file.
    
    variables:
        ServiceConnectionName: "<fill in your service connection name>"                 # Specify service connection name in Azure DevOps project settings.
        SubscriptionName: "<fill in your subscription name>"                            # Specify Subscription name.
        ResourceGroupName: "<fill in your resource group name store to .tfstate file>"  # Specify Resource Group name.
        StorageAccountName: "<fill in your new storage account name>"                   # Specify Storage Account name.
        Location: "<fill in your location>"                                             # Specify Location.
    
    trigger:
      - none
    
    pool:
      vmImage: "ubuntu-latest"
    
    
    stages:
      - stage: TerraformState
        jobs:
        - job: Create_Storage_Account
          continueOnError: false
          steps:
            - task: AzurePowerShell@5
              displayName: 'Create Storage Account for Terraform State'
              inputs:
                azureSubscription: '$(ServiceConnectionName)' # Use Service Connection to deploy the code to Azure.
                ScriptType: 'InlineScript'
                Inline: |                                     # Powershell Inline commands
                  # Select Subscription to use
                  Select-AzSubscription -SubscriptionName '$(SubscriptionName)'
    
                  # Try to enable Resource Providers. 
                  Try {
                  Register-AzResourceProvider -ProviderNamespace Microsoft.Storage
                  }
    
                  # If already enabled, then send message that it is already enabled.
                  Catch {
                  echo "Resource Provider is already enabled."
                  }
    
                  # Create Resource Group.
                  New-AzResourceGroup -Name '$(ResourceGroupName)' -Location '$(Location)' -Force
    
                  # Create Storage Account
                  New-AzStorageAccount -ResourceGroupName '$(ResourceGroupName)' -Name '$(StorageAccountName)' -Location '$(Location)' -SkuName Standard_LRS -Kind StorageV2
                  
                  # Select Storage Account and create Blob Container.
                  $context = New-AzStorageContext -StorageAccountName '$(StorageAccountName)' -UseConnectedAccount
                  New-AzStorageContainer -Name 'tfstate' -Context $context
                  
                  # Select Storage Account and disabled Public Network Access.
                  Set-AzStorageAccount -ResourceGroupName '$(ResourceGroupName)' -Name '$(StorageAccountName)' -PublicNetworkAccess Disabled
                  
                  # Send message that the pipeline ran succesfully.
                  echo "Succesfully created Resource Provider, Resource Group, Storage Account, and Blob Container with .tfstate."
    
                azurePowerShellVersion: 'LatestVersion'

    11. Run the pipeline file and you will now have a resource group, storage account, and blob container in your Azure environment.

    12. Create a Pipeline Environment in Pipelines > Environments before running plan script with the name ‘gen-terraform-env’.
    This is needed to run the Terraform Plan and Apply script.

    13. Add .gitignore file in the repository:

    # Local .terraform directories
    **/.terraform/*
    
    # .tfstate files
    *.tfstate
    *.tfstate.*
    
    # Crash log files
    crash.log
    
    # Exclude all .tfvars files, which are likely to contain sentitive data, such as
    # password, private keys, and other secrets. These should not be part of version 
    # control as they are data points which are potentially sensitive and subject 
    # to change depending on the environment.
    #
    #*.tfvars
    
    # Ignore override files as they are usually used to override resources locally and so
    # are not checked in
    override.tf
    override.tf.json
    *_override.tf
    *_override.tf.json
    
    # Include override files you do wish to add to version control using negated pattern
    #
    # !example_override.tf
    
    # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
    # example: *tfplan*
    
    # Ignore CLI configuration files
    .terraformrc
    terraform.rc

    Terraform Plan – terraform-plan.yaml

    14. Paste the pipeline code in your terraform-plan.yaml file and change the variables that you mentioned created in the earlier steps.

    # Terraform PLAN Pipeline configuration
    
    parameters:
    - name: environment
      displayName: Environment OneManITArmy
      type: string
      values:
      - dev
    
    # Create if statement to decide which environment Terraform needs to deploy.
    variables:
      ${{ if eq(parameters.environment, 'dev') }}:
        serviceConnectionName: "<your service connection name>"                                      # Specify service connection name in Azure DevOps project settings.
        workingDirectory: '$(System.DefaultWorkingDirectory)'                                        # Specify working directory of your Terraform files.
        varFile: "$(System.DefaultWorkingDirectory)/env-tfvars/${{ parameters.environment }}.tfvars"
        backendResourceGroupName: "<your resource group name>"                                       # Specify RG-name that is created via Terraform where Storage account is held.
        backendStorageAccountName: "<your new storage account name>"                                 # Specify name of the Storage account is that created.
        backendContainerName: "<your tfstate blob container name>"                                   # Specify name of blob container in Storage account.
        backendKey: "<your tfstate backendkey name>.terraform.tfstate"                               # Specify filename mentioned in blob container above.
    
    trigger:
      - none
    
    pool:
      vmImage: "ubuntu-latest"
    
    stages:
    
      # Performs Terraform Init, Validate, and Plan.
      - stage: applicationinsights
        jobs:
        - job: validate
          continueOnError: false
          steps:
    
            # Powershell task to enable Storage Account public access so .tfstate file can be opened and modified.
            - task: AzurePowerShell@5
              displayName: 'Temporarily Allow Public Network Access to Terraform Backend Storage Account'
              inputs:
                azureSubscription: '$(serviceConnectionName)'
                ScriptType: 'InlineScript'
                Inline: |
                  # Enable Public Network Access
                  Set-AzStorageAccount -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -PublicNetworkAccess Enabled
                  Update-AzStorageAccountNetworkRuleSet -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -DefaultAction Allow
                  Start-Sleep -Seconds 60
                azurePowerShellVersion: 'LatestVersion'
    
            # Perform Terraform Init
            - task: TerraformTaskV2@2
              displayName: "init"
              inputs:
                provider: "azurerm"
                command: "init"
                workingDirectory: "$(workingDirectory)"
                backendServiceArm: "$(serviceConnectionName)"
                backendAzureRmResourceGroupName: "$(backendResourceGroupName)"
                backendAzureRmStorageAccountName: "$(backendStorageAccountName)"
                backendAzureRmContainerName: "$(backendContainerName)"
                backendAzureRmKey: "$(backendKey)"
    
            # Perform Terraform Validate    
            - task: TerraformTaskV2@2
              inputs:
                provider: "azurerm"
                command: "validate"
        
        # Perform Terraform Init
        - deployment: plan_terraform
          dependsOn: validate
          continueOnError: false
          environment: "gen-terraform-env"
          strategy:
            runOnce:
              deploy:
                steps:
                  - checkout: self
                  - task: TerraformTaskV2@2
                    displayName: "init"
                    inputs:
                      provider: "azurerm"
                      command: "init"
                      workingDirectory: "$(workingDirectory)"
                      backendServiceArm: "$(serviceConnectionName)"
                      backendAzureRmResourceGroupName: "$(backendResourceGroupName)"
                      backendAzureRmStorageAccountName: "$(backendStorageAccountName)"
                      backendAzureRmContainerName: "$(backendContainerName)"
                      backendAzureRmKey: "$(backendKey)"
    
                  # Perform Terraform Plan    
                  - task: TerraformTaskV2@2
                    displayName: "plan"
                    inputs:
                      provider: "azurerm"
                      command: "plan"
                      workingDirectory: "$(workingDirectory)"
                      commandOptions: '-var-file="$(varFile)" -var="backendResourceGroupName=$(backendResourceGroupName)" -var="backendStorageAccountName=$(backendStorageAccountName)" -var="backendContainerName=$(backendContainerName)" -var="backendKey=$(backendKey)"'
                      environmentServiceNameAzureRM: "$(serviceConnectionName)"
    
                  # Powershell job to disable Storage Account public access so .tfstate file is secured.     
                  - task: AzurePowerShell@5
                    displayName: 'Disable Public Network Access to Terraform Backend Storage Account'
                    inputs:
                      azureSubscription: '$(serviceConnectionName)'
                      ScriptType: 'InlineScript'
                      Inline: |
                        # Disable Public Network Access
                        Set-AzStorageAccount -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -PublicNetworkAccess Disabled
                      azurePowerShellVersion: 'LatestVersion'  
        
        # Error handling
        - job: catch_failed_plan
          dependsOn: plan_terraform
          condition: failed()
          continueOnError: false
          steps:
            - task: AzurePowerShell@5
              displayName: 'Disable Public Network Access to Terraform Backend Storage Account'
              inputs:
                azureSubscription: '$(serviceConnectionName)'
                ScriptType: 'InlineScript'
                Inline: |
                  # Disable Public Network Access
                  Set-AzStorageAccount -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -PublicNetworkAccess Disabled
                azurePowerShellVersion: 'LatestVersion'
    

    15. Run plan pipeline script terraform-plan.yaml

    Terraform Apply – terraform-apply.yaml

    16. Paste the pipeline code in your terraform-apply.yaml file and change the variables that you mentioned created in the earlier steps.

    # Terraform APPLY Pipeline configuration
    
    parameters:
    - name: environment
      displayName: Environment OneManITArmy
      type: string
      values:
      - dev
    
    # Create if statement to decide which environment Terraform needs to deploy.
    variables:
      ${{ if eq(parameters.environment, 'dev') }}:
        serviceConnectionName: "<your service connection name>"                                      # Specify service connection name in Azure DevOps project settings.
        workingDirectory: '$(System.DefaultWorkingDirectory)'                                        # Specify working directory of your Terraform files.
        varFile: "$(System.DefaultWorkingDirectory)/env-tfvars/${{ parameters.environment }}.tfvars"
        backendResourceGroupName: "<your resource group name>"                                       # Specify RG-name that is created via Terraform where Storage account is held.
        backendStorageAccountName: "<your new storage account name>"                                 # Specify name of the Storage account is that created.
        backendContainerName: "<your tfstate blob container name>"                                   # Specify name of blob container in Storage account.
        backendKey: "<your tfstate backendkey name>.terraform.tfstate"                               # Specify filename mentioned in blob container above.
    
    
    trigger:
      - none
    
    pool:
      vmImage: "ubuntu-latest"
    
    stages:
    
      # Performs Terraform Init, Validate, and Apply.
      - stage: applicationinsights
        jobs:
        - job: validate
          continueOnError: false
          steps:
    
            # Powershell task to enable Storage Account public access so .tfstate file can be opened and modified.
            - task: AzurePowerShell@5
              displayName: 'Temporarily Allow Public Network Access to Terraform Backend Storage Account'
              inputs:
                azureSubscription: '$(serviceConnectionName)'
                ScriptType: 'InlineScript'
                Inline: |
                  # Enable Public Network Access Terraform Backend Storage Account
                  Set-AzStorageAccount -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -PublicNetworkAccess Enabled
                  Start-Sleep -Seconds 60
                azurePowerShellVersion: 'LatestVersion'
    
            # Perform Terraform Init          
            - task: TerraformTaskV2@2
              displayName: "init"
              inputs:
                provider: "azurerm"
                command: "init"
                workingDirectory: "$(workingDirectory)"
                backendServiceArm: "$(serviceConnectionName)"
                backendAzureRmResourceGroupName: "$(backendResourceGroupName)"
                backendAzureRmStorageAccountName: "$(backendStorageAccountName)"
                backendAzureRmContainerName: "$(backendContainerName)"
                backendAzureRmKey: "$(backendKey)"
    
            # Perform Terraform Validate        
            - task: TerraformTaskV2@2
              inputs:
                provider: "azurerm"
                command: "validate"
    
        # Perform Terraform Init once more after validating the code.   
        - deployment: apply_terraform
          dependsOn: validate
          continueOnError: false
          environment: "gen-terraform-env"
          strategy:
            runOnce:
              deploy:
                steps:
                  - checkout: self
                  - task: TerraformTaskV2@2
                    displayName: "init"
                    inputs:
                      provider: "azurerm"
                      command: "init"
                      workingDirectory: "$(workingDirectory)"
                      backendServiceArm: "$(serviceConnectionName)"
                      backendAzureRmResourceGroupName: "$(backendResourceGroupName)"
                      backendAzureRmStorageAccountName: "$(backendStorageAccountName)"
                      backendAzureRmContainerName: "$(backendContainerName)"
                      backendAzureRmKey: "$(backendKey)"
    
                  # Perform Terraform Apply     
                  - task: TerraformTaskV2@2
                    displayName: "apply"
                    inputs:
                      provider: "azurerm"
                      command: "apply"
                      workingDirectory: "$(workingDirectory)"
                      commandOptions: '-var-file="$(varFile)" -var="backendResourceGroupName=$(backendResourceGroupName)" -var="backendStorageAccountName=$(backendStorageAccountName)" -var="backendContainerName=$(backendContainerName)" -var="backendKey=$(backendKey)"'
                      environmentServiceNameAzureRM: "$(serviceConnectionName)"
                  
                  # Powershell job to disable Storage Account public access so .tfstate file is secured. 
                  - task: AzurePowerShell@5
                    displayName: 'Disable Public Network Access to Terraform Backend Storage Account'
                    inputs:
                      azureSubscription: '$(serviceConnectionName)'
                      ScriptType: 'InlineScript'
                      Inline: |
                        # Disable Public Network Access
                        Set-AzStorageAccount -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -PublicNetworkAccess Disabled
                      azurePowerShellVersion: 'LatestVersion'
    
        # Error handling                              
        - job: catch_failed_apply
          dependsOn: apply_terraform
          condition: failed()
          continueOnError: false
          steps:
            - task: AzurePowerShell@5
              displayName: 'Disable Public Network Access to Terraform Backend Storage Account'
              inputs:
                azureSubscription: '$(serviceConnectionName)'
                ScriptType: 'InlineScript'
                Inline: |
                  # Disable Public Network Access Terraform Backend Storage Account.
                  Set-AzStorageAccount -ResourceGroupName "$(backendResourceGroupName)" -Name "$(backendStorageAccountName)" -PublicNetworkAccess Disabled
                azurePowerShellVersion: 'LatestVersion'
    

    17. Run plan pipeline script terraform-apply.yaml

    Congratulations! You can now enroll your Terraform code in Azure via Azure DevOps!

    The entire code can be downloaded in my repository.