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 Actions (You can learn about it here and here)
- Provisioning infrastructure in Azure with Terraform
- Creating Azure Functions manually
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.