Bicep Tips – Unleash the Local extensions

There’s been a really cool experimental feature release on Bicep 0.37.4 – a simplified local extension with C#. I didn’t grasp the full potential of it before starting to play with it myself, but now I realised the huge potential it offers, and decided to share it with you all.

There is official sample, but it omits some steps, so it might not be that easy to pick up if you don’t have much experience with C# and Bicep, so I created a full sample how to create a local extension and everything you need to do it.

Let’s build together a Bicep local extension which gets your local IP, so you can whitelist it in the Bicep, without needing to do any scripting in your pipelines, but stay 100% within Bicep to do so. What we’ll do is that we’ll build and consume a .NET-based local extension for Bicep under the j4ni namespace that pulls your machine’s public IP from the ipify API—end to end, in VS Code, on macOS or Windows. You’ll see how to scaffold, implement, publish, and run it locally with bicep local-deploy.

Prerequisites

  • .NET 9 SDK installed (Windows, macOS, or Linux). Download from dotnet.microsoft.com.
  • Bicep CLI v0.37.4 or later (please note, that this is different from az bicep, a separate tool:
    • Windows: winget install Microsoft.Bicep or choco install bicep
    • macOS: brew tap azure/bicep; brew install bicep
    • Linux: download the Linux binary from the GitHub releases and place it on your PATH.
  • Visual Studio Code with the C# and Bicep extensions.

1. Create the Project

Open a terminal in your desired parent folder, then:
dotnet new web -n J4NI.BicepLocalExtension
cd J4NI.BicepLocalExtension
dotnet restore
That creates you an empty ASP.NET Core project.

2. Configure the Project File

Create or replace J4NI.BicepLocalExtension.csproj with:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <AssemblyName>j4ni</AssemblyName>
    <RootNamespace>J4NI</RootNamespace>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
    <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Azure.Bicep.Local.Extension" Version="0.37.4" />
  </ItemGroup>
</Project>
This is C# project configuration, where the main point is that we define the assembly name and namespace, and define that we want to publish this as a single file.

3. Add Your Code Files

Program.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Bicep.Local.Extension.Host.Extensions;
using J4NI.BicepLocalExtension.Handlers;

var builder = WebApplication.CreateBuilder(args);

// Register the Bicep extension host
builder.AddBicepExtensionHost(args);

// Configure and add your local extension
builder.Services
    .AddBicepExtension(
        name: "j4ni",
        version: "0.1.0",
        isSingleton: true,
        typeAssembly: typeof(Program).Assembly)
    .WithResourceHandler<PublicIpHandler>();

var app = builder.Build();

// Map the extension endpoints
app.MapBicepExtension();

await app.RunAsync();

What’s happening in this section is that we register the middleware and services required to expose a Bicep “local deploy” endpoint. Then we declare our extension to the host. Then we set some parameters, like name is the identifier used in Bicep files, version lets you publish updates without cache conflicts, isSingleton sets that single handler instance serves all requests and typeAssembly points the host at your compiled DLL so it can discover resource types and handlers via reflection.

WithResourceHandler<PublicIpHandler>() tells the host: “When a Bicep file declares a resource of type PublicIp, route it to this handler class.”

var app = builder.Build(); app.MapBicepExtension(); finalizes the HTTP pipeline and maps a single POST endpoint (typically /) that the Bicep CLI invokes under the covers of bicep local-deploy.

Final step await app.RunAsync(); starts the Kestrel web server—your extension is now listening for Bicep to call it, perform resource operations, and return outputs.

Models.cs

using Azure.Bicep.Types.Concrete;
using Bicep.Local.Extension.Types.Attributes;

namespace J4NI.BicepLocalExtension
{
    public class PublicIpIdentifiers
    {
        [TypeProperty("The resource name", ObjectTypePropertyFlags.Identifier | ObjectTypePropertyFlags.Required)]
        public required string Name { get; set; }
    }

    [ResourceType("PublicIp")]
    public class PublicIp : PublicIpIdentifiers
    {
        [TypeProperty("Your machine’s public IP address")]
        public string? Output { get; set; }
    }
}

Models.cs defines the schema and metadata that let Bicep and your handler speak the same language—linking the declarative .bicep file to your imperative C# code.

Handlers/PublicIpHandler.cs

using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Bicep.Types.Concrete;                    // for ResourceResponse
using Bicep.Local.Extension.Host.Handlers;           // for TypedResourceHandler<T,U>, ResourceRequest
using Bicep.Local.Extension.Types.Attributes;        // for [ResourceType], [TypeProperty]
using J4NI.BicepLocalExtension;                      // for PublicIp, PublicIpIdentifiers

namespace J4NI.BicepLocalExtension.Handlers
{
    public class PublicIpHandler : TypedResourceHandler<PublicIp, PublicIpIdentifiers>
    {
        private static readonly HttpClient httpClient = new();

        // 1. Preview is optional but recommended
        protected override async Task<ResourceResponse> Preview(
            ResourceRequest request,
            CancellationToken cancellationToken)
        {
            await Task.CompletedTask;
            return GetResponse(request);
        }

        // 2. CreateOrUpdate must be overridden
        protected override async Task<ResourceResponse> CreateOrUpdate(
            ResourceRequest request,
            CancellationToken cancellationToken)
        {
            var ip = await httpClient.GetStringAsync("https://api.ipify.org", cancellationToken);
            request.Properties.Output = ip.Trim();
            return GetResponse(request);
        }

        // 3. Map your identifiers
        protected override PublicIpIdentifiers GetIdentifiers(PublicIp properties) =>
            new() { Name = properties.Name };
    }
}
Here we do the actual hard work, and get the public IP of your computer.

4. Publish Native Binaries

From your project root, run:
# macOS Apple Silicon
dotnet publish -c Release -r osx-arm64 --self-contained true /p:PublishSingleFile=true

# Linux x64
dotnet publish -c Release -r linux-x64 --self-contained true /p:PublishSingleFile=true

# Windows x64
dotnet publish -c Release -r win-x64   --self-contained true /p:PublishSingleFile=true
You can create all this packages, even you are not running those, so you can deploy to your build machines or team member machines as well.

5. Pack the Extension

Now run bicep publish-extension to bundle all RIDs into ./bin/j4ni:
bicep publish-extension \
  --bin-osx-arm64   ./bin/Release/osx-arm64/publish/j4ni \
  --bin-linux-x64   ./bin/Release/linux-x64/publish/j4ni \
  --bin-win-x64     ./bin/Release/win-x64/publish/j4ni.exe \
  --target          ./bin/j4ni \
  --force

6. Configure Bicep for Local Deploy

Create a bicepconfig.json next to your Bicep template:
{
  "experimentalFeaturesEnabled": {
    "localDeploy": true
  },
  "extensions": {
    "j4ni": "./bin/j4ni"
  }
}

7. Write & Run Your Bicep Template

main.bicep

targetScope = 'local'

extension j4ni 

resource pubIp 'PublicIp' = {
  name: 'demo'
}

output publicIp string = pubIp.output
Create main.bicepparam containing:
using 'main.bicep'
Finally, deploy locally:
bicep local-deploy main.bicepparam
You’ll see output similar to:
Output publicIp: 203.0.113.42
Resource pubIp (CreateOrUpdate): Succeeded
Result: Succeeded
We now have a working local extension that fetches your public IP without any scripting and you can use this with Bicep CLI anywhere! You could do your own for example setting database schema, extend the authorisation with Entra, or for example creating values for key vaults, all those things which were not possible with Bicep before! Hope you found this helpful!
Posted in Azure, Bicep | Tagged , , | 1 Comment

Bicep Tips: Using Graphs extension

Azure Bicep now includes support for Microsoft Graph resources via the Graph extension. This enables you to manage Entra ID—including users, groups, and applications—directly in Bicep templates alongside Azure resources. In this post, we’ll explore three scenarios:

  • Reading existing users
  • Reading existing groups
  • Creating a new application

There is support for Applications, App role assignments, Federated identity credentials, Groups, OAuth2 permission grants (delegated permission grants) and Service principals at the moment.

1. Reading Existing Users

You can reference existing users using the existing keyword and the userPrincipalName as the client-provided key.


extension 'br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.9-preview'

param userPrincipalName string

resource existingUser 'Microsoft.Graph/users@v1.0' existing = {
  userPrincipalName: userPrincipalName
}

output userId string = existingUser.id
output userDisplayName string = existingUser.displayName

This reads the user’s ID and displayName from Entra ID via Graph.

2. Reading Existing Groups

Similarly, you can reference existing groups using userPrincipalName or uniqueName. Ensure your group has its uniqueName client key set if using that identifier.


extension 'br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.9-preview'

resource existingGroup 'Microsoft.Graph/groups@v1.0' existing = {
  uniqueName: 'Finance-Team-Group'
}

output groupId string = existingGroup.id
output groupDisplayName string = existingGroup.displayName

3. Creating a Microsoft Graph Application

To create an application (app registration) via Graph:


extension 'br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.9-preview'

resource myApp 'Microsoft.Graph/applications@v1.0' = {
  displayName: 'MyBicepApp'
  uniqueName: 'my-bicep-app'
  identifierUris: [
    'api://my-bicep-app'
  ]
  signInAudience: 'AzureADMyOrg'
}

output appId string = myApp.appId
output appObjectId string = myApp.id

This deploys a new Entra ID application and outputs both its appId (client ID) and id (object ID).

Quickstart Configuration

Now you don't need to the whole configuration file dance, what used to be when this extension was still in preview, now you just add reference to Graph in your file: extension 'br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.9-preview'

This enables IntelliSense, validation, and compilation with Graph-native types

If referencing existing Graph resources not created via Bicep, ensure they possess the necessary client-provided key (uniqueName) so Bicep can identify them

Posted in Azure, Bicep | Tagged , , | Leave a comment

Bicep Essentials: Outputs

Azure Bicep deployments become significantly more powerful when you use outputs effectively. Outputs provide valuable information about deployed resources, making automation and chaining of deployments seamless. Let’s dive into how you can leverage outputs effectively.

Why Outputs Matter in Bicep

Outputs enable you to:

  • Provide deployment information back to scripts or pipelines.
  • Chain deployments by feeding outputs from one deployment into another.
  • Capture resource IDs, IP addresses, secrets, and more for automation and integration purposes.

Basic Output Syntax

The basic syntax for outputs in Bicep is straightforward:


output storageAccountName string = storageAccount.name
output resourceLocation string = resourceGroup().location

Safely Outputting Secrets

When dealing with sensitive values, like keys or passwords, always mark your outputs as secure:


@secure()
output storageAccountKey string = listKeys(storageAccount.id, storageAccount.apiVersion).keys[0].value
@secure()
output adminPassword string = adminUser.password

Consuming Bicep Outputs in Automation Pipelines

Bicep outputs seamlessly integrate into Azure CLI, Azure PowerShell, and automation pipelines. Here’s how you access outputs using Azure CLI:


az deployment group create \
  --resource-group myResourceGroup \
  --template-file main.bicep \
  --parameters @params.json \
  --query properties.outputs.storageAccountName.value -o tsv

Real-world Examples

Here’s a practical scenario where outputs are indispensable:

  • Automation of firewall rules: After deploying a database, output the IP address and automatically update firewall rules.
  • DNS automation: Output the public IP of a web server and dynamically update DNS records.

Example Bicep output for an IP address:


output publicIPAddress string = publicIP.properties.ipAddress

Conditional outputs

You can also conditionally output values, so only if you need to get a value out, it is returned. In this example includeSecrets bool is used to evaluate, if the SAS token is returned to caller.


targetScope = 'resourceGroup'

param includeSecrets bool = true
param storageAccountName string = 'mystorageaccount'
param location string = resourceGroup().location
param date string = utcNow()

resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    accessTier: 'Hot'
  }
}

var sasToken = storage.listAccountSas(storage.apiVersion,{
  signedServices: 'b' // blob service
  signedResourceTypes: 'co' // container and object
  signedPermission: 'r' // read
  signedProtocol: 'https'
  signedExpiry: dateTimeAdd(date, 'P1D')
})

@secure()
output sasTokenOrEmpty string = includeSecrets ? sasToken.accountSasToken : ''

Best Practices with Outputs

  • Consistency: Clearly name outputs to reflect the resource or value.
  • Documentation: Provide comments and document your outputs clearly.
  • Sensitivity: Use secure handling methods downstream for any sensitive data.

Effectively using outputs transforms your deployments into robust, automated, and informative workflows, enhancing your overall cloud operations efficiency.

Posted in Azure, Bicep | Tagged , , | Leave a comment

Azure Tech Tip: Find VM by features and size easily

When searching for the right Azure VM size, the documentation can feel like a maze. It’s frustrating to endlessly scroll through size tables just to find “4 cores, 8 GB RAM, ephemeral disk support, and accelerated networking in West Europe.” Sound familiar?

To save yourself from this common struggle, here’s a PowerShell Core script that filters Azure VM SKUs using flexible criteria—no need to open browser tabs or squint at feature matrices.

Introducing Get-AvailableVMs.ps1

The script is available in the pws-helpers GitHub repository and is maintained under:

scripts/Get-AvailableVMs.ps1

What It Does

The script lets you search VM sizes in a specific Azure region by specifying exact or minimum requirements. It queries the Azure CLI’s VM SKU metadata, parses capabilities, and displays filtered results based on:

  • vCPU count
  • Memory (in GB)
  • Disk IOPS
  • NIC count
  • Accelerated networking support
  • Ephemeral OS disk capability
  • Premium disk support
  • Capacity reservation support
  • VM family (e.g., D, E, NV)
  • Availability for deployment in your subscription

Usage Examples

Find all DS-series VMs with 4 cores and ephemeral disk support in West Europe:


.\Get-AvailableVMs.ps1 -Region "westeurope" -Cores 4 -EphemeralOSDisk true -Family "DS"

Find the latest versions only (e.g., only DSv5, not DSv4):


.\Get-AvailableVMs.ps1 -Region "westeurope" -Family "DS" -Latest

Search for all available VMs with accelerated networking and PremiumIO:


.\Get-AvailableVMs.ps1 -Region "westeurope" -AcceleratedNetworking true -PremiumIO true -Available true

The script:


param (
    [string]$Region = "westeurope",
    [string]$Mode = "exact",
    [int]$Cores,
    [int]$Memory,
    [int]$IOPS,
    [int]$NICs,
    $AcceleratedNetworking,
    $EphemeralOSDisk,
    $PremiumIO,
    $CapacityReservation,
    $Available,
    [string]$Family,
    [switch]$Latest

)

# Normalize and validate parameters
$Region = $Region.ToLower()
$Mode = $Mode.ToLower()

# Convert boolean-like parameters to actual booleans
function ConvertTo-Boolean($value) {
    if ($null -eq $value) { return $null }
    if ($value -is [bool]) { return $value }
    if ($value -is [int]) { return [bool]$value }
    if ($value -is [string]) {
        switch ($value.ToLower()) {
            "true" { return $true }
            "false" { return $false }
            "1" { return $true }
            "0" { return $false }
            "`$true" { return $true }
            "`$false" { return $false }
            default { 
                Write-Error "Invalid boolean value: '$value'. Use true/false, 1/0, or `$true/`$false"
                exit 1
            }
        }
    }
    Write-Error "Cannot convert value '$value' to boolean"
    exit 1
}

# Convert boolean parameters
if ($PSBoundParameters.ContainsKey("AcceleratedNetworking")) {
    $AcceleratedNetworking = ConvertTo-Boolean $AcceleratedNetworking
}
if ($PSBoundParameters.ContainsKey("EphemeralOSDisk")) {
    $EphemeralOSDisk = ConvertTo-Boolean $EphemeralOSDisk
}
if ($PSBoundParameters.ContainsKey("PremiumIO")) {
    $PremiumIO = ConvertTo-Boolean $PremiumIO
}
if ($PSBoundParameters.ContainsKey("CapacityReservation")) {
    $CapacityReservation = ConvertTo-Boolean $CapacityReservation
}
if ($PSBoundParameters.ContainsKey("Available")) {
    $Available = ConvertTo-Boolean $Available
}

# Validate Mode parameter
if ($Mode -notin @("min", "exact")) {
    Write-Error "Mode must be 'min' or 'exact' (case-insensitive). Provided value: '$Mode'"
    exit 1
}

# Check Azure CLI
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
    Write-Error "Azure CLI (az) not found. Please install and log in using 'az login'."
    exit 1
}

if (-not $PSBoundParameters.ContainsKey("Cores") -and -not $PSBoundParameters.ContainsKey("Memory") -and
    -not $PSBoundParameters.ContainsKey("IOPS") -and -not $PSBoundParameters.ContainsKey("NICs") -and
    -not $PSBoundParameters.ContainsKey("AcceleratedNetworking") -and
    -not $PSBoundParameters.ContainsKey("EphemeralOSDisk") -and
    -not $PSBoundParameters.ContainsKey("PremiumIO") -and
    -not $PSBoundParameters.ContainsKey("CapacityReservation") -and
    -not $PSBoundParameters.ContainsKey("Available") -and
    -not $PSBoundParameters.ContainsKey("Family") -and
    -not $Latest) {
    Write-Error "At least one filter parameter must be provided."
    exit 1
}

Write-Output "Fetching VM SKUs for region '$Region'..."

# Fetch VM SKUs with better error handling for cross-platform compatibility
$rawSkusJson = az vm list-skus --location $Region --resource-type "virtualMachines" --output json
if ($LASTEXITCODE -ne 0) {
    Write-Error "Failed to fetch VM SKUs from Azure CLI. Please ensure you are logged in with 'az login'."
    exit 1
}

# Handle JSON conversion with better cross-platform support
try {
    $rawSkus = $rawSkusJson | ConvertFrom-Json -Depth 10
} catch {
    Write-Error "Failed to parse VM SKU data. Error: $($_.Exception.Message)"
    exit 1
}

# Process SKUs
$vmSkus = $rawSkus | Where-Object { 
    # Case-insensitive region comparison
    $_.locations | ForEach-Object { $_.ToLower() } | Where-Object { $_ -eq $Region }
} | ForEach-Object {
    $capabilities = $_.capabilities

    $skuCores     = ($capabilities | Where-Object { $_.name -eq "vCPUs" }).value
    $skuMemory    = ($capabilities | Where-Object { $_.name -eq "MemoryGB" }).value
    $skuIOPS      = ($capabilities | Where-Object { $_.name -eq "UncachedDiskIOPS" }).value
    $accelNet     = ($capabilities | Where-Object { $_.name -eq "AcceleratedNetworkingEnabled" }).value
    $ephemeral    = ($capabilities | Where-Object { $_.name -eq "EphemeralOSDiskSupported" }).value
    $premiumIOCap = ($capabilities | Where-Object { $_.name -eq "PremiumIO" })
    $premiumIOValue = if ($premiumIOCap) { $premiumIOCap.value } else { $null }
    $reservationCap = ($capabilities | Where-Object { $_.name -eq "CapacityReservationSupported" })
    $reservationValue = if ($reservationCap) { $reservationCap.value } else { $null }
    $maxNics      = ($capabilities | Where-Object { $_.name -eq "MaxNetworkInterfaces" }).value

    # Check if VM is available for deployment (no location restrictions) - case-insensitive
    $isAvailable = -not ($_.restrictions | Where-Object { 
        $_.type -eq "Location" -and 
        ($_.restrictionInfo.locations | ForEach-Object { $_.ToLower() } | Where-Object { $_ -eq $Region }) -and
        ($_.reasonCode -eq "NotAvailableForSubscription" -or $_.reasonCode -eq "NotAvailableForRegion")
    })

    [PSCustomObject]@{
        Name                   = $_.name
        Size                   = $_.size
        Cores                  = [int]$skuCores
        Memory                 = [double]$skuMemory
        IOPS                   = if ($skuIOPS) { [int]$skuIOPS } else { $null }
        AcceleratedNetworking  = if (![string]::IsNullOrWhiteSpace($accelNet)) { [bool]::Parse($accelNet) } else { $false }
        EphemeralOSDisk        = if (![string]::IsNullOrWhiteSpace($ephemeral)) { [bool]::Parse($ephemeral) } else { $false }
        PremiumIO              = if (![string]::IsNullOrWhiteSpace($premiumIOValue)) { [bool]::Parse($premiumIOValue) } else { $false }
        CapacityReservation    = if (![string]::IsNullOrWhiteSpace($reservationValue)) { [bool]::Parse($reservationValue) } else { $false }
        MaxNICs                = if ($maxNics) { [int]$maxNics } else { $null }
        Available              = $isAvailable
        Family                 = $_.family
    }

}

$filtered = $vmSkus | Where-Object {
    # If only -Latest is specified, include all VMs (no filtering)
    if ($Latest -and -not $PSBoundParameters.ContainsKey("Cores") -and -not $PSBoundParameters.ContainsKey("Memory") -and
        -not $PSBoundParameters.ContainsKey("IOPS") -and -not $PSBoundParameters.ContainsKey("NICs") -and
        -not $PSBoundParameters.ContainsKey("AcceleratedNetworking") -and
        -not $PSBoundParameters.ContainsKey("EphemeralOSDisk") -and
        -not $PSBoundParameters.ContainsKey("PremiumIO") -and
        -not $PSBoundParameters.ContainsKey("CapacityReservation") -and
        -not $PSBoundParameters.ContainsKey("Available") -and
        -not $PSBoundParameters.ContainsKey("Family")) {
        return $true
    }

    $match = $true

    if ($Mode -eq "exact") {
        if ($PSBoundParameters.ContainsKey("Cores")) {
            $match = $match -and ($_.Cores -eq $Cores)
        }
        if ($PSBoundParameters.ContainsKey("Memory")) {
            $match = $match -and ($_.Memory -eq $Memory)
        }
        if ($PSBoundParameters.ContainsKey("IOPS")) {
            $match = $match -and ($_.IOPS -eq $IOPS)
        }
        if ($PSBoundParameters.ContainsKey("NICs")) {
            $match = $match -and ($_.MaxNICs -eq $NICs)
        }
    } else {
        if ($PSBoundParameters.ContainsKey("Cores")) {
            $match = $match -and ($_.Cores -ge $Cores)
        }
        if ($PSBoundParameters.ContainsKey("Memory")) {
            $match = $match -and ($_.Memory -ge $Memory)
        }
        if ($PSBoundParameters.ContainsKey("IOPS")) {
            $match = $match -and ($_.IOPS -ge $IOPS)
        }
        if ($PSBoundParameters.ContainsKey("NICs")) {
            $match = $match -and ($_.MaxNICs -ge $NICs)
        }
    }

    if ($PSBoundParameters.ContainsKey("AcceleratedNetworking")) {
        $match = $match -and ($_.AcceleratedNetworking -eq $AcceleratedNetworking)
    }
    if ($PSBoundParameters.ContainsKey("EphemeralOSDisk")) {
        $match = $match -and ($_.EphemeralOSDisk -eq $EphemeralOSDisk)
    }
    if ($PSBoundParameters.ContainsKey("PremiumIO")) {
        $match = $match -and ($_.PremiumIO -eq $PremiumIO)
    }
    if ($PSBoundParameters.ContainsKey("CapacityReservation")) {
        $match = $match -and ($_.CapacityReservation -eq $CapacityReservation)
    }
    if ($PSBoundParameters.ContainsKey("Available")) {
        $match = $match -and ($_.Available -eq $Available)
    }
    if ($PSBoundParameters.ContainsKey("Family")) {
        $vmFamilyPrefix = ($_.Size -replace '^([A-Za-z]{1,2}).*', '$1')

        $match = $match -and ($vmFamilyPrefix -eq $Family)
    }

    return $match
}

# Filter for latest versions if requested
if ($Latest) {
    
    $filtered = $filtered | Group-Object { 
        # Extract base family name by removing version and promo suffixes
        $name = $_.Name -replace '^Standard_', ''
        $baseName = $name -replace '_v\d+.*$', '' -replace '_Promo$', ''
        return $baseName
    } | ForEach-Object {
        $familyVMs = $_.Group
        
        # Group by version number
        $versionGroups = $familyVMs | Group-Object {
            $name = $_.Name -replace '^Standard_', ''
            if ($name -match '_v(\d+)') {
                [int]$matches[1]
            } else {
                1  # No version suffix means version 1
            }
        }
        
        # Get the highest version number and return all VMs with that version
        $maxVersion = ($versionGroups | Measure-Object Name -Maximum).Maximum
        $selectedVMs = ($versionGroups | Where-Object { [int]$_.Name -eq $maxVersion }).Group
        
        return $selectedVMs
    }
}

$sorted = $filtered | Sort-Object Memory, Cores

if ($sorted.Count -eq 0) {
    Write-Output "No VM sizes match the criteria."
} else {
    # Count available VMs vs total filtered VMs
    $availableCount = ($sorted | Where-Object { $_.Available -eq $true }).Count
    $totalCount = $sorted.Count
    Write-Output "Available $availableCount/$totalCount VM sizes matching criteria in $Region"
    Write-Output ""
    
    if ($IsWindows) {
        $sorted | Out-GridView -Title "Matching Azure VM Sizes in $Region (Available: $availableCount/$totalCount)"
    } else {
        $sorted | Select-Object Name,
            @{Name="MainFamily"; Expression={($_.Name -replace '^Standard_', '') -replace '^([A-Za-z]{1,2}).*', '$1'}},
            Cores,
            @{Name="MemoryGB"; Expression = { ($_.Memory.ToString("0.###")) }},
            IOPS, AcceleratedNetworking, EphemeralOSDisk, PremiumIO, CapacityReservation, Available, MaxNICs, Family |
        Format-Table -AutoSize
    }
}

Why This Helps

  • Saves time compared to manually browsing Azure SKU documentation
  • Supports scripting, automation, and repeatable queries
  • Compatible with Windows, macOS, and Linux using PowerShell Core
  • Respects availability for your current subscription (some SKUs are restricted)

Tips

  • Use -Mode exact (default) for precise matches, or -Mode min for >= filters
  • Use the script in DevOps pipelines to validate SKU availability before deployment
Posted in Azure | Tagged , , , | Leave a comment

Bicep Tip: onlyIfNotExists to conditionally deploy only if resource is not there yet

Azure Bicep finally got this long awaited feature. with experimental features—@onlyIfNotExists()— this introduces a clean way to skip deploying a resource if it already exists.

Whether you’re building reusable templates or managing large-scale, layered environments, this decorator makes deployments smarter and safer.

What Is @onlyIfNotExists?

The @onlyIfNotExists() decorator tells the Bicep engine to check if the resource already exists at the specified scope before deploying it. If the resource is found, the declaration is skipped silently.

This avoids failures due to duplicate deployments and makes your Bicep files idempotent without writing extra logic or complex conditions.

This feature requires the onlyIfNotExists experimental flag to be enabled. To use it, configure your Bicep CLI:


bicep config set experimentalFeaturesEnabled=true 
bicep config set experimentalFeature.onlyIfNotExists=true 

Basic Usage Example

Here’s a minimal Bicep resource with the decorator:


@onlyIfNotExists()
resource onlyDeployIfNotExists 'Microsoft.Resources/resourceGroups@2021-04-01' = { 
 name: 'example-rg' 
 location: 'westeurope' 
} 

In this example, the example-rg resource group will only be created if it doesn’t already exist in the current subscription.

Use Cases

Scenario Why It’s Useful
Reusable Bicep modules Ensure base resources are created once without wrapping in conditions.
Multi-region or layered deployments Avoids errors when running the same deployment in different regions.
Dev/test environments Allow safe re-use of templates even if previous runs partially succeeded.

Limitations & Considerations

  • This is currently an experimental feature and may change.
  • You must enable it in your local or pipeline Bicep configuration.
  • It’s not a replacement for if conditions when logic branching is needed.

How to Enable in CI/CD

If you’re using this in a pipeline, ensure the Bicep CLI version is recent (v0.22.1 or later), and pass this via environment variables or local configuration file:


bicepconfig.json: 
{ 
 "experimentalFeaturesEnabled": true, 
 "experimentalFeature.onlyIfNotExists": true 
} 

Or via CLI for a single deployment:

bicep build --config-file bicepconfig.json 

Conclusion

The @onlyIfNotExists() decorator is a great addition to Bicep’s toolset for graceful, idempotent deployments. It allows you to avoid errors and simplify logic when re-deploying resources that should only be created once.

If you’re building shared modules or orchestrating infrastructure that gets reused across environments, this feature can dramatically reduce noise and improve safety.

Posted in Azure | Tagged , | Leave a comment

Bicep Essentials: Troubleshooting and debugging

Even seasoned Azure engineers hit deployment failures when working with Bicep. Errors can stem from syntax issues, missing permissions, or misaligned resource dependencies. This post covers practical techniques to trace errors, export templates for comparison, use the Azure CLI’s --debug flags, and enable diagnostic settings to gain visibility into what’s going wrong.

1. ARM Template Export for Comparison

Bicep compiles down to an ARM template before deployment. Exporting that template lets you compare the intended state against what was deployed or preview changes.


# Export the deployed ARM template for the last deployment
az deployment group export \
  --resource-group my-rg \
  --name my-deployment-name \
  --output json > deployed-template.json

# Decompile a Bicep file back to ARM JSON for diffing
az bicep decompile \
  --file main.bicep \
  --stdout > compiled-template.json

# Compare the two templates with a diff tool
diff compiled-template.json deployed-template.json

By diffing the ARM JSON, you can spot unexpected resource definitions or property mismatches introduced by Bicep.

2. Use Azure CLI --debug and PowerShell -Debug Flags

The Azure CLI and Azure PowerShell both support verbose and debug output. This logs HTTP requests and responses, authorization details, and internal errors.


# Azure CLI with debug
az deployment group create \
  --resource-group my-rg \
  --template-file main.bicep \
  --parameters @params.bicepparam \
  --debug

# Azure PowerShell with Debug
New-AzResourceGroupDeployment `
  -ResourceGroupName my-rg `
  -TemplateFile main.bicep `
  -TemplateParameterFile params.bicepparam `
  -Debug

Inspect the logged API calls and error stacks to pinpoint permission issues or schema mismatches.

3. Enable Deployment Diagnostic Settings

Azure Monitor can capture deployment operations and failures at the subscription or resource group level. Configure diagnostic settings on the “Microsoft.Resources/deployments” resource type to forward logs to a storage account or Log Analytics.


resource diag 'Microsoft.Insights/diagnosticSettings@2017-05-01-preview' = {
  name: 'deployDiag'
  scope: resourceGroup() // or subscription()
  properties: {
    workspaceId: '/subscriptions/xxxx/resourceGroups/mon-rg/providers/Microsoft.OperationalInsights/workspaces/mon-law'
    logs: [
      {
        category: 'Administrative'
        enabled: true
        retentionPolicy: {
          days: 7
          enabled: false
        }
      }
      {
        category: 'Operation'
        enabled: true
        retentionPolicy: {
          days: 7
          enabled: false
        }
      }
    ]
  }
}

Once enabled, you can query your Log Analytics workspace:


AzureDiagnostics
| where Category == 'Admin' and ResourceProvider == 'Microsoft.Resources/deployments'
| sort by TimeGenerated desc

This surfaces deployment failures, start times, and detailed status codes.

4. Common Error Patterns & Fixes

  • Dependency Not Found: Make sure to use dependsOn or parent when one resource relies on another.
  • Permission Denied: Verify your deployment principal has the right RBAC roles on the target scope.
  • Invalid API Version: Check the resource type’s API version against the ARM reference or use the az provider show command to list supported versions.
  • Parameter Validation: Ensure your parameter values match expected types (string, int, secure) and required parameters are provided.

5. Inspect Bicep Linter and VS Code Integration

Use the Bicep VS Code extension to catch syntax errors before deployment. It highlights missing properties, invalid types, and unsupported decorators in real time.

Wrapping Up

Troubleshooting Bicep deployments becomes straightforward once you combine template exports, CLI debug logs, and diagnostic settings. Lean on these tools to isolate failures, validate your generated ARM templates, and ensure your deployment principal has the necessary permissions.

Posted in Azure, Bicep | Tagged | Leave a comment

Bicep Tip: URI functions and Availability Zone Mapping

Yet again, we got some nice tools while roaming in the land of Bicep! Two notable additions are:

  • parseUri() and buildUri() for working with URIs
  • toLogicalZone()/toPhysicalZone() and their array variants for Availability Zone mapping

1. URI functions: parseUri() & buildUri()

These functions let you break down and reassemble URIs directly in Bicep:

output parsedUri object = parseUri('https://example.com:1234/foo/bar')

// returns { scheme: 'https', host: 'example.com', port: 1234, path: '/foo/bar' }

output builtUri string = buildUri({
  scheme: 'https'
  host: 'example.com'
  port: 1234
  path: 'foo/bar'
})
// returns 'https://example.com:1234/foo/bar'

Real-world applications

  • Dynamic backend addressing: In multi-tiered app deployments, you can parse an API endpoint URI, modify the port or path, and then build a new URI for use in an Azure Function or API Management backend.
  • Constructing ARM template IDs or webhook targets: Build URIs for Key Vault webhooks, event subscriptions, or external callbacks dynamically based on deployment context.

2. Availability Zone mapping: toPhysicalZone(), toLogicalZone() & arrays

Bicep now supports mapping between “logical” zone numbers (1,2,3) and provider-specific physical zone identifiers in a subscription:

var subscriptionId = subscription().subscriptionId
var location = 'westus2'

// physical zone for logical '1'
output singlePhysicalZone string = toPhysicalZone(subscriptionId, location, '1')

// array version
output physicalZones string[] = toPhysicalZones(subscriptionId, location, ['1','2'])

// reverse mapping
output singleLogicalZone string = toLogicalZone(subscriptionId, location, 'westus2-az2')

// array reverse mapping
output logicalZones string[] = toLogicalZones(subscriptionId, location, ['westus2-az1','westus2-az2'])

Real-world applications

  • Consistent zone assignment across deployments: Standardize on logical zone “1”, letting Bicep determine the actual physical zone (‘westus2-az2’) for VMs or load balancers. This avoids hardcoding physical zone names and supports subscription portability.
  • Multi-zone clusters & Availability Zone spreads: In AKS, virtual machine scale sets, or database setups, you can convert an array of logical zones to physical ones for zone-aware subnets or node pools.

Incorporating these functions in your deployments improves maintainability, portability, and zone-aware design:

URI helpers replace brittle string operations with structured parsing and assembly.
AZ mapping ensures that logical constraints translate to correct physical infrastructure based on subscription-level mapping.

Together, these features help make Bicep templates more robust, adaptive, and easier to manage in complex environments.

Posted in Azure, Bicep | Tagged , , | Leave a comment

Azure Tech Tip: Find what access rights Entra Id Group Has In Azure

Managing access in large Azure environments often involves group-based RBAC (Role-Based Access Control) assignments. While this promotes cleaner and more scalable access control, it can become challenging to understand exactly where a specific Azure AD group has been granted rights across management groups and subscriptions.

The Get-GroupRbacRightsRecursively.ps1 script, available in the pws-helpers PowerShell module collection, addresses this problem by recursively searching through the entire management group hierarchy to find all role assignments for a given RBAC group.

How It Works

Given the name of an Azure AD group and the root management group ID, this script performs the following:

  • Logs into Azure CLI if needed.
  • Fetches the object ID for the Azure AD group by name.
  • Checks the role assignments at the root management group level.
  • Iterates through all management groups and their associated subscriptions to find all role assignments for the group.
  • Displays a summary of all found assignments in a table or grid view.

Script Breakdown

param(
    [string]$GroupName,
    [string]$RootId
)

(az account show --query id 2>&1) -match "az login" -and (az login) | Out-Null

$groupId = az ad group show --group $GroupName --query id --output tsv

if (-not $groupId) {
    Write-Host "The group '$GroupName' could not be found." -ForegroundColor Red
    exit 1
}

Write-Host "Fetching role assignments for the group '$GroupName' (Object ID: $groupId)..."
$allRoleAssignments = @()

if (-not $RootId) {
    Write-Host "Root management group ID was not provided." -ForegroundColor Red
} else {
    Write-Host "Checking role assignments for root management group: $RootId"
    try {
        $rootMgmtGroupRoleAssignments = az role assignment list --scope $RootId --query "[?principalId=='$groupId']" --output json 2>&1 | ConvertFrom-Json
        if (-not $rootMgmtGroupRoleAssignments) {
            Write-Host "No subscriptions on root level." -ForegroundColor Yellow
        } else {
            $allRoleAssignments += $rootMgmtGroupRoleAssignments
        }
    }
    catch {
        Write-Host "No subscriptions on root level." -ForegroundColor Yellow
    }
}

$managementGroups = az account management-group list --query '[].{name:name}' -o tsv

foreach ($mg in $managementGroups) {
    Write-Host "Fetching role assignments for management group '$mg'..."
    $mgmtGroupScope = "/providers/Microsoft.Management/managementGroups/$mg"
    $mgmtGroupRoleAssignments = az role assignment list --scope $mgmtGroupScope --query "[?principalId=='$groupId']" --output json | ConvertFrom-Json
    $allRoleAssignments += $mgmtGroupRoleAssignments

    $subscriptions = az account management-group subscription show-sub-under-mg --name $mg --query '[].{id:id}' -o tsv

    foreach ($subscriptionPath in $subscriptions) {
        $subscriptionId = $subscriptionPath -replace '.*/subscriptions/([^/]+)', '$1'
        Write-Host "Fetching role assignments for subscription '$subscriptionId'..."
        $subscriptionScope = "/subscriptions/$subscriptionId"

        try {
            $subscriptionRoleAssignments = az role assignment list --scope $subscriptionScope --query "[?principalId=='$groupId']" --output json | ConvertFrom-Json
            $allRoleAssignments += $subscriptionRoleAssignments
        }
        catch {
            Write-Host "Error fetching role assignments for subscription '$subscriptionId'. Continuing to the next subscription..." -ForegroundColor Yellow
        }
    }
}

if ($allRoleAssignments.Count -eq 0) {
    Write-Host "No role assignments found for the group '$GroupName' in the root management group, other management groups, or subscriptions." -ForegroundColor Yellow
} else {
    $filteredAssignments = $allRoleAssignments | Select-Object principalId, principalName, roleDefinitionId, roleDefinitionName, scope
    if ($IsWindows) {
        $filteredAssignments | Out-GridView -Title "Role Assignments for Group: $GroupName"
    } else {
        $filteredAssignments | Format-Table -AutoSize
    }
}

Why This Script Matters

In larger Azure environments with nested management groups and a multitude of subscriptions, visibility into what a specific group can access becomes a core part of access reviews, compliance, and zero trust auditing. Relying solely on portal views can be tedious and error-prone when you need to assess access rights recursively.

This script delivers a repeatable and efficient solution for:

  • Auditing access of Azure AD groups across all scopes.
  • Improving governance by identifying excessive or legacy permissions.
  • Automating compliance checks during tenant-wide security assessments.

You can find the script here:
Get-GroupRbacRightsRecursively.ps1 on GitHub

It is part of the pws-helpers module—a growing set of utilities designed to simplify Azure development and operations using PowerShell and Azure CLI.


Whether you’re conducting access reviews, implementing least-privilege principles, or simply trying to understand where a group has permissions, Get-GroupRbacRightsRecursively.ps1 provides the clarity and automation you need. Used in conjunction with other tools from the pws-helpers module, it can streamline governance and improve transparency across your Azure estate.

Posted in Azure | Tagged , , , , | Leave a comment

Azure Tech Tip: Get Azure Service Tag IP ranges

Managing network security in Azure often requires keeping your firewall or access control lists up to date with Microsoft’s published IP ranges. Rather than manually retrieving and parsing JSON each time, you can automate the process via a concise PowerShell snippet. In this post, I’ll show you how to fetch the ServiceBus service tag IP prefixes for the West Europe region and incorporate them into your infrastructure workflows.

Why Azure Service Tags Matter

Azure Service Tags are named identifiers for groups of IP address prefixes used by Azure services. Instead of specifying individual IPs, you can refer to a tag—like ServiceBus.WestEurope in your network security rules. This ensures your rules automatically stay current when Microsoft updates its backend IP ranges.

Benefits of using Service Tags

  • Simplicity, no need to track dozens of evolving IP prefixes.
  • Reliability, tags are maintained by Microsoft and updated in real time.
  • Maintainability, network rules remain clear and concise.
  • Here’s the one-liner that does the heavy lifting (Thanks to Majid Maskati for the tip):

    ((az network list-service-tags --location westeurope | ConvertFrom-Json).values |
    Where-Object { $_.id -eq 'ServiceBus.WestEurope' }
    ).properties.addressPrefixes

    Let’s break it down:

    Step Command / Property Description
    1 az network list-service-tags --location westeurope Retrieves a JSON document of all service tags for West Europe.
    2 | ConvertFrom-Json Converts the raw JSON into PowerShell objects.
    3 .values Selects the array of service-tag entries.
    4 Where-Object { $_.id -eq 'ServiceBus.WestEurope' } Filters for the Service Bus tag in West Europe.
    5 .properties.addressPrefixes Extracts the list of IP prefixes.

    Running the Script

    Open PowerShell (Windows, macOS, or Linux).
    Authenticate if you haven’t already:
    az login
    Copy and paste the one-liner.
    Review the output—a list of CIDR blocks such as 13.64.0.0/18, 40.67.0.0/17, etc.
    13.64.0.0/18
    40.67.0.0/17

    If you are looking for a way to get any service tag’s IPs, here’s more refined version of the previous script:

    
    # Define the Azure location
    $location = "westeurope"
    
    # Fetch service tags from Azure
    Write-Output "Fetching service tags for location: $location..."
    $serviceTags = az network list-service-tags --location $location | ConvertFrom-Json
    
    if ($null -eq $serviceTags) {
        throw "Failed to retrieve service tags for location: $location"
    }
    
    # Extract available tags into a sorted list
    $availableTags = $serviceTags.values | 
        Select-Object -ExpandProperty id | 
        Sort-Object
    
    # Interactive selection using Out-GridView
    $selectedTag = $availableTags | Out-GridView -Title "Select a Service Tag" -PassThru
    
    if (!$selectedTag) {
        Write-Warning "No service tag selected."
        exit
    }
    
    # Get the selected service tag's details
    $tagDetails = $serviceTags.values | Where-Object { $_.id -eq $selectedTag }
    
    # Output the address prefixes neatly
    Write-Output "`nIP ranges for Service Tag: '$selectedTag' in location '$location':"
    $tagDetails.properties.addressPrefixes | ForEach-Object { Write-Output "- $_" }
    

    This lists all Service Tags in region, and you can select it and then it lists IPs related to it. This above works only in Windows due Out-GridView, but it is easy to modify for other platforms as well.

    Parsing and Using the IPs

    Once you have the prefixes, you can for example enumerate firewall rules:

    $prefixes = ((az network list-service-tags --location westeurope |
    ConvertFrom-Json).values |
    Where-Object { $_.id -eq 'ServiceBus.WestEurope' }
    ).properties.addressPrefixes

    foreach ($cidr in $prefixes) {
    az network firewall network-rule create `
    --resource-group MyRg `
    --firewall-name MyFw `
    --collection-name ServiceBusRules `
    --name "SB-$cidr" `
    --protocols TCP `
    --source-addresses $cidr `
    --destination-ports 5671 5672
    }

    Integrate into IaC templates (ARM/Bicep/Terraform) by embedding the prefix list dynamically.

    Automating Updates

    For dynamic environments, consider scheduling a GitHub Actions or Azure DevOps pipeline to:

  • Run the snippet daily.
  • Serialize the results into a JSON or CSV in your repo.
  • Trigger an ARM template or Terraform apply if the list has changed.
  • This guarantees your network security always reflects Microsoft’s current backend IP ranges.

    Best Practices and Considerations

  • Cache results to avoid hitting API rate limits.
  • Validate new prefixes before applying (e.g., test in a staging firewall).
  • Monitor for deprecation of old prefixes.
  • Secure access to the automation pipeline—keep your Service Principal or managed identity credentials safe.
  • Posted in Azure | Tagged , | Leave a comment