Recommendations for using Azure CLI in your workflow

Azure CLI is widely used in GitHub Actions and Azure Pipelines, as well as many other CI/CD tools. Over the last few weeks, I've been looking into its performance and security and based on that here are a number of recommendations.

Recommendations for using Azure CLI in your workflow

Reduce azure-cli chattiness

In its default configuration Azure CLI can be quite chatty, even accidentally echoing secrets to the console if you're not using it wisely. There are a number of settings you can apply to reduce the chattiness and by doing so automatically improve your security posture:

az config set core.only_show_errors=true
az config set core.error_recommendation=off
az config set core.collect_telemetry=false
az config set logging.enable_log_file=false
az config set core.survey_message=false
az config set auto-upgrade.enable=false
az config set core.no_color=true
az config set extension.use_dynamic_install=false

Most switches are explained in the azure cli configuration docs. There is a module called init that you can use to configure a number of these settings with ease (not all unfortunately):

> az extension add --name init
> az init

Select an option by typing its number

     [1] Optimize for interaction
         These settings improve the output legibility and optimize for human interaction

     [2] Optimize for automation
         These settings optimize for machine efficiency

Unfortunately, the AzureCLI@2 task in Azure Pipelines, by default, ignores the global configuration (see below), an even better way to set these options is through environment variables:

AZURE_CORE_ONLY_SHOW_ERRORS=TRUE
AZURE_CORE_ERROR_RECOMMENDATION=FALSE
AZURE_CORE_COLLECT_TELEMETRY=FALSE
AZURE_LOGGING_ENABLE_LOG_FILE=FALSE
AZURE_CORE_SURVEY_MESSAGE=FALSE
AZURE_AUTO-UPGRADE_ENABLE=FALSE
AZURE_CORE_NO_COLOR=TRUE
AZURE_EXTENSION_USE_DYNAMIC_INSTALL=FALSE

For self-hosted runners/agents you can either set these variables in the VMs global environment settings, or in the runner/agent's .env file:

The .env file is stored in the runner/agent's root folder

Be sure to restart the agent afterwards.

Since workflow/pipeline variables are automatically lifted to environment variables, you can also define these in your workflow or in the repository settings:

# Azure Pipelines
variables:
  AZURE_CORE_ONLY_SHOW_ERRORS: TRUE
  AZURE_CORE_ERROR_RECOMMENDATION: FALSE
  AZURE_CORE_COLLECT_TELEMETRY: FALSE
  AZURE_LOGGING_ENABLE_LOG_FILE: FALSE
  AZURE_CORE_SURVEY_MESSAGE: FALSE
  AZURE_AUTO-UPGRADE_ENABLE: FALSE
  AZURE_CORE_NO_COLOR: TRUE
  AZURE_EXTENSION_USE_DYNAMIC_INSTALL: FALSE

# GitHub Actions
env:
  AZURE_CORE_ONLY_SHOW_ERRORS: TRUE
  AZURE_CORE_ERROR_RECOMMENDATION: FALSE
  AZURE_CORE_COLLECT_TELEMETRY: FALSE
  AZURE_LOGGING_ENABLE_LOG_FILE: FALSE
  AZURE_CORE_SURVEY_MESSAGE: FALSE
  AZURE_AUTO-UPGRADE_ENABLE: FALSE
  AZURE_CORE_NO_COLOR: TRUE
  AZURE_EXTENSION_USE_DYNAMIC_INSTALL: FALSE

For GitHub Actions, you can import the env file to an organization level variable library in a single command with the github cli:

> gh variable set --env-file .env --organization myorg

For Azure Pipelines, you can create a variable group and import that into every pipeline:

pwsh> az pipelines variable-group create --name azure-cli-default-settings --authorize --variables `
  AZURE_CORE_ONLY_SHOW_ERRORS=TRUE `
  AZURE_CORE_ERROR_RECOMMENDATION=FALSE `
  AZURE_CORE_COLLECT_TELEMETRY=FALSE `
  AZURE_LOGGING_ENABLE_LOG_FILE=FALSE `
  AZURE_CORE_SURVEY_MESSAGE=FALSE `
  AZURE_AUTO-UPGRADE_ENABLE=FALSE `
  AZURE_CORE_NO_COLOR=TRUE `
  AZURE_EXTENSION_USE_DYNAMIC_INSTALL=FALSE

For security capture the output

Some commands (such as reading appsettings) may return connection strings or passwords. You don't want these to end up in the logs. In addition to setting core.only_show_errors=true, you can protect yourself further by redirecting the output from az to a variable, null or a file.

# capture the result in a variable

$output = & az ...

# redirect output
& az ... > $null
& az ...  2>&1 > $null  # incl the error stream for extra protection

# write output to a file
& az ... -o ./output.json
& az ... --output ./output.json

Mind the case

While the Azure CLI accepts its commands and parameters in any case you use:

⚠️ Works, but don't do it
az CoNfIG SeT a=b

It greatly reduces the performance, especially on windows, due to the way the internal caching mechanism looks up the command implementations.

Instead, pass all the commands and switches in lowercase:

✅ use lowercase for all commands and switches
az config set a=b

This will save you precious time (about 10 seconds on Linux and up to a minute on Windows) every time you run az.

Use the global configuration on Hosted Runners/Agents

The Hosted Runners/Agents take great care to set-up and warm-up the Azure CLI to improve its performance, especially the first-run performance. It does so by setting the AZURE_GLOBAL_CONFIG environment variable to a folder that has been prepped during the virtual machine image creation.

Do not overwrite the AZURE_GLOBAL_CONFIG variable in your own scripts.

However, on Azure Pipelines, the AzureCLI@2 task overwrites the AZURE_GLOBAL_CONFIG variable and redirects it to the agent's temp directory.

You can add a switch to the task to turn off this behavior:

- task: AzureCLI@2
  inputs:
    useGlobalConfig: true

This was the default behavior in AzureCLI@1.

Using the global configuration will reduce the time needed to set-up the task by more than 1 minute on the Windows Hosted Runner/Agent and by about 10 seconds on Linux.

You might wonder why it doesn't use global config by default. The reason for this is to support multiple agents on the same VM calling az at the same time. By redirecting the global config for each agent to its own temp directory they can't accidentally overwrite each other's settings or fight over file locks.

For self-hosted non-ephemeral runners/agents it also ensures that each job starts with a fresh set of settings as the temp folder is cleared before each run.

Since the hosted runners/agents don't run more than one job in parallel and always start with a fresh VM, there is no need to perform this redirection.

az devops: Don't rely on auto-discover for speed

The Azure DevOps extension for Azure CLI offers an auto-discover option which uses the git repository's remote to automatically identify the Azure DevOps organization and project. While super convenient, it takes time to look up this information each time you run az devops or a related command.

Instead, pass the --organization and --project settings explicitly:

- pwsh: |
    az pipelines show --organization=$env:SYSTEM_COLLECTIONURI --project=$env:SYSTEM_TEAMPROJECT

Or set them once as defaults at the start of your workflow:

- pwsh: |
    az devops configure --defaults organization=$env:SYSTEM_COLLECTIONURI project=$env:SYSTEM_TEAMPROJECT

az devops: Don't use AzureCLI@2, use standard shell instead

The AzureCLI@2 task does a number of setup steps, authenticates to Azure, redirects the global configuration folder, does an update-check... But if you only need to run az devops commands, then you don't need any of that.

In that case you can use the standard scripting features of Azure Pipelines such as script: and pwsh: or - task: Bash@2 or - task: PowerShell@2 to gain a massive performance boost.

az devops: Use environment variable for authentication in Azure Pipelines

When you're using the az devops extension, you can authenticate in 3 ways:

  1. az login
  2. az devops login
  3. environment variable

Since Azure Pipelines already holds an authentication token in its environment, the fastest way to authenticate is to leverage that token:

- pwsh: |
      az pipelines list --organization=$env:SYSTEM_COLLECTIONURI --project=$env:SYSTEM_TEAMPROJECT
  env:
    AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)

You do have to pass the token explicitly, because Azure Pipelines won't pass secrets to tasks by default.