├── LICENSE ├── README.md ├── bicep ├── ModuleVM.bicep └── main.bicep ├── docs ├── quickstart-infrastructure.png ├── reporting-mail.png ├── tag_syntax.png └── update-management-big-picture.png ├── runbooks ├── UM-CleanUp-Schedules.ps1 ├── UM-CleanUp-Snapshots.ps1 ├── UM-PostTasks.ps1 ├── UM-PreTasks.ps1 └── UM-ScheduleUpdatesWithVmsTags.ps1 └── terraform └── baseInfra.tf /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Santiago / Vincent Misson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure Update Management - Schedule VM patching with tags 2 | 3 | This repo is a set of Runbooks that allows you to schedule Azure Virtual Machines patching by simply applying the `POLICY_UPDATE` tag on machines. 4 | 5 | - [Azure Update Management - Schedule VM patching with tags](#azure-update-management---schedule-vm-patching-with-tags) 6 | - [Global Picture](#global-picture) 7 | - [`POLICY_UPDATE` syntax](#policy_update-syntax) 8 | - [Features](#features) 9 | - [Unsupported scenarios](#unsupported-scenarios) 10 | - [Runbooks description](#runbooks-description) 11 | - [Limitations](#limitations) 12 | - [Getting started](#getting-started) 13 | - [Quick deployment (for testing purpose)](#quick-deployment-for-testing-purpose) 14 | - [Production Deployment & Recommendations](#production-deployment--recommendations) 15 | - [Authors](#authors) 16 | 17 | # Global Picture 18 | 19 |  20 | 21 | Shared Runbooks need the following initial bricks to work: 22 | * A Log Analytics Workspace 23 | * An Automation Account linked to the Log Analytics Workspace 24 | * The Automation account must use a System-assigned Managed Identity 25 | * The System-assigned Managed Identity must have **Contributor** role 26 | * assigned on all subscriptions where machines (Azure VMs or Azure Arc Servers) can be found 27 | * assigned to the Resource Group where the Automation Account is located 28 | * Machines that need to be patched must have: 29 | * Log Analytics Agent Extension installed, and linked to the Log Analytics Workspace 30 | * `POLICY_UPDATE` key tag declared with appropriated value 31 | 32 | ## `POLICY_UPDATE` syntax 33 | 34 | Here is the syntax to follow for the `POLICY_UPDATE` tag: 35 | 36 |  37 | 38 | **Examples**: 39 | * VM1 - `POLICY_UPDATE=Friday;10:00 PM;Never;*java*;` will be patched every Friday, at 10:00 PM. Even if updates require reboot, the VM will not be rebooted. Packages containing `java` string will be excluded. 40 | * VM2 - `POLICY_UPDATE=Tuesday,Sunday;08:00 AM;IfRequired;;TeamA@abc.com` will be patched every Tuesday and Sunday, at 08:00 AM. The VM will be rebooted only if a patch needs the machine to be reboot to be taken into account. No excluded packages. When patching is done, TeamA@abc.com will receive the list of updated packages by mail. 41 | * VM3 - `POLICY_UPDATE=Sunday;07:00 PM;Always;;` will be patched every Synday at 07:00 PM. The VM will be rebooted after applying patches, even if it is not required. No excluded packages. 42 | * VM4 - `POLICY_UPDATE=Monday;01:00 PM;Always;*java*,*oracle*;TeamB@abc.com` will be patched every Monday at 01:00 PM. The VM will be rebooted after applying patches. Packages containing `java` or `oracle` string will be excluded. When patching is done, TeamB@abc.com will receive the list of updated packages by mail. 43 | 44 | ## Features 45 | 46 | Shared Runbooks allows you to: 47 | * Patch **Azure Virtual Machines** and **Azure Arc Servers** with [Supported OS](https://docs.microsoft.com/en-us/azure/automation/update-management/operating-system-requirements#supported-operating-systems) 48 | * Patch in a multi-subscriptions context: the system-assigned managed identity must have **Contributor** role assigned on each subscription. 49 | * Perform several pre and post patching tasks: 50 | * Pre scripts, before patching: 51 | * [OPTIONAL] Snapshot VM OS disk 52 | * [OPTIONAL] Start VM if it is stopped 53 | * Post scripts, after patching: 54 | * [OPTIONAL] Shutdown VM if it was started by pre-script. 55 | * [OPTIONAL] Send a patching report email 56 | * Support Azure Arc Server 57 | * Pre-scripts and post scripts are not supported for Azure Arc Servers 58 | * You can schedule Azure Arc Servers restarts using `POLICY_RESTART` tag with Runbooks available in [this repo](https://github.com/dawlysd/schedule-azure-arc-servers-restarts-with-tags) 59 | 60 | ## Unsupported scenarios 61 | 62 | Below Azure Resource cannot be patched by Azure Update Management: 63 | * [VMSS](https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/overview): Leverage [automatic OS image upgrade](https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-automatic-upgrade) instead. 64 | * [AVD](https://docs.microsoft.com/en-us/azure/virtual-desktop/): Leverage [automatic updates](https://docs.microsoft.com/en-us/azure/virtual-desktop/configure-automatic-updates) instead. 65 | * [AKS](https://docs.microsoft.com/en-us/azure/aks/): Leverage [auto-upgrade channel](https://docs.microsoft.com/en-US/azure/aks/upgrade-cluster#set-auto-upgrade-channel) and [planned-maintenance](https://docs.microsoft.com/en-us/azure/aks/planned-maintenance) instead. 66 | 67 | ## Runbooks description 68 | 69 | There is a set of 5 Runbooks that must be deployed in the Automation Account: 70 | * **UM-ScheduleUpdatesWithVmsTags**: Must be scheduled (at least) daily. Searches for all machines with the `POLICY_UPDATE` tag and configures the Update Management schedules. 71 | * **UM-PreTasks**: Triggered before patching, it can perform several optional actions like OS disk snapshot, start VM if stopped, etc.. 72 | * **UM-PostTasks**: Triggered after patching, it can perform several optional actions like stop VM if it was started, send patching report mail, etc.. 73 | * **UM-CleanUp-Snapshots**: Must be scheduled daily, to delete snapshots that are X days older. 74 | * **UM-CleanUp-Schedules**: Must be schedule (at least) daily. It removes Update Management schedules for VM machines that not longer have the `POLICY_UPDATE` tag 75 | 76 | ## Prerequisites 77 | 78 | Automation Account must have the following modules installed: 79 | * Az.ResourceGraph, >= 0.11.0 80 | * Az.ConnectedMachine >= 0.2.0 81 | * Az.Automation >= 1.7.1 82 | * Az.Compute >= 4.17.1 83 | 84 | **Note**: Runbooks must be deployed using Powershell Runtime v5.1 85 | 86 | ## Limitations 87 | 88 | * VMs must have a unique name. It is not possible to have several VMs with the same name using `POLICY_UPDATE` tag. 89 | * A VM must be part of a single schedule. If it is not the case, *UM-CleanUp-Schedules* has side effects. 90 | * When the `POLICY_UPDATE` tag is applied, the first patching can be done the day after the execution of *UM-ScheduleUpdatesWithVmsTags*. 91 | 92 | # Getting started 93 | 94 | ## Quick deployment (for testing purpose) 95 | 96 | To quickly test provided Runbooks, use the provided bicep script to deploy a complete testing infrastructure that will: 97 | * Deploy a Log Analytics Workspace 98 | * Deploy an Automation Account using a System-assigned Magaged Identity 99 | * Install Update Management solution 100 | * Assign *Contributor* role on the System-assigned Managed Identity to the Resource Group 101 | * Deploy 5 Runbooks to the Automation Accounts and schedule few of them 102 | * Deploy 1 VNet 103 | * Deploy 6 VMs (3 Windows, 3 Linux) in the VNet 104 | * with Log Analytics agent installed and plugged to the Log Analytics Workspace 105 | * with `POLICY_UPDATE` tag examples 106 | * If you want to receive a **mail report**, 2 variables in the Automation Account needs to be defined : 107 | * SendGridAPIKey (type: secure string): API access key provided by SendGrid to use your account 108 | * SendGridSender (type: string): Sender email address (from) configured on SendGrid 109 | 110 | Here is a *partial* screenshot of deployed resources: 111 |  112 | 113 | **Quick start:** 114 | 115 | * Prerequisites: 116 | ```bash 117 | # Create a resource group 118 | $ az group create --location westeurope --name MyRg 119 | 120 | $ git clone https://github.com/dawlysd/azure-update-management-with-tags.git 121 | ... 122 | $ cd azure-update-management-with-tags/bicep 123 | ``` 124 | 125 | * Deploy **without email feature**: 126 | ```bash 127 | $ az deployment group create --resource-group MyRg --template-file main.bicep 128 | ``` 129 | If you want to enable patching report email feature later, just update `SendGridSender` and `SendGridAPIKey` Automation Account variables. 130 | 131 | * Deploy **with email feature**: 132 | ```bash 133 | $ az deployment group create --resource-group MyRg --template-file main.bicep --parameters SendGridSender="no-reply@mydomain.fr" SendGridAPIKey="SG.XXXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX" 134 | ``` 135 | Infrastructure deployment will take around 5 minutes and it can take until 20 minutes to have update agent ready and first patching assessment. 136 | 137 | 138 | ## Production Deployment & Recommendations 139 | 140 | For scaling up of this model in production, we recommend: 141 | * Minimize the number of Log analytics workspace and Automation Account. 142 | * Option 1: Using a single log analytics workspace and a single automation account. Provided runbooks support this option. 143 | * Option 2: Having one log analytics workspace and one automation account per geo. Provided runbooks must be updated to adopt this option. 144 | * Leverage Policies to install Log Analytics agent on VMs 145 | * [Built-in Deploy Log Analytics agent for Linux VMs](https://portal.azure.com/#blade/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F053d3325-282c-4e5c-b944-24faffd30d77) 146 | * [Built-in Deploy - Configure Log Analytics agent to be enabled on Windows virtual machines](https://portal.azure.com/#blade/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F0868462e-646c-4fe3-9ced-a733534b6a2c) 147 | * Track all unpatched VMs with UM (due to incompatible OS…) 148 | 149 | # Authors 150 | 151 | [Vincent Misson](https://github.com/vmisson) & [David Santiago](https://github.com/dawlysd) 152 | -------------------------------------------------------------------------------- /bicep/ModuleVM.bicep: -------------------------------------------------------------------------------- 1 | param VmName string 2 | param VmLocation string 3 | param VmSize string 4 | param VmOsType string 5 | param VmOsPublisher string 6 | param VmOsOffer string 7 | param VmOsSku string 8 | param VmOsVersion string 9 | param VmNicSubnetId string 10 | param WorkspaceId string 11 | param WorkspaceKey string 12 | 13 | var VmOsDiskName = '${VmName}od01' 14 | var VmNicName = '${VmName}ni01' 15 | 16 | param tags_policy_update string 17 | param adminUsername string 18 | param adminPassword string 19 | 20 | resource Nic 'Microsoft.Network/networkInterfaces@2020-08-01' = { 21 | name: VmNicName 22 | location: VmLocation 23 | properties: { 24 | ipConfigurations: [ 25 | { 26 | name: 'ipconfig1' 27 | properties: { 28 | privateIPAllocationMethod: 'Dynamic' 29 | subnet: { 30 | id: VmNicSubnetId 31 | } 32 | primary: true 33 | } 34 | } 35 | ] 36 | dnsSettings: { 37 | dnsServers: [] 38 | } 39 | enableAcceleratedNetworking: false 40 | enableIPForwarding: false 41 | } 42 | } 43 | 44 | resource VirtualMachine 'Microsoft.Compute/virtualMachines@2019-07-01' = { 45 | name: VmName 46 | location: VmLocation 47 | tags: { 48 | POLICY_UPDATE: tags_policy_update 49 | } 50 | properties: { 51 | hardwareProfile: { 52 | vmSize: VmSize 53 | } 54 | storageProfile: { 55 | osDisk: { 56 | name: VmOsDiskName 57 | createOption: 'FromImage' 58 | osType: VmOsType 59 | } 60 | imageReference: { 61 | publisher: VmOsPublisher 62 | offer: VmOsOffer 63 | sku: VmOsSku 64 | version: VmOsVersion 65 | } 66 | } 67 | osProfile: { 68 | computerName: VmName 69 | adminUsername: adminUsername 70 | adminPassword: adminPassword 71 | } 72 | networkProfile: { 73 | networkInterfaces: [ 74 | { 75 | id: Nic.id 76 | } 77 | ] 78 | } 79 | } 80 | } 81 | 82 | output VirtualMachineId string = VirtualMachine.id 83 | 84 | resource VmLinuxLaw 'Microsoft.Compute/virtualMachines/extensions@2021-04-01' = if (VmOsType == 'Linux') { 85 | name: '${VmName}/OmsAgentForLinux' 86 | location: VmLocation 87 | properties:{ 88 | publisher: 'Microsoft.EnterpriseCloud.Monitoring' 89 | type: 'OmsAgentForLinux' 90 | typeHandlerVersion: '1.13' 91 | autoUpgradeMinorVersion: true 92 | settings:{ 93 | workspaceId: WorkspaceId 94 | } 95 | protectedSettings:{ 96 | workspaceKey: WorkspaceKey 97 | } 98 | } 99 | dependsOn:[ 100 | VirtualMachine 101 | Nic 102 | ] 103 | } 104 | 105 | 106 | resource VmWindowsLaw 'Microsoft.Compute/virtualMachines/extensions@2021-04-01' = if (VmOsType == 'Windows') { 107 | name: '${VmName}/MicrosoftMonitoringAgent' 108 | location: VmLocation 109 | properties:{ 110 | publisher: 'Microsoft.EnterpriseCloud.Monitoring' 111 | type: 'MicrosoftMonitoringAgent' 112 | typeHandlerVersion: '1.0' 113 | autoUpgradeMinorVersion: true 114 | settings:{ 115 | workspaceId: WorkspaceId 116 | } 117 | protectedSettings:{ 118 | workspaceKey: WorkspaceKey 119 | } 120 | } 121 | dependsOn:[ 122 | VirtualMachine 123 | Nic 124 | ] 125 | } 126 | -------------------------------------------------------------------------------- /bicep/main.bicep: -------------------------------------------------------------------------------- 1 | // This script is given for testing purpose 2 | // 3 | // For a given RG, it will 4 | // - Deploy a Log Analytics Workspace 5 | // - Deploy an Automation account using a System-assigned Managed Identity, linked to the Log Analytics Workspace, with Update Management solution installed 6 | // - Give Contributor role to System-assigned Managed Identity to the RG 7 | // - Deploy Runbooks to Automation Account 8 | // - Deploy 1 VNet 9 | // - Deploy 6 VMs (3 Windows, 3 Linux) in the VNet, with Log analytics agent plugged to the Log Analytics Workspace 10 | // 11 | // Example of execution: 12 | // az deployment group create --resource-group MyRg --template-file main.bicep 13 | 14 | // Location 15 | param Location string = 'West Europe' 16 | param LocationShort string = 'weu' 17 | 18 | // Vnet 19 | param VNetName string = '${LocationShort}-spoke-vnet' 20 | param VNetAddressSpace array = [ 21 | '10.0.0.0/16' 22 | ] 23 | param VnetSubnetWorkloadAddressSpace string = '10.0.0.0/24' 24 | 25 | // Log Analytic Workspace 26 | param LawName string = 'poc-updatemanamgenet-lag03' 27 | param LawSku string = 'pergb2018' 28 | 29 | param AutomationAccountName string = 'automationaccount03' 30 | 31 | // VMs 32 | param VmSize string = 'Standard_B2s' 33 | param VmWindows_2019DC bool = true 34 | param VmWindows_2016 bool = true 35 | param VmWindows_2012R2 bool = true 36 | param VmUbuntu_20_04 bool = true 37 | param VmUbuntu_18_04 bool = true 38 | param VmCentOS_7_4 bool = true 39 | param adminUsername string = 'User01' 40 | param adminPassword string = 'HelloFromPatchingManager!)1=&' 41 | 42 | // Mail 43 | param SendGridAPIKey string = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 44 | param SendGridSender string = 'noreplay@mydomain.com' 45 | 46 | resource Lag 'Microsoft.OperationalInsights/workspaces@2021-06-01' = { 47 | name: LawName 48 | location: Location 49 | properties:{ 50 | sku:{ 51 | name: LawSku 52 | } 53 | } 54 | } 55 | 56 | resource AutomationAccount 'Microsoft.Automation/automationAccounts@2020-01-13-preview' = { 57 | name: AutomationAccountName 58 | location: Location 59 | identity: { 60 | type: 'SystemAssigned' 61 | } 62 | properties:{ 63 | sku: { 64 | name: 'Basic' 65 | } 66 | } 67 | } 68 | 69 | 70 | resource Az_ResourceGraph 'Microsoft.Automation/automationAccounts/modules@2020-01-13-preview' = { 71 | name: '${AutomationAccountName}/Az.ResourceGraph' 72 | dependsOn: [ 73 | AutomationAccount 74 | ] 75 | properties: { 76 | contentLink: { 77 | uri: 'https://www.powershellgallery.com/api/v2/package/Az.ResourceGraph/0.11.0' 78 | version: '0.11.0' 79 | } 80 | } 81 | } 82 | 83 | resource LinkAutomationAccount 'Microsoft.OperationalInsights/workspaces/linkedServices@2020-08-01' = { 84 | name: '${Lag.name}/Automation' 85 | dependsOn:[ 86 | Lag 87 | AutomationAccount 88 | ] 89 | properties: { 90 | resourceId: AutomationAccount.id 91 | } 92 | } 93 | 94 | resource Updates 'Microsoft.OperationsManagement/solutions@2015-11-01-preview' = { 95 | name: 'Updates(${LawName})' 96 | location: Location 97 | plan: { 98 | name: 'Updates(${LawName})' 99 | product: 'OMSGallery/Updates' 100 | promotionCode: '' 101 | publisher: 'Microsoft' 102 | } 103 | properties: { 104 | workspaceResourceId: Lag.id 105 | } 106 | } 107 | 108 | resource Runbook_ScheduleUpdatesWithVmsTags 'Microsoft.Automation/automationAccounts/runbooks@2019-06-01' = { 109 | name: '${AutomationAccount.name}/UM-ScheduleUpdatesWithVmsTags' 110 | location: Location 111 | properties:{ 112 | runbookType: 'PowerShell' 113 | logProgress: false 114 | logVerbose: false 115 | logActivityTrace: 0 116 | publishContentLink: { 117 | uri: 'https://raw.githubusercontent.com/dawlysd/azure-update-management-with-tags/main/runbooks/UM-ScheduleUpdatesWithVmsTags.ps1' 118 | } 119 | } 120 | } 121 | 122 | resource Runbook_PreTasks 'Microsoft.Automation/automationAccounts/runbooks@2019-06-01' = { 123 | name: '${AutomationAccount.name}/UM-PreTasks' 124 | location: Location 125 | properties:{ 126 | runbookType: 'PowerShell' 127 | logProgress: false 128 | logVerbose: false 129 | logActivityTrace: 0 130 | publishContentLink: { 131 | uri: 'https://raw.githubusercontent.com/dawlysd/azure-update-management-with-tags/main/runbooks/UM-PreTasks.ps1' 132 | } 133 | } 134 | } 135 | 136 | resource Runbook_PostTasks 'Microsoft.Automation/automationAccounts/runbooks@2019-06-01' = { 137 | name: '${AutomationAccount.name}/UM-PostTasks' 138 | location: Location 139 | properties:{ 140 | runbookType: 'PowerShell' 141 | logProgress: false 142 | logVerbose: false 143 | logActivityTrace: 0 144 | publishContentLink: { 145 | uri: 'https://raw.githubusercontent.com/dawlysd/azure-update-management-with-tags/main/runbooks/UM-PostTasks.ps1' 146 | } 147 | } 148 | } 149 | 150 | resource Runbook_CleanUpSchedules 'Microsoft.Automation/automationAccounts/runbooks@2019-06-01' = { 151 | name: '${AutomationAccount.name}/UM-CleanUp-Schedules' 152 | location: Location 153 | properties:{ 154 | runbookType: 'PowerShell' 155 | logProgress: false 156 | logVerbose: false 157 | logActivityTrace: 0 158 | publishContentLink: { 159 | uri: 'https://raw.githubusercontent.com/dawlysd/azure-update-management-with-tags/main/runbooks/UM-CleanUp-Schedules.ps1' 160 | } 161 | } 162 | } 163 | 164 | resource Runbook_CleanUpSnapshots 'Microsoft.Automation/automationAccounts/runbooks@2019-06-01' = { 165 | name: '${AutomationAccount.name}/UM-CleanUp-Snapshots' 166 | location: Location 167 | properties:{ 168 | runbookType: 'PowerShell' 169 | logProgress: false 170 | logVerbose: false 171 | logActivityTrace: 0 172 | publishContentLink: { 173 | uri: 'https://raw.githubusercontent.com/dawlysd/azure-update-management-with-tags/main/runbooks/UM-CleanUp-Snapshots.ps1' 174 | } 175 | } 176 | } 177 | 178 | resource DailySchedule 'Microsoft.Automation/automationAccounts/schedules@2020-01-13-preview' = { 179 | name: '${AutomationAccount.name}/Schedules-ScheduleVmsWithTags' 180 | properties:{ 181 | description: 'Schedule daily' 182 | startTime: '' 183 | frequency: 'Day' 184 | interval: 1 185 | } 186 | } 187 | 188 | param Sched1Guid string = newGuid() 189 | resource ScheduleRunbook_ScheduleUpdatesWithVmsTags 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { 190 | name: '${AutomationAccount.name}/${Sched1Guid}' 191 | properties:{ 192 | schedule:{ 193 | name: split(DailySchedule.name, '/')[1] 194 | } 195 | runbook:{ 196 | name: split(Runbook_ScheduleUpdatesWithVmsTags.name, '/')[1] 197 | } 198 | } 199 | } 200 | 201 | param Sched2Guid string = newGuid() 202 | resource ScheduleRunbook_CleanUpSnapshots 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { 203 | name: '${AutomationAccount.name}/${Sched2Guid}' 204 | properties:{ 205 | schedule:{ 206 | name: split(DailySchedule.name, '/')[1] 207 | } 208 | runbook:{ 209 | name: split(Runbook_CleanUpSnapshots.name, '/')[1] 210 | } 211 | } 212 | } 213 | 214 | param Sched3Guid string = newGuid() 215 | resource ScheduleRunbook_CleanUpSchedules 'Microsoft.Automation/automationAccounts/jobSchedules@2020-01-13-preview' = { 216 | name: '${AutomationAccount.name}/${Sched3Guid}' 217 | properties:{ 218 | schedule:{ 219 | name: split(DailySchedule.name, '/')[1] 220 | } 221 | runbook:{ 222 | name: split(Runbook_CleanUpSchedules.name, '/')[1] 223 | } 224 | } 225 | } 226 | 227 | resource Variable_SendGridAPIKey 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = { 228 | name: '${AutomationAccount.name}/SendGridAPIKey' 229 | properties: { 230 | isEncrypted: true 231 | value: '"${SendGridAPIKey}"' 232 | } 233 | } 234 | 235 | resource Variable_SendGridSender 'Microsoft.Automation/automationAccounts/variables@2020-01-13-preview' = { 236 | name: '${AutomationAccount.name}/SendGridSender' 237 | properties: { 238 | isEncrypted: false 239 | value: '"${SendGridSender}"' 240 | } 241 | } 242 | 243 | param assignmentName string = guid(resourceGroup().id) 244 | resource SystemAssignedManagedIdentityRgContributor 'Microsoft.Authorization/roleAssignments@2020-03-01-preview' = { 245 | name: assignmentName 246 | properties: { 247 | roleDefinitionId: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c' 248 | principalId: AutomationAccount.identity.principalId 249 | } 250 | dependsOn:[ 251 | AutomationAccount 252 | // Workaround because AutomationAccount.identity.principalId takes time to be available ... 253 | // This workaround avoid the PrincipalNotFound error message 254 | Win01 255 | ] 256 | } 257 | 258 | resource Vnet 'Microsoft.Network/virtualNetworks@2020-08-01' = { 259 | name: VNetName 260 | location: Location 261 | properties: { 262 | addressSpace: { 263 | addressPrefixes: VNetAddressSpace 264 | } 265 | subnets: [ 266 | { 267 | name: 'workload' 268 | properties: { 269 | addressPrefix: VnetSubnetWorkloadAddressSpace 270 | } 271 | } 272 | ] 273 | virtualNetworkPeerings: [] 274 | enableDdosProtection: false 275 | } 276 | } 277 | 278 | module Win01 'ModuleVM.bicep' = if (VmWindows_2019DC) { 279 | name: 'Win01' 280 | params:{ 281 | VmName: 'VmWin2019DC' 282 | VmLocation: Location 283 | VmSize: VmSize 284 | VmOsType: 'Windows' 285 | VmOsPublisher: 'MicrosoftWindowsServer' 286 | VmOsOffer: 'WindowsServer' 287 | VmOsSku: '2019-Datacenter' 288 | VmOsVersion: 'latest' 289 | VmNicSubnetId: Vnet.properties.subnets[0].id 290 | WorkspaceId: Lag.properties.customerId 291 | WorkspaceKey: listKeys(Lag.id, '2015-03-20').primarySharedKey 292 | adminUsername: adminUsername 293 | adminPassword: adminPassword 294 | tags_policy_update: 'Friday;10:00 PM;Never;*java*;' 295 | } 296 | dependsOn:[ 297 | Lag 298 | Vnet 299 | ] 300 | } 301 | 302 | module Win02 'ModuleVM.bicep' = if (VmWindows_2016) { 303 | name: 'Win02' 304 | params:{ 305 | VmName: 'VmWin2016' 306 | VmLocation: Location 307 | VmSize: VmSize 308 | VmOsType: 'Windows' 309 | VmOsPublisher: 'MicrosoftWindowsServer' 310 | VmOsOffer: 'WindowsServer' 311 | VmOsSku: '2016-datacenter-gensecond' 312 | VmOsVersion: 'latest' 313 | VmNicSubnetId: Vnet.properties.subnets[0].id 314 | WorkspaceId: Lag.properties.customerId 315 | WorkspaceKey: listKeys(Lag.id, '2015-03-20').primarySharedKey 316 | adminUsername: adminUsername 317 | adminPassword: adminPassword 318 | tags_policy_update: 'Tuesday,Sunday;08:00 AM;IfRequired;;TeamA@abc.com' 319 | } 320 | dependsOn:[ 321 | Lag 322 | Vnet 323 | ] 324 | } 325 | 326 | module Win03 'ModuleVM.bicep' = if (VmWindows_2012R2) { 327 | name: 'Win03' 328 | params:{ 329 | VmName: 'VmWin2012R2' 330 | VmLocation: Location 331 | VmSize: VmSize 332 | VmOsType: 'Windows' 333 | VmOsPublisher: 'MicrosoftWindowsServer' 334 | VmOsOffer: 'WindowsServer' 335 | VmOsSku: '2012-r2-datacenter-gensecond' 336 | VmOsVersion: 'latest' 337 | VmNicSubnetId: Vnet.properties.subnets[0].id 338 | WorkspaceId: Lag.properties.customerId 339 | WorkspaceKey: listKeys(Lag.id, '2015-03-20').primarySharedKey 340 | adminUsername: adminUsername 341 | adminPassword: adminPassword 342 | tags_policy_update: '' 343 | } 344 | dependsOn:[ 345 | Lag 346 | Vnet 347 | ] 348 | } 349 | 350 | module Lnx01 'ModuleVM.bicep' = if (VmUbuntu_20_04) { 351 | name: 'Lnx01' 352 | params:{ 353 | VmName: 'VmUbuntu2004' 354 | VmLocation: Location 355 | VmSize: VmSize 356 | VmOsType: 'Linux' 357 | VmOsPublisher: 'canonical' 358 | VmOsOffer: '0001-com-ubuntu-server-focal' 359 | VmOsSku: '20_04-lts' 360 | VmOsVersion: 'latest' 361 | VmNicSubnetId: Vnet.properties.subnets[0].id 362 | WorkspaceId: Lag.properties.customerId 363 | WorkspaceKey: listKeys(Lag.id, '2015-03-20').primarySharedKey 364 | adminUsername: adminUsername 365 | adminPassword: adminPassword 366 | tags_policy_update: 'Sunday;07:00 PM;Always;;' 367 | } 368 | dependsOn:[ 369 | Lag 370 | Vnet 371 | ] 372 | } 373 | 374 | module Lnx02 'ModuleVM.bicep' = if (VmUbuntu_18_04) { 375 | name: 'Lnx02' 376 | params:{ 377 | VmName: 'VmUbuntu1804' 378 | VmLocation: Location 379 | VmSize: VmSize 380 | VmOsType: 'Linux' 381 | VmOsPublisher: 'canonical' 382 | VmOsOffer: 'UbuntuServer' 383 | VmOsSku: '18_04-lts-gen2' 384 | VmOsVersion: 'latest' 385 | VmNicSubnetId: Vnet.properties.subnets[0].id 386 | WorkspaceId: Lag.properties.customerId 387 | WorkspaceKey: listKeys(Lag.id, '2015-03-20').primarySharedKey 388 | adminUsername: adminUsername 389 | adminPassword: adminPassword 390 | tags_policy_update: 'Monday;01:00 PM;Always;*java*,*oracle*;TeamB@abc.com' 391 | } 392 | dependsOn:[ 393 | Lag 394 | Vnet 395 | ] 396 | } 397 | 398 | module Lnx03 'ModuleVM.bicep' = if (VmCentOS_7_4) { 399 | name: 'Lnx03' 400 | params:{ 401 | VmName: 'VmCentOS74' 402 | VmLocation: Location 403 | VmSize: VmSize 404 | VmOsType: 'Linux' 405 | VmOsPublisher: 'OpenLogic' 406 | VmOsOffer: 'CentOS' 407 | VmOsSku: '7_9-gen2' 408 | VmOsVersion: 'latest' 409 | VmNicSubnetId: Vnet.properties.subnets[0].id 410 | WorkspaceId: Lag.properties.customerId 411 | WorkspaceKey: listKeys(Lag.id, '2015-03-20').primarySharedKey 412 | adminUsername: adminUsername 413 | adminPassword: adminPassword 414 | tags_policy_update: '' 415 | } 416 | dependsOn:[ 417 | Lag 418 | Vnet 419 | ] 420 | } 421 | -------------------------------------------------------------------------------- /docs/quickstart-infrastructure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidsntg/azure-update-management-with-tags/2f833251ba58a1ae1d3bf7937a734c4bfefa6841/docs/quickstart-infrastructure.png -------------------------------------------------------------------------------- /docs/reporting-mail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidsntg/azure-update-management-with-tags/2f833251ba58a1ae1d3bf7937a734c4bfefa6841/docs/reporting-mail.png -------------------------------------------------------------------------------- /docs/tag_syntax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidsntg/azure-update-management-with-tags/2f833251ba58a1ae1d3bf7937a734c4bfefa6841/docs/tag_syntax.png -------------------------------------------------------------------------------- /docs/update-management-big-picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidsntg/azure-update-management-with-tags/2f833251ba58a1ae1d3bf7937a734c4bfefa6841/docs/update-management-big-picture.png -------------------------------------------------------------------------------- /runbooks/UM-CleanUp-Schedules.ps1: -------------------------------------------------------------------------------- 1 | ################# 2 | # CONFIGURATION # 3 | ################# 4 | 5 | # Deployment Schedule name Prefix. Must be the same used on Updatemanagement-CleanUpSchedules Runbook. 6 | $schedulePrefix = "ScheduledByTags-" 7 | 8 | ########## 9 | # SCRIPT # 10 | ########## 11 | 12 | Import-Module Az.Automation 13 | Import-Module Az.Compute 14 | 15 | # Connect to Azure with Automation Account system-assigned managed identity 16 | Disable-AzContextAutosave -Scope Process 17 | $AzureContext = (Connect-AzAccount -Identity -WarningAction Ignore).context 18 | $AzureContext = Set-AzContext -SubscriptionName $AzureContext.Subscription -DefaultProfile $AzureContext 19 | 20 | # Get current automation account 21 | $automationAccountsQuery = @{ 22 | Query = "resources 23 | | where type == 'microsoft.automation/automationaccounts'" 24 | } 25 | $automationAccounts = Search-AzGraph @automationAccountsQuery 26 | 27 | foreach ($automationAccount in $automationAccounts) 28 | { 29 | Select-AzSubscription -SubscriptionId $automationAccount.subscriptionId 30 | $Job = Get-AzAutomationJob -ResourceGroupName $automationAccount.resourceGroup -AutomationAccountName $automationAccount.name -Id $PSPrivateMetadata.JobId.Guid -ErrorAction SilentlyContinue 31 | if (!([string]::IsNullOrEmpty($Job))) 32 | { 33 | $automationAccountRg = $Job.ResourceGroupName 34 | $automationAccountName = $Job.AutomationAccountName 35 | break; 36 | } 37 | } 38 | 39 | # Get Update Deployment Schedules 40 | $updateSchedules = Get-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $automationAccountRg -AutomationAccountName $automationAccountName | Where-Object { $_.Name -like "$($schedulePrefix)*" } 41 | 42 | foreach ($updateSchedule in $updateSchedules) 43 | { 44 | $schedule = Get-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $automationAccountRg -AutomationAccountName $automationAccountName -Name $updateSchedule.Name 45 | $vmScheduledId = $schedule.UpdateConfiguration.AzureVirtualMachines[0] 46 | $vmScheduledName = $vmScheduledId.Split("/")[8] 47 | 48 | # Quick naming check 49 | if ($schedule.Name.Replace($schedulePrefix,"") -ne $vmScheduledName) { 50 | Write-Output "Error ! Schedule Name & VM Name in schedule mismatch, continue..." 51 | continue 52 | } 53 | 54 | $vmScheduledTags = Get-AzTag -ResourceId $vmScheduledId 55 | $vmScheduledTags = $vmScheduledTags.PropertiesTable 56 | $deleteSchedule = $false 57 | 58 | if ($null -eq $vmScheduledTags) 59 | { 60 | $deleteSchedule = $true 61 | } 62 | else { 63 | if (! $vmScheduledTags.Contains("POLICY_UPDATE")) { 64 | $deleteSchedule = $true 65 | } 66 | } 67 | 68 | # Delete Schedule 69 | if ($deleteSchedule) 70 | { 71 | Write-Output "Removing $($updateSchedule.Name) ..." 72 | Remove-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $automationAccountRg -AutomationAccountName $automationAccountName -Name $updateSchedule.Name 73 | } 74 | else { 75 | Write-Output "Nothing to do!" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /runbooks/UM-CleanUp-Snapshots.ps1: -------------------------------------------------------------------------------- 1 | ################# 2 | # CONFIGURATION # 3 | ################# 4 | 5 | # Snapshot prefix to use. Must be the same used on UpdatementManagement-CleanUpSnapshots.ps1 Runbook 6 | $snapshotPrefix = "UpdateMngmnt_snapshot_" 7 | 8 | # Number of days to keep snapshot before deletion 9 | $keepSnapshotDays = 8 10 | 11 | ########## 12 | # SCRIPT # 13 | ########## 14 | 15 | Import-Module Az.Automation 16 | 17 | # Connect to Azure with Automation Account system-assigned managed identity 18 | Disable-AzContextAutosave -Scope Process 19 | $AzureContext = (Connect-AzAccount -Identity -WarningAction Ignore).context 20 | $AzureContext = Set-AzContext -SubscriptionName $AzureContext.Subscription -DefaultProfile $AzureContext 21 | 22 | # Get Snapshots 23 | $snapshotsQuery = @{ 24 | Query = "resources 25 | | where type == 'microsoft.compute/snapshots' 26 | | where name startswith '$($snapshotPrefix)'" 27 | } 28 | $snapshots = Search-AzGraph @snapshotsQuery 29 | 30 | foreach ($snapshot in $snapshots) 31 | { 32 | $snapDate = $snapshot.properties.timeCreated 33 | $currentDate = Get-Date 34 | $dateDiff = (New-TimeSpan -Start $snapDate -End $currentDate).Days 35 | 36 | if ($dateDiff -gt $keepSnapshotDays) 37 | { 38 | # Delete Snapshot 39 | Select-AzSubscription -SubscriptionId $snapshot.subscriptionId 40 | Write-Output "$($snapshot.name) will be deleted." 41 | Remove-AzSnapshot -ResourceGroupName $snapshot.resourceGroup -SnapshotName $snapshot.name -Force 42 | } 43 | else { 44 | # Keep Snapshot 45 | Write-Output "$($snapshot.name) will not be deleted. Creation date is less or equal to $($keepSnapshotDays) days." 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /runbooks/UM-PostTasks.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | 3 | .PARAMETER SoftwareUpdateConfigurationRunContext 4 | This is a system variable which is automatically passed in by Update Management during a deployment. 5 | 6 | #> 7 | 8 | param( 9 | [string]$SoftwareUpdateConfigurationRunContext, 10 | 11 | [Parameter(Mandatory = $true)] 12 | [string]$DestinationMail = "", 13 | 14 | [Parameter(Mandatory = $true)] 15 | [string]$ScheduleName = "" 16 | ) 17 | 18 | ################# 19 | # CONFIGURATION # 20 | ################# 21 | 22 | # Stop VM that were started after patching? Possible values: $true or $false. Value must be the same used in UM-PreTasks runbook by $startStopppedVmEnabled variable 23 | $stopStartedVmEnable = $true 24 | 25 | ########## 26 | # SCRIPT # 27 | ########## 28 | 29 | Import-Module Az.Automation 30 | Import-Module Az.Compute 31 | 32 | # Connect to Azure with Automation Account system-assigned managed identity 33 | Disable-AzContextAutosave -Scope Process 34 | $AzureContext = (Connect-AzAccount -Identity -WarningAction Ignore).context 35 | $AzureContext = Set-AzContext -SubscriptionName $AzureContext.Subscription -DefaultProfile $AzureContext 36 | 37 | # Get current automation account 38 | $automationAccountsQuery = @{ 39 | Query = "resources 40 | | where type == 'microsoft.automation/automationaccounts'" 41 | } 42 | $automationAccounts = Search-AzGraph @automationAccountsQuery 43 | 44 | foreach ($automationAccount in $automationAccounts) 45 | { 46 | Select-AzSubscription -SubscriptionId $automationAccount.subscriptionId 47 | $Job = Get-AzAutomationJob -ResourceGroupName $automationAccount.resourceGroup -AutomationAccountName $automationAccount.name -Id $PSPrivateMetadata.JobId.Guid -ErrorAction SilentlyContinue 48 | if (!([string]::IsNullOrEmpty($Job))) 49 | { 50 | $automationAccountSubscriptionId = $automationAccount.subscriptionId 51 | $automationAccountRg = $Job.ResourceGroupName 52 | $automationAccountName = $Job.AutomationAccountName 53 | break; 54 | } 55 | } 56 | 57 | $context = ConvertFrom-Json $SoftwareUpdateConfigurationRunContext 58 | $vmIds = $context.SoftwareUpdateConfigurationSettings.AzureVirtualMachines 59 | 60 | $vmIds | ForEach-Object { 61 | $vmId = $_ 62 | 63 | $split = $vmId -split "/"; 64 | 65 | if ($split.Length -eq 1) 66 | { 67 | # Azure Arc Server 68 | } 69 | else { 70 | # Azure VM 71 | $subscriptionId = $split[2]; 72 | $rg = $split[4]; 73 | $name = $split[8]; 74 | 75 | $mute = Select-AzSubscription -Subscription $subscriptionId 76 | 77 | #Hack to get VM log analytics ID 78 | $vmExtension = Get-AzVMExtension -ResourceGroupName $rg -VMName $name -DefaultProfile $mute 79 | $workspaceID = ($vmExtension | Select-String -InputObject {$_.PublicSettings} -Pattern "(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})" -All).Matches.Value 80 | } 81 | } 82 | 83 | if ($DestinationMail -and $ScheduleName -and $DestinationMail -Match "^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$") 84 | { 85 | Write-Output "Create reporting mail" 86 | #Log Analytics Query Informations 87 | $query = 'UpdateRunProgress 88 | | where TimeGenerated > now(-4h) 89 | | where InstallationStatus <> ''NotStarted'' 90 | | where UpdateRunName == ''' + $scheduleName + ''' 91 | | project Computer, strcat(Product, KBID), InstallationStatus, ResourceId 92 | | project-rename Product=Column1 93 | | order by InstallationStatus asc, Computer asc' 94 | 95 | #Log Analytics Request 96 | $queryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceID -Query $query 97 | 98 | $rows = $queryResults.Results 99 | $rows | ConvertTo-Json 100 | 101 | #Data Modeling for Mail Report 102 | if ($rows){ 103 | $mailContent = "
" 104 | $mailContent += "VM | Software | Status |
---|---|---|
" + $row.Computer + " | " + $row.Product + " | " + $row.InstallationStatus + " |