Optimize ARM Output Variables in Azure DevOps Pipelines

Published:7 April 2020 - 8 min. read

When building an Azure Pipeline in Azure DevOps (AzDo), it’s commonplace to build infrastructure as part of a pipeline. If you’re building infrastructure in Azure, Microsoft provides an infrastructure-as-code approach called Azure Resource Management (ARM) templates. When invoking a deployment from an ARM template within an Azure pipeline though can sometimes prove troublesome, especially when dealing with ARM output variables.

In this article, you’re to learn one of the most troublesome (personal opinion) aspects of using ARM templates in AzDo pipelines – managing ARM output variables.

Understanding ARM Output and Templates

When you invoke an ARM deployment via a template, ARM gives you the ability to send output back to the process that invoked the deployment called outputs. Outputs allow you to send values back out of the deployment just like a PowerShell function, for example.

ARM output is not unique to running deployments in Azure Pipelines. Regardless of how you invoke ARM deployments, you can always output values.

If you need to capture the value generated via an ARM template deployment, you can do so using the outputs section of an ARM template. For example, suppose you have an ARM template that creates an Azure SQL Server and database. You are invoking this ARM template in a larger automation workflow like an Azure pipeline. As part of that pipeline, you need to know what the full-qualified domain name (FQDN) of the SQL Server created and the database name.

If you include an outputs section at the bottom of the template, you can see how to return both the SQL Server’s FQDN and database name as seen in the following code snippet.

The following code snippet assumes a parameter of sqlServerName and sqlDbName have been defined in the ARM template.

"outputs": {
      "sqlServerName": {
          "value": "[reference(resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))).fullyQualifiedDomainName]",
          "type": "string"
      },
      "databaseName": {
          "value": "[parameters('sqlDbName')]",
          "type": "string"
      }
  }

Below you can see the entire ARM template the outputs section below came from. The outputs section is all the way to the bottom.

{
    "$schema": "<https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#>",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "sqlServerName": {
            "type": "string",
            "metadata": {
                "description": "Enter a globally unique hostname for your SQL server"
            }
        },
        "sqlDbName": {
            "type": "string"
        },
        "adminUserName": {
            "type": "string",
            "metadata": {
                "description": "Enter a username for SQL admin"
            }
        },
        "adminPassword": {
            "type": "securestring",
            "metadata": {
                "description": "Enter a password for SQL admin"
            }
        }
    },
    "resources": [
        {
            "name": "[parameters('sqlServerName')]",
            "type": "Microsoft.Sql/servers",
            "apiVersion": "2015-05-01-preview",
            "location": "[resourceGroup().location]",
            "tags": {
                "displayName": "[parameters('sqlServerName')]"
            },
            "properties": {
                "administratorLogin": "[parameters('adminUserName')]",
                "administratorLoginPassword": "[parameters('adminPassword')]"
            },
            "resources": [
                {
                    "type": "firewallRules",
                    "apiVersion": "2015-05-01-preview",
                    "dependsOn": [
                        "[resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))]"
                    ],
                    "location": "[resourceGroup().location]",
                    "name": "AllowAllWindowsAzureIps",
                    "properties": {
                        "startIpAddress": "0.0.0.0",
                        "endIpAddress": "0.0.0.0"
                    }
                }
            ]
        },
        {
            "name": "[concat(parameters('sqlServerName'), '/',parameters('sqlDbName'))]",
            "type": "Microsoft.Sql/servers/databases",
            "apiVersion": "2014-04-01",
            "location": "[resourceGroup().location]",
            "dependsOn": [
                "[resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))]"
            ],
            "properties": {
                "collation": "SQL_Latin1_General_CP1_CI_AS",
                "edition": "Basic",
                "maxSizeBytes": "1073741824",
                "requestedServiceObjectiveName": "Basic"
            }
        }
    ],
    "outputs": {
        "sqlServerName": {
            "value": "[reference(resourceId('Microsoft.Sql/servers', parameters('sqlServerName'))).fullyQualifiedDomainName]",
            "type": "string"
        },
        "databaseName": {
            "value": "[parameters('sqlDbName')]",
            "type": "string"
        }
    }
}

However you invoke the ARM deployment using the template above, ARM will return the FQDN of the SQL Server and the database name.

Invoking ARM Templates in Azure Pipelines

If you’re building a YAML-based Azure pipeline, one popular way to invoke ARM template deployments is to use the Azure Resource Group Deployment task. Using this task, you can define the ARM template, resource group to deploy to, the path to the template and so on.

In the following code snippet, you can see an example of an Azure Resource Group Deployment task defined in an Azure pipeline.

- task: AzureResourceManagerTemplateDeployment@3
  name: "Compute"
  inputs:
    azureResourceManagerConnection: "ARM"
    subscriptionId: "xxxxxx"
    action: "Create Or Update Resource Group"
    resourceGroupName: xxxxxxxx
    location: "xxxxxx"
    templateLocation: "Linked artifact"
    csmFile: "$(System.DefaultWorkingDirectory)/template.json"
    deploymentMode: "Incremental"
    deploymentOutputs: database_arm

Capturing ARM Output for the Azure Pipeline

Notice one attribute in the code snippet above, in particular – deploymentOutputs. By default, even if the ARM template defines outputs, the Azure pipeline will have no idea of them. The pipeline will simply discard the outputs. Unless you use the deploymentOutputs attribute.

The deploymentOutputs attribute tells the pipeline to assign all of the values returned via the outputs section in the ARM template and assign them to a pipeline variable in JSON format. In the above example, that pipeline variable is called database_arm.

The value database_arm is not required. You are free to use whatever variable name you wish.

If the task shown below were used to deploy the database ARM template mentioned earlier, the database_arm pipeline variable would contain a JSON string that looks like the code snippet below.

{
    "sqlServerName": {
        "value" : "[some sql server name].database.windows.net",
        "type": "string"
    },
    "databaseName": {
        "value" : "[some sql db name]",
        "type": "string"
    }
}

At this point, the Azure Pipeline would now know about an output variable called database_arm.

Parsing ARM Output Variables in a Pipeline

Creating a pipeline variable containing the ARM output is great but what are you going to do with them? You don’t just create a pipeline variable for nothing!

Let’s say you need both the FQDN of the SQL Server and the database name to pass to an Azure SQL Database Deployment task later on in the pipeline. You need to provide both of those values to the ServerName and DatabaseName attributes of the task.

We still only have a single pipeline variable called database_arm that consists of a JSON string. As-is, there’s no way to parse the values of sqlServerName and databaseName in that JSON string to the actual values you need.

You’d like to have two separate pipeline variables called databaseName and sqlServerName that each only contains the expected string value of [some sql db name] and [some sql server name] respectively. You need to parse the JSON and create two new pipeline variables.

When done, you’d love to have an Azure SQL Database Deployment task that looks like the below example. Notice absolutely no database_arm variable to speak of.

- task: SqlAzureDacpacDeployment@1
  inputs:
    azureSubscription: 'ARM'
    AuthenticationType: 'server'
    DatabaseName: "$(databaseName)" ## From database ARM deployment
    ServerName: "$(sqlServerName)" ## From database ARM deployment
    SqlUsername: "$(SqlAdminUsername)" ## From keyvault
    SqlPassword: "$(SqlAdminPassword)" ## From keyvault
    deployType: 'SqlTask'
    SqlFile: '$(System.DefaultWorkingDirectory)/file.sql'
    IpDetectionMethod: 'AutoDetect'

So how do you get from a database_arm pipeline variable full of loose JSON text to two pipeline variables called databaseName and sqlServerName? PowerShell!

Using PowerShell to Create Pipeline Variables

We have a mission: create two pipeline variables called databaseName and sqlServerName from a pipeline variable called database_arm that looks like the below code snippet.

{
    "sqlServerName": {
	    "value" : "[some sql server name].database.windows.net",
        "type": "string"
    },
    "databaseName": {
        "value" : "[some sql db name]",
        "type": "string"
    }
}

To succeed in this mission, first, forget about Azure Pipelines all together. You must now create some PowerShell code to “convert” these variables.

Defining Pipeline Variables

Before you should write any code, you first need to know how to define pipeline variables with PowerShell. Doing so required returning one of two strings via a PowerShell task.

To create output (and non-output) variables, return specifically formatted string as shown below. The below example sets a pipeline variable called foo to a value bar.

"##vso[task.setvariable variable=foo;]bar"

If you need the pipeline variable to be an output variable, you’d add isOutput=true to the string like below.

"##vso[task.setvariable variable=foo;isOutput=true]bar"

Wrap up either one or both of those strings in some PowerShell and you’d get:

## Creates a standard pipeline variable called foo and sets the value to bar
Write-Output "##vso[task.setvariable variable=foo;]bar"
## Creates an output variable called foo and sets the value to bar
Write-Output "##vso[task.setvariable variable=foo;isOutput=true]bar"

Converting an ARM Output Variable to Specific Values

You know by now that you can create an outputs section in an ARM template, deploy that template in an Azure pipeline and use the deploymentOutputs attribute on the pipeline task to create a pipeline variable containing a JSON string with the values of the outputs ARM template. section. You’ve also learned how to use PowerShell to create pipeline variables.

Let’s now bring everything together and automate this whole process as much as possible!

You’ve seen above that the database_arm pipeline variable created via the ARM template deployment looks like the following code snippet.

{
    "sqlServerName": {
				"value" : "[some sql server name].database.windows.net",
        "type": "string"
    },
    "databaseName": {
				"value" : "[some sql db name]",
        "type": "string"
    }
}

How would you grab the values from the JSON with PowerShell if this string weren’t in a pipeline at all?

Let’s say that you’ve copied and pasted the above JSON snippet into a variable called $armOutput in your local PowerShell session.

$armOutput = @'
{
    "sqlServerName": {
	    "value" : "[some sql server name].database.windows.net",
        "type": "string"
    },
    "databaseName": {
		"value" : "[some sql db name]",
        "type": "string"
    }
}
'@

Using the handy ConvertFrom-Json cmdlet, you can easily convert the above JSON string into a PowerShell object.

$armOutputObj = $armOutput | convertfrom-json

Once you have the JSON string in a PowerShell object, you can then inspect all properties and values using the PSObject.Properties property that’s on every PowerShell object as shown below.

$armOutputObj.PSObject.Properties | ForEach-Object {
    $keyname = $_.Name
    $value = $_.Value.value
    Write-Host "The value of [$keyName] is [$value]"
}

The above code snippet will yield this beautiful sight below.

The result of the PowerShell object
The result of the PowerShell object

You can now reference each value in $armOutput as separate values. You’re so close!

Since you’ll be performing the actions above in a pipeline and need to make those values pipeline variables, use your knowledge of creating pipeline variables with PowerShell.

$armOutputObj.PSObject.Properties | ForEach-Object {
    $keyname = $_.Name
    $value = $_.Value.value
    ## Creates a standard pipeline variable
    Write-Output "##vso[task.setvariable variable=$keyName;]$value"
		
    ## Creates an output variable
    Write-Output "##vso[task.setvariable variable=$keyName;isOutput=true]$value"
}

Now take the code above and add it to a PowerShell pipeline task like below. If you were following along in this example, you should now have two pipeline variables created called databaseName and sqlServerName!

- pwsh: |
	## database_arm is already a pipeline variable returned via the ARM deployment
	## task's deploymentOutputs attribute
	$armOutputObj = $env:database_arm | convertfrom-json
	$armOutputObj.PSObject.Properties | ForEach-Object {
    $keyname = $_.Name
    $value = $_.Value.value

    ## Creates a standard pipeline variable
    Write-Output "##vso[task.setvariable variable=$keyName;]$value"
		
    ## Creates an output variable
    Write-Output "##vso[task.setvariable variable=$keyName;isOutput=true]$value"
	}

Better yet, create a PowerShell script in the source repo of the Azure DevOps project like below to get fancy. The script below accounts for other types of deployment outputs like arrays, secure strings and is configurable to create output variables or not.

[CmdletBinding()]
param (
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$ArmOutputString,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [switch]$MakeOutput
)

Write-Output "Retrieved input: $ArmOutputString"
$armOutputObj = $ArmOutputString | ConvertFrom-Json

$armOutputObj.PSObject.Properties | ForEach-Object {
    $type = ($_.value.type).ToLower()
    $keyname = $_.Name
    $vsoAttribs = @("task.setvariable variable=$keyName")

    if ($type -eq "array") {
        $value = $_.Value.value.name -join ',' ## All array variables will come out as comma-separated strings
    } elseif ($type -eq "securestring") {
        $vsoAttribs += 'isSecret=true'
    } elseif ($type -ne "string") {
        throw "Type '$type' is not supported for '$keyname'"
    } else {
        $value = $_.Value.value
    }
        
    if ($MakeOutput.IsPresent) {
        $vsoAttribs += 'isOutput=true'
    }

    $attribString = $vsoAttribs -join ';'
    $var = "##vso[$attribString]$value"
    Write-Output -InputObject $var
}

Perhaps you’ve saved the above code to a file called parse_arm_deployment_output.ps1 in the project’s source repo. At that point, you can just reference the script to automatically create the ARM output pipeline variables to your choosing.

- pwsh: $(System.DefaultWorkingDirectory)/parse_arm_deployment_output.ps1 -ArmOutputString '$(database_arm)' -MakeOutput -ErrorAction Stop

Once the above PowerShell script runs, you can accomplish the goal of referencing the value of both variables as a pipeline variable in future tasks in the pipeline.

- task: SqlAzureDacpacDeployment@1
  inputs: azureSubscription: 'ARM'
    AuthenticationType: 'server'
    DatabaseName: "$(databaseName)" ## From database ARM deployment
    ServerName: "$(sqlServerName)" ## From database ARM deployment
    SqlUsername: "$(SqlAdminUsername)" ## From keyvault
    SqlPassword: "$(SqlAdminPassword)" ## From keyvault
    deployType: 'SqlTask'
    SqlFile: '$(System.DefaultWorkingDirectory)/file.sql'
    IpDetectionMethod: 'AutoDetect'

Conclusion

Managing ARM templates, deployments and ARM output variables can be challenging in an Azure pipeline. Once you come up with a solution proposed in this article though, you no longer have to worry about the ins and outs of how to do it. Be sure to grab the PowerShell script referenced in this article to save yourself tons of time in the future!

Hate ads? Want to support the writer? Get many of our tutorials packaged as an ATA Guidebook.

Explore ATA Guidebooks

Looks like you're offline!