Anti-Pattern 4: Not Using Outputs for Dependencies
Trent Steenholdt
December 4, 2024
13 minutes to read
Welcome back to our series on Azure Bicep anti-patterns. In this fourth installment, we address a crucial aspect of Infrastructure as Code (IaC) that often gets overlooked: not using outputs for dependencies, or simply, not using outputs at all. Outputs in Bicep serve as the essential glue between modules and systems, and neglecting them can lead to unnecessary and increased complexity in your deployments. Let’s dive into why outputs matter and how to effectively utilise them in your Bicep projects.
Revisiting Modularisation
Before we delve into outputs, it’s important to recall the significance of modularisation as discussed in part two. Properly modularising your Bicep code enhances readability, maintainability, and reusability. Outputs play a pivotal role in this by enabling seamless communication between these modules.
The Issue: Ignoring Outputs
A common mistake in Bicep development is the failure to use outputs to manage dependencies between modules. Instead, developers might:
- Rely heavily on
dependsOn
to manage resource deployment order. Then construct the resourceId of things by usingresourceId()
or worsevars
. - Use hard-coded references or variables that are difficult to track and maintain.
- Neglect to pass necessary information between modules, leading to tightly coupled and brittle deployments.
Why is this a problem?
- Bottlenecks: Without proper outputs, managing dependencies becomes cumbersome, potentially causing delays and errors in the deployment process.
- Complexity: Increased reliance on
dependsOn
and hard-coded references makes the codebase harder to understand and maintain. - Lack of Flexibility: Without outputs, integrating different modules or reusing them in different contexts becomes challenging.
Example of Ignoring Outputs
Consider a scenario where a Key Vault is deployed in one module, and its name is needed in another module for storing secrets. Without using outputs, you might handle this inefficiently like below.
var keyvaultName = 'myKeyvault'
// keyVault.bicep
resource keyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' = {
name: keyvaultName
location: location
properties: {
// Key Vault properties
}
}
// secrets.bicep
resource secret 'Microsoft.KeyVault/vaults/secrets@2021-06-01-preview' = {
name: '${keyvaultName}/mySecret'
properties: {
value: 'superSecretValue'
}
dependsOn: [
keyVault
]
}
In this example, secrets.bicep
directly references a var
called keyvaultName
, creating a tight coupling to this code file and making the code less modular (can’t split it away or use elsewhere) and harder to manage.
The Solution: Leveraging Outputs
Using outputs in Bicep modules allows you to pass necessary information between modules cleanly and efficiently. This practice enhances modularity and reduces dependency-related complexities.
Defining Outputs in a Module
First, define outputs in the module that creates the resource.
// keyVault.bicep
param keyVaultName string
resource keyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' = {
name: keyVaultName
location: location
properties: {
// Key Vault properties
}
}
output keyVaultName string = keyVault.name
output keyVaultId string = keyVault.id
Consuming Outputs in Another Module
Next, consume these outputs in the dependent module.
// secrets.bicep
param keyVaultName string
param keyVaultSecretName string
@secure()
param keyVaultSecretValue string
resource secret 'Microsoft.KeyVault/vaults/secrets@2021-06-01-preview' = {
name: '${keyVaultName}/${keyVaultSecretName}'
properties: {
value: 'keyVaultSecret'
}
}
Linking Modules with Outputs
Finally, link the modules in your main Bicep file using the outputs.
// main.bicep
param keyVaultName string = 'mykeyVault'
@secure()
param keyVaultSecretValue string = guid() # Overwritten in CI/CD with additional --parameters AzCLI call.
module keyVaultModule './keyVault.bicep' = {
name: 'deployKeyVault'
params: {
keyVaultName: keyVaultName
location: location
}
}
module secretsModule './secrets.bicep' = {
name: 'deploySecrets'
params: {
keyVaultName: keyVaultModule.outputs.keyVaultName
keyVaultSecretName: 'password'
keyVaultSecretValue: keyVaultSecretValue
}
}
Note how doing this removed the need for dependsOn
. The secretsModule
will only run when the outputs
are available to it. Additionally you can now take secrets.bicep
and use that Bicep file in other projects and code bases as the string in for the keyVaultName
can be any parameter.
Integrating Outputs with PowerShell
Handling outputs in automation scripts, say in CI/CD is also very easy to achieve. Using PowerShell, a language I use regularly in my day-to-day workings with IaC, is straightforward. Here’s how you can capture and utilise outputs from a Bicep deployment.
Example PowerShell Script
$deploymentResult = New-AzDeployment `
-Name $name `
-Location $location `
-TemplateFile $templatePath `
-TemplateParameterObject $parameters `
-SkipTemplateParameterPrompt `
-Confirm:$ConfirmPreference `
-WhatIf:$WhatIfPreference `
-Verbose
# Function to publish variables
function Publish-Variables(
[Parameter(Mandatory = $true)]
[Hashtable] $values,
[Parameter(Mandatory = $false)]
[switch] $isSecret = $false
) {
$isLocal = $env:LOCAL_DEPLOYMENT -eq "True"
$isAzureDevOps = $env:TF_BUILD -eq "True"
$isGithubActions = $env:GITHUB_ACTIONS -eq "true"
$localValues = @{}
$values.Keys | ForEach-Object {
$value = $values[$_]
# Handle secure string
if ($value -is [securestring]) {
if (-not $isSecret) {
throw "Attempted to publish SecureString $_ without -isSecret"
}
# Convert to plain text so we can interact with it
$value = ConvertFrom-SecureString -SecureString $value -AsPlainText
}
# Convert non-strings to JSON so it can be stored as a string
if ($value -isnot [string]) {
$value = ConvertTo-Json $value -Compress
}
# Always store in an environment variable so that it can be retrieved again
[System.Environment]::SetEnvironmentVariable($_, $value, [System.EnvironmentVariableTarget]::Process)
# If we are running locally then stash the value (with encryption for secrets)
if ($isLocal) {
if (-not $isSecret) {
$localValues[$_] = $value
}
else {
$localValues[$_] = "encrypted:" + (New-LocalEncryptedSecret (ConvertTo-SecureString -AsPlainText $value -Force))
}
}
# If we are running in Azure DevOps then we need to output the value as a variable
if ($isAzureDevOps) {
$output = @("task.setvariable variable=$_", "isOutput=true")
if ($isSecret) {
$output += "isSecret=true"
}
Write-Output "##vso[$($output -join ";");]$value"
}
# If we are running on GitHub Actions, we need to output the values as variable using Github Action syntax
if ($isGithubActions) {
if ($isSecret) {
Write-Output "::add-mask::$($_)"
}
# source: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
Write-Output "$($_)=$($value)" >> $env:GITHUB_OUTPUT
}
}
# If we are running locally then we need to stash the new values into the local json file
if ($isLocal) {
$localVariablesFile = '.\local.outputs.json'
$existingAsJson = Get-Content -Path $localVariablesFile -Raw -ErrorAction SilentlyContinue
if (-not $existingAsJson) {
$existingAsJson = "{}"
}
$existingAsHash = ConvertFrom-Json ($existingAsJson) -AsHashtable
$existingAsHash.Keys | ForEach-Object {
if (-not ($localValues[$_])) {
$localValues[$_] = $existingAsHash[$_]
}
}
Set-Content -Path $localVariablesFile -Value (ConvertTo-Json $localValues) -Force
}
}
# Publish the outputs
Publish-Variables -values $deploymentResult.Outputs
In this script:
- Deploy the Bicep Template: The
New-AzDeployment
cmdlet deploys the Bicep template and captures the outputs. - Publish Variables: The
Publish-Variables
function handles the outputs by setting environment variables, publishing them to Azure DevOps, or GitHub Actions as needed. It can even support local deployments if you set$env:LOCAL_DEPLOYMENT
totrue
in your IDE’s terminal.
Best practices for using Outputs
-
Define clear and readable outputs: Ensure that each module clearly defines the outputs it provides. This clarity makes it easier for other modules or scripts to consume them.
output storageAccountId string = storageAccount.id output storageAccountName string = storageAccount.name
-
Use Outputs for inter-module Communication: Instead of hard-coding references or relying on
dependsOn
, use outputs to pass necessary information between modules.// In main.bicep module storageModule './storage.bicep' = { name: 'deployStorage' params: { location: location tags: tags } } module appModule './app.bicep' = { name: 'deployApp' params: { storageAccountId: storageModule.outputs.storageAccountId } }
-
Minimise
dependsOn
Usage: Let Bicep infer dependencies based on resource references and outputs. Explicitly definedependsOn
only when necessary.// Letting Bicep infer the dependency resource app 'Microsoft.Web/sites@2021-02-01' = { name: 'myApp' location: location properties: { serverFarmId: appServicePlan.id } }
-
Be consistent with naming conventions: Use consistent and descriptive names for outputs to make their purpose clear.
output appServicePlanId string = appServicePlan.id output appServicePlanName string = appServicePlan.name
Conclusion
Outputs are an essential feature in Azure Bicep that facilitate clean and efficient communication between modules and external systems. By properly utilising outputs, you can eliminate unnecessary dependencies, reduce complexity, and enhance the maintainability of your IaC projects. Avoid the anti-pattern of ignoring outputs and embrace them as a best practice to streamline your infrastructure deployments.
What’s Next?
In our next post of this series, we’ll discuss the pitfalls of bypassing secure secret management by not storing secrets in Azure Key Vault and instead passing them directly through pipelines. Stay tuned as we continue to explore common anti-patterns and how to avoid them.