Creating Desired State Configuration with Azure Bicep

Many times you’ll run into situation that you would want your installation package to install the app automatically when you deploy your VM to Azure. DSC is the right tool for that, and there’s plenty of samples with ARM templates how you can do that. When you turn to Bicep, you won’t find any complete samples, but you need to try to collect from crumbles of information the necessary pieces, and it will take some time to figure out how you can do that. As I went through that path, I thought it would be nice to put all that in one place for the next one who need to do the same thing.

Create the DSC configuration

First thing you need, is a valid DSC configuration. Let’s create one using 7zip installation package. Create a new file called MyDSC1.ps1 with the following content:

Configuration InstallApp
{
    Import-DscResource -ModuleName PsDesiredStateConfiguration

    Node 'localhost'
    {
        Package InstallApp
        {
            Ensure = 'Present'
            Name = '7-Zip 19.00 (x64 edition)'
            Path = 'https://www.7-zip.org/a/7z1900-x64.msi'
            ProductId = '23170F69-40C1-2702-1900-000001000000'
        }
    }
}

Things worth mentioning here are the name of the configuration, InstallApp which we need later, and ProductIdProductId is something you can get with PowerShell from installed apps on your computer, and the Name parameter needs to match also the name which is visible in Apps & Features -list of installed apps, so you can’t put anything random there. If you are not running MSI package but EXE file, you can leave the ProductId field empty.

Azure doesn’t like ps1 files, but wants to have a zip archive instead. The recommended way to create it is to use PowerShell command Publish-AzVMDscConfiguration which is part of the Az.Compute -PowerShell module. If you don’t have that installed, run (and select A as your option when it asks):

Install-Module -Name Az.Compute -AllowClobber

Now that the module is installed (in case it wasn’t already), you can then create the zip archive with:

Publish-AzVMDscConfiguration .\MyDSC1.ps1 -OutputArchivePath '.\MyDSC1.zip'

Now that you have a zip archive including a ps1-file, you need to head to Azure portal, and create a Storage Account. After you have created one, open the Storage Account, under ‘Data Storage’, select ‘Containers’ and create new Container called ‘installers’ and upload the MyDSC1.zip to that container. Next open the file, and create SAS token for it, and copy the https link to notepad for later use.

Create the Bicep for the VM

Next we dig down to Bicep, and see how you insert VM extension to VM script. Lets see how the script would look and then dissect it a bit. Here we have a VM and just behind it we define the extension:

resource vm 'Microsoft.Compute/virtualMachines@2021-03-01' = {
  name: vmName
...
}
resource extDSC 'Microsoft.Compute/virtualMachines/extensions@2021-03-01' = {
  parent: vm
  name: 'Microsoft.Powershell.DSC'
  location: 'westeurope'
  properties: {
    autoUpgradeMinorVersion: true
    publisher: 'Microsoft.Powershell'
    type: 'DSC'
    typeHandlerVersion: '2.83'
    settings: {
      ModulesUrl: '<YOUR SAS TOKEN HERE, eg- https://mydsctest2021.blob.${environment().suffixes.storage}/installers/MyDSC1.zip?sp...'
      ConfigurationFunction: 'MyDSC1.ps1\\InstallApp'
      WmfVersion: 'latest'
      Privacy: {
        DataCollection: 'Enable'
      }
    }
    protectedSettings: {}
  }
}

You notice that we tie the extension to the machine by setting the parent to point to the VM resource. This also handles the DependsOn behind the scenes. typeHandlerVersion is just the version of the Poweshell.DSC module we are using. ModulesUrl is where we set the SAS token you created, but do notice the ${environment().suffixes.storage} which replaces the blob storage url. This is one of the Bicep rules, you will notice a warning if you don’t replace that part of the SAS, saying you are not allowed to have that URL in Bicep. The ConfigurationFunction defines that we have a configuration file called MyDSC1.ps1 which includes Configuration called InstallApp inside it, and that is what should be executed.

Now lets take a look of a complete Bicep file which you can use to test deploy VM and necessary other resources, with DSC which installs 7Zip:

// Parameters
param location string = 'westeurope'
param adminUsername string = 'azureAdmin'
@secure()
param adminPassword string

// Variables
var vnetName = 'myVnet'
var nsgName = 'myVM-nsg'
var nsgId = resourceId(resourceGroup().name, 'Microsoft.Network/networkSecurityGroups', nsgName)
var vnetId = resourceId(resourceGroup().name, 'Microsoft.Network/virtualNetworks', vnetName)
var subnetName = 'default'
var subnetRef = '${vnetId}/subnets/${subnetName}'

var subnetPrefix = '10.0.0.0/26'
var publicIpAddressName  = 'myVM-ip'
var vmName = 'myVM'
var addressPrefix  = [
  '10.0.0.0/25'
]

// Resources
resource nic 'Microsoft.Network/networkInterfaces@2018-10-01' = {
  name: 'myNIC'
  location: location
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          subnet: {
            id: subnetRef
          }
          privateIPAllocationMethod: 'Dynamic'
          publicIPAddress: {
            id: resourceId(resourceGroup().name, 'Microsoft.Network/publicIpAddresses', publicIpAddressName)
          }
        }
      }
    ]
    enableAcceleratedNetworking: true
    networkSecurityGroup: {
      id: nsgId
    }
  }
  dependsOn: [
    nsg
    vnet
    publicIP
  ]
}

resource nsg 'Microsoft.Network/networkSecurityGroups@2019-02-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: [
      {
        name: 'RDP'
        properties: {
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '3389'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
          access: 'Allow'
          priority: 300
          direction: 'Inbound'
          sourcePortRanges: []
          destinationPortRanges: []
          sourceAddressPrefixes: []
          destinationAddressPrefixes: []
        }
      }
    ]
  }
}

resource vnet 'Microsoft.Network/virtualNetworks@2020-11-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: addressPrefix
    }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: subnetPrefix
          networkSecurityGroup: {
            id: nsg.id
          }
        }
      }
    ]
  }
}

resource publicIP 'Microsoft.Network/publicIpAddresses@2019-02-01' = {
  name: publicIpAddressName
  location: location
  properties: {
    publicIPAllocationMethod: 'Dynamic'
  }
  sku: {
    name: 'Basic'
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2021-03-01' = {
  name: vmName
  location: location
  properties: {
    hardwareProfile: {
      vmSize: 'Standard_DS1_v2'
    }
    storageProfile: {
      osDisk: {
        createOption: 'FromImage'
        managedDisk: {
          storageAccountType: 'StandardSSD_LRS'
        }
      }
      imageReference: {
        publisher: 'MicrosoftWindowsServer'
        offer: 'WindowsServer'
        sku: '2019-Datacenter'
        version: 'latest'
      }
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
        }
      ]
    }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      adminPassword: adminPassword
      windowsConfiguration: {
        enableAutomaticUpdates: false
        provisionVMAgent: true
        patchSettings: {
          enableHotpatching: false
          patchMode: 'Manual'
        }
      }
    }
    diagnosticsProfile: {
      bootDiagnostics: {
        enabled: true
      }
    }
  }
}

resource extDSC 'Microsoft.Compute/virtualMachines/extensions@2021-03-01' = {
  parent: vm
  name: 'Microsoft.Powershell.DSC'
  location: 'westeurope'
  properties: {
    autoUpgradeMinorVersion: true
    publisher: 'Microsoft.Powershell'
    type: 'DSC'
    typeHandlerVersion: '2.83'
    settings: {
      ModulesUrl: '<PUT YOUR SAS TOKEN HERE AND USE ${environment().suffixes.storage}>'
      ConfigurationFunction: 'MyDSC1.ps1\\InstallApp'
      WmfVersion: 'latest'
      Privacy: {
        DataCollection: 'Enable'
      }
    }
    protectedSettings: {}
  }
}

The above script is not production code, and leaves doors open for hackers, so do not use that in production, it is here just for testing purposes and keeping things as simple as possible. Save the code above as vm.bicep and run the following commands to deploy it:

az login

az group create --name myRG --location "West Europe" 

az deployment group create --resource-group myRG --template-file vm.bicep

Now you can go to Azure portal, connect to the machine with RDP, and see the 7Zip installed on the Start Menu. Not too complicated, when you just find all the pieces needed to make it work.

Scale Set extensions

If you are wondering how to do the same with Virtual Machine Scale Set, wonder no more! You can use the resource definition for the extension from the script above, you just need to move it to ExtensionProfile under the scale set virtualMachineProfile. You would define the extension as extension profile to the scale set, like this:

      extensionProfile: {
        extensions: [
          {
            name: 'Microsoft.Powershell.DSC'
            properties: {
              autoUpgradeMinorVersion: true
              publisher: 'Microsoft.Powershell'
              type: 'DSC'
              typeHandlerVersion: '2.83'
              settings: {
                ModulesUrl: '<PUT YOUR SAS TOKEN HERE AND USE ${environment().suffixes.storage}>' 
                ConfigurationFunction: 'MyDSC1.ps1\\InstallApp'
                Properties: ''
                WmfVersion: 'latest'
                Privacy: {
                  DataCollection: 'Enable'
                }
              }
            }
          }          
        ]
      }

And if you are confused, how it would actually sit in the whole template, fear not, I have a full deployable scale set MVP (ports open to internet, so you can RDP to it, so not production ready!) here for you to try (do the az group create & az deployment using this file as parameter):

param adminUsername string = 'azureAdmin'
@secure()
param adminPassword string
param vmssName string = 'MyScaleSet'
param location string = resourceGroup().location
param windowsOSVersion string = '2019-Datacenter'

var osType = {
  publisher: 'MicrosoftWindowsServer'
  offer: 'WindowsServer'
  sku: windowsOSVersion
  version: 'latest'
}
var addressPrefix = '10.0.0.0/25'
var subnetPrefix = '10.0.0.0/26'
var virtualNetworkName = 'myvnet'
var publicIPAddressName = 'mypip'
var subnetName = 'mysubnet'
var loadBalancerName = 'mylb'
var natPoolName = 'mynatpool'
var bePoolName = 'mybepool'
var natStartPort = 50000
var natEndPort = 50119
var natBackendPort = 3389
var nicname = 'mynic'
var ipConfigName = 'myipconfig'
var nsgName='mynsg'

resource vnet 'Microsoft.Network/virtualNetworks@2020-11-01' = {
  name: virtualNetworkName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        addressPrefix
      ]
    }
    subnets: [
      {
        name: subnetName
        properties: {
          addressPrefix: subnetPrefix
          delegations: []
          privateEndpointNetworkPolicies: 'Enabled'
          privateLinkServiceNetworkPolicies: 'Enabled'
        }
      }
    ]
    virtualNetworkPeerings: []
    enableDdosProtection: false
  }
  dependsOn: [
    loadBalancer
    nsg
    publicIP
  ]
}

resource nsg 'Microsoft.Network/networkSecurityGroups@2020-11-01' = {
  name: nsgName
  location: 'westeurope'
  properties: {
    securityRules: [
      {
        name: 'RDP'
        properties: {
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '3389'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
          access: 'Allow'
          priority: 300
          direction: 'Inbound'
          sourcePortRanges: []
          destinationPortRanges: []
          sourceAddressPrefixes: []
          destinationAddressPrefixes: []
        }
      }
    ]
  }
}

resource nsgRule 'Microsoft.Network/networkSecurityGroups/securityRules@2020-11-01' = {
  parent: nsg
  name: 'RDP'
  properties: {
    protocol: 'Tcp'
    sourcePortRange: '*'
    destinationPortRange: '3389'
    sourceAddressPrefix: '*'
    destinationAddressPrefix: '*'
    access: 'Allow'
    priority: 300
    direction: 'Inbound'
    sourcePortRanges: []
    destinationPortRanges: []
    sourceAddressPrefixes: []
    destinationAddressPrefixes: []
  }
}

resource publicIP 'Microsoft.Network/publicIPAddresses@2020-06-01' = {
  name: publicIPAddressName
  location: location
  sku: {
    name: 'Standard'
  }
  properties: {
    publicIPAddressVersion: 'IPv4'
    publicIPAllocationMethod: 'Static'
  }
}

resource loadBalancer 'Microsoft.Network/loadBalancers@2020-06-01' = {
  name: loadBalancerName
  location: location
  sku: {
    name: 'Standard'
  }
  properties: {
    frontendIPConfigurations: [
      {
        name: 'LoadBalancerFrontEnd'
        properties: {
          privateIPAllocationMethod: 'Dynamic'
          publicIPAddress: {
            id: publicIP.id
          }
        }
      }
    ]
    backendAddressPools: [
      {
        name: bePoolName
      }
    ]
    inboundNatPools: [
      {
        name: natPoolName
        properties: {
          frontendIPConfiguration: {
            id: resourceId('Microsoft.Network/loadBalancers/frontendIPConfigurations', loadBalancerName, 'LoadBalancerFrontEnd')
          }
          protocol: 'Tcp'
          enableFloatingIP: false
          enableTcpReset: false
          frontendPortRangeStart: natStartPort
          frontendPortRangeEnd: natEndPort
          backendPort: natBackendPort
        }
      }
    ]
    probes: [
      {
        name: 'tcpProbe'
        properties: {
          protocol: 'Tcp'
          port: 80
          intervalInSeconds: 5
          numberOfProbes: 2
        }
      }
    ]    
    loadBalancingRules: [
      {
        name: 'LBRule'
        properties: {
          frontendIPConfiguration: {
            id: resourceId('Microsoft.Network/loadBalancers/frontendIPConfigurations', loadBalancerName, 'LoadBalancerFrontEnd')
          }
          backendAddressPool: {
            id: resourceId('Microsoft.Network/loadBalancers/backendAddressPools', loadBalancerName, '${bePoolName}')
          }
          protocol: 'Tcp'
          frontendPort: 80
          backendPort: 80
          enableFloatingIP: false
          idleTimeoutInMinutes: 5
          enableTcpReset: false
          disableOutboundSnat: false
          loadDistribution: 'Default'
          probe: {
            id: resourceId('Microsoft.Network/loadBalancers/probes', loadBalancerName, 'tcpProbe')
          }
        }
      }
    ]
  }
}

// VM Scale set
resource vmss 'Microsoft.Compute/virtualMachineScaleSets@2020-06-01' = {
  name: vmssName
  location: location
  sku: {
    name: 'Standard_DS1_v2'
    tier: 'Standard'
    capacity: 2
  }
  properties: {
    overprovision: true
    upgradePolicy: {
      mode: 'Manual'
      automaticOSUpgradePolicy: {
        enableAutomaticOSUpgrade: false
      }
    }
    virtualMachineProfile: {
      storageProfile: {
        osDisk: {
          createOption: 'FromImage'
          caching: 'ReadWrite'
        }
        imageReference: osType
      }
      osProfile: {
        computerNamePrefix: 'my'
        adminUsername: adminUsername
        adminPassword: adminPassword
      }
      networkProfile: {
        networkInterfaceConfigurations: [
          {
            name: nicname
            properties: {
              primary: true
              enableAcceleratedNetworking: true
              networkSecurityGroup: {
                id: nsg.id
              }
              dnsSettings: {
                dnsServers: []
              }
              enableIPForwarding: false              
              ipConfigurations: [
                {
                  name: ipConfigName
                  properties: {
                    publicIPAddressConfiguration: {
                      name: 'publicIp-my-vnet-nic01'
                      properties: {
                        idleTimeoutInMinutes: 15
                        ipTags: []
                        publicIPAddressVersion: 'IPv4'
                      }
                    }
                    primary: true
                    subnet: {
                      id: '${vnet.id}/subnets/${subnetName}'
                    }
                    loadBalancerBackendAddressPools: [ 
                      {
                          id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Network/loadBalancers/${loadBalancerName}/backendAddressPools/${bePoolName}'
                      }
                    ]
                    loadBalancerInboundNatPools: [ 
                      {
                        id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Network/loadBalancers/${loadBalancerName}/inboundNatPools/${natPoolName}'
                      }
                    ]
                  }
                }
              ]
            }
          }
        ]
      }
      extensionProfile: {
        extensions: [
          {
            name: 'Microsoft.Powershell.DSC'
            properties: {
              autoUpgradeMinorVersion: true
              publisher: 'Microsoft.Powershell'
              type: 'DSC'
              typeHandlerVersion: '2.83'
              settings: {
                ModulesUrl: '<YOUR SAS TOKEN HERE>' 
                ConfigurationFunction: 'MyDSC1.ps1\\InstallApp'
                Properties: ''
                WmfVersion: 'latest'
                Privacy: {
                  DataCollection: 'Enable'
                }
              }
            }
          }          
        ]
      }
    }
  }
}
This entry was posted in Azure and tagged , , , , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *