The Anatomy of a PowerShell Script
In the PowerShell world, a script or a function has always has three areas of work; input, processing and output. Each area serves a distinct purpose:- To customize script behavior – input
- To do thew work – processing
- To tell you what happened – output
Get-Thing -Name 'thing1' -Value 'value1'
PS> Get-Thing -Name 'thing1' -Value 'value1'
thingvalue
PS> Get-Thing -Name 'thing1' -Value 'value1'
[pscustomobject]@{
Name = 'thing1'
Value = 'value1'
}
The “Before” PowerShell Way
When you’re “converting” PowerShell scripts/functions for the web as in transforming them for Azure Functions, that anatomy changes. You still have the same input, processing and output areas of work but what your script takes as input and outputs is different. On the web, everything is HTTP and your scripts must accept and speak HTTP. For example, you may have a script you run locally that restarts a Windows service. That script might have a parameter, restart the requested service and return the service object.[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ServiceName
)
## Returns the service object output from Restart-Service
Restart-Service -Name $ServiceName
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ComputerName,
[Parameter(Mandatory)]
[string]$ServiceName
)
## Returns the service object output from Restart-Service
Invoke-Command -ComputerName $ComputerName -ScriptBlock { Restart-Service -Name $using:ServiceName }
.restartmyservice.ps1 -ComputerName 'REMOTEPC' -ServiceName 'someservice'
Restart-Service
or Invoke-Command
return some kind of object that’s then returned to you.
But now you need to restart a service on an Azure virtual machine, make a change to an Azure website or query an Azure SQL database; the location changes and so must your PowerShell script.
Serverless and Local: A Totally Different Environment
When you build a PowerShell script and intend to execute it on a machine on your network, you naturally make some assumptions:- It will run on a piece of hardware you control
- The script will have access to internal network resources
- It will execute under some kind of context like a domain user, local user, etc.
- It will run as long as it’s needed to complete the job
Out with the Old Parameters
Taking the new environment your script is running in, let’s change up that restart service script you’ve been relying on and start transforming it into an Azure Function. First, the parameters gotta go. No, you don’t have to get rid of input completely but you do have to remove PowerShell parameters and introduce “HTTP parameters”. Previously, you had a couple of parameters;ComputerName
and ServiceName
. In Azure Functions, a function triggered via HTTP must only have two PowerShell parameters; $Request
and $TriggerMetadata
.
The $Request
parameter contains all of the incoming HTTP stuff like the body, headers, source, etc. The $TriggerMetadata
parameter contains various other metadata about the function invocation such as how the function was triggered, queue messages, etc.
That’s it. Two PowerShell parameters.
Now, how, you may ask, do you tell your script now which service you’d like to restart? You learn how to “embed” that parameter in the HTTP body.
Just like web APIs, the HTTP body contains the data payload; basically the stuff you want to pass to the endpoint. Sounds familiar? The HTTP body passes information to the script just like your parameters do.
Let’s adjust the script now to accept the required parameters.
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
💡 You don’t even need to make the parameters mandatory because when the script is invoked, those will always be the two parameters passed to it.
Invoking the Azure Functions Script
We have the $Request parameter defined in the new script but what does it even look like? How do you even invoke this script to begin with to tell? HTTP! UsingInvoke-WebRequest
or Invoke-RestMethod
, you can invoke this script that’s embedded in an Azure Function. The URI will follow the format https://<function app name>.azurewebsites.net/<function name>?code=optionalcodehereforprivateauthentication.
Let’s say we have an Azure Function App created called MyApp and a function inside of it called RestartService. I could call the function like this:
Invoke-RestMethod -Uri 'https://myapp.azurewebsites.net/restartservice?code=.....'
I want to see what the Request parameter holds when I call this function so I’ll add a Write-Host reference like I usually do in the script and deploy the app (with the script) to Azure.💡 You can get any Azure function’s base URL with PowerShell by running:
PS> $functionApp = Get-AzFunctionApp -ResourceGroupName $resourceGroupName -Name $functionAppName
PS> $functionApp.HostName | ForEach-Object { "https://$_/$functionName }
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
Write-Host $Request
Invoke-RestMethod
and see what happens….
Absolutely nothing.
Why? Because all of your common “Write” cmdlets don’t work how you’re used to in the web world. There’s no verbose, information, error, output stream anymore. Your communication must go over the web; not over PowerShell.
Let’s Get Some Output with HTTP Bindings
To get some kind of output back, you must “bind” that output to what Azure Functions calls an HTTP binding. That essentially means you need to attach any output from your script to the HTTP response going out once the script is finished executing. To bind your script output to HTTP, you must use the Push-OutputBinding cmdlet. This cmdlet references a pre-existing output binding and adds a value to it. Function bindings are always stored in the function.json file that sits inside of your Azure function. To “bind” PowerShell output to function output, you must use Push-OutputBinding to attach the PowerShell output to the output binding name. In this case, it’sResponse
.
But, you can’t just assign the output directly to the Value parameter of Push-OutputBinding.
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
Push-OutputBinding -Name Response -Value $Request
Body
key and assign your output to the Body
key. Then, casting that hashtable to the HttpResponseContext and assigning that as the Value
.
using namespace System.Net
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
$response = @{
Body = $Request
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]$response)
Deploy changes, invoke the URI again and you’ll see that the request returns the contents of the Request parameter! This is starting to look like a PowerShell function again!💡 Don’t forget the
using namespace
System.Net
line. This loads the .NET assembly that the HTTPResponseContext type needs.
Adding “PowerShellEsque” Parameters
In a PowerShell script, we have the luxury of defining parameters and adding features like mandatory, parameter types, parameter validation, dynamic parameters and so on. We can do a lot with parameters but in an Azure Function, when your input is an HTTP request represented by a single$request
variable, your options are limited. All “parameters” to the script are held in the $request
hashtable. But, with some ingenuity, you can mimic that same functionality. By enumerating the key/value pairs in the $request
hashtable, you can process the parameters however you’d like.
For example, I know I only want to allow certain parameters in this script. Since my official PowerShell parameters are just $request
and $TriggerMetadata
, I can enumerate the key/value pairs in the $request
hashtable to mimic those parameters.
Creating Variables from HTTP Query Parameters
Maybe I want this Azure Function to have two parameters;Name
and Value
. In a typical PowerShell environment, you could reference a $Name
and $Value
variable inside of the script.
[CmdletBinding()]
param(
[Parameter()]
$Name,
[Parameter()]
$Value
)
Write-Host "The Name parameter is $Name"
Write-Host "The Value parameter is $Value"
New-Variable
cmdlet.
$Request.Query.GetEnumerator() | ForEach-Object {
New-Variable -Name $_.Key -Value $_.Value
}
$Name
and $Value
anywhere in the script.
using namespace System.Net
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
$Request.Query.GetEnumerator() | ForEach-Object {
New-Variable -Name $_.Key -Value $_.Value
}
$response = @{
Body = "The name passed was [$Name] with value of [$Value]
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]$response)
using namespace System.Net
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
$response = @{
Body = $null
}
## Define all parameters (mandatory or optional)
$allowedQueryParams = @('Name','Value')
## Define all of the parameters that must have a value
$requiredParams = @('Name')
## If any HTTP query params passed are not recognized, stop
[array]$notRecognizedParams = Compare-Object $allowedQueryParams @($request.Query.Keys) | Select-Object -ExpandProperty InputObject
if ($notRecognizedParams.Count -gt 0) {
throw "The parameter(s) [$($notRecognizedParams -join ',')] were not recognized!"
}
## Ensure all mandatory parameters were passed
[array]$missingMandatoryParams = $requiredParams | Where-Object { $_ -notin @($request.Query.Keys) }
if ($missingMandatoryParams.Count -gt 0) {
throw "Missing mandatory parameter(s) [$($missingMandatoryParams -join ',')]!"
}
## If all parametere were recognized and all mandatory parameters were used, now create variables from them
$Request.Query.GetEnumerator() | ForEach-Object {
New-Variable -Name $_.Key -Value $_.Value
}
$response = @{
Body = "The name passed was [$Name] with value of [$Value]"
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]$response)
The Final Touch: Creating Descriptive HTTP Errors
Now that we have a great script built out to add to an Azure Function, let’s add a little bit of error handling to it. You saw from the screenshot above that when the script doesn’t have the right parameters, it returns an HTTP/500 error but just saysInternal Server Error
. We can make this more descriptive by sending our own HTTP status code.
The final script below:
- Wraps all of the code inside of a
try/catch
block. - Defines a custom HTTP status code in the response
- Defines the exception message as the response body
- Returns the HTTP response in the
finally
block to return the response whether or not an exception was thrown
using namespace System.Net
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Request,
[Parameter(Mandatory)]
$TriggerMetadata
)
$response = @{}
try {
## Define all parameters (mandatory or optional)
$allowedQueryParams = @('Name','Value')
## Define all of the parameters that must have a value
$requiredParams = @('Name')
## If any HTTP query params passed are not recognized, stop
[array]$notRecognizedParams = Compare-Object $allowedQueryParams @($request.Query.Keys) | Select-Object -ExpandProperty InputObject
if ($notRecognizedParams.Count -gt 0) {
throw "The parameter(s) [$($notRecognizedParams -join ',')] were not recognized!"
}
## Ensure all mandatory parameters were passed
[array]$missingMandatoryParams = $requiredParams | Where-Object { $_ -notin @($request.Query.Keys) }
if ($missingMandatoryParams.Count -gt 0) {
throw "Missing mandatory parameter(s) [$($missingMandatoryParams -join ',')]!"
}
## If all parametere were recognized and all mandatory parameters were used, now create variables from them
$Request.Query.GetEnumerator() | ForEach-Object {
New-Variable -Name $_.Key -Value $_.Value
}
$response.Body = "The name passed was [$Name] with value of [$Value]"
} catch {
$response.StatusCode = [HttpStatusCode]::InternalServerError
$response.Body = ($_.Exception | Out-String)
} finally {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]$response)
}