He completado un PoC para adaptar un local script y convertirlo en una función que se dispara en base a un request HTTP. Fue una genial oportunidad de trabajar con arquitectura serverless usando Azure Functions. Como lo adapté desde un script Powershell hecho por uno de mis compañeros, la opción del lenguaje venía bastante marcada (es muy probable que si no me hubiera decidido por Python, que me conozco) La característica principal de la función es que interactúa con las rules de una policy de Azure Firewall para hacer backup o modificar (agregar o eliminar) algunas reglas concretas. Originalmente el script descargaba localmente una copia de seguridad como archivo .JSON y consumía otro .JSON de las reglas a modificar.

Mi objetivo era que fuese serverless y que residiera completamente en la infra Azure del cliente, así que la parte más interesante fue la de trasladar esos archivos para que fuesen descargados y leídos desde un Blob de Azure. Quizás no supe buscar bien (y buscar, busqué bastante) pero en mi experiencia la documentación de Azure Functions para usar Blobs como input u output binding con Powershell era limitada y los ejemplos escasos o muy sencillos. Por supuesto, siempre se puede utilizar el módulo de AzCLI Az.Storage, tirar de Connection String e interactuar con el Blob como desde cualquier otra parte pero, ¿dónde está la diversión en eso? ¿Por qué no tirar de los bindings? Me encontré con varios problemas, así que voy a poner aquí el código relevante junto con algunos comentarios,espero que a alguien le sirva y le ahorre algunas frustraciones.

Bindings:

Lo primero es declarar en el function.json los bindings que queremos. En este caso mi función tiene:

  • Un trigger HTTP.
  • Un output binding HTTP.
  • Un output binding hacia un blob.
  • Un input binding desde un blob (el mismo, pero no tiene por qué).

¿Dónde está mi connection String?

Puede haber tantos inputs y outputs como queramos, pero sólo un trigger. Lo más relevante aquí es el parámetro connectionen los blob bindings. Este parámetro hará referencia a un secret en la function app, en un namespace que es commún a todas las functions que tengamos bajo esa “app”. Muy similar a los secrets de repositorios de código como Github. De esta manera, podemos tener credenciales guardadas para su uso sin exponerlas. Como vemos, también se puede parametrizar el nombre del archivo en los bindings (backupFiley sourceFile en este caso)

 {
  "bindings": [
    {
      "authLevel": "function",
      "type": "httpTrigger",
      "direction": "in",
      "name": "Request",
      "methods": [
        "get",
        "post"
      ]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "Response"
    },
    {
      "name": "outputBlob",
      "type": "blob",
      "path": "poc/{backupFile}",     # El path es directamente el blob/file que queremos
      "connection": "blobCs",         # Esto es el nombre del secret que contiene el ConnectionString
      "direction": "out"
    },
    {
      "name": "inputBlob",
      "type": "blob",
      "path": "poc/{sourceFile}",
      "connection": "blobCs",
      "dataType": "binary",          # Typecast explícito
      "direction": "in"
    }
  ]
}

La función

La función está aquí simplificada, y la parte relevante es la que lee o escribe a los blob bindings.

Al comienzo, declaramos los módulos y los parámetros, en este caso se leen desde el body del HTTP request.

using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $inputBlob, $TriggerMetadata)

Import-Module Az.Accounts

# Interact with query parameters or the body of the request.
$sourceFile = $Request.Body.sourceFile
$backupFile = $Request.Body.backupFile

Output Blob: Escribir varios archivos a un output

Por diseño el output binding se considera directamente un sólo archivo. La solución para poder escribir más de un archivo es generar una carpeta temporal, guardar allí los archivos y zippearla luego para pushearla al output. Aproveché que en Powershell todo es un objeto y gestioné cada objeto rule, exportandolo a un archivo .XML en una carpeta temporal. Luego se comprime la carpeta. Las carpetas temporales se crean y borran en cada ejecución (de existir) ya que la function puede persistir el filesystem entre ejecuciones.

Write-Host "Rules discovery completed"
# Create temp folder to store all rules and then zip it
if (Test-Path -Path .\caughtRules) {
  Remove-Item -Path .\caughtRules -Recurse -Force | Out-Null
}
New-Item -ItemType Directory -Path .\caughtRules
# Have the rule collection, now lets catch the rules we targeted
foreach ($rule in $targetedRules) {
  $ruleName = $rule.Name
  $caughtRule = $existingrulecollection.GetRuleByName($ruleName)
  $caughtRule | Export-Clixml .\caughtRules\$ruleName.xml
}        
# Zip temp folder with the objects in xml
if (Test-Path -Path .\caughtRules.zip) {
  Remove-Item -Path .\caughtRules.zip -Force | Out-Null
}        
Compress-Archive -Path .\caughtRules\* -DestinationPath .\caughtRules.zip | Out-Null
# Copy zipped file to output blob
$zipPath = ".\caughtRules.zip"
$content = [System.IO.File]::ReadAllBytes($zipPath)
Push-OutputBinding -Name outputBlob -Value $content -Clobber

Leer el archivo

También por diseño el input binding se considera directamente un sólo archivo y además va a ser proporcionado como un string, algo bastánte problemático si lo que tenemos ahí es un .zip. El primer paso para ahorrar quebraderos de cabeza, que puede verse en el function.json, es hacer typecast del input a un ByteArray (declarado allí como binary).

Una vez hecho el typecast, sólo tuve que tratar el input blob como un byteArray desde el que leer mi archivo .zip. Por supuesto también tuve que crear carpetas temporales para almacenar las rules descomprimidas de la misma manera que con el output…

if (Test-Path -Path .\unzippedRules) {
  Remove-Item -Path .\unzippedRules -Recurse -Force | Out-Null
}
# Pulling inutBlob string into a MemoryStream
$asByteArray = $inputBlob
$zipPath = ".\sourceRules.zip"
# Writing ByteArray into .zip file
[System.IO.File]::WriteAllBytes($zipPath, $asByteArray)
# Expand .zip and load unzipped rules
Expand-Archive -Path .\sourceRules.zip -DestinationPath .\unzippedRules -Force | Out-Null
$objXmls = gci .\unzippedRules
$ruleArray = @()
  foreach ($xmlobj in $objXmls){
    $importedObject = Import-Clixml $xmlobj
    $ruleArray += $importedObject
}

Las claves

Realmente, aunque incluya un montón de “palabros”, leer y escribir desde Azure Functions en Powershell a una Azure Storage Account utilizando input y output blobs no es tan complicado. Sin embargo, la falta de ejemplos sencillos que sean extrapolables a cada caso particular pueden hacernos emplear más horas de las deseables en sacarlo. Recordemos lo más importante:

  1. Definir correctamente nuestros bindings (incluyendo los connection Strings si son necesarios para conectar a otro recurso)
  2. Hacer typecast del input si lo queremos tratar como algo que no sea string
  3. Leer y escribir correctamente. El input/output blob ya representa directamente el archivo.

Kudos