The DevOps Jedi

Taking the cloud by storm one line of code at a time....

Creating A Working Powershell Based Azure Function With Terraform

2023-09-107 min readDarren Johnson

This is a post I’ve wanted to write for a while, but was one of those side projects I’ve picked up and put down a few times due to other priorities getting in the way. This idea came about because I wrote a PowerShell script to perform some housekeeping actions in Azure and I wanted it to run unattended on a scheduled basis.

I considered a couple of options:

  • Configure the PowerShell script to run under an Azure Automation Account
  • Create an Azure Function to run the PowerShell script

I decided to explore the latter, as I had deployed empty functions before, but configuring the actual code to run had been done by the developers requesting the app.

How I Approached This

I couldn’t find a working example of how to achieve this online, so I decided to manually deploy an Azure Function App via the portal to see exactly what resources were deployed and how they were linked together.

I selected the options to create a function configured to run PowerShell Core 7.2 code using a Windows Consumption plan. The Consumption Plan was important as I wanted the solution to be cost effective and having a dedicated App Service Plan sat there doing nothing would incur cost.

I was prompted to create a storage account, and whether I wanted to permit public network access for function. As this function was making calls directly to Azure and had no VNet requirements, I left this enabled as I wanted to avoid the cost of the alternative which was implementing Private Link.

I was then asked to create an application insights instance which I did. Finally, I left the GitHub Actions settings as Disabled as I didn’t plan to integrate this at this time.

NOTE: If you’re wondering why I chose Windows over Linux, I tested both and found little to no performance difference, and wanted to use the Microsoft recommended WEBSITE_RUN_FROM_PACKAGE value (more on this later).

Reverse Engineering The Portal Deployment

After the resources were deployed, I compared them to a blank function app I had previously deployed via Terraform to see what the differences were.

Other than the odd cosmetic setting, there was one big difference as under the App files resource menu item were now 3 new files:

  • host.json
  • profile.ps1
  • requirements.psd1

Were these the missing gap I was looking for? Well let’s find out what each file does.

host.json

I found the Microsoft Documentation for this which explained this file is used to configure the Function App. I reviewed the contents of this and didn’t see the need to change anything.

{
  "version": "2.0",
  "managedDependency": {
    "Enabled": true
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[3.*, 4.0.0)"
  }
}

profile.ps1

The description in this file did a good job of explaining what it does so I just removed some commented out lines that weren’t relevant to me.


# Azure Functions profile.ps1
#
# This profile.ps1 will get executed every "cold start" of your Function App.
# "cold start" occurs when:
#
# * A Function App starts up for the very first time
# * A Function App starts up after being de-allocated due to inactivity
#
# You can define helper functions, run commands, or specify environment variables
# NOTE: any variables defined that are not environment variables will get reset after the first execution

# Authenticate with Azure PowerShell using MSI.

if ($env:MSI_SECRET) {
    Disable-AzContextAutosave -Scope Process | Out-Null
    Connect-AzAccount -Identity
}

requirements.psd1

This file does what it says, it ensure you have the requirements installed for your script to run and allows you to pin specific versions. I removed a commented out line and configured mine to use the Az.Storage modules my commands required.

# This file enables modules to be automatically managed by the Functions service.
# See https://aka.ms/functionsmanageddependency for additional information.
#
# For latest supported version, go to 'https://www.powershellgallery.com/packages/Az'.
@{
    'Az.Storage' = '5.3.0'
}

What About The PowerShell Script?

This now felt like I’d got the pre-reqs ready to go, but what about the functions themselves? The key was in the Microsoft Documentation as it mentioned the folder structure and showed how multiple functions could be created in folders underneath the root.

Because no existing functions had been created, I was able to create a sample function within the portal that used a Timer Trigger. This then created a folder with 3 more files in it:

  • function.json
  • readme.md
  • run.ps1

So what did these files do?

function.json

This defines how the function behaves, such as how it’s triggered and its input and output parameters.

{
  "bindings": [
    {
      "name": "Timer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 */5 * * * *"
    }
  ]
}

The schedule uses a cron expression which is explained in the readme.md file.

readme.md

This explains what the function does, and was populated with the content below.

# TimerTrigger - PowerShell

The `TimerTrigger` makes it incredibly easy to have your functions executed on a schedule. This sample demonstrates a simple use case of calling your function every 5 minutes.

## How it works

For a `TimerTrigger` to work, you provide a schedule in the form of a [cron expression](https://en.wikipedia.org/wiki/Cron#CRON_expression)(See the link for full details). A cron expression is a string with 6 separate expressions which represent a given schedule via patterns. The pattern we use to represent every 5 minutes is `0 */5 * * * *`. This, in plain text, means: "When seconds is equal to 0, minutes is divisible by 5, for any hour, day of the month, month, and day of the week".

## Learn more

<TODO> Documentation

run.ps1

This is the script to be executed, the reason for provisioning the function app in the first place. The sample content was below:

# Input bindings are passed in via param block.
param($Timer)

# Get the current universal time in the default string format.
$currentUTCtime = (Get-Date).ToUniversalTime()

# The 'IsPastDue' property is 'true' when the current function invocation is later than scheduled.
if ($Timer.IsPastDue) {
    Write-Host "PowerShell timer is running late!"
}

# Write an information log with the current time.
Write-Host "PowerShell timer trigger function ran! TIME: $currentUTCtime"

So How Did I Bring This All Together & Automate This?

The Microsoft Documentation highly recommended the WEBSITE_RUN_FROM_PACKAGE deployment option so I looked how to achieve this in Terraform.

The HashiCorp documentation for azurerm_windows_function_app indicated there was an argument named zip_deploy_file that could be used when configuring WEBSITE_RUN_FROM_PACKAGE=1 within the app_settings block, so I gave it a go by creating a zip file with the required content and setting the argument zip_deploy_file = "./function.zip"

NOTE: When I started this project the zip_deploy_file wasn’t available, and I had to use curl to upload the zip. This option was introduced in azurerm 3.50.0 and this newer method is much slicker.

Before I could deploy the zip file I needed to create it. I did this by creating the required folder structure to host 2 functions midday & midnight. Multiple folders are required as a function can only have a single trigger.

├── function
│   ├── host.json
│   ├── midday
│   │   ├── function.json
│   │   ├── readme.md
│   │   └── run.ps1
│   ├── midnight
│   │   ├── function.json
│   │   ├── readme.md
│   │   └── run.ps1
│   ├── profile.ps1
│   └── requirements.psd1

Within these folders were the required files for each function which were modified as follows:

function.json - midday: "schedule": "0 0 0 * * *"
function.json - midnight: "schedule": "0 0 12 * * *"

run.ps1 was identical in both folders and contained the original script I had written which required authenticating before running it. The authentication was now handled at the function level by profile.ps1 but I did need to add the line below to the top for the script for it to execute successfully:

param($Timer)

Finally, I zipped up the function folder using PowerShell as part of the Terraform configuration:

Compress-Archive -Path "./function/*" -DestinationPath "./function.zip"

This then deployed a fully working Azure Function running a PowerShell script that triggered every day at Midnight (00:00) and Midday (12:00).

When I get time I’ll sanitise the code and pop it on GitHub for you to have a look at. But as always, I hope this helps.