Automating the Deployment of Azure’s Update Management

So I had written a previous blog about how to deploy 127 deployment schedules using PowerShell. This blog will be written to go along with a video I will be creating for years Azure Advent Calendar 2019. My video will be available on December 17th.

This blog will cover some of the challenges I ran into when trying to design and deploy Azure’s Update Management at an enterprise level. I will cover my entire process and hopefully it will help with any future projects anyone else may be deciding around Azure’s Update Management.

This solution is built around Update Management but I also integrated Azure Policies, and Azure Monitor Workbooks for Update Compliance views. I used Billy York’s Workbook template and then edited a bit to fit my needs.

High Level Overview and Planning

I. Planning Tags and Deployment Schedule Names

II. Planning of Automation Accounts and Log Analytics Workspaces

III. Deployment of the Automation Account and Log Analytics Workspace

IV. Enable Update Management Agent on Azure VM’s

V. Automation of Azure VM Resource Tagging

VI. Configure AZ Windows VM’s Advance Settings for Update Management

VII. Automation of Deployment Schedules

VIII. The Complete Package

IX. Everything Else Including Azure Monitor Workbooks

I. Planning of the Tags and Deployment Schedule Names

First thing first, this solution was originally built to help deploy Update Management to over 40 plus subscriptions with various amounts of VM’s in each subscriptions. The ultimate goal was to give application owners and subscription owner control of when their IaaS VM’s could be patched and rebooted. A solution was built around dynamic groups within the deployment schedules using Tag’s to dynamically assign VM’s to each schedule. A list of schedules where defined along with a specific tag value for each schedule.

As mentioned in the section before a Tag named “UpdateWindow” would be assigned to all Azure VM’s within each subscription. That Tag would have a default value of “Default.” Then an Excel sheet with all the deployment schedules along with which tag value they lined up with was created.

The deployment schedules would run 3 times a day starting at 0:00 UTC, 08:00 UTC, and 16:00 UTC every day of the week for the first 3 weeks of the month. That would give me 63 possible deployment windows times 2. Since I would have deployment schedules for both Linux and Windows VM’s.

Here is an example of the Excel worksheet. You can see the tag value, the Deployment Schedule Name and the UTC time the deployment schedule would run.

TagDeployment Schedule NameUTC Time
MON1UTC01st Monday UTC0 – Windows Update12:00 AM
 1st Monday UTC0 – Linux Update12:00 AM
MON2UTC02nd Monday UTC0 – Windows Update12:00 AM
 2nd Monday UTC0 – Linux Update12:00 AM
MON3UTC03rd Monday UTC0 – Windows Update12:00 AM
 3rd Monday UTC0 – Linux Update12:00 AM
MON1UTC81st Monday UTC8 – Windows Update8:00 AM
 1st Monday UTC8 – Linux Update8:00 AM
MON2UTC82nd Monday UTC8 – Windows Update8:00 AM
 2nd Monday UTC8 – Linux Update8:00 AM
MON3UTC83rd Monday UTC8 – Windows Update8:00 AM
 3rd Monday UTC8 – Linux Update8:00 AM
MON1UTC161st Monday UTC16 – Windows Update4:00 PM
 1st Monday UTC16 – Linux Update4:00 PM
MON2UTC162nd Monday UTC16 – Windows Update4:00 PM
 2nd Monday UTC16 – Linux Update4:00 PM
MON3UTC163rd Monday UTC16 – Windows Update4:00 PM
 3rd Monday UTC16 – Linux Update4:00 PM

II. Planning of the Automation Account and Log Analytics Workspace

I am going to keep this part pretty simple. The solution I am working on is great for a new environment without any existing Automation Accounts or Log Analytics workspace. The scripts will also work for those environments that are already using these solutions. However, I have written the solution to create a new Log Analytics workspace and a new Automation Account. As mentioned in the next section, the scripts will also configure any existing VM to use this workspace and will also enable the update management agent for this specific instance of update management. So if your environment has machines going to various workspaces I would take caution when running my scripts.

III. Deployment of the Automation Account and Log Analytics Workspace

The frist script created was actually a script I took from the Cloud Adoption Framework web site. Automate onboarding had some good examples that I needed. I took the existing script and did some edits that would work for me.

I called my script New-AutomationDeployment and changed it to use variables for now. I will go back and redo a lot of these scripts to use parameters again soon. I also removed the section that enabled the VM Insights for now. I may go back and re-add that section but for now I didn’t need that.

The script creates a resource group, then it creates the Automation Account and Log Analytics workspace. It then enabled Update Management and Change Tracking solutions.

###################################
#  This script is will deploy automation components.
#  Current Version:  .01000001 01111010 01110101 01110010 01100101
#  Date Written:  8-29-2019
#  Last Updated:  9-15-2019
#  Created By:    Kristopher J. Turner (The Country Cloud Boy)
#  Uses the Az Module and not the AzureRM module
#####################################

$ResourceGroupName = ""
$ResourceGroupLocation = ""
$WorkspaceName = ""
$WorkspaceLocation = ""
$AutomationAccountName = ""
$AutomationAccountLocation = ""
$TenantID=""
$SubscriptionID=""
$AutoEnroll = $true

# Script settings
Set-StrictMode -Version Latest

function ThrowTerminatingError
{
     Param
    (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $ErrorId,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        $ErrorCategory,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        $Exception
    )

    $errorRecord = [System.Management.Automation.ErrorRecord]::New($Exception, $ErrorId, $ErrorCategory, $null)
    throw $errorRecord
}

Connect-AzAccount -Tenant $TenantID -SubscriptionId $SubscriptionID

#
# Automation account requires 6 chars at minimum
#
if ( $AutomationAccountName.Length -lt 6 )
{
    $message = "Automation account name validation failed: The name can contain only letters, numbers, and hyphens. The name must start with a letter, and it must end with a letter or a number. The account name length must be from 6 to 50 characters"
    ThrowTerminatingError -ErrorId "InvalidAutomationAccountName" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `
}

#
# Check all dependency files exist along with this script
#
$curInvocation = get-variable myinvocation
$mydir = split-path $curInvocation.Value.MyCommand.path

# $enableVMInsightsPerfCounterScriptFile = "$mydir\Enable-VMInsightsPerfCounters.ps1"
# if (-not (Test-Path -Path $enableVMInsightsPerfCounterScriptFile))
# {
#     $message = "$enableVMInsightsPerfCounterScriptFile does not exist. Please ensure this file exists in the same directory as the this script."
#     ThrowTerminatingError -ErrorId "ScriptNotFound" `
#         -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
#         -Exception $message `
# }

$changeTrackingTemplateFile = "$mydir\Templates\ChangeTracking-Filelist.json"
if (-not (Test-Path -Path $changeTrackingTemplateFile ) )
{
    $message = "$changeTrackingTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `
}

$scopeConfigTemplateFile = "$mydir\Templates\ScopeConfig.json"
if (-not (Test-Path -Path $scopeConfigTemplateFile ) )
{
    $message = "$scopeConfigTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `
}

$workspaceAutomationTemplateFile = "$mydir\Templates\Workspace-AutomationAccount.json"
if (-not (Test-Path -Path $workspaceAutomationTemplateFile ) )
{
    $message = "$workspaceAutomationTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `
}

$workspaceSolutionsTemplateFile = "$mydir\Templates\WorkspaceSolutions.json"
if (-not (Test-Path -Path $workspaceSolutionsTemplateFile ) )
{
    $message = "$workspaceSolutionsTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `
}

#
# Choose the right subscription
#
try
{
    $Subscription = Get-AzSubscription -SubscriptionID $SubscriptionID  -ErrorAction Stop
}
catch 
{
    ThrowTerminatingError -ErrorId "FailedToGetSubscriptionInformation" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidResult) `
        -Exception $_.Exception `
}

#
# Check if user is owner of the subscription
#
$azContext = Get-AzContext
$currentUser = $azContext.Account.Id
$userRole = Get-AzRoleAssignment -SignInName $currentUser -RoleDefinitionName Owner -Scope "/subscriptions/$($Subscription.Id)"
if (-not $userRole)
{
    $message = "Insufficient permissions for Policy assignment."
    ThrowTerminatingError -ErrorId "UserUnAuthorizedForPolicyAssignment" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message
}

#
# Create the Resource group if not exist
#
$newResourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -ErrorAction SilentlyContinue
If (-not $NewResourceGroup)
{
    Write-Output "Creating resource group: $($ResourceGroupName)"
    $newResourceGroup = New-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation
}
else
{
    #
    # We are intentionally not trying to re-use an existing resource group. 
    # For e.g. if it exists, but not in the location that was requested, could set us in an inconsistent state.
    #
    $message = "ResourceGroup: $($ResourceGroupName) already exists. Please use a new resource group."
    ThrowTerminatingError -ErrorId "ResourceGroupAlreadyExists" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message
}

#
# Deploy Workspace and solutions
#
try 
{
    Write-Output "Phase 1 Deployment start: Create resources"

    #
    # Check for workspace and automation account name to be unique in the subscription across resoure groups
    # Duplicate names can cause "bad request" error.
    #

    #
    # Check if the automation account already exists
    #
    $existingAutomationAccount = Get-AzResource -Name $AutomationAccountName -ResourceType "Microsoft.Automation/automationAccounts" -ErrorAction SilentlyContinue
    if ($existingAutomationAccount)
    {
        $message = "Automation account: $AutomationAccountName already exists in this subscription. Please use a unique name."
        ThrowTerminatingError -ErrorId "AutomationAccountAlreadyExists" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # Check if the workspace already exists
    #
    $workspace = Get-AzResource -Name $WorkspaceName -ResourceType "Microsoft.OperationalInsights/workspaces" -ErrorAction SilentlyContinue
    if ($workspace)
    {
        $message = "Workspace: $WorkspaceName already exists. Please use a unique name."
        ThrowTerminatingError -ErrorId "WorkspaceAlreadyExists" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # Start deployment, provisioning Automation Account and Workspace
    #
    try
    {
        New-AzResourceGroupDeployment -Name "WorkSpaceAndAutomationAccountProvisioning" -ResourceGroupName $ResourceGroupName -TemplateFile $workspaceAutomationTemplateFile -workspaceName $WorkspaceName -workspaceLocation $WorkspaceLocation -automationName $AutomationAccountName -automationLocation $AutomationAccountLocation -ErrorAction Stop
    }
    catch
    {
        $message = "Automation Account and Workspace, provisioning failed. Details below... `n $_"
        ThrowTerminatingError -ErrorId "AutomationAccountAndWorkspaceProvisioningFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # If we are here Automoation account and Workspace have been provisioned.
    #

    #
    # Enable solutions on the workspace
    #
    Write-Output "Phase 1 Deployment start: Enable solutions on the workspace"		
    try
    {
        New-AzResourceGroupDeployment -Name "EnableSolutions" -ResourceGroupName $ResourceGroupName -TemplateFile $workspaceSolutionsTemplateFile -workspaceName $WorkspaceName -WorkspaceLocation $WorkspaceLocation -Mode Incremental -ErrorAction Stop
    }
    catch
    {
        $message = "Solution provisioning on workspace failed. Detailed below... `n $_"
        ThrowTerminatingError -ErrorId "SolutionProvisioningFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }
    
    If ($AutoEnroll -eq $false)
    {
        #
        # Check if default change tracking saved search group already exists
        # If not, add the default change tracking saved search and scope config
        #
        $savedSearch = Get-AzOperationalInsightsSavedSearch -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName

        $createSavedSearch = $true
        foreach ($s in $savedSearch.Value)
        {
            if ($s.Id.Contains("changetracking|microsoftdefaultcomputergroup")) 
            {
                Write-output "Default saved search group already exists: $($s.Id)"
                $createSavedSearch = $false
                break
            }
        }

        if ($createSavedSearch)
        {
            Write-Output "Phase 1 Deployment start: Add scope config to the workspace"		

            try
            {
                New-AzResourceGroupDeployment -Name "AddScopeConfig" -ResourceGroupName $ResourceGroupName -TemplateFile $scopeConfigTemplateFile -workspaceName $WorkspaceName -WorkspaceLocation $Workspacelocation -Mode Incremental -ErrorAction Stop
            }
            catch
            {
                $message = "ScopeConfig provisioning on ResouceGroup failed. Detailed below... `n $_"
                ThrowTerminatingError -ErrorId "ScopeConfigProvisioningFailed" `
                -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
                -Exception $message 
            }
        }
    }

    #
    # Check if the workspace already exists
    #
    $workspace = Get-AzResource -Name $WorkspaceName -ResourceType "Microsoft.OperationalInsights/workspaces" -ErrorAction SilentlyContinue
    if (-not $workspace)
    {
        $message = "Workspace: $($WorkspaceName) does not exists. Please check."
        ThrowTerminatingError -ErrorId "WorkspaceNotFound" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # Phase 2 deployment
    #
    Write-Output "Phase 2 Deployment start: Configure Change tracking"		

    try
    {
        New-AzResourceGroupDeployment -Name "ConfigureChangeTracking" -ResourceGroupName $ResourceGroupName -TemplateFile $changeTrackingTemplateFile -workspaceName $WorkspaceName -WorkspaceLocation $Workspacelocation -Mode Incremental -ErrorAction Stop
    }
    catch
    {
        $message = "ChangeTracking provisioning on ResouceGroup failed. Detailed below... `n $_"
        ThrowTerminatingError -ErrorId "ChangeTrackingProvisioningFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }
}
catch 
{
    $message = "Deployment failed.`n $_"
        ThrowTerminatingError -ErrorId "DeploymentFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
}

IV. Enable Update Management Agent on Azure VM’s

The next task I had was to enable the update management agent on all the Azure VM’s, both Linux and Windows. Again, this is targeting an environment that doesn’t currently use Update Management.

The script New-VMUpdateAgent.ps1 would scan the current subscription for all Linux and Windows VM’s. It would then enable the Microsoft Monitoring Agent extension on the Windows VM’s and then enable the OMS Agent for Linux extension on Linux VM’s.

What is also nice is it will take any existing VM’s that may be connected to another Log Analytics workspace, disconnect them and then connect them to the newly created Log Analytics workspace.

Just a quick note: VMSS, WIndows 10, Windows 2008, etc not supported.

###################################
#  This script is enable the Update Management Agent on all VMs.
#  Current Version:  .01000001 01111010 01110101 01110010 01100101
#  Date Written:   8-29-2019
#  Created By:    Kristopher J. Turner (The Country Cloud Boy)
#  Uses the Az Module and not the AzureRM module
#####################################

$SubscriptionId = ""
$TenantID = ""
$WorkspaceName = ""

# Script settings
Set-StrictMode -Version Latest

Connect-AzAccount -Tenant $TenantID -SubscriptionId $SubscriptionID


$workspace = (Get-AzOperationalInsightsWorkspace).Where({$_.Name -eq $workspaceName})

if ($workspace.Name -ne $workspaceName)
{
    Write-Error "Unable to find OMS Workspace $workspaceName. "
}

$workspaceId = $workspace.CustomerId
$workspaceKey = (Get-AzOperationalInsightsWorkspaceSharedKeys -ResourceGroupName $workspace.ResourceGroupName -Name $workspace.Name).PrimarySharedKey

$WindowsVMs = Get-AzVM | where-object { $_.StorageProfile.OSDisk.OSType -eq "Windows" } | Sort-Object Name | ForEach-Object {$_.Name} | Out-String -Stream | Select-Object
$LinuxVMs = Get-AzVM | where-object { $_.StorageProfile.OSDisk.OSType -eq "Linux" } | Sort-Object Name | ForEach-Object {$_.Name} | Out-String -Stream | Select-Object


foreach($WindowsVM in $WindowsVMs){

$VMResourceGroup = get-azvm -name $WindowsVM | Select-Object -ExpandProperty ResourceGroupName
$VMLocation = Get-AzVM -Name $WindowsVM | Select-Object -ExpandProperty Location

Set-AzVMExtension -ResourceGroupName $VMresourcegroup -VMName $WindowsVM -Name 'MicrosoftMonitoringAgent' -Publisher 'Microsoft.EnterpriseCloud.Monitoring' -ExtensionType 'MicrosoftMonitoringAgent' -TypeHandlerVersion '1.0' -Location $VMLocation -SettingString "{'workspaceId':  '$workspaceId'}" -ProtectedSettingString "{'workspaceKey': '$workspaceKey' }"

}

foreach($LinuxVM in $LinuxVMs){

$VMResourceGroup = get-azvm -name $LinuxVM | Select-Object -ExpandProperty ResourceGroupName
$VMLocation = Get-AzVM -Name $LinuxVM | Select-Object -ExpandProperty Location

Set-AzVMExtension -ResourceGroupName $VMresourcegroup -VMName $LinuxVM -Name 'OmsAgentForLinux' -Publisher 'Microsoft.EnterpriseCloud.Monitoring' -ExtensionType 'OmsAgentForLinux' -TypeHandlerVersion '1.0' -Location $VMlocation -SettingString "{'workspaceId':  '$workspaceId'}" -ProtectedSettingString "{'workspaceKey': '$workspaceKey' }"

}

Write-Host "All Azure VMs have been enabled for Update Management.  Please don't forget to tip your serves.  Have a nice day!"

V. Automation of Azure VM Resource Tagging

Now I have the information I need for my tags. Now I needed a way to deploy these tags all at one time. There are two ways I could think of, the first was an Azure Policy and the other was a PowerShell script. So I decided both will do. I created a PowerShell script that would discover all VM’s in the current subscription and then assign the Tag “UpdateWindow” along with the Tag value of “Default.” This script would only add a new tag if the existing tag didn’t exist. I also created an Azure Policy that would basically do the same in order to catch any newly deploy VM’s in the future.

###################################
#  This script is will add a new tag and value all Azure VM's running within Subscription.
#  Current Version:  .01000001 01111010 01110101 01110010 01100101
#  Date Written:   8-29-2019
#  Created By:    Kristopher J. Turner (The Country Cloud Boy)
#  Uses the Az Module and not the AzureRM module
#####################################

#  Please fill in the following 4 variables.
$TenantID=""
$SubscriptionID=""
$vmtagname = "UpdateWindow"
$tagvalue = "Default"


Connect-AzAccount -Tenant $TenantID -SubscriptionId $SubscriptionID


#  Discovery of all Azure VM's in the current subscription.
$azurevms = Get-AzVM | Select-Object -ExpandProperty Name
Write-Host "Discovering Azure VM's in the following subscription $SubscriptionID  Please hold...."

Write-Host "The following VM's have been discovered in subscription $SubscriptionID"
$azurevms


foreach ($azurevm in $azurevms) {
    
    Write-Host Checking for tag "$vmtagname" on "$azurevm"
    $tagRGname = get-azvm -name $azurevm | Select-Object -ExpandProperty ResourceGroupName
    
    $tags = (Get-AzResource -ResourceGroupName $tagRGname `
                        -Name $azurevm).Tags

If ($tags.UpdateWindow){
Write-Host "$azurevm already has the tag $vmtagname."
}
else
{
Write-Host "Creating Tag $vmtagname and Value $tagvalue for $azurevm"
$tags.Add($vmtagname,$tagvalue)
  
    Set-AzResource -ResourceGroupName $tagRGname `
               -ResourceName $azurevm `
               -ResourceType Microsoft.Compute/virtualMachines `
               -Tag $tags `
               -Force `
   }
   
}

Write-Host "All tagging is done (and hopfully it worked).  Please exit the ride to your left.  Have a nice day!"

VI. Configure AZ Windows VM’s Advance Settings for Update Management

This script called New-VMWAUConfigs.ps1 and will run only on Windows VM’s in the current Azure Subscription. It performs 3 configurations on each VM.

The first configuration enables the Windows update agent to pre-download any known update. This allows for faster deployment times and shorter maintenance windows.

The second configuration disables the automation of any update installation. This will prevent the VM from updating outside of our deployment schedule.

The third configuration enables updates for other Microsoft products.

I created the script below but will also be creating some Azure Policy Guest configuration policies to do the same for any new VM introduced to the environment.

###################################
#  This script is will configure AZ Windows VM's Advance Setttings for Update Management.
#  Current Version:  .01000001 01111010 01110101 01110010 01100101
#  Date Written:   8-29-2019
#  Created By:    Kristopher J. Turner (The Country Cloud Boy)
#  Uses the Az Module and not the AzureRM module
#####################################

$SubscriptionId = ""
$TenantID = ""

# Script settings
Set-StrictMode -Version Latest

function ThrowTerminatingError
{
     Param
    (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $ErrorId,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        $ErrorCategory,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        $Exception
    )

    $errorRecord = [System.Management.Automation.ErrorRecord]::New($Exception, $ErrorId, $ErrorCategory, $null)
    throw $errorRecord
}

Connect-AzAccount -Tenant $TenantID -SubscriptionId $SubscriptionID


#  Discovery of all Azure VM's in the current subscription.
$azurevms = Get-AzVM | where-object { $_.StorageProfile.OSDisk.OSType -eq "Windows" } | Sort-Object Name | ForEach-Object { $_.Name } | Out-String -Stream | Select-Object
Write-Host "Discovering Azure VM's.  Please hold...."

foreach ($azurevm in $azurevms) {
    
    #  This will configure VM's to pre-download updates.
    $WUSettings = (New-Object -com "Microsoft.Update.AutoUpdate").Settings
    $WUSettings.NotificationLevel = 3
    $WUSettings.Save()

    #  This will disable the automation installation of updates on VM's.
    $AutoUpdatePath = "HKLM:SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
    Set-ItemProperty -Path $AutoUpdatePath -Name NoAutoUpdate -Value 1

    #  This will enable updates for other Microsoft products.
    $ServiceManager = (New-Object -com "Microsoft.Update.ServiceManager")
    $ServiceManager.Services
    $ServiceID = "7971f918-a847-4430-9279-4a52d1efe18d"
    $ServiceManager.AddService2($ServiceId, 7, "")

}

Write-Host "All settigns have been deployed. (and hopfully it worked).  Please exit the ride to your left.  Have a nice day!"

VII. Automation of the Deployment Schedules

Last but not least I have my New-DeploymentSchedules.ps1 script that I blogged about a while back. Azure Update Management – Creating Multiple Deployment Schedules with PowerShell.

I have changed a few things since that blog like how I pull the information to build the object array, etc. For more in depth details on this script please check out my previous blog.

At a high level, this script creates an Object array from a CSV file. The script then uses various variables pulled from that array to create the Azure Queries, the Automation Schedules, and the Software Update Configurations for both Windows and Linux Deployment Schedules.

###################################
#  This script is will create deployment schedules for Update Management.
#  Current Version:  .01000001 01111010 01110101 01110010 01100101
#  Date Written:  8-29-2019
#  Last Updated:  9-15-2019
#  Created By:    Kristopher J. Turner (The Country Cloud Boy)
#  Uses the Az Module and not the AzureRM module
#####################################

# Variables - Required
$AutomationAccountName=""
$ResourceGroupName=""
$TenantID=""
$SubscriptionID=""


Connect-AzAccount -Tenant $TenantID -SubscriptionId $SubscriptionID

#  Build the array
#  This will use the array.csv file.

$ScheduleConfig = Get-Content -Path .\array.csv | ConvertFrom-Csv

#  Schedule Deployments Start Here

$scope = "/subscriptions/$((Get-AzContext).subscription.id)"
$QueryScope = @($scope)

$WindowsSchedules = $ScheduleConfig | Where-Object {$_.OS -eq "Windows"}
$LinuxSchedules = $ScheduleConfig | Where-Object {$_.OS -eq "Linux"}

foreach($WindowsSchedule in $WindowsSchedules){

$tag = @{$WindowsSchedule.TagName=$WindowsSchedule.TagValue}
$azq = New-AzAutomationUpdateManagementAzureQuery -ResourceGroupName $ResourceGroupName `
                                       -AutomationAccountName $AutomationAccountName `
                                       -Scope $QueryScope `
                                       -Tag $tag

$AzureQueries = @($azq)

$date=((get-date).AddDays(1)).ToString("yyyy-MM-dd")
$time=$WindowsSchedule.Starttime
$datetime= $date + "t" + $time

$startTime = [DateTimeOffset]"$datetime"
$duration = New-TimeSpan -Hours 2

$schedule = New-AzAutomationSchedule -ResourceGroupName $ResourceGroupName `
                                                  -AutomationAccountName $AutomationAccountName `
                                                  -Name $WindowsSchedule.ScheduleName `
                                                  -StartTime $StartTime `
                                                  -DayofWeek $WindowsSchedule.DayofWeek `
                                                  -DayofWeekOccurrence $WindowsSchedule.DaysofWeekOccurrence `
                                                  -MonthInterval 1 `
                                                  -ForUpdateConfiguration

New-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $ResourceGroupName `
                                                 -AutomationAccountName $AutomationAccountName `
                                                 -Schedule $schedule `
                                                 -Windows `
                                                 -Azurequery $AzureQueries `
                                                 -IncludedUpdateClassification Critical,Security,Updates,UpdateRollup,Definition `
                                                 -Duration $duration `
                                                 -RebootSetting $WindowsSchedule.Reboot
}

foreach ($LinuxSchedule in $LinuxSchedules){

$tag = @{$LinuxSchedule.TagName=$LinuxSchedule.TagValue}
$azq = New-AzAutomationUpdateManagementAzureQuery -ResourceGroupName $ResourceGroupName `
                                       -AutomationAccountName $AutomationAccountName `
                                       -Scope $QueryScope `
                                       -Tag $tag

$AzureQueries = @($azq)

$date=((get-date).AddDays(1)).ToString("yyyy-MM-dd")
$time=$LinuxSchedule.Starttime
$datetime= $date + "t" + $time

$startTime = [DateTimeOffset]"$datetime"
$duration = New-TimeSpan -Hours 2
$schedule = New-AzAutomationSchedule -ResourceGroupName $ResourceGroupName `
                                                  -AutomationAccountName $AutomationAccountName `
                                                  -Name $LinuxSchedule.ScheduleName `
                                                  -StartTime $StartTime `
                                                  -DayofWeek $LinuxSchedule.DayofWeek `
                                                  -DayofWeekOccurrence $LinuxSchedule.DaysofWeekOccurrence `
                                                  -MonthInterval 1 `
                                                  -ForUpdateConfiguration

New-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $ResourceGroupName `
                                                 -AutomationAccountName $AutomationAccountName `
                                                 -Schedule $schedule `
                                                 -Linux `
                                                 -Azurequery $AzureQueries `
                                                 -IncludedPackageClassification Critical,Security `
                                                 -Duration $duration `
                                                 -RebootSetting $LinuxSchedule.Reboot
}

Write-host "That is all folks!"

VIII. The Complete Package

I have created several scripts to do a few different task in order to deploy this solution. However, I am lazy and didn’t want to run all those scripts one at a time. So I create my Deploy-AutomationSolution.ps1 script which takes all these scripts and combines them into a single script.

<#

.SYNOPSYS

    This script will add a new tag and value all Azure VM's running within Subscription.  It will configure VM's
    for updates to be installed only during maintenance windows, and allow pre-download of updates.  It will also
    create the deployment schedules.
    This includes:
    * Tags
    * etc....

.VERSION
    1.0  First version of deployment script.

.AUTHOR
    Kristopher J Turner
    Blog: http://www.kristopherjturner.com
    Twitter: @kristopherj

.CREDITS


.GUIDANCE

    Please refer to the Readme.md (https://github.com/countrycloudboy/Update-Management-Automation/blob/master/README.md) for recommended
    deployment instructions.

#>

#####################################################################################
#   Future Releases:
#       I plan to add the following:
#           *  Add automation of Azure Policy to catch all new VM's for tagging
#           *  Add automation of Azure Policy to catch all new VM's for workspace assignment
#           *  Add automation of Azure Monitor for Deployment Schedule Alets
#           *  Change from Variables to Parameters
#
######################################################################################

#######################################
#  Make sure all variables below are filled in correctly.
#######################################

$ResourceGroupName = ""
$ResourceGroupLocation = ""
$WorkspaceName = ""
$WorkspaceLocation = ""
$AutomationAccountName = ""
$AutomationAccountLocation = ""
$TenantID = ""
$SubscriptionID = ""
$VMTagName = "UpdateWindow"
$TagValue = "Default" 
$AutoEnroll = $true

# Script settings
Set-StrictMode -Version Latest

function ThrowTerminatingError {
    Param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $ErrorId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        $ErrorCategory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        $Exception
    )

    $errorRecord = [System.Management.Automation.ErrorRecord]::New($Exception, $ErrorId, $ErrorCategory, $null)
    throw $errorRecord
}

####################################
#  Connect to Azure Subscription
####################################


Connect-AzAccount -Tenant $TenantID -SubscriptionId $SubscriptionID

##################################################################################################################
#  Step 1 - Deployment of the Automation Account and Log Analytics Workspace
####

Write-Host "Step 1 -  Deployment of the Automation Account and Log Analytics Workspace"

#
# Automation account requires 6 chars at minimum
#
if ( $AutomationAccountName.Length -lt 6 ) {
    $message = "Automation account name validation failed: The name can contain only letters, numbers, and hyphens. The name must start with a letter, and it must end with a letter or a number. The account name length must be from 6 to 50 characters"
    ThrowTerminatingError -ErrorId "InvalidAutomationAccountName" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `

}

#
# Check all dependency files exist along with this script
#
$curInvocation = get-variable myinvocation
$mydir = split-path $curInvocation.Value.MyCommand.path

# $enableVMInsightsPerfCounterScriptFile = "$mydir\Enable-VMInsightsPerfCounters.ps1"
# if (-not (Test-Path -Path $enableVMInsightsPerfCounterScriptFile))
# {
#     $message = "$enableVMInsightsPerfCounterScriptFile does not exist. Please ensure this file exists in the same directory as the this script."
#     ThrowTerminatingError -ErrorId "ScriptNotFound" `
#         -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
#         -Exception $message `
# }

$changeTrackingTemplateFile = "$mydir\Templates\ChangeTracking-Filelist.json"
if (-not (Test-Path -Path $changeTrackingTemplateFile ) ) {
    $message = "$changeTrackingTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `

}

$scopeConfigTemplateFile = "$mydir\Templates\ScopeConfig.json"
if (-not (Test-Path -Path $scopeConfigTemplateFile ) ) {
    $message = "$scopeConfigTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `

}

$workspaceAutomationTemplateFile = "$mydir\Templates\Workspace-AutomationAccount.json"
if (-not (Test-Path -Path $workspaceAutomationTemplateFile ) ) {
    $message = "$workspaceAutomationTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `

}

$workspaceSolutionsTemplateFile = "$mydir\Templates\WorkspaceSolutions.json"
if (-not (Test-Path -Path $workspaceSolutionsTemplateFile ) ) {
    $message = "$workspaceSolutionsTemplateFile does not exist. Please ensure this file exists in the same directory as the this script."
    ThrowTerminatingError -ErrorId "TemplateNotFound" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message `

}

#
# Choose the right subscription
#
try {
    $Subscription = Get-AzSubscription -SubscriptionID $SubscriptionID  -ErrorAction Stop
}
catch {
    ThrowTerminatingError -ErrorId "FailedToGetSubscriptionInformation" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidResult) `
        -Exception $_.Exception `

}

#
# Check if user is owner of the subscription
#
$azContext = Get-AzContext
$currentUser = $azContext.Account.Id
$userRole = Get-AzRoleAssignment -SignInName $currentUser -RoleDefinitionName Owner -Scope "/subscriptions/$($Subscription.Id)"
if (-not $userRole) {
    $message = "Insufficient permissions for Policy assignment."
    ThrowTerminatingError -ErrorId "UserUnAuthorizedForPolicyAssignment" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message
}

#
# Create the Resource group if not exist
#
$newResourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -ErrorAction SilentlyContinue
If (-not $NewResourceGroup) {
    Write-Output "Creating resource group: $($ResourceGroupName)"
    $newResourceGroup = New-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation
}
else {
    #
    # We are intentionally not trying to re-use an existing resource group. 
    # For e.g. if it exists, but not in the location that was requested, could set us in an inconsistent state.
    #
    $message = "ResourceGroup: $($ResourceGroupName) already exists. Please use a new resource group."
    ThrowTerminatingError -ErrorId "ResourceGroupAlreadyExists" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message
}

#
# Deploy Workspace and solutions
#
try {
    Write-Output "Phase 1 Deployment start: Create resources"

    #
    # Check for workspace and automation account name to be unique in the subscription across resoure groups
    # Duplicate names can cause "bad request" error.
    #

    #
    # Check if the automation account already exists
    #
    $existingAutomationAccount = Get-AzResource -Name $AutomationAccountName -ResourceType "Microsoft.Automation/automationAccounts" -ErrorAction SilentlyContinue
    if ($existingAutomationAccount) {
        $message = "Automation account: $AutomationAccountName already exists in this subscription. Please use a unique name."
        ThrowTerminatingError -ErrorId "AutomationAccountAlreadyExists" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # Check if the workspace already exists
    #
    $workspace = Get-AzResource -Name $WorkspaceName -ResourceType "Microsoft.OperationalInsights/workspaces" -ErrorAction SilentlyContinue
    if ($workspace) {
        $message = "Workspace: $WorkspaceName already exists. Please use a unique name."
        ThrowTerminatingError -ErrorId "WorkspaceAlreadyExists" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # Start deployment, provisioning Automation Account and Workspace
    #
    try {
        New-AzResourceGroupDeployment -Name "WorkSpaceAndAutomationAccountProvisioning" -ResourceGroupName $ResourceGroupName -TemplateFile $workspaceAutomationTemplateFile -workspaceName $WorkspaceName -workspaceLocation $WorkspaceLocation -automationName $AutomationAccountName -automationLocation $AutomationAccountLocation -ErrorAction Stop
    }
    catch {
        $message = "Automation Account and Workspace, provisioning failed. Details below... `n $_"
        ThrowTerminatingError -ErrorId "AutomationAccountAndWorkspaceProvisioningFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # If we are here Automoation account and Workspace have been provisioned.
    #

    #
    # Enable solutions on the workspace
    #
    Write-Output "Phase 1 Deployment start: Enable solutions on the workspace"		
    try {
        New-AzResourceGroupDeployment -Name "EnableSolutions" -ResourceGroupName $ResourceGroupName -TemplateFile $workspaceSolutionsTemplateFile -workspaceName $WorkspaceName -WorkspaceLocation $WorkspaceLocation -Mode Incremental -ErrorAction Stop
    }
    catch {
        $message = "Solution provisioning on workspace failed. Detailed below... `n $_"
        ThrowTerminatingError -ErrorId "SolutionProvisioningFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }
    
    If ($AutoEnroll -eq $false) {
        #
        # Check if default change tracking saved search group already exists
        # If not, add the default change tracking saved search and scope config
        #
        $savedSearch = Get-AzOperationalInsightsSavedSearch -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName

        $createSavedSearch = $true
        foreach ($s in $savedSearch.Value) {
            if ($s.Id.Contains("changetracking|microsoftdefaultcomputergroup")) {
                Write-output "Default saved search group already exists: $($s.Id)"
                $createSavedSearch = $false
                break
            }
        }

        if ($createSavedSearch) {
            Write-Output "Phase 1 Deployment start: Add scope config to the workspace"		

            try {
                New-AzResourceGroupDeployment -Name "AddScopeConfig" -ResourceGroupName $ResourceGroupName -TemplateFile $scopeConfigTemplateFile -workspaceName $WorkspaceName -WorkspaceLocation $Workspacelocation -Mode Incremental -ErrorAction Stop
            }
            catch {
                $message = "ScopeConfig provisioning on ResouceGroup failed. Detailed below... `n $_"
                ThrowTerminatingError -ErrorId "ScopeConfigProvisioningFailed" `
                    -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
                    -Exception $message 
            }
        }
    }

    #
    # Check if the workspace already exists
    #
    $workspace = Get-AzResource -Name $WorkspaceName -ResourceType "Microsoft.OperationalInsights/workspaces" -ErrorAction SilentlyContinue
    if (-not $workspace) {
        $message = "Workspace: $($WorkspaceName) does not exists. Please check."
        ThrowTerminatingError -ErrorId "WorkspaceNotFound" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }

    #
    # Phase 2 deployment
    #
    Write-Output "Phase 2 Deployment start: Configure Change tracking"		

    try {
        New-AzResourceGroupDeployment -Name "ConfigureChangeTracking" -ResourceGroupName $ResourceGroupName -TemplateFile $changeTrackingTemplateFile -workspaceName $WorkspaceName -WorkspaceLocation $Workspacelocation -Mode Incremental -ErrorAction Stop
    }
    catch {
        $message = "ChangeTracking provisioning on ResouceGroup failed. Detailed below... `n $_"
        ThrowTerminatingError -ErrorId "ChangeTrackingProvisioningFailed" `
            -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
            -Exception $message
    }
}
catch {
    $message = "Deployment failed.`n $_"
    ThrowTerminatingError -ErrorId "DeploymentFailed" `
        -ErrorCategory ([System.Management.Automation.ErrorCategory]::InvalidOperation) `
        -Exception $message
}


##################################################################################################################
#  Step 2 - Enable Update Management Agent on Azure VM's
####

Write-Host "Step 2 - Enable Update Management Agent on Azure VM's"

$workspace = (Get-AzOperationalInsightsWorkspace).Where( { $_.Name -eq $workspaceName })

if ($workspace.Name -ne $workspaceName) {
    Write-Error "Unable to find OMS Workspace $workspaceName. "
}

$workspaceId = $workspace.CustomerId
$workspaceKey = (Get-AzOperationalInsightsWorkspaceSharedKeys -ResourceGroupName $workspace.ResourceGroupName -Name $workspace.Name).PrimarySharedKey

$WindowsVMs = Get-AzVM | where-object { $_.StorageProfile.OSDisk.OSType -eq "Windows" } | Sort-Object Name | ForEach-Object { $_.Name } | Out-String -Stream | Select-Object
$LinuxVMs = Get-AzVM | where-object { $_.StorageProfile.OSDisk.OSType -eq "Linux" } | Sort-Object Name | ForEach-Object { $_.Name } | Out-String -Stream | Select-Object


foreach ($WindowsVM in $WindowsVMs) {

    $VMResourceGroup = get-azvm -name $WindowsVM | Select-Object -ExpandProperty ResourceGroupName
    $VMLocation = Get-AzVM -Name $WindowsVM | Select-Object -ExpandProperty Location

    Set-AzVMExtension -ResourceGroupName $VMresourcegroup -VMName $WindowsVM -Name 'MicrosoftMonitoringAgent' -Publisher 'Microsoft.EnterpriseCloud.Monitoring' -ExtensionType 'MicrosoftMonitoringAgent' -TypeHandlerVersion '1.0' -Location $VMLocation -SettingString "{'workspaceId':  '$workspaceId'}" -ProtectedSettingString "{'workspaceKey': '$workspaceKey' }"

}

foreach ($LinuxVM in $LinuxVMs) {

    $VMResourceGroup = get-azvm -name $LinuxVM | Select-Object -ExpandProperty ResourceGroupName
    $VMLocation = Get-AzVM -Name $LinuxVM | Select-Object -ExpandProperty Location

    Set-AzVMExtension -ResourceGroupName $VMresourcegroup -VMName $LinuxVM -Name 'OmsAgentForLinux' -Publisher 'Microsoft.EnterpriseCloud.Monitoring' -ExtensionType 'OmsAgentForLinux' -TypeHandlerVersion '1.0' -Location $VMlocation -SettingString "{'workspaceId':  '$workspaceId'}" -ProtectedSettingString "{'workspaceKey': '$workspaceKey' }"

}

Write-Host "All Azure VMs have been enabled for Update Management.  Please don't forget to tip your serves.  Have a nice day!"


##################################################################################################################
#  Step 3 - Deploy Azure Policy for automating the enablement of Update Management on new Azure VMs..... (Coming Soon!)
####

Write-Host "Step 3 -  Deploy Azure Policy for Update Management Agent! (Sorry, this is coming soon!!!!!)  Until Next week!"


##################################################################################################################
#  Step 4 - Automation of Azure VM Resource Tagging.
####

Write-Host " Step 4 - Automation of Azure VM Resource Tagging."


#  Discovery of all Azure VM's in the current subscription.
$azurevms = Get-AzVM | Select-Object -ExpandProperty Name
Write-Host "Discovering Azure VM's in the following subscription $SubscriptionID  Please hold...."

Write-Host "The following VM's have been discovered in subscription $SubscriptionID"
$azurevms


foreach ($azurevm in $azurevms) {
    
    Write-Host Checking for tag "$vmtagname" on "$azurevm"
    $tagrg = get-azvm -name $azurevm | Select-Object -ExpandProperty ResourceGroupName
    
    $tags = (Get-AzResource -ResourceGroupName $tagrg `
            -Name $azurevm).Tags

    Write-Host "Creating Tag $vmtagname and Value $tagvalue for $azurevm"
    $tags.Add($vmtagname, $tagvalue)
  
    Set-AzResource -ResourceGroupName $tagrg `
        -ResourceName $azurevm `
        -ResourceType Microsoft.Compute/virtualMachines `
        -Tag $tags `
        -Force
}


#  Section use to work.  Commented out to discover why.
# foreach ($azurevm in $azurevms) {
    
#     Write-Host Checking for tag "$vmtagname" on "$azurevm"
#     $tagrg = get-azvm -name $azurevm | Select-Object -ExpandProperty ResourceGroupName
    
#     $tags = (Get-AzResource -ResourceGroupName $tagrg `
#                         -Name $azurevm).Tags

# If ($tags.UpdateWindow){
# Write-Host "$azurevm already has the tag $vmtagname."
# }
# else
# {
# Write-Host "Creating Tag $vmtagname and Value $tagvalue for $azurevm"
# $tags.Add($vmtagname,$tagvalue)
  
#     Set-AzResource -ResourceGroupName $tagrg `
#                -ResourceName $azurevm `
#                -ResourceType Microsoft.Compute/virtualMachines `
#                -Tag $tags `
#                -Force
#    }
   
# }

Write-Host "All tagging is done (and hopfully it worked).  Please exit the ride to your left.  Have a nice day!"


##################################################################################################################
#  Step 5 - Deploy Azure Policy for Tagging (Coming Soon!)
####

Write-Host "Step 5 -  Deploy Azure Policy for Tagging! (Sorry, this is coming soon!!!!!)  Please Play again!"


##################################################################################################################
#  Step 6 - Configure AZ Windows VM's Advance Setttings for Update Management.
####

Write-Host "Step 6 - Configure AZ Windows VM's Advance Setttings for Update Management."

#  Discovery of all Azure VM's in the current subscription.
$azurevms = Get-AzVM | where-object { $_.StorageProfile.OSDisk.OSType -eq "Windows" } | Sort-Object Name | ForEach-Object { $_.Name } | Out-String -Stream | Select-Object
Write-Host "Discovering Azure VM's.  Please hold...."

foreach ($azurevm in $azurevms) {
    
    #  This will configure VM's to pre-download updates.
    $WUSettings = (New-Object -com "Microsoft.Update.AutoUpdate").Settings
    $WUSettings.NotificationLevel = 3
    $WUSettings.Save()

    #  This will disable the automation installation of updates on VM's.
    $AutoUpdatePath = "HKLM:SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
    Set-ItemProperty -Path $AutoUpdatePath -Name NoAutoUpdate -Value 1

    #  This will enable updates for other Microsoft products.
    $ServiceManager = (New-Object -com "Microsoft.Update.ServiceManager")
    $ServiceManager.Services
    $ServiceID = "7971f918-a847-4430-9279-4a52d1efe18d"
    $ServiceManager.AddService2($ServiceId, 7, "")

}

Write-Host "All settigns have been deployed. (and hopfully it worked).  Please exit the ride to your left.  Have a nice day!"


##################################################################################################################
#  Step 7 - Deploy Azure Policy for WAU Client Configurations..... (Coming Soon!)
####

Write-Host "Step 7 -  Deploy Azure Policy for to configure WAU on Azure VM! (Sorry, this is coming soon!!!!!)  Not again!!"


##################################################################################################################
#  Step 8 - Create the Deployment Schedules
#####

Write-Host "Step 8 - Create the Deployment Schedules"


#  Build the array
#  This will use the array.csv file.

$ScheduleConfig = Get-Content -Path .\array.csv | ConvertFrom-Csv

#  Schedule Deployments Start Here

$scope = "/subscriptions/$((Get-AzContext).subscription.id)"
$QueryScope = @($scope)

$WindowsSchedules = $ScheduleConfig | Where-Object { $_.OS -eq "Windows" }
$LinuxSchedules = $ScheduleConfig | Where-Object { $_.OS -eq "Linux" }

foreach ($WindowsSchedule in $WindowsSchedules) {

    $tag = @{$WindowsSchedule.TagName = $WindowsSchedule.TagValue }
    $azq = New-AzAutomationUpdateManagementAzureQuery -ResourceGroupName $ResourceGroupName `
        -AutomationAccountName $AutomationAccountName `
        -Scope $QueryScope `
        -Tag $tag

    $AzureQueries = @($azq)

    $date = ((get-date).AddDays(1)).ToString("yyyy-MM-dd")
    $time = $WindowsSchedule.Starttime
    $datetime = $date + "t" + $time

    $startTime = [DateTimeOffset]"$datetime"
    $duration = New-TimeSpan -Hours 2

    $schedule = New-AzAutomationSchedule -ResourceGroupName $ResourceGroupName `
        -AutomationAccountName $AutomationAccountName `
        -Name $WindowsSchedule.ScheduleName `
        -StartTime $StartTime `
        -DayofWeek $WindowsSchedule.DayofWeek `
        -DayofWeekOccurrence $WindowsSchedule.DaysofWeekOccurrence `
        -MonthInterval 1 `
        -ForUpdateConfiguration

    New-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $ResourceGroupName `
        -AutomationAccountName $AutomationAccountName `
        -Schedule $schedule `
        -Windows `
        -Azurequery $AzureQueries `
        -IncludedUpdateClassification Critical, Security, Updates, UpdateRollup, Definition `
        -Duration $duration `
        -RebootSetting $WindowsSchedule.Reboot
}

foreach ($LinuxSchedule in $LinuxSchedules) {

    $tag = @{$LinuxSchedule.TagName = $LinuxSchedule.TagValue }
    $azq = New-AzAutomationUpdateManagementAzureQuery -ResourceGroupName $ResourceGroupName `
        -AutomationAccountName $AutomationAccountName `
        -Scope $QueryScope `
        -Tag $tag

    $AzureQueries = @($azq)

    $date = ((get-date).AddDays(1)).ToString("yyyy-MM-dd")
    $time = $LinuxSchedule.Starttime
    $datetime = $date + "t" + $time

    $startTime = [DateTimeOffset]"$datetime"
    $duration = New-TimeSpan -Hours 2
    $schedule = New-AzAutomationSchedule -ResourceGroupName $ResourceGroupName `
        -AutomationAccountName $AutomationAccountName `
        -Name $LinuxSchedule.ScheduleName `
        -StartTime $StartTime `
        -DayofWeek $LinuxSchedule.DayofWeek `
        -DayofWeekOccurrence $LinuxSchedule.DaysofWeekOccurrence `
        -MonthInterval 1 `
        -ForUpdateConfiguration

    New-AzAutomationSoftwareUpdateConfiguration -ResourceGroupName $ResourceGroupName `
        -AutomationAccountName $AutomationAccountName `
        -Schedule $schedule `
        -Linux `
        -Azurequery $AzureQueries `
        -IncludedPackageClassification Critical, Security `
        -Duration $duration `
        -RebootSetting $LinuxSchedule.Reboot
}

Write-Host "Now that took forever but the deployment schedules are done!"

##################################################################################################################
# All Done.
#####

Write-host "That is all folks!"

IX. Everything Else Including Azure Monitor Workbooks

As part of the solution I started to work with Azure Monitor Workbooks to provide a single view of all the VM’s across all subscriptions. I took a workbook that Billy York created and blogged about and then edited it for my use. His blog write up called Azure Automation Update Management Workbook was very good.

All these scripts including the Azure Monitor workbook template will be available on my Github repository located at https://github.com/kristopherjturner/Update-Management-Automation

I will try to improve what I have created so far. There is a long list of things I would like to add to this solution and improve. Here are just a few things coming in future releases:

  • Automation of Azure Monitor Alerts based off the Deployment Schedules
  • Changing it to parameters and not just variables.
  • Creating functions so only one section can be ran if needed.
  • Teams Integration for notifications on the Deployment Schedules.

Update Management Issues

Lastly, I would like to talk about some of my issues that I came across while deploying Azure Update Management.

  • StartTime parameter within the cmdlet New-AzAutomationSchedule issues
  • Deployment Schedules that have no VM’s associated always fail because of no VM’s to deploy to.
  • The lack of zero day update deployment.
  • There is no On-Demand scanning and patching.
  • No Cluster aware patching
  • No support for VMSS
  • No support for Windows 10
  • Wish there was a way to control what updates were allowed like in WSUS or other updates tools.

Final Thoughts

What was once a work related task has turned into a personal task now for me. I plan to improve this solution as time permits. Add a lot more intelligence into the scripts, etc. For now, the solution does what I needed it to do. From here on out it is more for personal growth and curiosity.

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s