Azure Functions, GitHub Actions, and Terraform: Assembling all the pieces


Azure Functions, GitHub Actions, and Terraform: Assembling all the pieces

Azure Functions, GitHub Actions, and Terraform

Automatically deploying Azure Functions projects with GitHub Actions can be quite easy, as we’ve seen earlier, but there is the mandatory manual step of setting up Azure. And how can we automate this step as well? With Terraform! By using Azure Functions, GitHub Actions, and Terraform all together, we can have a pipeline that provides the infrastructure, and that automatically deploys to it.

Pre-requisites

This article assumes that you are somewhat familiar with the following concepts:

Setting up GitHub and Terraform Cloud

Let’s begin by creating a new workspace for this project. Go to your organization’s workspaces and click on New > Workspace. Select “API-driven workflow”, name your workspace, and click on “Create workspace”.

Now we must create the token that we will use in our pipeline. Still, on Terraform Cloud, go to the User Settings > Tokens, click on “Create an API Token” and give it a meaningful description, for example, “Amazing Test Token 000001”. Or maybe “GitHub Actions”, since that’s where we plan on using it. Save the generated token somewhere, as we are about to use it and there’s no way to get it back.

On your GitHub Repository, create a new secret called “TF_API_TOKEN” and paste the token generated earlier. If you are unsure how to do it, you can find more info in our previous article.

Finally, we need to create a new service principal, that will be used for logging into Azure, and publishing our Function. In order to do this, you must have the Azure CLI installed and authenticated. Start by running the following command on your cmd:

az ad sp create-for-rbac --role="Contributor" --scopes="/subscriptions/b7191531-bd52-4d97-bc1f-fa9c125f6f65" --sdk-auth

Now go back to your GitHub secrets and a new one called “AZURE_RBAC_CREDENTIALS“, and paste the output of the previous operation. It should be something similar to this:

{
    "clientId": "<GUID>",
    "clientSecret": "<GUID>",
    "subscriptionId": "<GUID>",
    "tenantId": "<GUID>",
    ...
}

Analyzing Terraform file

Now that we have everything set up, let’s analyze how our Terraform files are configured. The variables.tf file is simple enough so we’ll just skip to explaining the main.tf file.

variables.tf

variable "location" {
  default = "westeurope"
}

variable "prefix" {
  default = "tfewc"
}

main.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0.2"
    }
  }

  cloud {
    organization = "evil-weather-inc"
    workspaces {
      name = "github-to-azure-lesgooo"
    }
  }
  required_version = ">= 1.1.0"
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "rg" {
  name     = "${var.prefix}rg"
  location = var.location
}

resource "azurerm_storage_account" "storageaccount" {
  name = "${var.prefix}storageaccount"
  resource_group_name = azurerm_resource_group.rg.name
  location = var.location
  account_tier = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_service_plan" "serviceplan" {
  name                = "${var.prefix}serviceplan"
  location            = var.location
  resource_group_name = azurerm_resource_group.rg.name
  os_type             = "Windows"
  sku_name            = "Y1"
}

resource "azurerm_windows_function_app" "function" {
  name                = "${var.prefix}-function"
  resource_group_name = azurerm_resource_group.rg.name
  location            = var.location

  storage_account_name       = azurerm_storage_account.storageaccount.name
  storage_account_access_key = azurerm_storage_account.storageaccount.primary_access_key
  service_plan_id            = azurerm_service_plan.serviceplan.id

  site_config {}
}

Assuming you have done the Terraform’s Build Infrastructure – Terraform Azure, the terraform, provider, and “azurerm_resource_group.rg” blocks shouldn’t be anything new.

In the “azurerm_storage_account.storageaccount” block we define our storage account. In line 29, we define “azurerm_resource_group.rg” as its resource group, then some other variables. You can check all of the supported arguments in the documentation.

Next, in “azurerm_service_plan.serviceplan” we define our service plan. Usually, that’s pretty transparent to us when creating a Function, so it can be missed. Points of interest here are the “os_type” and “sku_name” variables. “os_type” and its value are self-explanatory, and “sku_name” value, Y1, is for the consumption plan. Below you can find a list of service plan SKU values, extracted from this StackOverflow answer.

name    Tier        Full name
D1      Shared      an D1 Shared
F1      Free        an F1 Free
B1      Basic       an B1 Basic
B2      Basic       an B2 Basic
B3      Basic       an B3 Basic
S1      Standard    an S1 Standard
S2      Standard    an S2 Standard
S3      Standard    an S3 Standard
P1      Premium     an P1 Premium
P2      Premium     an P2 Premium
P3      Premium     an P3 Premium
P1V2    PremiumV2   an P1V2 PremiumV2
P2V2    PremiumV2   an P2V2 PremiumV2
P3V2    PremiumV2   an P3V2 PremiumV2
I1      Isolated    an I2 Isolated
I2      Isolated    an I2 Isolated
I3      Isolated    an I3 Isolated
Y1      Dynamic     a  function consumption plan

And finally, we have our “azurerm_windows_function_app.function“. In this example, we are setting the basic values (name, RG, and location), and then linking them with our previously created resources.

Updating the GitHub Actions workflow

We will be picking off from our last article, where we working in our Evil Weather Control repository. Let’s go over the necessary changes.

name: Build, test and deploy Deploy DotNet project to Azure Function App

on:
  push:
    branches: [ "main" ]

env:
  AZURE_FUNCTIONAPP_NAME: tfewc-function
  AZURE_FUNCTIONAPP_PACKAGE_PATH: EvilWeatherControlApp\published
  CONFIGURATION: Release
  DOTNET_VERSION: 6.0.x
  WORKING_DIRECTORY: EvilWeatherControlApp
  TESTING_DIRECTORY: EvilWeatherControlApp.Tests

jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}          

      - name: Terraform Init
        working-directory: ./Terraform
        id: init
        run: terraform init
      
      - name: Terraform Validate
        working-directory: ./Terraform
        id: validate
        run: terraform validate -no-color

      - name: Terraform Apply
        working-directory: ./Terraform
        run: terraform apply -auto-approve -input=false

  build-and-deploy:
    runs-on: windows-latest 
    needs:
        - terraform
    steps:
    - name: 'Checkout GitHub Action'
      uses: actions/checkout@v3   

    - name: Setup DotNet ${{ env.DOTNET_VERSION }} Environment
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: ${{ env.DOTNET_VERSION }}

    - name: 'Login via Azure CLI'
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_RBAC_CREDENTIALS }}

    - name: Build
      run: dotnet build "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }}
      
    - name: Test
      run: dotnet test "${{ env.TESTING_DIRECTORY }}" --verbosity normal
      
    - name: Publish
      run: dotnet publish "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-build --output "${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}"

    - name: 'Run Azure Functions Action'
      uses: Azure/functions-action@v1
      id: fa
      with:
        app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}
        package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}

We’ve updated our “AZURE_FUNCTIONAPP_NAME” variable to match our generated values in our Terraform script, otherwise, we would fail during deploying.

Then we introduced a new job called “terraform“. Most of its steps should be easy to understand, as long you are familiar with Terraform and GitHub Actions. Still, it’s worth noticing the “Setup Terraform” job, which needs a “cli_config_credentials_token” argument, which we are providing through “TF_API_TOKEN” that we configured earlier.

As for the existing job, “build-and-deploy“, we had to make a few modifications. On lines 44 and 45, we’ve added a dependency to the “terraform” job, since we don’t want to deploy anything if our infrastructure gets wrecked. We’ve also added a new step, “Login via Azure CLI”, that we provide the credentials earlier specified in the “AZURE_RBAC_CREDENTIALS” secret. This step is needed for us to authenticate when deploying the Function on the last step.

You might be wondering how we were able to publish before if we weren’t authenticating. That’s because we were specifying the “publish-profile” argument, but since there’s no straightforward way to obtain it with Terraform (you actually can, if you are wondering), we removed it and went with the login approach.

And that’s it! Now everytime there is a new push to master, we will run our jobs. If there’s any need to change our infrastructure, either due to changes in main.tf or someone messed up something in Azure, our Terraform job will take care of that. And if nothing goes wrong, it will automatically be published to Azure.