GitHub Actions - Azure Terraform CI/CD
Tags: azure, devops, terraform, githubactions
Published: 2023-07-05 22:45:07

Terraform is my preferred tool for Azure resource creation. I still run some Terraform commands from my local shell, but I've made a real effort to execute the bulk of my changes with [GitHub Actions](https://github.com/features/actions). 

This post will detail my **basic** Azure Terraform pipeline. If you're looking for more advanced content, this is not the article for you. 

First, I will highlight some workflow prerequisites. Once those have been covered, I will walkthrough the pipeline, step-by-step.  

Take a seat and let's get started.

![Homer - Take a Seat](https://media.giphy.com/media/RwLDkna2fN3fG/giphy.gif)

---

{%- # TOC start -%}

- **[Prerequisites](#prerequisites)**
- **[The Workflow](#the-workflow)**
   * [Getting Started](#getting-started)
   * [Azure Authentication](#azure-authentication)
   * [Access to Private Repositories](#authentication)
   * [Terraform Prep](#terraform-prep)
   * [Checkov](#checkov)
   * [More Terraform](#more-terraform)
   * [Checking the Plan](#checking-the-plan)
   * [Pull Request Comment](#pull-request-comment)
   * [Terraform Apply](#terraform-apply)
- **[Wrapping up](#wrapping-up)**

{%- # TOC end -%}

---

## Prerequisites

The workflow does not exist in a vacuum - there are a few things that need to be configured outside of the workflow itself.   

**Branch protection:** [Enforced Branch protection](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule#creating-a-branch-protection-rule) requires the use of pull requests and merges. The code will always be merged from another branch to _main_, the pull request will detail the specific changes to be made, and the _main_ branch will more reliably reflect the current state of the environment.

**GitHub Token:** [A fine-grained token](https://github.blog/2022-10-18-introducing-fine-grained-personal-access-tokens-for-github/) grants access to private repositories. The token is stored as a secret for later use.

**Azure Credentials:** An [app registration](https://learn.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals?tabs=browser) is used to authenticate the runner to Azure. The app registration's associated client secret - along with the subscription, tenant, and management endpoint are stored as a GitHub secret (in JSON syntax). 

```json
{									
    "clientId": "12345678-1234-5678-9012-345678901234",
    "clientSecret": "0000000000000000000000000000000000000000",
    "subscriptionId": "1010101-0101-0101-0101-010101010101",
    "tenantId": "ABCDEFG-HIJK-LMNO-PQRS-123456789012",
    "managementEndpointUrl": "https://management.core.windows.net/"
}
```

**Azure Storage Account:** This is an Azure focused project, so an [azurerm backend](https://developer.hashicorp.com/terraform/language/settings/backends/azurerm) seemed appropriate. An Azure Storage Account was created to store Terraform's statefile. The app registration's service principal has contributor rights to the storage account - Terraform will authenticate with the same secret stored above (more on that later).

---

## The Workflow

Now that the prerequisites have been addressed, we will dissect the pipeline, [tfpipeline.yaml](https://github.com/ColeHeard/githubactions-pipeline-gallery/blob/main/samples/azure/tfpipeline.yaml).

### Getting Started

The workflow will only begin once a trigger condition has been met, as described by __on:__
```yaml
name: "Basic Terraform Pipeline"

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  terraform:
    name: 'Terraform - Ubuntu'
    runs-on: 	Self-hosted

    defaults:
      run:
        shell: bash
```

This workflow is waiting for one of two events to occur:
1. A pull request is opened.
2. A push is made to the main branch.

On these conditions, the [self-hosted runner](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners) starts the **Job** as defined below.

The checkout step clones the repository down to the runner.
```yaml
    steps:
      - name: Code Checkout
        id: checkout
        uses: actions/checkout@v3
```
### Azure Authentication

The [Azure Login Action](https://github.com/marketplace/actions/azure-login) authenticates with the Azure JSON secret.
```yaml
      - name: Azure Authentication
        id: login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZJSON }}
```

The **parse** step uses [jq](https://github.com/jqlang/jq) to parse the Azure JSON. The key values are echoed to environmental variables for use by Terraform. 

The variables are ultimately passed to **$GITHUB_ENV**, one of GitHub Actions [default environmental variables](https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables).   
```yaml
      - name: JSON Parse
        id: parse
        env:
          AZJSON: ${{ secrets.AZJSON }}
        run: |
          ARM_CLIENT_ID=$(echo $AZJSON | jq -r '.["clientId"]')
          ARM_CLIENT_SECRET=$(echo $AZJSON | jq -r '.["clientSecret"]')
          ARM_TENANT_ID=$(echo $AZJSON | jq -r '.["tenantId"]')
          ARM_SUBSCRIPTION_ID=$(echo $AZJSON | jq -r '.["subscriptionId"]')
          echo ARM_CLIENT_ID=$ARM_CLIENT_ID >> $GITHUB_ENV
          echo ARM_CLIENT_SECRET=$ARM_CLIENT_SECRET >> $GITHUB_ENV
          echo ARM_TENANT_ID=$ARM_TENANT_ID >> $GITHUB_ENV
          echo ARM_SUBSCRIPTION_ID=$ARM_SUBSCRIPTION_ID >> $GITHUB_ENV
```

### Access to Private Repositories

The GitHub token is streamed to [.netrc](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html). Git is then configured to use https for the logon. These git changes provide access to the private registry configured within the same GitHub org.

This step also disables git's detached head warnings.
```yaml
      - name: GitHub Token
        id: token
        env:
          TOKEN: ${{ secrets.GHTOKEN }}
        run: |
          echo "machine github.com login x password ${TOKEN}" > ~/.netrc
          git config --global url."https://github.com/".insteadOf "git://github.com/"
          git config --global advice.detachedHead false
```

### Terraform Prep

Terraform is installed and initialized. 

The **azurerm** backend is configured to use environmental variables created during the parse step. Only a single secret is maintained for _all_ Azure authentication.  
```yaml
      - name: Install Terraform
        uses: hashicorp/setup-terraform@v2.0.3
        with:
          terraform_version: 1.3.5

      - name: Terraform Init
        id: init
        run: |
          terraform init
```

### Checkov
[Checkov](https://www.checkov.io) is an open-source static code analysis tool. 
The tool compares the code to defined policy - the policies can be out-of-the-box security checks or custom .py or .yaml files.

Here [Pip](https://pypi.org/project/pip/) installs Checkov.
```yaml
      - name: Install Checkov
        id: checkov
        if: github.event_name == 'pull_request'
        run: |
          pip install checkov
```

Notice the **if:** expression. These steps only run if the trigger event was a pull request.

Checkov will now run. 
```yaml
      - name: Checkov Static Test
        id: static
        if: github.event_name == 'pull_request'
        run: |
          checkov -d . --download-external-modules true
```

Checkov should run after Terraform init; any modules called by Terraform are installed during init and we'll want Checkov to test their code as well.

![CheckovOutput](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eredilq7jbrdseyofqc7.png)

### More Terraform

The next few steps are Terraform staples. We run format, validate, and plan.
```yaml
      - name: Terraform Format
        id: fmt
        run: terraform fmt -check -recursive
        continue-on-error: true

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color     
        
      - name: Terraform Plan
        id: tplan
        env: 
          TF_VAR_secret: '${{ secrets.example_secret }}'
        run: |
            terraform plan -no-color
```

### Checking the Plan

This step requires another Terraform plan run. The previous plan did not output to a file - the console output from **tplan** will be used later. 
```yaml
      - name: Checkov Plan Test
        id: cplan
        if: github.event_name == 'pull_request'
        env: 
           TF_VAR_secret: '${{ secrets.example_secret }}'
        run: |
            terraform plan --out plan.tfplan
            terraform show -json plan.tfplan > tfplan.json
            ls
            checkov -f tfplan.json --framework terraform_plan
```

### Pull Request Comment

This step uses the [GitHub Script](https://github.com/actions/github-script) action. 

A comment will be created on the pull request. The outcome of many previous steps is displayed for review. Additionally, the full output of the Terraform plan is available as well. 

Much of this step's code was borrowed from the [Setup Terraform](https://github.com/marketplace/actions/hashicorp-setup-terraform) Action documentation.
```yaml
      - name: Pull Request Comment
        id: comment
        uses: actions/github-script@v3
        if: github.event_name == 'pull_request'
        env:
          TPLAN: "terraform\n${{ steps.tplan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GHTOKEN }}
          script: |
            const output = `
            ### Pull Request Information
            Please review this pull request. Merging the PR will run Terraform Apply with the plan detailed below.

            #### Terraform Checks
            Init: \`${{ steps.init.outcome }}\`
            Format: \`${{ steps.fmt.outcome }}\`
            Validation: \`${{ steps.validate.outcome }}\`
            Plan: \`${{ steps.tplan.outcome }}\`

            #### Checkov
            Static: \`${{ steps.static.outcome }}\`
            Plan: \`${{ steps.cplan.outcome }}\`

            <details><summary>Plan File</summary>
          
            \`\`\`${process.env.TPLAN}\`\`\`
          
            </details>

            `
            github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
```

This is what the comment looks like:

![PR Comment](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w0ocgpnc5ixravuwyzaa.png)

If the trigger event was a pull request, the workflow ends here.

### Terraform Apply

The workflow only runs Terraform apply when the push occurs on the main branch. 
```yaml
      - name: Terraform Apply
        id: apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        env:
          TF_VAR_domain_pass: '${{ secrets.DOMAIN_JOIN_PASS }}'
          TF_VAR_local_pass: '${{ secrets.LOCAL_ADMIN_PASS }}'
          TF_VAR_workspace_key: '${{ secrets.LA_WORKSPACE_KEY }}'
        run: terraform apply -auto-approve
```

That's all. The workflow tour is finished.

Here is what it looks like all put together:

![Overview](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hlnrquvar0g3n1l56leq.png)

## Wrapping up

I've found a lot of value deploying resources with this workflow:
- Resources will be created using the same method, every deployment.
- With Branch protection enabled, approvals can easily be incorporated into the deployment process.
- Deploying resources with a purpose-built service principal follows the principal of least privilege.
- Automated policy checks ensures that they're always being run.
 
If you enjoyed this article, take a look at my [SharePoint Framework pipeline](https://dev.to/coleheard/github-actions-sharepoint-framework-cicd-kan).

Until next time!

![Ralph Wiggum - Goodbye](https://www.reactiongifs.com/wp-content/uploads/2013/07/ralph-wave.gif)