Efficient Azure Infrastructure Management with Terraform and GitLab CI/CD: A DevOps Approach

Efficient Azure Infrastructure Management with Terraform and GitLab CI/CD: A DevOps Approach

Introduction

In today's fast-paced DevOps world, managing infrastructure efficiently and securely is crucial.

This article delves into our sophisticated approach to deploying Azure infrastructure using Terraform, integrated within a GitLab CI/CD pipeline and a centralized terraform.tfstate on Azure. We aim to provide a high-level understanding, supplemented with code examples, to demonstrate how this process enables precise control and streamlined workflows for managing different environments such as development, quality assurance, and production.

Strategic Approach: Unification, Security, and Centralization

  1. Unification of Infrastructure Code by keeping the code for development, staging, and production environments in a single GitLab project to simplify management and maintenance.

  2. Security and Control in Deployment by integrating code control tasks in a CI/CD pipeline contributes to enhancing the security and quality of infrastructure deployment.

  3. Centralization of terraform.tfstate files in Azure Storage for better state management and facilitates collaboration among teams.

The strategy is to ensure that changes made in one environment do not inadvertently affect the others.

To begin with, you need to separate environments into specific folders. Each of these folders will contain its own Terraform code. This can lead to duplicate code, which you can overcome by using terraform modules.

Modifications to an environment must be made in a single, dedicated branch.

This is achieved by creating branches specific to each environment, with the CI pipeline executing Terraform commands (validate, plan, apply) only for the modified environment. This method requires a comprehensive understanding of the infrastructure and precise code management.

The pipeline will be triggered only when merging from a branch to the master.

Implementation Details: Setting Up the CI/CD Pipeline

First of all, you'll need a storage container to store your tfstate centrally. This is achieved via the provider.tf. In the example below, we're using variables declared via the CI/CD part of Gitlab.

  • TF_VAR_ARM_SUBSCRIPTION_ID

  • TF_VAR_ARM_CLIENT_ID

  • TF_VAR_ARM_CLIENT_SECRET

The values of these variables will be transmitted to terraform via the .gitlab-ci.yml file.

This snippet establishes the foundation for using Terraform with Azure. It specifies the required Terraform and Azure provider versions and configures the backend using Azure Storage. This setup is crucial for centralized state management, ensuring that the terraform.tfstate file is stored securely and is accessible for collaborative work. The use of variables (var.ARM_SUBSCRIPTION_ID, etc.) allows for dynamic configuration, enhancing security and flexibility in managing different Azure resources.

Example of a provider.tf

terraform {
  required_version = ">=1.2"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "3.74"
    }
  }
  backend "azurerm" {
    resource_group_name  = "existingstorageresourcegroup"
    storage_account_name = "existingstorageaccount"
    container_name       = "existingstoragecontainer"
    key                  = "projectname.tfstate"
  }
}
provider "azurerm" {
  subscription_id = var.ARM_SUBSCRIPTION_ID
  client_id       = var.ARM_CLIENT_ID
  client_secret   = var.ARM_CLIENT_SECRET
}

The .gitlab-ci.yml file defines the CI/CD pipeline in GitLab, crucial for automating the Terraform deployment process. It outlines various stages like terraform-validate, terraform-plan, and terraform-apply, each tailored to execute specific Terraform commands.

The use of conditional rules based on GitLab events and branch names ensures that changes in specific environments (like DEV) are isolated and managed effectively. This setup provides a structured and automated approach to infrastructure deployment, minimizing human errors and enhancing efficiency.

You'll see that we force only changes in DEV's environment to be taken into account. Simply duplicate this example with the desired environments, such as qas, prd, sandbox, etc.

Example of a .gitlab-ci.yml

variables:
  TF_VAR_ARM_SUBSCRIPTION_ID : ${ARM_SUBSCRIPTION_ID} #Will be used in TF codes
  TF_VAR_ARM_CLIENT_ID       : ${ARM_CLIENT_ID} #Will be used in TF codes
  TF_VAR_ARM_CLIENT_SECRET   : ${ARM_CLIENT_SECRET} #Will be used in TF codes
  TF_VAR_TF_STATE_NAME       : ${TF_STATE_NAME} #Will be used in TF codes
  DEV_FOLDER   : dev
  TF_ROOT      : ${CI_PROJECT_DIR}  # The relative path to the root directory of the Terraform project
  TF_STATE_NAME: default      # The name of the state file used by the GitLab Managed Terraform state backend
image:
  name: "registry.gitlab.com/gitlab-org/terraform-images/stable:latest"
cache:
  key: "${TF_ROOT}"
  paths:
    - ${TF_ROOT}/.terraform/
stages:
  - terraform-validate
  - terraform-plan
  - terraform-apply
DEV:terraform-validate:
  tags:
    - internal
  stage: terraform-validate
  script:
    - gitlab-terraform validate
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      changes:
        - "${DEV_FOLDER}/**/*"
    - if: '$CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_REF_NAME == "master" && $CI_OPEN_MERGE_REQUESTS == null'
      changes:
        - "${DEV_FOLDER}/**/*"
      when: never
  environment:
    name: ${TF_VAR_TF_STATE_NAME}
    action: prepare
  variables:
    TF_ROOT: ${CI_PROJECT_DIR}/${DEV_FOLDER}/
DEV:terraform-plan:
  tags:
    - internal
  stage: terraform-plan
  script:
    - gitlab-terraform plan
    - gitlab-terraform plan-json
  resource_group: ${TF_STATE_NAME}
  artifacts:
    public: false
    paths:
      - ${TF_ROOT}/plan.cache
    reports:
      terraform: ${TF_ROOT}/plan.json
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      changes:
        - "${DEV_FOLDER}/**/*"
    - if: '$CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_REF_NAME == "master" && $CI_OPEN_MERGE_REQUESTS == null'
      changes:
        - "${DEV_FOLDER}/**/*"
      when: never
  environment:
    name: ${TF_VAR_TF_STATE_NAME}
    action: prepare
  variables:
    TF_ROOT: ${CI_PROJECT_DIR}/${DEV_FOLDER}/
DEV:terraform-apply:
  tags:
    - internal
  stage: terraform-apply
  script:
    - gitlab-terraform apply
  resource_group: ${TF_STATE_NAME}
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" && $TF_AUTO_DEPLOY == "true"'
      changes:
        - "${DEV_FOLDER}/**/*"
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      changes:
        - "${DEV_FOLDER}/**/*"
      when: manual
  environment:
    name: ${TF_VAR_TF_STATE_NAME}
    action: prepare
  variables:
    TF_ROOT: ${CI_PROJECT_DIR}/${DEV_FOLDER}/

All you need now is some terraform code in main.tf to test the deployment of the infrastructure.

Execution

In GitLab create a new dev branch from the master and then commit some terraform code in it.

You can get a very simple azurerm_resource_group below and add it in the main.tf file in the dev folder of the dev branch

resource "azurerm_resource_group" "rg" {
  name     = "myTFResourceGroup"
  location = "westus2"
}

Start a new merge request and follow the pipeline execution

Quality and security improvement

To improve the quality and security of the infrastructure, we're going to add jobs to check the code before it is executed by terraform.

We are creating a new code-review stage in which we will add 3 jobs

  • gitleaks

  • checkov

  • terrascan

All these modifications must be added to the .gitlab-ci.yml file.

stages:
  - code-review
  - terraform-validate
  - terraform-plan
  - terraform-apply

The gitleaks Job definition

.gitleaks:
  stage: gitleaks
  allow_failure: false
  image:
    name: zricethezav/gitleaks:latest
    entrypoint: [""]
  # We add the "--no-git" opton to avoid scanning the entire commit history. 
  # The aim is to alert you to the presence of sensitive information.
  script: gitleaks detect --no-git -v -c .gitleaks.toml ${CI_PROJECT_DIR}/${FOLDER}/ --exit-code 0

The checkov Job definition

.checkov:
  stage: checkov
  allow_failure: true  # True for AutoDevOps compatibility
  image:
    name: bridgecrew/checkov:latest
    entrypoint:
      - '/usr/bin/env'
      - 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
  tags: ["internal"]
  rules:
    - if: $SAST_DISABLED
      when: never
    - if: $CI_COMMIT_BRANCH
      exists:
        - '**/*.yml'
        - '**/*.yaml'
        - '**/*.json'
        - '**/*.template'
        - '**/*.tf'      
        - '**/serverless.yml'
        - '**/serverless.yaml'
  script:
    - script -q -c 'checkov -d . ; echo $? > CKVEXIT'
    - exit $(cat CKVEXIT)

The terrascan Job definition

variables:
  TERRASCAN_TAG: "1.18.1"
.terrascan:
  image:
    name: tenable/terrascan:${TERRASCAN_TAG}
    entrypoint: ["/bin/sh", "-c"]
  tags: ["internal"]
  script:
    - /go/bin/terrascan scan -v -i terraform -d ${CI_PROJECT_DIR}/${FOLDER}/ -o json
# You can add the option --skip-rules to bybass false positive errors
# --skip-rules="AC_AZURE_0356,AC_AZURE_0389,AC_AZURE_0185,AC_AZURE_0169"

Add the jobs for the dev environment, before the terraform jobs.

DEV:gitleaks:
  stage: code-review
  extends: .gitleaks
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      changes:
        - "${DEV_FOLDER}/**/*"
  variables:
    FOLDER: ${DEV_FOLDER}

DEV:checkov:
  stage: code-review
  extends: .checkov
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      changes:
        - "${DEV_FOLDER}/**/*"
  variables:
    FOLDER: ${DEV_FOLDER}

DEV:terrascan:
  stage: code-review
  extends: .terrascan
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      changes:
        - "${DEV_FOLDER}/**/*"
    - if: '$CI_PIPELINE_SOURCE != "merge_request_event" && $CI_COMMIT_REF_NAME == "master" && $CI_OPEN_MERGE_REQUESTS == null'
      when: never
      changes:
        - "${DEV_FOLDER}/**/*"
  variables:
    FOLDER: ${DEV_FOLDER}

There's plenty of room for improvement in these examples, at every level, but the aim is to demonstrate simple use, with separation by environment type in sub-folders of the same project.

Final Thoughts: Mastering Azure Infrastructure with Terraform and GitLab CI/CD

In summary, integrating Terraform with GitLab CI/CD for Azure infrastructure management offers a sophisticated and efficient approach, blending centralized state management, streamlined workflow, and enhanced security.

By unifying infrastructure code across various environments in a single GitLab project, and incorporating robust code control within the CI/CD pipeline, this method not only boosts collaboration and control but also ensures compliance with evolving industry standards.

Embracing this strategy represents a significant leap in modern infrastructure management, striking a balance between agility, security, and maintainability.

Note 1 : AI makes mistakes

The cover image for this article was generated by AI, which inadvertently misspelled 'Azure' as 'Azrure'. We appreciate your understanding and hope this minor error doesn't detract from the key insights of our article on managing Azure infrastructure with Terraform and GitLab CI/CD."

Note 2 : Terraform's Licensing Changes and Introduction to OpenTofu:

It's important to be aware of the recent changes in Terraform's licensing model. These changes may affect how Terraform can be used in your projects, especially in larger-scale or commercial environments. We advise staying updated with Terraform's licensing terms to ensure compliance.

As a response to these licensing changes, the DevOps community has seen the emergence of solutions like OpenTofu. OpenTofu is designed as a workaround to these licensing issues, providing a similar functionality to Terraform but under a different licensing model. It's worth exploring OpenTofu as an alternative, particularly for those who are looking for more flexible licensing terms while maintaining the efficiency and reliability in cloud infrastructure management.