Using deploymentScripts to do additional IaC work
Most people who are professionally working with any of the cloud providers use some kind of infrastructure-as-code solution.
For Microsoft Azure, I’m mostly working with ARM- or Bicep templates to describe the resources necessary. While I’ve written ARM templates for years now, I’m enjoying creating Bicep templates a bit more due to the tooling it offers.
There is at least one downside to using these solutions, and that’s the fact most operations are happening on the Azure control plane. Often times this is good enough, as you only need to deploy some resources, specify some values to the resources, and be done with it. However, there are cases where you also need to invoke some actions which require the creation of data, identities, or trigger some kind of endpoint.
To facilitate this need, there’s a special kind of resource in Azure called deploymentScripts
.
What are deploymentScripts
?
As it’s mentioned in the docs, these scripts can be used to perform lots of custom actions, like:
- Add users to a directory.
- Perform data plane operations, for example, copy blobs or seed database.
- Look up and validate a license key.
- Create a self-signed certificate.
- Create an object in Azure AD.
- Look up IP Address blocks from custom system.
The benefits of deployment script:
- Easy to code, use, and debug. You can develop deployment scripts in your favorite development environments. The scripts can be embedded in templates or in external script files.
- You can specify the script language and platform. Currently, Azure PowerShell and Azure CLI deployment scripts on the Linux environment are supported.
- Allow passing command-line arguments to the script.
- Can specify script outputs and pass them back to the deployment.
I had not used deploymentScripts
until recently, but started with it a while back and can say that I love to have this resource available!
There are some things you need to know before starting to use this resource.
First of all, as also mentioned in the docs, there are two principals necessary. One principal is used to create the necessary resources, and the other is used to execute the script itself.
The first one can be your regular deployment principal if you want. What it’ll do is create a new deployment, create a storage account to upload the script to, create an Azure Container Instance which will run the script, and will make sure the script is invoked.
The second will be used to execute the script. Depending on what you plan to do in the script, this principal needs to have enough permission to do all of the actions within the tenant, subscription, resource group, or resource. For the script, you can choose to use an earlier created service principal, but it’s also possible to invoke it via a managed identity. When possible, I’d opt for the latter, as it can also be created within the same template(s).
Getting started with deploymentScripts
You can do lots of stuff when working with deploymentScripts
, but the first thing you need to consider is which language you want to work with and which identity to use.
While I’m a big fan of using the Azure CLI, I’m not very keen on writing large scripts with it without having the freedom of using lots of PowerShell language constructs. Because of this, I’m choosing to use Azure PowerShell for this. What this will do is pull a container image with Azure PowerShell installed on it, so you can get going.
Do know I have tried installing Azure CLI by downloading the binaries & invoking the installer using PowerShell and failed. The script you’re running is in a sandboxed environment, and it appears installing stuff is prohibited. It IS possible to import some PowerShell modules, I had to import the Az-Resources
module manually. I haven’t tried installing a module, my guess is that’s prohibited too.
Next up is choosing the identity. If you want to run with a specific service principal, the script has to be provided with the identifier & secret of this principal. When using a user-managed identity (my preference), the identifier of this identity needs to be supplied.
The script over here shows how to create a deploymentScript
in Bicep & set a couple of environment variables deploymentScript
.
var azureCliLanguageVersion = '2.0.77'
var azurePowerShellVersion = '8.3'
resource myDeploymentScriptResource 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
name: 'myDeploymentScriptResource'
location: location
kind: 'AzurePowerShell'
properties: {
forceUpdateTag: utcValue
// azCliVersion: azureCliLanguageVersion
azPowerShellVersion: azurePowerShellVersion
timeout: 'PT30M'
environmentVariables: [
{
name: 'DeploymentPrincipalId'
value: deploymentPrincipalId
}
{
name: 'DeploymentPrincipalSecret'
secureValue: deploymentPrincipalSecret
}
{
name: 'TenantId'
value: tenantId
}
]
scriptContent: loadTextContent('myScript.ps1')
cleanupPreference: 'OnSuccess'
retentionInterval: 'P1D'
}
dependsOn: []
}
Within the script, you can now log in using these environment variables.
$tenantId = ${Env:TenantId}
$SecureStringPwd = ${Env:DeploymentPrincipalSecret} | ConvertTo-SecureString -AsPlainText -Force
$pscredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${Env:DeploymentPrincipalId}, $SecureStringPwd
Connect-AzAccount -ServicePrincipal -Credential $pscredential -Tenant $tenantId
Using a managed identity is fairly similar.
resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' existing = {
name: managedIdentityName
scope: resourceGroup(managedIdentityResourceGroupName)
}
var azureCliLanguageVersion = '2.0.77'
var azurePowerShellVersion = '8.3'
resource myDeploymentScriptResource 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
name: 'myDeploymentScriptResource'
location: location
kind: 'AzurePowerShell'
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${managedIdentity.id}':{
}
}
}
properties: {
forceUpdateTag: utcValue
// azCliVersion: azureCliLanguageVersion
azPowerShellVersion: azurePowerShellVersion
timeout: 'PT30M'
environmentVariables: [
]
scriptContent: loadTextContent('myScript.ps1')
cleanupPreference: 'OnSuccess'
retentionInterval: 'P1D'
}
dependsOn: []
}
First, retrieve the created managed identity, then provide it as the identity. There’s no need to invoke the Connect-AzAccount
in the script anymore, this is all handled for you.
Note the cleanupPreference
? This is a great flag for debugging purposes. If the script fails, you can configure it to not clean up the created resources (Storage Account and Azure Container Instance). This way you can SSH into the container and check why the script failed to complete successfully.
You can also use Write-Output
-messages to keep track of where your script is and what it’s doing.
Outputting data
For regular debugging & progress, using Write-Output
is good.
What’s also possible, is to use the same kind of output as with any other Azure resource. This way, data returned from your script can be used in other resources depending on this data. For me, I’ve been creating some App Registrations and service principals in the PowerShell scripts, so I’m returning the Application Id
, Object Id
, and identifiers of the Key Vault entries where some of the created secrets are persisted.
To do so, you create an object called $DeploymentScriptOutputs
and fill it up with data.
$DeploymentScriptOutputs = @{}
$DeploymentScriptOutputs['myApplicationId'] = $myApplicationDetails.ApplicationId
$DeploymentScriptOutputs['myObjectId'] = $myApplicationDetails.ObjectId
$DeploymentScriptOutputs['myPrincipalObjectId'] = $myApplicationDetails.EnterpriseApplicationObjectId
$DeploymentScriptOutputs['mySecretId'] = $secretOutput.Id
In Bicep you can output this data from your module
output myServiceApplicationId string = reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceApplicationId
output myServiceObjectId string = reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceObjectId
output myServiceServicePrincipalObjectId string = reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceServicePrincipalObjectId
output myServiceSecretId string = reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceSecretId
Should I use ARM templates or Bicep?
If you ask me, when using deploymentScripts
you should use Bicep as it provides some nice helpers.
When using Bicep, you can use a helper function called loadTextContent('filename')
to integrate the contents of this file in the template. Using ARM templates, you have to do this manually. This means you have to copy-paste the said script file inside the ARM template and make sure it’s in the correct format (quotes, linebreaks, etc.). This method is the main reason I’d opt to use Bicep. Of course, there are lots of other benefits to using Bicep versus ARM templates, but that’s a completely different story.
There is/was one big problem for me to use Bicep. The project I needed this for is using ARM templates. To work with this, I first had to come up with a solid merging strategy for the two. The process has been described in my previous post on merging ARM templates using PowerShell.
What it’ll produce is a massive ARM template with contents looking similar to the following code block.
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"metadata": {
"_generator": {
"name": "bicep",
"version": "0.11.1.770",
"templateHash": "18030036569134202344"
}
},
"parameters": {
// The parameters
},
"variables": {
// This variable contains the complete script loaded by the `loadTextContent` method.
"$fxv#0": "Import-Module Az.Resources\r\n\r\n$ErrorActionPreference = 'Stop'\r\n$DeploymentScriptOutputs = @{}\r\n\r\n...",
"azurePowerShellVersion": "8.3"
},
"resources": [
{
"type": "Microsoft.Resources/deploymentScripts",
"apiVersion": "2020-10-01",
"name": "myDeploymentScriptResource",
"location": "[parameters('location')]",
"kind": "AzurePowerShell",
"identity": {
"type": "UserAssigned",
"userAssignedIdentities": {
"[format('{0}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('managedIdentityResourceGroupName')), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('managedIdentityName')))]": {}
}
},
"properties": {
"forceUpdateTag": "[parameters('utcValue')]",
"azPowerShellVersion": "[variables('azurePowerShellVersion')]",
"timeout": "PT30M",
"environmentVariables": [
{
"name": "TenantId",
"value": "[parameters('tenantId')]"
}
],
"scriptContent": "[variables('$fxv#0')]",
"cleanupPreference": "OnSuccess",
"retentionInterval": "P1D"
}
}
],
"outputs": {
"myServiceApplicationId": {
"type": "string",
"value": "[reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceApplicationId]"
},
"myServiceObjectId": {
"type": "string",
"value": "[reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceObjectId]"
},
"myServiceServicePrincipalObjectId": {
"type": "string",
"value": "[reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceServicePrincipalObjectId]"
},
"myServiceSecretId": {
"type": "string",
"value": "[reference('myDeploymentScriptResource', '2020-10-01').outputs.myServiceSecretId]"
}
}
Looking good, right?
This template can now be merged with the main template or used as a Linked Template.
When to use?
If you have a script running before, or after, an infrastructure deployment, I’d say you probably want to integrate this in a deploymentScript
. There are some security considerations that you need to be aware of, of course. But having these types of scripts, like creating users, App Registrations, invoking endpoints, etc. inside your IaC-code makes a lot of sense to me, IF your Azure resources are dependent on them. When this isn’t the case, it might make more sense to have an additional step inside your pipeline.