SHIFT

--- Sjoerd Hooft's InFormation Technology ---

User Tools

Site Tools


Sidebar

Recently Changed Pages:

View All Pages


View All Tags


LinkedIn




WIKI Disclaimer: As with most other things on the Internet, the content on this wiki is not supported. It was contributed by me and is published “as is”. It has worked for me, and might work for you.
Also note that any view or statement expressed anywhere on this site are strictly mine and not the opinions or views of my employer.


Pages with comments

View All Comments

armandyaml

Azure ARM and YAML Project

This article is all about setting up a new azure production like environment in a new subscription, with a few twists. It's all about automation, saving money, but still trying to touch on many topics. Automation will be a combination of PowerShell scripts, Azure CLI scripts, ARM templates and in the end these will be combined in an Azure DevOps pipeline. 

The scope will include:

  • Creation of a management group to hold the subscription and policies
  • Policies for
    • Allowed locations
    • Deny NSGs with Inbound RDP from the internet
    • Audit VMs with unmanaged disks
  • A recovery services vault
  • A VM with an additional disk
    • Which will be backed up
    • Which will automatically shut down at 17:30
  • Network Security Groups
  • Application Group
  • A Budget
  • A KeyVault
  • An Azure Dashboard which will provide an overview

Easy Deployment Test

Cloud Shell

To test a deployment fast and easy, go into the azure portal and start the cloud shell. Create or edit the following files like this:

  • code template.json
  • code parameters.json

Then deploy the files with this command for subscription resources: New-AzDeployment -TemplateFile template.json -TemplateParameterFile parameters.json -Location "westeurope"

Note that the location is ignored for the deployment as long as the location is setup in the template and parameters file.

Or deploy the files with this command for resources within a resource group: New-AzResourceGroupDeployment -TemplateFile template.json -TemplateParameterFile parameters.json -Location "westeurope" -ResourceGroupName "rg_shift"

Note that for long running deployments you could add the parameter -AsJob to the deployment. You can check the status of the jobs with Get-Job

Portal

To test a deployment fast and easy from the portal, search for “Deploy a Custom Deployment”. Click the “Build your own template in the editor” which will allow you to copy paste your template and parameter file into the portal.

Deploy from VS Code

Needs “az cli” installed and for convenience the ARM Tools extension in VS Code
az group create -n rg_arm_template -l westeurope
 
# Optional: Validate with Parameter File
az group deployment validate -g rg_arm_template --template-file "deployarm.json" --parameters "deployarm.parameters.json" --verbode
 
# Deploy - Optional: Add "-n dname" to give the deployment itself a name 
az group deployment create -g rg_arm_template --template-file "deployarm.json" --parameters "deployarm.parameters.json" --verbode
Note that the answer will be verbose, but starts with “error: null”

Resource Group

Template

A resourcegroup is a subscription resource, so notice the schema:

| template.json
{ 
  "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", 
  "contentVersion": "1.0.0.0", 
  "parameters": { 
    "companyname": { 
      "type": "string", 
      "defaultValue": "rg1", 
      "metadata": { 
        "description": "Default name for everything." 
      } 
    }, 
    "location": { 
      "type": "string", 
      "defaultValue": "westeurope", 
      "allowedValues": [  
        "westeurope",  
        "northeurope" 
      ], 
      "metadata": { 
        "description": "Location for the resourceGroup" 
      } 
    } 
  }, 
  "variables": {  
    "rgName": "[toLower(concat('rg_', parameters('companyname')))]" 
  }, 
  "resources": [ 
    { 
      "type": "Microsoft.Resources/resourceGroups", 
      "apiVersion": "2019-10-01", 
      "name": "[variables('rgName')]", 
      "location": "[parameters('location')]", 
      "tags": { 
            "application": "infrastructure", 
            "backup": "no", 
            "ciarating": "333", 
            "costcenter": "ict", 
            "dtap": "production", 
            "expiration": "never", 
            "note": "automated resourcegroup deployment with tags", 
            "owner": "ict", 
            "supportwindow": "7x24" 
      }, 
      "properties": {} 
    } 
  ] 
}

Parameters

| parameters.json
{  
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",  
    "contentVersion": "1.0.0.0",  
    "parameters": {  
        "companyname": {  
            "value": "shift"  
        },  
        "location": {  
            "value": "westeurope" 
        }  
    }  
}

Network

Now the next thing to create is a network with six subnets:

  • Management: Used for the IT department to manage Azure services
  • Production: Used for production services
  • Acceptance: Used for acceptance services
  • Test: Used for testing
  • Development: Used for development
  • Bastion: Used for the bastion services

Template

| aznetwork.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": { 
          "type": "string", 
          "defaultValue": "rg1", 
          "metadata": { 
            "description": "Default name for everything" 
          } 
        }, 
        "location": { 
          "type": "string", 
          "defaultValue": "westeurope", 
          "allowedValues": [  
            "westeurope",  
            "northeurope" 
          ], 
          "metadata": { 
            "description": "Location for the resourceGroup" 
          } 
        }, 
        "addressSpaces": { 
            "type": "string" 
        }, 
        "subnet0_name": { 
            "type": "string" 
        }, 
        "subnet0_addressRange": { 
            "type": "string" 
        }, 
        "subnet1_name": { 
            "type": "string" 
        }, 
        "subnet1_addressRange": { 
            "type": "string" 
        }, 
        "subnet2_name": { 
            "type": "string" 
        }, 
        "subnet2_addressRange": { 
            "type": "string" 
        }, 
        "subnet3_name": { 
            "type": "string" 
        }, 
        "subnet3_addressRange": { 
            "type": "string" 
        }, 
        "subnet4_name": { 
            "type": "string" 
        }, 
        "subnet4_addressRange": { 
            "type": "string" 
        }, 
        "subnet5_name": { 
            "type": "string" 
        }, 
        "subnet5_addressRange": { 
            "type": "string" 
        }, 
        "ddosProtectionPlanEnabled": { 
            "type": "bool" 
        } 
    }, 
    "variables": { 
      "rgName": "[toLower(concat('rg_', parameters('companyname')))]", 
      "vnetName": "[toLower(concat('vnet-', parameters('companyname')))]" 
    }, 
    "resources": [ 
        { 
            "name": "[variables('vnetName')]", 
            "type": "Microsoft.Network/VirtualNetworks", 
            "apiVersion": "2019-09-01", 
            "location": "[parameters('location')]", 
            "dependsOn": [], 
            "tags": { 
                "owner": "ict", 
                "costcenter": "ict", 
                "ciarating": "333", 
                "supportwindow": "7x24", 
                "backup": "no" 
            }, 
            "properties": { 
                "addressSpace": { 
                    "addressPrefixes": [ 
                        "[parameters('addressSpaces')]" 
                    ] 
                }, 
                "subnets": [ 
                    { 
                        "name": "[parameters('subnet0_name')]", 
                        "properties": { 
                            "addressPrefix": "[parameters('subnet0_addressRange')]" 
                        } 
                    }, 
                    { 
                        "name": "[parameters('subnet1_name')]", 
                        "properties": { 
                            "addressPrefix": "[parameters('subnet1_addressRange')]" 
                        } 
                    }, 
                    { 
                        "name": "[parameters('subnet2_name')]", 
                        "properties": { 
                            "addressPrefix": "[parameters('subnet2_addressRange')]" 
                        } 
                    }, 
                    { 
                        "name": "[parameters('subnet3_name')]", 
                        "properties": { 
                            "addressPrefix": "[parameters('subnet3_addressRange')]" 
                        } 
                    }, 
                    { 
                        "name": "[parameters('subnet4_name')]", 
                        "properties": { 
                            "addressPrefix": "[parameters('subnet4_addressRange')]" 
                        } 
                    }, 
                    { 
                        "name": "[parameters('subnet5_name')]", 
                        "properties": { 
                            "addressPrefix": "[parameters('subnet5_addressRange')]" 
                        } 
                    } 
                ], 
                "enableDdosProtection": "[parameters('ddosProtectionPlanEnabled')]" 
            } 
        } 
    ] 
}

Parameters

| aznetwork.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
            "value": "shift"  
        },  
        "location": {  
            "value": "westeurope" 
        }, 
        "addressSpaces": { 
            "value": "10.0.0.0/16" 
        }, 
        "subnet0_name": { 
            "value": "subnet_management" 
        }, 
        "subnet0_addressRange": { 
            "value": "10.0.0.0/24" 
        }, 
        "subnet1_name": { 
            "value": "AzureBastionSubnet" 
        }, 
        "subnet1_addressRange": { 
            "value": "10.0.255.0/24" 
        }, 
        "subnet2_name": { 
            "value": "subnet_production" 
        }, 
        "subnet2_addressRange": { 
            "value": "10.0.10.0/24" 
        }, 
        "subnet3_name": { 
            "value": "subnet_acceptance" 
        }, 
        "subnet3_addressRange": { 
            "value": "10.0.20.0/24" 
        }, 
        "subnet4_name": { 
            "value": "subnet_test" 
        }, 
        "subnet4_addressRange": { 
            "value": "10.0.30.0/24" 
        }, 
        "subnet5_name": { 
            "value": "subnet_dev" 
        }, 
        "subnet5_addressRange": { 
            "value": "10.0.40.0/25" 
        }, 
        "ddosProtectionPlanEnabled": { 
            "value": false 
        } 
    } 
}

Key Vault

Note that if we want to be able to do a proper rollout of a VM we'll need to setup a Key Vault in which we can store the password for the VM:

Template

| keyvault.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
            "type": "string",  
            "defaultValue": "rg1",  
            "metadata": {  
            "description": "Default name for everything"  
            }  
        },  
        "location": { 
        "type": "string", 
        "defaultValue": "[resourceGroup().location]" 
        }, 
        "tenantId": { 
        "type": "string", 
        "defaultValue": "[subscription().tenantId]" 
        }, 
        "adUserId": { 
        "type": "string" 
        }, 
        "secretName": { 
        "type": "string", 
        "defaultValue": "vmAdminPassword" 
        }, 
        "secretValue": { 
        "type": "securestring" 
        } 
    }, 
    "variables": {  
        "kvName": "[toLower(concat('kv-', parameters('companyname')))]" 
    },  
    "resources": [ 
      { 
        "type": "Microsoft.KeyVault/vaults", 
        "apiVersion": "2019-09-01", 
        "name": "[variables('kvName')]", 
        "location": "[parameters('location')]", 
        "properties": { 
          "enabledForDeployment": false, 
          "enabledForTemplateDeployment": true, 
          "enabledForDiskEncryption": false, 
          "accessPolicies": [ 
            { 
              "objectId": "[parameters('adUserId')]", 
              "tenantId": "[parameters('tenantId')]", 
              "permissions": { 
                "keys": [ 
                  "Get", 
                  "List", 
                  "Update", 
                  "Create", 
                  "Import", 
                  "Delete", 
                  "Recover", 
                  "Backup", 
                  "Restore" 
                ], 
                "secrets": [ 
                  "Get", 
                  "List", 
                  "Set", 
                  "Delete", 
                  "Recover", 
                  "Backup", 
                  "Restore" 
                ], 
                "certificates": [ 
                  "Get", 
                  "List", 
                  "Update", 
                  "Create", 
                  "Import", 
                  "Delete", 
                  "Recover", 
                  "Backup", 
                  "Restore", 
                  "ManageContacts", 
                  "ManageIssuers", 
                  "GetIssuers", 
                  "ListIssuers", 
                  "SetIssuers", 
                  "DeleteIssuers" 
                ] 
              } 
            } 
          ], 
          "tenantId": "[parameters('tenantId')]", 
          "sku": { 
            "name": "standard", 
            "family": "A" 
          } 
        } 
      }, 
      { 
        "type": "Microsoft.KeyVault/vaults/secrets", 
        "apiVersion": "2019-09-01", 
        "name": "[concat(variables('kvName'), '/', parameters('secretName'))]", 
        "location": "[parameters('location')]", 
        "scale": null, 
        "dependsOn": [ 
          "[resourceId('Microsoft.KeyVault/vaults',variables('kvName'))]" 
        ], 
        "properties": { 
          "contentType": "securestring", 
          "value": "[parameters('secretValue')]", 
          "attributes": { 
            "enabled": true 
          } 
        } 
      } 
    ], 
    "outputs": { 
      "keyVaultId": { 
        "type": "string", 
        "value": "[resourceId('Microsoft.KeyVault/vaults', variables('kvName'))]" 
      } 
    } 
  }

Parameters

| keyvault.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {   
            "value": "shift"   
        },   
        "location": {   
            "value": "westeurope"  
        },  
        "tenantId": { 
            "value": "[subscription().tenantId]"  
        }, 
        "adUserId": { 
            "value": "aa8346ce-98d3-4223-accf-7ddd2a270388" 
        }, 
        "secretName": { 
            "value": "vmAdminPassword" 
        }, 
        "secretValue": { 
            "value": "P@ssW0rd123!"  
        } 
    } 
}
Note that this assigns permissions to access the vault explicitly to my user account. To check your own ID, do: (Get-AzADUser -UserPrincipalName "emailaddressusedforlogintoportal").Id

VM

Now we will create a VM in the resourcegroup. For now, we will only add a datadisk and configure the autoshutdown.

Template

| vm.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json##", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
          "type": "string",  
          "defaultValue": "rg1",  
          "metadata": {  
            "description": "Name of the resourceGroup to create"  
          }  
        },  
        "location": {  
          "type": "string",  
          "defaultValue": "westeurope",  
          "allowedValues": [   
            "westeurope",   
            "northeurope"  
          ],  
          "metadata": {  
            "description": "Location for the resourceGroup"  
          }  
        },  
        "subnetName": { 
            "type": "string" 
        }, 
        "osDiskType": { 
            "type": "string" 
        }, 
        "dataDisks": { 
            "type": "array" 
        }, 
        "dataDiskResources": { 
            "type": "array" 
        }, 
        "virtualMachineSize": { 
            "type": "string" 
        }, 
        "adminUsername": { 
            "type": "string" 
        }, 
        "adminPassword": { 
            "type": "secureString" 
        }, 
        "patchMode": { 
            "type": "string" 
        }, 
        "autoShutdownStatus": { 
            "type": "string" 
        }, 
        "autoShutdownTime": { 
            "type": "string" 
        }, 
        "autoShutdownTimeZone": { 
            "type": "string" 
        }, 
        "autoShutdownNotificationStatus": { 
            "type": "string" 
        }, 
        "autoShutdownNotificationLocale": { 
            "type": "string" 
        }, 
        "autoShutdownNotificationEmail": { 
            "type": "string" 
        } 
    }, 
    "variables": { 
        "rgName": "[toLower(concat('rg_', parameters('companyname')))]",  
        "vnetName": "[toLower(concat('vnet-', parameters('companyname')))]", 
        "vmName": "[toLower(concat('vm-', parameters('companyname')))]", 
        "vmCompName": "[toLower(concat('vm-', parameters('companyname')))]", 
        "vnetId": "[resourceId(variables('rgName'),'Microsoft.Network/virtualNetworks',variables('vnetName'))]", 
        "subnetRef": "[concat(variables('vnetId'), '/subnets/', parameters('subnetName'))]", 
        "nicName": "[concat('nic-', variables('vmName'))]" 
    }, 
    "resources": [ 
        { 
            "name": "[variables('nicName')]", 
            "type": "Microsoft.Network/networkInterfaces", 
            "apiVersion": "2018-10-01", 
            "location": "[parameters('location')]", 
            "dependsOn": [], 
            "properties": { 
                "ipConfigurations": [ 
                    { 
                        "name": "ipconfig1", 
                        "properties": { 
                            "subnet": { 
                                "id": "[variables('subnetRef')]" 
                            }, 
                            "privateIPAllocationMethod": "Dynamic" 
                        } 
                    } 
                ] 
            }, 
            "tags": { 
                "dtap": "production", 
                "application": "management", 
                "costcenter": "ict", 
                "expiration": "never", 
                "owner": "ict" 
            } 
        }, 
        { 
            "name": "[parameters('dataDiskResources')[copyIndex()].name]", 
            "type": "Microsoft.Compute/disks", 
            "apiVersion": "2020-05-01", 
            "location": "[parameters('location')]", 
            "properties": "[parameters('dataDiskResources')[copyIndex()].properties]", 
            "sku": { 
                "name": "[parameters('dataDiskResources')[copyIndex()].sku]" 
            }, 
            "copy": { 
                "name": "managedDiskResources", 
                "count": "[length(parameters('dataDiskResources'))]" 
            }, 
            "tags": { 
                "dtap": "production", 
                "application": "management", 
                "backup": "yes", 
                "ciarating": "111", 
                "costcenter": "ict", 
                "expiration": "never", 
                "owner": "ict" 
            } 
        }, 
        { 
            "name": "[variables('vmName')]", 
            "type": "Microsoft.Compute/virtualMachines", 
            "apiVersion": "2020-06-01", 
            "location": "[parameters('location')]", 
            "dependsOn": [ 
                "managedDiskResources", 
                "[concat('Microsoft.Network/networkInterfaces/', variables('nicName'))]" 
            ], 
            "properties": { 
                "hardwareProfile": { 
                    "vmSize": "[parameters('virtualMachineSize')]" 
                }, 
                "storageProfile": { 
                    "osDisk": { 
                        "createOption": "fromImage", 
                        "managedDisk": { 
                            "storageAccountType": "[parameters('osDiskType')]" 
                        } 
                    }, 
                    "imageReference": { 
                        "publisher": "MicrosoftWindowsServer", 
                        "offer": "WindowsServer", 
                        "sku": "2019-Datacenter", 
                        "version": "latest" 
                    }, 
                    "copy": [ 
                        { 
                            "name": "dataDisks", 
                            "count": "[length(parameters('dataDisks'))]", 
                            "input": { 
                                "lun": "[parameters('dataDisks')[copyIndex('dataDisks')].lun]", 
                                "createOption": "[parameters('dataDisks')[copyIndex('dataDisks')].createOption]", 
                                "caching": "[parameters('dataDisks')[copyIndex('dataDisks')].caching]", 
                                "diskSizeGB": "[parameters('dataDisks')[copyIndex('dataDisks')].diskSizeGB]", 
                                "managedDisk": { 
                                    "id": "[coalesce(parameters('dataDisks')[copyIndex('dataDisks')].id, if(equals(parameters('dataDisks')[copyIndex('dataDisks')].name, json('null')), json('null'), resourceId('Microsoft.Compute/disks', parameters('dataDisks')[copyIndex('dataDisks')].name)))]", 
                                    "storageAccountType": "[parameters('dataDisks')[copyIndex('dataDisks')].storageAccountType]" 
                                }, 
                                "writeAcceleratorEnabled": "[parameters('dataDisks')[copyIndex('dataDisks')].writeAcceleratorEnabled]" 
                            } 
                        } 
                    ] 
                }, 
                "networkProfile": { 
                    "networkInterfaces": [ 
                        { 
                            "id": "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]" 
                        } 
                    ] 
                }, 
                "osProfile": { 
                    "computerName": "[variables('vmCompName')]", 
                    "adminUsername": "[parameters('adminUsername')]", 
                    "adminPassword": "[parameters('adminPassword')]", 
                    "windowsConfiguration": { 
                        "enableAutomaticUpdates": true, 
                        "provisionVmAgent": true, 
                        "patchSettings": { 
                            "patchMode": "[parameters('patchMode')]" 
                        } 
                    } 
                } 
            }, 
            "tags": { 
                "dtap": "production", 
                "application": "management", 
                "backup": "yes", 
                "ciarating": "111", 
                "costcenter": "ict", 
                "expiration": "never", 
                "owner": "ict" 
            } 
        }, 
        { 
            "name": "[concat('shutdown-computevm-', variables('vmName'))]", 
            "type": "Microsoft.DevTestLab/schedules", 
            "apiVersion": "2017-04-26-preview", 
            "location": "[parameters('location')]", 
            "dependsOn": [ 
                "[concat('Microsoft.Compute/virtualMachines/', variables('vmName'))]" 
            ], 
            "properties": { 
                "status": "[parameters('autoShutdownStatus')]", 
                "taskType": "ComputeVmShutdownTask", 
                "dailyRecurrence": { 
                    "time": "[parameters('autoShutdownTime')]" 
                }, 
                "timeZoneId": "[parameters('autoShutdownTimeZone')]", 
                "targetResourceId": "[resourceId('Microsoft.Compute/virtualMachines', variables('vmName'))]", 
                "notificationSettings": { 
                    "status": "[parameters('autoShutdownNotificationStatus')]", 
                    "notificationLocale": "[parameters('autoShutdownNotificationLocale')]", 
                    "timeInMinutes": "30", 
                    "emailRecipient": "[parameters('autoShutdownNotificationEmail')]" 
                } 
            }, 
            "tags": { 
                "dtap": "production" 
            } 
        } 
    ], 
    "outputs": { 
        "adminUsername": { 
            "type": "string", 
            "value": "[parameters('adminUsername')]" 
        } 
    } 
}

Parameters

| vm.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
            "value": "shift"  
        }, 
        "location": { 
            "value": "westeurope" 
        }, 
        "subnetName": { 
            "value": "subnet_management" 
        }, 
        "osDiskType": { 
            "value": "StandardSSD_LRS" 
        }, 
        "dataDisks": { 
            "value": [ 
                { 
                    "lun": 0, 
                    "createOption": "attach", 
                    "caching": "ReadOnly", 
                    "writeAcceleratorEnabled": false, 
                    "id": null, 
                    "name": "vm-shift_DataDisk_0", 
                    "storageAccountType": null, 
                    "diskSizeGB": null, 
                    "diskEncryptionSet": null 
                } 
            ] 
        }, 
        "dataDiskResources": { 
            "value": [ 
                { 
                    "name": "vm-shift_DataDisk_0", 
                    "sku": "StandardSSD_LRS", 
                    "properties": { 
                        "diskSizeGB": 128, 
                        "creationData": { 
                            "createOption": "empty" 
                        } 
                    } 
                } 
            ] 
        }, 
        "virtualMachineSize": { 
            "value": "Standard_DS1_v2" 
        }, 
        "adminUsername": { 
            "value": "adminshift" 
        }, 
        "adminPassword": { 
            "value": null 
        }, 
        "patchMode": { 
            "value": "AutomaticByOS" 
        }, 
        "autoShutdownStatus": { 
            "value": "Enabled" 
        }, 
        "autoShutdownTime": { 
            "value": "17:30" 
        }, 
        "autoShutdownTimeZone": { 
            "value": "W. Europe Standard Time" 
        }, 
        "autoShutdownNotificationStatus": { 
            "value": "Disabled" 
        }, 
        "autoShutdownNotificationLocale": { 
            "value": "en" 
        }, 
        "autoShutdownNotificationEmail": { 
            "value": "sjoerd@example.com" 
        } 
    } 
}

Network Security

We need two types of security in our setup. First we need to have Application Groups assigned to VM that are of the same type, and then have Network Security Groups assigned to these groups. Second, we need Network Security Groups assigned to the subnets. To do this, we need to create new resources for the required Application Groups and Network Security groups, but we also modify existing resources. ARM works with a desired state configuration which means we can add the resources that need to be modified from the previous deployment ARMs and modify them for our needs. In the template section you'll see how the network interface card and the virtual network are modified to work with Application Groups, Network Security Groups and dependencies.

Template

| networksecurity.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json##", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
          "type": "string",  
          "defaultValue": "rg1",  
          "metadata": {  
            "description": "Default name for everything"  
          }  
        },  
        "location": {  
          "type": "string",  
          "defaultValue": "westeurope",  
          "allowedValues": [   
            "westeurope",   
            "northeurope"  
          ],  
          "metadata": {  
            "description": "Location for the resourceGroup"  
          }  
        }, 
        "addressSpaces": {  
            "type": "string"  
        },  
        "subnet0_name": {  
            "type": "string"  
        },  
        "subnet0_addressRange": {  
            "type": "string"  
        },  
        "subnet1_name": {  
            "type": "string"  
        },  
        "subnet1_addressRange": {  
            "type": "string"  
        },  
        "subnet2_name": {  
            "type": "string"  
        },  
        "subnet2_addressRange": {  
            "type": "string"  
        },  
        "subnet3_name": {  
            "type": "string"  
        },  
        "subnet3_addressRange": {  
            "type": "string"  
        },  
        "subnet4_name": {  
            "type": "string"  
        },  
        "subnet4_addressRange": {  
            "type": "string"  
        },  
        "subnet5_name": {  
            "type": "string"  
        },  
        "subnet5_addressRange": {  
            "type": "string"  
        },  
        "ddosProtectionPlanEnabled": {  
            "type": "bool"  
        }, 
        "subnetName": { 
            "type": "string" 
        } 
    }, 
    "variables": { 
        "rgName": "[toLower(concat('rg_', parameters('companyname')))]",  
        "vnetName": "[toLower(concat('vnet-', parameters('companyname')))]", 
        "vmName": "[toLower(concat('vm-', parameters('companyname')))]", 
        "vmCompName": "[toLower(concat('vm-', parameters('companyname')))]", 
        "vnetId": "[resourceId(variables('rgName'),'Microsoft.Network/virtualNetworks',variables('vnetName'))]", 
        "subnetRef": "[concat(variables('vnetId'), '/subnets/', parameters('subnetName'))]", 
        "nicName": "[concat('nic-', variables('vmName'))]" 
    }, 
    "resources": [ 
        { 
            "type": "Microsoft.Network/applicationSecurityGroups", 
            "apiVersion": "2019-02-01", 
            "name": "asg-RDPVms", 
            "location": "[parameters('location')]", 
            "tags": { 
                "owner": "ict" 
            }, 
            "properties": {} 
        }, 
        { 
            "name": "[variables('nicName')]", 
            "type": "Microsoft.Network/networkInterfaces", 
            "apiVersion": "2018-10-01", 
            "location": "[parameters('location')]", 
            "dependsOn": [ 
                "[resourceId('Microsoft.Network/applicationSecurityGroups', 'asg-RDPVms')]" 
            ], 
            "properties": { 
                "ipConfigurations": [ 
                    { 
                        "name": "ipconfig1", 
                        "properties": { 
                            "subnet": { 
                                "id": "[variables('subnetRef')]" 
                            }, 
                            "privateIPAllocationMethod": "Dynamic", 
                            "applicationSecurityGroups" : [ 
                                { 
                                    "ID": "[resourceId('Microsoft.Network/applicationSecurityGroups', 'asg-RDPVms')]" 
                                } 
                            ] 
                        } 
                    } 
                ] 
            }, 
            "tags": { 
                "dtap": "production", 
                "application": "management", 
                "costcenter": "ict", 
                "expiration": "never", 
                "owner": "ict" 
            } 
        }, 
        { 
            "apiVersion": "2019-02-01", 
            "type": "Microsoft.Network/networkSecurityGroups", 
            "name": "nsg-AllowRDP", 
            "location": "[parameters('location')]", 
            "tags": { 
                "owner": "ict" 
            }, 
            "dependsOn": [ 
                "[resourceId('Microsoft.Network/applicationSecurityGroups', 'asg-RDPVms')]" 
            ], 
            "properties": { 
                "securityRules": [ 
                    { 
                        "name": "rule_allow_rdp", 
                        "properties": { 
                            "description": "Allow Inbound RDP", 
                            "protocol": "Tcp", 
                            "sourcePortRange": "*", 
                            "destinationPortRange": "3389", 
                            "sourceAddressPrefix": "*", 
                            "destinationApplicationSecurityGroups": [ 
                                { 
                                "ID": "[resourceId('Microsoft.Network/applicationSecurityGroups', 'asg-RDPVms')]" 
                                } 
                            ], 
                            "access": "Allow", 
                            "priority": 100, 
                            "direction": "Inbound" 
                        } 
                    }, 
                    { 
                        "name": "DenyVnetInBound", 
                        "properties": { 
                            "protocol": "*", 
                            "sourcePortRange": "*", 
                            "destinationPortRange": "*", 
                            "sourceAddressPrefix": "VirtualNetwork", 
                            "destinationAddressPrefix": "*", 
                            "access": "Deny", 
                            "priority": 1500, 
                            "direction": "Inbound", 
                            "sourcePortRanges": [], 
                            "destinationPortRanges": [], 
                            "sourceAddressPrefixes": [], 
                            "destinationAddressPrefixes": [] 
                        } 
                    } 
                ] 
            } 
        }, 
        { 
            "apiVersion": "2019-02-01", 
            "type": "Microsoft.Network/networkSecurityGroups", 
            "name": "nsg-subnet", 
            "location": "[parameters('location')]", 
            "tags": { 
                "owner": "ict" 
            }, 
            "dependsOn": [], 
            "properties": { 
                "securityRules": [ 
                    { 
                        "name": "DenyVnetInBound", 
                        "properties": { 
                            "protocol": "*", 
                            "sourcePortRange": "*", 
                            "destinationPortRange": "*", 
                            "sourceAddressPrefix": "VirtualNetwork", 
                            "destinationAddressPrefix": "*", 
                            "access": "Deny", 
                            "priority": 1500, 
                            "direction": "Inbound", 
                            "sourcePortRanges": [], 
                            "destinationPortRanges": [], 
                            "sourceAddressPrefixes": [], 
                            "destinationAddressPrefixes": [] 
                        } 
                    } 
                ] 
            } 
        }, 
                {  
            "name": "[variables('vnetName')]",  
            "type": "Microsoft.Network/VirtualNetworks",  
            "apiVersion": "2019-09-01",  
            "location": "[parameters('location')]",  
            "dependsOn": [ 
                "[resourceId('Microsoft.Network/networkSecurityGroups', 'nsg-subnet')]" 
            ],  
            "tags": {  
                "owner": "ict",  
                "costcenter": "ict",  
                "ciarating": "333",  
                "supportwindow": "7x24",  
                "backup": "no"  
            },  
            "properties": {  
                "addressSpace": {  
                    "addressPrefixes": [  
                        "[parameters('addressSpaces')]"  
                    ]  
                },  
                "subnets": [  
                    {  
                        "name": "[parameters('subnet0_name')]",  
                        "properties": {  
                            "addressPrefix": "[parameters('subnet0_addressRange')]", 
                            "networkSecurityGroup": { 
                                "ID" : "[resourceId('Microsoft.Network/networkSecurityGroups', 'nsg-subnet')]" 
                            } 
                        }  
                    },  
                    {  
                        "name": "[parameters('subnet1_name')]",  
                        "properties": {  
                            "addressPrefix": "[parameters('subnet1_addressRange')]" 
                        }  
                    },  
                    {  
                        "name": "[parameters('subnet2_name')]",  
                        "properties": {  
                            "addressPrefix": "[parameters('subnet2_addressRange')]", 
                            "networkSecurityGroup": { 
                                "ID" : "[resourceId('Microsoft.Network/networkSecurityGroups', 'nsg-subnet')]" 
                            } 
                        }  
                    },  
                    {  
                        "name": "[parameters('subnet3_name')]",  
                        "properties": {  
                            "addressPrefix": "[parameters('subnet3_addressRange')]", 
                            "networkSecurityGroup": { 
                                "ID" : "[resourceId('Microsoft.Network/networkSecurityGroups', 'nsg-subnet')]" 
                            } 
                        }  
                    },  
                    {  
                        "name": "[parameters('subnet4_name')]",  
                        "properties": {  
                            "addressPrefix": "[parameters('subnet4_addressRange')]", 
                            "networkSecurityGroup": { 
                                "ID" : "[resourceId('Microsoft.Network/networkSecurityGroups', 'nsg-subnet')]" 
                            } 
                        }  
                    },  
                    {  
                        "name": "[parameters('subnet5_name')]",  
                        "properties": {  
                            "addressPrefix": "[parameters('subnet5_addressRange')]", 
                            "networkSecurityGroup": { 
                                "ID" : "[resourceId('Microsoft.Network/networkSecurityGroups', 'nsg-subnet')]" 
                            } 
                        }  
                    }  
                ],  
                "enableDdosProtection": "[parameters('ddosProtectionPlanEnabled')]"  
            }  
        }  
    ] 
}

Parameters

| networksecurity.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
            "value": "shift"  
        }, 
        "location": { 
            "value": "westeurope" 
        }, 
        "addressSpaces": {  
            "value": "10.0.0.0/16"  
        },  
        "subnet0_name": {  
            "value": "subnet_management"  
        },  
        "subnet0_addressRange": {  
            "value": "10.0.0.0/24"  
        },  
        "subnet1_name": {  
            "value": "AzureBastionSubnet"  
        },  
        "subnet1_addressRange": {  
            "value": "10.0.255.0/24"  
        },  
        "subnet2_name": {  
            "value": "subnet_production"  
        },  
        "subnet2_addressRange": {  
            "value": "10.0.10.0/24"  
        },  
        "subnet3_name": {  
            "value": "subnet_acceptance"  
        },  
        "subnet3_addressRange": {  
            "value": "10.0.20.0/24"  
        },  
        "subnet4_name": {  
            "value": "subnet_test"  
        },  
        "subnet4_addressRange": {  
            "value": "10.0.30.0/24"  
        },  
        "subnet5_name": {  
            "value": "subnet_dev"  
        },  
        "subnet5_addressRange": {  
            "value": "10.0.40.0/25"  
        },  
        "ddosProtectionPlanEnabled": {  
            "value": false  
        }, 
        "subnetName": { 
            "value": "subnet_management" 
        } 
    } 
}
Notice that in the parameters previous used parameters from the VM deployment as well as the network deployment are added. Also note, that the subnetName parameter, which is used to identify the subnetName for the NIC in the VM template is not a logical name anymore, as it's name now confuses the subnet naming from the virtual network deployment.

Recovery Services Vault and Policy

Now we can create a Recovery Services Vault and configure a backup policy. Because we want to save money, we create a custom backup policy which skips the daily backups.

Template

| rsvault.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json##", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
          "type": "string",  
          "defaultValue": "rg1",  
          "metadata": {  
            "description": "Default name for everything"  
          }  
        },  
        "location": {  
          "type": "string",  
          "defaultValue": "westeurope",  
          "allowedValues": [   
            "westeurope",   
            "northeurope"  
          ],  
          "metadata": {  
            "description": "Location for the resourceGroup"  
          }  
        }, 
        "scheduleRunDays": { 
            "type": "array", 
            "metadata": { 
                "description": "Backup Schedule will run on array of Days like, Monday, Tuesday etc. Applies in Weekly Backup Type only." 
            } 
        }, 
        "scheduleRunTimes": { 
            "type": "array", 
            "metadata": { 
                "description": "Times in day when backup should be triggered. e.g. 01:00, 13:00. This will be used in LTR too for daily, weekly, monthly and yearly backup." 
        } 
        }, 
        "timeZone": { 
            "type": "string", 
            "metadata": { 
                "description": "Any Valid timezone, for example:UTC, Pacific Standard Time. Refer: https://msdn.microsoft.com/en-us/library/gg154758.aspx" 
            } 
        }, 
        "weeklyRetentionDurationCount": { 
            "type": "int", 
            "metadata": { 
                "description": "Number of weeks you want to retain the backup" 
            } 
        }, 
        "daysOfTheWeekForMontlyRetention": { 
            "type": "array", 
            "metadata": { 
                "description": "Array of Days for Monthly Retention (Min One or Max all values from scheduleRunDays, but not any other days which are not part of scheduleRunDays)" 
            } 
        }, 
        "weeksOfTheMonthForMonthlyRetention": { 
            "type": "array", 
            "metadata": { 
                "description": "Array of Weeks for Monthly Retention - First, Second, Third, Fourth, Last" 
            } 
        }, 
        "monthlyRetentionDurationCount": { 
            "type": "int", 
            "metadata": { 
                "description": "Number of months you want to retain the backup" 
            } 
        }, 
        "monthsOfYear": { 
            "type": "array", 
            "metadata": { 
                "description": "Array of Months for Yearly Retention" 
            } 
        }, 
        "daysOfTheWeekForYearlyRetention": { 
            "type": "array", 
            "metadata": { 
                "description": "Array of Days for Yearly Retention (Min One or Max all values from scheduleRunDays, but not any other days which are not part of scheduleRunDays)" 
            } 
        }, 
        "weeksOfTheMonthForYearlyRetention": { 
            "type": "array", 
            "metadata": { 
                "description": "Array of Weeks for Yearly Retention - First, Second, Third, Fourth, Last" 
            } 
        }, 
        "yearlyRetentionDurationCount": { 
            "type": "int", 
            "metadata": { 
                "description": "Number of years you want to retain the backup" 
            } 
        } 
    }, 
    "variables": { 
        "rgName": "[toLower(concat('rg_', parameters('companyname')))]",  
        "vnetName": "[toLower(concat('vnet-', parameters('companyname')))]", 
        "vmName": "[toLower(concat('vm-', parameters('companyname')))]", 
        "vaultName": "[toLower(concat('vault-', parameters('companyname')))]", 
        "backupPolicyName": "[toLower(concat('backupPolicy-', parameters('companyname')))]",
    }, 
    "resources": [ 
        { 
            "apiVersion": "2020-02-02", 
            "name": "[variables('vaultName')]", 
            "location": "[parameters('location')]", 
            "type": "Microsoft.RecoveryServices/vaults", 
            "sku": { 
                "name": "RS0", 
                "tier": "Standard" 
            }, 
            "properties": {}, 
            "tags": { 
                "owner": "ict", 
                "ciarating": "333" 
            } 
        }, 
        { 
            "apiVersion": "2016-12-01", 
            "name": "[concat(variables('vaultName'), '/', variables('backupPolicyName'))]", 
            "type": "Microsoft.RecoveryServices/vaults/backupPolicies", 
            "dependsOn": [ 
                "[concat('Microsoft.RecoveryServices/vaults/', variables('vaultName'))]" 
            ], 
            "location": "[parameters('location')]", 
            "properties": { 
                "backupManagementType": "AzureIaasVM", 
                "instantRpRetentionRangeInDays": 5, 
                "schedulePolicy": { 
                "scheduleRunFrequency": "Weekly", 
                "scheduleRunDays": "[parameters('scheduleRunDays')]", 
                "scheduleRunTimes": "[parameters('scheduleRunTimes')]", 
                "schedulePolicyType": "SimpleSchedulePolicy" 
                }, 
                "retentionPolicy": { 
                "dailySchedule": null, 
                "weeklySchedule": { 
                    "daysOfTheWeek": "[parameters('scheduleRunDays')]", 
                    "retentionTimes": "[parameters('scheduleRunTimes')]", 
                    "retentionDuration": { 
                    "count": "[parameters('weeklyRetentionDurationCount')]", 
                    "durationType": "Weeks" 
                    } 
                }, 
                "monthlySchedule": { 
                    "retentionScheduleFormatType": "Weekly", 
                    "retentionScheduleDaily": { 
                    "daysOfTheMonth": [ 
                        { 
                        "date": 1, 
                        "isLast": false 
                        } 
                    ] 
                    }, 
                    "retentionScheduleWeekly": { 
                    "daysOfTheWeek": "[parameters('daysOfTheWeekForMontlyRetention')]", 
                    "weeksOfTheMonth": "[parameters('weeksOfTheMonthForMonthlyRetention')]" 
                    }, 
                    "retentionTimes": "[parameters('scheduleRunTimes')]", 
                    "retentionDuration": { 
                    "count": "[parameters('monthlyRetentionDurationCount')]", 
                    "durationType": "Months" 
                    } 
                }, 
                "yearlySchedule": { 
                    "retentionScheduleFormatType": "Weekly", 
                    "monthsOfYear": "[parameters('monthsOfYear')]", 
                    "retentionScheduleDaily": { 
                    "daysOfTheMonth": [ 
                        { 
                        "date": 1, 
                        "isLast": false 
                        } 
                    ] 
                    }, 
                    "retentionScheduleWeekly": { 
                    "daysOfTheWeek": "[parameters('daysOfTheWeekForYearlyRetention')]", 
                    "weeksOfTheMonth": "[parameters('weeksOfTheMonthForYearlyRetention')]" 
                    }, 
                    "retentionTimes": "[parameters('scheduleRunTimes')]", 
                    "retentionDuration": { 
                    "count": "[parameters('yearlyRetentionDurationCount')]", 
                    "durationType": "Years" 
                    } 
                }, 
                "retentionPolicyType": "LongTermRetentionPolicy" 
                }, 
                "timeZone": "[parameters('timeZone')]" 
            } 
        } 
    ] 
}

Parameters

| rsvault.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
            "value": "shift"  
        }, 
        "location": { 
            "value": "westeurope" 
        }, 
        "scheduleRunDays": { 
            "value": [ "Sunday", "Tuesday", "Thursday" ] 
        }, 
        "scheduleRunTimes": { 
            "value": [ "2016-09-21T05:30:00Z" ] 
        }, 
        "timeZone": { 
            "value": "W. Europe Standard Time" 
        }, 
        "weeklyRetentionDurationCount": { 
            "value": 104 
        }, 
        "daysOfTheWeekForMontlyRetention": { 
            "value": [ "Sunday", "Tuesday" ] 
        }, 
        "weeksOfTheMonthForMonthlyRetention": { 
            "value": [ "First", "Third" ] 
        }, 
        "monthlyRetentionDurationCount": { 
            "value": 60 
        }, 
        "monthsOfYear": { 
            "value": [ "January", "March", "August" ] 
        }, 
        "daysOfTheWeekForYearlyRetention": { 
            "value": [ "Sunday", "Tuesday" ] 
        }, 
        "weeksOfTheMonthForYearlyRetention": { 
            "value": [ "First", "Third" ] 
        }, 
        "yearlyRetentionDurationCount": { 
            "value": 10 
        } 
    } 
}

Backup VM

Now that we have a recovery Services Vault and a backup policy we can enable the backup on a VM

Template

| vmbackup.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json##", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
          "type": "string",  
          "defaultValue": "rg1",  
          "metadata": {  
            "description": "Default name for everything"  
          }  
        },  
        "location": {  
          "type": "string",  
          "defaultValue": "westeurope",  
          "allowedValues": [   
            "westeurope",   
            "northeurope"  
          ],  
          "metadata": {  
            "description": "Location for the resourceGroup"  
          }  
        } 
    }, 
    "variables": { 
        "rgName": "[toLower(concat('rg_', parameters('companyname')))]",  
        "vmName": "[toLower(concat('vm-', parameters('companyname')))]", 
        "vaultName": "[toLower(concat('vault-', parameters('companyname')))]", 
        "backupFabric": "Azure", 
        "backupPolicyName": "[toLower(concat('backupPolicy-', parameters('companyname')))]", 
        "protectionContainer": "[concat('iaasvmcontainer;iaasvmcontainerv2;', variables('rgName'), ';', variables('vmName'))]", 
        "protectedItem": "[concat('vm;iaasvmcontainerv2;', variables('rgName'), ';', variables('vmName'))]" 
    }, 
    "resources": [ 
        { 
            "type": "Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems", 
            "apiVersion": "2020-02-02", 
            "name": "[concat(variables('vaultName'), '/', variables('backupFabric'), '/', variables('protectionContainer'), '/', variables('protectedItem'))]", 
            "dependsOn": [], 
            "properties": { 
                "protectedItemType": "Microsoft.Compute/virtualMachines", 
                "policyId": "[resourceId('Microsoft.RecoveryServices/vaults/backupPolicies', variables('vaultName'), variables('backupPolicyName'))]", 
                "sourceResourceId": "[resourceId('Microsoft.Compute/virtualMachines', variables('vmName'))]" 
            } 
        } 
    ] 
}
Notice that the resources in the dependsOn section are commented out, note that you'll need them in an integrated ARM:
          "dependsOn": [  
              "[resourceId('Microsoft.Compute/virtualMachines', variables('vmName'))]",  
              "[resourceId('Microsoft.RecoveryServices/vaults', variables('vaultName'))]"  
          ],


Notice that the default backup policy name: "defaultBackupPolicyName": "DefaultPolicy". This can be put in the variables if you want to use the default backup policy.

Parameters

| vmbackup.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "companyname": {  
            "value": "shift"  
        }, 
        "location": { 
            "value": "westeurope" 
        } 
    } 
}

Management Group

Management groups are containers that help you manage access, policy, and compliance across multiple subscriptions. Because of that, they are the perfect scope to assign policies to.

Azure CLI - Create Management Group

It's not (yet) possible to create management groups using ARM templates, however, it is possible to create one with Azure CLI or PowerShell. I've chosen the Azure CLI. Now for us, a single management group will do, but remember you can create management groups within management groups as well. Issue the following command into the cloud shell to create a management group:

az account management-group create --name 'mg-shift' --display-name 'SHIFT Management Group'

Azure CLI - Move a Subscription to Management Group

subscriptionId=$(az account show --query id)
 
az account management-group subscription add --name 'mg-shift' --subscription $subscriptionId
Note that this will move your current subscription to the management group. To view all subscriptions use az account list

You can use the script below to combine these actions:

managementGroupName="mg-shift"
managementGroupDisplayName='SHIFT Management Group'
subscriptionId=$(az account show --query id) 
#Remove leading and trailing "
subscriptionId=$(sed -e 's/^"//' -e 's/"$//' <<<"$subscriptionId")
az account management-group create --name $managementGroupName --display-name "$managementGroupDisplayName"
az account management-group subscription add --name $managementGroupName --subscription $subscriptionId

Add Policy to ManagementGroup

Now we will add a policy to the management group so it will be applied to all resources below the subscription

Allowed Locations

Template

| mgp.allowedlocations.json
{ 
  "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#", 
  "contentVersion": "1.0.0.0", 
  "parameters": { 
      "targetMG": { 
          "type": "string", 
          "metadata": { 
              "description": "Target Management Group" 
          } 
      }, 
      "allowedLocations": { 
          "type": "array", 
          "defaultValue": [ 
              "westeurope", 
              "northeurope" 
          ], 
          "metadata": { 
              "description": "An array of the allowed locations, all other locations will be denied by the created policy." 
          } 
      } 
  }, 
  "variables": { 
      "mgScope": "[tenantResourceId('Microsoft.Management/managementGroups', parameters('targetMG'))]", 
      "policyDefinition": "LocationRestriction" 
  }, 
  "resources": [ 
      { 
          "type": "Microsoft.Authorization/policyDefinitions", 
          "name": "[variables('policyDefinition')]", 
          "apiVersion": "2019-09-01", 
          "properties": { 
              "policyType": "Custom", 
              "mode": "All", 
              "parameters": { 
              }, 
              "policyRule": { 
                  "if": { 
                      "not": { 
                          "field": "location", 
                          "in": "[parameters('allowedLocations')]" 
                      } 
                  }, 
                  "then": { 
                      "effect": "deny" 
                  } 
              } 
          } 
      }, 
      { 
          "type": "Microsoft.Authorization/policyAssignments", 
          "name": "location-lock", 
          "apiVersion": "2019-09-01", 
          "dependsOn": [ 
              "[variables('policyDefinition')]" 
          ], 
          "properties": { 
              "scope": "[variables('mgScope')]", 
              "policyDefinitionId": "[extensionResourceId(variables('mgScope'), 'Microsoft.Authorization/policyDefinitions', variables('policyDefinition'))]" 
          } 
      } 
  ] 
}

Parameters

| mgp.allowedlocations.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
      "targetMG": { 
        "value": "mg-shift01" 
      }, 
      "allowedLocations": { 
        "value": [ 
          "westeurope", 
          "northeurope" 
        ] 
      } 
    } 
  }

Deny NSG with inbound RDP from Internet

Template

| mgp.denyrdpinternet.json
{  
  "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#",  
  "contentVersion": "1.0.0.0",  
  "parameters": {  
      "targetMG": {  
          "type": "string",  
          "metadata": {  
              "description": "Target Management Group"  
          }  
      },  
      "deniedPorts": { 
        "type": "Array", 
        "metadata": { 
          "displayName": "Ports to block", 
          "description": "The inbound ports that should be blocked" 
        } 
      } 
  },  
  "variables": {  
      "mgScope": "[tenantResourceId('Microsoft.Management/managementGroups', parameters('targetMG'))]",  
      "policyDefinitionName": "NSGRestriction"  
  },  
  "resources": [  
      {  
          "type": "Microsoft.Authorization/policyDefinitions",  
          "name": "[variables('policyDefinitionName')]",  
          "apiVersion": "2019-09-01",  
          "properties": {  
              "policyType": "Custom",  
              "mode": "All",  
              "parameters": {  
              },  
              "policyRule": { 
                  "if": { 
                    "allOf": [ 
                      { 
                        "field": "type", 
                        "equals": "Microsoft.Network/networkSecurityGroups/securityRules" 
                      }, 
                      { 
                        "allOf": [ 
                          { 
                            "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", 
                            "equals": "Allow" 
                          }, 
                          { 
                            "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", 
                            "equals": "Inbound" 
                          }, 
                          { 
                            "anyOf": [ 
                              { 
                                "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", 
                                "in": "[parameters('deniedPorts')]" 
                              }, 
                              { 
                                "not": { 
                                  "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]", 
                                  "notIn": "[parameters('deniedPorts')]" 
                                } 
                              } 
                            ] 
                          }, 
                          { 
                            "anyOf": [ 
                              { 
                                "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", 
                                "in": [ 
                                  "*", 
                                  "Internet" 
                                ] 
                              } 
                            ] 
                          } 
                        ] 
                      } 
                    ] 
                  }, 
                  "then": { 
                    "effect": "deny" 
                  } 
            } 
          } 
      }, 
      {  
          "type": "Microsoft.Authorization/policyAssignments",  
          "name": "nsg-restriction",  
          "apiVersion": "2019-09-01",  
          "dependsOn": [  
              "[variables('policyDefinitionName')]"  
          ],  
          "properties": {  
              "scope": "[variables('mgScope')]",  
              "policyDefinitionId": "[extensionResourceId(variables('mgScope'), 'Microsoft.Authorization/policyDefinitions', variables('policyDefinitionName'))]"  
          }  
      }  
  ]  
}

Parameters

| mgp.denyrdpinternet.parameters.json
{  
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",  
    "contentVersion": "1.0.0.0",  
    "parameters": {  
      "targetMG": {  
        "value": "mg-shift01"  
      },  
      "deniedPorts": {  
        "value": [  
          "3389" 
        ]  
      }  
    }  
}

Add Managed Policy

You can also assign managed policies:

  • Go to Azure → Policy
  • Go to Definitions
  • Set the definition type to Policy and other filters for the policy that you need, for example: “Audit VMs that do not use managed disks”
  • Click on the policy definition and note the Definition ID, for example: “/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d”

Template

| mgp.auditvmsunmanageddisks.json
{ 
  "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#", 
  "contentVersion": "1.0.0.0", 
  "parameters": { 
      "targetMG": { 
          "type": "string", 
          "metadata": { 
              "description": "Target Management Group" 
          } 
      }, 
      "policyDefinitionID": { 
        "type": "string", 
        "metadata": { 
          "description": "Specifies the ID of the policy definition or policy set definition being assigned." 
        } 
      } 
  }, 
  "variables": { 
      "mgScope": "[tenantResourceId('Microsoft.Management/managementGroups', parameters('targetMG'))]", 
      "policyDefinitionName": "VMsWithoutManagedDisks" 
  }, 
  "resources": [ 
      { 
          "type": "Microsoft.Authorization/policyAssignments", 
          "name": "[variables('policyDefinitionName')]", 
          "apiVersion": "2019-09-01", 
          "properties": { 
              "scope": "[variables('mgScope')]", 
              "policyDefinitionId": "[parameters('policyDefinitionID')]" 
          } 
      } 
  ] 
}
Note that the policyDefinitionName cannot exceed 24 characters

Parameters

| mgp.auditvmsunmanageddisks.parameters.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
      "targetMG": { 
        "value": "mg-shift01" 
      }, 
      "policyDefinitionID": { 
        "value": "/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d" 
      } 
    } 
}

Budget

Template

| budget.json
{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "companyname": { 
          "type": "string", 
          "defaultValue": "myBudget", 
          "metadata": { 
            "description": "Default name for everything." 
          } 
        }, 
        "amount": {
            "type": "string",
            "defaultValue": "1000",
            "metadata": {
                "description": "The total amount of cost or usage to track with the budget"
            }
        },
        "budgetCategory": {
            "type": "string",
            "defaultValue": "Cost",
            "allowedValues": [
                "Cost",
                "Usage"
            ],
            "metadata": {
                "description": "The category of the budget, whether the budget tracks cost or usage."
            }
        },
        "timeGrain": {
            "type": "string",
            "defaultValue": "Monthly",
            "allowedValues": [
                "Monthly",
                "Quarterly",
                "Annually"
            ],
            "metadata": {
                "description": "The time covered by a budget. Tracking of the amount will be reset based on the time grain."
            }
        },
        "myDate": {
            "type": "string",
            "defaultValue": "[utcNow('yyyy-MM-dd')]", 
            "metadata": {
                "description": "Check output in output section. "
            }
        },
        "startDate": {
            "type": "string",
            "defaultValue": "[utcNow('yyyy-MM-dd')]", 
            "metadata": {
                "description": "The start date must be first of the month in YYYY-MM-DD format. Future start date should not be more than three months. Past start date should be selected within the timegrain period. Please consider impact when redeploying templates on using dates that are regenerated. "
            }
        },
        "endDate": {
            "type": "string",
            "defaultValue": "",
            "metadata": {
                "description": "The end date for the budget in YYYY-MM-DD format. If not provided, we default this to 10 years from the start date."
            }
        },
        "operator": {
            "type": "string",
            "defaultValue": "GreaterThan",
            "allowedValues": [
                "EqualTo",
                "GreaterThan",
                "GreaterThanOrEqualTo"
            ],
            "metadata": {
                "description": "The comparison operator."
            }
        },
        "threshold": {
            "type": "string",
            "defaultValue": "90",
            "metadata": {
                "description": "Threshold value associated with a notification. Notification is sent when the cost exceeded the threshold. It is always percent and has to be between 0 and 1000."
            }
        },
        "contactEmails": {
            "type": "array",
            "metadata": {
                "description": "The list of email addresses to send the budget notification to when the threshold is exceeded."
            }
        },
        "contactRoles": {
            "type": "array",
            "defaultValue": [
                "Owner",
                "Contributor",
                "Reader"
            ],
            "metadata": {
                "description": "The list of contact roles to send the budget notification to when the threshold is exceeded."
            }
        },
        "contactGroups": {
            "type": "array",
            "metadata": {
                "description": "The list of action groups to send the budget notification to when the threshold is exceeded. It accepts array of strings."
            }
        }
    },
    "variables": {
        "budgetName": "[concat('budget-', parameters('companyname'))]"
    },
    "resources": [
        {
            "type": "Microsoft.Consumption/budgets",
            "name": "[variables('budgetName')]",
            "apiVersion": "2019-10-01",
            "properties": {
                "category": "[parameters('budgetCategory')]",
                "amount": "[parameters('amount')]",
                "timeGrain": "[parameters('timeGrain')]",
                "timePeriod": {
                    "startDate": "[parameters('startDate')]",
                    "endDate": "[parameters('endDate')]"
                },
                "notifications": {
                    "First-Notification": {
                        "enabled": true,
                        "operator": "[parameters('operator')]",
                        "threshold": "[parameters('threshold')]",
                        "contactEmails": "[parameters('contactEmails')]",
                        "contactRoles": "[parameters('contactRoles')]",
                        "contactGroups": "[parameters('contactGroups')]"
                    }
                }
            }
        }
    ],
    "outputs": {
        "date": {
            "type": "string",
            "value": "[parameters('myDate')]"            
        }
    }
}

Parameters

| budget.parameters.json
{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
      "companyname": {
        "value": "myBudget"
      },
      "amount": {
        "value": "1000"
      },
      "budgetCategory": {
        "value": "Cost"
      },
      "timeGrain": {
        "value": "Monthly"
      },
      "operator": {
        "value": "GreaterThan"
      },
      "threshold": {
        "value": "90"
      },
      "contactEmails": {
        "value": []
      },
      "contactRoles": {
        "value": [
          "Owner"
        ]
      },
      "contactGroups": {
        "value": []
      }
    }
}

Azure Portal Dashboard

And now the place where it all should come together, an Azure Dashboard with useful information to show you a State of the Day, which you can use for compliance and security objectives. Note that the Azure Portal Dashboard has a few drawbacks:

  • You can't add everything. You can pin a lot of items either through the dashboard or from the portal resource itself, put if the resource doesn't display a little pin next to the blade title and it doesn't show in the edit options from the dashboard you can't add it natively
  • You can add most of the rest using Graph queries using Kusto. However, you can't rename the tile you're displaying the data in, so you need to use Markdown items to explain what everything is.
  • The Markdown tile has it's own limitations in display options and support, for example it doesn't allow for javascript, iframes and style tags.

Secure Score

You can add the Azure Secure Score by going to edit → Add Security Metric.

Date and Time

You can add the time and date (useful for compliance reporting) by going to edit → Add Clock

Policy Compliance

Now remember we've set policies to the management group to ensure compliance over all of our subscriptions. However, this view can't be added to the DashBoard using native tiles. We will add the compliance overview using the Resource Graph grid tile. Now we want to add two overviews for now:

  • An overview of the assigned policies and the number of resources that are compliant and non-compliant
  • A list of the resources that are non-compliant per assigned policy (note that we will create only one for now)
Additionally we will add markdown tiles for explanations and a link to the real policy overview.

Overview of Assigned Policies on a Management Group

Add a Resource graph grid tile by going to Edit → Add “Resource graph grid tile”, and save the dashboard, as you can only edit the graph query from the “normal” dashboard view. After saving you can configure the tile by clicking “Configure Tile”

Kusto Query

Enter this query:

//Non-Compliant resources
policyresources
| where properties['complianceState'] == "NonCompliant" and properties['policyAssignmentScope'] has "/providers/Microsoft.Management/managementGroups/"
| summarize NonCompliantResources = count() by policyAssignmentName = tostring(properties['policyAssignmentName'])
//Compliant resources
policyresources
| where properties['complianceState'] == "Compliant" and properties['policyAssignmentScope'] has "/providers/Microsoft.Management/managementGroups/"
| summarize CompliantResources = count() by policyAssignmentName = tostring(properties['policyAssignmentName'])
//Combine compliant with non-compliant
policyresources
| where properties['policyAssignmentScope'] has "/providers/Microsoft.Management/managementGroups/"
| extend ComplianceState = properties['complianceState']
| summarize NonCompliantResources = countif(ComplianceState == "NonCompliant") by policyAssignmentName = tostring(properties['policyAssignmentName'])
| join ( policyresources
	| where properties['policyAssignmentScope'] has "/providers/Microsoft.Management/managementGroups/"
	| extend ComplianceState = properties['complianceState']
	| summarize CompliantResources = countif(ComplianceState == "Compliant") by policyAssignmentName = tostring(properties['policyAssignmentName'])
	) on policyAssignmentName
| project policyAssignmentName, NonCompliantResources, CompliantResources
Note that for completeness sake I've also listed the queries if you just want Compliant or Non-Compliant resources.

Click on “Update pinned part on dashboard” to save the query for the tile.

Overview of Non-Compliant Resources per Assignment

Note that you need to repeat these steps per Assignment.

You can repeat the steps above, except for the Kusto Query.

Kusto Query

Enter this query:

policyresources 
| where properties['complianceState'] == "NonCompliant" and properties['policyAssignmentName'] == "location-lock" 
| project location, resourceGroup, properties['policyAssignmentName'], properties['resourceType'], properties['resourceId']

Azure Costs

You can add a costs overview from the Costs Analysis blade itself. In the Azure Portal go to:

  • Cost Management + Billing
  • Go to Cost Management
  • Go to Cost Analysis
  • Make sure the scope is set to the Management Group and modify the view to your likings. Once done, click Save to save the view.
    • Note that the name you provide will also be the name of the Tile in the dashboard
  • Once you've saved the view, click the little pin next to the blade title (Cost Management: Shift Management Group | Cost Analysis)
  • Select the dashboard you want to pin the view on.

Now the Cost Analysis view is pinned to your dashboard.

Some More Resource Graphs

VM Count by VM Size

Note that you need a “Resource graph chart tile” for this one:
// VM Count by size 
extend sku = aliases['Microsoft.Compute/virtualMachines/sku.name']  
| where type=~'Microsoft.Compute/virtualMachines'  
| summarize count() by tostring(sku)  
| project sku, total=count_

List Public IP Addressess

Needs “Resource graph grid tile”
Resources 
| where type contains 'publicIPAddresses' and isnotempty(properties.ipAddress) 
| project properties.ipAddress 
| limit 100

VMs and Public IP address

Needs “Resource graph grid tile”
Resources 
| where type =~ 'microsoft.compute/virtualmachines' 
| extend nics=array_length(properties.networkProfile.networkInterfaces)  
| mv-expand nic=properties.networkProfile.networkInterfaces  
| where nics == 1 or nic.properties.primary =~ 'true' or isempty(nic)  
| project vmId = id, vmName = name, vmSize=tostring(properties.hardwareProfile.vmSize), nicId = tostring(nic.id)  
| join kind=leftouter ( 
    Resources 
    | where type =~ 'microsoft.network/networkinterfaces' 
    | extend ipConfigsCount=array_length(properties.ipConfigurations)  
    | mv-expand ipconfig=properties.ipConfigurations  
    | where ipConfigsCount == 1 or ipconfig.properties.primary =~ 'true' 
    | project nicId = id, publicIpId = tostring(ipconfig.properties.publicIPAddress.id)) 
on nicId 
| project-away nicId1 
| summarize by vmId, vmName, vmSize, nicId, publicIpId 
| join kind=leftouter ( 
    Resources 
    | where type =~ 'microsoft.network/publicipaddresses' 
    | project publicIpId = id, publicIpAddress = properties.ipAddress) 
on publicIpId 
| project-away publicIpId1

Deploy Azure Dashboard using ARM Templates

You can follow the procedure below to deploy the dashboard I created. Note that to make it useful for repeated deployment you would create a default for projects so you can deploy a dashboard per project for example.

  • To deploy a custom dashboard we first need to create one so we can download it. We'll use the  previously created one with all the modifications.
  • Go to the dashboard and click Download.
Note that after downloading, you need to make it into a template, as explained here. In my case, it just comes down to adding the parameter dashboardName and refer to the parameter in the name and tags properties.

Template

| dashboard.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "dashboardName": { 
            "type": "string" 
        }, 
        "location": {  
            "type": "string" 
        },
        "targetMG": {  
            "type": "string", 
            "metadata": {  
                "description": "Target Management Group"  
            }  
        }
    }, 
    "variables": {
        "budgetid": "[concat('budget-', parameters('dashboardName'))]",
        "dashtitle": "[concat(parameters('dashboardName'), ' Azure Dashboard')]"
    }, 
    "resources": [ 
        { 
        "properties": { 
            "lenses": { 
            "0": { 
                "order": 0, 
                "parts": { 
                "0": { 
                    "position": { 
                    "x": 0, 
                    "y": 0, 
                    "colSpan": 4, 
                    "rowSpan": 4 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/HubsExtension/PartType/MarkdownPart", 
                    "settings": { 
                        "content": { 
                        "settings": { 
                            "content": "__Dashboard usage__\n\nUse the dashboard to get a comprehensive view of the Azure environment. \n", 
                            "title": "[variables('dashtitle')]", 
                            "subtitle": "State of the Day", 
                            "markdownSource": 1, 
                            "markdownUri": null 
                        } 
                        } 
                    } 
                    } 
                }, 
                "1": { 
                    "position": { 
                    "x": 4, 
                    "y": 0, 
                    "colSpan": 2, 
                    "rowSpan": 2 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/Microsoft_AAD_IAM/PartType/OrganizationIdentityPart" 
                    } 
                }, 
                "2": { 
                    "position": { 
                    "x": 6, 
                    "y": 0, 
                    "colSpan": 2, 
                    "rowSpan": 3 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/Microsoft_Azure_Security/PartType/SecurityMetricGalleryTileViewModel" 
                    } 
                }, 
                "3": { 
                    "position": { 
                    "x": 8, 
                    "y": 0, 
                    "colSpan": 6, 
                    "rowSpan": 4 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "scope", 
                        "value": "[subscription().id]" 
                        }, 
                        { 
                        "name": "scopeName", 
                        "value": "[subscription().displayName]" 
                        }, 
                        { 
                        "name": "view", 
                        "value": { 
                            "currency": "EUR", 
                            "dateRange": "CurrentBillingPeriod", 
                            "query": { 
                            "type": "ActualCost", 
                            "dataSet": { 
                                "granularity": "Daily", 
                                "aggregation": { 
                                "totalCost": { 
                                    "name": "Cost", 
                                    "function": "Sum" 
                                } 
                                }, 
                                "sorting": [ 
                                { 
                                    "direction": "ascending", 
                                    "name": "UsageDate" 
                                } 
                                ] 
                            }, 
                            "timeframe": "None" 
                            }, 
                            "chart": "Area", 
                            "accumulated": "true", 
                            "pivots": [ 
                            { 
                                "type": "Dimension", 
                                "name": "ServiceName" 
                            }, 
                            { 
                                "type": "Dimension", 
                                "name": "ResourceLocation" 
                            }, 
                            { 
                                "type": "Dimension", 
                                "name": "Subscription" 
                            } 
                            ], 
                            "scope": "[subscription().id]", 
                            "kpis": [ 
                            { 
                                "type": "Budget", 
                                "id": "[subscriptionResourceId('Microsoft.Consumption/budgets', variables('budgetid'))]",
                                "enabled": true, 
                                "extendedProperties": {} 
                            }, 
                            { 
                                "type": "Forecast", 
                                "enabled": true 
                            } 
                            ], 
                            "displayName": "DefaultCostAnalysis" 
                        }, 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "externalState", 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/Microsoft_Azure_CostManagement/PartType/CostAnalysisPinPart", 
                    "deepLink": "#blade/Microsoft_Azure_CostManagement/Menu/costanalysis" 
                    } 
                }, 
                "4": { 
                    "position": { 
                    "x": 4, 
                    "y": 2, 
                    "colSpan": 2, 
                    "rowSpan": 2 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/HubsExtension/PartType/ClockPart", 
                    "settings": { 
                        "content": { 
                        "settings": { 
                            "timezoneId": "W. Europe Standard Time", 
                            "timeFormat": "HH:mm", 
                            "version": 1 
                        } 
                        } 
                    } 
                    } 
                }, 
                "5": { 
                    "position": { 
                    "x": 0, 
                    "y": 4, 
                    "colSpan": 8, 
                    "rowSpan": 2 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/HubsExtension/PartType/MarkdownPart", 
                    "settings": { 
                        "content": { 
                        "settings": { 
                            "content": "[concat('View <a href=\"https://portal.azure.com/#blade/Microsoft_Azure_Policy/PolicyMenuBlade/Compliance/scope/%2Fproviders%2FMicrosoft.Management%2FmanagementGroups%2F', parameters('targetMG'), '\">all</a> policies on managementgroup ', parameters('targetMG'))]",
                            "title": "All Policies assigned to ManagementGroups", 
                            "subtitle": "My subtitle", 
                            "markdownSource": 1, 
                            "markdownUri": null 
                        } 
                        } 
                    } 
                    } 
                }, 
                "6": { 
                    "position": { 
                    "x": 0, 
                    "y": 6, 
                    "colSpan": 10, 
                    "rowSpan": 4 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "chartType", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "isShared", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "queryId", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "partTitle", 
                        "value": "Query 1", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "query", 
                        "value": "//Combine compliant with non-compliant\npolicyresources\n| where properties['policyAssignmentScope'] has \"/providers/Microsoft.Management/managementGroups/\"\n| extend ComplianceState = properties['complianceState']\n| summarize NonCompliantResources = countif(ComplianceState == \"NonCompliant\") by policyAssignmentName = tostring(properties['policyAssignmentName'])\n| join ( policyresources\n\t| where properties['policyAssignmentScope'] has \"/providers/Microsoft.Management/managementGroups/\"\n\t| extend ComplianceState = properties['complianceState']\n\t| summarize CompliantResources = countif(ComplianceState == \"Compliant\") by policyAssignmentName = tostring(properties['policyAssignmentName'])\n\t) on policyAssignmentName\n| project policyAssignmentName, NonCompliantResources, CompliantResources", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "formatResults", 
                        "value": true, 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/HubsExtension/PartType/ArgQueryGridTile", 
                    "settings": {} 
                    } 
                }, 
                "7": { 
                    "position": { 
                    "x": 0, 
                    "y": 10, 
                    "colSpan": 7, 
                    "rowSpan": 1 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/HubsExtension/PartType/MarkdownPart", 
                    "settings": { 
                        "content": { 
                        "settings": { 
                            "content": "\n", 
                            "title": "Non-Compliant Resources", 
                            "subtitle": "For policy Assignment location-lock", 
                            "markdownSource": 1, 
                            "markdownUri": null 
                        } 
                        } 
                    } 
                    } 
                }, 
                "8": { 
                    "position": { 
                    "x": 0, 
                    "y": 11, 
                    "colSpan": 25, 
                    "rowSpan": 6 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "chartType", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "isShared", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "queryId", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "partTitle", 
                        "value": "Query 1", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "query", 
                        "value": "policyresources\r\n| where properties['complianceState'] == \"NonCompliant\" and properties['policyAssignmentName'] == \"location-lock\"\r\n| project location, resourceGroup, properties['policyAssignmentName'], properties['resourceType'], properties['resourceId']", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "formatResults", 
                        "value": true, 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/HubsExtension/PartType/ArgQueryGridTile", 
                    "settings": {} 
                    } 
                }, 
                "9": { 
                    "position": { 
                    "x": 0, 
                    "y": 17, 
                    "colSpan": 7, 
                    "rowSpan": 1 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/HubsExtension/PartType/MarkdownPart", 
                    "settings": { 
                        "content": { 
                        "settings": { 
                            "content": "", 
                            "title": "Resource Information", 
                            "subtitle": "Overview of Resources | VM Sizes", 
                            "markdownSource": 1, 
                            "markdownUri": null 
                        } 
                        } 
                    } 
                    } 
                }, 
                "10": { 
                    "position": { 
                    "x": 0, 
                    "y": 18, 
                    "colSpan": 6, 
                    "rowSpan": 7 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "resourceType", 
                        "value": "Microsoft.Resources/resources", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "filter", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "scope", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "kind", 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/HubsExtension/PartType/BrowseAllResourcesPinnedPart" 
                    } 
                }, 
                "11": { 
                    "position": { 
                    "x": 6, 
                    "y": 18, 
                    "colSpan": 7, 
                    "rowSpan": 7 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "isShared", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "queryId", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "formatResults", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "partTitle", 
                        "value": "Query 1", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "query", 
                        "value": "// VM Count by size\nextend sku = aliases['Microsoft.Compute/virtualMachines/sku.name'] \n| where type=~'Microsoft.Compute/virtualMachines' \n| summarize count() by tostring(sku) \n| project sku, total=count_", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "chartType", 
                        "value": 1, 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/HubsExtension/PartType/ArgQueryChartTile", 
                    "settings": {} 
                    } 
                }, 
                "12": { 
                    "position": { 
                    "x": 13, 
                    "y": 18, 
                    "colSpan": 11, 
                    "rowSpan": 6 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "chartType", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "isShared", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "queryId", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "formatResults", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "partTitle", 
                        "value": "Query 1", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "query", 
                        "value": "Resources\n| where type =~ 'microsoft.compute/virtualmachines'\n| extend nics=array_length(properties.networkProfile.networkInterfaces) \n| mv-expand nic=properties.networkProfile.networkInterfaces \n| where nics == 1 or nic.properties.primary =~ 'true' or isempty(nic) \n| project vmId = id, vmName = name, vmSize=tostring(properties.hardwareProfile.vmSize), nicId = tostring(nic.id) \n| join kind=leftouter (\n    Resources\n    | where type =~ 'microsoft.network/networkinterfaces'\n    | extend ipConfigsCount=array_length(properties.ipConfigurations) \n    | mv-expand ipconfig=properties.ipConfigurations \n    | where ipConfigsCount == 1 or ipconfig.properties.primary =~ 'true'\n    | project nicId = id, publicIpId = tostring(ipconfig.properties.publicIPAddress.id))\non nicId\n| project-away nicId1\n| summarize by vmId, vmName, vmSize, nicId, publicIpId\n| join kind=leftouter (\n    Resources\n    | where type =~ 'microsoft.network/publicipaddresses'\n    | project publicIpId = id, publicIpAddress = properties.ipAddress)\non publicIpId\n| project-away publicIpId1", 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/HubsExtension/PartType/ArgQueryGridTile", 
                    "settings": {} 
                    } 
                }, 
                "13": { 
                    "position": { 
                    "x": 0, 
                    "y": 25, 
                    "colSpan": 7, 
                    "rowSpan": 1 
                    }, 
                    "metadata": { 
                    "inputs": [], 
                    "type": "Extension/HubsExtension/PartType/MarkdownPart", 
                    "settings": { 
                        "content": { 
                        "settings": { 
                            "content": "", 
                            "title": "Public IP Addresses", 
                            "subtitle": "", 
                            "markdownSource": 1, 
                            "markdownUri": null 
                        } 
                        } 
                    } 
                    } 
                }, 
                "14": { 
                    "position": { 
                    "x": 0, 
                    "y": 26, 
                    "colSpan": 6, 
                    "rowSpan": 4 
                    }, 
                    "metadata": { 
                    "inputs": [ 
                        { 
                        "name": "chartType", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "isShared", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "queryId", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "formatResults", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "partTitle", 
                        "value": "Query 1", 
                        "isOptional": true 
                        }, 
                        { 
                        "name": "query", 
                        "value": "Resources\n| where type contains 'publicIPAddresses' and isnotempty(properties.ipAddress)\n| project properties.ipAddress\n| limit 100", 
                        "isOptional": true 
                        } 
                    ], 
                    "type": "Extension/HubsExtension/PartType/ArgQueryGridTile", 
                    "settings": {} 
                    } 
                } 
                } 
            } 
            }, 
            "metadata": { 
            "model": { 
                "timeRange": { 
                "value": { 
                    "relative": { 
                    "duration": 24, 
                    "timeUnit": 1 
                    } 
                }, 
                "type": "MsPortalFx.Composition.Configuration.ValueTypes.TimeRange" 
                }, 
                "filterLocale": { 
                "value": "en-us" 
                }, 
                "filters": { 
                "value": { 
                    "MsPortalFx_TimeRange": { 
                    "model": { 
                        "format": "utc", 
                        "granularity": "auto", 
                        "relative": "24h" 
                    }, 
                    "displayCache": { 
                        "name": "UTC Time", 
                        "value": "Past 24 hours" 
                    }, 
                    "filteredPartIds": [] 
                    } 
                } 
                } 
            } 
            } 
        }, 
        "name": "[parameters('dashboardName')]", 
        "type": "Microsoft.Portal/dashboards", 
        "location": "[parameters('location')]", 
        "tags": { 
            "hidden-title": "[parameters('dashboardName')]" 
        }, 
        "apiVersion": "2015-08-01-preview" 
        } 
    ] 
}

Parameters

| dashboard.parameters.json
{  
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",  
    "contentVersion": "1.0.0.0",  
    "parameters": {  
      "dashboardName": {  
        "value": "Shift-Dashboard"  
      }, 
      "location": {  
          "value": "westeurope"  
      },
      "targetMG": {
          "value": "mg-shift" 
      }
    }  
}

 Azure DevOps

Now let's move all of this into Azure DevOps.

Setup Azure DevOps

I won't go to deep into setting up Azure DevOps but I'll list the steps:

  • In Azure DevOps:
    • Create a new project and clone the repository into VS Code
  • In VS Code:
    • Create a folder called InfraAsCode and create the (empty) file aznetwork.json in the cloned repository.
    • Create a folder called Build and create the (empty) file azpipeline.yaml in the cloned repository.
    • Commit (Initial commit, created and initialized the repo) and push the changes to the repository.
  • In Azure DevOps

Create a Pipeline

As true DevOps engineers we'll create a pipelin in yaml:

  • Go to pipelines → click Create Pipeline
  • Select Azure Repos Git (YAML)
  • Select the default repository that was created upon project creation
  • Select Existing Azure Pipelines YAML file → and select the previously created yaml file
    • You can now immediately edit the file, when you're done, click save and commit the change.
      • Don't select save and run as we haven't added the arm template yet.
| azpipeline-starter.yaml
# Pipeline 
#  
# Description: 
#   Deploy Infrastructure as Code to Azure  
# 
# References: 
#   YAML: https://aka.ms/yaml 
trigger: 
- main 
pool: 
  vmImage: 'ubuntu-latest' 
steps: 
- task: PowerShell@2 
  displayName: 'PS: Display Build Variables' 
  inputs: 
    targetType: 'inline' 
    script: 'get-childitem -path env:*' 
    showWarnings: true 
    pwsh: true 
- task: AzureResourceManagerTemplateDeployment@3 
  inputs: 
    deploymentScope: 'Resource Group' 
    azureResourceManagerConnection: 'Visual Studio Professional' 
    subscriptionId: '6da33ac0-b34f-4a15-8a69-64c1eb3d45ee' 
    action: 'Create Or Update Resource Group' 
    resourceGroupName: 'rg_$(companyname)' 
    location: 'West Europe' 
    templateLocation: 'Linked artifact' 
    csmFile: 'deploynetwork.json' 
    deploymentMode: 'Incremental' 
    deploymentName: 'Network Deployment'

Final Yaml Pipeline

After adding all the tasks, tweaking, testing and workarounds, you should come up with the final yaml:

| azpipeline.yaml
# Pipeline
# 
# Description:
#   Deploy Infrastructure as Code to Azure 
#
# References:
#   YAML: https://aka.ms/yaml
#   ARM Deploy Task: https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/deploy/azure-resource-group-deployment 
#   Input parameters: https://docs.microsoft.com/en-us/azure/devops/pipelines/process/runtime-parameters
#   Condotions: https://docs.microsoft.com/en-us/azure/devops/pipelines/process/conditions?view=azure-devops&tabs=yaml 
#   Variables: https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch
#      - Do not use variables with _ in name, this will break yaml (works fine in classic pipelines)
#      - Pass variables to options within "" to preserve spaces
#   VM Password: https://docs.microsoft.com/en-us/azure/virtual-machines/windows/faq#what-are-the-password-requirements-when-creating-a-vm 
#   Secrets: https://devkimchi.com/2019/04/24/6-ways-passing-secrets-to-arm-templates/ 
#   PAT usage: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate
#   PAT creation: https://docs.microsoft.com/en-us/azure/devops/cli/log-in-via-pat
#   Role assignments: https://docs.microsoft.com/en-us/azure/role-based-access-control/role-assignments-cli 
#

parameters:
- name: defaultcompanyname
  displayName: Default Company Name. Will be used as base name for the resource group, vm, etc. Can be 2-8 char, all text. 
  type: string
  default: 
 
# Note. This is not a best practice. For production environments, setup your keyvault before deployment. 
- name: defaultpassword
  displayName: Default PassWord. Overwrite with your own value, using Upper and Lower charachters, numbers ans special characters, min, 12.
  type: string
  default: skipme

- name: designatedowner
  displayName: Provide the user principal name of the designated owner of the management group in case the Azure DevOps pipeline had read permissions to Azure AD. Otherwise, specify the object ID of the user, for example sjoerd@getshifting_com or aa8346ce-98d3-4223-accf-7ddd2a270388.
  type: string
  default: aa8346ce-98d3-4223-accf-7ddd2a270388

- name: softbudget
  displayName: Set a soft budget. Resources won't stop. Email to owner will be sent out on 90%. 24hour evaluation times may apply.
  type: string
  default: 45
 
# Disable CI/CD
trigger: none

pool:
  name: Azure Pipelines
  vmImage: 'windows-latest'
  #vmImage: 'ubuntu-latest'

steps:
# Parameters are required but checked here anyway if it is 0 characters, if so, it fails and exit the pipeline. 
- ${{ if eq(length(parameters.defaultcompanyname), 0) }}:
  - script: |
      echo Default Company Name is empty
      exit 1
    displayName: Check for input parameter
 
# The following task will display all parameters as a separate task when run. 
- ${{ each parameter in parameters }}:
  - script: echo ${{ parameter.Key }} ${{ parameter.Value }}
    displayName: Display all input parameters

- task: AzurePowerShell@5
  displayName: 'PS: Set Resource Group and Azure ID variables, create and display all system variables. '
  inputs:
    azureSubscription: 'Visual Studio Professional'
    ScriptType: 'InlineScript'
    Inline: |
      $Context = Get-AzContext
      $SubscriptionId = $Context.Subscription.Id
      $TenantId = $Context.Subscription.TenantId
      $firstdayofmonth = get-date -Format "yyyy-MM-01"
      Write-Host "##vso[task.setvariable variable=rg]rg_${{ parameters.defaultcompanyname }}"
      Write-Host "##vso[task.setvariable variable=subscriptionid]$SubscriptionId"
      Write-Host "##vso[task.setvariable variable=tenantid]$TenantId"
      Write-Host "##vso[task.setvariable variable=firstdayofmonth]$firstdayofmonth"
      get-childitem -path env:*
    azurePowerShellVersion: 'LatestVersion'
    pwsh: true

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy Network ARM
  inputs:
    deploymentScope: 'Resource Group'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    action: 'Create Or Update Resource Group'
    resourceGroupName: '$(rg)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/aznetwork.json'
    csmParametersFile: 'InfraAsCode/aznetwork.parameters.json'
    overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}"'
    deploymentMode: 'Incremental'
    deploymentName: 'Network-Deployment'
 
# The following task only runs if the password is NOT set to skipme
- ${{ if ne(parameters.defaultpassword, 'skipme') }}:
  - task: AzureResourceManagerTemplateDeployment@3
    displayName: Deploy Key Vault ARM
    inputs:
      deploymentScope: 'Resource Group'
      azureResourceManagerConnection: 'Visual Studio Professional'
      subscriptionId: '$(subscriptionid)'
      action: 'Create Or Update Resource Group'
      resourceGroupName: '$(rg)'
      location: 'West Europe'
      templateLocation: 'Linked artifact'
      csmFile: 'InfraAsCode/keyvault.json'
      csmParametersFile: 'InfraAsCode/keyvault.parameters.json'
      overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}" -secretValue "${{ parameters.defaultpassword }}"'
      deploymentMode: 'Incremental'
      deploymentName: 'KeyVault-Deployment'
      deploymentOutputs: 'arm-output'
  - task: PowerShell@2
    displayName: 'PS: Create system variables from ARM Output. '
    inputs:
      filePath: 'Support/parse_arm_deployment_output.ps1'
      arguments: >
        -ArmOutputString '$(arm-output)'
      showWarnings: true
      pwsh: true
  - task: PowerShell@2
    displayName: 'PS: Display all system variables. '
    continueOnError: true 
    inputs:
      targetType: 'inline'
      script: |
        get-childitem -path env:*
      showWarnings: true
      pwsh: true
  - task: AzurePowerShell@5
    displayName: 'PS: Set Permission to access Key Vault from pipeline. '
    env:
      kvname: $(KEYVAULTNAME)
    inputs:
      azureSubscription: 'Visual Studio Professional'
      ScriptType: 'InlineScript'
      Inline: |
        $Context = Get-AzContext
        $AzureDevOpsServicePrincipal = Get-AzADServicePrincipal -ApplicationId $Context.Account.Id
        $ObjectId = $AzureDevOpsServicePrincipal.Id
        write-host "Test $ObjectId"
        write-host "Test $env:KVNAME"
        Set-AzKeyVaultAccessPolicy -VaultName $env:KVNAME -ObjectId $ObjectId -PermissionsToSecrets get,list
      azurePowerShellVersion: 'LatestVersion'
      pwsh: true
  - task: AzureKeyVault@1
    displayName: 'Vault: import all secrets from Vault'
    inputs:
      azureSubscription: 'Visual Studio Professional'
      KeyVaultName: $(KEYVAULTNAME)
      SecretsFilter: '*'
      RunAsPreJob: false

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy VM ARM
  inputs:
    deploymentScope: 'Resource Group'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    action: 'Create Or Update Resource Group'
    resourceGroupName: '$(rg)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/vm.json'
    csmParametersFile: 'InfraAsCode/vm.parameters.json'
    overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}" -adminPassword "$(vmAdminPassword)"'
    deploymentMode: 'Incremental'
    deploymentName: 'VM-Deployment'

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy Network Security ARM
  inputs:
    deploymentScope: 'Resource Group'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    action: 'Create Or Update Resource Group'
    resourceGroupName: '$(rg)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/networksecurity.json'
    csmParametersFile: 'InfraAsCode/networksecurity.parameters.json'
    overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}"'
    deploymentMode: 'Incremental'
    deploymentName: 'NetworkSecurity-Deployment'

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy Recovery Services Vault ARM
  inputs:
    deploymentScope: 'Resource Group'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    action: 'Create Or Update Resource Group'
    resourceGroupName: '$(rg)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/rsvault.json'
    csmParametersFile: 'InfraAsCode/rsvault.parameters.json'
    overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}"'
    deploymentMode: 'Incremental'
    deploymentName: 'RS-Vault-Deployment'

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy VM Backup ARM
  inputs:
    deploymentScope: 'Resource Group'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    action: 'Create Or Update Resource Group'
    resourceGroupName: '$(rg)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/vmbackup.json'
    csmParametersFile: 'InfraAsCode/vmbackup.parameters.json'
    overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}"'
    deploymentMode: 'Incremental'
    deploymentName: 'VM-Backup-Deployment'
 

- task: AzurePowerShell@5
  displayName: 'PS: Create Management Group and Assign Owner. '
  env:
    defaultname: "${{ parameters.defaultcompanyname }}"
    newowner: "${{ parameters.designatedowner }}"
  inputs:
    azureSubscription: 'Visual Studio Professional'
    ScriptType: 'InlineScript'
    Inline: |
      # Management Group
      $managementGroupId = "mg-" + $env:defaultname
      Write-Host "##vso[task.setvariable variable=mgid]$managementGroupId"
      $managementGroupDisplayName = $env:defaultname + " Management Group"
      # Define Object ID
      Write-Output "This task needs Azure DevOps to have read permissions on Azure Active Directory. "
      Write-Output "In Azure DevOps -> Project -> Project Settings -> Service Connections -> Find your Service Connection -> Manage Service Principal. "
      Write-Output "This will open the Azure AD registered Application page of the service principal. "
      Write-Output "Go to API permissions -> Add a Permission  -> Azure Active Directory Graph."
      Write-Output "Add the Application Permission Directory.Read.All and click Add Permissions. "
      Write-Output "Click Grant admin consent for ... to apply the new permissions. "
      if ($env:newowner -match "@"){
        $userid = (Get-AzADUser -UserPrincipalName $env:newowner).id
        Write-Output "$env:newowner object id = $userid"
        if (([string]::IsNullOrEmpty($userid))){
          Write-Host "##vso[task.logissue type=error]User Object ID for $env:newowner cannot be retrieved. Please retrieve the object id from the Cloud Shell: (Get-AzADUser -UserPrincipalName $env:newowner).id "
          exit 1
        }
      }else{
        $userid = $env:newowner
      }
      # Start
      Write-Output "The Object ID $userid will be owner of the new Management Group $managementGroupId"
      try{
        #New-AzManagementGroup -GroupName $managementGroupId -DisplayName $managementGroupDisplayName # Breaking change
        New-AzManagementGroup -GroupId $managementGroupId -DisplayName $managementGroupDisplayName # Breaking change
      }
      catch{
        Write-Output "Something failed while creating the management group. Error: "
        write-output "$($_.Exception.Message)"
        Write-Output "If the error matches Unable to cast object of type Microsoft.Azure.Management.ManagementGroups.Models.ManagementGroup to type Newtonsoft.Json.Linq.JObject it's because the management group already exists. "
      }
      try{
        New-AzRoleAssignment -ObjectId $userid -RoleDefinitionName "Owner" -Scope "/providers/Microsoft.Management/managementGroups/$managementGroupId"
      }
      catch{
        Write-Output "Something failed while assigning permissions. Error: "
        Write-Output "$($_.Exception.Message)"
        Write-Output "If the error matches Exception of type Microsoft.Rest.Azure.CloudException was thrown. this can be cautionally ignored. Check if the permissions were assigned correctly, but as far as I can find this is a general error message and a bug. "
      }
    azurePowerShellVersion: 'LatestVersion'
    pwsh: true

- task: AzureCLI@2
  displayName: Move Subscription to Management Group
  continueOnError: true 
  env:
      defaultname: "${{ parameters.defaultcompanyname }}"
      mgid: "$(mgid)"
      subid: "$(subscriptionid)"
  inputs:
    azureSubscription: 'Visual Studio Professional'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      echo "This all can and should be done in the previous task. But I initially used Azure CLI and wanted to keep some of it in the pipeline for future reference. "
      managementGroupDisplayName="$defaultname Management Group"
      echo "##vso[task.setvariable variable=mgname]$managementGroupDisplayName"
      echo "Note that if the subscription is already in a Management Group the Azure DevOps Service Principal needs (owner?) permissions to the existing management group. Also, the management group will not be visible to users until the subscription is assigned, unless explicit permissions are assigned (as done in the previous task). "
      echo "If the subscription is in the Tenant Root Group the Azure DevOps Service Principal needs (owner?) permissions on the subscription. "
      az account management-group list --query "[].{name:name, id:id}" --output tsv
      az account management-group subscription add --name $mgid --subscription $subid || echo "Moving the subscription failed, probably because of permissions. Please move the subscription manually. "

- task: PowerShell@2
  displayName: 'PS: Display all system variables. '
  continueOnError: true 
  inputs:
    targetType: 'inline'
    script: |
      get-childitem -path env:*
    showWarnings: true
    pwsh: true
 
# The next two tasks do not work currently. The first task creates an Azure DevOps Service Connection. The second task uses the service connection to deploy policies on a management group level. 
# Error if you try it anyway: #There was a resource authorization issue: "The pipeline is not valid. Job Job: Step AzureResourceManagerTemplateDeployment7 input ConnectedServiceName references service connection $(scname) which could not be found. The service connection does not exist or has not been authorized for use. For authorization details, refer to https://aka.ms/yamlauthz."
# If you want to use this method, use a fixed service connection name in the second task, and create / update a service connection in the first task with this fixed name. Optionally, you should create an additional task to delete the service connection. 
# Here I choose to deploy the policy using the AzureCLI. 
# - task: AzureCLI@2
#   displayName: Create Azure DevOps Management Group Service Connection
#   env:
#       defaultname: "${{ parameters.defaultcompanyname }}"
#       subid: "$(subscriptionid)"
#       tenantid: "$(tenantid)"
#       mgid: "$(mgid)"
#       mgname: "$(mgname)"
#   inputs:
#     azureSubscription: 'Visual Studio Professional'
#     scriptType: 'bash'
#     scriptLocation: 'inlineScript'
#     inlineScript: |
#       export SCNAME="SC - MG - "$defaultname
#       echo "##vso[task.setvariable variable=scname]$SCNAME"
#       export SCFILE=".\Support\serviceconnection.json"
#       export AZURE_DEVOPS_EXT_PAT="PATmustbeprovidedfromkeyvault"
#       cat $SCFILE
#       # Replace tokens in the template service connection file
#       sed -i "s/__tenant-id__/$tenantid/g" $SCFILE
#       sed -i "s/__management-group-id__/$mgid/g" $SCFILE
#       sed -i "s/__management-group-name__/$mgname/g" $SCFILE
#       sed -i "s/__service-connection-name__/$SCNAME/g" $SCFILE
#       cat $SCFILE
#       az devops configure -d organization=https://dev.azure.com/<ORGNAME>/
#       az devops service-endpoint create --service-endpoint-configuration $SCFILE --project <PROJECTNAME>
#       az devops service-endpoint list --project <PROJECTNAME> -o table 

- task: AzureCLI@2
  displayName: Deploy MG Policy - Allowed Locations
  env:
      mgid: "$(mgid)"
  inputs:
    azureSubscription: 'Visual Studio Professional'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      az deployment mg create --location westeurope \
        --management-group-id "$(mgid)" \
        --name "MGP-AllowedLocations" \
        --template-file "InfraAsCode/mgp.allowedlocations.json" \
        --parameters "InfraAsCode/mgp.allowedlocations.parameters.json" \
        --parameters targetMG="$(mgid)"

- task: AzureCLI@2
  displayName: Deploy MG Policy - Deny RDP with Inbound RDP
  env:
      mgid: "$(mgid)"
  inputs:
    azureSubscription: 'Visual Studio Professional'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      az deployment mg create --location westeurope \
        --management-group-id "$(mgid)" \
        --name "MGP-DenyRDPwithInboundInternet" \
        --template-file "InfraAsCode/mgp.denyrdpinternet.json" \
        --parameters @InfraAsCode/mgp.denyrdpinternet.parameters.json \
        --parameters targetMG="$(mgid)"

- task: AzureCLI@2
  displayName: Deploy MG Policy - Audit VMs that do not use managed disks
  env:
      mgid: "$(mgid)"
  inputs:
    azureSubscription: 'Visual Studio Professional'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      az deployment mg create --location westeurope \
        --management-group-id "$(mgid)" \
        --name "MGP-AuditVMsUnmanagedDisks" \
        --template-file "InfraAsCode/mgp.auditvmsunmanageddisks.json" \
        --parameters @InfraAsCode/mgp.auditvmsunmanageddisks.parameters.json \
        --parameters targetMG="$(mgid)"

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy Budget ARM
  inputs:
    deploymentScope: 'Subscription'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/budget.json'
    csmParametersFile: 'InfraAsCode/budget.parameters.json'
    overrideParameters: '-companyname "${{ parameters.defaultcompanyname }}" -amount "${{ parameters.softbudget }}" -startDate "$(firstdayofmonth)"'
    deploymentMode: 'Incremental'
    deploymentName: 'Budget-Deployment'

- task: AzureResourceManagerTemplateDeployment@3
  displayName: Deploy Dashboard ARM
  inputs:
    deploymentScope: 'Resource Group'
    azureResourceManagerConnection: 'Visual Studio Professional'
    subscriptionId: '$(subscriptionid)'
    action: 'Create Or Update Resource Group'
    resourceGroupName: '$(rg)'
    location: 'West Europe'
    templateLocation: 'Linked artifact'
    csmFile: 'InfraAsCode/dashboard.json'
    csmParametersFile: 'InfraAsCode/dashboard.parameters.json'
    overrideParameters: '-dashboardName "${{ parameters.defaultcompanyname }}" -targetMG "$(mgid)"'
    deploymentMode: 'Incremental'
    deploymentName: 'DashBoard-Deployment'
Note that the parse_arm_deployment_output.ps1 script that is used in the template is listed below.

Azure DevOps Service Connection

Note the two tasks that are commented out, I tried configuring a service connection for Management Group Deployment tasks, however, I finally was blocked by the restriction that the service connections must pre-exist of starting the pipeline. Because it took some time and I didn't want to waste it I kept it in the yaml, but for it to work you also need the serviceconnection.json template:

| serviceconnection.json
{
    "administratorsGroup": null,
    "authorization": {
        "scheme": "ManagedServiceIdentity",
        "parameters": {
            "tenantid": "__tenant-id__"
        }
    },
    "createdBy": null,
    "data": {
        "environment": "AzureCloud",
        "scopeLevel": "ManagementGroup",
        "managementGroupId": "__management-group-id__",
        "managementGroupName": "__management-group-name__"
    },
    "description": "",
    "groupScopeId": null,
    "name": "__service-connection-name__",
    "operationStatus": null,
    "readersGroup": null,
    "serviceEndpointProjectReferences": [
        {
            "description": "",
            "name": "__service-connection-name__"
        }
    ],
    "type": "azurerm",
    "url": "https://management.azure.com/",
    "isShared": false,
    "owner": "library"
}

ARM in Azure DevOps Lab Series

Very interesting series about ARM of DevOps lab with Abel Wang:

  • https://youtu.be/VWe-stknCIM - ARM Series #1: Demystifying ARM Templates - Introduction
  • https://youtu.be/YqoiR5HDhSo - ARM Series #2: Creating Your First Template
    • Use arm! in vscode with arm tools extension to create an empty arm template
      • Use ctrl+space to see the different valid options for a specific part of the ARM template, as well as additional properties to for example resources.
      • Has snippets for resources like stor → arm-storage → create storage account
        • Use tab to go through the different parts that you can modify
  • https://youtu.be/uYRLVx1bfZU - ARM Series #3: Parameters
    • Use armp! / new-parameter in vscode to add a parameter to the template file
    • Ctrl + Space also works for the type of a parameter
    • Right click on a template file and select “Select/Create Parameter file” to create the corresponding parameter file
  • https://youtu.be/n4WPf6F2oto – ARM Series #4: Template Functions
  • https://youtu.be/SvmdPNVcF_A – ARM Series #5: Variables
  • https://youtu.be/T-GnsabXcGY – ARM Series #6: Template Output
    • Reference to template output:  “name”: “[reference('linkedTemplate').outputs.resourceID.value]”
    • See below on parsing template output in CICD
  • https://youtu.be/Bl_gcsdnAH4 – ARM Series #7: Controlling Deployment
    • Condition is like a if statement, if true, then the resource gets deployed
  • https://youtu.be/rUY7wmz0W10 – ARM Series #8: Linked and Nested Templates
    • With inner expressionevaluation mode, you can import the properties of the outer template into the inner template
  • https://youtu.be/hEcFfXJNgeE – ARM Series #9: Loops
    • loops are done with the copy function
    • copyIndex is used to track the loop count, and starts with 0
    • Use mode:serial and batchSize within the copy properties of the resource to control the number of resources that are created at the same time
    • Use [length(parameters('name'))] if you want to provide names in an array for your resource, and use parameters('name')[copyIndex()] to grab the actual name
  • https://youtu.be/YrEAy7oLVew – ARM Series #10: Template for Resource X
  • https://youtu.be/DHFjc3UWvx4 - ARM Series #11: GitHub Actions
  • https://youtu.be/j4I3C6U8K4c - ARM Series #12: Azure DevOps With ARM Templates
    • Override parameter file: “-parametername value”
    • Give the deployment a name for easy looking up in the azure portal

Resulting ARM from the DevOps Lab Series

{ 
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", 
    "contentVersion": "1.0.0.0", 
    "parameters": { 
        "name": { 
            "type": "string", 
            "metadata": { 
                "description": "Base name of application, storage and plan. Text only. " 
            }, 
            "maxLength": 10 
        }, 
        "environment": { 
            "type": "string", 
            "allowedValues": [ 
                "DEV", 
                "QA", 
                "PROD" 
            ], 
            "defaultValue": "DEV", 
            "metadata": { 
                "description": "Define type of deployment" 
            } 
        }, 
        "webAppCount": { 
            "type": "int", 
            "defaultValue": 1, 
            "metadata": { 
                "description": "How many web apps do you need. " 
            } 
        } 
    }, 
    "functions": [], 
    "variables": { 
        "suffix": "[uniqueString(resourceGroup().id)]", 
        "planName": "[toLower(concat(parameters('name'),'pln', variables('suffix')))]", 
        "primeStorageName": "[toLower(concat(parameters('name'), variables('suffix')))]", 
        "storageInfo": { 
            "DEV": { 
                "storageSKU": "Standard_LRS", 
                "storageTier": "Standard" 
            }, 
            "QA": { 
                "storageSKU": "Premium_LRS", 
                "storageTier": "Premium" 
            }, 
            "PROD": { 
                "storageSKU": "Premium_LRS", 
                "storageTier": "Premium" 
            } 
        }, 
        "storageSKU": "[variables('storageInfo')[parameters('environment')].storageSKU]", 
        "storageTier": "[variables('storageInfo')[parameters('environment')].storageTier]" 
    }, 
    "resources": [ 
        { 
            "name": "[variables('primeStorageName')]", 
            "type": "Microsoft.Storage/storageAccounts", 
            "condition": "[equals(parameters('environment'), 'PROD')]", 
            "apiVersion": "2019-06-01", 
            "tags": { 
                "displayName": "[toLower(concat(parameters('name'), uniqueString(resourceGroup().id)))]" 
            }, 
            "location": "[resourceGroup().location]", 
            "kind": "StorageV2", 
            "sku": { 
                "name": "[variables('storageSKU')]", 
                "tier": "[variables('storageTier')]" 
            }, 
            "properties": { 
                "supportsHttpsTrafficOnly": true 
            } 
        }, 
        { 
            "name": "[toLower(concat(parameters('name'), copyIndex(), variables('suffix')))]", 
            "type": "Microsoft.Web/sites", 
            "apiVersion": "2018-11-01", 
            "copy":{ 
                "name": "webappcopy", 
                "count": "[parameters('webAppCount')]" 
            }, 
            "location": "[resourceGroup().location]", 
            "tags": { 
                "[concat('hidden-related:', resourceGroup().id, '/providers/Microsoft.Web/serverfarms/', variables('planName'))]": "Resource", 
                "displayName": "[toLower(concat(parameters('name'), copyIndex(), variables('suffix')))]" 
            }, 
            "dependsOn": [ 
                "[resourceId('Microsoft.Web/serverfarms', variables('planName'))]" 
            ], 
            "properties": { 
                "name": "[toLower(concat(parameters('name'), copyIndex(), variables('suffix')))]", 
                "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('planName'))]" 
            } 
        }, 
        { 
            "name": "[variables('planName')]", 
            "type": "Microsoft.Web/serverfarms", 
            "apiVersion": "2018-02-01", 
            "location": "[resourceGroup().location]", 
            "sku": { 
                "name": "F1", 
                "capacity": 1 
            }, 
            "tags": { 
                "displayName": "[variables('planName')]" 
            }, 
            "properties": { 
                "name": "[variables('planName')]" 
            } 
        } 
    ], 
    "outputs": { 
        "storageAccountName": { 
            "type": "string", 
            "condition": "[equals(parameters('environment'), 'PROD')]",
            "value": "[variables('primeStorageName')]" 
        }, 
        "storageEndpoint": { 
            "type": "object", 
            "condition": "[equals(parameters('environment'), 'PROD')]",
            "value": "[reference(variables('primeStorageName')).primaryEndPoints]" 
        }, 
        "storageKey": { 
            "type": "string", 
            "condition": "[equals(parameters('environment'), 'PROD')]",
            "value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('primeStorageName')), providers('Microsoft.Storage', 'storageAccounts').apiVersions[0]).keys[0].value]" 
        } 
    } 
}

Parse ARM Output to DevOps Parameters

CICD pipeline output, add the following line to your yaml arm deployment task:

deploymentOutputs: arm-output

| parse_arm_deployment_output.ps1
[CmdletBinding()] 
param ( 
    [Parameter(Mandatory)] 
    [ValidateNotNullOrEmpty()] 
    [string]$ArmOutputString, 
    [Parameter()] 
    [ValidateNotNullOrEmpty()] 
    [switch]$MakeOutput 
) 
Write-Output "Retrieved input: $ArmOutputString" 
$armOutputObj = $ArmOutputString | ConvertFrom-Json 
$armOutputObj.PSObject.Properties | ForEach-Object { 
    $type = ($_.value.type).ToLower() 
    $keyname = $_.Name 
    $vsoAttribs = @("task.setvariable variable=$keyName") 
    if ($type -eq "array") { 
        $value = $_.Value.value.name -join ',' ## All array variables will come out as comma-separated strings 
    } elseif ($type -eq "securestring") { 
        $vsoAttribs += 'isSecret=true' 
    } elseif ($type -ne "string") { 
        throw "Type '$type' is not supported for '$keyname'" 
    } else { 
        $value = $_.Value.value 
    } 
 
    if ($MakeOutput.IsPresent) { 
        $vsoAttribs += 'isOutput=true' 
    } 
    $attribString = $vsoAttribs -join ';' 
    $var = "##vso[$attribString]$value" 
    Write-Output -InputObject $var 
}
Run with:

- pwsh: $(System.DefaultWorkingDirectory)/parse_arm_deployment_output.ps1 -ArmOutputString '$(arm-output)' -MakeOutput -ErrorAction Stop

Note that a working example is listed in the azpipeline.yaml above.

Playing with ARM Functions

There are quite some functions available but sometimes they can be a bit tricky to play around with. I found that having a small template like the one below solves this very well. Just deploy using the “Deploy a custom template” option in the Azure portal and within a second you can view the output.

Template

| functionstemplate.json
{ 
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", 
    "contentVersion": "1.0.0.0", 
    "resources": [], 
    "outputs": { 
        "subscriptionOutput": { 
            "value": "[subscription()]", 
            "type" : "object" 
        }, 
		"subscriptionOutput2": { 
            "value": "[subscription().id]", 
            "type" : "string" 
        }, 
        "subscriptionOutput3": { 
            "value": "[subscription().subscriptionid]", 
            "type" : "string" 
        }, 
        "subscriptionOutput4": { 
            "value": "[subscription().tenantId]", 
            "type" : "string" 
        }, 
        "subscriptionOutput5": { 
            "value": "[subscription().displayName]", 
            "type" : "string" 
        }, 
        "subscriptionResourceOutput": { 
            "value": "[subscriptionResourceId('Microsoft.Consumption/budgets', 'budgetname')]", 
            "type" : "string" 
        } 
    } 
}

Parameter for Current Date and Time

"mydate": { 
            "type": "string", 
            "defaultValue": "[utcNow('yyyyMMddHHmm')]", 
            "metadata": { 
                "description": "description" 
            } 
        },

Resources

You could leave a comment if you were logged in.
armandyaml.txt · Last modified: 2021/09/24 00:24 (external edit)