Posts without posting: building a social media sharing relay with F♯ and Azure Functions, part 3

In the first two parts of the series, I introduced the architecture for building a link sharing relay and then built it with a little bit of F# code. This post covers setting up the infrastructure and deploying the code as an Azure Function.

Azure functions offer a really nice way to build serverless endpoints. Azure functions are phenomenally flexible, and in the previous part I showed how to set up an F# Azure function with an HttpTrigger. It runs locally, but to make it useful we need to deploy it to Azure. We’ll need to configure not a small amount of infrastructure to do so.

Posts without posting, a series

  1. Architecting a relay
  2. Writing the code
  3. Configuring Azure Functions with Terraform
  4. Setting up an iOS Shortcut

This post will be a little lengthy, but hopefully not as long as the last. I’ll use terraform to set things up, but there’s one step that doesn’t seem to be working. Configuring a Custom Domain for the Azure Function seems not to work, so this will be a small manual step. It’s not ideal, but it’s only one extra step for managing DNS. Some of my terraform scripts are inherited from previous work, but I’ll do my best to present them here as if I am using them for the first time.

To deploy the function, we’ll do the following:

  1. Set up an Azure Resource Group
  2. Set up Azure Storage for deploying the function code
  3. Set up Azure Insights for monitoring
  4. Set up a Key Vault for secrets management
  5. Set up a Service Plan for the Azure Function
  6. Set up an Azure Function
  7. Configure a Key Vault Access Policy for the Azure Function
  8. Deploy the function

Let’s get started!

Create an Azure Resource Group

First, we’ll set up a couple variables and data source in terraform.

# variables.tf

variable "domain" {
  type    = string
  default = "emilygorcenski.com"
}


variable "regions" {
  type = map(string)
  default = {
    "primary"   = "germanywestcentral"
    "functions-eu" = "northeurope"
  }
}

Not every region allows you to deploy Azure functions with a consumption plan (serverless), so I create some options in my variables. Next, we’ll set up our terraform provider. I’m ignoring the part where I have a remote terraform backend, but I recommend adding that, as well. My versions here are almost surely out of date, but I didn’t have time to fix that before writing this post. I’m on a schedule here!

# main.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 2.91"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 2.0.0"
    }
  }
}

Finally, we can add the following to our main.tf file. Of course, variable names in ALL_CAPS should be replaced with something that makes sense for you.

resource "azurerm_resource_group" "rg" {
  name     = "RESOURCE_GROUP_NAME"
  location = var.regions["primary"]
}

Setting up Azure storage

Once we have the resource group set up, we can configure a storage bucket that we can deploy our code into. Let’s create a new file, link-sharer.tf, and put the following block in it.

resource "azurerm_storage_account" "links_app_storage" {
  name                      = "STORAGE_NAME"
  resource_group_name       = azurerm_resource_group.rg.name
  location                  = var.regions["functions-eu"]
  account_tier              = "Standard"
  account_replication_type  = "LRS"
}

No further action is needed here.

Configure Azure Insights

We’ll want to monitor our function, and while this step might be a little out of order, that’s ok. Let’s configure Azure Insights to let us monitor our function once we deploy. In link-sharer.tf, add:

resource "azurerm_application_insights" "links_afapp_insights" {
  name                = "APP_INSIGHTS_NAME"
  location            = var.regions["functions-eu"]
  resource_group_name = azurerm_resource_group.rg.name
  application_type    = "web"
}

Set up a Key Vault

This step is a little more difficult. Let’s create a new file, key-vault.tf. We’ll start by configuring the Key Vault.

resource "azurerm_key_vault" "links_afapp_keyvault" {
  name                       = "KEYVAULT_NAME"
  location                   = azurerm_resource_group.rg.location
  resource_group_name        = azurerm_resource_group.rg.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "standard"
  soft_delete_retention_days = 7

  access_policy {
    tenant_id = data.azurerm_client_config.current.tenant_id
    object_id = data.external.account_info.result.object_id

    certificate_permissions = [
      "Create",
      "Delete",
      "DeleteIssuers",
      "Get",
      "GetIssuers",
      "Import",
      "List",
      "ListIssuers",
      "ManageContacts",
      "ManageIssuers",
      "Purge",
      "SetIssuers",
      "Update"
    ]

    key_permissions = [
      "Backup",
      "Create",
      "Decrypt",
      "Delete",
      "Encrypt",
      "Get",
      "Import",
      "List",
      "Purge",
      "Recover",
      "Restore",
      "Sign",
      "UnwrapKey",
      "Update",
      "Verify",
      "WrapKey"
    ]

    secret_permissions = [
      "Backup",
      "Delete",
      "Get",
      "List",
      "Purge",
      "Recover",
      "Restore",
      "Set"
    ]
  }
}

This will allow you to set up your Key Vault and gives your user account full permissions to manage keys. I think this is probably suboptimal, but I haven’t really dove into finding a better way to do this yet.

Next, in your main.tf file, put the following:

provider "azurerm" {
  features {
    key_vault {
      purge_soft_delete_on_destroy = true
    }
  }
}

This is needed because as of December 2020, all Key Vaults need to have “Soft Delete on Destroy” enabled.

Also in main.tf, let’s load our local data from our JSON for the secrets. I suppose we could also be reading these from environment variables, too, but this is how I do it.

locals {
  twitter_data  = jsondecode(file("PATH/TO/TWITTER/CREDS/twitter-api.json"))
  mastodon_data = jsondecode(file("PATH/TO/MASTODON/CREDS/mastodon-api.json"))
  google_data   = jsondecode(file("PATH/TO/GOOGLE/CREDS/google-key.json"))
}

Now, we can use this data in our key-vault.tf file to set up the secrets.

resource "azurerm_key_vault_secret" "google-sheets-key" {
    name = "GOOGLE-SERVICE-ACCOUNT-CREDENTIAL"
    value = base64encode(local.google_data.GOOGLE_SERVICE_ACCOUNT_CREDENTIAL)
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "google-sheet-id" {
    name = "GOOGLE-SHEET-ID"
    value = local.google_data.GOOGLE_SHEET_ID
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "twitter-consumer-key" {
    name = "twitter-consumer-key"
    value = local.twitter_data.TWITTER_CONSUMER_KEY
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "twitter-consumer-key-secret" {
    name = "twitter-consumer-key-secret"
    value = local.twitter_data.TWITTER_CONSUMER_KEY_SECRET
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "twitter-access-token" {
    name = "twitter-access-token"
    value = local.twitter_data.TWITTER_ACCESS_TOKEN
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "twitter-access-token-secret" {
    name = "twitter-access-token-secret"
    value = local.twitter_data.TWITTER_ACCESS_TOKEN_SECRET
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "mastodon-server" {
    name = "mastodon-server"
    value = local.mastodon_data.MASTODON_SERVER
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

resource "azurerm_key_vault_secret" "mastodon-access-token" {
    name = "mastodon-access-token"
    value = local.mastodon_data.MASTODON_ACCESS_TOKEN
    key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
}

That’s it for now. We’ll come back to this.

Set up a Service Plan

We need to set up a Service Plan where we specify our compute SKU and other details for the Azure function. This is easy. We want to use a Y1 SKU on a Windows OS. This is a consumption (serverless) SKU where you pay for what you use.

resource "azurerm_service_plan" "links_afapp_service_plan" {
  name                  = "SERVICE_PLAN_NAME"
  resource_group_name   = azurerm_resource_group.rg.name
  location              = var.regions["functions-eu"]
  sku_name              = "Y1"
  os_type               = "Windows"
}

Set up the Azure Function

With all this done, we can finally set up the Azure Function. In your main.tf, add the following:

resource "azurerm_windows_function_app" "links_app" {
  name                       = "AZURE_FUNCTION_NAME"
  resource_group_name        = azurerm_resource_group.rg.name
  location                   = var.regions["functions-eu"]
  service_plan_id            = azurerm_service_plan.links_afapp_service_plan.id
  storage_account_name       = azurerm_storage_account.links_app_storage.name
  storage_account_access_key = azurerm_storage_account.links_app_storage.primary_access_key

  identity {
    type = "SystemAssigned"
  }
  
  site_config {
    use_32_bit_worker = false
  }
  
  app_settings = {
    "FUNCTIONS_WORKER_RUNTIME" = "dotnet",
    "AzureWebJobsDisableHomepage" = "true",
    "GOOGLE_SERVICE_ACCOUNT_CREDENTIAL" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.google-sheets-key.id})",
    "GOOGLE_SHEET_ID" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.google-sheet-id.id})",
    "APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.il_afapp_insights.instrumentation_key,
    "TWITTER_CONSUMER_KEY" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.twitter-consumer-key.id})",
    "TWITTER_CONSUMER_KEY_SECRET" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.twitter-consumer-key-secret.id})",
    "TWITTER_ACCESS_TOKEN" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.twitter-access-token.id})",
    "TWITTER_ACCESS_TOKEN_SECRET" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.twitter-access-token-secret.id})",
    "MASTODON_SERVER" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.mastodon-server.id})",
    "MASTODON_ACCESS_TOKEN" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.mastodon-access-token.id})",
  } 
}

The app settings here are very important, this is how we load secrets in from the Azure Key Vault. This should automatically load in the secrets. However, in my experience, if I change a secret, it does not load the latest version. I’ll figure that out later.

Before we can make this work, however, we need to let the Function App read from the Key Vault.

Read secrets from the Key Vault

In your main.tf, add the following at the end.

resource "azurerm_key_vault_access_policy" "il-managed-id-policy-eu" {
  key_vault_id = azurerm_key_vault.links_afapp_keyvault.id
  tenant_id    = azurerm_windows_function_app.links_app.identity.0.tenant_id
  object_id    = azurerm_windows_function_app.links_app.identity.0.principal_id

  secret_permissions = [
    "Get"
  ]
}

Sometimes, on re-running terraform, this does not stick. I don’t know why. Sometimes I have to manually find the Principal ID, which is under “Identity” in the left nav in the Function App, and then copy the GUID into the “Access Policies” section within the Key Vault.

Set up DNS

I can’t terraform this, perhaps because of lack of supported functionality, but in the Function App you can configure a custom CNAME domain under “Custom Domains.” Simply copy the relevant information to your DNS provider.

Deploy the function

If the terraform works, then go ahead and apply the changes to your cloud, and let’s get ready to deploy the app! This is very simple. Navigate to your folder and type func azure functionapp publish AZURE_FUNCTION_NAME, replacing the last bit with your function name, of course.

You’ll want to go to “Configuration” in your Function App to see that secrets are being properly read. If so, great! Go ahead and send a cURL request and see how it works!

If it doesn’t, email me and I will try to help. I probably missed something or made a mistake, because what I present here is a bit modified from my own setup.

Thanks for following along. Next up, integrating with an iOS shortcut!

Posted: 31.12.2022

Built: 27.01.2023

Updated: 08.01.2023

Hash: 6131aa5

Words: 1438

Estimated Reading Time: 8 minutes